WIP: Add Drafts, Templates, and Recurring Transactions #45

Draft
andrew wants to merge 18 commits from drafts into main
54 changed files with 3265 additions and 1190 deletions

View File

@ -0,0 +1,22 @@
name: Build and Test API
on:
push:
paths:
- 'finnow-api/**'
- '.gitea/workflows/api-dev.yaml'
branches-ignore:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-latest
- name: Test
run: dub test
working-directory: ./finnow-api
- name: Build
run: dub build --build=release
working-directory: ./finnow-api

View File

@ -4,6 +4,8 @@ on:
paths: paths:
- 'finnow-api/**' - 'finnow-api/**'
- '.gitea/workflows/api.yaml' - '.gitea/workflows/api.yaml'
branches:
- main
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -0,0 +1,24 @@
name: Build Web App
on:
push:
paths:
- 'web-app/**'
- '.gitea/workflows/web-app-dev.yaml'
branches-ignore:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.x'
- name: Enable Corepack
run: corepack enable
- name: Install Dependencies
run: yarn install --immutable
working-directory: ./web-app
- name: Build
run: yarn build
working-directory: ./web-app

View File

@ -4,6 +4,8 @@ on:
paths: paths:
- 'web-app/**' - 'web-app/**'
- '.gitea/workflows/web-app.yaml' - '.gitea/workflows/web-app.yaml'
branches:
- main
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -4,6 +4,7 @@
], ],
"copyright": "Copyright © 2024, Andrew Lalis", "copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": { "dependencies": {
"cronexp": ">=0.1.0-beta3 <0.2.0-0",
"d2sqlite3": "~>1.0", "d2sqlite3": "~>1.0",
"handy-http-starter": "~>1.6", "handy-http-starter": "~>1.6",
"jwt4d": "~>0.0.2", "jwt4d": "~>0.0.2",

View File

@ -2,12 +2,13 @@
"fileVersion": 1, "fileVersion": 1,
"versions": { "versions": {
"asdf": "0.8.0", "asdf": "0.8.0",
"cronexp": "0.1.0-beta3",
"d2sqlite3": "1.0.0", "d2sqlite3": "1.0.0",
"dxml": "0.4.5", "dxml": "0.4.5",
"handy-http-data": "1.3.0", "handy-http-data": "1.3.2",
"handy-http-handlers": "1.3.0", "handy-http-handlers": "1.3.0",
"handy-http-primitives": "1.8.1", "handy-http-primitives": "1.10.0",
"handy-http-starter": "1.7.0", "handy-http-starter": "1.7.1",
"handy-http-transport": "1.10.1", "handy-http-transport": "1.10.1",
"handy-http-websockets": "1.2.0", "handy-http-websockets": "1.2.0",
"jwt4d": "0.0.2", "jwt4d": "0.0.2",
@ -15,7 +16,7 @@
"mir-core": "1.7.4", "mir-core": "1.7.4",
"openssl": "3.3.4", "openssl": "3.3.4",
"path-matcher": "1.2.0", "path-matcher": "1.2.0",
"photon": "0.18.11", "photon": "0.18.12",
"scheduled": "1.4.0", "scheduled": "1.4.0",
"secured": "3.0.0", "secured": "3.0.0",
"sharded-map": "2.7.0", "sharded-map": "2.7.0",

View File

@ -202,12 +202,12 @@ SQL",
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) { private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
return util.sqlite.findOne( return util.sqlite.findOne(
db, db,
"SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~ "SELECT vr.id, vr.type, vr.value, vr.currency, vr.timestamp FROM history_item_linked_value_record h " ~
"LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~ "LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~
"WHERE h.item_id = ?", "WHERE h.item_id = ?",
(row) { (row) {
auto obj = new AccountHistoryValueRecordItemResponse(); auto obj = new AccountHistoryValueRecordItemResponse();
obj.timestamp = item.timestamp; obj.timestamp = row.peek!string(4);
obj.type = item.type; obj.type = item.type;
obj.valueRecordId = row.peek!ulong(0); obj.valueRecordId = row.peek!ulong(0);
obj.valueRecordType = row.peek!string(1); obj.valueRecordType = row.peek!string(1);
@ -222,13 +222,14 @@ SQL",
private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) { private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
return util.sqlite.findOne( return util.sqlite.findOne(
db, db,
"SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~ "SELECT je.type, je.amount, je.currency, tx.id, tx.description, tx.timestamp " ~
"FROM history_item_linked_journal_entry h " ~
"LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~ "LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~
"LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~ "LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~
"WHERE h.item_id = ?", "WHERE h.item_id = ?",
(row) { (row) {
auto obj = new AccountHistoryJournalEntryItemResponse(); auto obj = new AccountHistoryJournalEntryItemResponse();
obj.timestamp = item.timestamp; obj.timestamp = row.peek!string(5);
obj.type = item.type; obj.type = item.type;
obj.journalEntryType = row.peek!string(0); obj.journalEntryType = row.peek!string(0);
obj.amount = row.peek!ulong(1); obj.amount = row.peek!ulong(1);

View File

@ -5,12 +5,9 @@ import account.model;
import attachment.data; import attachment.data;
import attachment.dto; import attachment.dto;
import util.money; import util.money;
import util.data : serializeOptional;
/// The data the API provides for an Account entity. /// The data the API provides for an Account entity.
struct AccountResponse { struct AccountResponse {
import asdf : serdeTransformOut;
ulong id; ulong id;
string createdAt; string createdAt;
bool archived; bool archived;
@ -19,7 +16,6 @@ struct AccountResponse {
string name; string name;
Currency currency; Currency currency;
string description; string description;
@serdeTransformOut!serializeOptional
Optional!long currentBalance; Optional!long currentBalance;
static AccountResponse of(in Account account, Optional!long currentBalance) { static AccountResponse of(in Account account, Optional!long currentBalance) {
@ -37,6 +33,14 @@ struct AccountResponse {
} }
} }
/// A limited set of account data, usually for use in other responses.
struct SimpleAccountResponse {
ulong id;
string name;
string type;
string numberSuffix;
}
// The data provided by a user to create a new account. // The data provided by a user to create a new account.
struct AccountCreationPayload { struct AccountCreationPayload {
string type; string type;

View File

@ -2,7 +2,6 @@ module analytics.data;
import std.datetime; import std.datetime;
import handy_http_primitives : Optional; import handy_http_primitives : Optional;
import asdf : serdeTransformOut;
import util.money; import util.money;
import util.data; import util.data;
@ -36,7 +35,6 @@ struct CategorySpendData {
ulong categoryId; ulong categoryId;
string categoryName; string categoryName;
string categoryColor; string categoryColor;
@serdeTransformOut!serializeOptional
Optional!ulong parentCategoryId; Optional!ulong parentCategoryId;
long amount; long amount;
} }

View File

@ -9,7 +9,6 @@ import std.algorithm;
import std.array; import std.array;
import std.conv; import std.conv;
import slf4d; import slf4d;
import asdf;
import profile.data; import profile.data;
import profile.model; import profile.model;

View File

@ -25,7 +25,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
publicHandler.registerHandlers!(auth.api_public); publicHandler.registerHandlers!(auth.api_public);
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!! // Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
// h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint); publicHandler.addMapping(HttpMethod.POST, "/api/sample-data", HttpRequestHandler.of(&sampleDataEndpoint));
// Authenticated endpoints: // Authenticated endpoints:
import auth.api; import auth.api;

View File

@ -9,6 +9,7 @@ interface AttachmentRepository {
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId); Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
Attachment[] findAllByTransactionId(ulong transactionId); Attachment[] findAllByTransactionId(ulong transactionId);
Attachment[] findAllByValueRecordId(ulong valueRecordId); Attachment[] findAllByValueRecordId(ulong valueRecordId);
Attachment[] findAllByTransactionDraftId(ulong draftId);
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content); ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
void remove(ulong id); void remove(ulong id);
Optional!(ubyte[]) getContent(ulong id); Optional!(ubyte[]) getContent(ulong id);

View File

@ -46,6 +46,13 @@ class SqliteAttachmentRepository : AttachmentRepository {
); );
} }
Attachment[] findAllByTransactionDraftId(ulong draftId) {
return findAllByLinkedEntity(
"SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?",
draftId
);
}
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) { ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) {
util.sqlite.update( util.sqlite.update(
db, db,

View File

@ -53,6 +53,8 @@ interface ProfileDataSource {
TransactionCategoryRepository getTransactionCategoryRepository(); TransactionCategoryRepository getTransactionCategoryRepository();
TransactionTagRepository getTransactionTagRepository(); TransactionTagRepository getTransactionTagRepository();
TransactionRepository getTransactionRepository(); TransactionRepository getTransactionRepository();
TransactionDraftRepository getTransactionDraftRepository();
RecurringTransactionRepository getRecurringTransactionRepository();
AnalyticsRepository getAnalyticsRepository(); AnalyticsRepository getAnalyticsRepository();
@ -93,6 +95,12 @@ version(unittest) {
TransactionRepository getTransactionRepository() { TransactionRepository getTransactionRepository() {
throw new Exception("Not implemented"); throw new Exception("Not implemented");
} }
TransactionDraftRepository getTransactionDraftRepository() {
throw new Exception("Not implemented");
}
RecurringTransactionRepository getRecurringTransactionRepository() {
throw new Exception("Not implemented");
}
AnalyticsRepository getAnalyticsRepository() { AnalyticsRepository getAnalyticsRepository() {
throw new Exception("Not implemented"); throw new Exception("Not implemented");
} }

View File

@ -185,7 +185,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
} }
private const SCHEMA = import("sql/schema.sql"); private const SCHEMA = import("sql/schema.sql");
private const uint SCHEMA_VERSION = 1; private const uint SCHEMA_VERSION = 2;
private const SCHEMA_VERSION_PROPERTY = "database-schema-version"; private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
/** /**
@ -215,6 +215,8 @@ class SqliteProfileDataSource : ProfileDataSource {
TransactionCategoryRepository transactionCategoryRepo; TransactionCategoryRepository transactionCategoryRepo;
TransactionTagRepository transactionTagRepo; TransactionTagRepository transactionTagRepo;
TransactionRepository transactionRepo; TransactionRepository transactionRepo;
TransactionDraftRepository transactionDraftRepo;
RecurringTransactionRepository recurringTransactionRepo;
AnalyticsRepository analyticsRepo; AnalyticsRepository analyticsRepo;
this(string path) { this(string path) {
@ -297,6 +299,20 @@ class SqliteProfileDataSource : ProfileDataSource {
return transactionRepo; return transactionRepo;
} }
TransactionDraftRepository getTransactionDraftRepository() {
if (transactionDraftRepo is null) {
transactionDraftRepo = new SqliteTransactionDraftRepository(db);
}
return transactionDraftRepo;
}
RecurringTransactionRepository getRecurringTransactionRepository() {
if (recurringTransactionRepo is null) {
recurringTransactionRepo = new SqliteRecurringTransactionRepository(db);
}
return recurringTransactionRepo;
}
AnalyticsRepository getAnalyticsRepository() { AnalyticsRepository getAnalyticsRepository() {
if (analyticsRepo is null) { if (analyticsRepo is null) {
analyticsRepo = new SqliteAnalyticsRepository(db); analyticsRepo = new SqliteAnalyticsRepository(db);
@ -322,7 +338,8 @@ class SqliteProfileDataSource : ProfileDataSource {
if (currentVersion == SCHEMA_VERSION) return; if (currentVersion == SCHEMA_VERSION) return;
static const migrations = [ static const migrations = [
import("sql/migrations/1.sql") import("sql/migrations/1.sql"),
import("sql/migrations/2.sql")
]; ];
static if (migrations.length != SCHEMA_VERSION) { static if (migrations.length != SCHEMA_VERSION) {
static assert(false, "Schema version doesn't match the list of defined migrations."); static assert(false, "Schema version doesn't match the list of defined migrations.");

View File

@ -11,6 +11,7 @@ void startScheduledJobs() {
); );
JobScheduler jobScheduler = new TaskPoolScheduler(); JobScheduler jobScheduler = new TaskPoolScheduler();
jobScheduler.addJob(() { jobScheduler.addJob(() {
// Clear old analytics data from profiles. // Clear old analytics data from profiles.
import profile.data; import profile.data;
@ -38,5 +39,32 @@ void startScheduledJobs() {
); );
}, analyticsSchedule); }, analyticsSchedule);
// Add a scheduled job to regularly check for and create recurring transaction drafts.
jobScheduler.addJob(() {
import profile.data;
import profile.data_impl_sqlite;
import profile.model;
import transaction.dto;
import transaction.data;
import util.pagination;
import std.stdio;
import std.datetime;
import cronexp;
FileSystemProfileRepository.doForAllUserProfiles((Profile profile, ProfileRepository profileRepo) {
writefln!"Recurring transaction check: %s / %s"(profile.username, profile.name);
ProfileDataSource ds = profileRepo.getDataSource(profile);
RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
foreach (rt; result.items) {
writeln(rt.scheduleExpr);
DateTime now = cast(DateTime) Clock.currTime();
auto cron = CronExpr(rt.scheduleExpr);
writefln!"scheduleExpr = %s -> %s"(rt.scheduleExpr, cron.getNext(now));
// TODO: Figure out how to actually create the transactions!
}
});
}, new FixedIntervalSchedule(minutes(1), Clock.currTime(UTC())));
jobScheduler.start(); jobScheduler.start();
} }

View File

@ -185,7 +185,7 @@ struct CategoryPayload {
string name; string name;
string description; string description;
string color; string color;
Nullable!ulong parentId; Optional!ulong parentId;
} }
@PostMapping(PROFILE_PATH ~ "/categories") @PostMapping(PROFILE_PATH ~ "/categories")
@ -215,3 +215,95 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse
private ulong getCategoryId(in ServerHttpRequest request) { private ulong getCategoryId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "categoryId"); return getPathParamOrThrow!ulong(request, "categoryId");
} }
// Drafts & Templates & Recurring Transactions
immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("draft.id", SortDir.DESC)]);
@GetMapping(PROFILE_PATH ~ "/transaction-drafts")
void handleGetDrafts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE);
Page!TransactionDraftListItem page = getDrafts(ds, pr);
writeJsonBody(response, page);
}
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
void handleGetDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
TransactionDraftResponse draft = getDraft(ds, getDraftId(request));
import asdf : serializeToJson;
string jsonStr = serializeToJson(draft);
response.writeBodyString(jsonStr, "application/json");
}
@PostMapping(PROFILE_PATH ~ "/transaction-drafts")
void handleAddDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto fullPayload = parseMultipartFilesAndBody!TransactionDraftPayload(request);
ProfileDataSource ds = getProfileDataSource(request);
TransactionDraftResponse draft = addDraft(ds, fullPayload.payload, fullPayload.files);
import asdf : serializeToJson;
string jsonStr = serializeToJson(draft);
response.writeBodyString(jsonStr, "application/json");
}
@PutMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
void handleUpdateDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
auto fullPayload = parseMultipartFilesAndBody!TransactionDraftPayload(request);
TransactionDraftResponse draft = updateDraft(ds, getDraftId(request), fullPayload.payload, fullPayload.files);
import asdf : serializeToJson;
string jsonStr = serializeToJson(draft);
response.writeBodyString(jsonStr, "application/json");
}
@DeleteMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
void handleDeleteDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
ulong draftId = getDraftId(request);
deleteDraft(ds, draftId);
}
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong/recurring-transactions")
void handleGetDraftRecurringTransactions(
ref ServerHttpRequest request,
ref ServerHttpResponse response
) {
ProfileDataSource ds = getProfileDataSource(request);
ulong draftId = getDraftId(request);
RecurringTransactionResponse[] results = getRecurringTransactionsForDraft(ds, draftId);
writeJsonBody(response, results);
}
private ulong getDraftId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "draftId");
}
immutable DEFAULT_RECURRING_TRANSACTIONS_PAGE = PageRequest(1, 10, [Sort("id", SortDir.DESC)]);
@GetMapping(PROFILE_PATH ~ "/recurring-transactions")
void handleGetRecurringTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_RECURRING_TRANSACTIONS_PAGE);
Page!RecurringTransactionResponse page = getRecurringTransactions(ds, pr);
writeJsonBody(response, page);
}
@PostMapping(PROFILE_PATH ~ "/recurring-transactions")
void handlePostRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
RecurringTransactionPayload payload = readJsonBodyAs!RecurringTransactionPayload(request);
ProfileDataSource ds = getProfileDataSource(request);
RecurringTransactionResponse result = createRecurringTransaction(ds, payload);
writeJsonBody(response, result);
}
@DeleteMapping(PROFILE_PATH ~ "/recurring-transactions/:recurringTransactionId:ulong")
void handleDeleteRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
ulong id = getRecurringTransactionId(request);
deleteRecurringTransaction(ds, id);
}
private ulong getRecurringTransactionId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "recurringTransactionId");
}

View File

@ -36,6 +36,7 @@ interface TransactionCategoryRepository {
); );
} }
// TODO: Migrate into transaction repo, similar to drafts!
interface TransactionTagRepository { interface TransactionTagRepository {
string[] findAllByTransactionId(ulong transactionId); string[] findAllByTransactionId(ulong transactionId);
void updateTags(ulong transactionId, in string[] tags); void updateTags(ulong transactionId, in string[] tags);
@ -52,3 +53,25 @@ interface TransactionRepository {
TransactionDetail update(ulong transactionId, in AddTransactionPayload data); TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
void deleteById(ulong id); void deleteById(ulong id);
} }
interface TransactionDraftRepository {
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr);
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr);
Page!TransactionDraftListItem findAll(in PageRequest pr);
Optional!TransactionDraftResponse findById(ulong id);
TransactionDraftResponse insert(in TransactionDraftPayload data);
void linkAttachment(ulong draftId, ulong attachmentId);
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data);
void deleteById(ulong id);
void updateTags(ulong draftId, in string[] tags);
string[] findAllTags();
}
interface RecurringTransactionRepository {
Page!RecurringTransactionResponse findAll(in PageRequest pr);
RecurringTransactionResponse[] findAllByDraftId(ulong draftId);
Optional!RecurringTransactionResponse findById(ulong id);
RecurringTransactionResponse insert(in RecurringTransactionPayload data);
void deleteById(ulong id);
}

View File

@ -1,6 +1,7 @@
module transaction.data_impl_sqlite; module transaction.data_impl_sqlite;
import handy_http_primitives : Optional, StringMultiValueMap; import handy_http_primitives : Optional, StringMultiValueMap, mapIfPresent, toOptional;
import util.data;
import std.datetime; import std.datetime;
import std.typecons; import std.typecons;
import d2sqlite3; import d2sqlite3;
@ -13,6 +14,8 @@ import util.money;
import util.pagination; import util.pagination;
import util.data; import util.data;
import account.model; import account.model;
import account.dto;
import attachment.dto;
class SqliteTransactionVendorRepository : TransactionVendorRepository { class SqliteTransactionVendorRepository : TransactionVendorRepository {
private Database db; private Database db;
@ -228,7 +231,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
import std.typecons; import std.typecons;
return TransactionCategory( return TransactionCategory(
row.peek!ulong(0), row.peek!ulong(0),
toOptional(row.peek!(Nullable!ulong)(1)), row.parseOptional!ulong(1),
row.peek!string(2), row.peek!string(2),
row.peek!string(3), row.peek!string(3),
row.peek!string(4) row.peek!string(4)
@ -283,12 +286,8 @@ class SqliteTransactionRepository : TransactionRepository {
} }
Page!TransactionsListItem findAll(in PageRequest pr) { Page!TransactionsListItem findAll(in PageRequest pr) {
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql(); string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql();
QueryBuilder qb = getBuilderForTransactionsList(); TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
addSelectsForTransactionsList(qb);
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\""); ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
return Page!(TransactionsListItem).of(results, pr, totalCount); return Page!(TransactionsListItem).of(results, pr, totalCount);
} }
@ -328,10 +327,10 @@ class SqliteTransactionRepository : TransactionRepository {
qb.conditions = []; qb.conditions = [];
qb.argBinders = []; qb.argBinders = [];
addSelectsForTransactionsList(qb); addSelectsForTransactionsList(qb);
qb.groupBy("txn.id");
qb.where("txn.id IN (" ~ idsStr ~ ")"); qb.where("txn.id IN (" ~ idsStr ~ ")");
string query = qb.build() ~ "\n" ~ pr.toOrderClause(); string query = qb.build() ~ "\n" ~ pr.toOrderClause();
Statement stmt = db.prepare(query); TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
auto results = parseListItems(stmt.execute());
return Page!TransactionsListItem.of(results, pr, count); return Page!TransactionsListItem.of(results, pr, count);
} }
@ -370,7 +369,7 @@ class SqliteTransactionRepository : TransactionRepository {
Optional!TransactionDetail findById(ulong id) { Optional!TransactionDetail findById(ulong id) {
Optional!TransactionDetail item = util.sqlite.findOne( Optional!TransactionDetail item = util.sqlite.findOne(
db, db,
import("sql/get_transaction.sql"), import("sql/query/get_transaction.sql"),
(row) { (row) {
TransactionDetail item; TransactionDetail item;
item.id = row.peek!ulong(0); item.id = row.peek!ulong(0);
@ -383,43 +382,41 @@ class SqliteTransactionRepository : TransactionRepository {
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7); Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
if (!vendorId.isNull) { if (!vendorId.isNull) {
item.vendor = Optional!(TransactionDetail.Vendor).of( item.vendor = Optional!TransactionVendor.of(TransactionVendor(
TransactionDetail.Vendor( vendorId.get,
vendorId.get, row.peek!string(8),
row.peek!string(8), row.peek!string(9)
row.peek!string(9) ));
)).toNullable;
} }
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10); Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
if (!categoryId.isNull) { if (!categoryId.isNull) {
item.category = Optional!(TransactionDetail.Category).of( item.category = Optional!TransactionCategory.of(TransactionCategory(
TransactionDetail.Category( categoryId.get,
categoryId.get, row.parseOptional!ulong(11),
row.peek!(Nullable!ulong)(11), row.peek!string(12),
row.peek!string(12), row.peek!string(13),
row.peek!string(13), row.peek!string(14)
row.peek!string(14) ));
)).toNullable;
} }
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15); Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
if (!creditedAccountId.isNull) { if (!creditedAccountId.isNull) {
item.creditedAccount = Optional!(TransactionDetail.Account).of( item.creditedAccount = Optional!SimpleAccountResponse.of(
TransactionDetail.Account( SimpleAccountResponse(
creditedAccountId.get, creditedAccountId.get,
row.peek!string(16), row.peek!string(16),
row.peek!string(17), row.peek!string(17),
row.peek!string(18) row.peek!string(18)
)).toNullable; ));
} }
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19); Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
if (!debitedAccountId.isNull) { if (!debitedAccountId.isNull) {
item.debitedAccount = Optional!(TransactionDetail.Account).of( item.debitedAccount = Optional!SimpleAccountResponse.of(
TransactionDetail.Account( SimpleAccountResponse(
debitedAccountId.get, debitedAccountId.get,
row.peek!string(20), row.peek!string(20),
row.peek!string(21), row.peek!string(21),
row.peek!string(22) row.peek!string(22)
)).toNullable; ));
} }
string tagsStr = row.peek!string(23); string tagsStr = row.peek!string(23);
if (tagsStr !is null && tagsStr.length > 0) { if (tagsStr !is null && tagsStr.length > 0) {
@ -435,23 +432,23 @@ class SqliteTransactionRepository : TransactionRepository {
if (item.isNull) return item; if (item.isNull) return item;
item.value.lineItems = util.sqlite.findAll( item.value.lineItems = util.sqlite.findAll(
db, db,
import("sql/get_line_items.sql"), import("sql/query/get_line_items.sql"),
(row) { (row) {
TransactionDetail.LineItem li; TransactionLineItemResponse li;
li.idx = row.peek!uint(0); li.idx = row.peek!uint(0);
li.valuePerItem = row.peek!long(1); li.valuePerItem = row.peek!long(1);
li.quantity = row.peek!ulong(2); li.quantity = row.peek!ulong(2);
li.description = row.peek!string(3); li.description = row.peek!string(3);
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4); Optional!ulong categoryId = row.parseOptional!ulong(4);
if (!categoryId.isNull) { if (categoryId) {
li.category = Optional!(TransactionDetail.Category).of( li.category = Optional!TransactionCategory.of(
TransactionDetail.Category( TransactionCategory(
categoryId.get, categoryId.value,
row.peek!(Nullable!ulong)(5), row.parseOptional!ulong(5),
row.peek!string(6), row.peek!string(6),
row.peek!string(7), row.peek!string(7),
row.peek!string(8) row.peek!string(8)
)).toNullable; ));
} }
return li; return li;
}, },
@ -470,8 +467,8 @@ class SqliteTransactionRepository : TransactionRepository {
data.currencyCode, data.currencyCode,
data.description, data.description,
data.internalTransfer, data.internalTransfer,
data.vendorId, data.vendorId.toNullable(),
data.categoryId data.categoryId.toNullable()
); );
ulong transactionId = db.lastInsertRowid(); ulong transactionId = db.lastInsertRowid();
insertLineItems(transactionId, data); insertLineItems(transactionId, data);
@ -496,8 +493,8 @@ class SqliteTransactionRepository : TransactionRepository {
data.currencyCode, data.currencyCode,
data.description, data.description,
data.internalTransfer, data.internalTransfer,
data.vendorId, data.vendorId.toNullable(),
data.categoryId, data.categoryId.toNullable(),
transactionId transactionId
); );
// Re-write all line items: // Re-write all line items:
@ -521,97 +518,56 @@ class SqliteTransactionRepository : TransactionRepository {
); );
} }
/** private static TransactionsListItem parseListItem(Row row) {
* Function to parse a list of transaction list items as obtained from the
* `get_transactions.sql` query. Because there are possibly multiple rows
* per transaction, we have to deal with the result range as a whole.
* Params:
* r = The result range to read.
* Returns: The list of transaction list items.
*/
private static TransactionsListItem[] parseListItems(ResultRange r) {
import std.array : appender;
auto app = appender!(TransactionsListItem[]);
TransactionsListItem item; TransactionsListItem item;
item.id = row.peek!ulong(0);
/// Helper function that appends the current item to the list, and resets state. item.timestamp = row.peek!string(1);
void appendItem() { item.addedAt = row.peek!string(2);
item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5);
item.internalTransfer = row.peek!bool(6);
Optional!ulong vendorId = row.parseOptional!ulong(7);
if (vendorId) {
item.vendor = SimpleVendorResponse(
vendorId.value,
row.peek!string(8)
).toOptional;
}
Optional!ulong categoryId = row.parseOptional!ulong(9);
if (categoryId) {
item.category = SimpleCategoryResponse(
categoryId.value,
row.peek!string(10),
row.peek!string(11)
).toOptional;
}
Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
if (creditedAccountId) {
item.creditedAccount = SimpleAccountResponse(
creditedAccountId.value,
row.peek!string(13),
row.peek!string(14),
row.peek!string(15)
).toOptional;
}
Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
if (debitedAccountId) {
item.debitedAccount = SimpleAccountResponse(
debitedAccountId.value,
row.peek!string(17),
row.peek!string(18),
row.peek!string(19)
).toOptional;
}
string aggregateTags = row.peek!string(20);
if (aggregateTags !is null) {
import std.string : split;
import std.algorithm : sort; import std.algorithm : sort;
item.tags = aggregateTags.split(",");
sort(item.tags); sort(item.tags);
app ~= item;
item.id = 0;
item.tags = [];
item.vendor = Optional!(TransactionsListItem.Vendor).empty();
item.category = Optional!(TransactionsListItem.Category).empty();
item.creditedAccount = Optional!(TransactionsListItem.Account).empty();
item.debitedAccount = Optional!(TransactionsListItem.Account).empty();
} }
return item;
size_t rowCount = 0;
foreach (Row row; r) {
rowCount++;
ulong txnId = row.peek!ulong(0);
if (item.id != txnId) {
// We're parsing a new item. First, append the current one if there is one.
if (item.id != 0) {
appendItem();
}
item.id = txnId;
item.timestamp = row.peek!string(1);
item.addedAt = row.peek!string(2);
item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5);
item.internalTransfer = row.peek!bool(6);
// Read the nullable Vendor information.
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
if (!vendorId.isNull) {
string vendorName = row.peek!string(8);
item.vendor = Optional!(TransactionsListItem.Vendor).of(
TransactionsListItem.Vendor(vendorId.get, vendorName));
}
// Read the nullable Category information.
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
if (!categoryId.isNull) {
string categoryName = row.peek!string(10);
string categoryColor = row.peek!string(11);
item.category = Optional!(TransactionsListItem.Category).of(
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
}
// Read the nullable creditedAccount.
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
if (!creditedAccountId.isNull) {
ulong id = creditedAccountId.get;
string name = row.peek!string(13);
string type = row.peek!string(14);
string suffix = row.peek!string(15);
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
// Read the nullable debitedAccount.
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
if (!debitedAccountId.isNull) {
ulong id = debitedAccountId.get;
string name = row.peek!string(17);
string type = row.peek!string(18);
string suffix = row.peek!string(19);
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
}
// Read multi-row properties, like tags, to the current item.
string tag = row.peek!string(20);
if (tag !is null) {
item.tags ~= tag;
}
}
// If there's one last cached item, append it to the list.
if (item.id != 0) {
appendItem();
}
return app[];
} }
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) { private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
@ -624,7 +580,7 @@ class SqliteTransactionRepository : TransactionRepository {
lineItem.valuePerItem, lineItem.valuePerItem,
lineItem.quantity, lineItem.quantity,
lineItem.description, lineItem.description,
lineItem.categoryId lineItem.categoryId.toNullable()
); );
} }
} }
@ -664,6 +620,348 @@ class SqliteTransactionRepository : TransactionRepository {
.select("account_debit.name") .select("account_debit.name")
.select("account_debit.type") .select("account_debit.type")
.select("account_debit.number_suffix") .select("account_debit.number_suffix")
.select("tags.tag"); .select("GROUP_CONCAT(tags.tag)");
}
}
class SqliteTransactionDraftRepository : TransactionDraftRepository {
private Database db;
this(Database db) {
this.db = db;
}
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr) {
return findAllInternal(pr, DraftType.DRAFT.toOptional);
}
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) {
return findAllInternal(pr, DraftType.TEMPLATE.toOptional);
}
Page!TransactionDraftListItem findAll(in PageRequest pr) {
return findAllInternal(pr, Optional!DraftType.empty());
}
private static enum DraftType {
DRAFT,
TEMPLATE
}
private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, Optional!DraftType type) {
QueryBuilder qb = getBuilderForDraftsList();
addSelectsForDraftsList(qb);
qb.groupBy("draft.id");
if (type) {
if (type.value == DraftType.DRAFT) {
qb.where("template_name IS NULL");
} else {
qb.where("template_name IS NOT NULL");
}
}
string query = qb.build() ~ "\n" ~ pr.toSql();
TransactionDraftListItem[] results = util.sqlite.findAll(db, query, &parseDraftListItem);
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM transaction_draft");
return Page!(TransactionDraftListItem).of(results, pr, totalCount);
}
Optional!TransactionDraftResponse findById(ulong id) {
// First fetch the draft list item (contains all basic properties).
QueryBuilder qb = getBuilderForDraftsList();
addSelectsForDraftsList(qb);
qb.groupBy("draft.id");
qb.where("draft.id = ?");
string query = qb.build();
Optional!TransactionDraftListItem li = util.sqlite.findOne(db, query, &parseDraftListItem, id);
if (li.isNull) return Optional!TransactionDraftResponse.empty();
TransactionDraftListItem draft = li.value;
// Then fetch line items.
TransactionDraftResponse response;
response.id = draft.id;
response.addedAt = draft.addedAt;
response.templateName = draft.templateName;
response.timestamp = draft.timestamp;
response.amount = draft.amount;
response.currency = draft.currency;
response.description = draft.description;
response.internalTransfer = draft.internalTransfer;
response.vendor = draft.vendor;
response.category = draft.category;
response.creditedAccount = draft.creditedAccount;
response.debitedAccount = draft.debitedAccount;
response.tags = li.value.tags;
response.lineItems = util.sqlite.findAll(
db,
import("sql/query/get_line_items_draft.sql"),
(row) {
TransactionLineItemResponse item;
item.idx = row.peek!uint(0);
item.valuePerItem = row.peek!long(1);
item.quantity = row.peek!ulong(2);
item.description = row.peek!string(3);
Optional!ulong categoryId = row.parseOptional!ulong(4);
if (categoryId) {
item.category = Optional!TransactionCategory.of(
TransactionCategory(
categoryId.value,
row.parseOptional!ulong(5),
row.peek!string(6),
row.peek!string(7),
row.peek!string(8)
));
}
return item;
},
draft.id
);
// Return the response, excluding attachments (they are fetched using the attachment repo).
return Optional!TransactionDraftResponse.of(response);
}
TransactionDraftResponse insert(in TransactionDraftPayload data) {
util.sqlite.update(
db,
import("sql/insert_transaction_draft.sql"),
Clock.currTime(UTC()).toISOExtString(),
data.templateName.toNullable(),
data.timestamp.toNullable(),
data.amount.toNullable(),
data.currencyCode.toNullable(),
data.description.toNullable(),
data.internalTransfer.toNullable(),
data.vendorId.toNullable(),
data.categoryId.toNullable(),
data.creditedAccountId.toNullable(),
data.debitedAccountId.toNullable()
);
ulong draftId = db.lastInsertRowid();
insertLineItems(draftId, data);
return findById(draftId).orElseThrow();
}
void linkAttachment(ulong draftId, ulong attachmentId) {
util.sqlite.update(
db,
"INSERT INTO transaction_draft_attachment (draft_id, attachment_id) VALUES (?, ?)",
draftId,
attachmentId
);
}
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) {
util.sqlite.update(
db,
import("sql/update_transaction_draft.sql"),
data.templateName.toNullable(),
data.timestamp.toNullable(),
data.amount.toNullable(),
data.currencyCode.toNullable(),
data.description.toNullable(),
data.internalTransfer.toNullable(),
data.vendorId.toNullable(),
data.categoryId.toNullable(),
data.creditedAccountId.toNullable(),
data.debitedAccountId.toNullable(),
draftId
);
// Re-write all line items:
util.sqlite.update(
db,
"DELETE FROM transaction_draft_line_item WHERE draft_id = ?",
draftId
);
insertLineItems(draftId, data);
return findById(draftId).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.update(
db,
"DELETE FROM attachment WHERE id IN (
SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?
)",
id
);
util.sqlite.deleteById(db, "transaction_draft", id);
}
void updateTags(ulong draftId, in string[] tags) {
util.sqlite.update(
db,
"DELETE FROM transaction_draft_tag WHERE draft_id = ?",
draftId
);
foreach (tag; tags) {
util.sqlite.update(
db,
"INSERT INTO transaction_draft_tag (draft_id, tag) VALUES (?, ?)",
draftId, tag
);
}
}
string[] findAllTags() {
return util.sqlite.findAll(
db,
"SELECT DISTINCT tag FROM transaction_draft_tag ORDER BY tag",
r => r.peek!string(0)
);
}
private QueryBuilder getBuilderForDraftsList() {
return QueryBuilder("transaction_draft draft")
.join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id")
.join("LEFT JOIN transaction_category category ON category.id = draft.category_id")
.join("LEFT JOIN account account_credit ON account_credit.id = draft.credited_account_id")
.join("LEFT JOIN account account_debit ON account_debit.id = draft.debited_account_id")
.join("LEFT JOIN transaction_draft_tag tags ON tags.draft_id = draft.id");
}
private void addSelectsForDraftsList(ref QueryBuilder qb) {
qb
.select("draft.id")
.select("draft.added_at")
.select("draft.template_name")
.select("draft.timestamp")
.select("draft.amount")// 5
.select("draft.currency")
.select("draft.description")
.select("draft.internal_transfer")
.select("vendor.id")
.select("vendor.name")// 10
.select("category.id")
.select("category.name")
.select("category.color")
.select("account_credit.id")
.select("account_credit.name")// 15
.select("account_credit.type")
.select("account_credit.number_suffix")
.select("account_debit.id")
.select("account_debit.name")
.select("account_debit.type")// 20
.select("account_debit.number_suffix")
.select("group_concat(tags.tag)");
}
private static TransactionDraftListItem parseDraftListItem(Row row) {
TransactionDraftListItem item;
item.id = row.peek!ulong(0);
item.addedAt = row.peek!string(1);
item.templateName = row.parseOptional!string(2);
item.timestamp = row.parseOptional!string(3);
item.amount = row.parseOptional!ulong(4);
item.currency = row.parseOptional!(string, PeekMode.slice)(5)
.mapIfPresent!(s => Currency.ofCode(s));
item.description = row.parseOptional!string(6);
item.internalTransfer = row.parseOptional!bool(7);
Optional!ulong vendorId = row.parseOptional!ulong(8);
if (vendorId) {
item.vendor = SimpleVendorResponse(
vendorId.value,
row.peek!string(9)
).toOptional;
}
Optional!ulong categoryId = row.parseOptional!ulong(10);
if (categoryId) {
item.category = SimpleCategoryResponse(
categoryId.value,
row.peek!string(11),
row.peek!string(12),
).toOptional;
}
Optional!ulong creditedAccountId = row.parseOptional!ulong(13);
if (creditedAccountId) {
item.creditedAccount = SimpleAccountResponse(
creditedAccountId.value,
row.peek!string(14),
row.peek!string(15),
row.peek!string(16)
).toOptional;
}
Optional!ulong debitedAccountId = row.parseOptional!ulong(17);
if (debitedAccountId) {
item.debitedAccount = SimpleAccountResponse(
debitedAccountId.value,
row.peek!string(18),
row.peek!string(19),
row.peek!string(20)
).toOptional;
}
string aggregateTags = row.peek!string(21);
if (aggregateTags !is null) {
import std.string : split;
item.tags = aggregateTags.split(",");
}
import std.algorithm : sort;
sort(item.tags);
return item;
}
private void insertLineItems(ulong draftId, in TransactionDraftPayload payload) {
foreach (size_t idx, lineItem; payload.lineItems) {
util.sqlite.update(
db,
import("sql/insert_line_item_draft.sql"),
draftId,
idx,
lineItem.valuePerItem,
lineItem.quantity,
lineItem.description,
lineItem.categoryId.toNullable()
);
}
}
}
class SqliteRecurringTransactionRepository : RecurringTransactionRepository {
private Database db;
this(Database db) {
this.db = db;
}
Page!RecurringTransactionResponse findAll(in PageRequest pr) {
string query = "SELECT * FROM recurring_transaction " ~ pr.toSql();
RecurringTransactionResponse[] results = util.sqlite.findAll(db, query, &parseRecurringTransaction);
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM recurring_transaction");
return Page!(RecurringTransactionResponse).of(results, pr, totalCount);
}
RecurringTransactionResponse[] findAllByDraftId(ulong draftId) {
string query = "SELECT * FROM recurring_transaction WHERE draft_id = ? ORDER BY id";
return util.sqlite.findAll(db, query, &parseRecurringTransaction, draftId);
}
Optional!RecurringTransactionResponse findById(ulong id) {
return util.sqlite.findOne(
db,
"SELECT * FROM recurring_transaction WHERE id = ?",
&parseRecurringTransaction,
id
);
}
RecurringTransactionResponse insert(in RecurringTransactionPayload data) {
util.sqlite.update(
db,
"INSERT INTO recurring_transaction " ~
"(draft_id, schedule_expr) " ~
"VALUES (?, ?)",
data.draftId,
data.scheduleExpr
);
return findById(db.lastInsertRowid()).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "recurring_transaction", id);
}
private static RecurringTransactionResponse parseRecurringTransaction(Row row) {
return RecurringTransactionResponse(
row.peek!ulong(0),
row.peek!ulong(1),
row.peek!string(2)
);
} }
} }

