Added more currency support, refactored tags, and optimized transactions query.
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-10 18:59:08 -04:00
parent e0b998156d
commit 4b9e859c85
20 changed files with 437 additions and 233 deletions

View File

@ -5,7 +5,7 @@
"copyright": "Copyright © 2024, Andrew Lalis", "copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": { "dependencies": {
"d2sqlite3": "~>1.0", "d2sqlite3": "~>1.0",
"handy-http-starter": "~>1.5", "handy-http-starter": "~>1.6",
"jwt4d": "~>0.0.2", "jwt4d": "~>0.0.2",
"secured": "~>3.0" "secured": "~>3.0"
}, },

View File

@ -7,8 +7,8 @@
"handy-http-data": "1.3.0", "handy-http-data": "1.3.0",
"handy-http-handlers": "1.1.0", "handy-http-handlers": "1.1.0",
"handy-http-primitives": "1.8.1", "handy-http-primitives": "1.8.1",
"handy-http-starter": "1.5.0", "handy-http-starter": "1.6.0",
"handy-http-transport": "1.7.3", "handy-http-transport": "1.8.0",
"handy-http-websockets": "1.2.0", "handy-http-websockets": "1.2.0",
"jwt4d": "0.0.2", "jwt4d": "0.0.2",
"mir-algorithm": "3.22.4", "mir-algorithm": "3.22.4",

View File

@ -57,13 +57,17 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
import transaction.api; import transaction.api;
// Transaction vendor endpoints: // Transaction vendor endpoints:
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &getVendors); a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &handleGetVendors);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor); a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleGetVendor);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &createVendor); a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor); a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor); a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleDeleteVendor);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &getTransactions); a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions);
import data_api;
// Various other data endpoints:
a.map(HttpMethod.GET, "/currencies", &handleGetCurrencies);
// Protect all authenticated paths with a filter. // Protect all authenticated paths with a filter.
import auth.service : AuthenticationFilter; import auth.service : AuthenticationFilter;

View File

@ -26,7 +26,9 @@ void main() {
configureLoggingProvider(provider); configureLoggingProvider(provider);
infoF!"Loaded app config: port = %d, webOrigin = %s"(config.port, config.webOrigin); infoF!"Loaded app config: port = %d, webOrigin = %s"(config.port, config.webOrigin);
HttpTransport transport = new TaskPoolHttp1Transport(mapApiHandlers(config.webOrigin), config.port); Http1TransportConfig transportConfig = defaultConfig();
transportConfig.port = config.port;
HttpTransport transport = new TaskPoolHttp1Transport(mapApiHandlers(config.webOrigin), transportConfig);
transport.start(); transport.start();
} }

View File

@ -0,0 +1,9 @@
module data_api;
import handy_http_primitives;
import handy_http_data;
import util.money;
void handleGetCurrencies(ref ServerHttpRequest request, ref ServerHttpResponse response) {
writeJsonBody(response, ALL_CURRENCIES);
}

View File

