Added functioning account editor.
Build and Deploy Web App / build-and-deploy (push) Successful in 19s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m20s Details

This commit is contained in:
Andrew Lalis 2025-08-16 20:34:42 -04:00
parent 0b67bb605b
commit e29d4e1c0f
23 changed files with 962 additions and 122 deletions

View File

@ -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 thats painted orange.
Lowe's,Hardware store thats 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 McDonalds 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.

1 Amazon Online retailer for pretty much anything.
2 eBay Website to buy and bid on stuff from people.
3 Walmart Big, ugly general store.
4 Target Slightly less ugly general store.
5 Best Buy Large tech-focused store.
6 Costco Wholesale club grocery store.
7 Home Depot Hardware store that’s painted orange.
8 Lowe's Hardware store that’s painted blue.
9 Kroger A giant grocery conglomerate.
10 CVS A chain of corner-store pharmacies.
11 Walgreens Another chain of corner-store pharmacies.
12 Starbucks Coffee distributor.
13 McDonald's The most famous fast-food.
14 Burger King Second to McDonald’s for burger fast-food.
15 Subway An outdated sandwich shop.
16 Pizza Hut Pizza chain restaurant.
17 Domino's Another pizza chain restaurant.
18 Chipotle “Mexican” fast food place.
19 Taco Bell Another “Mexican” fast food place.
20 Panera Bread Some random breakfast restaurant.
21 Dunkin' Coffee and donuts shop.
22 Chick-fil-A Chicken shop that contributes to anti-gay politics.
23 Advance Auto Parts Car parts store.
24 AutoZone Another car parts store.
25 Delta Air Lines The most popular american air carrier.
26 American Airlines Another american air carrier, based in Charlotte, North Carolina.
27 United Airlines An american air carrier based in Chicago, Illinois.
28 Squarespace Online website and domain name seller.
29 DigitalOcean Cloud hosting provider.
30 GitHub Source code repository provider owned by Microsoft.
31 Heroku Some other cloud hosting provider.
32 Stripe Payment provider.
33 PayPal Another payment provider.
34 Verizon American phone carrier.
35 ALDI Multinational minimalist grocery store.
36 IKEA Swedish build-it-yourself furniture store.
37 Primark Irish budget clothing store.
38 H&M Budget european clothing store.
39 Petco Pet store.
40 China King Some random chinese store.
41 GEICO Car insurance.
42 Bank of America A bank.
43 Citi Another bank.

View File

@ -32,4 +32,5 @@ interface AccountJournalEntryRepository {
Currency currency
);
void deleteById(ulong id);
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId);
}

View File

@ -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;

View File

@ -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:

View File

@ -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
}

View File

@ -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);
}

View File

@ -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
);
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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

View File

@ -0,0 +1,9 @@
UPDATE "transaction"
SET
timestamp = ?,
amount = ?,
currency = ?,
description = ?,
vendor_id = ?,
category_id = ?
WHERE id = ?

View File

@ -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')
}
}

View File

@ -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) {

View File

@ -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">
{{ "&nbsp;".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>

View File

@ -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">

View File

@ -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>

View File

@ -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)
}

View File

@ -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>

View File

@ -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

View File

@ -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">
{{ "&nbsp;".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>

View File

@ -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>

View File

@ -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' },
},
],
},
],