View File

@ -1,14 +1,36 @@
module transaction.dto; module transaction.dto;
import handy_http_primitives : Optional; import handy_http_primitives : Optional;
import asdf : serdeTransformOut;
import std.typecons; import std.typecons;
import transaction.model : TransactionCategory; import transaction.model : TransactionCategory, TransactionVendor;
import attachment.dto; import attachment.dto;
import account.dto;
import util.data; import util.data;
import util.money; import util.money;
/// A simple selection of vendor data to be included in other responses.
struct SimpleVendorResponse {
ulong id;
string name;
}
/// A simple selection of category data to be included in other responses.
struct SimpleCategoryResponse {
ulong id;
string name;
string color;
}
/// Response data for a transaction's line item.
struct TransactionLineItemResponse {
uint idx;
long valuePerItem;
ulong quantity;
string description;
Optional!TransactionCategory category;
}
/// The transaction data provided when a list of transactions is requested. /// The transaction data provided when a list of transactions is requested.
struct TransactionsListItem { struct TransactionsListItem {
ulong id; ulong id;
@ -18,33 +40,11 @@ struct TransactionsListItem {
Currency currency; Currency currency;
string description; string description;
bool internalTransfer; bool internalTransfer;
@serdeTransformOut!serializeOptional Optional!SimpleVendorResponse vendor;
Optional!Vendor vendor; Optional!SimpleCategoryResponse category;
@serdeTransformOut!serializeOptional Optional!SimpleAccountResponse creditedAccount;
Optional!Category category; Optional!SimpleAccountResponse debitedAccount;
@serdeTransformOut!serializeOptional
Optional!Account creditedAccount;
@serdeTransformOut!serializeOptional
Optional!Account debitedAccount;
string[] tags; string[] tags;
static struct Account {
ulong id;
string name;
string type;
string numberSuffix;
}
static struct Vendor {
ulong id;
string name;
}
static struct Category {
ulong id;
string name;
string color;
}
} }
/// Transaction data provided when fetching a single transaction. /// Transaction data provided when fetching a single transaction.
@ -56,42 +56,13 @@ struct TransactionDetail {
Currency currency; Currency currency;
string description; string description;
bool internalTransfer; bool internalTransfer;
Nullable!Vendor vendor; Optional!TransactionVendor vendor;
Nullable!Category category; Optional!TransactionCategory category;
Nullable!Account creditedAccount; Optional!SimpleAccountResponse creditedAccount;
Nullable!Account debitedAccount; Optional!SimpleAccountResponse debitedAccount;
string[] tags; string[] tags;
LineItem[] lineItems; TransactionLineItemResponse[] lineItems;
AttachmentResponse[] attachments; AttachmentResponse[] attachments;
static struct Vendor {
ulong id;
string name;
string description;
}
static struct Category {
ulong id;
Nullable!ulong parentId;
string name;
string description;
string color;
}
static struct LineItem {
uint idx;
long valuePerItem;
ulong quantity;
string description;
Nullable!Category category;
}
static struct Account {
ulong id;
string name;
string type;
string numberSuffix;
}
} }
/// Data provided when a new transaction is added by a user. /// Data provided when a new transaction is added by a user.
@ -101,26 +72,25 @@ struct AddTransactionPayload {
string currencyCode; string currencyCode;
string description; string description;
bool internalTransfer; bool internalTransfer;
Nullable!ulong vendorId; Optional!ulong vendorId;
Nullable!ulong categoryId; Optional!ulong categoryId;
Nullable!ulong creditedAccountId; Optional!ulong creditedAccountId;
Nullable!ulong debitedAccountId; Optional!ulong debitedAccountId;
string[] tags; string[] tags;
LineItem[] lineItems; LineItemPayload[] lineItems;
ulong[] attachmentIdsToRemove; ulong[] attachmentIdsToRemove;
static struct LineItem { static struct LineItemPayload {
long valuePerItem; long valuePerItem;
ulong quantity; ulong quantity;
string description; string description;
Nullable!ulong categoryId; Optional!ulong categoryId;
} }
} }
/// Structure for depicting an entire hierarchical tree structure of categories. /// Structure for depicting an entire hierarchical tree structure of categories.
struct TransactionCategoryTree { struct TransactionCategoryTree {
ulong id; ulong id;
@serdeTransformOut!serializeOptional
Optional!ulong parentId; Optional!ulong parentId;
string name; string name;
string description; string description;
@ -131,7 +101,6 @@ struct TransactionCategoryTree {
struct TransactionCategoryResponse { struct TransactionCategoryResponse {
ulong id; ulong id;
@serdeTransformOut!serializeOptional
Optional!ulong parentId; Optional!ulong parentId;
string name; string name;
string description; string description;
@ -170,3 +139,82 @@ struct AggregateTransactionData {
} }
CurrencyData[] currencies; CurrencyData[] currencies;
} }
/// Response data for drafts as they'd appear in a list, with less data.
struct TransactionDraftListItem {
ulong id;
string addedAt;
Optional!string templateName;
Optional!string timestamp;
Optional!ulong amount;
Optional!Currency currency;
Optional!string description;
Optional!bool internalTransfer;
Optional!SimpleVendorResponse vendor;
Optional!SimpleCategoryResponse category;
Optional!SimpleAccountResponse creditedAccount;
Optional!SimpleAccountResponse debitedAccount;
string[] tags;
}
/// Data representing a draft (or template).
struct TransactionDraftResponse {
ulong id;
string addedAt;
Optional!string templateName;
Optional!string timestamp;
Optional!ulong amount;
Optional!Currency currency;
Optional!string description;
Optional!bool internalTransfer;
Optional!SimpleVendorResponse vendor;
Optional!SimpleCategoryResponse category;
Optional!SimpleAccountResponse creditedAccount;
Optional!SimpleAccountResponse debitedAccount;
string[] tags;
TransactionLineItemResponse[] lineItems;
AttachmentResponse[] attachments;
}
/// Data provided by users when creating or updating drafts.
struct TransactionDraftPayload {
Optional!string templateName;
Optional!string timestamp;
Optional!ulong amount;
Optional!string currencyCode;
Optional!string description;
Optional!bool internalTransfer;
Optional!ulong vendorId;
Optional!ulong categoryId;
Optional!ulong creditedAccountId;
Optional!ulong debitedAccountId;
string[] tags;
LineItemPayload[] lineItems;
ulong[] attachmentIdsToRemove;
static struct LineItemPayload {
long valuePerItem;
ulong quantity;
string description;
Optional!ulong categoryId;
}
}
// Recurring Transaction stuff:
struct RecurringTransactionPayload {
ulong draftId;
string scheduleExpr;
}
struct RecurringTransactionResponse {
ulong id;
ulong draftId;
string scheduleExpr;
}

View File

@ -6,9 +6,9 @@ import std.datetime;
import util.money; import util.money;
struct TransactionVendor { struct TransactionVendor {
immutable ulong id; ulong id;
immutable string name; string name;
immutable string description; string description;
} }
struct TransactionCategory { struct TransactionCategory {
@ -20,23 +20,23 @@ struct TransactionCategory {
} }
struct Transaction { struct Transaction {
immutable ulong id; ulong id;
/// The time at which the transaction happened. /// The time at which the transaction happened.
immutable SysTime timestamp; SysTime timestamp;
/// The time at which the transaction entity was saved. /// The time at which the transaction entity was saved.
immutable SysTime addedAt; SysTime addedAt;
immutable ulong amount; ulong amount;
immutable Currency currency; Currency currency;
immutable string description; string description;
immutable Optional!ulong vendorId; Optional!ulong vendorId;
immutable Optional!ulong categoryId; Optional!ulong categoryId;
} }
struct TransactionLineItem { struct TransactionLineItem {
immutable ulong transactionId; ulong transactionId;
immutable uint idx; uint idx;
immutable long valuePerItem; long valuePerItem;
immutable ulong quantity; ulong quantity;
immutable string description; string description;
immutable Optional!ulong categoryId; Optional!ulong categoryId;
} }

View File

@ -16,15 +16,16 @@ import account.data;
import util.money; import util.money;
import util.pagination; import util.pagination;
import util.data; import util.data;
import util.validation.transaction;
import util.validation.draft;
import attachment.data; import attachment.data;
import attachment.dto; import attachment.dto;
// Transactions Services // Transactions Services
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) { Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
Page!TransactionsListItem page = ds.getTransactionRepository() return ds.getTransactionRepository()
.findAll(pageRequest); .findAll(pageRequest);
return page;
} }
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) { TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
@ -46,6 +47,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
SysTime now = Clock.currTime(UTC());
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
// Add the transaction: // Add the transaction:
@ -53,20 +55,20 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
ds.doTransaction(() { ds.doTransaction(() {
TransactionDetail txn = txnRepo.insert(payload); TransactionDetail txn = txnRepo.insert(payload);
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
if (!payload.creditedAccountId.isNull) { if (payload.creditedAccountId) {
jeRepo.insert( jeRepo.insert(
timestamp, timestamp,
payload.creditedAccountId.get, payload.creditedAccountId.value,
txn.id, txn.id,
txn.amount, txn.amount,
AccountJournalEntryType.CREDIT, AccountJournalEntryType.CREDIT,
txn.currency txn.currency
); );
} }
if (!payload.debitedAccountId.isNull) { if (payload.debitedAccountId) {
jeRepo.insert( jeRepo.insert(
timestamp, timestamp,
payload.debitedAccountId.get, payload.debitedAccountId.value,
txn.id, txn.id,
txn.amount, txn.amount,
AccountJournalEntryType.DEBIT, AccountJournalEntryType.DEBIT,
@ -75,7 +77,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
} }
TransactionTagRepository tagRepo = ds.getTransactionTagRepository(); TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
tagRepo.updateTags(txn.id, payload.tags); tagRepo.updateTags(txn.id, payload.tags);
updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo); updateAttachments(txn.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
txnId = txn.id; txnId = txn.id;
}); });
return getTransaction(ds, txnId); return getTransaction(ds, txnId);
@ -96,6 +98,7 @@ TransactionDetail updateTransaction(
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
SysTime now = Clock.currTime(UTC());
const TransactionDetail prev = transactionRepo.findById(transactionId) const TransactionDetail prev = transactionRepo.findById(transactionId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
@ -105,7 +108,7 @@ TransactionDetail updateTransaction(
TransactionDetail curr = transactionRepo.update(transactionId, payload); TransactionDetail curr = transactionRepo.update(transactionId, payload);
updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp); updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp);
tagRepo.updateTags(transactionId, payload.tags); tagRepo.updateTags(transactionId, payload.tags);
updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo); updateAttachments(curr.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
}); });
return getTransaction(ds, transactionId); return getTransaction(ds, transactionId);
} }
@ -120,45 +123,45 @@ private void updateLinkedAccountJournalEntries(
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code; const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
const bool updateCreditEntry = amountOrCurrencyChanged || ( const bool updateCreditEntry = amountOrCurrencyChanged || (
(prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) || (!prev.creditedAccount && payload.creditedAccountId) ||
(!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) || (prev.creditedAccount && !payload.creditedAccountId) ||
( (
!prev.creditedAccount.isNull && prev.creditedAccount &&
!payload.creditedAccountId.isNull && payload.creditedAccountId &&
prev.creditedAccount.get.id != payload.creditedAccountId.get prev.creditedAccount.value.id != payload.creditedAccountId.value
) )
); );
const bool updateDebitEntry = amountOrCurrencyChanged || ( const bool updateDebitEntry = amountOrCurrencyChanged || (
(prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) || (!prev.debitedAccount && payload.creditedAccountId) ||
(!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) || (prev.debitedAccount && !payload.debitedAccountId) ||
( (
!prev.debitedAccount.isNull && prev.debitedAccount &&
!payload.debitedAccountId.isNull && payload.debitedAccountId &&
prev.debitedAccount.get.id != payload.debitedAccountId.get prev.debitedAccount.value.id != payload.debitedAccountId.value
) )
); );
// Update journal entries if necessary: // Update journal entries if necessary:
if (updateCreditEntry && !prev.creditedAccount.isNull) { if (updateCreditEntry && prev.creditedAccount) {
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id); jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
} }
if (updateCreditEntry && !payload.creditedAccountId.isNull) { if (updateCreditEntry && payload.creditedAccountId) {
jeRepo.insert( jeRepo.insert(
timestamp, timestamp,
payload.creditedAccountId.get, payload.creditedAccountId.value,
curr.id, curr.id,
curr.amount, curr.amount,
AccountJournalEntryType.CREDIT, AccountJournalEntryType.CREDIT,
curr.currency curr.currency
); );
} }
if (updateDebitEntry && !prev.debitedAccount.isNull) { if (updateDebitEntry && prev.debitedAccount) {
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id); jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
} }
if (updateDebitEntry && !payload.debitedAccountId.isNull) { if (updateDebitEntry && payload.debitedAccountId) {
jeRepo.insert( jeRepo.insert(
timestamp, timestamp,
payload.debitedAccountId.get, payload.debitedAccountId.value,
curr.id, curr.id,
curr.amount, curr.amount,
AccountJournalEntryType.DEBIT, AccountJournalEntryType.DEBIT,
@ -181,76 +184,6 @@ void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
}); });
} }
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.");
}
if (
!payload.creditedAccountId.isNull &&
!payload.debitedAccountId.isNull &&
payload.creditedAccountId.get == payload.debitedAccountId.get
) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
}
if (payload.amount == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
}
SysTime now = Clock.currTime(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.");
}
if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
}
if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
}
if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
}
if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
}
foreach (tag; payload.tags) {
import std.regex;
auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`);
if (!matchFirst(tag, r)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\".");
}
}
if (payload.lineItems.length > 0) {
long lineItemsTotal = 0;
foreach (lineItem; payload.lineItems) {
if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
}
if (lineItem.quantity == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
}
for (ulong i = 0; i < lineItem.quantity; i++) {
lineItemsTotal += lineItem.valuePerItem;
}
}
if (lineItemsTotal != payload.amount) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Total of all line items doesn't equal the transaction's total."
);
}
}
}
/** /**
* Helper function to add / remove attachments for a transaction. * Helper function to add / remove attachments for a transaction.
*/ */
@ -454,7 +387,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
if (repo.existsByName(payload.name)) { if (repo.existsByName(payload.name)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use."); throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
} }
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) { if (payload.parentId && !repo.existsById(payload.parentId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id."); throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
} }
import std.regex; import std.regex;
@ -463,7 +396,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string."); throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
} }
auto category = repo.insert( auto category = repo.insert(
toOptional(payload.parentId), payload.parentId,
payload.name, payload.name,
payload.description, payload.description,
payload.color payload.color
@ -481,7 +414,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
if (payload.name != prev.name && repo.existsByName(payload.name)) { if (payload.name != prev.name && repo.existsByName(payload.name)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use."); throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
} }
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) { if (payload.parentId && !repo.existsById(payload.parentId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id."); throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
} }
TransactionCategory curr = repo.updateById( TransactionCategory curr = repo.updateById(
@ -489,7 +422,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
payload.name, payload.name,
payload.description, payload.description,
payload.color, payload.color,
toOptional!ulong(payload.parentId) payload.parentId
); );
return TransactionCategoryResponse.of(curr); return TransactionCategoryResponse.of(curr);
} }
@ -497,3 +430,124 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
void deleteCategory(ProfileDataSource ds, ulong categoryId) { void deleteCategory(ProfileDataSource ds, ulong categoryId) {
ds.getTransactionCategoryRepository().deleteById(categoryId); ds.getTransactionCategoryRepository().deleteById(categoryId);
} }
// Draft services
Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) {
return ds.getTransactionDraftRepository().findAll(pr);
}
TransactionDraftResponse getDraft(ProfileDataSource ds, ulong draftId) {
return ds.getTransactionDraftRepository()
.findById(draftId)
// Populate the list of attachments for the draft.
.mapIfPresent!((draft) {
import std.algorithm : map;
import std.array : array;
draft.attachments = ds.getAttachmentRepository()
.findAllByTransactionDraftId(draft.id)
.map!(AttachmentResponse.of)
.array;
return draft;
})
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}
TransactionDraftResponse addDraft(ProfileDataSource ds, in TransactionDraftPayload payload, in MultipartFile[] files) {
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
validateDraftPayload(
ds.getTransactionVendorRepository(),
ds.getTransactionCategoryRepository(),
ds.getAccountRepository(),
payload
);
SysTime now = Clock.currTime(UTC());
ulong draftId;
ds.doTransaction(() {
TransactionDraftResponse draft = draftRepo.insert(payload);
draftRepo.updateTags(draft.id, payload.tags);
updateDraftAttachments(draft.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo);
draftId = draft.id;
});
return getDraft(ds, draftId);
}
TransactionDraftResponse updateDraft(
ProfileDataSource ds,
ulong draftId,
in TransactionDraftPayload payload,
in MultipartFile[] files
) {
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
validateDraftPayload(
ds.getTransactionVendorRepository(),
ds.getTransactionCategoryRepository(),
ds.getAccountRepository(),
payload
);
SysTime now = Clock.currTime(UTC());
ds.doTransaction(() {
draftRepo.update(draftId, payload);
draftRepo.updateTags(draftId, payload.tags);
updateDraftAttachments(draftId, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo);
});
return getDraft(ds, draftId);
}
void deleteDraft(ProfileDataSource ds, ulong draftId) {
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
TransactionDraftResponse draft = draftRepo.findById(draftId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ds.doTransaction(() {
// First delete all attachments.
foreach (a; attachmentRepo.findAllByTransactionDraftId(draft.id)) {
attachmentRepo.remove(a.id);
}
draftRepo.deleteById(draft.id);
});
}
private void updateDraftAttachments(
ulong draftId,
SysTime timestamp,
in ulong[] attachmentIdsToRemove,
in MultipartFile[] attachmentsToAdd,
AttachmentRepository attachmentRepo,
TransactionDraftRepository draftRepo
) {
foreach (file; attachmentsToAdd) {
ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
draftRepo.linkAttachment(draftId, attachmentId);
}
foreach (idToRemove; attachmentIdsToRemove) {
attachmentRepo.remove(idToRemove);
}
}
// Recurring Transactions:
Page!RecurringTransactionResponse getRecurringTransactions(ProfileDataSource ds, PageRequest pr) {
return ds.getRecurringTransactionRepository().findAll(pr);
}
RecurringTransactionResponse createRecurringTransaction(ProfileDataSource ds, in RecurringTransactionPayload payload) {
return ds.getRecurringTransactionRepository()
.insert(payload);
}
void deleteRecurringTransaction(ProfileDataSource ds, ulong id) {
ds.getRecurringTransactionRepository()
.deleteById(id);
}
RecurringTransactionResponse[] getRecurringTransactionsForDraft(ProfileDataSource ds, ulong draftId) {
return ds.getRecurringTransactionRepository()
.findAllByDraftId(draftId);
}

View File

@ -21,13 +21,6 @@ Nullable!T toNullable(T)(Optional!T value) {
} }
} }
auto serializeOptional(T)(Optional!T value) {
if (value.isNull) {
return Nullable!T();
}
return Nullable!T(value.value);
}
T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) { T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) {
import handy_http_handlers.path_handler; import handy_http_handlers.path_handler;
import std.conv : to, ConvException; import std.conv : to, ConvException;

View File

@ -1,7 +1,7 @@
module util.sample_data; module util.sample_data;
import slf4d; import slf4d;
import handy_http_primitives : Optional, mapIfPresent; import handy_http_primitives : Optional, mapIfPresent, toOptional;
import auth; import auth;
import profile; import profile;
@ -143,10 +143,10 @@ void generateRandomTransactions(ProfileDataSource ds) {
AddTransactionPayload data; AddTransactionPayload data;
data.timestamp = timestamp.toISOExtString(); data.timestamp = timestamp.toISOExtString();
if (uniform01() < 0.7) { if (uniform01() < 0.7) {
data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable; data.vendorId = Optional!ulong.of(choice(vendors).id);
} }
if (uniform01() < 0.8) { if (uniform01() < 0.8) {
data.categoryId = Optional!ulong.of(choice(categories).id).toNullable; data.categoryId = Optional!ulong.of(choice(categories).id);
} }
// Randomly choose an account to credit / debit the transaction to. // Randomly choose an account to credit / debit the transaction to.
@ -162,11 +162,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
} }
} }
if (uniform01() < 0.5) { if (uniform01() < 0.5) {
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable; data.creditedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable; if (secondaryAccountId) data.debitedAccountId = secondaryAccountId;
} else { } else {
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable; data.debitedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable; if (secondaryAccountId) data.creditedAccountId = secondaryAccountId;
} }
// Randomly choose some tags to add. // Randomly choose some tags to add.
@ -185,25 +185,25 @@ void generateRandomTransactions(ProfileDataSource ds) {
if (uniform01 < 0.5) { if (uniform01 < 0.5) {
long lineItemTotal = 0; long lineItemTotal = 0;
foreach (n; 1..uniform(1, 20)) { foreach (n; 1..uniform(1, 20)) {
AddTransactionPayload.LineItem item; AddTransactionPayload.LineItemPayload item;
item.valuePerItem = uniform(1, 10_000); item.valuePerItem = uniform(1, 10_000);
item.quantity = uniform(1, 5); item.quantity = uniform(1, 5);
lineItemTotal += item.quantity * item.valuePerItem; lineItemTotal += item.quantity * item.valuePerItem;
item.description = "Sample item " ~ n.to!string; item.description = "Sample item " ~ n.to!string;
if (uniform01 < 0.5) { if (uniform01 < 0.5) {
TransactionCategory category = choice(categories); TransactionCategory category = choice(categories);
item.categoryId = category.id; item.categoryId = Optional!ulong.of(category.id);
} }
data.lineItems ~= item; data.lineItems ~= item;
} }
long diff = data.amount - lineItemTotal; long diff = data.amount - lineItemTotal;
// Add one final line item that adds up to the transaction total. // Add one final line item that adds up to the transaction total.
if (diff != 0) { if (diff != 0) {
data.lineItems ~= AddTransactionPayload.LineItem( data.lineItems ~= AddTransactionPayload.LineItemPayload(
diff, diff,
1, 1,
"Last item which reconciles line items total with transaction amount.", "Last item which reconciles line items total with transaction amount.",
Nullable!ulong.init Optional!ulong.empty()
); );
} }
} }

View File

@ -192,11 +192,35 @@ immutable(ubyte[]) parseBlob(Row row, size_t idx) {
return row.peek!(ubyte[], PeekMode.slice)(idx).idup; return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
} }
/**
* Reads an Optional primitive value from a result row.
* Params:
* row = The row to read from.
* idx = The column index in the row to read.
* Returns: An optional containing the data, if present.
*/
Optional!T parseOptional(T, PeekMode mode = PeekMode.copy)(Row row, size_t idx) {
import std.typecons : Nullable;
import std.traits : isSomeString;
static if (isSomeString!T) {
Nullable!T n = row.peek!(Nullable!T, mode)(idx);
} else {
Nullable!T n = row.peek!(Nullable!T)(idx);
}
if (n.isNull) return Optional!(T).empty();
static if (isSomeString!T) {
// If the string value is null, return empty as well.
if (n.get() is null) return Optional!(T).empty();
}
return Optional!(T).of(n.get());
}
struct QueryBuilder { struct QueryBuilder {
string fromTable; string fromTable;
string[] selections; string[] selections;
string[] joins; string[] joins;
string[] conditions; string[] conditions;
string[] groupings;
void delegate(ref Statement, ref int)[] argBinders; void delegate(ref Statement, ref int)[] argBinders;
this(string fromTable) { this(string fromTable) {
@ -223,6 +247,11 @@ struct QueryBuilder {
return this; return this;
} }
ref groupBy(string grouping) {
groupings ~= grouping;
return this;
}
string build() const { string build() const {
import std.algorithm : map; import std.algorithm : map;
import std.string : join; import std.string : join;
@ -240,6 +269,10 @@ struct QueryBuilder {
app ~= "\nWHERE\n"; app ~= "\nWHERE\n";
app ~= conditions.map!(s => " " ~ s).join(" AND\n"); app ~= conditions.map!(s => " " ~ s).join(" AND\n");
} }
if (groupings.length > 0) {
app ~= "\nGROUP BY\n";
app ~= groupings.map!(s => " " ~ s).join(",\n");
}
return app[]; return app[];
} }

View File

@ -0,0 +1,46 @@
module util.validation.common;
import handy_http_primitives.optional;
import std.datetime;
struct ValidationError {
string field;
string message;
}
interface ValidationRule(T) {
Optional!ValidationError validate(in T payload);
}
ValidationError[] applyValidationRules(T)(ValidationRule!(T)[] rules, in T payload) {
import std.array;
auto app = appender!(ValidationError[]);
foreach (rule; rules) {
auto result = rule.validate(payload);
if (result) app ~= result.value;
}
return app[];
}
// Helper functions:
void validateTags(in string[] tags) {
import std.regex;
import handy_http_primitives: HttpStatus, HttpStatusException;
foreach (tag; tags) {
import std.regex;
auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`);
if (!matchFirst(tag, r)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\".");
}
}
}
SysTime validateTimestampFormat(string timestampStr) {
import handy_http_primitives: HttpStatus, HttpStatusException;
try {
return SysTime.fromISOExtString(timestampStr, UTC());
} catch (TimeException e) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime.");
}
}

