Refactor entire process of adding transactions.
This commit is contained in:
parent
277a6dc591
commit
0b67bb605b
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"asdf": "0.7.17",
|
"asdf": {
|
||||||
|
"repository": "git+https://github.com/libmir/asdf.git",
|
||||||
|
"version": "7f77a3031975816b604a513ddeefbc9e514f236c"
|
||||||
|
},
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"dxml": "0.4.4",
|
"dxml": "0.4.4",
|
||||||
"handy-http-data": "1.3.0",
|
"handy-http-data": "1.3.0",
|
||||||
|
|
|
@ -10,6 +10,7 @@ import std.datetime : SysTime;
|
||||||
|
|
||||||
interface AccountRepository {
|
interface AccountRepository {
|
||||||
Optional!Account findById(ulong id);
|
Optional!Account findById(ulong id);
|
||||||
|
bool existsById(ulong id);
|
||||||
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description);
|
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description);
|
||||||
void setArchived(ulong id, bool archived);
|
void setArchived(ulong id, bool archived);
|
||||||
Account update(ulong id, in Account newData);
|
Account update(ulong id, in Account newData);
|
||||||
|
|
|
@ -21,6 +21,10 @@ class SqliteAccountRepository : AccountRepository {
|
||||||
return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id);
|
return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool existsById(ulong id) {
|
||||||
|
return util.sqlite.exists(db, "SELECT id FROM account WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
|
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
|
|
|
@ -145,7 +145,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
|
|
||||||
const SCHEMA = import("sql/schema.sql");
|
const SCHEMA = import("sql/schema.sql");
|
||||||
private const string dbPath;
|
private const string dbPath;
|
||||||
private Database db;
|
Database db;
|
||||||
|
|
||||||
this(string path) {
|
this(string path) {
|
||||||
this.dbPath = path;
|
this.dbPath = path;
|
||||||
|
|
|
@ -9,6 +9,7 @@ import std.typecons;
|
||||||
import transaction.model;
|
import transaction.model;
|
||||||
import transaction.data;
|
import transaction.data;
|
||||||
import transaction.service;
|
import transaction.service;
|
||||||
|
import transaction.dto;
|
||||||
import profile.data;
|
import profile.data;
|
||||||
import profile.service;
|
import profile.service;
|
||||||
import account.api;
|
import account.api;
|
||||||
|
@ -20,47 +21,6 @@ import util.data;
|
||||||
|
|
||||||
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
||||||
|
|
||||||
struct TransactionsListItemAccount {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
string type;
|
|
||||||
string numberSuffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TransactionsListItemVendor {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TransactionsListItemCategory {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
string color;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The transaction data provided when a list of transactions is requested.
|
|
||||||
struct TransactionsListItem {
|
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
|
|
||||||
ulong id;
|
|
||||||
string timestamp;
|
|
||||||
string addedAt;
|
|
||||||
ulong amount;
|
|
||||||
Currency currency;
|
|
||||||
string description;
|
|
||||||
|
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!TransactionsListItemVendor vendor;
|
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!TransactionsListItemCategory category;
|
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!TransactionsListItemAccount creditedAccount;
|
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!TransactionsListItemAccount debitedAccount;
|
|
||||||
|
|
||||||
string[] tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
||||||
|
@ -69,33 +29,17 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
// TODO
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
}
|
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
|
||||||
|
import asdf : serializeToJson;
|
||||||
struct AddTransactionPayloadLineItem {
|
string jsonStr = serializeToJson(txn);
|
||||||
long valuePerItem;
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
ulong quantity;
|
|
||||||
string description;
|
|
||||||
Nullable!ulong categoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AddTransactionPayload {
|
|
||||||
string timestamp;
|
|
||||||
ulong amount;
|
|
||||||
string currencyCode;
|
|
||||||
string description;
|
|
||||||
Nullable!ulong vendorId;
|
|
||||||
Nullable!ulong categoryId;
|
|
||||||
Nullable!ulong creditedAccountId;
|
|
||||||
Nullable!ulong debitedAccountId;
|
|
||||||
string[] tags;
|
|
||||||
AddTransactionPayloadLineItem[] lineItems;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||||
addTransaction2(ds, payload);
|
addTransaction(ds, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import handy_http_primitives : Optional;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
|
||||||
import transaction.model;
|
import transaction.model;
|
||||||
import transaction.api : TransactionsListItem;
|
import transaction.dto;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ interface TransactionVendorRepository {
|
||||||
Optional!TransactionVendor findById(ulong id);
|
Optional!TransactionVendor findById(ulong id);
|
||||||
TransactionVendor[] findAll();
|
TransactionVendor[] findAll();
|
||||||
bool existsByName(string name);
|
bool existsByName(string name);
|
||||||
|
bool existsById(ulong id);
|
||||||
TransactionVendor insert(string name, string description);
|
TransactionVendor insert(string name, string description);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
TransactionVendor updateById(ulong id, string name, string description);
|
TransactionVendor updateById(ulong id, string name, string description);
|
||||||
|
@ -19,6 +20,7 @@ interface TransactionVendorRepository {
|
||||||
|
|
||||||
interface TransactionCategoryRepository {
|
interface TransactionCategoryRepository {
|
||||||
Optional!TransactionCategory findById(ulong id);
|
Optional!TransactionCategory findById(ulong id);
|
||||||
|
bool existsById(ulong id);
|
||||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
|
@ -27,21 +29,13 @@ interface TransactionCategoryRepository {
|
||||||
|
|
||||||
interface TransactionTagRepository {
|
interface TransactionTagRepository {
|
||||||
string[] findAllByTransactionId(ulong transactionId);
|
string[] findAllByTransactionId(ulong transactionId);
|
||||||
void updateTags(ulong transactionId, string[] tags);
|
void updateTags(ulong transactionId, in string[] tags);
|
||||||
string[] findAll();
|
string[] findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TransactionRepository {
|
interface TransactionRepository {
|
||||||
Page!TransactionsListItem findAll(PageRequest pr);
|
Page!TransactionsListItem findAll(PageRequest pr);
|
||||||
Optional!Transaction findById(ulong id);
|
Optional!TransactionDetail findById(ulong id);
|
||||||
Transaction insert(
|
TransactionDetail insert(in AddTransactionPayload data);
|
||||||
SysTime timestamp,
|
|
||||||
SysTime addedAt,
|
|
||||||
ulong amount,
|
|
||||||
Currency currency,
|
|
||||||
string description,
|
|
||||||
Optional!ulong vendorId,
|
|
||||||
Optional!ulong categoryId
|
|
||||||
);
|
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import d2sqlite3;
|
||||||
|
|
||||||
import transaction.model;
|
import transaction.model;
|
||||||
import transaction.data;
|
import transaction.data;
|
||||||
import transaction.api;
|
import transaction.dto;
|
||||||
import util.sqlite;
|
import util.sqlite;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
@ -35,6 +35,10 @@ class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||||
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
|
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool existsById(ulong id) {
|
||||||
|
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
TransactionVendor insert(string name, string description) {
|
TransactionVendor insert(string name, string description) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
|
@ -77,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
|
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool existsById(ulong id) {
|
||||||
|
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
|
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
return util.sqlite.findAll(
|
return util.sqlite.findAll(
|
||||||
|
@ -147,7 +155,7 @@ class SqliteTransactionTagRepository : TransactionTagRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTags(ulong transactionId, string[] tags) {
|
void updateTags(ulong transactionId, in string[] tags) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"DELETE FROM transaction_tag WHERE transaction_id = ?",
|
"DELETE FROM transaction_tag WHERE transaction_id = ?",
|
||||||
|
@ -200,15 +208,15 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
||||||
if (!vendorId.isNull) {
|
if (!vendorId.isNull) {
|
||||||
string vendorName = row.peek!string(7);
|
string vendorName = row.peek!string(7);
|
||||||
item.vendor = Optional!TransactionsListItemVendor.of(
|
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||||
TransactionsListItemVendor(vendorId.get, vendorName));
|
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
||||||
}
|
}
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
||||||
if (!categoryId.isNull) {
|
if (!categoryId.isNull) {
|
||||||
string categoryName = row.peek!string(9);
|
string categoryName = row.peek!string(9);
|
||||||
string categoryColor = row.peek!string(10);
|
string categoryColor = row.peek!string(10);
|
||||||
item.category = Optional!TransactionsListItemCategory.of(
|
item.category = Optional!(TransactionsListItem.Category).of(
|
||||||
TransactionsListItemCategory(categoryId.get, categoryName, categoryColor));
|
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||||
}
|
}
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
||||||
if (!creditedAccountId.isNull) {
|
if (!creditedAccountId.isNull) {
|
||||||
|
@ -216,8 +224,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
string name = row.peek!string(12);
|
string name = row.peek!string(12);
|
||||||
string type = row.peek!string(13);
|
string type = row.peek!string(13);
|
||||||
string suffix = row.peek!string(14);
|
string suffix = row.peek!string(14);
|
||||||
item.creditedAccount = Optional!TransactionsListItemAccount.of(
|
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||||
TransactionsListItemAccount(id, name, type, suffix));
|
TransactionsListItem.Account(id, name, type, suffix));
|
||||||
}
|
}
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
||||||
if (!debitedAccountId.isNull) {
|
if (!debitedAccountId.isNull) {
|
||||||
|
@ -225,13 +233,15 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
string name = row.peek!string(16);
|
string name = row.peek!string(16);
|
||||||
string type = row.peek!string(17);
|
string type = row.peek!string(17);
|
||||||
string suffix = row.peek!string(18);
|
string suffix = row.peek!string(18);
|
||||||
item.debitedAccount = Optional!TransactionsListItemAccount.of(
|
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||||
TransactionsListItemAccount(id, name, type, suffix));
|
TransactionsListItem.Account(id, name, type, suffix));
|
||||||
}
|
}
|
||||||
string tagsStr = row.peek!string(19);
|
string tagsStr = row.peek!string(19);
|
||||||
if (tagsStr.length > 0) {
|
if (tagsStr !is null && tagsStr.length > 0) {
|
||||||
import std.string : split;
|
import std.string : split;
|
||||||
item.tags = tagsStr.split(",");
|
item.tags = tagsStr.split(",");
|
||||||
|
} else {
|
||||||
|
item.tags = [];
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
@ -239,34 +249,125 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional!Transaction findById(ulong id) {
|
Optional!TransactionDetail findById(ulong id) {
|
||||||
return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id);
|
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||||
|
db,
|
||||||
|
import("sql/get_transaction.sql"),
|
||||||
|
(row) {
|
||||||
|
TransactionDetail item;
|
||||||
|
item.id = row.peek!ulong(0);
|
||||||
|
item.timestamp = row.peek!string(1);
|
||||||
|
item.addedAt = row.peek!string(2);
|
||||||
|
item.amount = row.peek!ulong(3);
|
||||||
|
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||||
|
item.description = row.peek!string(5);
|
||||||
|
|
||||||
|
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
||||||
|
if (!vendorId.isNull) {
|
||||||
|
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
||||||
|
TransactionDetail.Vendor(
|
||||||
|
vendorId.get,
|
||||||
|
row.peek!string(7),
|
||||||
|
row.peek!string(8)
|
||||||
|
)).toNullable;
|
||||||
|
}
|
||||||
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
|
||||||
|
if (!categoryId.isNull) {
|
||||||
|
item.category = Optional!(TransactionDetail.Category).of(
|
||||||
|
TransactionDetail.Category(
|
||||||
|
categoryId.get,
|
||||||
|
row.peek!(Nullable!ulong)(10),
|
||||||
|
row.peek!string(11),
|
||||||
|
row.peek!string(12),
|
||||||
|
row.peek!string(13)
|
||||||
|
)).toNullable;
|
||||||
|
}
|
||||||
|
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(14);
|
||||||
|
if (!creditedAccountId.isNull) {
|
||||||
|
item.creditedAccount = Optional!(TransactionDetail.Account).of(
|
||||||
|
TransactionDetail.Account(
|
||||||
|
creditedAccountId.get,
|
||||||
|
row.peek!string(15),
|
||||||
|
row.peek!string(16),
|
||||||
|
row.peek!string(17)
|
||||||
|
)).toNullable;
|
||||||
|
}
|
||||||
|
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(18);
|
||||||
|
if (!debitedAccountId.isNull) {
|
||||||
|
item.debitedAccount = Optional!(TransactionDetail.Account).of(
|
||||||
|
TransactionDetail.Account(
|
||||||
|
debitedAccountId.get,
|
||||||
|
row.peek!string(19),
|
||||||
|
row.peek!string(20),
|
||||||
|
row.peek!string(21)
|
||||||
|
)).toNullable;
|
||||||
|
}
|
||||||
|
string tagsStr = row.peek!string(22);
|
||||||
|
if (tagsStr !is null && tagsStr.length > 0) {
|
||||||
|
import std.string : split;
|
||||||
|
item.tags = tagsStr.split(",");
|
||||||
|
} else {
|
||||||
|
item.tags = [];
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (item.isNull) return item;
|
||||||
|
item.value.lineItems = util.sqlite.findAll(
|
||||||
|
db,
|
||||||
|
import("sql/get_line_items.sql"),
|
||||||
|
(row) {
|
||||||
|
TransactionDetail.LineItem li;
|
||||||
|
li.idx = row.peek!uint(0);
|
||||||
|
li.valuePerItem = row.peek!long(1);
|
||||||
|
li.quantity = row.peek!ulong(2);
|
||||||
|
li.description = row.peek!string(3);
|
||||||
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4);
|
||||||
|
if (!categoryId.isNull) {
|
||||||
|
li.category = Optional!(TransactionDetail.Category).of(
|
||||||
|
TransactionDetail.Category(
|
||||||
|
categoryId.get,
|
||||||
|
row.peek!(Nullable!ulong)(5),
|
||||||
|
row.peek!string(6),
|
||||||
|
row.peek!string(7),
|
||||||
|
row.peek!string(8)
|
||||||
|
)).toNullable;
|
||||||
|
}
|
||||||
|
return li;
|
||||||
|
},
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
Transaction insert(
|
TransactionDetail insert(in AddTransactionPayload data) {
|
||||||
SysTime timestamp,
|
|
||||||
SysTime addedAt,
|
|
||||||
ulong amount,
|
|
||||||
Currency currency,
|
|
||||||
string description,
|
|
||||||
Optional!ulong vendorId,
|
|
||||||
Optional!ulong categoryId
|
|
||||||
) {
|
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"INSERT INTO " ~ TABLE_NAME ~ "
|
import("sql/insert_transaction.sql"),
|
||||||
(timestamp, added_at, amount, currency, description, vendor_id, category_id)
|
data.timestamp,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
Clock.currTime(UTC()),
|
||||||
timestamp.toISOExtString(),
|
data.amount,
|
||||||
addedAt.toISOExtString(),
|
data.currencyCode,
|
||||||
amount,
|
data.description,
|
||||||
currency.code,
|
data.vendorId,
|
||||||
description,
|
data.categoryId
|
||||||
toNullable(vendorId),
|
|
||||||
toNullable(categoryId)
|
|
||||||
);
|
);
|
||||||
ulong id = db.lastInsertRowid();
|
ulong transactionId = db.lastInsertRowid();
|
||||||
return findById(id).orElseThrow();
|
// Insert line items:
|
||||||
|
foreach (size_t idx, lineItem; data.lineItems) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
import("sql/insert_line_item.sql"),
|
||||||
|
transactionId,
|
||||||
|
idx,
|
||||||
|
lineItem.valuePerItem,
|
||||||
|
lineItem.quantity,
|
||||||
|
lineItem.description,
|
||||||
|
lineItem.categoryId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return findById(transactionId).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteById(ulong id) {
|
void deleteById(ulong id) {
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
module transaction.dto;
|
||||||
|
|
||||||
|
import handy_http_primitives : Optional;
|
||||||
|
import asdf : serdeTransformOut;
|
||||||
|
import std.typecons;
|
||||||
|
|
||||||
|
import util.data;
|
||||||
|
import util.money;
|
||||||
|
|
||||||
|
/// The transaction data provided when a list of transactions is requested.
|
||||||
|
struct TransactionsListItem {
|
||||||
|
ulong id;
|
||||||
|
string timestamp;
|
||||||
|
string addedAt;
|
||||||
|
ulong amount;
|
||||||
|
Currency currency;
|
||||||
|
string description;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!Vendor vendor;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!Category category;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!Account creditedAccount;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!Account debitedAccount;
|
||||||
|
string[] tags;
|
||||||
|
|
||||||
|
static struct Account {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
string type;
|
||||||
|
string numberSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct Vendor {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct Category {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
string color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction data provided when fetching a single transaction.
|
||||||
|
struct TransactionDetail {
|
||||||
|
ulong id;
|
||||||
|
string timestamp;
|
||||||
|
string addedAt;
|
||||||
|
ulong amount;
|
||||||
|
Currency currency;
|
||||||
|
string description;
|
||||||
|
Nullable!Vendor vendor;
|
||||||
|
Nullable!Category category;
|
||||||
|
Nullable!Account creditedAccount;
|
||||||
|
Nullable!Account debitedAccount;
|
||||||
|
string[] tags;
|
||||||
|
LineItem[] lineItems;
|
||||||
|
|
||||||
|
static struct Vendor {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
string description;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct Category {
|
||||||
|
ulong id;
|
||||||
|
Nullable!ulong parentId;
|
||||||
|
string name;
|
||||||
|
string description;
|
||||||
|
string color;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct LineItem {
|
||||||
|
uint idx;
|
||||||
|
long valuePerItem;
|
||||||
|
ulong quantity;
|
||||||
|
string description;
|
||||||
|
Nullable!Category category;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct Account {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
string type;
|
||||||
|
string numberSuffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data provided when a new transaction is added by a user.
|
||||||
|
struct AddTransactionPayload {
|
||||||
|
string timestamp;
|
||||||
|
ulong amount;
|
||||||
|
string currencyCode;
|
||||||
|
string description;
|
||||||
|
Nullable!ulong vendorId;
|
||||||
|
Nullable!ulong categoryId;
|
||||||
|
Nullable!ulong creditedAccountId;
|
||||||
|
Nullable!ulong debitedAccountId;
|
||||||
|
string[] tags;
|
||||||
|
LineItem[] lineItems;
|
||||||
|
|
||||||
|
static struct LineItem {
|
||||||
|
long valuePerItem;
|
||||||
|
ulong quantity;
|
||||||
|
string description;
|
||||||
|
Nullable!ulong categoryId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,11 +33,10 @@ struct Transaction {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionLineItem {
|
struct TransactionLineItem {
|
||||||
immutable ulong id;
|
|
||||||
immutable ulong transactionId;
|
immutable ulong transactionId;
|
||||||
|
immutable uint idx;
|
||||||
immutable long valuePerItem;
|
immutable long valuePerItem;
|
||||||
immutable ulong quantity;
|
immutable ulong quantity;
|
||||||
immutable uint idx;
|
|
||||||
immutable string description;
|
immutable string description;
|
||||||
immutable Optional!ulong categoryId;
|
immutable Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,10 @@ import std.datetime;
|
||||||
import transaction.api;
|
import transaction.api;
|
||||||
import transaction.model;
|
import transaction.model;
|
||||||
import transaction.data;
|
import transaction.data;
|
||||||
|
import transaction.dto;
|
||||||
import profile.data;
|
import profile.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
|
import account.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
|
||||||
|
@ -16,64 +18,109 @@ import util.pagination;
|
||||||
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
||||||
Page!TransactionsListItem page = ds.getTransactionRepository()
|
Page!TransactionsListItem page = ds.getTransactionRepository()
|
||||||
.findAll(pageRequest);
|
.findAll(pageRequest);
|
||||||
return page; // Return an empty page for now!
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addTransaction2(ProfileDataSource ds, in AddTransactionPayload payload) {
|
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
// TODO
|
return ds.getTransactionRepository().findById(transactionId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
void addTransaction(
|
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
|
||||||
ProfileDataSource ds,
|
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||||
SysTime timestamp,
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
SysTime addedAt,
|
AccountRepository accountRepo = ds.getAccountRepository();
|
||||||
ulong amount,
|
|
||||||
Currency currency,
|
// Validate transaction details:
|
||||||
string description,
|
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
||||||
Optional!ulong vendorId,
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
||||||
Optional!ulong categoryId,
|
|
||||||
Optional!ulong creditedAccountId,
|
|
||||||
Optional!ulong debitedAccountId,
|
|
||||||
TransactionLineItem[] lineItems,
|
|
||||||
string[] tags
|
|
||||||
) {
|
|
||||||
if (creditedAccountId.isNull && debitedAccountId.isNull) {
|
|
||||||
throw new Exception("At least one account must be linked to a transaction.");
|
|
||||||
}
|
}
|
||||||
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
|
if (
|
||||||
auto txRepo = ds.getTransactionRepository();
|
!payload.creditedAccountId.isNull &&
|
||||||
Transaction tx = txRepo.insert(
|
!payload.debitedAccountId.isNull &&
|
||||||
timestamp,
|
payload.creditedAccountId.get == payload.debitedAccountId.get
|
||||||
addedAt,
|
) {
|
||||||
amount,
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
|
||||||
currency,
|
}
|
||||||
description,
|
if (payload.amount == 0) {
|
||||||
vendorId,
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
|
||||||
categoryId
|
}
|
||||||
|
SysTime now = Clock.currTime(UTC());
|
||||||
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
if (timestamp > now) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
||||||
|
}
|
||||||
|
if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
|
||||||
|
}
|
||||||
|
if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
|
||||||
|
}
|
||||||
|
if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
|
||||||
|
}
|
||||||
|
if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
|
||||||
|
}
|
||||||
|
foreach (tag; payload.tags) {
|
||||||
|
import std.regex;
|
||||||
|
auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`);
|
||||||
|
if (!matchFirst(tag, r)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.lineItems.length > 0) {
|
||||||
|
long lineItemsTotal = 0;
|
||||||
|
foreach (lineItem; payload.lineItems) {
|
||||||
|
if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
|
||||||
|
}
|
||||||
|
if (lineItem.quantity == 0) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
|
||||||
|
}
|
||||||
|
for (ulong i = 0; i < lineItem.quantity; i++) {
|
||||||
|
lineItemsTotal += lineItem.valuePerItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lineItemsTotal != payload.amount) {
|
||||||
|
throw new HttpStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Total of all line items doesn't equal the transaction's total."
|
||||||
);
|
);
|
||||||
if (creditedAccountId) {
|
}
|
||||||
journalEntryRepo.insert(
|
}
|
||||||
|
|
||||||
|
// Add the transaction:
|
||||||
|
ulong txnId;
|
||||||
|
ds.doTransaction(() {
|
||||||
|
TransactionRepository txRepo = ds.getTransactionRepository();
|
||||||
|
TransactionDetail txn = txRepo.insert(payload);
|
||||||
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
|
if (!payload.creditedAccountId.isNull) {
|
||||||
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
creditedAccountId.value,
|
payload.creditedAccountId.get,
|
||||||
tx.id,
|
txn.id,
|
||||||
amount,
|
txn.amount,
|
||||||
AccountJournalEntryType.CREDIT,
|
AccountJournalEntryType.CREDIT,
|
||||||
currency
|
txn.currency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (debitedAccountId) {
|
if (!payload.debitedAccountId.isNull) {
|
||||||
journalEntryRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
debitedAccountId.value,
|
payload.debitedAccountId.get,
|
||||||
tx.id,
|
txn.id,
|
||||||
amount,
|
txn.amount,
|
||||||
AccountJournalEntryType.DEBIT,
|
AccountJournalEntryType.DEBIT,
|
||||||
currency
|
txn.currency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (tags.length > 0) {
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||||
ds.getTransactionTagRepository().updateTags(tx.id, tags);
|
tagRepo.updateTags(txn.id, payload.tags);
|
||||||
}
|
txnId = txn.id;
|
||||||
|
});
|
||||||
|
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendors Services
|
// Vendors Services
|
||||||
|
|
|
@ -7,12 +7,15 @@ import auth;
|
||||||
import profile;
|
import profile;
|
||||||
import account;
|
import account;
|
||||||
import transaction;
|
import transaction;
|
||||||
|
import transaction.dto;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
import util.data;
|
||||||
|
|
||||||
import std.random;
|
import std.random;
|
||||||
import std.conv;
|
import std.conv;
|
||||||
import std.array;
|
import std.array;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
import std.typecons;
|
||||||
|
|
||||||
void generateSampleData() {
|
void generateSampleData() {
|
||||||
UserRepository userRepo = new FileSystemUserRepository;
|
UserRepository userRepo = new FileSystemUserRepository;
|
||||||
|
@ -51,7 +54,7 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
||||||
infoF!" Generating random profile %s."(profileName);
|
infoF!" Generating random profile %s."(profileName);
|
||||||
Profile profile = profileRepo.createProfile(profileName);
|
Profile profile = profileRepo.createProfile(profileName);
|
||||||
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||||
ds.doTransaction(() {
|
|
||||||
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
||||||
Currency preferredCurrency = choice(ALL_CURRENCIES);
|
Currency preferredCurrency = choice(ALL_CURRENCIES);
|
||||||
|
|
||||||
|
@ -75,7 +78,6 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
||||||
infoF!" Generated %d random categories."(categoryCount);
|
infoF!" Generated %d random categories."(categoryCount);
|
||||||
|
|
||||||
generateRandomTransactions(ds);
|
generateRandomTransactions(ds);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||||
|
@ -105,24 +107,21 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
.findAllByParentId(Optional!ulong.empty);
|
.findAllByParentId(Optional!ulong.empty);
|
||||||
const Account[] accounts = ds.getAccountRepository().findAll();
|
const Account[] accounts = ds.getAccountRepository().findAll();
|
||||||
|
|
||||||
SysTime now = Clock.currTime(UTC());
|
|
||||||
SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
|
SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
|
||||||
|
|
||||||
for (int i = 0; i < 100; i++) {
|
for (int i = 0; i < 100; i++) {
|
||||||
Optional!ulong vendorId;
|
AddTransactionPayload data;
|
||||||
|
data.timestamp = timestamp.toISOExtString();
|
||||||
if (uniform01() < 0.7) {
|
if (uniform01() < 0.7) {
|
||||||
vendorId = Optional!ulong.of(choice(vendors).id);
|
data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional!ulong categoryId;
|
|
||||||
if (uniform01() < 0.8) {
|
if (uniform01() < 0.8) {
|
||||||
categoryId = Optional!ulong.of(choice(categories).id);
|
data.categoryId = Optional!ulong.of(choice(categories).id).toNullable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly choose an account to credit / debit the transaction to.
|
// Randomly choose an account to credit / debit the transaction to.
|
||||||
Optional!ulong creditedAccountId;
|
|
||||||
Optional!ulong debitedAccountId;
|
|
||||||
Account primaryAccount = choice(accounts);
|
Account primaryAccount = choice(accounts);
|
||||||
|
data.currencyCode = primaryAccount.currency.code;
|
||||||
Optional!ulong secondaryAccountId;
|
Optional!ulong secondaryAccountId;
|
||||||
if (uniform01() < 0.25) {
|
if (uniform01() < 0.25) {
|
||||||
foreach (acc; accounts) {
|
foreach (acc; accounts) {
|
||||||
|
@ -133,11 +132,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uniform01() < 0.5) {
|
if (uniform01() < 0.5) {
|
||||||
creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||||
if (secondaryAccountId) debitedAccountId = secondaryAccountId;
|
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
|
||||||
} else {
|
} else {
|
||||||
debitedAccountId = Optional!ulong.of(primaryAccount.id);
|
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||||
if (secondaryAccountId) creditedAccountId = secondaryAccountId;
|
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly choose some tags to add.
|
// Randomly choose some tags to add.
|
||||||
|
@ -147,24 +146,40 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
tags ~= "tag-" ~ n.to!string;
|
tags ~= "tag-" ~ n.to!string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
data.tags = tags;
|
||||||
|
|
||||||
ulong value = uniform(0, 1_000_000);
|
data.amount = uniform(0, 1_000_000);
|
||||||
|
data.description = "This is a sample transaction which was generated as part of sample data.";
|
||||||
|
|
||||||
addTransaction(
|
// Generate random line items:
|
||||||
ds,
|
if (uniform01 < 0.5) {
|
||||||
timestamp,
|
long lineItemTotal = 0;
|
||||||
now,
|
foreach (n; 1..uniform(1, 20)) {
|
||||||
value,
|
AddTransactionPayload.LineItem item;
|
||||||
primaryAccount.currency,
|
item.valuePerItem = uniform(1, 10_000);
|
||||||
"Test transaction " ~ to!string(i),
|
item.quantity = uniform(1, 5);
|
||||||
vendorId,
|
lineItemTotal += item.quantity * item.valuePerItem;
|
||||||
categoryId,
|
item.description = "Sample item " ~ n.to!string;
|
||||||
creditedAccountId,
|
if (uniform01 < 0.5) {
|
||||||
debitedAccountId,
|
TransactionCategory category = choice(categories);
|
||||||
[],
|
item.categoryId = category.id;
|
||||||
tags
|
}
|
||||||
|
data.lineItems ~= item;
|
||||||
|
}
|
||||||
|
long diff = data.amount - lineItemTotal;
|
||||||
|
// Add one final line item that adds up to the transaction total.
|
||||||
|
if (diff != 0) {
|
||||||
|
data.lineItems ~= AddTransactionPayload.LineItem(
|
||||||
|
diff,
|
||||||
|
1,
|
||||||
|
"Last item which reconciles line items total with transaction amount.",
|
||||||
|
Nullable!ulong.init
|
||||||
);
|
);
|
||||||
infoF!" Generated transaction %d"(i);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto txn = addTransaction(ds, data);
|
||||||
|
infoF!" Generated transaction %d"(txn.id);
|
||||||
timestamp -= seconds(uniform(10, 1_000_000));
|
timestamp -= seconds(uniform(10, 1_000_000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
SELECT
|
||||||
|
i.idx,
|
||||||
|
i.value_per_item,
|
||||||
|
i.quantity,
|
||||||
|
i.description,
|
||||||
|
i.category_id,
|
||||||
|
category.parent_id,
|
||||||
|
category.name,
|
||||||
|
category.description,
|
||||||
|
category.color
|
||||||
|
FROM transaction_line_item i
|
||||||
|
LEFT JOIN transaction_category category
|
||||||
|
ON category.id = i.category_id
|
||||||
|
WHERE i.transaction_id = ?
|
||||||
|
ORDER BY idx;
|
|
@ -0,0 +1,46 @@
|
||||||
|
SELECT
|
||||||
|
txn.id AS id,
|
||||||
|
txn.timestamp AS timestamp,
|
||||||
|
txn.added_at AS added_at,
|
||||||
|
txn.amount AS amount,
|
||||||
|
txn.currency AS currency,
|
||||||
|
txn.description AS description,
|
||||||
|
|
||||||
|
txn.vendor_id AS vendor_id,
|
||||||
|
vendor.name AS vendor_name,
|
||||||
|
vendor.description AS vendor_description,
|
||||||
|
|
||||||
|
txn.category_id AS category_id,
|
||||||
|
category.parent_id AS category_parent_id,
|
||||||
|
category.name AS category_name,
|
||||||
|
category.description AS category_description,
|
||||||
|
category.color AS category_color,
|
||||||
|
|
||||||
|
account_credit.id AS credited_account_id,
|
||||||
|
account_credit.name AS credited_account_name,
|
||||||
|
account_credit.type AS credited_account_type,
|
||||||
|
account_credit.number_suffix AS credited_account_number_suffix,
|
||||||
|
|
||||||
|
account_debit.id AS debited_account_id,
|
||||||
|
account_debit.name AS debited_account_name,
|
||||||
|
account_debit.type AS debited_account_type,
|
||||||
|
account_debit.number_suffix AS debited_account_number_suffix,
|
||||||
|
|
||||||
|
GROUP_CONCAT(tag) AS tags
|
||||||
|
FROM
|
||||||
|
"transaction" txn
|
||||||
|
LEFT JOIN transaction_vendor vendor
|
||||||
|
ON vendor.id = txn.vendor_id
|
||||||
|
LEFT JOIN transaction_category category
|
||||||
|
ON category.id = txn.category_id
|
||||||
|
LEFT JOIN account_journal_entry j_credit
|
||||||
|
ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'
|
||||||
|
LEFT JOIN account account_credit
|
||||||
|
ON account_credit.id = j_credit.account_id
|
||||||
|
LEFT JOIN account_journal_entry j_debit
|
||||||
|
ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'
|
||||||
|
LEFT JOIN account account_debit
|
||||||
|
ON account_debit.id = j_debit.account_id
|
||||||
|
LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id
|
||||||
|
WHERE txn.id = ?
|
||||||
|
GROUP BY txn.id
|
|
@ -0,0 +1,8 @@
|
||||||
|
INSERT INTO transaction_line_item (
|
||||||
|
transaction_id,
|
||||||
|
idx,
|
||||||
|
value_per_item,
|
||||||
|
quantity,
|
||||||
|
description,
|
||||||
|
category_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
@ -0,0 +1,9 @@
|
||||||
|
INSERT INTO "transaction" (
|
||||||
|
timestamp,
|
||||||
|
added_at,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
description,
|
||||||
|
vendor_id,
|
||||||
|
category_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@ -93,13 +93,13 @@ CREATE TABLE transaction_attachment (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE transaction_line_item (
|
CREATE TABLE transaction_line_item (
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
transaction_id INTEGER NOT NULL,
|
transaction_id INTEGER NOT NULL,
|
||||||
|
idx INTEGER NOT NULL DEFAULT 0,
|
||||||
value_per_item INTEGER NOT NULL,
|
value_per_item INTEGER NOT NULL,
|
||||||
quantity INTEGER NOT NULL DEFAULT 1,
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
idx INTEGER NOT NULL DEFAULT 0,
|
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
category_id INTEGER,
|
category_id INTEGER,
|
||||||
|
CONSTRAINT pk_transaction_line_item PRIMARY KEY (transaction_id, idx),
|
||||||
CONSTRAINT fk_transaction_line_item_transaction
|
CONSTRAINT fk_transaction_line_item_transaction
|
||||||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
|
|
@ -14,6 +14,14 @@ export interface TransactionVendorPayload {
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionCategory {
|
||||||
|
id: number
|
||||||
|
parentId: number | null
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: number
|
id: number
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
@ -56,6 +64,36 @@ export interface TransactionsListItemAccount {
|
||||||
numberSuffix: string
|
numberSuffix: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionDetail {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
addedAt: string
|
||||||
|
amount: number
|
||||||
|
currency: Currency
|
||||||
|
description: string
|
||||||
|
vendor: TransactionVendor | null
|
||||||
|
category: TransactionCategory | null
|
||||||
|
creditedAccount: TransactionDetailAccount | null
|
||||||
|
debitedAccount: TransactionDetailAccount | null
|
||||||
|
tags: string[]
|
||||||
|
lineItems: TransactionDetailLineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionDetailAccount {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
numberSuffix: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionDetailLineItem {
|
||||||
|
idx: number
|
||||||
|
valuePerItem: number
|
||||||
|
quantity: number
|
||||||
|
description: number
|
||||||
|
category: TransactionCategory | null
|
||||||
|
}
|
||||||
|
|
||||||
export class TransactionApiClient extends ApiClient {
|
export class TransactionApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
@ -89,4 +127,8 @@ export class TransactionApiClient extends ApiClient {
|
||||||
): Promise<Page<TransactionsListItem>> {
|
): Promise<Page<TransactionsListItem>> {
|
||||||
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
|
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTransaction(id: number): Promise<TransactionDetail> {
|
||||||
|
return await super.getJson(this.path + '/transactions/' + id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ async function fetchProfiles() {
|
||||||
|
|
||||||
function selectProfile(profile: Profile) {
|
function selectProfile(profile: Profile) {
|
||||||
profileStore.onProfileSelected(profile)
|
profileStore.onProfileSelected(profile)
|
||||||
router.push('/')
|
router.push('/profiles/' + profile.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addProfile() {
|
async function addProfile() {
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ApiError } from '@/api/base';
|
||||||
|
import { formatMoney } from '@/api/data';
|
||||||
|
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction';
|
||||||
|
import AppPage from '@/components/AppPage.vue';
|
||||||
|
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||||
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
|
import { showAlert } from '@/util/alert';
|
||||||
|
import { onMounted, ref, type Ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
const transaction: Ref<TransactionDetail | undefined> = ref()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!profileStore.state) {
|
||||||
|
await router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionId = parseInt(route.params.id as string)
|
||||||
|
try {
|
||||||
|
const api = new TransactionApiClient(profileStore.state)
|
||||||
|
transaction.value = await api.getTransaction(transactionId)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
await router.replace('/')
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
await showAlert('Failed to fetch transaction: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
|
||||||
|
<PropertiesTable>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<td>{{ new Date(transaction.timestamp).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Added at</th>
|
||||||
|
<td>{{ new Date(transaction.addedAt).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Amount</th>
|
||||||
|
<td>{{ formatMoney(transaction.amount, transaction.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<td>{{ transaction.description }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="transaction.vendor">
|
||||||
|
<th>Vendor</th>
|
||||||
|
<td>
|
||||||
|
{{ transaction.vendor.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="transaction.category">
|
||||||
|
<th>Category</th>
|
||||||
|
<td>
|
||||||
|
{{ transaction.category.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="transaction.creditedAccount">
|
||||||
|
<th>Credited Account</th>
|
||||||
|
<td>
|
||||||
|
{{ transaction.creditedAccount.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="transaction.debitedAccount">
|
||||||
|
<th>Debited Account</th>
|
||||||
|
<td>
|
||||||
|
{{ transaction.debitedAccount.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tags</th>
|
||||||
|
<td>
|
||||||
|
<span v-for="tag in transaction.tags" :key="tag">{{ tag }},</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</PropertiesTable>
|
||||||
|
|
||||||
|
<div v-if="transaction.lineItems.length > 0">
|
||||||
|
<h3>Line Items</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Amount per Item</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="i in transaction.lineItems" :key="i.idx">
|
||||||
|
<td>{{ i.idx + 1 }}</td>
|
||||||
|
<td>{{ formatMoney(i.valuePerItem, transaction.currency) }}</td>
|
||||||
|
<td>{{ i.quantity }}</td>
|
||||||
|
<td>{{ i.description }}</td>
|
||||||
|
<td>{{ i.category?.name }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</AppPage>
|
||||||
|
</template>
|
|
@ -48,6 +48,9 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
<td>{{ tx.description }}</td>
|
<td>{{ tx.description }}</td>
|
||||||
<td>{{ tx.creditedAccount?.name }}</td>
|
<td>{{ tx.creditedAccount?.name }}</td>
|
||||||
<td>{{ tx.debitedAccount?.name }}</td>
|
<td>{{ tx.debitedAccount?.name }}</td>
|
||||||
|
<td>
|
||||||
|
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -1,42 +1,32 @@
|
||||||
import UserAccountLayout from '@/pages/UserAccountLayout.vue'
|
|
||||||
import LoginPage from '@/pages/LoginPage.vue'
|
|
||||||
import ProfilePage from '@/pages/ProfilePage.vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth-store'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||||
import UserHomePage from '@/pages/UserHomePage.vue'
|
|
||||||
import ProfilesPage from '@/pages/ProfilesPage.vue'
|
|
||||||
import { useProfileStore } from '@/stores/profile-store'
|
import { useProfileStore } from '@/stores/profile-store'
|
||||||
import AccountPage from '@/pages/AccountPage.vue'
|
|
||||||
import EditAccountPage from '@/pages/forms/EditAccountPage.vue'
|
|
||||||
import MyUserPage from '@/pages/MyUserPage.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
component: async () => LoginPage,
|
component: () => import('@/pages/LoginPage.vue'),
|
||||||
meta: { title: 'Login' },
|
meta: { title: 'Login' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: async () => UserAccountLayout,
|
component: () => import('@/pages/UserAccountLayout.vue'),
|
||||||
beforeEnter: onlyAuthenticated,
|
beforeEnter: onlyAuthenticated,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: async () => UserHomePage,
|
redirect: '/profiles',
|
||||||
meta: { title: 'Home' },
|
|
||||||
beforeEnter: profileSelected,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'me',
|
path: 'me',
|
||||||
component: async () => MyUserPage,
|
component: () => import('@/pages/MyUserPage.vue'),
|
||||||
meta: { title: 'My User' },
|
meta: { title: 'My User' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'profiles',
|
path: 'profiles',
|
||||||
component: async () => ProfilesPage,
|
component: () => import('@/pages/ProfilesPage.vue'),
|
||||||
meta: { title: 'Profiles' },
|
meta: { title: 'Profiles' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -45,24 +35,29 @@ const router = createRouter({
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: async () => ProfilePage,
|
component: () => import('@/pages/UserHomePage.vue'),
|
||||||
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
|
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'accounts/:id',
|
path: 'accounts/:id',
|
||||||
component: async () => AccountPage,
|
component: () => import('@/pages/AccountPage.vue'),
|
||||||
meta: { title: 'Account' },
|
meta: { title: 'Account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'accounts/:id/edit',
|
path: 'accounts/:id/edit',
|
||||||
component: async () => EditAccountPage,
|
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||||
meta: { title: 'Edit Account' },
|
meta: { title: 'Edit Account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'add-account',
|
path: 'add-account',
|
||||||
component: async () => EditAccountPage,
|
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||||
meta: { title: 'Add Account' },
|
meta: { title: 'Add Account' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'transactions/:id',
|
||||||
|
component: () => import('@/pages/TransactionPage.vue'),
|
||||||
|
meta: { title: 'Transaction' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in New Issue