Refactor entire process of adding transactions.
Build and Deploy Web App / build-and-deploy (push) Successful in 17s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m16s Details

This commit is contained in:
Andrew Lalis 2025-08-11 13:18:23 -04:00
parent 277a6dc591
commit 0b67bb605b
21 changed files with 683 additions and 234 deletions

View File

@ -1,7 +1,10 @@
{
"fileVersion": 1,
"versions": {
"asdf": "0.7.17",
"asdf": {
"repository": "git+https://github.com/libmir/asdf.git",
"version": "7f77a3031975816b604a513ddeefbc9e514f236c"
},
"d2sqlite3": "1.0.0",
"dxml": "0.4.4",
"handy-http-data": "1.3.0",

View File

@ -10,6 +10,7 @@ import std.datetime : SysTime;
interface AccountRepository {
Optional!Account findById(ulong id);
bool existsById(ulong id);
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description);
void setArchived(ulong id, bool archived);
Account update(ulong id, in Account newData);

View File

@ -21,6 +21,10 @@ class SqliteAccountRepository : AccountRepository {
return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id);
}
bool existsById(ulong id) {
return util.sqlite.exists(db, "SELECT id FROM account WHERE id = ?", id);
}
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
util.sqlite.update(
db,

View File

@ -145,7 +145,7 @@ class SqliteProfileDataSource : ProfileDataSource {
const SCHEMA = import("sql/schema.sql");
private const string dbPath;
private Database db;
Database db;
this(string path) {
this.dbPath = path;

View File

@ -9,6 +9,7 @@ import std.typecons;
import transaction.model;
import transaction.data;
import transaction.service;
import transaction.dto;
import profile.data;
import profile.service;
import account.api;
@ -20,47 +21,6 @@ import util.data;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
struct TransactionsListItemAccount {
ulong id;
string name;
string type;
string numberSuffix;
}
struct TransactionsListItemVendor {
ulong id;
string name;
}
struct TransactionsListItemCategory {
ulong id;
string name;
string color;
}
/// The transaction data provided when a list of transactions is requested.
struct TransactionsListItem {
import asdf : serdeTransformOut;
ulong id;
string timestamp;
string addedAt;
ulong amount;
Currency currency;
string description;
@serdeTransformOut!serializeOptional
Optional!TransactionsListItemVendor vendor;
@serdeTransformOut!serializeOptional
Optional!TransactionsListItemCategory category;
@serdeTransformOut!serializeOptional
Optional!TransactionsListItemAccount creditedAccount;
@serdeTransformOut!serializeOptional
Optional!TransactionsListItemAccount debitedAccount;
string[] tags;
}
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
@ -69,33 +29,17 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
}
void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
// TODO
}
struct AddTransactionPayloadLineItem {
long valuePerItem;
ulong quantity;
string description;
Nullable!ulong categoryId;
}
struct AddTransactionPayload {
string timestamp;
ulong amount;
string currencyCode;
string description;
Nullable!ulong vendorId;
Nullable!ulong categoryId;
Nullable!ulong creditedAccountId;
Nullable!ulong debitedAccountId;
string[] tags;
AddTransactionPayloadLineItem[] lineItems;
ProfileDataSource ds = getProfileDataSource(request);
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
import asdf : serializeToJson;
string jsonStr = serializeToJson(txn);
response.writeBodyString(jsonStr, "application/json");
}
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
auto payload = readJsonBodyAs!AddTransactionPayload(request);
addTransaction2(ds, payload);
addTransaction(ds, payload);
}
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {

View File

@ -4,7 +4,7 @@ import handy_http_primitives : Optional;
import std.datetime;
import transaction.model;
import transaction.api : TransactionsListItem;
import transaction.dto;
import util.money;
import util.pagination;
@ -12,6 +12,7 @@ interface TransactionVendorRepository {
Optional!TransactionVendor findById(ulong id);
TransactionVendor[] findAll();
bool existsByName(string name);
bool existsById(ulong id);
TransactionVendor insert(string name, string description);
void deleteById(ulong id);
TransactionVendor updateById(ulong id, string name, string description);
@ -19,6 +20,7 @@ interface TransactionVendorRepository {
interface TransactionCategoryRepository {
Optional!TransactionCategory findById(ulong id);
bool existsById(ulong id);
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
void deleteById(ulong id);
@ -27,21 +29,13 @@ interface TransactionCategoryRepository {
interface TransactionTagRepository {
string[] findAllByTransactionId(ulong transactionId);
void updateTags(ulong transactionId, string[] tags);
void updateTags(ulong transactionId, in string[] tags);
string[] findAll();
}
interface TransactionRepository {
Page!TransactionsListItem findAll(PageRequest pr);
Optional!Transaction findById(ulong id);
Transaction insert(
SysTime timestamp,
SysTime addedAt,
ulong amount,
Currency currency,
string description,
Optional!ulong vendorId,
Optional!ulong categoryId
);
Optional!TransactionDetail findById(ulong id);
TransactionDetail insert(in AddTransactionPayload data);
void deleteById(ulong id);
}

View File

@ -7,7 +7,7 @@ import d2sqlite3;
import transaction.model;
import transaction.data;
import transaction.api;
import transaction.dto;
import util.sqlite;
import util.money;
import util.pagination;
@ -35,6 +35,10 @@ class SqliteTransactionVendorRepository : TransactionVendorRepository {
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
}
bool existsById(ulong id) {
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE id = ?", id);
}
TransactionVendor insert(string name, string description) {
util.sqlite.update(
db,
@ -77,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
}
bool existsById(ulong id) {
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
}
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
if (parentId) {
return util.sqlite.findAll(
@ -147,7 +155,7 @@ class SqliteTransactionTagRepository : TransactionTagRepository {
);
}
void updateTags(ulong transactionId, string[] tags) {
void updateTags(ulong transactionId, in string[] tags) {
util.sqlite.update(
db,
"DELETE FROM transaction_tag WHERE transaction_id = ?",
@ -200,15 +208,15 @@ class SqliteTransactionRepository : TransactionRepository {
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
if (!vendorId.isNull) {
string vendorName = row.peek!string(7);
item.vendor = Optional!TransactionsListItemVendor.of(
TransactionsListItemVendor(vendorId.get, vendorName));
item.vendor = Optional!(TransactionsListItem.Vendor).of(
TransactionsListItem.Vendor(vendorId.get, vendorName));
}
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
if (!categoryId.isNull) {
string categoryName = row.peek!string(9);
string categoryColor = row.peek!string(10);
item.category = Optional!TransactionsListItemCategory.of(
TransactionsListItemCategory(categoryId.get, categoryName, categoryColor));
item.category = Optional!(TransactionsListItem.Category).of(
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
if (!creditedAccountId.isNull) {
@ -216,8 +224,8 @@ class SqliteTransactionRepository : TransactionRepository {
string name = row.peek!string(12);
string type = row.peek!string(13);
string suffix = row.peek!string(14);
item.creditedAccount = Optional!TransactionsListItemAccount.of(
TransactionsListItemAccount(id, name, type, suffix));
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
if (!debitedAccountId.isNull) {
@ -225,13 +233,15 @@ class SqliteTransactionRepository : TransactionRepository {
string name = row.peek!string(16);
string type = row.peek!string(17);
string suffix = row.peek!string(18);
item.debitedAccount = Optional!TransactionsListItemAccount.of(
TransactionsListItemAccount(id, name, type, suffix));
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
string tagsStr = row.peek!string(19);
if (tagsStr.length > 0) {
if (tagsStr !is null && tagsStr.length > 0) {
import std.string : split;
item.tags = tagsStr.split(",");
} else {
item.tags = [];
}
return item;
});
@ -239,34 +249,125 @@ class SqliteTransactionRepository : TransactionRepository {
return Page!(TransactionsListItem).of(results, pr, totalCount);
}
Optional!Transaction findById(ulong id) {
return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id);
Optional!TransactionDetail findById(ulong id) {
Optional!TransactionDetail item = util.sqlite.findOne(
db,
import("sql/get_transaction.sql"),
(row) {
TransactionDetail item;
item.id = row.peek!ulong(0);
item.timestamp = row.peek!string(1);
item.addedAt = row.peek!string(2);
item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5);
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
if (!vendorId.isNull) {
item.vendor = Optional!(TransactionDetail.Vendor).of(
TransactionDetail.Vendor(
vendorId.get,
row.peek!string(7),
row.peek!string(8)
)).toNullable;
}
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
if (!categoryId.isNull) {
item.category = Optional!(TransactionDetail.Category).of(
TransactionDetail.Category(
categoryId.get,
row.peek!(Nullable!ulong)(10),
row.peek!string(11),
row.peek!string(12),
row.peek!string(13)
)).toNullable;
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(14);
if (!creditedAccountId.isNull) {
item.creditedAccount = Optional!(TransactionDetail.Account).of(
TransactionDetail.Account(
creditedAccountId.get,
row.peek!string(15),
row.peek!string(16),
row.peek!string(17)
)).toNullable;
}
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(18);
if (!debitedAccountId.isNull) {
item.debitedAccount = Optional!(TransactionDetail.Account).of(
TransactionDetail.Account(
debitedAccountId.get,
row.peek!string(19),
row.peek!string(20),
row.peek!string(21)
)).toNullable;
}
string tagsStr = row.peek!string(22);
if (tagsStr !is null && tagsStr.length > 0) {
import std.string : split;
item.tags = tagsStr.split(",");
} else {
item.tags = [];
}
return item;
},
id
);
if (item.isNull) return item;
item.value.lineItems = util.sqlite.findAll(
db,
import("sql/get_line_items.sql"),
(row) {
TransactionDetail.LineItem li;
li.idx = row.peek!uint(0);
li.valuePerItem = row.peek!long(1);
li.quantity = row.peek!ulong(2);
li.description = row.peek!string(3);
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4);
if (!categoryId.isNull) {
li.category = Optional!(TransactionDetail.Category).of(
TransactionDetail.Category(
categoryId.get,
row.peek!(Nullable!ulong)(5),
row.peek!string(6),
row.peek!string(7),
row.peek!string(8)
)).toNullable;
}
return li;
},
id
);
return item;
}
Transaction insert(
SysTime timestamp,
SysTime addedAt,
ulong amount,
Currency currency,
string description,
Optional!ulong vendorId,
Optional!ulong categoryId
) {
TransactionDetail insert(in AddTransactionPayload data) {
util.sqlite.update(
db,
"INSERT INTO " ~ TABLE_NAME ~ "
(timestamp, added_at, amount, currency, description, vendor_id, category_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
timestamp.toISOExtString(),
addedAt.toISOExtString(),
amount,
currency.code,
description,
toNullable(vendorId),
toNullable(categoryId)
import("sql/insert_transaction.sql"),
data.timestamp,
Clock.currTime(UTC()),
data.amount,
data.currencyCode,
data.description,
data.vendorId,
data.categoryId
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
ulong transactionId = db.lastInsertRowid();
// Insert line items:
foreach (size_t idx, lineItem; data.lineItems) {
util.sqlite.update(
db,
import("sql/insert_line_item.sql"),
transactionId,
idx,
lineItem.valuePerItem,
lineItem.quantity,
lineItem.description,
lineItem.categoryId
);
}
return findById(transactionId).orElseThrow();
}
void deleteById(ulong id) {

View File

@ -0,0 +1,111 @@
module transaction.dto;
import handy_http_primitives : Optional;
import asdf : serdeTransformOut;
import std.typecons;
import util.data;
import util.money;
/// The transaction data provided when a list of transactions is requested.
struct TransactionsListItem {
ulong id;
string timestamp;
string addedAt;
ulong amount;
Currency currency;
string description;
@serdeTransformOut!serializeOptional
Optional!Vendor vendor;
@serdeTransformOut!serializeOptional
Optional!Category category;
@serdeTransformOut!serializeOptional
Optional!Account creditedAccount;
@serdeTransformOut!serializeOptional
Optional!Account debitedAccount;
string[] tags;
static struct Account {
ulong id;
string name;
string type;
string numberSuffix;
}
static struct Vendor {
ulong id;
string name;
}
static struct Category {
ulong id;
string name;
string color;
}
}
/// Transaction data provided when fetching a single transaction.
struct TransactionDetail {
ulong id;
string timestamp;
string addedAt;
ulong amount;
Currency currency;
string description;
Nullable!Vendor vendor;
Nullable!Category category;
Nullable!Account creditedAccount;
Nullable!Account debitedAccount;
string[] tags;
LineItem[] lineItems;
static struct Vendor {
ulong id;
string name;
string description;
}
static struct Category {
ulong id;
Nullable!ulong parentId;
string name;
string description;
string color;
}
static struct LineItem {
uint idx;
long valuePerItem;
ulong quantity;
string description;
Nullable!Category category;
}
static struct Account {
ulong id;
string name;
string type;
string numberSuffix;
}
}
/// Data provided when a new transaction is added by a user.
struct AddTransactionPayload {
string timestamp;
ulong amount;
string currencyCode;
string description;
Nullable!ulong vendorId;
Nullable!ulong categoryId;
Nullable!ulong creditedAccountId;
Nullable!ulong debitedAccountId;
string[] tags;
LineItem[] lineItems;
static struct LineItem {
long valuePerItem;
ulong quantity;
string description;
Nullable!ulong categoryId;
}
}

View File

@ -33,11 +33,10 @@ struct Transaction {
}
struct TransactionLineItem {
immutable ulong id;
immutable ulong transactionId;
immutable uint idx;
immutable long valuePerItem;
immutable ulong quantity;
immutable uint idx;
immutable string description;
immutable Optional!ulong categoryId;
}

View File

@ -6,8 +6,10 @@ import std.datetime;
import transaction.api;
import transaction.model;
import transaction.data;
import transaction.dto;
import profile.data;
import account.model;
import account.data;
import util.money;
import util.pagination;
@ -16,64 +18,109 @@ import util.pagination;
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
Page!TransactionsListItem page = ds.getTransactionRepository()
.findAll(pageRequest);
return page; // Return an empty page for now!
return page;
}
void addTransaction2(ProfileDataSource ds, in AddTransactionPayload payload) {
// TODO
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
return ds.getTransactionRepository().findById(transactionId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}
void addTransaction(
ProfileDataSource ds,
SysTime timestamp,
SysTime addedAt,
ulong amount,
Currency currency,
string description,
Optional!ulong vendorId,
Optional!ulong categoryId,
Optional!ulong creditedAccountId,
Optional!ulong debitedAccountId,
TransactionLineItem[] lineItems,
string[] tags
) {
if (creditedAccountId.isNull && debitedAccountId.isNull) {
throw new Exception("At least one account must be linked to a transaction.");
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
AccountRepository accountRepo = ds.getAccountRepository();
// Validate transaction details:
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
}
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
auto txRepo = ds.getTransactionRepository();
Transaction tx = txRepo.insert(
timestamp,
addedAt,
amount,
currency,
description,
vendorId,
categoryId
);
if (creditedAccountId) {
journalEntryRepo.insert(
timestamp,
creditedAccountId.value,
tx.id,
amount,
AccountJournalEntryType.CREDIT,
currency
);
if (
!payload.creditedAccountId.isNull &&
!payload.debitedAccountId.isNull &&
payload.creditedAccountId.get == payload.debitedAccountId.get
) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
}
if (debitedAccountId) {
journalEntryRepo.insert(
timestamp,
debitedAccountId.value,
tx.id,
amount,
AccountJournalEntryType.DEBIT,
currency
);
if (payload.amount == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
}
if (tags.length > 0) {
ds.getTransactionTagRepository().updateTags(tx.id, tags);
SysTime now = Clock.currTime(UTC());
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
if (timestamp > now) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
}
if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
}
if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
}
if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
}
if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
}
foreach (tag; payload.tags) {
import std.regex;
auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`);
if (!matchFirst(tag, r)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\".");
}
}
if (payload.lineItems.length > 0) {
long lineItemsTotal = 0;
foreach (lineItem; payload.lineItems) {
if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
}
if (lineItem.quantity == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
}
for (ulong i = 0; i < lineItem.quantity; i++) {
lineItemsTotal += lineItem.valuePerItem;
}
}
if (lineItemsTotal != payload.amount) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Total of all line items doesn't equal the transaction's total."
);
}
}
// Add the transaction:
ulong txnId;
ds.doTransaction(() {
TransactionRepository txRepo = ds.getTransactionRepository();
TransactionDetail txn = txRepo.insert(payload);
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
if (!payload.creditedAccountId.isNull) {
jeRepo.insert(
timestamp,
payload.creditedAccountId.get,
txn.id,
txn.amount,
AccountJournalEntryType.CREDIT,
txn.currency
);
}
if (!payload.debitedAccountId.isNull) {
jeRepo.insert(
timestamp,
payload.debitedAccountId.get,
txn.id,
txn.amount,
AccountJournalEntryType.DEBIT,
txn.currency
);
}
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
tagRepo.updateTags(txn.id, payload.tags);
txnId = txn.id;
});
return ds.getTransactionRepository().findById(txnId).orElseThrow();
}
// Vendors Services

View File

@ -7,12 +7,15 @@ import auth;
import profile;
import account;
import transaction;
import transaction.dto;
import util.money;
import util.data;
import std.random;
import std.conv;
import std.array;
import std.datetime;
import std.typecons;
void generateSampleData() {
UserRepository userRepo = new FileSystemUserRepository;
@ -51,31 +54,30 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
infoF!" Generating random profile %s."(profileName);
Profile profile = profileRepo.createProfile(profileName);
ProfileDataSource ds = profileRepo.getDataSource(profile);
ds.doTransaction(() {
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
Currency preferredCurrency = choice(ALL_CURRENCIES);
const int accountCount = uniform(3, 10);
for (int i = 0; i < accountCount; i++) {
generateRandomAccount(i, ds, preferredCurrency);
}
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
Currency preferredCurrency = choice(ALL_CURRENCIES);
auto vendorRepo = ds.getTransactionVendorRepository();
const int vendorCount = uniform(5, 30);
for (int i = 0; i < vendorCount; i++) {
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
}
infoF!" Generated %d random vendors."(vendorCount);
const int accountCount = uniform(3, 10);
for (int i = 0; i < accountCount; i++) {
generateRandomAccount(i, ds, preferredCurrency);
}
auto categoryRepo = ds.getTransactionCategoryRepository();
const int categoryCount = uniform(5, 30);
for (int i = 0; i < categoryCount; i++) {
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
}
infoF!" Generated %d random categories."(categoryCount);
auto vendorRepo = ds.getTransactionVendorRepository();
const int vendorCount = uniform(5, 30);
for (int i = 0; i < vendorCount; i++) {
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
}
infoF!" Generated %d random vendors."(vendorCount);
generateRandomTransactions(ds);
});
auto categoryRepo = ds.getTransactionCategoryRepository();
const int categoryCount = uniform(5, 30);
for (int i = 0; i < categoryCount; i++) {
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
}
infoF!" Generated %d random categories."(categoryCount);
generateRandomTransactions(ds);
}
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
@ -105,24 +107,21 @@ void generateRandomTransactions(ProfileDataSource ds) {
.findAllByParentId(Optional!ulong.empty);
const Account[] accounts = ds.getAccountRepository().findAll();
SysTime now = Clock.currTime(UTC());
SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
for (int i = 0; i < 100; i++) {
Optional!ulong vendorId;
AddTransactionPayload data;
data.timestamp = timestamp.toISOExtString();
if (uniform01() < 0.7) {
vendorId = Optional!ulong.of(choice(vendors).id);
data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable;
}
Optional!ulong categoryId;
if (uniform01() < 0.8) {
categoryId = Optional!ulong.of(choice(categories).id);
data.categoryId = Optional!ulong.of(choice(categories).id).toNullable;
}
// Randomly choose an account to credit / debit the transaction to.
Optional!ulong creditedAccountId;
Optional!ulong debitedAccountId;
Account primaryAccount = choice(accounts);
data.currencyCode = primaryAccount.currency.code;
Optional!ulong secondaryAccountId;
if (uniform01() < 0.25) {
foreach (acc; accounts) {
@ -133,11 +132,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
}
}
if (uniform01() < 0.5) {
creditedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccountId) debitedAccountId = secondaryAccountId;
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
} else {
debitedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccountId) creditedAccountId = secondaryAccountId;
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
}
// Randomly choose some tags to add.
@ -147,24 +146,40 @@ void generateRandomTransactions(ProfileDataSource ds) {
tags ~= "tag-" ~ n.to!string;
}
}
data.tags = tags;
ulong value = uniform(0, 1_000_000);
data.amount = uniform(0, 1_000_000);
data.description = "This is a sample transaction which was generated as part of sample data.";
addTransaction(
ds,
timestamp,
now,
value,
primaryAccount.currency,
"Test transaction " ~ to!string(i),
vendorId,
categoryId,
creditedAccountId,
debitedAccountId,
[],
tags
);
infoF!" Generated transaction %d"(i);
// Generate random line items:
if (uniform01 < 0.5) {
long lineItemTotal = 0;
foreach (n; 1..uniform(1, 20)) {
AddTransactionPayload.LineItem item;
item.valuePerItem = uniform(1, 10_000);
item.quantity = uniform(1, 5);
lineItemTotal += item.quantity * item.valuePerItem;
item.description = "Sample item " ~ n.to!string;
if (uniform01 < 0.5) {
TransactionCategory category = choice(categories);
item.categoryId = category.id;
}
data.lineItems ~= item;
}
long diff = data.amount - lineItemTotal;
// Add one final line item that adds up to the transaction total.
if (diff != 0) {
data.lineItems ~= AddTransactionPayload.LineItem(
diff,
1,
"Last item which reconciles line items total with transaction amount.",
Nullable!ulong.init
);
}
}
auto txn = addTransaction(ds, data);
infoF!" Generated transaction %d"(txn.id);
timestamp -= seconds(uniform(10, 1_000_000));
}
}

View File

@ -0,0 +1,15 @@
SELECT
i.idx,
i.value_per_item,
i.quantity,
i.description,
i.category_id,
category.parent_id,
category.name,
category.description,
category.color
FROM transaction_line_item i
LEFT JOIN transaction_category category
ON category.id = i.category_id
WHERE i.transaction_id = ?
ORDER BY idx;

View File

@ -0,0 +1,46 @@
SELECT
txn.id AS id,
txn.timestamp AS timestamp,
txn.added_at AS added_at,
txn.amount AS amount,
txn.currency AS currency,
txn.description AS description,
txn.vendor_id AS vendor_id,
vendor.name AS vendor_name,
vendor.description AS vendor_description,
txn.category_id AS category_id,
category.parent_id AS category_parent_id,
category.name AS category_name,
category.description AS category_description,
category.color AS category_color,
account_credit.id AS credited_account_id,
account_credit.name AS credited_account_name,
account_credit.type AS credited_account_type,
account_credit.number_suffix AS credited_account_number_suffix,
account_debit.id AS debited_account_id,
account_debit.name AS debited_account_name,
account_debit.type AS debited_account_type,
account_debit.number_suffix AS debited_account_number_suffix,
GROUP_CONCAT(tag) AS tags
FROM
"transaction" txn
LEFT JOIN transaction_vendor vendor
ON vendor.id = txn.vendor_id
LEFT JOIN transaction_category category
ON category.id = txn.category_id
LEFT JOIN account_journal_entry j_credit
ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'
LEFT JOIN account account_credit
ON account_credit.id = j_credit.account_id
LEFT JOIN account_journal_entry j_debit
ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'
LEFT JOIN account account_debit
ON account_debit.id = j_debit.account_id
LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id
WHERE txn.id = ?
GROUP BY txn.id

View File

@ -0,0 +1,8 @@
INSERT INTO transaction_line_item (
transaction_id,
idx,
value_per_item,
quantity,
description,
category_id
) VALUES (?, ?, ?, ?, ?, ?)

View File

@ -0,0 +1,9 @@
INSERT INTO "transaction" (
timestamp,
added_at,
amount,
currency,
description,
vendor_id,
category_id
) VALUES (?, ?, ?, ?, ?, ?, ?)

View File

@ -93,13 +93,13 @@ CREATE TABLE transaction_attachment (
);
CREATE TABLE transaction_line_item (
id INTEGER PRIMARY KEY,
transaction_id INTEGER NOT NULL,
idx INTEGER NOT NULL DEFAULT 0,
value_per_item INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
idx INTEGER NOT NULL DEFAULT 0,
description TEXT NOT NULL,
category_id INTEGER,
CONSTRAINT pk_transaction_line_item PRIMARY KEY (transaction_id, idx),
CONSTRAINT fk_transaction_line_item_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE,

View File

@ -14,6 +14,14 @@ export interface TransactionVendorPayload {
description: string
}
export interface TransactionCategory {
id: number
parentId: number | null
name: string
description: string
color: string
}
export interface Transaction {
id: number
timestamp: string
@ -56,6 +64,36 @@ export interface TransactionsListItemAccount {
numberSuffix: string
}
export interface TransactionDetail {
id: number
timestamp: string
addedAt: string
amount: number
currency: Currency
description: string
vendor: TransactionVendor | null
category: TransactionCategory | null
creditedAccount: TransactionDetailAccount | null
debitedAccount: TransactionDetailAccount | null
tags: string[]
lineItems: TransactionDetailLineItem[]
}
export interface TransactionDetailAccount {
id: number
name: string
type: string
numberSuffix: string
}
export interface TransactionDetailLineItem {
idx: number
valuePerItem: number
quantity: number
description: number
category: TransactionCategory | null
}
export class TransactionApiClient extends ApiClient {
readonly path: string
@ -89,4 +127,8 @@ export class TransactionApiClient extends ApiClient {
): Promise<Page<TransactionsListItem>> {
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
}
async getTransaction(id: number): Promise<TransactionDetail> {
return await super.getJson(this.path + '/transactions/' + id)
}
}

View File

@ -36,7 +36,7 @@ async function fetchProfiles() {
function selectProfile(profile: Profile) {
profileStore.onProfileSelected(profile)
router.push('/')
router.push('/profiles/' + profile.name)
}
async function addProfile() {

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { ApiError } from '@/api/base';
import { formatMoney } from '@/api/data';
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction';
import AppPage from '@/components/AppPage.vue';
import PropertiesTable from '@/components/PropertiesTable.vue';
import { useProfileStore } from '@/stores/profile-store';
import { showAlert } from '@/util/alert';
import { onMounted, ref, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const transaction: Ref<TransactionDetail | undefined> = ref()
onMounted(async () => {
if (!profileStore.state) {
await router.replace('/')
return
}
const transactionId = parseInt(route.params.id as string)
try {
const api = new TransactionApiClient(profileStore.state)
transaction.value = await api.getTransaction(transactionId)
} catch (err) {
console.error(err)
await router.replace('/')
if (err instanceof ApiError) {
await showAlert('Failed to fetch transaction: ' + err.message)
}
}
})
</script>
<template>
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
<PropertiesTable>
<tr>
<th>Timestamp</th>
<td>{{ new Date(transaction.timestamp).toLocaleString() }}</td>
</tr>
<tr>
<th>Added at</th>
<td>{{ new Date(transaction.addedAt).toLocaleString() }}</td>
</tr>
<tr>
<th>Amount</th>
<td>{{ formatMoney(transaction.amount, transaction.currency) }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ transaction.description }}</td>
</tr>
<tr v-if="transaction.vendor">
<th>Vendor</th>
<td>
{{ transaction.vendor.name }}
</td>
</tr>
<tr v-if="transaction.category">
<th>Category</th>
<td>
{{ transaction.category.name }}
</td>
</tr>
<tr v-if="transaction.creditedAccount">
<th>Credited Account</th>
<td>
{{ transaction.creditedAccount.name }}
</td>
</tr>
<tr v-if="transaction.debitedAccount">
<th>Debited Account</th>
<td>
{{ transaction.debitedAccount.name }}
</td>
</tr>
<tr>
<th>Tags</th>
<td>
<span v-for="tag in transaction.tags" :key="tag">{{ tag }},</span>
</td>
</tr>
</PropertiesTable>
<div v-if="transaction.lineItems.length > 0">
<h3>Line Items</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Amount per Item</th>
<th>Quantity</th>
<th>Description</th>
<th>Category</th>
</tr>
</thead>
<tbody>
<tr v-for="i in transaction.lineItems" :key="i.idx">
<td>{{ i.idx + 1 }}</td>
<td>{{ formatMoney(i.valuePerItem, transaction.currency) }}</td>
<td>{{ i.quantity }}</td>
<td>{{ i.description }}</td>
<td>{{ i.category?.name }}</td>
</tr>
</tbody>
</table>
</div>
</AppPage>
</template>

View File

@ -48,6 +48,9 @@ async function fetchPage(pageRequest: PageRequest) {
<td>{{ tx.description }}</td>
<td>{{ tx.creditedAccount?.name }}</td>
<td>{{ tx.debitedAccount?.name }}</td>
<td>
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
</td>
</tr>
</tbody>
</table>

View File

@ -1,42 +1,32 @@
import UserAccountLayout from '@/pages/UserAccountLayout.vue'
import LoginPage from '@/pages/LoginPage.vue'
import ProfilePage from '@/pages/ProfilePage.vue'
import { useAuthStore } from '@/stores/auth-store'
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
import UserHomePage from '@/pages/UserHomePage.vue'
import ProfilesPage from '@/pages/ProfilesPage.vue'
import { useProfileStore } from '@/stores/profile-store'
import AccountPage from '@/pages/AccountPage.vue'
import EditAccountPage from '@/pages/forms/EditAccountPage.vue'
import MyUserPage from '@/pages/MyUserPage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
component: async () => LoginPage,
component: () => import('@/pages/LoginPage.vue'),
meta: { title: 'Login' },
},
{
path: '/',
component: async () => UserAccountLayout,
component: () => import('@/pages/UserAccountLayout.vue'),
beforeEnter: onlyAuthenticated,
children: [
{
path: '',
component: async () => UserHomePage,
meta: { title: 'Home' },
beforeEnter: profileSelected,
redirect: '/profiles',
},
{
path: 'me',
component: async () => MyUserPage,
component: () => import('@/pages/MyUserPage.vue'),
meta: { title: 'My User' },
},
{
path: 'profiles',
component: async () => ProfilesPage,
component: () => import('@/pages/ProfilesPage.vue'),
meta: { title: 'Profiles' },
},
{
@ -45,24 +35,29 @@ const router = createRouter({
children: [
{
path: '',
component: async () => ProfilePage,
component: () => import('@/pages/UserHomePage.vue'),
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
},
{
path: 'accounts/:id',
component: async () => AccountPage,
component: () => import('@/pages/AccountPage.vue'),
meta: { title: 'Account' },
},
{
path: 'accounts/:id/edit',
component: async () => EditAccountPage,
component: () => import('@/pages/forms/EditAccountPage.vue'),
meta: { title: 'Edit Account' },
},
{
path: 'add-account',
component: async () => EditAccountPage,
component: () => import('@/pages/forms/EditAccountPage.vue'),
meta: { title: 'Add Account' },
},
{
path: 'transactions/:id',
component: () => import('@/pages/TransactionPage.vue'),
meta: { title: 'Transaction' },
},
],
},
],