@ -143,7 +143,7 @@ class SqliteProfileDataSource : ProfileDataSource {
import transaction.data; import transaction.data;
import transaction.data_impl_sqlite; import transaction.data_impl_sqlite;
const SCHEMA = import("schema.sql"); const SCHEMA = import("sql/schema.sql");
private const string dbPath; private const string dbPath;
private Database db; private Database db;

View File

@ -10,66 +10,72 @@ import transaction.data;
import transaction.service; import transaction.service;
import profile.data; import profile.data;
import profile.service; import profile.service;
import account.api;
import util.money; import util.money;
import util.pagination; import util.pagination;
import util.data; 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 TransactionResponse { 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; import asdf : serdeTransformOut;
ulong id; ulong id;
string timestamp; string timestamp;
string addedAt; string addedAt;
ulong amount; ulong amount;
string currency; Currency currency;
string description; string description;
@serdeTransformOut!serializeOptional
Optional!ulong vendorId;
@serdeTransformOut!serializeOptional
Optional!ulong categoryId;
static TransactionResponse of(in Transaction tx) { @serdeTransformOut!serializeOptional
return TransactionResponse( Optional!TransactionsListItemVendor vendor;
tx.id, @serdeTransformOut!serializeOptional
tx.timestamp.toISOExtString(), Optional!TransactionsListItemCategory category;
tx.addedAt.toISOExtString(), @serdeTransformOut!serializeOptional
tx.amount, Optional!TransactionsListItemAccount creditedAccount;
tx.currency.code.idup, @serdeTransformOut!serializeOptional
tx.description, Optional!TransactionsListItemAccount debitedAccount;
tx.vendorId,
tx.categoryId string[] tags;
);
}
} }
void getTransactions(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);
Page!Transaction page = ds.getTransactionRepository().findAll(pr); auto responsePage = getTransactions(ds, pr);
Page!TransactionResponse responsePage = page.mapTo!()(&TransactionResponse.of);
writeJsonBody(response, responsePage); writeJsonBody(response, responsePage);
} }
void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { // Vendors API
void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository(); TransactionVendor[] vendors = getAllVendors(ds);
TransactionVendor[] vendors = vendorRepo.findAll();
writeJsonBody(response, vendors); writeJsonBody(response, vendors);
} }
void getVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
long vendorId = request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository(); TransactionVendor vendor = getVendor(ds, getVendorId(request));
TransactionVendor vendor = vendorRepo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(response, vendor); writeJsonBody(response, vendor);
} }
@ -78,54 +84,27 @@ struct VendorPayload {
string description; string description;
} }
void createVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request); VendorPayload payload = readJsonBodyAs!VendorPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository(); TransactionVendor vendor = createVendor(ds, payload);
if (vendorRepo.existsByName(payload.name)) {
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Vendor name is already in use.");
return;
}
TransactionVendor vendor = vendorRepo.insert(payload.name, payload.description);
writeJsonBody(response, vendor); writeJsonBody(response, vendor);
} }
void updateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request); VendorPayload payload = readJsonBodyAs!VendorPayload(request);
long vendorId = request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository(); TransactionVendor updated = updateVendor(ds, getVendorId(request), payload);
TransactionVendor existingVendor = vendorRepo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
if (payload.name != existingVendor.name && vendorRepo.existsByName(payload.name)) {
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Vendor name is already in use.");
return;
}
TransactionVendor updated = vendorRepo.updateById(
vendorId,
payload.name,
payload.description
);
writeJsonBody(response, updated); writeJsonBody(response, updated);
} }
void deleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
long vendorId = request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository(); deleteVendor(ds, getVendorId(request));
vendorRepo.deleteById(vendorId);
} }
private ulong getVendorId(in ServerHttpRequest request) {
return getPathParamOrThrow(request, "vendorId");
}
// Categories API

View File

