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, "fileVersion": 1,
"versions": { "versions": {
"asdf": "0.7.17", "asdf": {
"repository": "git+https://github.com/libmir/asdf.git",
"version": "7f77a3031975816b604a513ddeefbc9e514f236c"
},
"d2sqlite3": "1.0.0", "d2sqlite3": "1.0.0",
"dxml": "0.4.4", "dxml": "0.4.4",
"handy-http-data": "1.3.0", "handy-http-data": "1.3.0",

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import std.typecons;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
import transaction.service; import transaction.service;
import transaction.dto;
import profile.data; import profile.data;
import profile.service; import profile.service;
import account.api; import account.api;
@ -20,47 +21,6 @@ import util.data;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); 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) { void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); 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) { void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
// TODO ProfileDataSource ds = getProfileDataSource(request);
} TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
import asdf : serializeToJson;
struct AddTransactionPayloadLineItem { string jsonStr = serializeToJson(txn);
long valuePerItem; response.writeBodyString(jsonStr, "application/json");
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;
} }
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
auto payload = readJsonBodyAs!AddTransactionPayload(request); auto payload = readJsonBodyAs!AddTransactionPayload(request);
addTransaction2(ds, payload); addTransaction(ds, payload);
} }
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {

View File

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

View File

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

View File

@ -6,8 +6,10 @@ import std.datetime;
import transaction.api; import transaction.api;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
import transaction.dto;
import profile.data; import profile.data;
import account.model; import account.model;
import account.data;
import util.money; import util.money;
import util.pagination; import util.pagination;
@ -16,64 +18,109 @@ import util.pagination;
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) { Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
Page!TransactionsListItem page = ds.getTransactionRepository() Page!TransactionsListItem page = ds.getTransactionRepository()
.findAll(pageRequest); .findAll(pageRequest);
return page; // Return an empty page for now! return page;
} }
void addTransaction2(ProfileDataSource ds, in AddTransactionPayload payload) { TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
// TODO return ds.getTransactionRepository().findById(transactionId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
} }
void addTransaction( TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
ProfileDataSource ds, TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
SysTime timestamp, TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
SysTime addedAt, AccountRepository accountRepo = ds.getAccountRepository();
ulong amount,
Currency currency, // Validate transaction details:
string description, if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
Optional!ulong vendorId, throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
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.");
} }
auto journalEntryRepo = ds.getAccountJournalEntryRepository(); if (
auto txRepo = ds.getTransactionRepository(); !payload.creditedAccountId.isNull &&
Transaction tx = txRepo.insert( !payload.debitedAccountId.isNull &&
timestamp, payload.creditedAccountId.get == payload.debitedAccountId.get
addedAt, ) {
amount, throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
currency,
description,
vendorId,
categoryId
);
if (creditedAccountId) {
journalEntryRepo.insert(
timestamp,
creditedAccountId.value,
tx.id,
amount,
AccountJournalEntryType.CREDIT,
currency
);
} }
if (debitedAccountId) { if (payload.amount == 0) {
journalEntryRepo.insert( throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
timestamp,
debitedAccountId.value,
tx.id,
amount,
AccountJournalEntryType.DEBIT,
currency
);
} }
if (tags.length > 0) { SysTime now = Clock.currTime(UTC());
ds.getTransactionTagRepository().updateTags(tx.id, tags); 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 // Vendors Services

View File

@ -7,12 +7,15 @@ import auth;
import profile; import profile;
import account; import account;
import transaction; import transaction;
import transaction.dto;
import util.money; import util.money;
import util.data;
import std.random; import std.random;
import std.conv; import std.conv;
import std.array; import std.array;
import std.datetime; import std.datetime;
import std.typecons;
void generateSampleData() { void generateSampleData() {
UserRepository userRepo = new FileSystemUserRepository; UserRepository userRepo = new FileSystemUserRepository;
@ -51,31 +54,30 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
infoF!" Generating random profile %s."(profileName); infoF!" Generating random profile %s."(profileName);
Profile profile = profileRepo.createProfile(profileName); Profile profile = profileRepo.createProfile(profileName);
ProfileDataSource ds = profileRepo.getDataSource(profile); 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); ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
for (int i = 0; i < accountCount; i++) { Currency preferredCurrency = choice(ALL_CURRENCIES);
generateRandomAccount(i, ds, preferredCurrency);
}
auto vendorRepo = ds.getTransactionVendorRepository(); const int accountCount = uniform(3, 10);
const int vendorCount = uniform(5, 30); for (int i = 0; i < accountCount; i++) {
for (int i = 0; i < vendorCount; i++) { generateRandomAccount(i, ds, preferredCurrency);
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data."); }
}
infoF!" Generated %d random vendors."(vendorCount);
auto categoryRepo = ds.getTransactionCategoryRepository(); auto vendorRepo = ds.getTransactionVendorRepository();
const int categoryCount = uniform(5, 30); const int vendorCount = uniform(5, 30);
for (int i = 0; i < categoryCount; i++) { for (int i = 0; i < vendorCount; i++) {
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF"); vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
} }
infoF!" Generated %d random categories."(categoryCount); 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) { void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
@ -105,24 +107,21 @@ void generateRandomTransactions(ProfileDataSource ds) {
.findAllByParentId(Optional!ulong.empty); .findAllByParentId(Optional!ulong.empty);
const Account[] accounts = ds.getAccountRepository().findAll(); const Account[] accounts = ds.getAccountRepository().findAll();
SysTime now = Clock.currTime(UTC());
SysTime timestamp = Clock.currTime(UTC()) - seconds(1); SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
Optional!ulong vendorId; AddTransactionPayload data;
data.timestamp = timestamp.toISOExtString();
if (uniform01() < 0.7) { 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) { 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. // Randomly choose an account to credit / debit the transaction to.
Optional!ulong creditedAccountId;
Optional!ulong debitedAccountId;
Account primaryAccount = choice(accounts); Account primaryAccount = choice(accounts);
data.currencyCode = primaryAccount.currency.code;
Optional!ulong secondaryAccountId; Optional!ulong secondaryAccountId;
if (uniform01() < 0.25) { if (uniform01() < 0.25) {
foreach (acc; accounts) { foreach (acc; accounts) {
@ -133,11 +132,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
} }
} }
if (uniform01() < 0.5) { if (uniform01() < 0.5) {
creditedAccountId = Optional!ulong.of(primaryAccount.id); data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
if (secondaryAccountId) debitedAccountId = secondaryAccountId; if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
} else { } else {
debitedAccountId = Optional!ulong.of(primaryAccount.id); data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
if (secondaryAccountId) creditedAccountId = secondaryAccountId; if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
} }
// Randomly choose some tags to add. // Randomly choose some tags to add.
@ -147,24 +146,40 @@ void generateRandomTransactions(ProfileDataSource ds) {
tags ~= "tag-" ~ n.to!string; 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( // Generate random line items:
ds, if (uniform01 < 0.5) {
timestamp, long lineItemTotal = 0;
now, foreach (n; 1..uniform(1, 20)) {
value, AddTransactionPayload.LineItem item;
primaryAccount.currency, item.valuePerItem = uniform(1, 10_000);
"Test transaction " ~ to!string(i), item.quantity = uniform(1, 5);
vendorId, lineItemTotal += item.quantity * item.valuePerItem;
categoryId, item.description = "Sample item " ~ n.to!string;
creditedAccountId, if (uniform01 < 0.5) {
debitedAccountId, TransactionCategory category = choice(categories);
[], item.categoryId = category.id;
tags }
); data.lineItems ~= item;
infoF!" Generated transaction %d"(i); }
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)); 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 ( CREATE TABLE transaction_line_item (
id INTEGER PRIMARY KEY,
transaction_id INTEGER NOT NULL, transaction_id INTEGER NOT NULL,
idx INTEGER NOT NULL DEFAULT 0,
value_per_item INTEGER NOT NULL, value_per_item INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1, quantity INTEGER NOT NULL DEFAULT 1,
idx INTEGER NOT NULL DEFAULT 0,
description TEXT NOT NULL, description TEXT NOT NULL,
category_id INTEGER, category_id INTEGER,
CONSTRAINT pk_transaction_line_item PRIMARY KEY (transaction_id, idx),
CONSTRAINT fk_transaction_line_item_transaction CONSTRAINT fk_transaction_line_item_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,

View File

@ -14,6 +14,14 @@ export interface TransactionVendorPayload {
description: string description: string
} }
export interface TransactionCategory {
id: number
parentId: number | null
name: string
description: string
color: string
}
export interface Transaction { export interface Transaction {
id: number id: number
timestamp: string timestamp: string
@ -56,6 +64,36 @@ export interface TransactionsListItemAccount {
numberSuffix: string 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 { export class TransactionApiClient extends ApiClient {
readonly path: string readonly path: string
@ -89,4 +127,8 @@ export class TransactionApiClient extends ApiClient {
): Promise<Page<TransactionsListItem>> { ): Promise<Page<TransactionsListItem>> {
return await super.getJsonPage(this.path + '/transactions', paginationOptions) 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) { function selectProfile(profile: Profile) {
profileStore.onProfileSelected(profile) profileStore.onProfileSelected(profile)
router.push('/') router.push('/profiles/' + profile.name)
} }
async function addProfile() { 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.description }}</td>
<td>{{ tx.creditedAccount?.name }}</td> <td>{{ tx.creditedAccount?.name }}</td>
<td>{{ tx.debitedAccount?.name }}</td> <td>{{ tx.debitedAccount?.name }}</td>
<td>
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </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 { useAuthStore } from '@/stores/auth-store'
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' 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 { 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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/login', path: '/login',
component: async () => LoginPage, component: () => import('@/pages/LoginPage.vue'),
meta: { title: 'Login' }, meta: { title: 'Login' },
}, },
{ {
path: '/', path: '/',
component: async () => UserAccountLayout, component: () => import('@/pages/UserAccountLayout.vue'),
beforeEnter: onlyAuthenticated, beforeEnter: onlyAuthenticated,
children: [ children: [
{ {
path: '', path: '',
component: async () => UserHomePage, redirect: '/profiles',
meta: { title: 'Home' },
beforeEnter: profileSelected,
}, },
{ {
path: 'me', path: 'me',
component: async () => MyUserPage, component: () => import('@/pages/MyUserPage.vue'),
meta: { title: 'My User' }, meta: { title: 'My User' },
}, },
{ {
path: 'profiles', path: 'profiles',
component: async () => ProfilesPage, component: () => import('@/pages/ProfilesPage.vue'),
meta: { title: 'Profiles' }, meta: { title: 'Profiles' },
}, },
{ {
@ -45,24 +35,29 @@ const router = createRouter({
children: [ children: [
{ {
path: '', path: '',
component: async () => ProfilePage, component: () => import('@/pages/UserHomePage.vue'),
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name }, meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
}, },
{ {
path: 'accounts/:id', path: 'accounts/:id',
component: async () => AccountPage, component: () => import('@/pages/AccountPage.vue'),
meta: { title: 'Account' }, meta: { title: 'Account' },
}, },
{ {
path: 'accounts/:id/edit', path: 'accounts/:id/edit',
component: async () => EditAccountPage, component: () => import('@/pages/forms/EditAccountPage.vue'),
meta: { title: 'Edit Account' }, meta: { title: 'Edit Account' },
}, },
{ {
path: 'add-account', path: 'add-account',
component: async () => EditAccountPage, component: () => import('@/pages/forms/EditAccountPage.vue'),
meta: { title: 'Add Account' }, meta: { title: 'Add Account' },
}, },
{
path: 'transactions/:id',
component: () => import('@/pages/TransactionPage.vue'),
meta: { title: 'Transaction' },
},
], ],
}, },
], ],