Compare commits

...

22 Commits

Author SHA1 Message Date
Andrew Lalis b0ae73e445 Merge branch 'drafts'
Build and Deploy Web App / build-and-deploy (push) Successful in 23s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m57s Details
2026-07-01 15:29:29 -04:00
Andrew Lalis 8bf19887ad Added ability to copy transaction to new draft.
Build Web App / build-and-deploy (push) Successful in 18s Details
2026-07-01 15:20:20 -04:00
Andrew Lalis 710d77438f Added wrapper for transaction editor action clicks to handle loading state.
Build Web App / build-and-deploy (push) Successful in 17s Details
2026-07-01 14:56:10 -04:00
Andrew Lalis 830d0b2db5 Added improvements to recurring transactions modal, and implemented scheduled job to process recurring transactions.
Build Web App / build-and-deploy (push) Successful in 28s Details
Build and Test API / build-and-deploy (push) Successful in 2m6s Details
2026-07-01 14:51:19 -04:00
Andrew Lalis c0542900be Added initial no-op scheduled task for creating drafts from recurring transactions.
Build Web App / build-and-deploy (push) Successful in 19s Details
Build and Test API / build-and-deploy (push) Successful in 1m51s Details
2026-06-30 15:32:44 -04:00
Andrew Lalis 6280ab7cfb Added recurring transaction API implementation and UI elements. 2026-06-30 15:17:09 -04:00
Andrew Lalis 115a79a5c0 Added validation framework basics.
Build Web App / build-and-deploy (push) Successful in 19s Details
Build and Test API / build-and-deploy (push) Successful in 1m49s Details
2026-06-30 13:09:04 -04:00
Andrew Lalis 7f161af9e7 Added common validation in backend and frontend, finished up draft & template editing. 2026-06-30 13:08:46 -04:00
Andrew Lalis 23cfe0b1a9 Added draft card and page components, added specialization for template drafts.
Build Web App / build-and-deploy (push) Successful in 18s Details
Build and Test API / build-and-deploy (push) Successful in 1m48s Details
2026-06-28 21:33:08 -04:00
Andrew Lalis ed9b53ee79 Added actions and most of the rest of the editor context implementations. 2026-06-28 20:26:39 -04:00
Andrew Lalis c7994b8282 Removed old unused tag form properties from editor page. 2026-06-28 13:47:52 -04:00
Andrew Lalis 9ce0ffd3a4 Refactored editor page locations, started on generic editor logic. 2026-06-28 13:43:29 -04:00
Andrew Lalis d0e8b9ab4b Added drafts module, fixed issues with draft query.
Build Web App / build-and-deploy (push) Successful in 17s Details
Build and Test API / build-and-deploy (push) Successful in 1m42s Details
2026-06-27 22:21:06 -04:00
Andrew Lalis 13aadc2358 Updated API client code to match new API endpoints.
Build Web App / build-and-deploy (push) Successful in 17s Details
Build and Test API / build-and-deploy (push) Successful in 1m43s Details
2026-06-27 21:59:05 -04:00
Andrew Lalis 69c3079174 Updated API with hopefully the rest of the draft implementation. 2026-06-27 21:57:52 -04:00
Andrew Lalis 3b35f8f16d Removed unused import 2026-06-27 19:54:23 -04:00
Andrew Lalis df4737d0a5 Fixed issue #47 - history timestamp not updating.
Build and Test API / build-and-deploy (push) Successful in 1m42s Details
2026-06-27 19:51:29 -04:00
Andrew Lalis 7e7458c2f5 Update trigger path to dev workflow files.
Build Web App / build-and-deploy (push) Successful in 16s Details
Build and Test API / build-and-deploy (push) Successful in 1m36s Details
2026-06-27 18:37:31 -04:00
Andrew Lalis 55878a13f6 Fix names of workflows.
Build Web App / build-and-deploy (push) Successful in 16s Details
2026-06-27 18:36:43 -04:00
Andrew Lalis 4a3e840fce Updated titles of dev workflows.
Build and Deploy Web App / build-and-deploy (push) Successful in 16s Details
2026-06-27 18:35:59 -04:00
Andrew Lalis 5bda3ee4af Restrict deployment to main branch CI
Build and Deploy Web App / build-and-deploy (push) Successful in 19s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m46s Details
2026-06-27 18:34:59 -04:00
Andrew Lalis d165ac0753 Refactored API to use optional globally, prepped draft stuff.
Build and Deploy API / build-and-deploy (push) Has been cancelled Details
2026-06-15 18:44:23 -04:00
55 changed files with 3630 additions and 1192 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,25 @@ 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.service;
import std.datetime.stopwatch;
StopWatch sw = StopWatch(AutoStart.yes);
FileSystemProfileRepository.doForAllUserProfiles((Profile profile, ProfileRepository profileRepo) {
ProfileDataSource ds = profileRepo.getDataSource(profile);
updateRecurringTransactionScheduling(ds);
});
sw.stop();
long elapsedTimeMs = sw.peek.total!"msecs";
if (elapsedTimeMs > 1000) {
warnF!"Took more than 1 second to process recurring transactions: %d ms."(elapsedTimeMs);
}
}, 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,30 @@ 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);
void deleteAllByDraftId(ulong draftId);
void addToQueue(ulong id, string timestamp);
Optional!string findInQueue(ulong id);
void removeFromQueue(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.peek!(Nullable!ulong)(11), row.parseOptional!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.
void appendItem() {
import std.algorithm : sort;
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();
}
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.timestamp = row.peek!string(1);
item.addedAt = row.peek!string(2); item.addedAt = row.peek!string(2);
item.amount = row.peek!ulong(3); item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5); item.description = row.peek!string(5);
item.internalTransfer = row.peek!bool(6); item.internalTransfer = row.peek!bool(6);
// Read the nullable Vendor information. Optional!ulong vendorId = row.parseOptional!ulong(7);
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7); if (vendorId) {
if (!vendorId.isNull) { item.vendor = SimpleVendorResponse(
string vendorName = row.peek!string(8); vendorId.value,
item.vendor = Optional!(TransactionsListItem.Vendor).of( row.peek!string(8)
TransactionsListItem.Vendor(vendorId.get, vendorName)); ).toOptional;
} }
// Read the nullable Category information. Optional!ulong categoryId = row.parseOptional!ulong(9);
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9); if (categoryId) {
if (!categoryId.isNull) { item.category = SimpleCategoryResponse(
string categoryName = row.peek!string(10); categoryId.value,
string categoryColor = row.peek!string(11); row.peek!string(10),
item.category = Optional!(TransactionsListItem.Category).of( row.peek!string(11)
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); ).toOptional;
} }
// Read the nullable creditedAccount. Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12); if (creditedAccountId) {
if (!creditedAccountId.isNull) { item.creditedAccount = SimpleAccountResponse(
ulong id = creditedAccountId.get; creditedAccountId.value,
string name = row.peek!string(13); row.peek!string(13),
string type = row.peek!string(14); row.peek!string(14),
string suffix = row.peek!string(15); row.peek!string(15)
item.creditedAccount = Optional!(TransactionsListItem.Account).of( ).toOptional;
TransactionsListItem.Account(id, name, type, suffix));
} }
// Read the nullable debitedAccount. Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16); if (debitedAccountId) {
if (!debitedAccountId.isNull) { item.debitedAccount = SimpleAccountResponse(
ulong id = debitedAccountId.get; debitedAccountId.value,
string name = row.peek!string(17); row.peek!string(17),
string type = row.peek!string(18); row.peek!string(18),
string suffix = row.peek!string(19); row.peek!string(19)
item.debitedAccount = Optional!(TransactionsListItem.Account).of( ).toOptional;
TransactionsListItem.Account(id, name, type, suffix));
} }
string aggregateTags = row.peek!string(20);
if (aggregateTags !is null) {
import std.string : split;
import std.algorithm : sort;
item.tags = aggregateTags.split(",");
sort(item.tags);
} }
return item;
// 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,387 @@ 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);
// If template name is empty / NULL, clear any recurring transactions that may exist.
if (data.templateName.isNull || data.templateName.value.length == 0) {
new SqliteRecurringTransactionRepository(this.db)
.deleteAllByDraftId(draftId);
}
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);
}
void deleteAllByDraftId(ulong draftId) {
util.sqlite.update(
db,
"DELETE FROM recurring_transaction WHERE draft_id = ?",
draftId
);
}
void addToQueue(ulong id, string timestamp) {
util.sqlite.update(
db,
"INSERT INTO recurring_transaction_queue (recurring_transaction_id, scheduled_timestamp) VALUES (?, ?)",
id,
timestamp
);
}
Optional!string findInQueue(ulong id) {
return util.sqlite.findOne(
db,
"SELECT scheduled_timestamp FROM recurring_transaction_queue WHERE recurring_transaction_id = ?",
row => row.peek!string(0),
id
);
}
void removeFromQueue(ulong id) {
util.sqlite.update(
db,
"DELETE FROM recurring_transaction_queue WHERE recurring_transaction_id = ?",
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,225 @@ 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);
}
/**
* A routine used by a scheduled job that runs periodically for all users, and
* all those users' profiles. It iterates over every one of the user's recurring
* transactions and does the following:
* - Adds it to the persistent queue with the next timestamp if it's not
* already there.
* - Creates a new draft transaction from the recurring transaction's linked
* template, if its queued timestamp has elapsed. Then requeues that
* recurring transaction for its next timestamp.
* Params:
* ds = The profile data source to operate on.
*/
void updateRecurringTransactionScheduling(ProfileDataSource ds) {
import cronexp;
RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
const DateTime NOW = cast(DateTime) Clock.currTime(UTC());
ds.doTransaction(() {
foreach (RecurringTransactionResponse rt; result.items) {
auto cron = CronExpr(rt.scheduleExpr);
auto nextScheduledOccurrence = cron.getNext(NOW);
if (nextScheduledOccurrence.isNull) {
rtRepo.deleteById(rt.id);
} else {
// First, check if it's already in queue to execute (and execute it if it's time).
Optional!string queuedTimestampStr = rtRepo.findInQueue(rt.id);
bool shouldRequeue = false;
if (queuedTimestampStr) {
DateTime queuedTimestamp = DateTime.fromISOExtString(queuedTimestampStr.value);
if (queuedTimestamp < NOW) {
createDraftForRecurringTransaction(ds, rt, queuedTimestamp);
rtRepo.removeFromQueue(rt.id);
shouldRequeue = true;
} else if (queuedTimestamp != nextScheduledOccurrence) {
// The queued timestamp doesn't match the next scheduled occurrence.
// This shouldn't really happen at all, but if it does, we remove it
// so the proper scheduled timestamp can be added.
warnF!("Found a recurring transaction whose queued timestamp " ~
"doesn't match the next scheduled occurrence as per its " ~
"Cron schedule expression. Removing that queued transaction " ~
"and requeueing it using the Cron expression's next date. " ~
"Recurring Transaction ID: %d; Cron Expression: %s")(rt.id, rt.scheduleExpr);
rtRepo.removeFromQueue(rt.id);
shouldRequeue = true;
}
} else {
shouldRequeue = true;
}
if (shouldRequeue) {
const nextTimestamp = nextScheduledOccurrence.get().toISOExtString();
infoF!"Requeueing recurring transaction %d to occur next at %s."(rt.id, nextTimestamp);
rtRepo.addToQueue(rt.id, nextTimestamp);
}
}
}
});
}
private void createDraftForRecurringTransaction(
ProfileDataSource ds,
in RecurringTransactionResponse rt,
DateTime queuedTimestamp
) {
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
Optional!TransactionDraftResponse draftOpt = draftRepo.findById(rt.draftId);
if (!draftOpt) {
warnF!"Couldn't create draft for recurring transaction %d because template draft %d doesn't exist."(
rt.id, rt.draftId
);
return;
}
TransactionDraftResponse templateDraft = draftOpt.value;
TransactionDraftPayload payload;
payload.timestamp = queuedTimestamp.toISOExtString().toOptional();
payload.amount = templateDraft.amount;
payload.currencyCode = templateDraft.currency.mapIfPresent!(c => c.code.idup);
string description = templateDraft.description.orElse("")
~ "\n- Created automatically as a recurring transaction from template: \"" ~
templateDraft.templateName.orElse("Unknown") ~ "\".";
payload.description = description.toOptional;
payload.internalTransfer = templateDraft.internalTransfer;
payload.vendorId = templateDraft.vendor.mapIfPresent!(v => v.id);
payload.categoryId = templateDraft.category.mapIfPresent!(c => c.id);
payload.creditedAccountId = templateDraft.creditedAccount.mapIfPresent!(a => a.id);
payload.debitedAccountId = templateDraft.debitedAccount.mapIfPresent!(a => a.id);
payload.tags = templateDraft.tags;
import std.algorithm : map;
import std.array : array;
payload.lineItems = templateDraft.lineItems.map!(li => TransactionDraftPayload.LineItemPayload(
li.valuePerItem,
li.quantity,
li.description,
li.category.mapIfPresent!(c => c.id)
)).array;
auto createdDraft = draftRepo.insert(payload);
draftRepo.updateTags(createdDraft.id, payload.tags);
infoF!"Inserted draft %d for scheduled recurring transaction %d."(createdDraft.id, rt.id);
}

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,84 @@
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
);
-- A "queue" table where upcoming recurring transactions are posted until it's
-- time to add them for real. This ensures proper handling of recurring transactions.
CREATE TABLE recurring_transaction_queue (
recurring_transaction_id INTEGER PRIMARY KEY,
scheduled_timestamp TEXT NOT NULL,
CONSTRAINT fk_recurring_transaction_queue_id
FOREIGN KEY (recurring_transaction_id) REFERENCES recurring_transaction(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,92 @@ 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
);
-- A "queue" table where upcoming recurring transactions are posted until it's
-- time to add them for real. This ensures proper handling of recurring transactions.
CREATE TABLE recurring_transaction_queue (
recurring_transaction_id INTEGER PRIMARY KEY,
scheduled_timestamp TEXT NOT NULL,
CONSTRAINT fk_recurring_transaction_queue_id
FOREIGN KEY (recurring_transaction_id) REFERENCES recurring_transaction(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,8 @@
"@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",
"cronstrue": "^3.24.0",
"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,237 @@
<script setup lang="ts">
import {
TransactionApiClient,
type RecurringTransactionPayload,
type RecurringTransactionResponse,
} from '@/api/transaction'
import ModalWrapper from './common/ModalWrapper.vue'
import { computed, ref, useTemplateRef, watch, 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'
import { CronExpressionParser } from 'cron-parser'
const route = useRoute()
const props = defineProps<{ draftId: number }>()
const modal = useTemplateRef('modal')
const savedTxn: Ref<RecurringTransactionResponse | undefined> = ref()
// Form Data:
enum ScheduleType {
Monthly,
Custom,
}
const chosenScheduleType: Ref<ScheduleType> = ref(ScheduleType.Monthly)
const inputIsValid = ref(true)
const dayOfMonth: Ref<number> = ref(1)
const customScheduleText = ref('')
const customCronErrorMsg = ref<string | null>(null)
// Reset form data when the user chooses a different schedule type.
watch(chosenScheduleType, () => {
dayOfMonth.value = 1
customScheduleText.value = ''
customCronErrorMsg.value = null
})
watch([chosenScheduleType, dayOfMonth, customScheduleText], () => {
inputIsValid.value = checkInputValid()
})
const upcomingDates = computed(() => {
if (!inputIsValid.value) return []
const cronExpr = getScheduleExpression()
const cron = CronExpressionParser.parse(cronExpr)
return cron.take(3).map((d) => d.toString())
})
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: getScheduleExpression(),
}
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
}
}
function getScheduleExpression(): string {
if (chosenScheduleType.value === ScheduleType.Custom) {
try {
CronExpressionParser.parse(customScheduleText.value)
return customScheduleText.value.trim()
} catch (err) {
console.warn('Failed to parse schedule Cron expression.', err)
throw err
}
} else if (chosenScheduleType.value === ScheduleType.Monthly) {
return `0 0 12 ${dayOfMonth.value} * *`
}
throw new Error('Unsupported schedule type.')
}
function checkInputValid(): boolean {
if (chosenScheduleType.value === ScheduleType.Custom) {
customCronErrorMsg.value = null
if (customScheduleText.value.trim().length === 0) {
customCronErrorMsg.value = 'Missing cron expression.'
return false
}
try {
const expr = CronExpressionParser.parse(customScheduleText.value)
if (expr.hasNext()) {
return true
} else {
customCronErrorMsg.value = 'This cron expression has no discernable next date.'
return false
}
} catch (err) {
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
customCronErrorMsg.value = err.message
}
return false
}
} else if (chosenScheduleType.value === ScheduleType.Monthly) {
return dayOfMonth.value >= 1 && dayOfMonth.value <= 28
}
return false
}
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, the new draft's timestamp will be set
to the current date and time.
</p>
<p class="font-italic font-size-small text-muted">
Note that Finnow processes recurring transactions at a regular interval, but to avoid
issues, please don't create recurring transactions whose schedule is less than hourly, as
these may be too frequent for the system to process reliably without skipping.
</p>
<AppForm>
<FormGroup>
<FormControl label="Type of Schedule">
<select v-model="chosenScheduleType">
<option :value="ScheduleType.Monthly">Monthly</option>
<option :value="ScheduleType.Custom">Custom</option>
</select>
</FormControl>
</FormGroup>
<div v-if="chosenScheduleType === ScheduleType.Monthly">
<h3>Monthly Schedule</h3>
<p>
Schedule a recurring transaction to occur on the same calendar day every month. To
ensure that this schedule works all year, you must pick a day between the 1st and 28th.
</p>
<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="28"
v-model="dayOfMonth"
style="max-width: 100px"
/>
</FormControl>
</FormGroup>
</div>
<div v-if="chosenScheduleType === ScheduleType.Custom">
<h3>Custom Schedule</h3>
<p>
Use a custom
<a
href="https://en.wikipedia.org/wiki/Cron"
target="_blank"
>Cron</a
>
expression to define when this recurring transaction should happen.
</p>
<FormGroup>
<FormControl label="Schedule Expression">
<input
type="text"
maxlength="64"
v-model="customScheduleText"
style="max-width: 300px"
/>
</FormControl>
</FormGroup>
<p
v-if="customCronErrorMsg"
class="text-negative font-size-xsmall m0"
>
Failed to parse cron expression: {{ customCronErrorMsg }}
</p>
<p
v-if="customCronErrorMsg === null"
class="text-positive font-size-xsmall m0"
>
Cron expression is valid.
</p>
</div>
<!-- A little area at the bottom to show hypothetical upcoming dates with the chosen schedule: -->
<div v-if="inputIsValid">
<p class="font-size-small mb-0 font-italic">
Using the schedule you've chosen above, here are some upcoming dates at which this
recurring transaction will create a new draft.
</p>
<ul class="font-size-small mt-1">
<li
v-for="d in upcomingDates"
:key="d"
>
{{ d }}
</li>
</ul>
</div>
</AppForm>
</template>
<template v-slot:buttons>
<AppButton
:disabled="!inputIsValid"
@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,250 @@
<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 cronstrue from 'cronstrue'
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) {
return cronstrue.toString(rt.scheduleExpr)
}
</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 style="white-space: pre-wrap">{{ 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 v-if="draft.templateName !== null && draft.templateName.length > 0">
<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

@ -93,6 +93,34 @@ async function onVendorClicked() {
) )
} }
} }
async function copyToDraft() {
if (!transaction.value) return
const tx = transaction.value
const newDraft = await transactionApi.addDraft({
templateName: null,
timestamp: tx.timestamp,
amount: tx.amount,
currencyCode: tx.currency.code,
description: tx.description,
internalTransfer: tx.internalTransfer,
vendorId: tx.vendor?.id ?? null,
categoryId: tx.category?.id ?? null,
creditedAccountId: tx.creditedAccount?.id ?? null,
debitedAccountId: tx.debitedAccount?.id ?? null,
tags: tx.tags,
lineItems: tx.lineItems.map((li) => {
return {
valuePerItem: li.valuePerItem,
quantity: li.quantity,
description: li.description,
categoryId: li.category?.id ?? null,
}
}),
attachmentIdsToRemove: [],
})
router.push(`/profiles/${getSelectedProfile(route)}/transaction-drafts/${newDraft.id}`)
}
</script> </script>
<template> <template>
<AppPage <AppPage
@ -140,7 +168,7 @@ async function onVendorClicked() {
/> />
</div> </div>
<p>{{ transaction.description }}</p> <p style="white-space: pre-wrap">{{ transaction.description }}</p>
<div <div
v-if="transaction.creditedAccount" v-if="transaction.creditedAccount"
@ -229,7 +257,7 @@ async function onVendorClicked() {
<AppButton <AppButton
icon="wrench" icon="wrench"
@click=" @click="
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`) router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction?.id}/edit`)
" "
> >
Edit Edit
@ -240,5 +268,13 @@ async function onVendorClicked() {
>Delete</AppButton >Delete</AppButton
> >
</ButtonBar> </ButtonBar>
<ButtonBar>
<AppButton
icon="copy"
@click="copyToDraft()"
size="sm"
>Copy to New Draft</AppButton
>
</ButtonBar>
</AppPage> </AppPage>
</template> </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,264 @@
<!--
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 TransactionEditorAction,
type TransactionEditorContextBase,
type TransactionEditorFormFields,
} from './util'
import ButtonBar from '@/components/common/ButtonBar.vue'
import AppButton from '@/components/common/AppButton.vue'
import { showAlert } from '@/util/alert'
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)
})
function onActionClicked(action: TransactionEditorAction) {
loading.value = true
action
.callback(formData.value, route, router)
.catch((err) => {
showAlert(err)
})
.finally(() => {
loading.value = false
})
}
</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="onActionClicked(action)"
>
{{ action.name }}
</AppButton>
</ButtonBar>
</AppForm>
</AppPage>
</template>

View File

@ -0,0 +1,506 @@
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!
createNewVendorIfNeeded(formData, api)
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))
createNewVendorIfNeeded(formData, api)
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!
createNewVendorIfNeeded(formData, api)
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)
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))
createNewVendorIfNeeded(formData, api)
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 createNewVendorIfNeeded(
formData: TransactionEditorFormFields,
api: TransactionApiClient,
) {
if (formData.vendor && formData.vendor.id === -1) {
const vendor = await api.createVendor({ name: formData.vendor.name, description: null })
formData.vendor = vendor
}
}
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