@ -4,6 +4,7 @@ import handy_http_primitives : Optional;
import std.datetime; import std.datetime;
import transaction.model; import transaction.model;
import transaction.api : TransactionsListItem;
import util.money; import util.money;
import util.pagination; import util.pagination;
@ -25,15 +26,13 @@ interface TransactionCategoryRepository {
} }
interface TransactionTagRepository { interface TransactionTagRepository {
Optional!TransactionTag findById(ulong id); string[] findAllByTransactionId(ulong transactionId);
Optional!TransactionTag findByName(string name); void updateTags(ulong transactionId, string[] tags);
TransactionTag[] findAll(); string[] findAll();
TransactionTag insert(string name);
void deleteById(ulong id);
} }
interface TransactionRepository { interface TransactionRepository {
Page!Transaction findAll(PageRequest pr); Page!TransactionsListItem findAll(PageRequest pr);
Optional!Transaction findById(ulong id); Optional!Transaction findById(ulong id);
Transaction insert( Transaction insert(
SysTime timestamp, SysTime timestamp,

View File

@ -2,10 +2,12 @@ module transaction.data_impl_sqlite;
import handy_http_primitives : Optional; import handy_http_primitives : Optional;
import std.datetime; import std.datetime;
import std.typecons;
import d2sqlite3; import d2sqlite3;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
import transaction.api;
import util.sqlite; import util.sqlite;
import util.money; import util.money;
import util.pagination; import util.pagination;
@ -136,48 +138,35 @@ class SqliteTransactionTagRepository : TransactionTagRepository {
this.db = db; this.db = db;
} }
Optional!TransactionTag findById(ulong id) { string[] findAllByTransactionId(ulong transactionId) {
return findOne(db, "SELECT * FROM transaction_tag WHERE id = ?", &parseTag, id);
}
Optional!TransactionTag findByName(string name) {
return findOne(db, "SELECT * FROM transaction_tag WHERE name = ?", &parseTag, name);
}
TransactionTag[] findAll() {
return util.sqlite.findAll( return util.sqlite.findAll(
db, db,
"SELECT * FROM transaction_tag ORDER BY name ASC", "SELECT tag FROM transaction_tag WHERE transaction_id = ? ORDER BY tag",
&parseTag r => r.peek!string(0),
transactionId
); );
} }
TransactionTag insert(string name) { void updateTags(ulong transactionId, string[] tags) {
auto existingTag = findByName(name); util.sqlite.update(
if (existingTag) { db,
return existingTag.value; "DELETE FROM transaction_tag WHERE transaction_id = ?",
transactionId
);
foreach (tag; tags) {
util.sqlite.update(
db,
"INSERT INTO transaction_tag (transaction_id, tag) VALUES (?, ?)",
transactionId, tag
);
} }
util.sqlite.update(
db,
"INSERT INTO transaction_tag (name) VALUES (?)",
name
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
} }
void deleteById(ulong id) { string[] findAll() {
util.sqlite.update( return util.sqlite.findAll(
db, db,
"DELETE FROM transaction_tag WHERE id = ?", "SELECT DISTINCT tag FROM transaction_tag ORDER BY tag",
id r => r.peek!string(0)
);
}
private static TransactionTag parseTag(Row row) {
return TransactionTag(
row.peek!ulong(0),
row.peek!string(1)
); );
} }
} }
@ -189,19 +178,65 @@ class SqliteTransactionRepository : TransactionRepository {
this.db = db; this.db = db;
} }
Page!Transaction findAll(PageRequest pr) { Page!TransactionsListItem findAll(PageRequest pr) {
const BASE_QUERY = import("sql/get_transactions.sql");
// TODO: Implement filtering or something! // TODO: Implement filtering or something!
import std.array; import std.array;
const string rootQuery = "SELECT * FROM " ~ TABLE_NAME;
const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME; const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME;
auto sqlBuilder = appender!string; auto sqlBuilder = appender!string;
sqlBuilder ~= rootQuery; sqlBuilder ~= BASE_QUERY;
sqlBuilder ~= " "; sqlBuilder ~= " ";
sqlBuilder ~= pr.toSql(); sqlBuilder ~= pr.toSql();
string query = sqlBuilder[]; string query = sqlBuilder[];
Transaction[] results = util.sqlite.findAll(db, query, &parseTransaction); TransactionsListItem[] results = util.sqlite.findAll(db, query, (row) {
TransactionsListItem 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) {
string vendorName = row.peek!string(7);
item.vendor = Optional!TransactionsListItemVendor.of(
TransactionsListItemVendor(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));
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
if (!creditedAccountId.isNull) {
ulong id = creditedAccountId.get;
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));
}
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
if (!debitedAccountId.isNull) {
ulong id = debitedAccountId.get;
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));
}
string tagsStr = row.peek!string(19);
if (tagsStr.length > 0) {
import std.string : split;
item.tags = tagsStr.split(",");
}
return item;
});
ulong totalCount = util.sqlite.count(db, countQuery); ulong totalCount = util.sqlite.count(db, countQuery);
return Page!(Transaction).of(results, pr, totalCount); return Page!(TransactionsListItem).of(results, pr, totalCount);
} }
Optional!Transaction findById(ulong id) { Optional!Transaction findById(ulong id) {

View File

@ -19,11 +19,6 @@ struct TransactionCategory {
immutable string color; immutable string color;
} }
struct TransactionTag {
immutable ulong id;
immutable string name;
}
struct Transaction { struct Transaction {
immutable ulong id; immutable ulong id;
/// The time at which the transaction happened. /// The time at which the transaction happened.

View File

@ -1,8 +1,9 @@
module transaction.service; module transaction.service;
import handy_http_primitives : Optional; import handy_http_primitives;
import std.datetime; import std.datetime;
import transaction.api;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
import profile.data; import profile.data;
@ -10,6 +11,14 @@ import account.model;
import util.money; import util.money;
import util.pagination; import util.pagination;
// Transactions Services
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
Page!TransactionsListItem page = ds.getTransactionRepository()
.findAll(pageRequest);
return page; // Return an empty page for now!
}
void addTransaction( void addTransaction(
ProfileDataSource ds, ProfileDataSource ds,
SysTime timestamp, SysTime timestamp,
@ -21,43 +30,80 @@ void addTransaction(
Optional!ulong categoryId, Optional!ulong categoryId,
Optional!ulong creditedAccountId, Optional!ulong creditedAccountId,
Optional!ulong debitedAccountId, Optional!ulong debitedAccountId,
TransactionLineItem[] lineItems TransactionLineItem[] lineItems,
// TODO: Add attachments and tags! string[] tags
) { ) {
if (creditedAccountId.isNull && debitedAccountId.isNull) { if (creditedAccountId.isNull && debitedAccountId.isNull) {
throw new Exception("At least one account must be linked to a transaction."); throw new Exception("At least one account must be linked to a transaction.");
} }
ds.doTransaction(() { auto journalEntryRepo = ds.getAccountJournalEntryRepository();
auto journalEntryRepo = ds.getAccountJournalEntryRepository(); auto txRepo = ds.getTransactionRepository();
auto txRepo = ds.getTransactionRepository(); Transaction tx = txRepo.insert(
Transaction tx = txRepo.insert( timestamp,
addedAt,
amount,
currency,
description,
vendorId,
categoryId
);
if (creditedAccountId) {
journalEntryRepo.insert(
timestamp, timestamp,
addedAt, creditedAccountId.value,
tx.id,
amount, amount,
currency, AccountJournalEntryType.CREDIT,
description, currency
vendorId,
categoryId
); );
if (creditedAccountId) { }
journalEntryRepo.insert( if (debitedAccountId) {
timestamp, journalEntryRepo.insert(
creditedAccountId.value, timestamp,
tx.id, debitedAccountId.value,
amount, tx.id,
AccountJournalEntryType.CREDIT, amount,
currency AccountJournalEntryType.DEBIT,
); currency
} );
if (debitedAccountId) { }
journalEntryRepo.insert( if (tags.length > 0) {
timestamp, ds.getTransactionTagRepository().updateTags(tx.id, tags);
debitedAccountId.value, }
tx.id, }
amount,
AccountJournalEntryType.DEBIT, // Vendors Services
currency
); TransactionVendor[] getAllVendors(ProfileDataSource ds) {
} return ds.getTransactionVendorRepository().findAll();
}); }
TransactionVendor getVendor(ProfileDataSource ds, ulong vendorId) {
return ds.getTransactionVendorRepository().findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}
TransactionVendor createVendor(ProfileDataSource ds, in VendorPayload payload) {
auto vendorRepo = ds.getTransactionVendorRepository();
if (vendorRepo.existsByName(payload.name)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor name is already in use.");
}
return vendorRepo.insert(payload.name, payload.description);
}
TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPayload payload) {
TransactionVendorRepository repo = ds.getTransactionVendorRepository();
TransactionVendor existingVendor = repo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
if (payload.name != existingVendor.name && repo.existsByName(payload.name)) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Vendor name is already in use."
);
}
return repo.updateById(vendorId, payload.name, payload.description);
}
void deleteVendor(ProfileDataSource ds, ulong vendorId) {
ds.getTransactionVendorRepository().deleteById(vendorId);
} }

