Refactor entire process of adding transactions.
This commit is contained in:
parent
277a6dc591
commit
0b67bb605b
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"fileVersion": 1,
|
||||
"versions": {
|
||||
"asdf": "0.7.17",
|
||||
"asdf": {
|
||||
"repository": "git+https://github.com/libmir/asdf.git",
|
||||
"version": "7f77a3031975816b604a513ddeefbc9e514f236c"
|
||||
},
|
||||
"d2sqlite3": "1.0.0",
|
||||
"dxml": "0.4.4",
|
||||
"handy-http-data": "1.3.0",
|
||||
|
|
|
@ -10,6 +10,7 @@ import std.datetime : SysTime;
|
|||
|
||||
interface AccountRepository {
|
||||
Optional!Account findById(ulong id);
|
||||
bool existsById(ulong id);
|
||||
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description);
|
||||
void setArchived(ulong id, bool archived);
|
||||
Account update(ulong id, in Account newData);
|
||||
|
|
|
@ -21,6 +21,10 @@ class SqliteAccountRepository : AccountRepository {
|
|||
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) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
|
|
|
@ -145,7 +145,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
|
||||
const SCHEMA = import("sql/schema.sql");
|
||||
private const string dbPath;
|
||||
private Database db;
|
||||
Database db;
|
||||
|
||||
this(string path) {
|
||||
this.dbPath = path;
|
||||
|
|
|
@ -9,6 +9,7 @@ import std.typecons;
|
|||
import transaction.model;
|
||||
import transaction.data;
|
||||
import transaction.service;
|
||||
import transaction.dto;
|
||||
import profile.data;
|
||||
import profile.service;
|
||||
import account.api;
|
||||
|
@ -20,47 +21,6 @@ import util.data;
|
|||
|
||||
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) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
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) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
struct AddTransactionPayloadLineItem {
|
||||
long valuePerItem;
|
||||
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;
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
|
||||
import asdf : serializeToJson;
|
||||
string jsonStr = serializeToJson(txn);
|
||||
response.writeBodyString(jsonStr, "application/json");
|
||||
}
|
||||
|
||||
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||
addTransaction2(ds, payload);
|
||||
addTransaction(ds, payload);
|
||||
}
|
||||
|
||||
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import handy_http_primitives : Optional;
|
|||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
import transaction.api : TransactionsListItem;
|
||||
import transaction.dto;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
||||
|
@ -12,6 +12,7 @@ interface TransactionVendorRepository {
|
|||
Optional!TransactionVendor findById(ulong id);
|
||||
TransactionVendor[] findAll();
|
||||
bool existsByName(string name);
|
||||
bool existsById(ulong id);
|
||||
TransactionVendor insert(string name, string description);
|
||||
void deleteById(ulong id);
|
||||
TransactionVendor updateById(ulong id, string name, string description);
|
||||
|
@ -19,6 +20,7 @@ interface TransactionVendorRepository {
|
|||
|
||||
interface TransactionCategoryRepository {
|
||||
Optional!TransactionCategory findById(ulong id);
|
||||
bool existsById(ulong id);
|
||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||
void deleteById(ulong id);
|
||||
|
@ -27,21 +29,13 @@ interface TransactionCategoryRepository {
|
|||
|
||||
interface TransactionTagRepository {
|
||||
string[] findAllByTransactionId(ulong transactionId);
|
||||
void updateTags(ulong transactionId, string[] tags);
|
||||
void updateTags(ulong transactionId, in string[] tags);
|
||||
string[] findAll();
|
||||
}
|
||||
|
||||
interface TransactionRepository {
|
||||
Page!TransactionsListItem findAll(PageRequest pr);
|
||||
Optional!Transaction findById(ulong id);
|
||||
Transaction insert(
|
||||
SysTime timestamp,
|
||||
SysTime addedAt,
|
||||
ulong amount,
|
||||
Currency currency,
|
||||
string description,
|
||||
Optional!ulong vendorId,
|
||||
Optional!ulong categoryId
|
||||
);
|
||||
Optional!TransactionDetail findById(ulong id);
|
||||
TransactionDetail insert(in AddTransactionPayload data);
|
||||
void deleteById(ulong id);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import d2sqlite3;
|
|||
|
||||
import transaction.model;
|
||||
import transaction.data;
|
||||
import transaction.api;
|
||||
import transaction.dto;
|
||||
import util.sqlite;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
@ -35,6 +35,10 @@ class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
|||
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) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
|
@ -77,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
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) {
|
||||
if (parentId) {
|
||||
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(
|
||||
db,
|
||||
"DELETE FROM transaction_tag WHERE transaction_id = ?",
|
||||
|
@ -200,15 +208,15 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
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));
|
||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||
TransactionsListItem.Vendor(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));
|
||||
item.category = Optional!(TransactionsListItem.Category).of(
|
||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||
}
|
||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
||||
if (!creditedAccountId.isNull) {
|
||||
|
@ -216,8 +224,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
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));
|
||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
||||
if (!debitedAccountId.isNull) {
|
||||
|
@ -225,13 +233,15 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
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));
|
||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
string tagsStr = row.peek!string(19);
|
||||
if (tagsStr.length > 0) {
|
||||
if (tagsStr !is null && tagsStr.length > 0) {
|
||||
import std.string : split;
|
||||
item.tags = tagsStr.split(",");
|
||||
} else {
|
||||
item.tags = [];
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
@ -239,34 +249,125 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||
}
|
||||
|
||||
Optional!Transaction findById(ulong id) {
|
||||
return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id);
|
||||
Optional!TransactionDetail findById(ulong 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(
|
||||
SysTime timestamp,
|
||||
SysTime addedAt,
|
||||
ulong amount,
|
||||
Currency currency,
|
||||
string description,
|
||||
Optional!ulong vendorId,
|
||||
Optional!ulong categoryId
|
||||
) {
|
||||
TransactionDetail insert(in AddTransactionPayload data) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO " ~ TABLE_NAME ~ "
|
||||
(timestamp, added_at, amount, currency, description, vendor_id, category_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
timestamp.toISOExtString(),
|
||||
addedAt.toISOExtString(),
|
||||
amount,
|
||||
currency.code,
|
||||
description,
|
||||
toNullable(vendorId),
|
||||
toNullable(categoryId)
|
||||
import("sql/insert_transaction.sql"),
|
||||
data.timestamp,
|
||||
Clock.currTime(UTC()),
|
||||
data.amount,
|
||||
data.currencyCode,
|
||||
data.description,
|
||||
data.vendorId,
|
||||
data.categoryId
|
||||
);
|
||||
ulong id = db.lastInsertRowid();
|
||||
return findById(id).orElseThrow();
|
||||
ulong transactionId = db.lastInsertRowid();
|
||||
// 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) {
|
||||
|
|
|
@ -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 {
|
||||
immutable ulong id;
|
||||
immutable ulong transactionId;
|
||||
immutable uint idx;
|
||||
immutable long valuePerItem;
|
||||
immutable ulong quantity;
|
||||
immutable uint idx;
|
||||
immutable string description;
|
||||
immutable Optional!ulong categoryId;
|
||||
}
|
||||
|
|
|
@ -6,8 +6,10 @@ import std.datetime;
|
|||
import transaction.api;
|
||||
import transaction.model;
|
||||
import transaction.data;
|
||||
import transaction.dto;
|
||||
import profile.data;
|
||||
import account.model;
|
||||
import account.data;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
||||
|
@ -16,64 +18,109 @@ import util.pagination;
|
|||
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
||||
Page!TransactionsListItem page = ds.getTransactionRepository()
|
||||
.findAll(pageRequest);
|
||||
return page; // Return an empty page for now!
|
||||
return page;
|
||||
}
|
||||
|
||||
void addTransaction2(ProfileDataSource ds, in AddTransactionPayload payload) {
|
||||
// TODO
|
||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||
return ds.getTransactionRepository().findById(transactionId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
void addTransaction(
|
||||
ProfileDataSource ds,
|
||||
SysTime timestamp,
|
||||
SysTime addedAt,
|
||||
ulong amount,
|
||||
Currency currency,
|
||||
string description,
|
||||
Optional!ulong vendorId,
|
||||
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.");
|
||||
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
|
||||
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
|
||||
// Validate transaction details:
|
||||
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
||||
}
|
||||
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
|
||||
auto txRepo = ds.getTransactionRepository();
|
||||
Transaction tx = txRepo.insert(
|
||||
timestamp,
|
||||
addedAt,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
vendorId,
|
||||
categoryId
|
||||
);
|
||||
if (creditedAccountId) {
|
||||
journalEntryRepo.insert(
|
||||
timestamp,
|
||||
creditedAccountId.value,
|
||||
tx.id,
|
||||
amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
currency
|
||||
);
|
||||
if (
|
||||
!payload.creditedAccountId.isNull &&
|
||||
!payload.debitedAccountId.isNull &&
|
||||
payload.creditedAccountId.get == payload.debitedAccountId.get
|
||||
) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
|
||||
}
|
||||
if (debitedAccountId) {
|
||||
journalEntryRepo.insert(
|
||||
timestamp,
|
||||
debitedAccountId.value,
|
||||
tx.id,
|
||||
amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
currency
|
||||
);
|
||||
if (payload.amount == 0) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
ds.getTransactionTagRepository().updateTags(tx.id, tags);
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the transaction:
|
||||
ulong txnId;
|
||||
ds.doTransaction(() {
|
||||
TransactionRepository txRepo = ds.getTransactionRepository();
|
||||
TransactionDetail txn = txRepo.insert(payload);
|
||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
if (!payload.creditedAccountId.isNull) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.creditedAccountId.get,
|
||||
txn.id,
|
||||
txn.amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
txn.currency
|
||||
);
|
||||
}
|
||||
if (!payload.debitedAccountId.isNull) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.debitedAccountId.get,
|
||||
txn.id,
|
||||
txn.amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
txn.currency
|
||||
);
|
||||
}
|
||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||
tagRepo.updateTags(txn.id, payload.tags);
|
||||
txnId = txn.id;
|
||||
});
|
||||
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
||||
}
|
||||
|
||||
// Vendors Services
|
||||
|
|
|
@ -7,12 +7,15 @@ import auth;
|
|||
import profile;
|
||||
import account;
|
||||
import transaction;
|
||||
import transaction.dto;
|
||||
import util.money;
|
||||
import util.data;
|
||||
|
||||
import std.random;
|
||||
import std.conv;
|
||||
import std.array;
|
||||
import std.datetime;
|
||||
import std.typecons;
|
||||
|
||||
void generateSampleData() {
|
||||
UserRepository userRepo = new FileSystemUserRepository;
|
||||
|
@ -51,31 +54,30 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
|||
infoF!" Generating random profile %s."(profileName);
|
||||
Profile profile = profileRepo.createProfile(profileName);
|
||||
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||
ds.doTransaction(() {
|
||||
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
||||
Currency preferredCurrency = choice(ALL_CURRENCIES);
|
||||
|
||||
const int accountCount = uniform(3, 10);
|
||||
for (int i = 0; i < accountCount; i++) {
|
||||
generateRandomAccount(i, ds, preferredCurrency);
|
||||
}
|
||||
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
||||
Currency preferredCurrency = choice(ALL_CURRENCIES);
|
||||
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
const int vendorCount = uniform(5, 30);
|
||||
for (int i = 0; i < vendorCount; i++) {
|
||||
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
|
||||
}
|
||||
infoF!" Generated %d random vendors."(vendorCount);
|
||||
const int accountCount = uniform(3, 10);
|
||||
for (int i = 0; i < accountCount; i++) {
|
||||
generateRandomAccount(i, ds, preferredCurrency);
|
||||
}
|
||||
|
||||
auto categoryRepo = ds.getTransactionCategoryRepository();
|
||||
const int categoryCount = uniform(5, 30);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
|
||||
}
|
||||
infoF!" Generated %d random categories."(categoryCount);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
const int vendorCount = uniform(5, 30);
|
||||
for (int i = 0; i < vendorCount; i++) {
|
||||
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
|
||||
}
|
||||
infoF!" Generated %d random vendors."(vendorCount);
|
||||
|
||||
generateRandomTransactions(ds);
|
||||
});
|
||||
auto categoryRepo = ds.getTransactionCategoryRepository();
|
||||
const int categoryCount = uniform(5, 30);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
|
||||
}
|
||||
infoF!" Generated %d random categories."(categoryCount);
|
||||
|
||||
generateRandomTransactions(ds);
|
||||
}
|
||||
|
||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||
|
@ -105,24 +107,21 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
.findAllByParentId(Optional!ulong.empty);
|
||||
const Account[] accounts = ds.getAccountRepository().findAll();
|
||||
|
||||
SysTime now = Clock.currTime(UTC());
|
||||
SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
|
||||
|
||||
for (int i = 0; i < 100; i++) {
|
||||
Optional!ulong vendorId;
|
||||
AddTransactionPayload data;
|
||||
data.timestamp = timestamp.toISOExtString();
|
||||
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) {
|
||||
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.
|
||||
Optional!ulong creditedAccountId;
|
||||
Optional!ulong debitedAccountId;
|
||||
Account primaryAccount = choice(accounts);
|
||||
data.currencyCode = primaryAccount.currency.code;
|
||||
Optional!ulong secondaryAccountId;
|
||||
if (uniform01() < 0.25) {
|
||||
foreach (acc; accounts) {
|
||||
|
@ -133,11 +132,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
}
|
||||
}
|
||||
if (uniform01() < 0.5) {
|
||||
creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccountId) debitedAccountId = secondaryAccountId;
|
||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
|
||||
} else {
|
||||
debitedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccountId) creditedAccountId = secondaryAccountId;
|
||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
|
||||
}
|
||||
|
||||
// Randomly choose some tags to add.
|
||||
|
@ -147,24 +146,40 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
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(
|
||||
ds,
|
||||
timestamp,
|
||||
now,
|
||||
value,
|
||||
primaryAccount.currency,
|
||||
"Test transaction " ~ to!string(i),
|
||||
vendorId,
|
||||
categoryId,
|
||||
creditedAccountId,
|
||||
debitedAccountId,
|
||||
[],
|
||||
tags
|
||||
);
|
||||
infoF!" Generated transaction %d"(i);
|
||||
// Generate random line items:
|
||||
if (uniform01 < 0.5) {
|
||||
long lineItemTotal = 0;
|
||||
foreach (n; 1..uniform(1, 20)) {
|
||||
AddTransactionPayload.LineItem item;
|
||||
item.valuePerItem = uniform(1, 10_000);
|
||||
item.quantity = uniform(1, 5);
|
||||
lineItemTotal += item.quantity * item.valuePerItem;
|
||||
item.description = "Sample item " ~ n.to!string;
|
||||
if (uniform01 < 0.5) {
|
||||
TransactionCategory category = choice(categories);
|
||||
item.categoryId = category.id;
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
auto txn = addTransaction(ds, data);
|
||||
infoF!" Generated transaction %d"(txn.id);
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
idx INTEGER NOT NULL DEFAULT 0,
|
||||
value_per_item INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
idx INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
CONSTRAINT pk_transaction_line_item PRIMARY KEY (transaction_id, idx),
|
||||
CONSTRAINT fk_transaction_line_item_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
|
|
@ -14,6 +14,14 @@ export interface TransactionVendorPayload {
|
|||
description: string
|
||||
}
|
||||
|
||||
export interface TransactionCategory {
|
||||
id: number
|
||||
parentId: number | null
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number
|
||||
timestamp: string
|
||||
|
@ -56,6 +64,36 @@ export interface TransactionsListItemAccount {
|
|||
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 {
|
||||
readonly path: string
|
||||
|
||||
|
@ -89,4 +127,8 @@ export class TransactionApiClient extends ApiClient {
|
|||
): Promise<Page<TransactionsListItem>> {
|
||||
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) {
|
||||
profileStore.onProfileSelected(profile)
|
||||
router.push('/')
|
||||
router.push('/profiles/' + profile.name)
|
||||
}
|
||||
|
||||
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.creditedAccount?.name }}</td>
|
||||
<td>{{ tx.debitedAccount?.name }}</td>
|
||||
<td>
|
||||
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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 { 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 AccountPage from '@/pages/AccountPage.vue'
|
||||
import EditAccountPage from '@/pages/forms/EditAccountPage.vue'
|
||||
import MyUserPage from '@/pages/MyUserPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
component: async () => LoginPage,
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { title: 'Login' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: async () => UserAccountLayout,
|
||||
component: () => import('@/pages/UserAccountLayout.vue'),
|
||||
beforeEnter: onlyAuthenticated,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: async () => UserHomePage,
|
||||
meta: { title: 'Home' },
|
||||
beforeEnter: profileSelected,
|
||||
redirect: '/profiles',
|
||||
},
|
||||
{
|
||||
path: 'me',
|
||||
component: async () => MyUserPage,
|
||||
component: () => import('@/pages/MyUserPage.vue'),
|
||||
meta: { title: 'My User' },
|
||||
},
|
||||
{
|
||||
path: 'profiles',
|
||||
component: async () => ProfilesPage,
|
||||
component: () => import('@/pages/ProfilesPage.vue'),
|
||||
meta: { title: 'Profiles' },
|
||||
},
|
||||
{
|
||||
|
@ -45,24 +35,29 @@ const router = createRouter({
|
|||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: async () => ProfilePage,
|
||||
component: () => import('@/pages/UserHomePage.vue'),
|
||||
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
|
||||
},
|
||||
{
|
||||
path: 'accounts/:id',
|
||||
component: async () => AccountPage,
|
||||
component: () => import('@/pages/AccountPage.vue'),
|
||||
meta: { title: 'Account' },
|
||||
},
|
||||
{
|
||||
path: 'accounts/:id/edit',
|
||||
component: async () => EditAccountPage,
|
||||
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||
meta: { title: 'Edit Account' },
|
||||
},
|
||||
{
|
||||
path: 'add-account',
|
||||
component: async () => EditAccountPage,
|
||||
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||
meta: { title: 'Add Account' },
|
||||
},
|
||||
{
|
||||
path: 'transactions/:id',
|
||||
component: () => import('@/pages/TransactionPage.vue'),
|
||||
meta: { title: 'Transaction' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
Loading…
Reference in New Issue