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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
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;
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ~ "\".");
|
||||||
|
}
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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 (
|
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,
|
|
@ -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 { 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue