Added more currency support, refactored tags, and optimized transactions query.
This commit is contained in:
parent
e0b998156d
commit
4b9e859c85
|
@ -5,7 +5,7 @@
|
|||
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||
"dependencies": {
|
||||
"d2sqlite3": "~>1.0",
|
||||
"handy-http-starter": "~>1.5",
|
||||
"handy-http-starter": "~>1.6",
|
||||
"jwt4d": "~>0.0.2",
|
||||
"secured": "~>3.0"
|
||||
},
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
"handy-http-data": "1.3.0",
|
||||
"handy-http-handlers": "1.1.0",
|
||||
"handy-http-primitives": "1.8.1",
|
||||
"handy-http-starter": "1.5.0",
|
||||
"handy-http-transport": "1.7.3",
|
||||
"handy-http-starter": "1.6.0",
|
||||
"handy-http-transport": "1.8.0",
|
||||
"handy-http-websockets": "1.2.0",
|
||||
"jwt4d": "0.0.2",
|
||||
"mir-algorithm": "3.22.4",
|
||||
|
|
|
@ -57,13 +57,17 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
|
||||
import transaction.api;
|
||||
// Transaction vendor endpoints:
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &getVendors);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &createVendor);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &handleGetVendors);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleGetVendor);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor);
|
||||
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.
|
||||
import auth.service : AuthenticationFilter;
|
||||
|
|
|
@ -26,7 +26,9 @@ void main() {
|
|||
configureLoggingProvider(provider);
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -143,7 +143,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
import transaction.data;
|
||||
import transaction.data_impl_sqlite;
|
||||
|
||||
const SCHEMA = import("schema.sql");
|
||||
const SCHEMA = import("sql/schema.sql");
|
||||
private const string dbPath;
|
||||
private Database db;
|
||||
|
||||
|
|
|
@ -10,66 +10,72 @@ import transaction.data;
|
|||
import transaction.service;
|
||||
import profile.data;
|
||||
import profile.service;
|
||||
import account.api;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
|
||||
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;
|
||||
|
||||
ulong id;
|
||||
string timestamp;
|
||||
string addedAt;
|
||||
ulong amount;
|
||||
string currency;
|
||||
Currency currency;
|
||||
string description;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong vendorId;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong categoryId;
|
||||
|
||||
static TransactionResponse of(in Transaction tx) {
|
||||
return TransactionResponse(
|
||||
tx.id,
|
||||
tx.timestamp.toISOExtString(),
|
||||
tx.addedAt.toISOExtString(),
|
||||
tx.amount,
|
||||
tx.currency.code.idup,
|
||||
tx.description,
|
||||
tx.vendorId,
|
||||
tx.categoryId
|
||||
);
|
||||
}
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!TransactionsListItemVendor vendor;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!TransactionsListItemCategory category;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!TransactionsListItemAccount creditedAccount;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!TransactionsListItemAccount debitedAccount;
|
||||
|
||||
string[] tags;
|
||||
}
|
||||
|
||||
void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
||||
Page!Transaction page = ds.getTransactionRepository().findAll(pr);
|
||||
Page!TransactionResponse responsePage = page.mapTo!()(&TransactionResponse.of);
|
||||
auto responsePage = getTransactions(ds, pr);
|
||||
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);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionVendor[] vendors = vendorRepo.findAll();
|
||||
TransactionVendor[] vendors = getAllVendors(ds);
|
||||
writeJsonBody(response, vendors);
|
||||
}
|
||||
|
||||
void getVendor(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;
|
||||
}
|
||||
void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionVendor vendor = vendorRepo.findById(vendorId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
TransactionVendor vendor = getVendor(ds, getVendorId(request));
|
||||
writeJsonBody(response, vendor);
|
||||
}
|
||||
|
||||
|
@ -78,54 +84,27 @@ struct VendorPayload {
|
|||
string description;
|
||||
}
|
||||
|
||||
void createVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
VendorPayload payload = readJsonBodyAs!VendorPayload(request);
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
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);
|
||||
TransactionVendor vendor = createVendor(ds, payload);
|
||||
writeJsonBody(response, vendor);
|
||||
}
|
||||
|
||||
void updateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
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);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
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
|
||||
);
|
||||
TransactionVendor updated = updateVendor(ds, getVendorId(request), payload);
|
||||
writeJsonBody(response, updated);
|
||||
}
|
||||
|
||||
void deleteVendor(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;
|
||||
}
|
||||
void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
vendorRepo.deleteById(vendorId);
|
||||
deleteVendor(ds, getVendorId(request));
|
||||
}
|
||||
|
||||
private ulong getVendorId(in ServerHttpRequest request) {
|
||||
return getPathParamOrThrow(request, "vendorId");
|
||||
}
|
||||
|
||||
// Categories API
|
||||
|
|
|
@ -4,6 +4,7 @@ import handy_http_primitives : Optional;
|
|||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
import transaction.api : TransactionsListItem;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
||||
|
@ -25,15 +26,13 @@ interface TransactionCategoryRepository {
|
|||
}
|
||||
|
||||
interface TransactionTagRepository {
|
||||
Optional!TransactionTag findById(ulong id);
|
||||
Optional!TransactionTag findByName(string name);
|
||||
TransactionTag[] findAll();
|
||||
TransactionTag insert(string name);
|
||||
void deleteById(ulong id);
|
||||
string[] findAllByTransactionId(ulong transactionId);
|
||||
void updateTags(ulong transactionId, string[] tags);
|
||||
string[] findAll();
|
||||
}
|
||||
|
||||
interface TransactionRepository {
|
||||
Page!Transaction findAll(PageRequest pr);
|
||||
Page!TransactionsListItem findAll(PageRequest pr);
|
||||
Optional!Transaction findById(ulong id);
|
||||
Transaction insert(
|
||||
SysTime timestamp,
|
||||
|
|
|
@ -2,10 +2,12 @@ module transaction.data_impl_sqlite;
|
|||
|
||||
import handy_http_primitives : Optional;
|
||||
import std.datetime;
|
||||
import std.typecons;
|
||||
import d2sqlite3;
|
||||
|
||||
import transaction.model;
|
||||
import transaction.data;
|
||||
import transaction.api;
|
||||
import util.sqlite;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
@ -136,48 +138,35 @@ class SqliteTransactionTagRepository : TransactionTagRepository {
|
|||
this.db = db;
|
||||
}
|
||||
|
||||
Optional!TransactionTag findById(ulong id) {
|
||||
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() {
|
||||
string[] findAllByTransactionId(ulong transactionId) {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT * FROM transaction_tag ORDER BY name ASC",
|
||||
&parseTag
|
||||
"SELECT tag FROM transaction_tag WHERE transaction_id = ? ORDER BY tag",
|
||||
r => r.peek!string(0),
|
||||
transactionId
|
||||
);
|
||||
}
|
||||
|
||||
TransactionTag insert(string name) {
|
||||
auto existingTag = findByName(name);
|
||||
if (existingTag) {
|
||||
return existingTag.value;
|
||||
void updateTags(ulong transactionId, string[] tags) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"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) {
|
||||
util.sqlite.update(
|
||||
string[] findAll() {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"DELETE FROM transaction_tag WHERE id = ?",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
private static TransactionTag parseTag(Row row) {
|
||||
return TransactionTag(
|
||||
row.peek!ulong(0),
|
||||
row.peek!string(1)
|
||||
"SELECT DISTINCT tag FROM transaction_tag ORDER BY tag",
|
||||
r => r.peek!string(0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -189,19 +178,65 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
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!
|
||||
import std.array;
|
||||
const string rootQuery = "SELECT * FROM " ~ TABLE_NAME;
|
||||
const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME;
|
||||
auto sqlBuilder = appender!string;
|
||||
sqlBuilder ~= rootQuery;
|
||||
sqlBuilder ~= BASE_QUERY;
|
||||
sqlBuilder ~= " ";
|
||||
sqlBuilder ~= pr.toSql();
|
||||
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);
|
||||
return Page!(Transaction).of(results, pr, totalCount);
|
||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||
}
|
||||
|
||||
Optional!Transaction findById(ulong id) {
|
||||
|
|
|
@ -19,11 +19,6 @@ struct TransactionCategory {
|
|||
immutable string color;
|
||||
}
|
||||
|
||||
struct TransactionTag {
|
||||
immutable ulong id;
|
||||
immutable string name;
|
||||
}
|
||||
|
||||
struct Transaction {
|
||||
immutable ulong id;
|
||||
/// The time at which the transaction happened.
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
module transaction.service;
|
||||
|
||||
import handy_http_primitives : Optional;
|
||||
import handy_http_primitives;
|
||||
import std.datetime;
|
||||
|
||||
import transaction.api;
|
||||
import transaction.model;
|
||||
import transaction.data;
|
||||
import profile.data;
|
||||
|
@ -10,6 +11,14 @@ import account.model;
|
|||
import util.money;
|
||||
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(
|
||||
ProfileDataSource ds,
|
||||
SysTime timestamp,
|
||||
|
@ -21,43 +30,80 @@ void addTransaction(
|
|||
Optional!ulong categoryId,
|
||||
Optional!ulong creditedAccountId,
|
||||
Optional!ulong debitedAccountId,
|
||||
TransactionLineItem[] lineItems
|
||||
// TODO: Add attachments and tags!
|
||||
TransactionLineItem[] lineItems,
|
||||
string[] tags
|
||||
) {
|
||||
if (creditedAccountId.isNull && debitedAccountId.isNull) {
|
||||
throw new Exception("At least one account must be linked to a transaction.");
|
||||
}
|
||||
ds.doTransaction(() {
|
||||
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
|
||||
auto txRepo = ds.getTransactionRepository();
|
||||
Transaction tx = txRepo.insert(
|
||||
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
|
||||
auto txRepo = ds.getTransactionRepository();
|
||||
Transaction tx = txRepo.insert(
|
||||
timestamp,
|
||||
addedAt,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
vendorId,
|
||||
categoryId
|
||||
);
|
||||
if (creditedAccountId) {
|
||||
journalEntryRepo.insert(
|
||||
timestamp,
|
||||
addedAt,
|
||||
creditedAccountId.value,
|
||||
tx.id,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
vendorId,
|
||||
categoryId
|
||||
AccountJournalEntryType.CREDIT,
|
||||
currency
|
||||
);
|
||||
if (creditedAccountId) {
|
||||
journalEntryRepo.insert(
|
||||
timestamp,
|
||||
creditedAccountId.value,
|
||||
tx.id,
|
||||
amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
currency
|
||||
);
|
||||
}
|
||||
if (debitedAccountId) {
|
||||
journalEntryRepo.insert(
|
||||
timestamp,
|
||||
debitedAccountId.value,
|
||||
tx.id,
|
||||
amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
currency
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (debitedAccountId) {
|
||||
journalEntryRepo.insert(
|
||||
timestamp,
|
||||
debitedAccountId.value,
|
||||
tx.id,
|
||||
amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
currency
|
||||
);
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
ds.getTransactionTagRepository().updateTags(tx.id, tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Vendors Services
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -25,3 +25,19 @@ auto serializeOptional(T)(Optional!T 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 ~ "\".");
|
||||
}
|
||||
|
|
|
@ -8,13 +8,15 @@ import std.traits : isSomeString, EnumMembers;
|
|||
*/
|
||||
struct Currency {
|
||||
/// 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.
|
||||
immutable ubyte fractionalDigits;
|
||||
ubyte fractionalDigits;
|
||||
/// 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) {
|
||||
throw new Exception("Invalid currency code: " ~ code);
|
||||
}
|
||||
|
@ -27,15 +29,15 @@ struct Currency {
|
|||
|
||||
/// An enumeration of all available currencies.
|
||||
enum Currencies : Currency {
|
||||
AUD = Currency("AUD", 2, 36),
|
||||
USD = Currency("USD", 2, 840),
|
||||
CAD = Currency("CAD", 2, 124),
|
||||
GBP = Currency("GBP", 2, 826),
|
||||
EUR = Currency("EUR", 2, 978),
|
||||
CHF = Currency("CHF", 2, 756),
|
||||
ZAR = Currency("ZAR", 2, 710),
|
||||
JPY = Currency("JPY", 0, 392),
|
||||
INR = Currency("INR", 2, 356)
|
||||
AUD = Currency("AUD", 2, 36, "$"),
|
||||
USD = Currency("USD", 2, 840, "$"),
|
||||
CAD = Currency("CAD", 2, 124, "$"),
|
||||
GBP = Currency("GBP", 2, 826, "£"),
|
||||
EUR = Currency("EUR", 2, 978, "€"),
|
||||
CHF = Currency("CHF", 2, 756, "Fr"),
|
||||
ZAR = Currency("ZAR", 2, 710, "R"),
|
||||
JPY = Currency("JPY", 0, 392, "¥"),
|
||||
INR = Currency("INR", 2, 356, "₹")
|
||||
}
|
||||
|
||||
immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies];
|
||||
|
|
|
@ -51,44 +51,43 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
|||
infoF!" Generating random profile %s."(profileName);
|
||||
Profile profile = profileRepo.createProfile(profileName);
|
||||
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);
|
||||
for (int i = 0; i < accountCount; i++) {
|
||||
generateRandomAccount(i, ds);
|
||||
}
|
||||
const int accountCount = uniform(3, 10);
|
||||
for (int i = 0; i < accountCount; i++) {
|
||||
generateRandomAccount(i, ds, preferredCurrency);
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
auto tagRepo = ds.getTransactionTagRepository();
|
||||
const int tagCount = uniform(5, 30);
|
||||
for (int i = 0; i < tagCount; i++) {
|
||||
tagRepo.insert("test-tag-" ~ to!string(i));
|
||||
}
|
||||
infoF!" Generated %d random tags."(tagCount);
|
||||
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 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);
|
||||
generateRandomTransactions(ds);
|
||||
});
|
||||
}
|
||||
|
||||
void generateRandomAccount(int idx, ProfileDataSource ds) {
|
||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
string idxStr = idx.to!string;
|
||||
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
|
||||
string name = "Test Account " ~ idxStr;
|
||||
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().";
|
||||
Account account = accountRepo.insert(
|
||||
type,
|
||||
|
@ -97,16 +96,13 @@ void generateRandomAccount(int idx, ProfileDataSource ds) {
|
|||
currency,
|
||||
description
|
||||
);
|
||||
infoF!" Generated random account: %s, #%s"(name, numberSuffix);
|
||||
infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code);
|
||||
}
|
||||
|
||||
void generateRandomTransactions(ProfileDataSource ds) {
|
||||
const bool hasVendor = uniform01() > 0.3;
|
||||
const bool hasCategory = uniform01() > 0.2;
|
||||
const TransactionVendor[] vendors = ds.getTransactionVendorRepository.findAll();
|
||||
const TransactionCategory[] categories = ds.getTransactionCategoryRepository()
|
||||
.findAllByParentId(Optional!ulong.empty);
|
||||
const TransactionTag[] tags = ds.getTransactionTagRepository().findAll();
|
||||
const Account[] accounts = ds.getAccountRepository().findAll();
|
||||
|
||||
SysTime now = Clock.currTime(UTC());
|
||||
|
@ -114,32 +110,44 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
|
||||
for (int i = 0; i < 100; i++) {
|
||||
Optional!ulong vendorId;
|
||||
if (hasVendor) {
|
||||
if (uniform01() < 0.7) {
|
||||
vendorId = Optional!ulong.of(choice(vendors).id);
|
||||
}
|
||||
|
||||
Optional!ulong categoryId;
|
||||
if (hasCategory) {
|
||||
if (uniform01() < 0.8) {
|
||||
categoryId = Optional!ulong.of(choice(categories).id);
|
||||
}
|
||||
|
||||
// Randomly choose an account to credit / debit the transaction to.
|
||||
Optional!ulong creditedAccountId;
|
||||
Optional!ulong debitedAccountId;
|
||||
Account primaryAccount = choice(accounts);
|
||||
Optional!ulong secondaryAccount;
|
||||
Optional!ulong secondaryAccountId;
|
||||
if (uniform01() < 0.25) {
|
||||
foreach (acc; accounts) {
|
||||
if (acc.id != primaryAccount.id && acc.currency == primaryAccount.currency) {
|
||||
secondaryAccount.value = acc.id;
|
||||
secondaryAccountId = Optional!ulong.of(acc.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uniform01() > 0.5) {
|
||||
if (uniform01() < 0.5) {
|
||||
creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccount) debitedAccountId = secondaryAccount;
|
||||
if (secondaryAccountId) debitedAccountId = secondaryAccountId;
|
||||
} else {
|
||||
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);
|
||||
|
||||
addTransaction(
|
||||
|
@ -153,7 +161,8 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
categoryId,
|
||||
creditedAccountId,
|
||||
debitedAccountId,
|
||||
[]
|
||||
[],
|
||||
tags
|
||||
);
|
||||
infoF!" Generated transaction %d"(i);
|
||||
timestamp -= seconds(uniform(10, 1_000_000));
|
||||
|
|
|
@ -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
|
|
@ -57,8 +57,9 @@ CREATE TABLE transaction_category (
|
|||
);
|
||||
|
||||
CREATE TABLE transaction_tag (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
transaction_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE "transaction" (
|
||||
|
@ -77,6 +78,7 @@ CREATE TABLE "transaction" (
|
|||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
|
||||
|
||||
CREATE TABLE transaction_attachment (
|
||||
transaction_id INTEGER NOT NULL,
|
||||
|
@ -90,18 +92,6 @@ CREATE TABLE transaction_attachment (
|
|||
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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
transaction_id INTEGER NOT NULL,
|
|
@ -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))
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { ApiClient } from './base'
|
||||
import type { Currency } from './data'
|
||||
import { type Page, type PageRequest } from './pagination'
|
||||
import type { Profile } from './profile'
|
||||
|
||||
|
@ -24,6 +25,37 @@ export interface Transaction {
|
|||
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 {
|
||||
readonly path: string
|
||||
|
||||
|
@ -54,7 +86,7 @@ export class TransactionApiClient extends ApiClient {
|
|||
|
||||
async getTransactions(
|
||||
paginationOptions: PageRequest | undefined = undefined,
|
||||
): Promise<Page<Transaction>> {
|
||||
): Promise<Page<TransactionsListItem>> {
|
||||
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,12 @@ async function doLogin() {
|
|||
disableForm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generateSampleData() {
|
||||
fetch('http://localhost:8080/api/sample-data', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
|
@ -52,5 +58,9 @@ async function doLogin() {
|
|||
<button type="submit" :disabled="disableForm">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<button type="button" @click="generateSampleData()">Generate Sample Data</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { formatMoney } from '@/api/data';
|
||||
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 PaginationControls from '@/components/PaginationControls.vue';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
|
||||
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 () => {
|
||||
await fetchPage(transactions.value.pageRequest)
|
||||
|
@ -33,6 +34,8 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
<th>Amount</th>
|
||||
<th>Currency</th>
|
||||
<th>Description</th>
|
||||
<th>Credited Account</th>
|
||||
<th>Debited Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -40,9 +43,11 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
<td>
|
||||
{{ new Date(tx.timestamp).toLocaleDateString() }}
|
||||
</td>
|
||||
<td>{{ tx.amount }}</td>
|
||||
<td>{{ tx.currency }}</td>
|
||||
<td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
|
||||
<td>{{ tx.currency.code }}</td>
|
||||
<td>{{ tx.description }}</td>
|
||||
<td>{{ tx.creditedAccount?.name }}</td>
|
||||
<td>{{ tx.debitedAccount?.name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
Loading…
Reference in New Issue