View File

@ -25,3 +25,19 @@ auto serializeOptional(T)(Optional!T value) {
} }
return Nullable!T(value.value); return Nullable!T(value.value);
} }
ulong getPathParamOrThrow(T = ulong)(in ServerHttpRequest req, string name) {
import handy_http_handlers.path_handler;
import std.conv;
foreach (param; getPathParams(req)) {
if (param.name == name) {
try {
return param.value.to!T;
} catch (ConvException e) {
// Skip and throw if no params match.
}
}
}
// No params matched, so throw a NOT FOUND error.
throw new HttpStatusException(HttpStatus.NOT_FOUND, "Missing required path parameter \"" ~ name ~ "\".");
}

View File

@ -8,13 +8,15 @@ import std.traits : isSomeString, EnumMembers;
*/ */
struct Currency { struct Currency {
/// The common 3-character code for the currency, like "USD". /// The common 3-character code for the currency, like "USD".
immutable char[3] code; char[3] code;
/// The number of digits after the decimal place that the currency supports. /// The number of digits after the decimal place that the currency supports.
immutable ubyte fractionalDigits; ubyte fractionalDigits;
/// The ISO 4217 numeric code for the currency. /// The ISO 4217 numeric code for the currency.
immutable ushort numericCode; ushort numericCode;
/// The symbol used when writing monetary values of this currency.
string symbol;
static Currency ofCode(S)(S code) if (isSomeString!S) { static Currency ofCode(S)(in S code) if (isSomeString!S) {
if (code.length != 3) { if (code.length != 3) {
throw new Exception("Invalid currency code: " ~ code); throw new Exception("Invalid currency code: " ~ code);
} }
@ -27,15 +29,15 @@ struct Currency {
/// An enumeration of all available currencies. /// An enumeration of all available currencies.
enum Currencies : Currency { enum Currencies : Currency {
AUD = Currency("AUD", 2, 36), AUD = Currency("AUD", 2, 36, "$"),
USD = Currency("USD", 2, 840), USD = Currency("USD", 2, 840, "$"),
CAD = Currency("CAD", 2, 124), CAD = Currency("CAD", 2, 124, "$"),
GBP = Currency("GBP", 2, 826), GBP = Currency("GBP", 2, 826, "£"),
EUR = Currency("EUR", 2, 978), EUR = Currency("EUR", 2, 978, "€"),
CHF = Currency("CHF", 2, 756), CHF = Currency("CHF", 2, 756, "Fr"),
ZAR = Currency("ZAR", 2, 710), ZAR = Currency("ZAR", 2, 710, "R"),
JPY = Currency("JPY", 0, 392), JPY = Currency("JPY", 0, 392, "¥"),
INR = Currency("INR", 2, 356) INR = Currency("INR", 2, 356, "₹")
} }
immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies]; immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies];

View File

@ -51,44 +51,43 @@ 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.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); ds.doTransaction(() {
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
Currency preferredCurrency = choice(ALL_CURRENCIES);
const int accountCount = uniform(1, 10); const int accountCount = uniform(3, 10);
for (int i = 0; i < accountCount; i++) { for (int i = 0; i < accountCount; i++) {
generateRandomAccount(i, ds); generateRandomAccount(i, ds, preferredCurrency);
} }
auto vendorRepo = ds.getTransactionVendorRepository(); auto vendorRepo = ds.getTransactionVendorRepository();
const int vendorCount = uniform(5, 30); const int vendorCount = uniform(5, 30);
for (int i = 0; i < vendorCount; i++) { for (int i = 0; i < vendorCount; i++) {
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data."); vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
} }
infoF!" Generated %d random vendors."(vendorCount); infoF!" Generated %d random vendors."(vendorCount);
auto tagRepo = ds.getTransactionTagRepository(); auto categoryRepo = ds.getTransactionCategoryRepository();
const int tagCount = uniform(5, 30); const int categoryCount = uniform(5, 30);
for (int i = 0; i < tagCount; i++) { for (int i = 0; i < categoryCount; i++) {
tagRepo.insert("test-tag-" ~ to!string(i)); categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
} }
infoF!" Generated %d random tags."(tagCount); infoF!" Generated %d random categories."(categoryCount);
auto categoryRepo = ds.getTransactionCategoryRepository(); generateRandomTransactions(ds);
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) { void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
AccountRepository accountRepo = ds.getAccountRepository(); AccountRepository accountRepo = ds.getAccountRepository();
string idxStr = idx.to!string; string idxStr = idx.to!string;
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr; string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
string name = "Test Account " ~ idxStr; string name = "Test Account " ~ idxStr;
AccountType type = choice(ALL_ACCOUNT_TYPES); AccountType type = choice(ALL_ACCOUNT_TYPES);
Currency currency = choice(ALL_CURRENCIES); Currency currency = preferredCurrency;
if (uniform01() < 0.1) {
currency = choice(ALL_CURRENCIES);
}
string description = "This is a testing account generated by util.sample_data.generateRandomAccount()."; string description = "This is a testing account generated by util.sample_data.generateRandomAccount().";
Account account = accountRepo.insert( Account account = accountRepo.insert(
type, type,
@ -97,16 +96,13 @@ void generateRandomAccount(int idx, ProfileDataSource ds) {
currency, currency,
description description
); );
infoF!" Generated random account: %s, #%s"(name, numberSuffix); infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code);
} }
void generateRandomTransactions(ProfileDataSource ds) { void generateRandomTransactions(ProfileDataSource ds) {
const bool hasVendor = uniform01() > 0.3;
const bool hasCategory = uniform01() > 0.2;
const TransactionVendor[] vendors = ds.getTransactionVendorRepository.findAll(); const TransactionVendor[] vendors = ds.getTransactionVendorRepository.findAll();
const TransactionCategory[] categories = ds.getTransactionCategoryRepository() const TransactionCategory[] categories = ds.getTransactionCategoryRepository()
.findAllByParentId(Optional!ulong.empty); .findAllByParentId(Optional!ulong.empty);
const TransactionTag[] tags = ds.getTransactionTagRepository().findAll();
const Account[] accounts = ds.getAccountRepository().findAll(); const Account[] accounts = ds.getAccountRepository().findAll();
SysTime now = Clock.currTime(UTC()); SysTime now = Clock.currTime(UTC());
@ -114,32 +110,44 @@ void generateRandomTransactions(ProfileDataSource ds) {
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
Optional!ulong vendorId; Optional!ulong vendorId;
if (hasVendor) { if (uniform01() < 0.7) {
vendorId = Optional!ulong.of(choice(vendors).id); vendorId = Optional!ulong.of(choice(vendors).id);
} }
Optional!ulong categoryId; Optional!ulong categoryId;
if (hasCategory) { if (uniform01() < 0.8) {
categoryId = Optional!ulong.of(choice(categories).id); categoryId = Optional!ulong.of(choice(categories).id);
} }
// Randomly choose an account to credit / debit the transaction to.
Optional!ulong creditedAccountId; Optional!ulong creditedAccountId;
Optional!ulong debitedAccountId; Optional!ulong debitedAccountId;
Account primaryAccount = choice(accounts); Account primaryAccount = choice(accounts);
Optional!ulong secondaryAccount; Optional!ulong secondaryAccountId;
if (uniform01() < 0.25) { if (uniform01() < 0.25) {
foreach (acc; accounts) { foreach (acc; accounts) {
if (acc.id != primaryAccount.id && acc.currency == primaryAccount.currency) { if (acc.id != primaryAccount.id && acc.currency == primaryAccount.currency) {
secondaryAccount.value = acc.id; secondaryAccountId = Optional!ulong.of(acc.id);
break; break;
} }
} }
} }
if (uniform01() > 0.5) { if (uniform01() < 0.5) {
creditedAccountId = Optional!ulong.of(primaryAccount.id); creditedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccount) debitedAccountId = secondaryAccount; if (secondaryAccountId) debitedAccountId = secondaryAccountId;
} else { } else {
debitedAccountId = Optional!ulong.of(primaryAccount.id); debitedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccount) creditedAccountId = secondaryAccount; if (secondaryAccountId) creditedAccountId = secondaryAccountId;
} }
// Randomly choose some tags to add.
string[] tags;
foreach (n; 1..10) {
if (uniform01 < 0.25) {
tags ~= "tag-" ~ n.to!string;
}
}
ulong value = uniform(0, 1_000_000); ulong value = uniform(0, 1_000_000);
addTransaction( addTransaction(
@ -153,7 +161,8 @@ void generateRandomTransactions(ProfileDataSource ds) {
categoryId, categoryId,
creditedAccountId, creditedAccountId,
debitedAccountId, debitedAccountId,
[] [],
tags
); );
infoF!" Generated transaction %d"(i); infoF!" Generated transaction %d"(i);
timestamp -= seconds(uniform(10, 1_000_000)); timestamp -= seconds(uniform(10, 1_000_000));

View File

@ -0,0 +1,42 @@
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,
txn.category_id AS category_id,
category.name AS category_name,
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
GROUP BY txn.id

View File

@ -57,8 +57,9 @@ CREATE TABLE transaction_category (
); );
CREATE TABLE transaction_tag ( CREATE TABLE transaction_tag (
id INTEGER PRIMARY KEY, transaction_id INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE tag TEXT NOT NULL,
CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag)
); );
CREATE TABLE "transaction" ( CREATE TABLE "transaction" (
@ -77,6 +78,7 @@ CREATE TABLE "transaction" (
FOREIGN KEY (category_id) REFERENCES transaction_category(id) FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE SET NULL ON UPDATE CASCADE ON DELETE SET NULL
); );
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
CREATE TABLE transaction_attachment ( CREATE TABLE transaction_attachment (
transaction_id INTEGER NOT NULL, transaction_id INTEGER NOT NULL,
@ -90,18 +92,6 @@ CREATE TABLE transaction_attachment (
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE transaction_tag_join (
transaction_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
CONSTRAINT fk_transaction_tag_join_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_tag_join_tag
FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_line_item ( CREATE TABLE transaction_line_item (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
transaction_id INTEGER NOT NULL, transaction_id INTEGER NOT NULL,

29
web-app/src/api/data.ts Normal file
View File

@ -0,0 +1,29 @@
import { ApiClient } from './base'
export interface Currency {
code: string
fractionalDigits: number
numericCode: number
symbol: string
}
export class DataApiClient extends ApiClient {
async getCurrencies(): Promise<Currency[]> {
return await super.getJson('/currencies')
}
}
/**
* Formats a money value (integer amount and currency) as a string.
* @param amount The integer amount to format.
* @param currency The currency of the value.
* @returns A string representation of the money value.
*/
export function formatMoney(amount: number, currency: Currency) {
const format = new Intl.NumberFormat(undefined, {
currency: currency.code,
style: 'currency',
currencyDisplay: 'narrowSymbol',
})
return format.format(amount / Math.pow(10, currency.fractionalDigits))
}

View File

@ -1,4 +1,5 @@
import { ApiClient } from './base' import { ApiClient } from './base'
import type { Currency } from './data'
import { type Page, type PageRequest } from './pagination' import { type Page, type PageRequest } from './pagination'
import type { Profile } from './profile' import type { Profile } from './profile'
@ -24,6 +25,37 @@ export interface Transaction {
categoryId: number | null categoryId: number | null
} }
export interface TransactionsListItem {
id: number
timestamp: string
addedAt: string
amount: number
currency: Currency
description: string
vendor: TransactionsListItemVendor | null
category: TransactionsListItemCategory | null
creditedAccount: TransactionsListItemAccount | null
debitedAccount: TransactionsListItemAccount | null
}
export interface TransactionsListItemVendor {
id: number
name: string
}
export interface TransactionsListItemCategory {
id: number
name: string
color: string
}
export interface TransactionsListItemAccount {
id: number
name: string
type: string
numberSuffix: string
}
export class TransactionApiClient extends ApiClient { export class TransactionApiClient extends ApiClient {
readonly path: string readonly path: string
@ -54,7 +86,7 @@ export class TransactionApiClient extends ApiClient {
async getTransactions( async getTransactions(
paginationOptions: PageRequest | undefined = undefined, paginationOptions: PageRequest | undefined = undefined,
): Promise<Page<Transaction>> { ): Promise<Page<TransactionsListItem>> {
return await super.getJsonPage(this.path + '/transactions', paginationOptions) return await super.getJsonPage(this.path + '/transactions', paginationOptions)
} }
} }

View File

@ -35,6 +35,12 @@ async function doLogin() {
disableForm.value = false disableForm.value = false
} }
} }
function generateSampleData() {
fetch('http://localhost:8080/api/sample-data', {
method: 'POST'
})
}
</script> </script>
<template> <template>
<div> <div>
@ -52,5 +58,9 @@ async function doLogin() {
<button type="submit" :disabled="disableForm">Login</button> <button type="submit" :disabled="disableForm">Login</button>
</div> </div>
</form> </form>
<div>
<button type="button" @click="generateSampleData()">Generate Sample Data</button>
</div>
</div> </div>
</template> </template>

View File

@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatMoney } from '@/api/data';
import type { Page, PageRequest } from '@/api/pagination'; import type { Page, PageRequest } from '@/api/pagination';
import { TransactionApiClient, type Transaction } from '@/api/transaction'; import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction';
import HomeModule from '@/components/HomeModule.vue'; import HomeModule from '@/components/HomeModule.vue';
import PaginationControls from '@/components/PaginationControls.vue'; import PaginationControls from '@/components/PaginationControls.vue';
import { useProfileStore } from '@/stores/profile-store'; import { useProfileStore } from '@/stores/profile-store';
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue';
const profileStore = useProfileStore() const profileStore = useProfileStore()
const transactions: Ref<Page<Transaction>> = ref({ items: [], pageRequest: { page: 1, size: 10, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true }) const transactions: Ref<Page<TransactionsListItem>> = ref({ items: [], pageRequest: { page: 1, size: 10, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true })
onMounted(async () => { onMounted(async () => {
await fetchPage(transactions.value.pageRequest) await fetchPage(transactions.value.pageRequest)
@ -33,6 +34,8 @@ async function fetchPage(pageRequest: PageRequest) {
<th>Amount</th> <th>Amount</th>
<th>Currency</th> <th>Currency</th>
<th>Description</th> <th>Description</th>
<th>Credited Account</th>
<th>Debited Account</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -40,9 +43,11 @@ async function fetchPage(pageRequest: PageRequest) {
<td> <td>
{{ new Date(tx.timestamp).toLocaleDateString() }} {{ new Date(tx.timestamp).toLocaleDateString() }}
</td> </td>
<td>{{ tx.amount }}</td> <td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
<td>{{ tx.currency }}</td> <td>{{ tx.currency.code }}</td>
<td>{{ tx.description }}</td> <td>{{ tx.description }}</td>
<td>{{ tx.creditedAccount?.name }}</td>
<td>{{ tx.debitedAccount?.name }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>