View File

@ -0,0 +1,59 @@
module util.validation.draft;
import handy_http_primitives;
import std.datetime;
import transaction.data;
import transaction.dto;
import util.validation.common;
import account.data;
void validateDraftPayload(
TransactionVendorRepository vendorRepo,
TransactionCategoryRepository categoryRepo,
AccountRepository accountRepo,
in TransactionDraftPayload payload
) {
if (payload.amount && !payload.currencyCode) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Currency is required when saving an amount.");
}
if (payload.amount && payload.amount.value == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
}
if (payload.timestamp) {
validateTimestampFormat(payload.timestamp.value);
}
if (payload.vendorId && !vendorRepo.existsById(payload.vendorId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
}
if (payload.categoryId && !categoryRepo.existsById(payload.categoryId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
}
if (payload.creditedAccountId && !accountRepo.existsById(payload.creditedAccountId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
}
if (payload.debitedAccountId && !accountRepo.existsById(payload.debitedAccountId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
}
validateTags(payload.tags);
if (payload.lineItems.length > 0) {
long lineItemsTotal = 0;
foreach (lineItem; payload.lineItems) {
if (lineItem.categoryId && !categoryRepo.existsById(lineItem.categoryId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
}
if (lineItem.quantity == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
}
for (ulong i = 0; i < lineItem.quantity; i++) {
lineItemsTotal += lineItem.valuePerItem;
}
}
if (payload.amount && lineItemsTotal != payload.amount.value) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Total of all line items doesn't equal the transaction's total."
);
}
}
}

View File

@ -0,0 +1,78 @@
module util.validation.transaction;
import handy_http_primitives;
import std.array;
import std.datetime;
import util.validation.common;
import transaction.dto;
import transaction.data;
import account.data;
// class AtLeastOneLinkedAccountRule : ValidationRule!AddTransactionPayload {
// Optional!ValidationError validate(in AddTransactionPayload payload) {
// if (!payload.creditedAccountId && !payload.debitedAccountId) {
// return ValidationError("creditedAccountId", "At least one account must be linked.").toOptional();
// }
// return Optional!ValidationError.empty();
// }
// }
void validateTransactionPayload(
TransactionVendorRepository vendorRepo,
TransactionCategoryRepository categoryRepo,
AccountRepository accountRepo,
in AddTransactionPayload payload
) {
if (!payload.creditedAccountId && !payload.debitedAccountId) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
}
if (
payload.creditedAccountId &&
payload.debitedAccountId &&
payload.creditedAccountId.value == payload.debitedAccountId.value
) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
}
if (payload.amount == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
}
SysTime now = Clock.currTime(UTC());
SysTime timestamp = validateTimestampFormat(payload.timestamp);
if (timestamp > now) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
}
if (payload.vendorId && !vendorRepo.existsById(payload.vendorId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
}
if (payload.categoryId && !categoryRepo.existsById(payload.categoryId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
}
if (payload.creditedAccountId && !accountRepo.existsById(payload.creditedAccountId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
}
if (payload.debitedAccountId && !accountRepo.existsById(payload.debitedAccountId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
}
validateTags(payload.tags);
if (payload.lineItems.length > 0) {
long lineItemsTotal = 0;
foreach (lineItem; payload.lineItems) {
if (lineItem.categoryId && !categoryRepo.existsById(lineItem.categoryId.value)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
}
if (lineItem.quantity == 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
}
for (ulong i = 0; i < lineItem.quantity; i++) {
lineItemsTotal += lineItem.valuePerItem;
}
}
if (lineItemsTotal != payload.amount) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Total of all line items doesn't equal the transaction's total."
);
}
}
}

View File

@ -0,0 +1,8 @@
INSERT INTO transaction_draft_line_item (
draft_id,
idx,
value_per_item,
quantity,
description,
category_id
) VALUES (?, ?, ?, ?, ?, ?)

View File

@ -0,0 +1,13 @@
INSERT INTO transaction_draft (
added_at,
template_name,
timestamp,
amount,
currency,
description,
internal_transfer,
vendor_id,
category_id,
credited_account_id,
debited_account_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

View File

@ -0,0 +1,74 @@
CREATE TABLE transaction_draft (
id INTEGER PRIMARY KEY,
added_at TEXT NOT NULL,
template_name TEXT,
timestamp TEXT,
amount INTEGER,
currency TEXT,
description TEXT,
internal_transfer BOOLEAN DEFAULT FALSE,
vendor_id INTEGER,
category_id INTEGER,
credited_account_id INTEGER,
debited_account_id INTEGER,
CONSTRAINT fk_transaction_draft_vendor
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_draft_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_draft_credited_account
FOREIGN KEY (credited_account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_draft_debited_account
FOREIGN KEY (debited_account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT ck_transaction_amount_positive
CHECK (amount IS NULL OR amount > 0)
);
CREATE TABLE transaction_draft_tag (
draft_id INTEGER NOT NULL,
tag TEXT NOT NULL,
CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag),
CONSTRAINT fk_transaction_draft_tag_draft
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_draft_attachment (
draft_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL,
PRIMARY KEY (draft_id, attachment_id),
CONSTRAINT fk_transaction_draft_attachment_transaction
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_draft_attachment_attachment
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_draft_line_item (
draft_id INTEGER NOT NULL,
idx INTEGER NOT NULL DEFAULT 0,
value_per_item INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL,
category_id INTEGER,
CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx),
CONSTRAINT fk_transaction_draft_line_item_transaction
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_draft_line_item_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE SET NULL
);
CREATE TABLE recurring_transaction (
id INTEGER PRIMARY KEY,
draft_id INTEGER NOT NULL,
schedule_expr TEXT NOT NULL,
CONSTRAINT fk_recurring_transaction_draft
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE
);

View File

@ -3,11 +3,13 @@ i.idx,
i.value_per_item, i.value_per_item,
i.quantity, i.quantity,
i.description, i.description,
i.category_id, i.category_id,
category.parent_id, category.parent_id,
category.name, category.name,
category.description, category.description,
category.color category.color
FROM transaction_line_item i FROM transaction_line_item i
LEFT JOIN transaction_category category LEFT JOIN transaction_category category
ON category.id = i.category_id ON category.id = i.category_id

View File

@ -0,0 +1,17 @@
SELECT
i.idx,
i.value_per_item,
i.quantity,
i.description,
i.category_id,
category.parent_id,
category.name,
category.description,
category.color
FROM transaction_draft_line_item i
LEFT JOIN transaction_category category
ON category.id = i.category_id
WHERE i.draft_id = ?
ORDER BY idx;

View File

@ -0,0 +1,38 @@
SELECT
txn.id,
txn.timestamp,
txn.added_at,
txn.amount,
txn.currency,
txn.description,
txn.internal_transfer,
txn.vendor_id,
vendor.name,
txn.category_id,
category.name,
category.color,
account_credit.id,
account_credit.name,
account_credit.type,
account_credit.number_suffix,
account_debit.id,
account_debit.name,
account_debit.type,
account_debit.number_suffix,
GROUP_CONCAT(tags.tag)
FROM "transaction" txn
LEFT JOIN transaction_vendor vendor ON vendor.id = txn.vendor_id
LEFT JOIN transaction_category category ON category.id = txn.category_id
LEFT JOIN account_journal_entry j_credit
ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'
LEFT JOIN account account_credit ON account_credit.id = j_credit.account_id
LEFT JOIN account_journal_entry j_debit
ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'
LEFT JOIN account account_debit ON account_debit.id = j_debit.account_id
LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id
GROUP BY txn.id

View File

@ -1,4 +1,9 @@
-- This schema is included at compile-time into source/profile/data_impl_sqlite.d SqliteProfileDataSource -- This schema is included at compile-time into
-- source/profile/data_impl_sqlite.d SqliteProfileDataSource
-- ----------------------------------------------------------------------------
-- This is the full current schema for Finnow's database. Tables and statements
-- are defined in the order in which they're executed to build a new database
-- for a newly-created profile.
-- Basic/Utility Entities -- Basic/Utility Entities
@ -218,3 +223,82 @@ CREATE TABLE history_item_linked_journal_entry (
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id) FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
-- Drafts / Templates / Recurring Transactions
-- Generally, draft tables are copies of their normal transaction counterparts,
-- with looser nullability constraints in some cases.
CREATE TABLE transaction_draft (
id INTEGER PRIMARY KEY,
added_at TEXT NOT NULL,
template_name TEXT,
timestamp TEXT,
amount INTEGER,
currency TEXT,
description TEXT,
internal_transfer BOOLEAN DEFAULT FALSE,
vendor_id INTEGER,
category_id INTEGER,
credited_account_id INTEGER,
debited_account_id INTEGER,
CONSTRAINT fk_transaction_draft_vendor
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_draft_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_draft_credited_account
FOREIGN KEY (credited_account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_draft_debited_account
FOREIGN KEY (debited_account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT ck_transaction_amount_positive
CHECK (amount IS NULL OR amount > 0)
);
CREATE TABLE transaction_draft_tag (
draft_id INTEGER NOT NULL,
tag TEXT NOT NULL,
CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag),
CONSTRAINT fk_transaction_draft_tag_draft
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_draft_attachment (
draft_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL,
PRIMARY KEY (draft_id, attachment_id),
CONSTRAINT fk_transaction_draft_attachment_transaction
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_draft_attachment_attachment
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_draft_line_item (
draft_id INTEGER NOT NULL,
idx INTEGER NOT NULL DEFAULT 0,
value_per_item INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL,
category_id INTEGER,
CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx),
CONSTRAINT fk_transaction_draft_line_item_transaction
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_draft_line_item_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE SET NULL
);
CREATE TABLE recurring_transaction (
id INTEGER PRIMARY KEY,
draft_id INTEGER NOT NULL,
schedule_expr TEXT NOT NULL,
CONSTRAINT fk_recurring_transaction_draft
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
ON UPDATE CASCADE ON DELETE CASCADE
);

View File

@ -0,0 +1,13 @@
UPDATE transaction_draft
SET
template_name = ?,
timestamp = ?,
amount = ?,
currency = ?,
description = ?,
internal_transfer = ?,
vendor_id = ?,
category_id = ?,
credited_account_id = ?,
debited_account_id = ?
WHERE id = ?

View File

@ -25,6 +25,7 @@
"@idle-observer/vue3": "^0.2.0", "@idle-observer/vue3": "^0.2.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"cron-parser": "^5.6.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",

View File

@ -59,6 +59,13 @@ export interface Account {
currentBalance: number | null currentBalance: number | null
} }
export interface SimpleAccountResponse {
id: number
name: string
type: string
numberSuffix: string
}
export interface AccountCreationPayload { export interface AccountCreationPayload {
type: string type: string
numberSuffix: string numberSuffix: string

View File

@ -1,8 +1,28 @@
import type { SimpleAccountResponse } from './account'
import type { Attachment } from './attachment' import type { Attachment } from './attachment'
import { ApiClient } from './base' import { ApiClient } from './base'
import type { Currency } from './data' import type { Currency } from './data'
import { type Page, type PageRequest } from './pagination' import { type Page, type PageRequest } from './pagination'
export interface SimpleVendorResponse {
id: number
name: string
}
export interface SimpleCategoryResponse {
id: number
name: string
color: string
}
export interface TransactionLineItemResponse {
idx: number
valuePerItem: number
quantity: number
description: string
category: TransactionCategory | null
}
export interface TransactionVendor { export interface TransactionVendor {
id: number id: number
name: string name: string
@ -47,10 +67,10 @@ export interface TransactionsListItem {
currency: Currency currency: Currency
description: string description: string
internalTransfer: boolean internalTransfer: boolean
vendor: TransactionsListItemVendor | null vendor: SimpleVendorResponse | null
category: TransactionsListItemCategory | null category: SimpleCategoryResponse | null
creditedAccount: TransactionsListItemAccount | null creditedAccount: SimpleAccountResponse | null
debitedAccount: TransactionsListItemAccount | null debitedAccount: SimpleAccountResponse | null
tags: string[] tags: string[]
} }
@ -82,28 +102,13 @@ export interface TransactionDetail {
internalTransfer: boolean internalTransfer: boolean
vendor: TransactionVendor | null vendor: TransactionVendor | null
category: TransactionCategory | null category: TransactionCategory | null
creditedAccount: TransactionDetailAccount | null creditedAccount: SimpleAccountResponse | null
debitedAccount: TransactionDetailAccount | null debitedAccount: SimpleAccountResponse | null
tags: string[] tags: string[]
lineItems: TransactionDetailLineItem[] lineItems: TransactionLineItemResponse[]
attachments: Attachment[] attachments: Attachment[]
} }
export interface TransactionDetailAccount {
id: number
name: string
type: string
numberSuffix: string
}
export interface TransactionDetailLineItem {
idx: number
valuePerItem: number
quantity: number
description: string
category: TransactionCategory | null
}
export interface AddTransactionPayload { export interface AddTransactionPayload {
timestamp: string timestamp: string
amount: number amount: number
@ -144,6 +149,67 @@ export interface AggregateTransactionData {
currencies: AggregateTransactionCurrencyData[] currencies: AggregateTransactionCurrencyData[]
} }
export interface TransactionDraftListItem {
id: number
addedAt: string
templateName: string | null
timestamp: string | null
amount: number | null
currency: Currency | null
description: string | null
internalTransfer: boolean | null
vendor: SimpleVendorResponse | null
category: SimpleCategoryResponse | null
creditedAccount: SimpleAccountResponse | null
debitedAccount: SimpleAccountResponse | null
tags: string[]
}
export interface TransactionDraftResponse {
id: number
addedAt: string
templateName: string | null
timestamp: string | null
amount: number | null
currency: Currency | null
description: string | null
internalTransfer: boolean | null
vendor: SimpleVendorResponse | null
category: SimpleCategoryResponse | null
creditedAccount: SimpleAccountResponse | null
debitedAccount: SimpleAccountResponse | null
tags: string[]
lineItems: TransactionLineItemResponse[]
attachments: Attachment[]
}
export interface TransactionDraftPayload {
templateName: string | null
timestamp: string | null
amount: number | null
currencyCode: string | null
description: string | null
internalTransfer: boolean | null
vendorId: number | null
categoryId: number | null
creditedAccountId: number | null
debitedAccountId: number | null
tags: string[]
lineItems: AddTransactionPayloadLineItem[]
attachmentIdsToRemove: number[]
}
export interface RecurringTransactionPayload {
draftId: number
scheduleExpr: string
}
export interface RecurringTransactionResponse {
id: number
draftId: number
scheduleExpr: string
}
export class TransactionApiClient extends ApiClient { export class TransactionApiClient extends ApiClient {
readonly path: string readonly path: string
@ -277,4 +343,77 @@ export class TransactionApiClient extends ApiClient {
getAllTags(): Promise<string[]> { getAllTags(): Promise<string[]> {
return super.getJson(this.path + '/transaction-tags') return super.getJson(this.path + '/transaction-tags')
} }
// Drafts:
getDrafts(
paginationOptions: PageRequest | undefined = undefined,
): Promise<Page<TransactionDraftListItem>> {
return super.getJsonPage(this.path + '/transaction-drafts', paginationOptions)
}
getTemplateDrafts(
paginationOptions: PageRequest | undefined = undefined,
): Promise<Page<TransactionDraftListItem>> {
const params = new URLSearchParams()
params.append('template', 'true')
if (paginationOptions !== undefined) {
params.append('page', paginationOptions.page + '')
params.append('size', paginationOptions.size + '')
for (const sort of paginationOptions.sorts) {
params.append('sort', sort.attribute + ',' + sort.dir)
}
}
return super.getJson(this.path + '/transaction-drafts?' + params.toString())
}
getDraft(id: number): Promise<TransactionDraftResponse> {
return super.getJson(this.path + '/transaction-drafts/' + id)
}
addDraft(data: TransactionDraftPayload, files: File[] = []): Promise<TransactionDraftResponse> {
const formData = new FormData()
formData.append('payload', JSON.stringify(data))
for (const file of files) {
formData.append('file', file)
}
return super.postFormData(this.path + '/transaction-drafts', formData)
}
updateDraft(
id: number,
data: TransactionDraftPayload,
files: File[] = [],
): Promise<TransactionDraftResponse> {
const formData = new FormData()
formData.append('payload', JSON.stringify(data))
for (const file of files) {
formData.append('file', file)
}
return super.putFormData(this.path + '/transaction-drafts/' + id, formData)
}
deleteDraft(id: number): Promise<void> {
return super.delete(this.path + '/transaction-drafts/' + id)
}
getRecurringTransactionsForDraft(draftId: number): Promise<RecurringTransactionResponse[]> {
return super.getJson(`${this.path}/transaction-drafts/${draftId}/recurring-transactions`)
}
getRecurringTransactions(
paginationOptions: PageRequest | undefined = undefined,
): Promise<Page<RecurringTransactionResponse>> {
return super.getJsonPage(this.path + '/recurring-transactions', paginationOptions)
}
createRecurringTransaction(
data: RecurringTransactionPayload,
): Promise<RecurringTransactionResponse> {
return super.postJson(this.path + '/recurring-transactions', data)
}
deleteRecurringTransaction(id: number): Promise<void> {
return super.delete(`${this.path}/recurring-transactions/${id}`)
}
} }

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import {
TransactionApiClient,
type RecurringTransactionPayload,
type RecurringTransactionResponse,
} from '@/api/transaction'
import ModalWrapper from './common/ModalWrapper.vue'
import { ref, useTemplateRef, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import AppForm from './common/form/AppForm.vue'
import FormGroup from './common/form/FormGroup.vue'
import FormControl from './common/form/FormControl.vue'
import AppButton from './common/AppButton.vue'
import { getSelectedProfile } from '@/api/profile.ts'
const route = useRoute()
const props = defineProps<{ draftId: number }>()
const modal = useTemplateRef('modal')
const savedTxn: Ref<RecurringTransactionResponse | undefined> = ref()
// Form Data:
const dayOfMonth: Ref<number> = ref(1)
async function show(): Promise<RecurringTransactionResponse | undefined> {
if (!modal.value) return undefined
savedTxn.value = undefined
const result = await modal.value.show()
if (result === 'saved') {
return savedTxn.value
}
return undefined
}
async function addRecurringTransaction() {
const payload: RecurringTransactionPayload = {
draftId: props.draftId,
scheduleExpr: `0 0 0 ${dayOfMonth.value} * *`,
}
const api = new TransactionApiClient(getSelectedProfile(route))
try {
savedTxn.value = await api.createRecurringTransaction(payload)
modal.value?.close('saved')
} catch (err) {
console.error(err)
savedTxn.value = undefined
}
}
defineExpose({ show })
</script>
<template>
<ModalWrapper ref="modal">
<template v-slot:default>
<h2>Add Recurring Transaction</h2>
<p>
Use this template to add a <em>recurring transaction</em>. According to the interval you
specify below, Finnow will create a new draft using this template each time, which you can
then review and submit.
</p>
<p>
Each time a new draft is created from this template for recurring transactions, the new
draft's timestamp will be set to the current date and time.
</p>
<AppForm>
<h3>Monthly Schedule</h3>
<FormGroup>
<FormControl
label="Day of Month"
hint="Specify on which day of the month this transaction occurs."
>
<input
type="number"
step="1"
min="1"
max="31"
v-model="dayOfMonth"
/>
</FormControl>
</FormGroup>
</AppForm>
</template>
<template v-slot:buttons>
<AppButton @click="addRecurringTransaction()">Add</AppButton>
<AppButton
button-style="secondary"
@click="modal?.close()"
>Cancel</AppButton
>
</template>
</ModalWrapper>
</template>

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TransactionDetailLineItem } from '@/api/transaction' import type { TransactionLineItemResponse } from '@/api/transaction'
import AppButton from './common/AppButton.vue' import AppButton from './common/AppButton.vue'
import { formatMoney, type Currency } from '@/api/data' import { formatMoney, type Currency } from '@/api/data'
defineProps<{ defineProps<{
lineItem: TransactionDetailLineItem lineItem: TransactionLineItemResponse
currency: Currency currency: Currency
totalCount?: number totalCount?: number
editable?: boolean editable?: boolean

View File

@ -4,7 +4,7 @@ transaction. This editor shows a table of current line items, and includes a
modal for adding a new one. modal for adding a new one.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction' import { type TransactionCategoryTree, type TransactionLineItemResponse } from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue' import AppButton from '@/components/common/AppButton.vue'
import FormGroup from '@/components/common/form/FormGroup.vue' import FormGroup from '@/components/common/form/FormGroup.vue'
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data' import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'
@ -15,7 +15,7 @@ import CategorySelect from './CategorySelect.vue'
import LineItemCard from './LineItemCard.vue' import LineItemCard from './LineItemCard.vue'
import AppBadge from './common/AppBadge.vue' import AppBadge from './common/AppBadge.vue'
const model = defineModel<TransactionDetailLineItem[]>({ required: true }) const model = defineModel<TransactionLineItemResponse[]>({ required: true })
const props = defineProps<{ const props = defineProps<{
transactionAmount: number transactionAmount: number
currency: Currency currency: Currency

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'
import type { TransactionDraftListItem } from '@/api/transaction'
import { useRoute, useRouter } from 'vue-router'
import AppBadge from './common/AppBadge.vue'
import CategoryLabel from './CategoryLabel.vue'
import TagLabel from './TagLabel.vue'
const router = useRouter()
const route = useRoute()
const props = defineProps<{ draft: TransactionDraftListItem }>()
function goToDraft() {
const profile = getSelectedProfile(route)
router.push(`/profiles/${profile}/transaction-drafts/${props.draft.id}`)
}
</script>
<template>
<div
class="transaction-draft-card"
@click="goToDraft()"
>
<div>
<!-- Top row contains timestamp and amount. -->
<div style="display: flex; justify-content: space-between">
<div>
<div class="font-mono font-size-xsmall text-normal">
Draft #{{ draft.id }}
<AppBadge
v-if="draft.templateName"
size="sm"
>
Template: {{ draft.templateName }}
</AppBadge>
</div>
<div
class="text-muted font-mono font-size-xsmall"
v-if="draft.timestamp"
>
{{ new Date(draft.timestamp).toLocaleString() }}
</div>
</div>
<div>
<div
class="font-mono align-right font-size-small"
v-if="draft.amount && draft.currency"
>
{{ formatMoney(draft.amount, draft.currency) }}
</div>
<div
v-if="draft.creditedAccount !== null"
class="font-size-small text-muted"
>
Credited to <span class="text-normal font-bold">{{ draft.creditedAccount.name }}</span>
</div>
<div
v-if="draft.debitedAccount !== null"
class="font-size-small text-muted"
>
Debited to <span class="text-normal font-bold">{{ draft.debitedAccount.name }}</span>
</div>
</div>
</div>
<!-- Middle row contains the description. -->
<div>
<p class="transaction-draft-card-description">{{ draft.description }}</p>
</div>
</div>
<!-- Bottom row contains other links. -->
<div style="display: flex; justify-content: space-between">
<div>
<CategoryLabel
:category="draft.category"
v-if="draft.category"
style="margin-left: 0"
/>
<AppBadge v-if="draft.vendor">{{ draft.vendor.name }}</AppBadge>
</div>
<div>
<!-- Only show the first 3 tags, and add a "+N" badge for any more. -->
<TagLabel
v-for="tag in draft.tags.slice(0, 3)"
:key="tag"
:tag="tag"
/>
<AppBadge
v-if="draft.tags.length > 3"
class="text-muted"
>+{{ draft.tags.length - 3 }}</AppBadge
>
</div>
</div>
</div>
</template>
<style>
.transaction-draft-card {
background-color: var(--bg);
padding: 0.5rem;
border-radius: 0.5rem;
margin: 0.5rem 0;
cursor: pointer;
height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.transaction-draft-card:hover {
background-color: var(--bg-darker);
}
.transaction-draft-card-description {
margin: 0.25rem 0;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,260 @@
<script setup lang="ts">
import { ApiError } from '@/api/base'
import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'
import {
TransactionApiClient,
type RecurringTransactionResponse,
type TransactionDraftResponse,
} from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'
import CategoryLabel from '@/components/CategoryLabel.vue'
import PropertiesTable from '@/components/PropertiesTable.vue'
import TagLabel from '@/components/TagLabel.vue'
import { showAlert, showConfirm } from '@/util/alert'
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import AttachmentRow from '@/components/common/AttachmentRow.vue'
import LineItemCard from '@/components/LineItemCard.vue'
import AppBadge from '@/components/common/AppBadge.vue'
import ButtonBar from '@/components/common/ButtonBar.vue'
import { useRoute, useRouter } from 'vue-router'
import AddRecurringTransactionModal from '@/components/AddRecurringTransactionModal.vue'
import { CronExpressionParser } from 'cron-parser'
const route = useRoute()
const router = useRouter()
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
const addRecurringTransactionModal = useTemplateRef('addRecurringTransactionModal')
const draft: Ref<TransactionDraftResponse | undefined> = ref()
const recurringTransactions: Ref<RecurringTransactionResponse[]> = ref([])
const pageTitle = computed(() => {
if (draft.value === undefined) return 'Transaction Draft'
if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
return `Transaction Template ${draft.value.id}: "${draft.value.templateName}"`
}
return 'Transaction Draft ' + draft.value.id
})
onMounted(async () => {
const draftId = parseInt(route.params.id as string)
recurringTransactions.value = []
try {
draft.value = await transactionApi.getDraft(draftId)
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(draftId)
} catch (err) {
console.error(err)
await router.replace('/')
if (err instanceof ApiError) {
await showAlert('Failed to fetch transaction: ' + err.message)
}
}
})
async function deleteDraft() {
if (!draft.value) return
const conf = await showConfirm(
'Are you sure you want to delete this draft? This will permanently delete all data pertaining to this draft, and it cannot be recovered.',
)
if (!conf) return
try {
await transactionApi.deleteDraft(draft.value.id)
await router.replace(`/profiles/${getSelectedProfile(route)}`)
} catch (err) {
console.error(err)
}
}
async function onVendorClicked() {
if (draft.value && draft.value.vendor) {
await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`)
}
}
async function addRecurringTransaction() {
const result = await addRecurringTransactionModal.value?.show()
if (result !== undefined && draft.value) {
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
draft.value.id,
)
}
}
async function deleteRecurringTransaction(rt: RecurringTransactionResponse) {
if (!draft.value) return
await transactionApi.deleteRecurringTransaction(rt.id)
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
draft.value.id,
)
}
function formatRecurringTransactionScheduleExpr(rt: RecurringTransactionResponse) {
const interval = CronExpressionParser.parse(rt.scheduleExpr)
const dayOfMonth = interval.fields.dayOfMonth.serialize().values[0] as number
return `The ${dayOfMonth}${getNumberAdjectiveSuffix(dayOfMonth)} day of every month`
}
function getNumberAdjectiveSuffix(n: number) {
const digit = n % 10
if (digit === 1) return 'st'
if (digit === 2) return 'nd'
if (digit === 3) return 'rd'
return 'th'
}
</script>
<template>
<AppPage
:title="pageTitle"
v-if="draft"
>
<!-- Top-row with some badges for amount, vendor, and category. -->
<div>
<AppBadge
size="lg"
class="font-mono"
v-if="draft.currency && draft.amount"
>
{{ draft.currency.code }} {{ formatMoney(draft.amount, draft.currency) }}
</AppBadge>
<AppBadge
size="md"
v-if="draft.vendor"
style="cursor: pointer"
@click="onVendorClicked()"
>
{{ draft.vendor.name }}
</AppBadge>
<CategoryLabel
v-if="draft.category"
:category="draft.category"
:clickable="true"
/>
<AppBadge
size="sm"
v-if="draft.internalTransfer"
>
<font-awesome-icon icon="fa-rotate"></font-awesome-icon>
Internal Transfer
</AppBadge>
</div>
<!-- Second row that lists all tags. -->
<div
v-if="draft.tags.length > 0"
class="mt-1"
>
<TagLabel
v-for="t in draft.tags"
:key="t"
:tag="t"
/>
</div>
<p>{{ draft.description }}</p>
<div
v-if="draft.creditedAccount"
class="my-1"
>
<strong class="text-negative">Credited</strong> from
<RouterLink
:to="`/profiles/${getSelectedProfile(route)}/accounts/${draft.creditedAccount.id}`"
>
{{ draft.creditedAccount.name }} (#{{ draft.creditedAccount.numberSuffix }})
</RouterLink>
</div>
<div
v-if="draft.debitedAccount"
class="my-1"
>
<strong class="text-positive">Debited</strong> to
<RouterLink
:to="`/profiles/${getSelectedProfile(route)}/accounts/${draft.debitedAccount.id}`"
>
{{ draft.debitedAccount.name }} (#{{ draft.debitedAccount.numberSuffix }})
</RouterLink>
</div>
<!-- All remaining properties are put in this table. -->
<PropertiesTable>
<tr>
<th>Timestamp</th>
<td v-if="draft.timestamp !== null">{{ new Date(draft.timestamp).toLocaleString() }}</td>
<td v-if="draft.timestamp === null"><em>null</em></td>
</tr>
<tr>
<th>Added to Finnow</th>
<td>{{ new Date(draft.addedAt).toLocaleString() }}</td>
</tr>
</PropertiesTable>
<div v-if="draft.lineItems.length > 0 && draft.currency">
<h3>Line Items</h3>
<LineItemCard
v-for="item of draft.lineItems"
:key="item.idx"
:line-item="item"
:currency="draft.currency"
:total-count="draft.lineItems.length"
:editable="false"
/>
</div>
<div v-if="draft.attachments.length > 0">
<h3>Attachments</h3>
<AttachmentRow
v-for="a in draft.attachments"
:attachment="a"
:key="a.id"
disabled
/>
</div>
<ButtonBar>
<AppButton
icon="wrench"
@click="
router.push(`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft?.id}/edit`)
"
>
Edit
</AppButton>
<AppButton
icon="trash"
@click="deleteDraft()"
>Delete</AppButton
>
</ButtonBar>
<ButtonBar>
<AppButton
icon="repeat"
@click="addRecurringTransaction()"
>
Add Recurring Transaction
</AppButton>
</ButtonBar>
<div v-if="recurringTransactions.length > 0">
<h3>Recurring Transactions</h3>
<p>This template will be used to create new drafts using the following schedules:</p>
<ul>
<li
v-for="rt in recurringTransactions"
:key="rt.id"
>
{{ formatRecurringTransactionScheduleExpr(rt) }}
<AppButton
icon="trash"
@click="deleteRecurringTransaction(rt)"
/>
</li>
</ul>
</div>
<AddRecurringTransactionModal
ref="addRecurringTransactionModal"
:draft-id="draft.id"
/>
</AppPage>
</template>

View File

@ -2,12 +2,14 @@
import ProfileModule from './home/ProfileModule.vue' import ProfileModule from './home/ProfileModule.vue'
import AccountsModule from './home/AccountsModule.vue' import AccountsModule from './home/AccountsModule.vue'
import TransactionsModule from './home/TransactionsModule.vue' import TransactionsModule from './home/TransactionsModule.vue'
import DraftsModule from './home/DraftsModule.vue'
</script> </script>
<template> <template>
<div class="app-module-container"> <div class="app-module-container">
<ProfileModule /> <ProfileModule />
<AccountsModule /> <AccountsModule />
<TransactionsModule /> <TransactionsModule />
<DraftsModule />
<!-- <AnalyticsModule /> --> <!-- <AnalyticsModule /> -->
</div> </div>
</template> </template>

View File

@ -1,429 +0,0 @@
<!--
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, floatMoneyToInteger, type Currency } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'
import {
TransactionApiClient,
type AddTransactionPayload,
type TransactionDetail,
type TransactionDetailLineItem,
type TransactionVendor,
} from '@/api/transaction'
import AppPage from '@/components/common/AppPage.vue'
import CategorySelect from '@/components/CategorySelect.vue'
import FileSelector from '@/components/common/FileSelector.vue'
import AppForm from '@/components/common/form/AppForm.vue'
import FormActions from '@/components/common/form/FormActions.vue'
import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'
import LineItemsEditor from '@/components/LineItemsEditor.vue'
import { getDatetimeLocalValueForNow } from '@/util/time'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VendorSelect from '@/components/VendorSelect.vue'
import TagsSelect from '@/components/TagsSelect.vue'
const route = useRoute()
const router = useRouter()
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
const accountApi = new AccountApiClient(route)
const existingTransaction: Ref<TransactionDetail | null> = ref(null)
const editing = computed(() => {
return existingTransaction.value !== null || route.meta.title === 'Edit Transaction'
})
const formValid = computed(() => {
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
)
})
const unsavedEdits = computed(() => {
console.log('Checking if there are unsaved edits...')
if (!existingTransaction.value) return true
const tx = existingTransaction.value
const tagsEqual =
tags.value.every((t) => tx.tags.includes(t)) && tx.tags.every((t) => tags.value.includes(t))
let lineItemsEqual = false
if (lineItems.value.length === tx.lineItems.length) {
lineItemsEqual = true
for (let i = 0; i < lineItems.value.length; i++) {
const i1 = lineItems.value[i]
const i2 = tx.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
}
}
}
const attachmentsChanged =
attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
const timestampChanged = new Date(timestamp.value).toISOString() !== tx.timestamp
const amountChanged =
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== tx.amount
const currencyChanged = currency.value?.code !== tx.currency.code
const descriptionChanged = description.value !== tx.description
const internalTransferChanged = internalTransfer.value !== tx.internalTransfer
const vendorChanged = vendor.value?.id !== tx.vendor?.id
const categoryChanged = categoryId.value !== (tx.category?.id ?? null)
const creditedAccountChanged = creditedAccountId.value !== (tx.creditedAccount?.id ?? null)
const debitedAccountChanged = debitedAccountId.value !== (tx.debitedAccount?.id ?? null)
console.log(`
Timestamp changed: ${timestampChanged}
Amount changed: ${amountChanged}
Currency changed: ${currencyChanged}
Description changed: ${descriptionChanged}
Internal Transfer changed: ${internalTransferChanged}
Vendor changed: ${vendorChanged}
Category changed: ${categoryChanged}
Credited account changed: ${creditedAccountChanged}
Debited account changed: ${debitedAccountChanged}
Tags changed: ${!tagsEqual}
Line items changed: ${!lineItemsEqual}
Attachments changed: ${attachmentsChanged}
`)
return (
timestampChanged ||
amountChanged ||
currencyChanged ||
descriptionChanged ||
internalTransferChanged ||
vendorChanged ||
categoryChanged ||
creditedAccountChanged ||
debitedAccountChanged ||
!tagsEqual ||
!lineItemsEqual ||
attachmentsChanged
)
})
// 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.code === c.code),
)
})
const allAccounts: Ref<Account[]> = ref([])
const availableAccounts = computed(() => {
return allAccounts.value.filter((a) => a.currency.code === currency.value?.code)
})
const loading = ref(false)
// Form data:
const timestamp = ref('')
const amount = ref(0)
const currency: Ref<Currency | null> = ref(null)
const description = ref('')
const internalTransfer = ref(false)
const vendor: Ref<TransactionVendor | 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 customTagInput = ref('')
const customTagInputValid = ref(false)
const attachmentsToUpload: Ref<File[]> = ref([])
const removedAttachmentIds: Ref<number[]> = ref([])
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 () => {
const dataClient = new DataApiClient()
// Fetch various collections of data needed for different user choices.
dataClient.getCurrencies().then((currencies) => (allCurrencies.value = currencies))
accountApi.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 transactionApi.getTransaction(transactionId)
loadValuesFromExistingTransaction(existingTransaction.value)
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
} else {
// Load default values.
timestamp.value = getDatetimeLocalValueForNow()
amount.value = Math.pow(10, currency.value?.fractionalDigits ?? 0)
}
// Load default values from the query parameters.
if ('credited-account' in route.query) {
creditedAccountId.value = parseInt(route.query['credited-account'] as string)
}
if ('debited-account' in route.query) {
debitedAccountId.value = parseInt(route.query['debited-account'] as string)
}
})
/**
* 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 (currency.value === null) {
return
}
let vendorId: number | null = vendor.value?.id ?? null
if (vendor.value !== null && vendorId === -1) {
const newVendor = await transactionApi.createVendor({
name: vendor.value?.name,
description: null,
})
vendorId = newVendor.id
}
const localDate = new Date(timestamp.value)
const payload: AddTransactionPayload = {
timestamp: localDate.toISOString(),
amount: floatMoneyToInteger(amount.value, currency.value),
currencyCode: currency.value?.code ?? '',
description: description.value,
internalTransfer: internalTransfer.value,
vendorId: vendorId,
categoryId: categoryId.value,
creditedAccountId: creditedAccountId.value,
debitedAccountId: debitedAccountId.value,
tags: tags.value,
lineItems: lineItems.value.map((i) => {
return { ...i, categoryId: i.category?.id ?? null }
}),
attachmentIdsToRemove: removedAttachmentIds.value,
}
let savedTransaction = null
try {
loading.value = true
if (existingTransaction.value) {
savedTransaction = await transactionApi.updateTransaction(
existingTransaction.value?.id,
payload,
attachmentsToUpload.value,
)
} else {
savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value)
}
await router.replace(
`/profiles/${getSelectedProfile(route)}/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 (editing.value) {
router.replace(
`/profiles/${getSelectedProfile(route)}/transactions/${existingTransaction.value?.id}`,
)
} else {
router.replace(`/profiles/${getSelectedProfile(route)}`)
}
}
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
internalTransfer.value = t.internalTransfer
vendor.value = t.vendor ?? 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)
}
</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">
<VendorSelect v-model="vendor" />
</FormControl>
<FormControl label="Category">
<CategorySelect v-model="categoryId" />
</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-if="currency"
v-model="lineItems"
:currency="currency"
:transaction-amount="floatMoneyToInteger(amount, currency)"
/>
<FormGroup>
<!-- Tags -->
<FormControl label="Tags">
<TagsSelect v-model="tags" />
</FormControl>
</FormGroup>
<FormGroup>
<h5>Attachments</h5>
<FileSelector
:initial-files="existingTransaction?.attachments ?? []"
v-model:uploaded-files="attachmentsToUpload"
v-model:removed-files="removedAttachmentIds"
/>
</FormGroup>
<!-- One last group for less-often used fields: -->
<FormGroup>
<FormControl
label="Internal Transfer"
hint="Mark this transaction as an internal transfer to ignore it in analytics. Useful for things like credit card payments."
>
<input
type="checkbox"
v-model="internalTransfer"
:disabled="loading"
/>
</FormControl>
</FormGroup>
<FormActions
@cancel="doCancel()"
:disabled="loading || !formValid || !unsavedEdits"
:submit-text="editing ? 'Save' : 'Add'"
/>
</AppForm>
</AppPage>
</template>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Page, PageRequest } from '@/api/pagination'
import { TransactionApiClient, type TransactionDraftListItem } from '@/api/transaction'
import PaginationControls from '@/components/common/PaginationControls.vue'
import HomeModule from '@/components/HomeModule.vue'
import { useRoute } from 'vue-router'
import { onMounted, ref, type Ref } from 'vue'
import { getSelectedProfile } from '@/api/profile'
import TransactionDraftCard from '@/components/TransactionDraftCard.vue'
const route = useRoute()
const page: Ref<Page<TransactionDraftListItem>> = ref({
items: [],
pageRequest: { page: 1, size: 5, sorts: [] },
totalElements: 0,
totalPages: 0,
isFirst: true,
isLast: true,
})
onMounted(async () => {
await fetchPage(page.value.pageRequest)
})
async function fetchPage(pageRequest: PageRequest) {
const api = new TransactionApiClient(getSelectedProfile(route))
try {
page.value = await api.getDrafts(pageRequest)
} catch (err) {
console.error(err)
}
}
</script>
<template>
<HomeModule title="Drafts">
<template v-slot:default>
<PaginationControls
:page="page"
@update="(pr) => fetchPage(pr)"
class="align-right"
/>
<TransactionDraftCard
v-for="draft in page.items"
:key="draft.id"
:draft="draft"
/>
<p v-if="page.totalElements === 0">There are no drafts.</p>
</template>
</HomeModule>
</template>

View File

@ -0,0 +1,250 @@
<!--
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, floatMoneyToInteger, type Currency } from '@/api/data'
import AppPage from '@/components/common/AppPage.vue'
import CategorySelect from '@/components/CategorySelect.vue'
import FileSelector from '@/components/common/FileSelector.vue'
import AppForm from '@/components/common/form/AppForm.vue'
import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'
import LineItemsEditor from '@/components/LineItemsEditor.vue'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VendorSelect from '@/components/VendorSelect.vue'
import TagsSelect from '@/components/TagsSelect.vue'
import {
defaultEmptyFormFields,
DraftEditorContext,
loadEditorContextFromRoute,
NewTransactionEditorContext,
TransactionEditorContext,
type TransactionEditorContextBase,
type TransactionEditorFormFields,
} from './util'
import ButtonBar from '@/components/common/ButtonBar.vue'
import AppButton from '@/components/common/AppButton.vue'
const route = useRoute()
const router = useRouter()
const accountApi = new AccountApiClient(route)
// 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.code === c.code),
)
})
const allAccounts: Ref<Account[]> = ref([])
const availableAccounts = computed(() => {
return allAccounts.value.filter((a) => a.currency.code === formData.value.currency?.code)
})
// Reactive form data:
const loading = ref(false)
const formData: Ref<TransactionEditorFormFields> = ref(defaultEmptyFormFields())
const editorContext: Ref<TransactionEditorContextBase> = ref(new NewTransactionEditorContext())
const pageTitle = computed(() => {
if (editorContext.value instanceof NewTransactionEditorContext) {
return 'Add Transaction'
} else if (editorContext.value instanceof DraftEditorContext) {
return 'Edit Draft Transaction'
} else if (editorContext.value instanceof TransactionEditorContext) {
return 'Edit Transaction'
}
return 'Edit Transaction'
})
watch(availableCurrencies, (newValue: Currency[]) => {
if (newValue.length === 1) {
formData.value.currency = newValue[0]
}
})
onMounted(async () => {
const dataClient = new DataApiClient()
// Fetch various collections of data needed for different user choices.
dataClient.getCurrencies().then((currencies) => (allCurrencies.value = currencies))
accountApi.getAccounts().then((accounts) => (allAccounts.value = accounts))
try {
editorContext.value = await loadEditorContextFromRoute(route)
} catch (err) {
console.error(err)
}
formData.value = editorContext.value.initializeFormFields(route.query)
})
</script>
<template>
<AppPage :title="pageTitle">
<AppForm>
<!-- Initial draft-only form group: -->
<FormGroup v-if="editorContext instanceof DraftEditorContext">
<FormControl
label="Template Name"
hint="Add a name to this draft to turn it into a Template, which you can use when creating new transactions or scheduled transactions."
>
<input
type="text"
v-model="formData.templateName"
:disabled="loading"
style="max-width: 200px"
maxlength="32"
/>
</FormControl>
</FormGroup>
<FormGroup>
<!-- Basic properties -->
<FormControl label="Timestamp">
<input
type="datetime-local"
v-model="formData.timestamp"
step="1"
:disabled="loading"
style="min-width: 250px"
/>
</FormControl>
<FormControl label="Amount">
<input
type="number"
v-model="formData.amount"
step="0.01"
min="0.01"
:disabled="loading"
style="max-width: 100px"
/>
</FormControl>
<FormControl label="Currency">
<select
v-model="formData.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="formData.description"
:disabled="loading"
></textarea>
</FormControl>
</FormGroup>
<FormGroup>
<!-- Vendor & Category -->
<FormControl label="Vendor">
<VendorSelect v-model="formData.vendor" />
</FormControl>
<FormControl label="Category">
<CategorySelect v-model="formData.categoryId" />
</FormControl>
</FormGroup>
<FormGroup>
<!-- Accounts -->
<FormControl label="Credited Account">
<select
v-model="formData.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="formData.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-if="formData.currency"
v-model="formData.lineItems"
:currency="formData.currency"
:transaction-amount="floatMoneyToInteger(formData.amount ?? 0, formData.currency)"
/>
<FormGroup>
<!-- Tags -->
<FormControl label="Tags">
<TagsSelect v-model="formData.tags" />
</FormControl>
</FormGroup>
<FormGroup>
<h5>Attachments</h5>
<FileSelector
:initial-files="formData.existingAttachments"
v-model:uploaded-files="formData.attachmentsToUpload"
v-model:removed-files="formData.removedAttachmentIds"
/>
</FormGroup>
<!-- One last group for less-often used fields: -->
<FormGroup>
<FormControl
label="Internal Transfer"
hint="Mark this transaction as an internal transfer to ignore it in analytics. Useful for things like credit card payments."
>
<input
type="checkbox"
v-model="formData.internalTransfer"
:disabled="loading"
/>
</FormControl>
</FormGroup>
<!-- The set of available actions is defined by the current editor context. -->
<ButtonBar>
<AppButton
v-for="action in editorContext.getActions(formData)"
:key="action.name"
:disabled="action.disabled"
@click="action.callback(formData, route, router)"
>
{{ action.name }}
</AppButton>
</ButtonBar>
</AppForm>
</AppPage>
</template>

View File

@ -0,0 +1,493 @@
import type { Attachment } from '@/api/attachment'
import { floatMoneyToInteger, integerMoneyToFloat, type Currency } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'
import {
TransactionApiClient,
type AddTransactionPayload,
type TransactionDetail,
type TransactionDraftPayload,
type TransactionDraftResponse,
type TransactionLineItemResponse,
type TransactionVendor,
} from '@/api/transaction'
import { getDatetimeLocalValueForNow } from '@/util/time'
import type { RouteLocation, Router } from 'vue-router'
/**
* The set of all form fields on the transaction editor page. Note that some
* fields may only be used in certain contexts.
*/
export interface TransactionEditorFormFields {
timestamp: string | null
amount: number | null
templateName: string | null // Only for drafts, not transactions.
currency: Currency | null
description: string | null
internalTransfer: boolean | null
vendor: TransactionVendor | null
categoryId: number | null
creditedAccountId: number | null
debitedAccountId: number | null
lineItems: TransactionLineItemResponse[]
tags: string[]
attachmentsToUpload: File[]
removedAttachmentIds: number[]
existingAttachments: Attachment[]
}
export function defaultEmptyFormFields(): TransactionEditorFormFields {
return {
timestamp: null,
amount: null,
templateName: null,
currency: null,
description: null,
internalTransfer: null,
vendor: null,
categoryId: null,
creditedAccountId: null,
debitedAccountId: null,
lineItems: [],
tags: [],
attachmentsToUpload: [],
removedAttachmentIds: [],
existingAttachments: [],
}
}
export interface TransactionEditorAction {
name: string
disabled: boolean
callback: (
formData: TransactionEditorFormFields,
route: RouteLocation,
router: Router,
) => Promise<void>
}
/**
* Base class for transaction editor contexts.
*/
export interface TransactionEditorContextBase {
isFormDataValid(formData: TransactionEditorFormFields): boolean
areChangesPresent(formData: TransactionEditorFormFields): boolean
initializeFormFields(
queryParams: Record<string, string | null | (string | null)[]>,
): TransactionEditorFormFields
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[]
}
/**
* Editor context that's used when the user starts editing a new transaction,
* not related to any saved transaction or draft.
*/
export class NewTransactionEditorContext implements TransactionEditorContextBase {
isFormDataValid(formData: TransactionEditorFormFields): boolean {
return (
isFormDataValidForDraftSave(formData) || isFormDataValidForTransactionSubmission(formData)
)
}
areChangesPresent(formData: TransactionEditorFormFields): boolean {
return (
formData.timestamp !== null ||
formData.amount !== null ||
formData.templateName !== null ||
formData.currency !== null ||
formData.description !== null ||
formData.internalTransfer !== null ||
formData.vendor !== null ||
formData.creditedAccountId !== null ||
formData.debitedAccountId !== null ||
formData.lineItems.length > 0 ||
formData.tags.length > 0 ||
formData.attachmentsToUpload.length > 0
)
}
initializeFormFields(
queryParams: Record<string, string | null | (string | null)[]>,
): TransactionEditorFormFields {
const fields = defaultEmptyFormFields()
fields.timestamp = getDatetimeLocalValueForNow()
if ('credited-account' in queryParams) {
fields.creditedAccountId = parseInt(queryParams['credited-account'] as string)
}
if ('debited-account' in queryParams) {
fields.debitedAccountId = parseInt(queryParams['debited-account'] as string)
}
return fields
}
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
return [
{
name: 'Save',
disabled: !(
this.areChangesPresent(formData) && isFormDataValidForTransactionSubmission(formData)
),
callback: async (formData, route, router) => {
const api = new TransactionApiClient(getSelectedProfile(route))
// Assume that form data is valid!
const data = toTransactionPayload(formData)
const txn = await api.addTransaction(data, formData.attachmentsToUpload)
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`)
},
},
{
name: 'Save Draft',
disabled: !this.areChangesPresent(formData) || !isFormDataValidForDraftSave(formData),
callback: async (formData, route, router) => {
const api = new TransactionApiClient(getSelectedProfile(route))
const data = toDraftPayload(formData)
const draft = await api.addDraft(data, formData.attachmentsToUpload)
await router.replace(
`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`,
)
},
},
{
name: 'Cancel',
disabled: false,
callback: async (_formData, route, router) => {
await goBackOrHome(router, route)
},
},
]
}
}
/**
* Editor context for when the user is editing a transaction.
*/
export class TransactionEditorContext implements TransactionEditorContextBase {
private existingTransaction: TransactionDetail
constructor(existingTransaction: TransactionDetail) {
this.existingTransaction = existingTransaction
}
isFormDataValid(formData: TransactionEditorFormFields): boolean {
return isFormDataValidForTransactionSubmission(formData)
}
areChangesPresent(formData: TransactionEditorFormFields): boolean {
const tx: TransactionDetail = this.existingTransaction
const tagsChanged =
formData.tags.every((t) => tx.tags.includes(t)) &&
tx.tags.every((t) => formData.tags.includes(t))
const lineItemsChanged =
JSON.stringify(formData.lineItems) !== JSON.stringify(formData.lineItems)
const attachmentsChanged =
formData.attachmentsToUpload.length > 0 || formData.removedAttachmentIds.length > 0
const timestampChanged = new Date(formData.timestamp ?? 0).toISOString() !== tx.timestamp
const amountChanged =
floatMoneyToInteger(formData.amount ?? 0, formData.currency ?? tx.currency) !== tx.amount
const currencyChanged = formData.currency?.code !== tx.currency.code
const descriptionChanged = formData.description !== tx.description
const internalTransferChanged = formData.internalTransfer !== tx.internalTransfer
const vendorChanged = formData.vendor?.id !== tx.vendor?.id
const categoryChanged = formData.categoryId !== (tx.category?.id ?? null)
const creditedAccountChanged = formData.creditedAccountId !== (tx.creditedAccount?.id ?? null)
const debitedAccountChanged = formData.debitedAccountId !== (tx.debitedAccount?.id ?? null)
return (
tagsChanged ||
lineItemsChanged ||
attachmentsChanged ||
timestampChanged ||
amountChanged ||
currencyChanged ||
descriptionChanged ||
internalTransferChanged ||
vendorChanged ||
categoryChanged ||
creditedAccountChanged ||
debitedAccountChanged
)
}
initializeFormFields(): TransactionEditorFormFields {
const tx = this.existingTransaction
return {
timestamp: getLocalDateTimeStringFromUTCTimestamp(tx.timestamp),
amount: integerMoneyToFloat(tx.amount, tx.currency),
templateName: null,
currency: tx.currency,
description: tx.description,
internalTransfer: tx.internalTransfer,
vendor: tx.vendor ?? null,
categoryId: tx.category?.id ?? null,
creditedAccountId: tx.creditedAccount?.id ?? null,
debitedAccountId: tx.debitedAccount?.id ?? null,
lineItems: [...tx.lineItems],
tags: [...tx.tags],
attachmentsToUpload: [],
removedAttachmentIds: [],
existingAttachments: [...tx.attachments],
}
}
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
return [
{
name: 'Save',
disabled: !this.areChangesPresent(formData) || !this.isFormDataValid(formData),
callback: async (formData, route, router) => {
const api = new TransactionApiClient(getSelectedProfile(route))
// Assume that form data is valid!
const data = toTransactionPayload(formData)
const txn = await api.updateTransaction(
this.existingTransaction.id,
data,
formData.attachmentsToUpload,
)
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`)
},
},
{
name: 'Cancel',
disabled: false,
callback: async (_formData, route, router) => {
await goBackOrHome(router, route)
},
},
]
}
}
/**
* Editor context for when the user is editing an existing draft.
*/
export class DraftEditorContext implements TransactionEditorContextBase {
private existingDraft: TransactionDraftResponse
constructor(existingDraft: TransactionDraftResponse) {
this.existingDraft = existingDraft
}
isFormDataValid(formData: TransactionEditorFormFields): boolean {
const result =
(formData.amount === null || formData.currency !== null) &&
(formData.amount === null || formData.amount > 0) &&
(formData.creditedAccountId === null ||
formData.debitedAccountId === null ||
formData.creditedAccountId !== formData.debitedAccountId) &&
(formData.templateName === null || formData.templateName.length <= 32)
console.log('Checking draft editor valid:', formData, result)
return result
}
areChangesPresent(): boolean {
return true
}
initializeFormFields(): TransactionEditorFormFields {
const d = this.existingDraft
const fields = defaultEmptyFormFields()
if (d.templateName !== null && d.templateName.length > 0) {
fields.templateName = d.templateName
}
if (d.timestamp !== null) {
fields.timestamp = getLocalDateTimeStringFromUTCTimestamp(d.timestamp)
}
if (d.amount !== null && d.currency !== null) {
fields.currency = d.currency
fields.amount = integerMoneyToFloat(d.amount, d.currency)
}
fields.description = d.description
fields.internalTransfer = d.internalTransfer
if (d.vendor !== null) {
fields.vendor = {
id: d.vendor.id,
name: d.vendor.name,
description: '',
}
// TODO: Update TransactionDraftResponse format to include full vendor data.
}
if (d.category !== null) {
fields.categoryId = d.category.id
}
if (d.creditedAccount !== null) {
fields.creditedAccountId = d.creditedAccount.id
}
if (d.debitedAccount !== null) {
fields.debitedAccountId = d.debitedAccount.id
}
fields.lineItems = [...d.lineItems]
fields.tags = [...d.tags]
fields.existingAttachments = [...d.attachments]
return fields
}
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
return [
{
name: 'Save Draft',
disabled: !this.areChangesPresent() || !this.isFormDataValid(formData),
callback: async (formData, route, router) => {
const api = new TransactionApiClient(getSelectedProfile(route))
const data = toDraftPayload(formData)
const draft = await api.updateDraft(
this.existingDraft.id,
data,
formData.attachmentsToUpload,
)
await router.replace(
`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`,
)
},
},
{
name: 'Submit Transaction',
disabled: !this.areChangesPresent() || !isFormDataValidForTransactionSubmission(formData),
callback: async (formData, route, router) => {
const api = new TransactionApiClient(getSelectedProfile(route))
// First call the normal "Save" callback from the NewTransactionEditorContext.
const tec = new NewTransactionEditorContext()
const saveTxnAction = tec.getActions(formData).find((a) => a.name === 'Save')!
await saveTxnAction.callback(formData, route, router)
// Then if this is not a template draft, delete it.
if (formData.templateName === null || formData.templateName.length === 0) {
await api.deleteDraft(this.existingDraft.id)
}
},
},
{
name: 'Cancel',
disabled: false,
callback: async (_formData, route, router) => {
await goBackOrHome(router, route)
},
},
]
}
}
// Helper functions below here!
/**
* Obtains an editor context by determining what the user is doing based on the
* route they've navigated to.
* @param route The current route (which tells us what intent the user has).
* @returns A promise that resolves to an editor context.
*/
export async function loadEditorContextFromRoute(
route: RouteLocation,
): Promise<TransactionEditorContextBase> {
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
if (route.name === 'edit-transaction') {
const transactionIdStr = route.params.id
if (transactionIdStr && typeof transactionIdStr === 'string') {
const transactionId = parseInt(transactionIdStr)
const existingTransaction = await transactionApi.getTransaction(transactionId)
return new TransactionEditorContext(existingTransaction)
}
} else if (route.name === 'edit-draft') {
const draftIdStr = route.params.id
if (draftIdStr && typeof draftIdStr === 'string') {
const draftId = parseInt(draftIdStr)
const existingDraft = await transactionApi.getDraft(draftId)
return new DraftEditorContext(existingDraft)
}
}
return new NewTransactionEditorContext()
}
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)
}
function toDraftPayload(formData: TransactionEditorFormFields): TransactionDraftPayload {
if (typeof formData.amount === 'string') {
formData.amount = null
}
if (formData.amount !== null && formData.currency !== null) {
formData.amount = floatMoneyToInteger(formData.amount, formData.currency)
} else {
formData.amount = null
formData.currency = null
}
let isoTimestamp = null
if (formData.timestamp !== null && formData.timestamp.length > 0) {
isoTimestamp = new Date(formData.timestamp).toISOString()
}
return {
templateName: formData.templateName,
timestamp: isoTimestamp,
amount: formData.amount,
currencyCode: formData.currency?.code ?? null,
description: formData.description,
internalTransfer: formData.internalTransfer,
vendorId: formData.vendor?.id ?? null,
categoryId: formData.categoryId,
creditedAccountId: formData.creditedAccountId,
debitedAccountId: formData.debitedAccountId,
tags: [...formData.tags],
lineItems: [...formData.lineItems].map((li) => {
return {
valuePerItem: li.valuePerItem,
quantity: li.quantity,
description: li.description,
categoryId: li.category?.id ?? null,
}
}),
attachmentIdsToRemove: [...formData.removedAttachmentIds],
}
}
function toTransactionPayload(formData: TransactionEditorFormFields): AddTransactionPayload {
const payload: AddTransactionPayload = {
timestamp: new Date(formData.timestamp!).toISOString(),
amount: floatMoneyToInteger(formData.amount!, formData.currency!),
currencyCode: formData.currency!.code,
description: formData.description ?? '',
internalTransfer: formData.internalTransfer ?? false,
vendorId: formData.vendor?.id ?? null,
categoryId: formData.categoryId,
creditedAccountId: formData.creditedAccountId,
debitedAccountId: formData.debitedAccountId,
tags: formData.tags,
lineItems: formData.lineItems.map((li) => {
return { ...li, categoryId: li.category?.id ?? null }
}),
attachmentIdsToRemove: formData.removedAttachmentIds,
}
return payload
}
async function goBackOrHome(router: Router, route: RouteLocation) {
if (window.history.length > 0) {
await router.back()
} else {
await router.replace(`/profiles/${getSelectedProfile(route)}`)
}
}
function isFormDataValidForTransactionSubmission(formData: TransactionEditorFormFields): boolean {
return (
formData.timestamp !== null &&
formData.timestamp.length > 0 &&
formData.amount !== null &&
formData.amount > 0 &&
formData.currency !== null &&
formData.description !== null &&
formData.description.length > 0 &&
(formData.creditedAccountId !== null || formData.debitedAccountId !== null) &&
formData.creditedAccountId !== formData.debitedAccountId
)
}
function isFormDataValidForDraftSave(formData: TransactionEditorFormFields): boolean {
return (
formData.amount !== null &&
formData.amount > 0 &&
formData.timestamp !== null &&
formData.currency !== null
)
}

View File

@ -44,7 +44,7 @@ const router = createRouter({
}, },
{ {
path: 'accounts/:id/edit', path: 'accounts/:id/edit',
component: () => import('@/pages/forms/EditAccountPage.vue'), component: () => import('@/pages/EditAccountPage.vue'),
meta: { title: 'Edit Account' }, meta: { title: 'Edit Account' },
}, },
{ {
@ -54,7 +54,7 @@ const router = createRouter({
}, },
{ {
path: 'add-account', path: 'add-account',
component: () => import('@/pages/forms/EditAccountPage.vue'), component: () => import('@/pages/EditAccountPage.vue'),
meta: { title: 'Add Account' }, meta: { title: 'Add Account' },
}, },
{ {
@ -63,20 +63,32 @@ const router = createRouter({
meta: { title: 'Transaction' }, meta: { title: 'Transaction' },
}, },
{ {
path: 'transaction-drafts/:id',
component: () => import('@/pages/TransactionDraftPage.vue'),
meta: { title: 'Draft' },
},
{
name: 'edit-transaction',
path: 'transactions/:id/edit', path: 'transactions/:id/edit',
component: () => import('@/pages/forms/EditTransactionPage.vue'), component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
meta: { title: 'Edit Transaction' }, meta: { title: 'Edit Transaction' },
}, },
{
path: 'add-transaction',
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
meta: { title: 'Add Transaction' },
},
{
name: 'edit-draft',
path: 'transaction-drafts/:id/edit',
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
meta: { title: 'Edit Draft' },
},
{ {
path: 'transactions/search', path: 'transactions/search',
component: () => import('@/pages/TransactionSearchPage.vue'), component: () => import('@/pages/TransactionSearchPage.vue'),
meta: { title: 'Search Transactions' }, meta: { title: 'Search Transactions' },
}, },
{
path: 'add-transaction',
component: () => import('@/pages/forms/EditTransactionPage.vue'),
meta: { title: 'Add Transaction' },
},
{ {
path: 'vendors', path: 'vendors',
component: () => import('@/pages/VendorsPage.vue'), component: () => import('@/pages/VendorsPage.vue'),

File diff suppressed because it is too large Load Diff