Added functioning account editor.
This commit is contained in:
parent
0b67bb605b
commit
e29d4e1c0f
|
@ -1,43 +1,43 @@
|
|||
Amazon
|
||||
eBay
|
||||
Walmart
|
||||
Target
|
||||
Best Buy
|
||||
Costco
|
||||
Home Depot
|
||||
Lowe's
|
||||
Kroger
|
||||
CVS
|
||||
Walgreens
|
||||
Starbucks
|
||||
McDonald's
|
||||
Burger King
|
||||
Subway
|
||||
Pizza Hut
|
||||
Domino's
|
||||
Chipotle
|
||||
Taco Bell
|
||||
Panera Bread
|
||||
Dunkin'
|
||||
Chick-fil-A
|
||||
Advance Auto Parts
|
||||
AutoZone
|
||||
Delta Air Lines
|
||||
American Airlines
|
||||
United Airlines
|
||||
Squarespace
|
||||
DigitalOcean
|
||||
GitHub
|
||||
Heroku
|
||||
Stripe
|
||||
PayPal
|
||||
Verizon
|
||||
ALDI
|
||||
IKEA
|
||||
Primark
|
||||
H&M
|
||||
Petco
|
||||
China King
|
||||
GEICO
|
||||
Bank of America
|
||||
Citi
|
||||
Amazon,Online retailer for pretty much anything.
|
||||
eBay,Website to buy and bid on stuff from people.
|
||||
Walmart,"Big, ugly general store."
|
||||
Target,Slightly less ugly general store.
|
||||
Best Buy,Large tech-focused store.
|
||||
Costco,Wholesale club grocery store.
|
||||
Home Depot,Hardware store that’s painted orange.
|
||||
Lowe's,Hardware store that’s painted blue.
|
||||
Kroger,A giant grocery conglomerate.
|
||||
CVS,A chain of corner-store pharmacies.
|
||||
Walgreens,Another chain of corner-store pharmacies.
|
||||
Starbucks,Coffee distributor.
|
||||
McDonald's,The most famous fast-food.
|
||||
Burger King,Second to McDonald’s for burger fast-food.
|
||||
Subway,An outdated sandwich shop.
|
||||
Pizza Hut,Pizza chain restaurant.
|
||||
Domino's,Another pizza chain restaurant.
|
||||
Chipotle,“Mexican” fast food place.
|
||||
Taco Bell,Another “Mexican” fast food place.
|
||||
Panera Bread,Some random breakfast restaurant.
|
||||
Dunkin',Coffee and donuts shop.
|
||||
Chick-fil-A,Chicken shop that contributes to anti-gay politics.
|
||||
Advance Auto Parts,Car parts store.
|
||||
AutoZone,Another car parts store.
|
||||
Delta Air Lines,The most popular american air carrier.
|
||||
American Airlines,"Another american air carrier, based in Charlotte, North Carolina."
|
||||
United Airlines,"An american air carrier based in Chicago, Illinois."
|
||||
Squarespace,Online website and domain name seller.
|
||||
DigitalOcean,Cloud hosting provider.
|
||||
GitHub,Source code repository provider owned by Microsoft.
|
||||
Heroku,Some other cloud hosting provider.
|
||||
Stripe,Payment provider.
|
||||
PayPal,Another payment provider.
|
||||
Verizon,American phone carrier.
|
||||
ALDI,Multinational minimalist grocery store.
|
||||
IKEA,Swedish build-it-yourself furniture store.
|
||||
Primark,Irish budget clothing store.
|
||||
H&M,Budget european clothing store.
|
||||
Petco,Pet store.
|
||||
China King,Some random chinese store.
|
||||
GEICO,Car insurance.
|
||||
Bank of America,A bank.
|
||||
Citi,Another bank.
|
||||
|
|
|
|
@ -32,4 +32,5 @@ interface AccountJournalEntryRepository {
|
|||
Currency currency
|
||||
);
|
||||
void deleteById(ulong id);
|
||||
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId);
|
||||
}
|
||||
|
|
|
@ -83,6 +83,7 @@ SQL",
|
|||
|
||||
void deleteById(ulong id) {
|
||||
doTransaction(db, () {
|
||||
// Delete associated history.
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM history
|
||||
|
@ -92,6 +93,14 @@ SQL",
|
|||
)",
|
||||
id
|
||||
);
|
||||
// Delete all associated transactions.
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM \"transaction\" WHERE id IN " ~
|
||||
"(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)",
|
||||
id
|
||||
);
|
||||
// Finally delete the account itself (and all cascaded entities, like journal entries).
|
||||
util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
|
||||
});
|
||||
}
|
||||
|
@ -229,6 +238,14 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
|
|||
util.sqlite.deleteById(db, "account_journal_entry", id);
|
||||
}
|
||||
|
||||
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?",
|
||||
accountId, transactionId
|
||||
);
|
||||
}
|
||||
|
||||
static AccountJournalEntry parseEntry(Row row) {
|
||||
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
||||
AccountJournalEntryType type;
|
||||
|
|
|
@ -62,12 +62,20 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleDeleteVendor);
|
||||
|
||||
// Transaction category endpoints:
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories", &handleGetCategories);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleGetCategory);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory);
|
||||
// Transaction endpoints:
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleGetTransaction);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleDeleteTransaction);
|
||||
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags", &handleGetAllTags);
|
||||
|
||||
import data_api;
|
||||
// Various other data endpoints:
|
||||
|
|
|
@ -39,16 +39,32 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||
addTransaction(ds, payload);
|
||||
TransactionDetail txn = addTransaction(ds, payload);
|
||||
import asdf : serializeToJson;
|
||||
string jsonStr = serializeToJson(txn);
|
||||
response.writeBodyString(jsonStr, "application/json");
|
||||
}
|
||||
|
||||
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
// TODO
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
ulong txnId = getTransactionIdOrThrow(request);
|
||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||
TransactionDetail txn = updateTransaction(ds, txnId, payload);
|
||||
import asdf : serializeToJson;
|
||||
string jsonStr = serializeToJson(txn);
|
||||
response.writeBodyString(jsonStr, "application/json");
|
||||
}
|
||||
|
||||
void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
// TODO
|
||||
ulong txnId = getTransactionIdOrThrow(request);
|
||||
deleteTransaction(ds, txnId);
|
||||
}
|
||||
|
||||
void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
string[] tags = ds.getTransactionTagRepository().findAll();
|
||||
writeJsonBody(response, tags);
|
||||
}
|
||||
|
||||
private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
|
||||
|
@ -98,3 +114,24 @@ private ulong getVendorId(in ServerHttpRequest request) {
|
|||
}
|
||||
|
||||
// Categories API
|
||||
|
||||
void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
TransactionCategoryTree[] categories = getCategories(getProfileDataSource(request));
|
||||
writeJsonBody(response, categories);
|
||||
}
|
||||
|
||||
void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
// TODO
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ interface TransactionVendorRepository {
|
|||
interface TransactionCategoryRepository {
|
||||
Optional!TransactionCategory findById(ulong id);
|
||||
bool existsById(ulong id);
|
||||
TransactionCategory[] findAll();
|
||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||
void deleteById(ulong id);
|
||||
|
@ -37,5 +38,6 @@ interface TransactionRepository {
|
|||
Page!TransactionsListItem findAll(PageRequest pr);
|
||||
Optional!TransactionDetail findById(ulong id);
|
||||
TransactionDetail insert(in AddTransactionPayload data);
|
||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||
void deleteById(ulong id);
|
||||
}
|
||||
|
|
|
@ -85,6 +85,14 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
||||
}
|
||||
|
||||
TransactionCategory[] findAll() {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT * FROM transaction_category ORDER BY parent_id, name",
|
||||
&parseCategory
|
||||
);
|
||||
}
|
||||
|
||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
|
||||
if (parentId) {
|
||||
return util.sqlite.findAll(
|
||||
|
@ -346,7 +354,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
db,
|
||||
import("sql/insert_transaction.sql"),
|
||||
data.timestamp,
|
||||
Clock.currTime(UTC()),
|
||||
Clock.currTime(UTC()).toISOExtString(),
|
||||
data.amount,
|
||||
data.currencyCode,
|
||||
data.description,
|
||||
|
@ -354,19 +362,29 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
data.categoryId
|
||||
);
|
||||
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
|
||||
);
|
||||
}
|
||||
insertLineItems(transactionId, data);
|
||||
return findById(transactionId).orElseThrow();
|
||||
}
|
||||
|
||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
import("sql/update_transaction.sql"),
|
||||
data.timestamp,
|
||||
data.amount,
|
||||
data.currencyCode,
|
||||
data.description,
|
||||
data.vendorId,
|
||||
data.categoryId,
|
||||
transactionId
|
||||
);
|
||||
// Re-write all line items:
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM transaction_line_item WHERE transaction_id = ?",
|
||||
transactionId
|
||||
);
|
||||
insertLineItems(transactionId, data);
|
||||
return findById(transactionId).orElseThrow();
|
||||
}
|
||||
|
||||
|
@ -387,4 +405,19 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
toOptional(row.peek!(Nullable!ulong)(7))
|
||||
);
|
||||
}
|
||||
|
||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,3 +109,15 @@ struct AddTransactionPayload {
|
|||
Nullable!ulong categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure for depicting an entire hierarchical tree structure of categories.
|
||||
struct TransactionCategoryTree {
|
||||
ulong id;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
string color;
|
||||
TransactionCategoryTree[] children;
|
||||
uint depth;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import account.model;
|
|||
import account.data;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
import core.internal.container.common;
|
||||
|
||||
// Transactions Services
|
||||
|
||||
|
@ -31,7 +32,114 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
|
||||
// Validate transaction details:
|
||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) {
|
||||
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
TransactionRepository transactionRepo = ds.getTransactionRepository();
|
||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||
|
||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
|
||||
const TransactionDetail prev = transactionRepo.findById(transactionId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
|
||||
// Update the transaction:
|
||||
ds.doTransaction(() {
|
||||
TransactionDetail curr = transactionRepo.update(transactionId, payload);
|
||||
bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
||||
bool updateCreditEntry = amountOrCurrencyChanged || (
|
||||
prev.creditedAccount != curr.creditedAccount
|
||||
);
|
||||
bool updateDebitEntry = amountOrCurrencyChanged || (
|
||||
prev.debitedAccount != curr.debitedAccount
|
||||
);
|
||||
|
||||
// Update journal entries if necessary:
|
||||
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, transactionId);
|
||||
}
|
||||
if (updateCreditEntry && !curr.creditedAccount.isNull) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
curr.creditedAccount.get.id,
|
||||
transactionId,
|
||||
curr.amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
curr.currency
|
||||
);
|
||||
}
|
||||
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, transactionId);
|
||||
}
|
||||
if (updateDebitEntry && !curr.debitedAccount.isNull) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
curr.debitedAccount.get.id,
|
||||
transactionId,
|
||||
curr.amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
curr.currency
|
||||
);
|
||||
}
|
||||
|
||||
// Update tags.
|
||||
tagRepo.updateTags(transactionId, payload.tags);
|
||||
});
|
||||
return transactionRepo.findById(transactionId).orElseThrow();
|
||||
}
|
||||
|
||||
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||
TransactionRepository txnRepo = ds.getTransactionRepository();
|
||||
TransactionDetail txn = txnRepo.findById(transactionId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
txnRepo.deleteById(txn.id);
|
||||
}
|
||||
|
||||
private void validateTransactionPayload(
|
||||
TransactionVendorRepository vendorRepo,
|
||||
TransactionCategoryRepository categoryRepo,
|
||||
AccountRepository accountRepo,
|
||||
in AddTransactionPayload payload
|
||||
) {
|
||||
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
||||
}
|
||||
|
@ -46,7 +154,12 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
|
||||
}
|
||||
SysTime now = Clock.currTime(UTC());
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
SysTime timestamp;
|
||||
try {
|
||||
timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
} catch (TimeException e) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime.");
|
||||
}
|
||||
if (timestamp > now) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
||||
}
|
||||
|
@ -89,38 +202,6 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
@ -158,3 +239,31 @@ TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPa
|
|||
void deleteVendor(ProfileDataSource ds, ulong vendorId) {
|
||||
ds.getTransactionVendorRepository().deleteById(vendorId);
|
||||
}
|
||||
|
||||
// Categories Services
|
||||
|
||||
TransactionCategoryTree[] getCategories(ProfileDataSource ds) {
|
||||
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
||||
return getCategoriesRecursive(repo, Optional!ulong.empty, 0);
|
||||
}
|
||||
|
||||
private TransactionCategoryTree[] getCategoriesRecursive(
|
||||
TransactionCategoryRepository repo,
|
||||
Optional!ulong parentId,
|
||||
uint depth
|
||||
) {
|
||||
import util.data : toNullable;
|
||||
TransactionCategoryTree[] nodes;
|
||||
foreach (category; repo.findAllByParentId(parentId)) {
|
||||
nodes ~= TransactionCategoryTree(
|
||||
category.id,
|
||||
parentId,
|
||||
category.name,
|
||||
category.description,
|
||||
category.color,
|
||||
getCategoriesRecursive(repo, Optional!ulong.of(category.id), depth + 1),
|
||||
depth
|
||||
);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,9 @@ import std.conv;
|
|||
import std.array;
|
||||
import std.datetime;
|
||||
import std.typecons;
|
||||
import std.file;
|
||||
import std.algorithm;
|
||||
import std.csv;
|
||||
|
||||
void generateSampleData() {
|
||||
UserRepository userRepo = new FileSystemUserRepository;
|
||||
|
@ -63,21 +66,42 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
|||
generateRandomAccount(i, ds, preferredCurrency);
|
||||
}
|
||||
|
||||
ds.doTransaction(() {
|
||||
generateVendors(ds);
|
||||
generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty);
|
||||
});
|
||||
generateRandomTransactions(ds);
|
||||
}
|
||||
|
||||
void generateVendors(ProfileDataSource ds) {
|
||||
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.");
|
||||
string vendorsCsv = readText("sample-data/vendors.csv");
|
||||
uint vendorCount = 0;
|
||||
foreach (record; csvReader!(Tuple!(string, string))(vendorsCsv)) {
|
||||
vendorRepo.insert(record[0], record[1]);
|
||||
vendorCount++;
|
||||
}
|
||||
infoF!" Generated %d random vendors."(vendorCount);
|
||||
}
|
||||
|
||||
auto categoryRepo = ds.getTransactionCategoryRepository();
|
||||
const int categoryCount = uniform(5, 30);
|
||||
void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) {
|
||||
const int categoryCount = uniform(5, 10);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
|
||||
string name = "Test Category " ~ to!string(i);
|
||||
if (parentId) {
|
||||
name ~= " (child of " ~ parentId.value.to!string ~ ")";
|
||||
}
|
||||
TransactionCategory category = repo.insert(
|
||||
parentId,
|
||||
name,
|
||||
"Testing category.",
|
||||
"FFFFFF"
|
||||
);
|
||||
infoF!" Generating child categories for %d, depth = %d"(i, depth);
|
||||
if (depth < 2) {
|
||||
generateCategories(repo, Optional!ulong.of(category.id), depth + 1);
|
||||
}
|
||||
}
|
||||
infoF!" Generated %d random categories."(categoryCount);
|
||||
|
||||
generateRandomTransactions(ds);
|
||||
}
|
||||
|
||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||
|
|
|
@ -56,12 +56,6 @@ CREATE TABLE transaction_category (
|
|||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_tag (
|
||||
transaction_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE "transaction" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
|
@ -80,6 +74,15 @@ CREATE TABLE "transaction" (
|
|||
);
|
||||
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
|
||||
|
||||
CREATE TABLE transaction_tag (
|
||||
transaction_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag),
|
||||
CONSTRAINT fk_transaction_tag_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_attachment (
|
||||
transaction_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
|
@ -121,7 +124,9 @@ CREATE TABLE account_journal_entry (
|
|||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_journal_entry_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT uq_account_journal_entry_ids
|
||||
UNIQUE (account_id, transaction_id)
|
||||
);
|
||||
|
||||
-- Value records
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
UPDATE "transaction"
|
||||
SET
|
||||
timestamp = ?,
|
||||
amount = ?,
|
||||
currency = ?,
|
||||
description = ?,
|
||||
vendor_id = ?,
|
||||
category_id = ?
|
||||
WHERE id = ?
|
|
@ -22,6 +22,16 @@ export interface TransactionCategory {
|
|||
color: string
|
||||
}
|
||||
|
||||
export interface TransactionCategoryTree {
|
||||
id: number
|
||||
parentId: number | null
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
children: TransactionCategoryTree[]
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number
|
||||
timestamp: string
|
||||
|
@ -90,10 +100,30 @@ export interface TransactionDetailLineItem {
|
|||
idx: number
|
||||
valuePerItem: number
|
||||
quantity: number
|
||||
description: number
|
||||
description: string
|
||||
category: TransactionCategory | null
|
||||
}
|
||||
|
||||
export interface AddTransactionPayload {
|
||||
timestamp: string
|
||||
amount: number
|
||||
currencyCode: string
|
||||
description: string
|
||||
vendorId: number | null
|
||||
categoryId: number | null
|
||||
creditedAccountId: number | null
|
||||
debitedAccountId: number | null
|
||||
tags: string[]
|
||||
lineItems: AddTransactionPayloadLineItem[]
|
||||
}
|
||||
|
||||
export interface AddTransactionPayloadLineItem {
|
||||
valuePerItem: number
|
||||
quantity: number
|
||||
description: string
|
||||
categoryId: number | null
|
||||
}
|
||||
|
||||
export class TransactionApiClient extends ApiClient {
|
||||
readonly path: string
|
||||
|
||||
|
@ -122,6 +152,10 @@ export class TransactionApiClient extends ApiClient {
|
|||
return await super.delete(this.path + '/vendors/' + id)
|
||||
}
|
||||
|
||||
async getCategories(): Promise<TransactionCategoryTree[]> {
|
||||
return await super.getJson(this.path + '/categories')
|
||||
}
|
||||
|
||||
async getTransactions(
|
||||
paginationOptions: PageRequest | undefined = undefined,
|
||||
): Promise<Page<TransactionsListItem>> {
|
||||
|
@ -131,4 +165,20 @@ export class TransactionApiClient extends ApiClient {
|
|||
async getTransaction(id: number): Promise<TransactionDetail> {
|
||||
return await super.getJson(this.path + '/transactions/' + id)
|
||||
}
|
||||
|
||||
async addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return await super.postJson(this.path + '/transactions', data)
|
||||
}
|
||||
|
||||
async updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return await super.putJson(this.path + '/transactions/' + id, data)
|
||||
}
|
||||
|
||||
async deleteTransaction(id: number): Promise<void> {
|
||||
return await super.delete(this.path + '/transactions/' + id)
|
||||
}
|
||||
|
||||
async getAllTags(): Promise<string[]> {
|
||||
return await super.getJson(this.path + '/transaction-tags')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,9 @@ defineProps<{
|
|||
defineEmits(['click'])
|
||||
</script>
|
||||
<template>
|
||||
<button class="app-button" :class="{ 'app-button-secondary': buttonStyle === 'secondary' }" @click="$emit('click')"
|
||||
:type="buttonType" :disabled="disabled ?? false">
|
||||
<button class="app-button"
|
||||
:class="{ 'app-button-secondary': buttonStyle === 'secondary', 'app-button-disabled': disabled ?? false }"
|
||||
@click="$emit('click')" :type="buttonType" :disabled="disabled ?? false">
|
||||
<span v-if="icon">
|
||||
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
|
||||
</span>
|
||||
|
@ -44,7 +45,12 @@ defineEmits(['click'])
|
|||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.app-button:hover {
|
||||
.app-button-disabled {
|
||||
color: #686868;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.app-button:hover:not(.app-button-disabled) {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
|
@ -54,9 +60,8 @@ defineEmits(['click'])
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-button:active {
|
||||
.app-button:active:not(.app-button-disabled) {
|
||||
background-color: #3b4968;
|
||||
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
<!--
|
||||
Part of the EditTransactionPage which controls the list of line items for the
|
||||
transaction. This editor shows a table of current line items, and includes a
|
||||
modal for adding a new one.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction';
|
||||
import AppButton from './AppButton.vue';
|
||||
import FormGroup from './form/FormGroup.vue';
|
||||
import { formatMoney, type Currency } from '@/api/data';
|
||||
import ModalWrapper from './ModalWrapper.vue';
|
||||
import FormControl from './form/FormControl.vue';
|
||||
import { ref, type Ref, useTemplateRef } from 'vue';
|
||||
|
||||
const model = defineModel<TransactionDetailLineItem[]>({ required: true })
|
||||
defineProps<{
|
||||
currency: Currency | null,
|
||||
categories: TransactionCategoryTree[]
|
||||
}>()
|
||||
|
||||
const addLineItemDescription = ref('')
|
||||
const addLineItemValuePerItem = ref(0)
|
||||
const addLineItemQuantity = ref(0)
|
||||
const addLineItemCategory: Ref<TransactionCategoryTree | null> = ref(null)
|
||||
const addLineItemModal = useTemplateRef('addLineItemModal')
|
||||
|
||||
|
||||
function canAddLineItem() {
|
||||
return addLineItemDescription.value.length > 0 &&
|
||||
addLineItemQuantity.value > 0
|
||||
}
|
||||
|
||||
function showAddLineItemModal() {
|
||||
addLineItemDescription.value = ''
|
||||
addLineItemValuePerItem.value = 1.00
|
||||
addLineItemQuantity.value = 1
|
||||
addLineItemCategory.value = null
|
||||
addLineItemModal.value?.show()
|
||||
}
|
||||
|
||||
function addLineItem() {
|
||||
const idxs: number[] = model.value.map(i => i.idx)
|
||||
const newIdx = Math.max(...idxs)
|
||||
model.value.push({
|
||||
idx: newIdx,
|
||||
description: addLineItemDescription.value,
|
||||
quantity: addLineItemQuantity.value,
|
||||
valuePerItem: addLineItemValuePerItem.value,
|
||||
category: addLineItemCategory.value
|
||||
})
|
||||
addLineItemModal.value?.close()
|
||||
}
|
||||
|
||||
function removeLineItem(idx: number) {
|
||||
model.value = model.value.filter(i => i.idx !== idx)
|
||||
for (let i = 0; i < model.value.length; i++) {
|
||||
model.value[i].idx = i
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<FormGroup>
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Value</th>
|
||||
<th>Quantity</th>
|
||||
<th>Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="lineItem in model" :key="lineItem.idx">
|
||||
<td>{{ lineItem.description }}</td>
|
||||
<td style="text-align: right;">{{ currency ? formatMoney(lineItem.valuePerItem, currency) :
|
||||
lineItem.valuePerItem }}</td>
|
||||
<td style="text-align: right;">{{ lineItem.quantity }}</td>
|
||||
<td style="text-align: right;">{{ lineItem.category?.name ?? 'None' }}</td>
|
||||
<td>
|
||||
<AppButton icon="trash" @click="removeLineItem(lineItem.idx)"></AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="model.length === 0">
|
||||
<td colspan="4">No line items present.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<AppButton button-type="button" @click="showAddLineItemModal()">Add</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Modal for adding a new line item. -->
|
||||
<ModalWrapper ref="addLineItemModal">
|
||||
<template v-slot:default>
|
||||
<h3>Add Line Item</h3>
|
||||
<FormGroup>
|
||||
<FormControl label="Description">
|
||||
<textarea v-model="addLineItemDescription"></textarea>
|
||||
</FormControl>
|
||||
<FormControl label="Value Per Item">
|
||||
<input type="number" step="0.01" v-model="addLineItemValuePerItem" />
|
||||
</FormControl>
|
||||
<FormControl label="Quantity">
|
||||
<input type="number" step="1" min="1" v-model="addLineItemQuantity" />
|
||||
</FormControl>
|
||||
<FormControl label="Category">
|
||||
<select v-model="addLineItemCategory">
|
||||
<option v-for="category in categories" :key="category.id" :value="category">
|
||||
{{ " ".repeat(4 * category.depth) + category.name }}
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton @click="addLineItem()" :disabled="!canAddLineItem()">Add</AppButton>
|
||||
<AppButton button-style="secondary" @click="addLineItemModal?.close()">Cancel</AppButton>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</FormGroup>
|
||||
</template>
|
|
@ -7,8 +7,8 @@ defineProps<{ submitText?: string, cancelText?: string, disabled?: boolean }>()
|
|||
<template>
|
||||
<div class="app-form-actions">
|
||||
<AppButton button-type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
|
||||
<AppButton button-style="secondary" @click="$emit('cancel')" :disabled="disabled ?? false">{{ cancelText ?? 'Cancel'
|
||||
}}</AppButton>
|
||||
<AppButton button-style="secondary" @click="$emit('cancel')">{{ cancelText ?? 'Cancel'
|
||||
}}</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="css">
|
||||
|
|
|
@ -23,4 +23,24 @@ defineProps<{
|
|||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Styles for different form controls under here: */
|
||||
|
||||
.app-form-control>label>input {
|
||||
font-size: 16px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.app-form-control>label>textarea {
|
||||
font-size: 16px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.app-form-control>label>select {
|
||||
font-size: 16px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -36,7 +36,7 @@ async function deleteAccount() {
|
|||
try {
|
||||
const api = new AccountApiClient(profileStore.state)
|
||||
await api.deleteAccount(account.value.id)
|
||||
await router.replace('/')
|
||||
await router.replace(`/profiles/${profileStore.state.name}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
|
|
@ -2,10 +2,11 @@
|
|||
import { ApiError } from '@/api/base';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction';
|
||||
import AppButton from '@/components/AppButton.vue';
|
||||
import AppPage from '@/components/AppPage.vue';
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
import { showAlert } from '@/util/alert';
|
||||
import { showAlert, showConfirm } from '@/util/alert';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
|
@ -33,6 +34,18 @@ onMounted(async () => {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function deleteTransaction() {
|
||||
if (!transaction.value || !profileStore.state) return
|
||||
const conf = await showConfirm('Are you sure you want to delete this transaction? This will permanently delete all data pertaining to this transaction, and it cannot be recovered.')
|
||||
if (!conf) return
|
||||
try {
|
||||
await new TransactionApiClient(profileStore.state).deleteTransaction(transaction.value.id)
|
||||
await router.replace(`/profiles/${profileStore.state.name}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
|
||||
|
@ -108,5 +121,11 @@ onMounted(async () => {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<AppButton icon="wrench"
|
||||
@click="router.push(`/profiles/${profileStore.state?.name}/transactions/${transaction.id}/edit`)">Edit
|
||||
</AppButton>
|
||||
<AppButton icon="trash" @click="deleteTransaction()">Delete</AppButton>
|
||||
</div>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
@ -26,7 +26,6 @@ const currency = ref('USD')
|
|||
const description = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
console.log(route)
|
||||
if (!profileStore.state) return
|
||||
|
||||
const accountIdStr = route.params.id
|
||||
|
|
|
@ -0,0 +1,351 @@
|
|||
<!--
|
||||
This page is quite large, and handles the form in which users can create and
|
||||
edit transactions. It's accessed through two routes:
|
||||
- /profiles/:profileName/transactions/:transactionId for editing
|
||||
- /profiles/:profileName/add-transaction for creating a new transaction
|
||||
|
||||
The form consists of a few main sections:
|
||||
- Standard form controls for various fields like timestamp, amount, description, etc.
|
||||
- Line items table for editing the list of line items.
|
||||
- Tags editor for editing the set of tags.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account';
|
||||
import { DataApiClient, type Currency } from '@/api/data';
|
||||
import { TransactionApiClient, type AddTransactionPayload, type TransactionCategoryTree, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
||||
import AppPage from '@/components/AppPage.vue';
|
||||
import AppForm from '@/components/form/AppForm.vue';
|
||||
import FormActions from '@/components/form/FormActions.vue';
|
||||
import FormControl from '@/components/form/FormControl.vue';
|
||||
import FormGroup from '@/components/form/FormGroup.vue';
|
||||
import LineItemsEditor from '@/components/LineItemsEditor.vue';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
import { computed, onMounted, ref, watch, type Ref } from 'vue';
|
||||
import { useRoute, useRouter, } from 'vue-router';
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
|
||||
const existingTransaction: Ref<TransactionDetail | null> = ref(null)
|
||||
const editing = computed(() => {
|
||||
return existingTransaction.value !== null || route.meta.title === 'Edit Transaction'
|
||||
})
|
||||
|
||||
// General data used to populate form controls.
|
||||
const allCurrencies: Ref<Currency[]> = ref([])
|
||||
const availableCurrencies = computed(() => {
|
||||
return allCurrencies.value.filter(c => allAccounts.value.some(a => a.currency === c.code))
|
||||
})
|
||||
const availableVendors: Ref<TransactionVendor[]> = ref([])
|
||||
const availableCategories: Ref<TransactionCategoryTree[]> = ref([])
|
||||
const allAccounts: Ref<Account[]> = ref([])
|
||||
const availableAccounts = computed(() => {
|
||||
return allAccounts.value.filter(a => a.currency === currency.value?.code)
|
||||
})
|
||||
const allTags: Ref<string[]> = ref([])
|
||||
const availableTags = computed(() => {
|
||||
return allTags.value.filter(t => !tags.value.includes(t))
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
// Form data:
|
||||
const timestamp = ref('')
|
||||
const amount = ref(0)
|
||||
const currency: Ref<Currency | null> = ref(null)
|
||||
const description = ref('')
|
||||
const vendorId: Ref<number | null> = ref(null)
|
||||
const categoryId: Ref<number | null> = ref(null)
|
||||
const creditedAccountId: Ref<number | null> = ref(null)
|
||||
const debitedAccountId: Ref<number | null> = ref(null)
|
||||
const lineItems: Ref<TransactionDetailLineItem[]> = ref([])
|
||||
const tags: Ref<string[]> = ref([])
|
||||
const selectedTagToAdd: Ref<string | null> = ref(null)
|
||||
const customTagInput = ref('')
|
||||
const customTagInputValid = ref(false)
|
||||
|
||||
watch(customTagInput, (newValue: string) => {
|
||||
const result = newValue.match("^[a-z0-9-_]{3,32}$")
|
||||
customTagInputValid.value = result !== null && result.length > 0
|
||||
})
|
||||
watch(availableCurrencies, (newValue: Currency[]) => {
|
||||
if (newValue.length === 1) {
|
||||
currency.value = newValue[0]
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!profileStore.state) return
|
||||
const dataClient = new DataApiClient()
|
||||
const transactionClient = new TransactionApiClient(profileStore.state)
|
||||
const accountClient = new AccountApiClient(profileStore.state)
|
||||
|
||||
// Fetch various collections of data needed for different user choices.
|
||||
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies)
|
||||
transactionClient.getVendors().then(vendors => availableVendors.value = vendors)
|
||||
transactionClient.getAllTags().then(t => allTags.value = t)
|
||||
transactionClient.getCategories().then(categories => {
|
||||
// Flatten the recursive list of categories.
|
||||
const flattened: TransactionCategoryTree[] = []
|
||||
flattenCategories(flattened, categories)
|
||||
availableCategories.value = flattened
|
||||
})
|
||||
accountClient.getAccounts().then(accounts => allAccounts.value = accounts)
|
||||
|
||||
|
||||
const transactionIdStr = route.params.id
|
||||
if (transactionIdStr && typeof (transactionIdStr) === 'string') {
|
||||
const transactionId = parseInt(transactionIdStr)
|
||||
try {
|
||||
loading.value = true
|
||||
existingTransaction.value = await transactionClient.getTransaction(transactionId)
|
||||
loadValuesFromExistingTransaction(existingTransaction.value)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function flattenCategories(arr: TransactionCategoryTree[], tree: TransactionCategoryTree[]) {
|
||||
for (const category of tree) {
|
||||
arr.push(category)
|
||||
flattenCategories(arr, category.children)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the transaction. If the user is editing an existing transaction,
|
||||
* then that transaction will be updated. Otherwise, a new transaction is
|
||||
* created.
|
||||
*/
|
||||
async function doSubmit() {
|
||||
if (!profileStore.state) return
|
||||
|
||||
const localDate = new Date(timestamp.value)
|
||||
const scaledAmount = amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0)
|
||||
const payload: AddTransactionPayload = {
|
||||
timestamp: localDate.toISOString(),
|
||||
amount: scaledAmount,
|
||||
currencyCode: currency.value?.code ?? '',
|
||||
description: description.value,
|
||||
vendorId: vendorId.value,
|
||||
categoryId: categoryId.value,
|
||||
creditedAccountId: creditedAccountId.value,
|
||||
debitedAccountId: debitedAccountId.value,
|
||||
tags: tags.value,
|
||||
lineItems: lineItems.value.map(i => {
|
||||
return { ...i, categoryId: i.category?.id ?? null }
|
||||
})
|
||||
}
|
||||
|
||||
const transactionApi = new TransactionApiClient(profileStore.state)
|
||||
let savedTransaction = null
|
||||
try {
|
||||
loading.value = true
|
||||
if (existingTransaction.value) {
|
||||
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload)
|
||||
} else {
|
||||
savedTransaction = await transactionApi.addTransaction(payload)
|
||||
}
|
||||
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels editing / submitting a transaction, and takes the user back to their
|
||||
* profile's homepage.
|
||||
*/
|
||||
function doCancel() {
|
||||
if (!profileStore.state) return
|
||||
if (editing.value) {
|
||||
router.replace(`/profiles/${profileStore.state.name}/transactions/${existingTransaction.value?.id}`)
|
||||
} else {
|
||||
router.replace(`/profiles/${profileStore.state.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
if (customTagInput.value.trim().length > 0) {
|
||||
tags.value.push(customTagInput.value.trim())
|
||||
tags.value.sort()
|
||||
customTagInput.value = ''
|
||||
} else if (selectedTagToAdd.value !== null) {
|
||||
tags.value.push(selectedTagToAdd.value)
|
||||
tags.value.sort()
|
||||
selectedTagToAdd.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function loadValuesFromExistingTransaction(t: TransactionDetail) {
|
||||
timestamp.value = getLocalDateTimeStringFromUTCTimestamp(t.timestamp)
|
||||
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
|
||||
currency.value = t.currency
|
||||
description.value = t.description
|
||||
vendorId.value = t.vendor?.id ?? null
|
||||
categoryId.value = t.category?.id ?? null
|
||||
creditedAccountId.value = t.creditedAccount?.id ?? null
|
||||
debitedAccountId.value = t.debitedAccount?.id ?? null
|
||||
lineItems.value = [...t.lineItems]
|
||||
tags.value = [...t.tags]
|
||||
}
|
||||
|
||||
function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
date.setMilliseconds(0)
|
||||
const timezoneOffset = new Date().getTimezoneOffset() * 60_000
|
||||
return (new Date(date.getTime() - timezoneOffset)).toISOString().slice(0, -1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the form is valid, which if true, means the user is allowed
|
||||
* to save the form.
|
||||
*/
|
||||
function isFormValid() {
|
||||
return timestamp.value.length > 0 &&
|
||||
amount.value > 0 &&
|
||||
currency.value !== null &&
|
||||
description.value.length > 0 &&
|
||||
(
|
||||
creditedAccountId.value !== null ||
|
||||
debitedAccountId.value !== null
|
||||
) &&
|
||||
creditedAccountId.value !== debitedAccountId.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the user's editing an existing transaction, and there is at
|
||||
* least one edit to it. Otherwise, there's no point in saving.
|
||||
*/
|
||||
function isEdited() {
|
||||
if (!existingTransaction.value) return false
|
||||
const tagsEqual = tags.value.every(t => existingTransaction.value?.tags.includes(t)) &&
|
||||
existingTransaction.value.tags.every(t => tags.value.includes(t))
|
||||
let lineItemsEqual = false
|
||||
if (lineItems.value.length === existingTransaction.value.lineItems.length) {
|
||||
lineItemsEqual = true
|
||||
for (let i = 0; i < lineItems.value.length; i++) {
|
||||
const i1 = lineItems.value[i]
|
||||
const i2 = existingTransaction.value.lineItems[i]
|
||||
if (
|
||||
i1.idx !== i2.idx ||
|
||||
i1.quantity !== i2.quantity ||
|
||||
i1.valuePerItem !== i2.valuePerItem ||
|
||||
i1.description !== i2.description ||
|
||||
(i1.category?.id ?? null) !== (i2.category?.id ?? null)
|
||||
) {
|
||||
lineItemsEqual = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
|
||||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
|
||||
currency.value !== existingTransaction.value.currency ||
|
||||
description.value !== existingTransaction.value.description ||
|
||||
vendorId.value !== (existingTransaction.value.vendor?.id ?? null) ||
|
||||
categoryId.value !== (existingTransaction.value.category?.id ?? null) ||
|
||||
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
|
||||
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
|
||||
!tagsEqual ||
|
||||
!lineItemsEqual
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppPage :title="editing ? 'Edit Transaction' : 'Add Transaction'">
|
||||
<AppForm @submit="doSubmit()">
|
||||
<FormGroup>
|
||||
<!-- Basic properties -->
|
||||
<FormControl label="Timestamp">
|
||||
<input type="datetime-local" v-model="timestamp" step="1" :disabled="loading" style="min-width: 250px;" />
|
||||
</FormControl>
|
||||
<FormControl label="Amount">
|
||||
<input type="number" v-model="amount" step="0.01" min="0.01" :disabled="loading" style="max-width: 100px;" />
|
||||
</FormControl>
|
||||
<FormControl label="Currency">
|
||||
<select v-model="currency" :disabled="loading || availableCurrencies.length === 1">
|
||||
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
|
||||
{{ currency.code }}
|
||||
</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Description" style="min-width: 200px;">
|
||||
<textarea v-model="description" :disabled="loading"></textarea>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Vendor & Category -->
|
||||
<FormControl label="Vendor">
|
||||
<select v-model="vendorId" :disabled="loading">
|
||||
<option v-for="vendor in availableVendors" :key="vendor.id" :value="vendor.id">
|
||||
{{ vendor.name }}
|
||||
</option>
|
||||
<option :value="null" :selected="vendorId === null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Category">
|
||||
<select v-model="categoryId" :disabled="loading">
|
||||
<option v-for="category in availableCategories" :key="category.id" :value="category.id">
|
||||
{{ " ".repeat(4 * category.depth) + category.name }}
|
||||
</option>
|
||||
<option :value="null" :selected="categoryId === null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Accounts -->
|
||||
<FormControl label="Credited Account">
|
||||
<select v-model="creditedAccountId" :disabled="loading">
|
||||
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }} ({{ account.numberSuffix }})
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Debited Account">
|
||||
<select v-model="debitedAccountId" :disabled="loading">
|
||||
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }} ({{ account.numberSuffix }})
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<LineItemsEditor v-model="lineItems" :currency="currency" :categories="availableCategories" />
|
||||
|
||||
<FormGroup>
|
||||
<!-- Tags -->
|
||||
<FormControl label="Tags">
|
||||
<div style="margin-top: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<span v-for="tag in tags" :key="tag"
|
||||
style="margin: 0.25rem; padding: 0.25rem 0.75rem; background-color: var(--bg-secondary); border-radius: 0.5rem;">
|
||||
{{ tag }}
|
||||
<font-awesome-icon :icon="'fa-x'" style="color: gray; cursor: pointer;"
|
||||
@click="tags = tags.filter(t => t !== tag)"></font-awesome-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<select v-model="selectedTagToAdd">
|
||||
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
|
||||
</select>
|
||||
<input v-model="customTagInput" placeholder="Custom tag..." />
|
||||
<button type="button" @click="addTag()" :disabled="selectedTagToAdd === null && !customTagInputValid">Add
|
||||
Tag</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
||||
:submit-text="editing ? 'Save' : 'Add'" />
|
||||
</AppForm>
|
||||
</AppPage>
|
||||
</template>
|
|
@ -2,11 +2,14 @@
|
|||
import { formatMoney } from '@/api/data';
|
||||
import type { Page, PageRequest } from '@/api/pagination';
|
||||
import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction';
|
||||
import AppButton from '@/components/AppButton.vue';
|
||||
import HomeModule from '@/components/HomeModule.vue';
|
||||
import PaginationControls from '@/components/PaginationControls.vue';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const transactions: Ref<Page<TransactionsListItem>> = ref({ items: [], pageRequest: { page: 1, size: 10, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true })
|
||||
|
||||
|
@ -56,5 +59,9 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
</table>
|
||||
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
|
||||
</template>
|
||||
<template v-slot:actions>
|
||||
<AppButton icon="plus" @click="router.push(`/profiles/${profileStore.state?.name}/add-transaction`)">Add
|
||||
Transaction</AppButton>
|
||||
</template>
|
||||
</HomeModule>
|
||||
</template>
|
||||
|
|
|
@ -58,6 +58,16 @@ const router = createRouter({
|
|||
component: () => import('@/pages/TransactionPage.vue'),
|
||||
meta: { title: 'Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'transactions/:id/edit',
|
||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
meta: { title: 'Edit Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'add-transaction',
|
||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
meta: { title: 'Add Transaction' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
Loading…
Reference in New Issue