WIP: Add Drafts, Templates, and Recurring Transactions #45
|
|
@ -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
|
||||
|
|
@ -4,6 +4,8 @@ on:
|
|||
paths:
|
||||
- 'finnow-api/**'
|
||||
- '.gitea/workflows/api.yaml'
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -4,6 +4,8 @@ on:
|
|||
paths:
|
||||
- 'web-app/**'
|
||||
- '.gitea/workflows/web-app.yaml'
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
],
|
||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||
"dependencies": {
|
||||
"cronexp": ">=0.1.0-beta3 <0.2.0-0",
|
||||
"d2sqlite3": "~>1.0",
|
||||
"handy-http-starter": "~>1.6",
|
||||
"jwt4d": "~>0.0.2",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
"fileVersion": 1,
|
||||
"versions": {
|
||||
"asdf": "0.8.0",
|
||||
"cronexp": "0.1.0-beta3",
|
||||
"d2sqlite3": "1.0.0",
|
||||
"dxml": "0.4.5",
|
||||
"handy-http-data": "1.3.0",
|
||||
"handy-http-data": "1.3.2",
|
||||
"handy-http-handlers": "1.3.0",
|
||||
"handy-http-primitives": "1.8.1",
|
||||
"handy-http-starter": "1.7.0",
|
||||
"handy-http-primitives": "1.10.0",
|
||||
"handy-http-starter": "1.7.1",
|
||||
"handy-http-transport": "1.10.1",
|
||||
"handy-http-websockets": "1.2.0",
|
||||
"jwt4d": "0.0.2",
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
"mir-core": "1.7.4",
|
||||
"openssl": "3.3.4",
|
||||
"path-matcher": "1.2.0",
|
||||
"photon": "0.18.11",
|
||||
"photon": "0.18.12",
|
||||
"scheduled": "1.4.0",
|
||||
"secured": "3.0.0",
|
||||
"sharded-map": "2.7.0",
|
||||
|
|
|
|||
|
|
@ -202,12 +202,12 @@ SQL",
|
|||
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
|
||||
return util.sqlite.findOne(
|
||||
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 " ~
|
||||
"WHERE h.item_id = ?",
|
||||
(row) {
|
||||
auto obj = new AccountHistoryValueRecordItemResponse();
|
||||
obj.timestamp = item.timestamp;
|
||||
obj.timestamp = row.peek!string(4);
|
||||
obj.type = item.type;
|
||||
obj.valueRecordId = row.peek!ulong(0);
|
||||
obj.valueRecordType = row.peek!string(1);
|
||||
|
|
@ -222,13 +222,14 @@ SQL",
|
|||
private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
|
||||
return util.sqlite.findOne(
|
||||
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 \"transaction\" tx ON tx.id = je.transaction_id " ~
|
||||
"WHERE h.item_id = ?",
|
||||
(row) {
|
||||
auto obj = new AccountHistoryJournalEntryItemResponse();
|
||||
obj.timestamp = item.timestamp;
|
||||
obj.timestamp = row.peek!string(5);
|
||||
obj.type = item.type;
|
||||
obj.journalEntryType = row.peek!string(0);
|
||||
obj.amount = row.peek!ulong(1);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,9 @@ import account.model;
|
|||
import attachment.data;
|
||||
import attachment.dto;
|
||||
import util.money;
|
||||
import util.data : serializeOptional;
|
||||
|
||||
/// The data the API provides for an Account entity.
|
||||
struct AccountResponse {
|
||||
import asdf : serdeTransformOut;
|
||||
|
||||
ulong id;
|
||||
string createdAt;
|
||||
bool archived;
|
||||
|
|
@ -19,7 +16,6 @@ struct AccountResponse {
|
|||
string name;
|
||||
Currency currency;
|
||||
string description;
|
||||
@serdeTransformOut!serializeOptional
|
||||
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.
|
||||
struct AccountCreationPayload {
|
||||
string type;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ module analytics.data;
|
|||
|
||||
import std.datetime;
|
||||
import handy_http_primitives : Optional;
|
||||
import asdf : serdeTransformOut;
|
||||
|
||||
import util.money;
|
||||
import util.data;
|
||||
|
|
@ -36,7 +35,6 @@ struct CategorySpendData {
|
|||
ulong categoryId;
|
||||
string categoryName;
|
||||
string categoryColor;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentCategoryId;
|
||||
long amount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import std.algorithm;
|
|||
import std.array;
|
||||
import std.conv;
|
||||
import slf4d;
|
||||
import asdf;
|
||||
|
||||
import profile.data;
|
||||
import profile.model;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
publicHandler.registerHandlers!(auth.api_public);
|
||||
|
||||
// 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:
|
||||
import auth.api;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface AttachmentRepository {
|
|||
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
|
||||
Attachment[] findAllByTransactionId(ulong transactionId);
|
||||
Attachment[] findAllByValueRecordId(ulong valueRecordId);
|
||||
Attachment[] findAllByTransactionDraftId(ulong draftId);
|
||||
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
||||
void remove(ulong id);
|
||||
Optional!(ubyte[]) getContent(ulong id);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ interface ProfileDataSource {
|
|||
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||
TransactionTagRepository getTransactionTagRepository();
|
||||
TransactionRepository getTransactionRepository();
|
||||
TransactionDraftRepository getTransactionDraftRepository();
|
||||
RecurringTransactionRepository getRecurringTransactionRepository();
|
||||
|
||||
AnalyticsRepository getAnalyticsRepository();
|
||||
|
||||
|
|
@ -93,6 +95,12 @@ version(unittest) {
|
|||
TransactionRepository getTransactionRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
TransactionDraftRepository getTransactionDraftRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
RecurringTransactionRepository getRecurringTransactionRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
AnalyticsRepository getAnalyticsRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
|||
}
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
|
|
@ -215,6 +215,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
TransactionCategoryRepository transactionCategoryRepo;
|
||||
TransactionTagRepository transactionTagRepo;
|
||||
TransactionRepository transactionRepo;
|
||||
TransactionDraftRepository transactionDraftRepo;
|
||||
RecurringTransactionRepository recurringTransactionRepo;
|
||||
AnalyticsRepository analyticsRepo;
|
||||
|
||||
this(string path) {
|
||||
|
|
@ -297,6 +299,20 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
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() {
|
||||
if (analyticsRepo is null) {
|
||||
analyticsRepo = new SqliteAnalyticsRepository(db);
|
||||
|
|
@ -322,7 +338,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
if (currentVersion == SCHEMA_VERSION) return;
|
||||
|
||||
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 assert(false, "Schema version doesn't match the list of defined migrations.");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ void startScheduledJobs() {
|
|||
);
|
||||
|
||||
JobScheduler jobScheduler = new TaskPoolScheduler();
|
||||
|
||||
jobScheduler.addJob(() {
|
||||
// Clear old analytics data from profiles.
|
||||
import profile.data;
|
||||
|
|
@ -38,5 +39,32 @@ void startScheduledJobs() {
|
|||
);
|
||||
|
||||
}, analyticsSchedule);
|
||||
|
||||
// Add a scheduled job to regularly check for and create recurring transaction drafts.
|
||||
jobScheduler.addJob(() {
|
||||
import profile.data;
|
||||
import profile.data_impl_sqlite;
|
||||
import profile.model;
|
||||
import transaction.dto;
|
||||
import transaction.data;
|
||||
import util.pagination;
|
||||
import std.stdio;
|
||||
import std.datetime;
|
||||
import cronexp;
|
||||
FileSystemProfileRepository.doForAllUserProfiles((Profile profile, ProfileRepository profileRepo) {
|
||||
writefln!"Recurring transaction check: %s / %s"(profile.username, profile.name);
|
||||
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||
RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
|
||||
Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
|
||||
foreach (rt; result.items) {
|
||||
writeln(rt.scheduleExpr);
|
||||
DateTime now = cast(DateTime) Clock.currTime();
|
||||
auto cron = CronExpr(rt.scheduleExpr);
|
||||
writefln!"scheduleExpr = %s -> %s"(rt.scheduleExpr, cron.getNext(now));
|
||||
// TODO: Figure out how to actually create the transactions!
|
||||
}
|
||||
});
|
||||
}, new FixedIntervalSchedule(minutes(1), Clock.currTime(UTC())));
|
||||
|
||||
jobScheduler.start();
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ struct CategoryPayload {
|
|||
string name;
|
||||
string description;
|
||||
string color;
|
||||
Nullable!ulong parentId;
|
||||
Optional!ulong parentId;
|
||||
}
|
||||
|
||||
@PostMapping(PROFILE_PATH ~ "/categories")
|
||||
|
|
@ -215,3 +215,95 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
private ulong getCategoryId(in ServerHttpRequest request) {
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface TransactionCategoryRepository {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: Migrate into transaction repo, similar to drafts!
|
||||
interface TransactionTagRepository {
|
||||
string[] findAllByTransactionId(ulong transactionId);
|
||||
void updateTags(ulong transactionId, in string[] tags);
|
||||
|
|
@ -52,3 +53,25 @@ interface TransactionRepository {
|
|||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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.typecons;
|
||||
import d2sqlite3;
|
||||
|
|
@ -13,6 +14,8 @@ import util.money;
|
|||
import util.pagination;
|
||||
import util.data;
|
||||
import account.model;
|
||||
import account.dto;
|
||||
import attachment.dto;
|
||||
|
||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||
private Database db;
|
||||
|
|
@ -228,7 +231,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
import std.typecons;
|
||||
return TransactionCategory(
|
||||
row.peek!ulong(0),
|
||||
toOptional(row.peek!(Nullable!ulong)(1)),
|
||||
row.parseOptional!ulong(1),
|
||||
row.peek!string(2),
|
||||
row.peek!string(3),
|
||||
row.peek!string(4)
|
||||
|
|
@ -283,12 +286,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
}
|
||||
|
||||
Page!TransactionsListItem findAll(in PageRequest pr) {
|
||||
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql();
|
||||
QueryBuilder qb = getBuilderForTransactionsList();
|
||||
addSelectsForTransactionsList(qb);
|
||||
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
|
||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
|
||||
string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql();
|
||||
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||
}
|
||||
|
|
@ -328,10 +327,10 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
qb.conditions = [];
|
||||
qb.argBinders = [];
|
||||
addSelectsForTransactionsList(qb);
|
||||
qb.groupBy("txn.id");
|
||||
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||
Statement stmt = db.prepare(query);
|
||||
auto results = parseListItems(stmt.execute());
|
||||
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||
return Page!TransactionsListItem.of(results, pr, count);
|
||||
}
|
||||
|
||||
|
|
@ -370,7 +369,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
Optional!TransactionDetail findById(ulong id) {
|
||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||
db,
|
||||
import("sql/get_transaction.sql"),
|
||||
import("sql/query/get_transaction.sql"),
|
||||
(row) {
|
||||
TransactionDetail item;
|
||||
item.id = row.peek!ulong(0);
|
||||
|
|
@ -383,43 +382,41 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
|
||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||
if (!vendorId.isNull) {
|
||||
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
||||
TransactionDetail.Vendor(
|
||||
item.vendor = Optional!TransactionVendor.of(TransactionVendor(
|
||||
vendorId.get,
|
||||
row.peek!string(8),
|
||||
row.peek!string(9)
|
||||
)).toNullable;
|
||||
));
|
||||
}
|
||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
|
||||
if (!categoryId.isNull) {
|
||||
item.category = Optional!(TransactionDetail.Category).of(
|
||||
TransactionDetail.Category(
|
||||
item.category = Optional!TransactionCategory.of(TransactionCategory(
|
||||
categoryId.get,
|
||||
row.peek!(Nullable!ulong)(11),
|
||||
row.parseOptional!ulong(11),
|
||||
row.peek!string(12),
|
||||
row.peek!string(13),
|
||||
row.peek!string(14)
|
||||
)).toNullable;
|
||||
));
|
||||
}
|
||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
|
||||
if (!creditedAccountId.isNull) {
|
||||
item.creditedAccount = Optional!(TransactionDetail.Account).of(
|
||||
TransactionDetail.Account(
|
||||
item.creditedAccount = Optional!SimpleAccountResponse.of(
|
||||
SimpleAccountResponse(
|
||||
creditedAccountId.get,
|
||||
row.peek!string(16),
|
||||
row.peek!string(17),
|
||||
row.peek!string(18)
|
||||
)).toNullable;
|
||||
));
|
||||
}
|
||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
|
||||
if (!debitedAccountId.isNull) {
|
||||
item.debitedAccount = Optional!(TransactionDetail.Account).of(
|
||||
TransactionDetail.Account(
|
||||
item.debitedAccount = Optional!SimpleAccountResponse.of(
|
||||
SimpleAccountResponse(
|
||||
debitedAccountId.get,
|
||||
row.peek!string(20),
|
||||
row.peek!string(21),
|
||||
row.peek!string(22)
|
||||
)).toNullable;
|
||||
));
|
||||
}
|
||||
string tagsStr = row.peek!string(23);
|
||||
if (tagsStr !is null && tagsStr.length > 0) {
|
||||
|
|
@ -435,23 +432,23 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
if (item.isNull) return item;
|
||||
item.value.lineItems = util.sqlite.findAll(
|
||||
db,
|
||||
import("sql/get_line_items.sql"),
|
||||
import("sql/query/get_line_items.sql"),
|
||||
(row) {
|
||||
TransactionDetail.LineItem li;
|
||||
TransactionLineItemResponse li;
|
||||
li.idx = row.peek!uint(0);
|
||||
li.valuePerItem = row.peek!long(1);
|
||||
li.quantity = row.peek!ulong(2);
|
||||
li.description = row.peek!string(3);
|
||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4);
|
||||
if (!categoryId.isNull) {
|
||||
li.category = Optional!(TransactionDetail.Category).of(
|
||||
TransactionDetail.Category(
|
||||
categoryId.get,
|
||||
row.peek!(Nullable!ulong)(5),
|
||||
Optional!ulong categoryId = row.parseOptional!ulong(4);
|
||||
if (categoryId) {
|
||||
li.category = Optional!TransactionCategory.of(
|
||||
TransactionCategory(
|
||||
categoryId.value,
|
||||
row.parseOptional!ulong(5),
|
||||
row.peek!string(6),
|
||||
row.peek!string(7),
|
||||
row.peek!string(8)
|
||||
)).toNullable;
|
||||
));
|
||||
}
|
||||
return li;
|
||||
},
|
||||
|
|
@ -470,8 +467,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
data.currencyCode,
|
||||
data.description,
|
||||
data.internalTransfer,
|
||||
data.vendorId,
|
||||
data.categoryId
|
||||
data.vendorId.toNullable(),
|
||||
data.categoryId.toNullable()
|
||||
);
|
||||
ulong transactionId = db.lastInsertRowid();
|
||||
insertLineItems(transactionId, data);
|
||||
|
|
@ -496,8 +493,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
data.currencyCode,
|
||||
data.description,
|
||||
data.internalTransfer,
|
||||
data.vendorId,
|
||||
data.categoryId,
|
||||
data.vendorId.toNullable(),
|
||||
data.categoryId.toNullable(),
|
||||
transactionId
|
||||
);
|
||||
// Re-write all line items:
|
||||
|
|
@ -521,97 +518,56 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]);
|
||||
private static TransactionsListItem parseListItem(Row row) {
|
||||
TransactionsListItem item;
|
||||
|
||||
/// 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.id = row.peek!ulong(0);
|
||||
item.timestamp = row.peek!string(1);
|
||||
item.addedAt = row.peek!string(2);
|
||||
item.amount = row.peek!ulong(3);
|
||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||
item.description = row.peek!string(5);
|
||||
item.internalTransfer = row.peek!bool(6);
|
||||
// Read the nullable Vendor information.
|
||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||
if (!vendorId.isNull) {
|
||||
string vendorName = row.peek!string(8);
|
||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
||||
Optional!ulong vendorId = row.parseOptional!ulong(7);
|
||||
if (vendorId) {
|
||||
item.vendor = SimpleVendorResponse(
|
||||
vendorId.value,
|
||||
row.peek!string(8)
|
||||
).toOptional;
|
||||
}
|
||||
// Read the nullable Category information.
|
||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
|
||||
if (!categoryId.isNull) {
|
||||
string categoryName = row.peek!string(10);
|
||||
string categoryColor = row.peek!string(11);
|
||||
item.category = Optional!(TransactionsListItem.Category).of(
|
||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||
Optional!ulong categoryId = row.parseOptional!ulong(9);
|
||||
if (categoryId) {
|
||||
item.category = SimpleCategoryResponse(
|
||||
categoryId.value,
|
||||
row.peek!string(10),
|
||||
row.peek!string(11)
|
||||
).toOptional;
|
||||
}
|
||||
// Read the nullable creditedAccount.
|
||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
|
||||
if (!creditedAccountId.isNull) {
|
||||
ulong id = creditedAccountId.get;
|
||||
string name = row.peek!string(13);
|
||||
string type = row.peek!string(14);
|
||||
string suffix = row.peek!string(15);
|
||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
|
||||
if (creditedAccountId) {
|
||||
item.creditedAccount = SimpleAccountResponse(
|
||||
creditedAccountId.value,
|
||||
row.peek!string(13),
|
||||
row.peek!string(14),
|
||||
row.peek!string(15)
|
||||
).toOptional;
|
||||
}
|
||||
// Read the nullable debitedAccount.
|
||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
|
||||
if (!debitedAccountId.isNull) {
|
||||
ulong id = debitedAccountId.get;
|
||||
string name = row.peek!string(17);
|
||||
string type = row.peek!string(18);
|
||||
string suffix = row.peek!string(19);
|
||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
|
||||
if (debitedAccountId) {
|
||||
item.debitedAccount = SimpleAccountResponse(
|
||||
debitedAccountId.value,
|
||||
row.peek!string(17),
|
||||
row.peek!string(18),
|
||||
row.peek!string(19)
|
||||
).toOptional;
|
||||
}
|
||||
string aggregateTags = row.peek!string(20);
|
||||
if (aggregateTags !is null) {
|
||||
import std.string : split;
|
||||
import std.algorithm : sort;
|
||||
item.tags = aggregateTags.split(",");
|
||||
sort(item.tags);
|
||||
}
|
||||
|
||||
// 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[];
|
||||
return item;
|
||||
}
|
||||
|
||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||
|
|
@ -624,7 +580,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
lineItem.valuePerItem,
|
||||
lineItem.quantity,
|
||||
lineItem.description,
|
||||
lineItem.categoryId
|
||||
lineItem.categoryId.toNullable()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -664,6 +620,348 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
.select("account_debit.name")
|
||||
.select("account_debit.type")
|
||||
.select("account_debit.number_suffix")
|
||||
.select("tags.tag");
|
||||
.select("GROUP_CONCAT(tags.tag)");
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||
private Database db;
|
||||
this(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr) {
|
||||
return findAllInternal(pr, DraftType.DRAFT.toOptional);
|
||||
}
|
||||
|
||||
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) {
|
||||
return findAllInternal(pr, DraftType.TEMPLATE.toOptional);
|
||||
}
|
||||
|
||||
Page!TransactionDraftListItem findAll(in PageRequest pr) {
|
||||
return findAllInternal(pr, Optional!DraftType.empty());
|
||||
}
|
||||
|
||||
private static enum DraftType {
|
||||
DRAFT,
|
||||
TEMPLATE
|
||||
}
|
||||
|
||||
private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, Optional!DraftType type) {
|
||||
QueryBuilder qb = getBuilderForDraftsList();
|
||||
addSelectsForDraftsList(qb);
|
||||
qb.groupBy("draft.id");
|
||||
if (type) {
|
||||
if (type.value == DraftType.DRAFT) {
|
||||
qb.where("template_name IS NULL");
|
||||
} else {
|
||||
qb.where("template_name IS NOT NULL");
|
||||
}
|
||||
}
|
||||
string query = qb.build() ~ "\n" ~ pr.toSql();
|
||||
TransactionDraftListItem[] results = util.sqlite.findAll(db, query, &parseDraftListItem);
|
||||
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM transaction_draft");
|
||||
return Page!(TransactionDraftListItem).of(results, pr, totalCount);
|
||||
}
|
||||
|
||||
Optional!TransactionDraftResponse findById(ulong id) {
|
||||
// First fetch the draft list item (contains all basic properties).
|
||||
QueryBuilder qb = getBuilderForDraftsList();
|
||||
addSelectsForDraftsList(qb);
|
||||
qb.groupBy("draft.id");
|
||||
qb.where("draft.id = ?");
|
||||
string query = qb.build();
|
||||
Optional!TransactionDraftListItem li = util.sqlite.findOne(db, query, &parseDraftListItem, id);
|
||||
if (li.isNull) return Optional!TransactionDraftResponse.empty();
|
||||
TransactionDraftListItem draft = li.value;
|
||||
// Then fetch line items.
|
||||
TransactionDraftResponse response;
|
||||
response.id = draft.id;
|
||||
response.addedAt = draft.addedAt;
|
||||
response.templateName = draft.templateName;
|
||||
response.timestamp = draft.timestamp;
|
||||
response.amount = draft.amount;
|
||||
response.currency = draft.currency;
|
||||
response.description = draft.description;
|
||||
response.internalTransfer = draft.internalTransfer;
|
||||
response.vendor = draft.vendor;
|
||||
response.category = draft.category;
|
||||
response.creditedAccount = draft.creditedAccount;
|
||||
response.debitedAccount = draft.debitedAccount;
|
||||
response.tags = li.value.tags;
|
||||
response.lineItems = util.sqlite.findAll(
|
||||
db,
|
||||
import("sql/query/get_line_items_draft.sql"),
|
||||
(row) {
|
||||
TransactionLineItemResponse item;
|
||||
item.idx = row.peek!uint(0);
|
||||
item.valuePerItem = row.peek!long(1);
|
||||
item.quantity = row.peek!ulong(2);
|
||||
item.description = row.peek!string(3);
|
||||
Optional!ulong categoryId = row.parseOptional!ulong(4);
|
||||
if (categoryId) {
|
||||
item.category = Optional!TransactionCategory.of(
|
||||
TransactionCategory(
|
||||
categoryId.value,
|
||||
row.parseOptional!ulong(5),
|
||||
row.peek!string(6),
|
||||
row.peek!string(7),
|
||||
row.peek!string(8)
|
||||
));
|
||||
}
|
||||
return item;
|
||||
},
|
||||
draft.id
|
||||
);
|
||||
// Return the response, excluding attachments (they are fetched using the attachment repo).
|
||||
return Optional!TransactionDraftResponse.of(response);
|
||||
}
|
||||
|
||||
TransactionDraftResponse insert(in TransactionDraftPayload data) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
import("sql/insert_transaction_draft.sql"),
|
||||
Clock.currTime(UTC()).toISOExtString(),
|
||||
data.templateName.toNullable(),
|
||||
data.timestamp.toNullable(),
|
||||
data.amount.toNullable(),
|
||||
data.currencyCode.toNullable(),
|
||||
data.description.toNullable(),
|
||||
data.internalTransfer.toNullable(),
|
||||
data.vendorId.toNullable(),
|
||||
data.categoryId.toNullable(),
|
||||
data.creditedAccountId.toNullable(),
|
||||
data.debitedAccountId.toNullable()
|
||||
);
|
||||
ulong draftId = db.lastInsertRowid();
|
||||
insertLineItems(draftId, data);
|
||||
return findById(draftId).orElseThrow();
|
||||
}
|
||||
|
||||
void linkAttachment(ulong draftId, ulong attachmentId) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO transaction_draft_attachment (draft_id, attachment_id) VALUES (?, ?)",
|
||||
draftId,
|
||||
attachmentId
|
||||
);
|
||||
}
|
||||
|
||||
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
import("sql/update_transaction_draft.sql"),
|
||||
data.templateName.toNullable(),
|
||||
data.timestamp.toNullable(),
|
||||
data.amount.toNullable(),
|
||||
data.currencyCode.toNullable(),
|
||||
data.description.toNullable(),
|
||||
data.internalTransfer.toNullable(),
|
||||
data.vendorId.toNullable(),
|
||||
data.categoryId.toNullable(),
|
||||
data.creditedAccountId.toNullable(),
|
||||
data.debitedAccountId.toNullable(),
|
||||
draftId
|
||||
);
|
||||
// Re-write all line items:
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM transaction_draft_line_item WHERE draft_id = ?",
|
||||
draftId
|
||||
);
|
||||
insertLineItems(draftId, data);
|
||||
return findById(draftId).orElseThrow();
|
||||
}
|
||||
|
||||
void deleteById(ulong id) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM attachment WHERE id IN (
|
||||
SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?
|
||||
)",
|
||||
id
|
||||
);
|
||||
util.sqlite.deleteById(db, "transaction_draft", id);
|
||||
}
|
||||
|
||||
void updateTags(ulong draftId, in string[] tags) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM transaction_draft_tag WHERE draft_id = ?",
|
||||
draftId
|
||||
);
|
||||
foreach (tag; tags) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO transaction_draft_tag (draft_id, tag) VALUES (?, ?)",
|
||||
draftId, tag
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
string[] findAllTags() {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT DISTINCT tag FROM transaction_draft_tag ORDER BY tag",
|
||||
r => r.peek!string(0)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private QueryBuilder getBuilderForDraftsList() {
|
||||
return QueryBuilder("transaction_draft draft")
|
||||
.join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id")
|
||||
.join("LEFT JOIN transaction_category category ON category.id = draft.category_id")
|
||||
.join("LEFT JOIN account account_credit ON account_credit.id = draft.credited_account_id")
|
||||
.join("LEFT JOIN account account_debit ON account_debit.id = draft.debited_account_id")
|
||||
.join("LEFT JOIN transaction_draft_tag tags ON tags.draft_id = draft.id");
|
||||
}
|
||||
|
||||
private void addSelectsForDraftsList(ref QueryBuilder qb) {
|
||||
qb
|
||||
.select("draft.id")
|
||||
.select("draft.added_at")
|
||||
.select("draft.template_name")
|
||||
.select("draft.timestamp")
|
||||
.select("draft.amount")// 5
|
||||
.select("draft.currency")
|
||||
.select("draft.description")
|
||||
.select("draft.internal_transfer")
|
||||
.select("vendor.id")
|
||||
.select("vendor.name")// 10
|
||||
.select("category.id")
|
||||
.select("category.name")
|
||||
.select("category.color")
|
||||
.select("account_credit.id")
|
||||
.select("account_credit.name")// 15
|
||||
.select("account_credit.type")
|
||||
.select("account_credit.number_suffix")
|
||||
.select("account_debit.id")
|
||||
.select("account_debit.name")
|
||||
.select("account_debit.type")// 20
|
||||
.select("account_debit.number_suffix")
|
||||
.select("group_concat(tags.tag)");
|
||||
}
|
||||
|
||||
private static TransactionDraftListItem parseDraftListItem(Row row) {
|
||||
TransactionDraftListItem item;
|
||||
item.id = row.peek!ulong(0);
|
||||
item.addedAt = row.peek!string(1);
|
||||
item.templateName = row.parseOptional!string(2);
|
||||
item.timestamp = row.parseOptional!string(3);
|
||||
item.amount = row.parseOptional!ulong(4);
|
||||
item.currency = row.parseOptional!(string, PeekMode.slice)(5)
|
||||
.mapIfPresent!(s => Currency.ofCode(s));
|
||||
item.description = row.parseOptional!string(6);
|
||||
item.internalTransfer = row.parseOptional!bool(7);
|
||||
Optional!ulong vendorId = row.parseOptional!ulong(8);
|
||||
if (vendorId) {
|
||||
item.vendor = SimpleVendorResponse(
|
||||
vendorId.value,
|
||||
row.peek!string(9)
|
||||
).toOptional;
|
||||
}
|
||||
Optional!ulong categoryId = row.parseOptional!ulong(10);
|
||||
if (categoryId) {
|
||||
item.category = SimpleCategoryResponse(
|
||||
categoryId.value,
|
||||
row.peek!string(11),
|
||||
row.peek!string(12),
|
||||
).toOptional;
|
||||
}
|
||||
Optional!ulong creditedAccountId = row.parseOptional!ulong(13);
|
||||
if (creditedAccountId) {
|
||||
item.creditedAccount = SimpleAccountResponse(
|
||||
creditedAccountId.value,
|
||||
row.peek!string(14),
|
||||
row.peek!string(15),
|
||||
row.peek!string(16)
|
||||
).toOptional;
|
||||
}
|
||||
Optional!ulong debitedAccountId = row.parseOptional!ulong(17);
|
||||
if (debitedAccountId) {
|
||||
item.debitedAccount = SimpleAccountResponse(
|
||||
debitedAccountId.value,
|
||||
row.peek!string(18),
|
||||
row.peek!string(19),
|
||||
row.peek!string(20)
|
||||
).toOptional;
|
||||
}
|
||||
string aggregateTags = row.peek!string(21);
|
||||
if (aggregateTags !is null) {
|
||||
import std.string : split;
|
||||
item.tags = aggregateTags.split(",");
|
||||
}
|
||||
import std.algorithm : sort;
|
||||
sort(item.tags);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void insertLineItems(ulong draftId, in TransactionDraftPayload payload) {
|
||||
foreach (size_t idx, lineItem; payload.lineItems) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
import("sql/insert_line_item_draft.sql"),
|
||||
draftId,
|
||||
idx,
|
||||
lineItem.valuePerItem,
|
||||
lineItem.quantity,
|
||||
lineItem.description,
|
||||
lineItem.categoryId.toNullable()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteRecurringTransactionRepository : RecurringTransactionRepository {
|
||||
private Database db;
|
||||
this(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
Page!RecurringTransactionResponse findAll(in PageRequest pr) {
|
||||
string query = "SELECT * FROM recurring_transaction " ~ pr.toSql();
|
||||
RecurringTransactionResponse[] results = util.sqlite.findAll(db, query, &parseRecurringTransaction);
|
||||
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM recurring_transaction");
|
||||
return Page!(RecurringTransactionResponse).of(results, pr, totalCount);
|
||||
}
|
||||
|
||||
RecurringTransactionResponse[] findAllByDraftId(ulong draftId) {
|
||||
string query = "SELECT * FROM recurring_transaction WHERE draft_id = ? ORDER BY id";
|
||||
return util.sqlite.findAll(db, query, &parseRecurringTransaction, draftId);
|
||||
}
|
||||
|
||||
Optional!RecurringTransactionResponse findById(ulong id) {
|
||||
return util.sqlite.findOne(
|
||||
db,
|
||||
"SELECT * FROM recurring_transaction WHERE id = ?",
|
||||
&parseRecurringTransaction,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
RecurringTransactionResponse insert(in RecurringTransactionPayload data) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO recurring_transaction " ~
|
||||
"(draft_id, schedule_expr) " ~
|
||||
"VALUES (?, ?)",
|
||||
data.draftId,
|
||||
data.scheduleExpr
|
||||
);
|
||||
return findById(db.lastInsertRowid()).orElseThrow();
|
||||
}
|
||||
|
||||
void deleteById(ulong id) {
|
||||
util.sqlite.deleteById(db, "recurring_transaction", id);
|
||||
}
|
||||
|
||||
private static RecurringTransactionResponse parseRecurringTransaction(Row row) {
|
||||
return RecurringTransactionResponse(
|
||||
row.peek!ulong(0),
|
||||
row.peek!ulong(1),
|
||||
row.peek!string(2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,36 @@
|
|||
module transaction.dto;
|
||||
|
||||
import handy_http_primitives : Optional;
|
||||
import asdf : serdeTransformOut;
|
||||
import std.typecons;
|
||||
|
||||
import transaction.model : TransactionCategory;
|
||||
import transaction.model : TransactionCategory, TransactionVendor;
|
||||
import attachment.dto;
|
||||
import account.dto;
|
||||
import util.data;
|
||||
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.
|
||||
struct TransactionsListItem {
|
||||
ulong id;
|
||||
|
|
@ -18,33 +40,11 @@ struct TransactionsListItem {
|
|||
Currency currency;
|
||||
string description;
|
||||
bool internalTransfer;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Vendor vendor;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Category category;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Account creditedAccount;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Account debitedAccount;
|
||||
Optional!SimpleVendorResponse vendor;
|
||||
Optional!SimpleCategoryResponse category;
|
||||
Optional!SimpleAccountResponse creditedAccount;
|
||||
Optional!SimpleAccountResponse debitedAccount;
|
||||
string[] tags;
|
||||
|
||||
static struct Account {
|
||||
ulong id;
|
||||
string name;
|
||||
string type;
|
||||
string numberSuffix;
|
||||
}
|
||||
|
||||
static struct Vendor {
|
||||
ulong id;
|
||||
string name;
|
||||
}
|
||||
|
||||
static struct Category {
|
||||
ulong id;
|
||||
string name;
|
||||
string color;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transaction data provided when fetching a single transaction.
|
||||
|
|
@ -56,42 +56,13 @@ struct TransactionDetail {
|
|||
Currency currency;
|
||||
string description;
|
||||
bool internalTransfer;
|
||||
Nullable!Vendor vendor;
|
||||
Nullable!Category category;
|
||||
Nullable!Account creditedAccount;
|
||||
Nullable!Account debitedAccount;
|
||||
Optional!TransactionVendor vendor;
|
||||
Optional!TransactionCategory category;
|
||||
Optional!SimpleAccountResponse creditedAccount;
|
||||
Optional!SimpleAccountResponse debitedAccount;
|
||||
string[] tags;
|
||||
LineItem[] lineItems;
|
||||
TransactionLineItemResponse[] lineItems;
|
||||
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.
|
||||
|
|
@ -101,26 +72,25 @@ struct AddTransactionPayload {
|
|||
string currencyCode;
|
||||
string description;
|
||||
bool internalTransfer;
|
||||
Nullable!ulong vendorId;
|
||||
Nullable!ulong categoryId;
|
||||
Nullable!ulong creditedAccountId;
|
||||
Nullable!ulong debitedAccountId;
|
||||
Optional!ulong vendorId;
|
||||
Optional!ulong categoryId;
|
||||
Optional!ulong creditedAccountId;
|
||||
Optional!ulong debitedAccountId;
|
||||
string[] tags;
|
||||
LineItem[] lineItems;
|
||||
LineItemPayload[] lineItems;
|
||||
ulong[] attachmentIdsToRemove;
|
||||
|
||||
static struct LineItem {
|
||||
static struct LineItemPayload {
|
||||
long valuePerItem;
|
||||
ulong quantity;
|
||||
string description;
|
||||
Nullable!ulong categoryId;
|
||||
Optional!ulong categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure for depicting an entire hierarchical tree structure of categories.
|
||||
struct TransactionCategoryTree {
|
||||
ulong id;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
|
|
@ -131,7 +101,6 @@ struct TransactionCategoryTree {
|
|||
|
||||
struct TransactionCategoryResponse {
|
||||
ulong id;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
|
|
@ -170,3 +139,82 @@ struct AggregateTransactionData {
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import std.datetime;
|
|||
import util.money;
|
||||
|
||||
struct TransactionVendor {
|
||||
immutable ulong id;
|
||||
immutable string name;
|
||||
immutable string description;
|
||||
ulong id;
|
||||
string name;
|
||||
string description;
|
||||
}
|
||||
|
||||
struct TransactionCategory {
|
||||
|
|
@ -20,23 +20,23 @@ struct TransactionCategory {
|
|||
}
|
||||
|
||||
struct Transaction {
|
||||
immutable ulong id;
|
||||
ulong id;
|
||||
/// The time at which the transaction happened.
|
||||
immutable SysTime timestamp;
|
||||
SysTime timestamp;
|
||||
/// The time at which the transaction entity was saved.
|
||||
immutable SysTime addedAt;
|
||||
immutable ulong amount;
|
||||
immutable Currency currency;
|
||||
immutable string description;
|
||||
immutable Optional!ulong vendorId;
|
||||
immutable Optional!ulong categoryId;
|
||||
SysTime addedAt;
|
||||
ulong amount;
|
||||
Currency currency;
|
||||
string description;
|
||||
Optional!ulong vendorId;
|
||||
Optional!ulong categoryId;
|
||||
}
|
||||
|
||||
struct TransactionLineItem {
|
||||
immutable ulong transactionId;
|
||||
immutable uint idx;
|
||||
immutable long valuePerItem;
|
||||
immutable ulong quantity;
|
||||
immutable string description;
|
||||
immutable Optional!ulong categoryId;
|
||||
ulong transactionId;
|
||||
uint idx;
|
||||
long valuePerItem;
|
||||
ulong quantity;
|
||||
string description;
|
||||
Optional!ulong categoryId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,16 @@ import account.data;
|
|||
import util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
import util.validation.transaction;
|
||||
import util.validation.draft;
|
||||
import attachment.data;
|
||||
import attachment.dto;
|
||||
|
||||
// Transactions Services
|
||||
|
||||
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
||||
Page!TransactionsListItem page = ds.getTransactionRepository()
|
||||
return ds.getTransactionRepository()
|
||||
.findAll(pageRequest);
|
||||
return page;
|
||||
}
|
||||
|
||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||
|
|
@ -46,6 +47,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||
|
||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||
SysTime now = Clock.currTime(UTC());
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
|
||||
// Add the transaction:
|
||||
|
|
@ -53,20 +55,20 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
ds.doTransaction(() {
|
||||
TransactionDetail txn = txnRepo.insert(payload);
|
||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
if (!payload.creditedAccountId.isNull) {
|
||||
if (payload.creditedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.creditedAccountId.get,
|
||||
payload.creditedAccountId.value,
|
||||
txn.id,
|
||||
txn.amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
txn.currency
|
||||
);
|
||||
}
|
||||
if (!payload.debitedAccountId.isNull) {
|
||||
if (payload.debitedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.debitedAccountId.get,
|
||||
payload.debitedAccountId.value,
|
||||
txn.id,
|
||||
txn.amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
|
|
@ -75,7 +77,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
}
|
||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||
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;
|
||||
});
|
||||
return getTransaction(ds, txnId);
|
||||
|
|
@ -96,6 +98,7 @@ TransactionDetail updateTransaction(
|
|||
|
||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
SysTime now = Clock.currTime(UTC());
|
||||
|
||||
const TransactionDetail prev = transactionRepo.findById(transactionId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
|
|
@ -105,7 +108,7 @@ TransactionDetail updateTransaction(
|
|||
TransactionDetail curr = transactionRepo.update(transactionId, payload);
|
||||
updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp);
|
||||
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);
|
||||
}
|
||||
|
|
@ -120,45 +123,45 @@ private void updateLinkedAccountJournalEntries(
|
|||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
||||
const bool updateCreditEntry = amountOrCurrencyChanged || (
|
||||
(prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
||||
(!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) ||
|
||||
(!prev.creditedAccount && payload.creditedAccountId) ||
|
||||
(prev.creditedAccount && !payload.creditedAccountId) ||
|
||||
(
|
||||
!prev.creditedAccount.isNull &&
|
||||
!payload.creditedAccountId.isNull &&
|
||||
prev.creditedAccount.get.id != payload.creditedAccountId.get
|
||||
prev.creditedAccount &&
|
||||
payload.creditedAccountId &&
|
||||
prev.creditedAccount.value.id != payload.creditedAccountId.value
|
||||
)
|
||||
);
|
||||
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
||||
(prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
||||
(!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) ||
|
||||
(!prev.debitedAccount && payload.creditedAccountId) ||
|
||||
(prev.debitedAccount && !payload.debitedAccountId) ||
|
||||
(
|
||||
!prev.debitedAccount.isNull &&
|
||||
!payload.debitedAccountId.isNull &&
|
||||
prev.debitedAccount.get.id != payload.debitedAccountId.get
|
||||
prev.debitedAccount &&
|
||||
payload.debitedAccountId &&
|
||||
prev.debitedAccount.value.id != payload.debitedAccountId.value
|
||||
)
|
||||
);
|
||||
|
||||
// Update journal entries if necessary:
|
||||
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id);
|
||||
if (updateCreditEntry && prev.creditedAccount) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
|
||||
}
|
||||
if (updateCreditEntry && !payload.creditedAccountId.isNull) {
|
||||
if (updateCreditEntry && payload.creditedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.creditedAccountId.get,
|
||||
payload.creditedAccountId.value,
|
||||
curr.id,
|
||||
curr.amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
curr.currency
|
||||
);
|
||||
}
|
||||
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id);
|
||||
if (updateDebitEntry && prev.debitedAccount) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
|
||||
}
|
||||
if (updateDebitEntry && !payload.debitedAccountId.isNull) {
|
||||
if (updateDebitEntry && payload.debitedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.debitedAccountId.get,
|
||||
payload.debitedAccountId.value,
|
||||
curr.id,
|
||||
curr.amount,
|
||||
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.
|
||||
*/
|
||||
|
|
@ -454,7 +387,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
|||
if (repo.existsByName(payload.name)) {
|
||||
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.");
|
||||
}
|
||||
import std.regex;
|
||||
|
|
@ -463,7 +396,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
|||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
|
||||
}
|
||||
auto category = repo.insert(
|
||||
toOptional(payload.parentId),
|
||||
payload.parentId,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.color
|
||||
|
|
@ -481,7 +414,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
|||
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
||||
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.");
|
||||
}
|
||||
TransactionCategory curr = repo.updateById(
|
||||
|
|
@ -489,7 +422,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
|||
payload.name,
|
||||
payload.description,
|
||||
payload.color,
|
||||
toOptional!ulong(payload.parentId)
|
||||
payload.parentId
|
||||
);
|
||||
return TransactionCategoryResponse.of(curr);
|
||||
}
|
||||
|
|
@ -497,3 +430,124 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
|||
void deleteCategory(ProfileDataSource ds, ulong 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
import handy_http_handlers.path_handler;
|
||||
import std.conv : to, ConvException;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
module util.sample_data;
|
||||
|
||||
import slf4d;
|
||||
import handy_http_primitives : Optional, mapIfPresent;
|
||||
import handy_http_primitives : Optional, mapIfPresent, toOptional;
|
||||
|
||||
import auth;
|
||||
import profile;
|
||||
|
|
@ -143,10 +143,10 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
AddTransactionPayload data;
|
||||
data.timestamp = timestamp.toISOExtString();
|
||||
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) {
|
||||
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.
|
||||
|
|
@ -162,11 +162,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
}
|
||||
}
|
||||
if (uniform01() < 0.5) {
|
||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
|
||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId;
|
||||
} else {
|
||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
|
||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId;
|
||||
}
|
||||
|
||||
// Randomly choose some tags to add.
|
||||
|
|
@ -185,25 +185,25 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
if (uniform01 < 0.5) {
|
||||
long lineItemTotal = 0;
|
||||
foreach (n; 1..uniform(1, 20)) {
|
||||
AddTransactionPayload.LineItem item;
|
||||
AddTransactionPayload.LineItemPayload item;
|
||||
item.valuePerItem = uniform(1, 10_000);
|
||||
item.quantity = uniform(1, 5);
|
||||
lineItemTotal += item.quantity * item.valuePerItem;
|
||||
item.description = "Sample item " ~ n.to!string;
|
||||
if (uniform01 < 0.5) {
|
||||
TransactionCategory category = choice(categories);
|
||||
item.categoryId = category.id;
|
||||
item.categoryId = Optional!ulong.of(category.id);
|
||||
}
|
||||
data.lineItems ~= item;
|
||||
}
|
||||
long diff = data.amount - lineItemTotal;
|
||||
// Add one final line item that adds up to the transaction total.
|
||||
if (diff != 0) {
|
||||
data.lineItems ~= AddTransactionPayload.LineItem(
|
||||
data.lineItems ~= AddTransactionPayload.LineItemPayload(
|
||||
diff,
|
||||
1,
|
||||
"Last item which reconciles line items total with transaction amount.",
|
||||
Nullable!ulong.init
|
||||
Optional!ulong.empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,11 +192,35 @@ immutable(ubyte[]) parseBlob(Row row, size_t idx) {
|
|||
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 {
|
||||
string fromTable;
|
||||
string[] selections;
|
||||
string[] joins;
|
||||
string[] conditions;
|
||||
string[] groupings;
|
||||
void delegate(ref Statement, ref int)[] argBinders;
|
||||
|
||||
this(string fromTable) {
|
||||
|
|
@ -223,6 +247,11 @@ struct QueryBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
ref groupBy(string grouping) {
|
||||
groupings ~= grouping;
|
||||
return this;
|
||||
}
|
||||
|
||||
string build() const {
|
||||
import std.algorithm : map;
|
||||
import std.string : join;
|
||||
|
|
@ -240,6 +269,10 @@ struct QueryBuilder {
|
|||
app ~= "\nWHERE\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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
INSERT INTO transaction_draft_line_item (
|
||||
draft_id,
|
||||
idx,
|
||||
value_per_item,
|
||||
quantity,
|
||||
description,
|
||||
category_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
|
|
@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
CREATE TABLE transaction_draft (
|
||||
id INTEGER PRIMARY KEY,
|
||||
added_at TEXT NOT NULL,
|
||||
template_name TEXT,
|
||||
timestamp TEXT,
|
||||
amount INTEGER,
|
||||
currency TEXT,
|
||||
description TEXT,
|
||||
internal_transfer BOOLEAN DEFAULT FALSE,
|
||||
vendor_id INTEGER,
|
||||
category_id INTEGER,
|
||||
credited_account_id INTEGER,
|
||||
debited_account_id INTEGER,
|
||||
CONSTRAINT fk_transaction_draft_vendor
|
||||
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_credited_account
|
||||
FOREIGN KEY (credited_account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_debited_account
|
||||
FOREIGN KEY (debited_account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT ck_transaction_amount_positive
|
||||
CHECK (amount IS NULL OR amount > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_tag (
|
||||
draft_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag),
|
||||
CONSTRAINT fk_transaction_draft_tag_draft
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_attachment (
|
||||
draft_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (draft_id, attachment_id),
|
||||
CONSTRAINT fk_transaction_draft_attachment_transaction
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_draft_attachment_attachment
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_line_item (
|
||||
draft_id INTEGER NOT NULL,
|
||||
idx INTEGER NOT NULL DEFAULT 0,
|
||||
value_per_item INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx),
|
||||
CONSTRAINT fk_transaction_draft_line_item_transaction
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_draft_line_item_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recurring_transaction (
|
||||
id INTEGER PRIMARY KEY,
|
||||
draft_id INTEGER NOT NULL,
|
||||
schedule_expr TEXT NOT NULL,
|
||||
CONSTRAINT fk_recurring_transaction_draft
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
|
@ -3,11 +3,13 @@ i.idx,
|
|||
i.value_per_item,
|
||||
i.quantity,
|
||||
i.description,
|
||||
|
||||
i.category_id,
|
||||
category.parent_id,
|
||||
category.name,
|
||||
category.description,
|
||||
category.color
|
||||
|
||||
FROM transaction_line_item i
|
||||
LEFT JOIN transaction_category category
|
||||
ON category.id = i.category_id
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -218,3 +223,82 @@ CREATE TABLE history_item_linked_journal_entry (
|
|||
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
|
||||
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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = ?
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
"@idle-observer/vue3": "^0.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"cron-parser": "^5.6.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,13 @@ export interface Account {
|
|||
currentBalance: number | null
|
||||
}
|
||||
|
||||
export interface SimpleAccountResponse {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
numberSuffix: string
|
||||
}
|
||||
|
||||
export interface AccountCreationPayload {
|
||||
type: string
|
||||
numberSuffix: string
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
import type { SimpleAccountResponse } from './account'
|
||||
import type { Attachment } from './attachment'
|
||||
import { ApiClient } from './base'
|
||||
import type { Currency } from './data'
|
||||
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 {
|
||||
id: number
|
||||
name: string
|
||||
|
|
@ -47,10 +67,10 @@ export interface TransactionsListItem {
|
|||
currency: Currency
|
||||
description: string
|
||||
internalTransfer: boolean
|
||||
vendor: TransactionsListItemVendor | null
|
||||
category: TransactionsListItemCategory | null
|
||||
creditedAccount: TransactionsListItemAccount | null
|
||||
debitedAccount: TransactionsListItemAccount | null
|
||||
vendor: SimpleVendorResponse | null
|
||||
category: SimpleCategoryResponse | null
|
||||
creditedAccount: SimpleAccountResponse | null
|
||||
debitedAccount: SimpleAccountResponse | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
|
|
@ -82,28 +102,13 @@ export interface TransactionDetail {
|
|||
internalTransfer: boolean
|
||||
vendor: TransactionVendor | null
|
||||
category: TransactionCategory | null
|
||||
creditedAccount: TransactionDetailAccount | null
|
||||
debitedAccount: TransactionDetailAccount | null
|
||||
creditedAccount: SimpleAccountResponse | null
|
||||
debitedAccount: SimpleAccountResponse | null
|
||||
tags: string[]
|
||||
lineItems: TransactionDetailLineItem[]
|
||||
lineItems: TransactionLineItemResponse[]
|
||||
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 {
|
||||
timestamp: string
|
||||
amount: number
|
||||
|
|
@ -144,6 +149,67 @@ export interface AggregateTransactionData {
|
|||
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 {
|
||||
readonly path: string
|
||||
|
||||
|
|
@ -277,4 +343,77 @@ export class TransactionApiClient extends ApiClient {
|
|||
getAllTags(): Promise<string[]> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type RecurringTransactionPayload,
|
||||
type RecurringTransactionResponse,
|
||||
} from '@/api/transaction'
|
||||
import ModalWrapper from './common/ModalWrapper.vue'
|
||||
import { ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppForm from './common/form/AppForm.vue'
|
||||
import FormGroup from './common/form/FormGroup.vue'
|
||||
import FormControl from './common/form/FormControl.vue'
|
||||
import AppButton from './common/AppButton.vue'
|
||||
import { getSelectedProfile } from '@/api/profile.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const props = defineProps<{ draftId: number }>()
|
||||
const modal = useTemplateRef('modal')
|
||||
const savedTxn: Ref<RecurringTransactionResponse | undefined> = ref()
|
||||
|
||||
// Form Data:
|
||||
const dayOfMonth: Ref<number> = ref(1)
|
||||
|
||||
async function show(): Promise<RecurringTransactionResponse | undefined> {
|
||||
if (!modal.value) return undefined
|
||||
savedTxn.value = undefined
|
||||
const result = await modal.value.show()
|
||||
if (result === 'saved') {
|
||||
return savedTxn.value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function addRecurringTransaction() {
|
||||
const payload: RecurringTransactionPayload = {
|
||||
draftId: props.draftId,
|
||||
scheduleExpr: `0 0 0 ${dayOfMonth.value} * *`,
|
||||
}
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
try {
|
||||
savedTxn.value = await api.createRecurringTransaction(payload)
|
||||
modal.value?.close('saved')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
savedTxn.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal">
|
||||
<template v-slot:default>
|
||||
<h2>Add Recurring Transaction</h2>
|
||||
<p>
|
||||
Use this template to add a <em>recurring transaction</em>. According to the interval you
|
||||
specify below, Finnow will create a new draft using this template each time, which you can
|
||||
then review and submit.
|
||||
</p>
|
||||
<p>
|
||||
Each time a new draft is created from this template for recurring transactions, the new
|
||||
draft's timestamp will be set to the current date and time.
|
||||
</p>
|
||||
<AppForm>
|
||||
<h3>Monthly Schedule</h3>
|
||||
<FormGroup>
|
||||
<FormControl
|
||||
label="Day of Month"
|
||||
hint="Specify on which day of the month this transaction occurs."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
max="31"
|
||||
v-model="dayOfMonth"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
</AppForm>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton @click="addRecurringTransaction()">Add</AppButton>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="modal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { TransactionDetailLineItem } from '@/api/transaction'
|
||||
import type { TransactionLineItemResponse } from '@/api/transaction'
|
||||
import AppButton from './common/AppButton.vue'
|
||||
import { formatMoney, type Currency } from '@/api/data'
|
||||
|
||||
defineProps<{
|
||||
lineItem: TransactionDetailLineItem
|
||||
lineItem: TransactionLineItemResponse
|
||||
currency: Currency
|
||||
totalCount?: number
|
||||
editable?: boolean
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ transaction. This editor shows a table of current line items, and includes a
|
|||
modal for adding a new one.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction'
|
||||
import { type TransactionCategoryTree, type TransactionLineItemResponse } from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'
|
||||
|
|
@ -15,7 +15,7 @@ import CategorySelect from './CategorySelect.vue'
|
|||
import LineItemCard from './LineItemCard.vue'
|
||||
import AppBadge from './common/AppBadge.vue'
|
||||
|
||||
const model = defineModel<TransactionDetailLineItem[]>({ required: true })
|
||||
const model = defineModel<TransactionLineItemResponse[]>({ required: true })
|
||||
const props = defineProps<{
|
||||
transactionAmount: number
|
||||
currency: Currency
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
<script setup lang="ts">
|
||||
import { ApiError } from '@/api/base'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type RecurringTransactionResponse,
|
||||
type TransactionDraftResponse,
|
||||
} from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import CategoryLabel from '@/components/CategoryLabel.vue'
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue'
|
||||
import TagLabel from '@/components/TagLabel.vue'
|
||||
import { showAlert, showConfirm } from '@/util/alert'
|
||||
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import AttachmentRow from '@/components/common/AttachmentRow.vue'
|
||||
import LineItemCard from '@/components/LineItemCard.vue'
|
||||
import AppBadge from '@/components/common/AppBadge.vue'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AddRecurringTransactionModal from '@/components/AddRecurringTransactionModal.vue'
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
||||
const addRecurringTransactionModal = useTemplateRef('addRecurringTransactionModal')
|
||||
|
||||
const draft: Ref<TransactionDraftResponse | undefined> = ref()
|
||||
const recurringTransactions: Ref<RecurringTransactionResponse[]> = ref([])
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (draft.value === undefined) return 'Transaction Draft'
|
||||
if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
|
||||
return `Transaction Template ${draft.value.id}: "${draft.value.templateName}"`
|
||||
}
|
||||
return 'Transaction Draft ' + draft.value.id
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const draftId = parseInt(route.params.id as string)
|
||||
recurringTransactions.value = []
|
||||
try {
|
||||
draft.value = await transactionApi.getDraft(draftId)
|
||||
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(draftId)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
await router.replace('/')
|
||||
if (err instanceof ApiError) {
|
||||
await showAlert('Failed to fetch transaction: ' + err.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function deleteDraft() {
|
||||
if (!draft.value) return
|
||||
const conf = await showConfirm(
|
||||
'Are you sure you want to delete this draft? This will permanently delete all data pertaining to this draft, and it cannot be recovered.',
|
||||
)
|
||||
if (!conf) return
|
||||
try {
|
||||
await transactionApi.deleteDraft(draft.value.id)
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function onVendorClicked() {
|
||||
if (draft.value && draft.value.vendor) {
|
||||
await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function addRecurringTransaction() {
|
||||
const result = await addRecurringTransactionModal.value?.show()
|
||||
if (result !== undefined && draft.value) {
|
||||
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
|
||||
draft.value.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecurringTransaction(rt: RecurringTransactionResponse) {
|
||||
if (!draft.value) return
|
||||
await transactionApi.deleteRecurringTransaction(rt.id)
|
||||
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
|
||||
draft.value.id,
|
||||
)
|
||||
}
|
||||
|
||||
function formatRecurringTransactionScheduleExpr(rt: RecurringTransactionResponse) {
|
||||
const interval = CronExpressionParser.parse(rt.scheduleExpr)
|
||||
const dayOfMonth = interval.fields.dayOfMonth.serialize().values[0] as number
|
||||
return `The ${dayOfMonth}${getNumberAdjectiveSuffix(dayOfMonth)} day of every month`
|
||||
}
|
||||
|
||||
function getNumberAdjectiveSuffix(n: number) {
|
||||
const digit = n % 10
|
||||
if (digit === 1) return 'st'
|
||||
if (digit === 2) return 'nd'
|
||||
if (digit === 3) return 'rd'
|
||||
return 'th'
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppPage
|
||||
:title="pageTitle"
|
||||
v-if="draft"
|
||||
>
|
||||
<!-- Top-row with some badges for amount, vendor, and category. -->
|
||||
<div>
|
||||
<AppBadge
|
||||
size="lg"
|
||||
class="font-mono"
|
||||
v-if="draft.currency && draft.amount"
|
||||
>
|
||||
{{ draft.currency.code }} {{ formatMoney(draft.amount, draft.currency) }}
|
||||
</AppBadge>
|
||||
<AppBadge
|
||||
size="md"
|
||||
v-if="draft.vendor"
|
||||
style="cursor: pointer"
|
||||
@click="onVendorClicked()"
|
||||
>
|
||||
{{ draft.vendor.name }}
|
||||
</AppBadge>
|
||||
<CategoryLabel
|
||||
v-if="draft.category"
|
||||
:category="draft.category"
|
||||
:clickable="true"
|
||||
/>
|
||||
<AppBadge
|
||||
size="sm"
|
||||
v-if="draft.internalTransfer"
|
||||
>
|
||||
<font-awesome-icon icon="fa-rotate"></font-awesome-icon>
|
||||
Internal Transfer
|
||||
</AppBadge>
|
||||
</div>
|
||||
<!-- Second row that lists all tags. -->
|
||||
<div
|
||||
v-if="draft.tags.length > 0"
|
||||
class="mt-1"
|
||||
>
|
||||
<TagLabel
|
||||
v-for="t in draft.tags"
|
||||
:key="t"
|
||||
:tag="t"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p>{{ draft.description }}</p>
|
||||
|
||||
<div
|
||||
v-if="draft.creditedAccount"
|
||||
class="my-1"
|
||||
>
|
||||
<strong class="text-negative">Credited</strong> from
|
||||
<RouterLink
|
||||
:to="`/profiles/${getSelectedProfile(route)}/accounts/${draft.creditedAccount.id}`"
|
||||
>
|
||||
{{ draft.creditedAccount.name }} (#{{ draft.creditedAccount.numberSuffix }})
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="draft.debitedAccount"
|
||||
class="my-1"
|
||||
>
|
||||
<strong class="text-positive">Debited</strong> to
|
||||
<RouterLink
|
||||
:to="`/profiles/${getSelectedProfile(route)}/accounts/${draft.debitedAccount.id}`"
|
||||
>
|
||||
{{ draft.debitedAccount.name }} (#{{ draft.debitedAccount.numberSuffix }})
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- All remaining properties are put in this table. -->
|
||||
<PropertiesTable>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<td v-if="draft.timestamp !== null">{{ new Date(draft.timestamp).toLocaleString() }}</td>
|
||||
<td v-if="draft.timestamp === null"><em>null</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Added to Finnow</th>
|
||||
<td>{{ new Date(draft.addedAt).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</PropertiesTable>
|
||||
|
||||
<div v-if="draft.lineItems.length > 0 && draft.currency">
|
||||
<h3>Line Items</h3>
|
||||
<LineItemCard
|
||||
v-for="item of draft.lineItems"
|
||||
:key="item.idx"
|
||||
:line-item="item"
|
||||
:currency="draft.currency"
|
||||
:total-count="draft.lineItems.length"
|
||||
:editable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.attachments.length > 0">
|
||||
<h3>Attachments</h3>
|
||||
<AttachmentRow
|
||||
v-for="a in draft.attachments"
|
||||
:attachment="a"
|
||||
:key="a.id"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<ButtonBar>
|
||||
<AppButton
|
||||
icon="wrench"
|
||||
@click="
|
||||
router.push(`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft?.id}/edit`)
|
||||
"
|
||||
>
|
||||
Edit
|
||||
</AppButton>
|
||||
<AppButton
|
||||
icon="trash"
|
||||
@click="deleteDraft()"
|
||||
>Delete</AppButton
|
||||
>
|
||||
</ButtonBar>
|
||||
<ButtonBar>
|
||||
<AppButton
|
||||
icon="repeat"
|
||||
@click="addRecurringTransaction()"
|
||||
>
|
||||
Add Recurring Transaction
|
||||
</AppButton>
|
||||
</ButtonBar>
|
||||
|
||||
<div v-if="recurringTransactions.length > 0">
|
||||
<h3>Recurring Transactions</h3>
|
||||
<p>This template will be used to create new drafts using the following schedules:</p>
|
||||
<ul>
|
||||
<li
|
||||
v-for="rt in recurringTransactions"
|
||||
:key="rt.id"
|
||||
>
|
||||
{{ formatRecurringTransactionScheduleExpr(rt) }}
|
||||
<AppButton
|
||||
icon="trash"
|
||||
@click="deleteRecurringTransaction(rt)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<AddRecurringTransactionModal
|
||||
ref="addRecurringTransactionModal"
|
||||
:draft-id="draft.id"
|
||||
/>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
@ -2,12 +2,14 @@
|
|||
import ProfileModule from './home/ProfileModule.vue'
|
||||
import AccountsModule from './home/AccountsModule.vue'
|
||||
import TransactionsModule from './home/TransactionsModule.vue'
|
||||
import DraftsModule from './home/DraftsModule.vue'
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-module-container">
|
||||
<ProfileModule />
|
||||
<AccountsModule />
|
||||
<TransactionsModule />
|
||||
<DraftsModule />
|
||||
<!-- <AnalyticsModule /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
<!--
|
||||
This page is quite large, and handles the form in which users can create and
|
||||
edit transactions. It's accessed through two routes:
|
||||
- /profiles/:profileName/transactions/:transactionId for editing
|
||||
- /profiles/:profileName/add-transaction for creating a new transaction
|
||||
|
||||
The form consists of a few main sections:
|
||||
- Standard form controls for various fields like timestamp, amount, description, etc.
|
||||
- Line items table for editing the list of line items.
|
||||
- Tags editor for editing the set of tags.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account'
|
||||
import { DataApiClient, floatMoneyToInteger, type Currency } from '@/api/data'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import CategorySelect from '@/components/CategorySelect.vue'
|
||||
import FileSelector from '@/components/common/FileSelector.vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import LineItemsEditor from '@/components/LineItemsEditor.vue'
|
||||
import { computed, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import VendorSelect from '@/components/VendorSelect.vue'
|
||||
import TagsSelect from '@/components/TagsSelect.vue'
|
||||
import {
|
||||
defaultEmptyFormFields,
|
||||
DraftEditorContext,
|
||||
loadEditorContextFromRoute,
|
||||
NewTransactionEditorContext,
|
||||
TransactionEditorContext,
|
||||
type TransactionEditorContextBase,
|
||||
type TransactionEditorFormFields,
|
||||
} from './util'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const accountApi = new AccountApiClient(route)
|
||||
|
||||
// General data used to populate form controls.
|
||||
const allCurrencies: Ref<Currency[]> = ref([])
|
||||
const availableCurrencies = computed(() => {
|
||||
return allCurrencies.value.filter((c) =>
|
||||
allAccounts.value.some((a) => a.currency.code === c.code),
|
||||
)
|
||||
})
|
||||
const allAccounts: Ref<Account[]> = ref([])
|
||||
const availableAccounts = computed(() => {
|
||||
return allAccounts.value.filter((a) => a.currency.code === formData.value.currency?.code)
|
||||
})
|
||||
|
||||
// Reactive form data:
|
||||
const loading = ref(false)
|
||||
const formData: Ref<TransactionEditorFormFields> = ref(defaultEmptyFormFields())
|
||||
const editorContext: Ref<TransactionEditorContextBase> = ref(new NewTransactionEditorContext())
|
||||
const pageTitle = computed(() => {
|
||||
if (editorContext.value instanceof NewTransactionEditorContext) {
|
||||
return 'Add Transaction'
|
||||
} else if (editorContext.value instanceof DraftEditorContext) {
|
||||
return 'Edit Draft Transaction'
|
||||
} else if (editorContext.value instanceof TransactionEditorContext) {
|
||||
return 'Edit Transaction'
|
||||
}
|
||||
return 'Edit Transaction'
|
||||
})
|
||||
|
||||
watch(availableCurrencies, (newValue: Currency[]) => {
|
||||
if (newValue.length === 1) {
|
||||
formData.value.currency = newValue[0]
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const dataClient = new DataApiClient()
|
||||
|
||||
// Fetch various collections of data needed for different user choices.
|
||||
dataClient.getCurrencies().then((currencies) => (allCurrencies.value = currencies))
|
||||
accountApi.getAccounts().then((accounts) => (allAccounts.value = accounts))
|
||||
|
||||
try {
|
||||
editorContext.value = await loadEditorContextFromRoute(route)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
formData.value = editorContext.value.initializeFormFields(route.query)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<AppPage :title="pageTitle">
|
||||
<AppForm>
|
||||
<!-- Initial draft-only form group: -->
|
||||
<FormGroup v-if="editorContext instanceof DraftEditorContext">
|
||||
<FormControl
|
||||
label="Template Name"
|
||||
hint="Add a name to this draft to turn it into a Template, which you can use when creating new transactions or scheduled transactions."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="formData.templateName"
|
||||
:disabled="loading"
|
||||
style="max-width: 200px"
|
||||
maxlength="32"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Basic properties -->
|
||||
<FormControl label="Timestamp">
|
||||
<input
|
||||
type="datetime-local"
|
||||
v-model="formData.timestamp"
|
||||
step="1"
|
||||
:disabled="loading"
|
||||
style="min-width: 250px"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Amount">
|
||||
<input
|
||||
type="number"
|
||||
v-model="formData.amount"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
:disabled="loading"
|
||||
style="max-width: 100px"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Currency">
|
||||
<select
|
||||
v-model="formData.currency"
|
||||
:disabled="loading || availableCurrencies.length === 1"
|
||||
>
|
||||
<option
|
||||
v-for="currency in availableCurrencies"
|
||||
:key="currency.code"
|
||||
:value="currency"
|
||||
>
|
||||
{{ currency.code }}
|
||||
</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="Description"
|
||||
style="min-width: 200px"
|
||||
>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
:disabled="loading"
|
||||
></textarea>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Vendor & Category -->
|
||||
<FormControl label="Vendor">
|
||||
<VendorSelect v-model="formData.vendor" />
|
||||
</FormControl>
|
||||
<FormControl label="Category">
|
||||
<CategorySelect v-model="formData.categoryId" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Accounts -->
|
||||
<FormControl label="Credited Account">
|
||||
<select
|
||||
v-model="formData.creditedAccountId"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.numberSuffix }})
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Debited Account">
|
||||
<select
|
||||
v-model="formData.debitedAccountId"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.numberSuffix }})
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<LineItemsEditor
|
||||
v-if="formData.currency"
|
||||
v-model="formData.lineItems"
|
||||
:currency="formData.currency"
|
||||
:transaction-amount="floatMoneyToInteger(formData.amount ?? 0, formData.currency)"
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Tags -->
|
||||
<FormControl label="Tags">
|
||||
<TagsSelect v-model="formData.tags" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<h5>Attachments</h5>
|
||||
<FileSelector
|
||||
:initial-files="formData.existingAttachments"
|
||||
v-model:uploaded-files="formData.attachmentsToUpload"
|
||||
v-model:removed-files="formData.removedAttachmentIds"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<!-- One last group for less-often used fields: -->
|
||||
<FormGroup>
|
||||
<FormControl
|
||||
label="Internal Transfer"
|
||||
hint="Mark this transaction as an internal transfer to ignore it in analytics. Useful for things like credit card payments."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="formData.internalTransfer"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<!-- The set of available actions is defined by the current editor context. -->
|
||||
<ButtonBar>
|
||||
<AppButton
|
||||
v-for="action in editorContext.getActions(formData)"
|
||||
:key="action.name"
|
||||
:disabled="action.disabled"
|
||||
@click="action.callback(formData, route, router)"
|
||||
>
|
||||
{{ action.name }}
|
||||
</AppButton>
|
||||
</ButtonBar>
|
||||
</AppForm>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
import type { Attachment } from '@/api/attachment'
|
||||
import { floatMoneyToInteger, integerMoneyToFloat, type Currency } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type AddTransactionPayload,
|
||||
type TransactionDetail,
|
||||
type TransactionDraftPayload,
|
||||
type TransactionDraftResponse,
|
||||
type TransactionLineItemResponse,
|
||||
type TransactionVendor,
|
||||
} from '@/api/transaction'
|
||||
import { getDatetimeLocalValueForNow } from '@/util/time'
|
||||
import type { RouteLocation, Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* The set of all form fields on the transaction editor page. Note that some
|
||||
* fields may only be used in certain contexts.
|
||||
*/
|
||||
export interface TransactionEditorFormFields {
|
||||
timestamp: string | null
|
||||
amount: number | null
|
||||
templateName: string | null // Only for drafts, not transactions.
|
||||
currency: Currency | null
|
||||
description: string | null
|
||||
internalTransfer: boolean | null
|
||||
vendor: TransactionVendor | null
|
||||
categoryId: number | null
|
||||
creditedAccountId: number | null
|
||||
debitedAccountId: number | null
|
||||
lineItems: TransactionLineItemResponse[]
|
||||
tags: string[]
|
||||
attachmentsToUpload: File[]
|
||||
removedAttachmentIds: number[]
|
||||
existingAttachments: Attachment[]
|
||||
}
|
||||
|
||||
export function defaultEmptyFormFields(): TransactionEditorFormFields {
|
||||
return {
|
||||
timestamp: null,
|
||||
amount: null,
|
||||
templateName: null,
|
||||
currency: null,
|
||||
description: null,
|
||||
internalTransfer: null,
|
||||
vendor: null,
|
||||
categoryId: null,
|
||||
creditedAccountId: null,
|
||||
debitedAccountId: null,
|
||||
lineItems: [],
|
||||
tags: [],
|
||||
attachmentsToUpload: [],
|
||||
removedAttachmentIds: [],
|
||||
existingAttachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransactionEditorAction {
|
||||
name: string
|
||||
disabled: boolean
|
||||
callback: (
|
||||
formData: TransactionEditorFormFields,
|
||||
route: RouteLocation,
|
||||
router: Router,
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for transaction editor contexts.
|
||||
*/
|
||||
export interface TransactionEditorContextBase {
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean
|
||||
areChangesPresent(formData: TransactionEditorFormFields): boolean
|
||||
initializeFormFields(
|
||||
queryParams: Record<string, string | null | (string | null)[]>,
|
||||
): TransactionEditorFormFields
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor context that's used when the user starts editing a new transaction,
|
||||
* not related to any saved transaction or draft.
|
||||
*/
|
||||
export class NewTransactionEditorContext implements TransactionEditorContextBase {
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
isFormDataValidForDraftSave(formData) || isFormDataValidForTransactionSubmission(formData)
|
||||
)
|
||||
}
|
||||
|
||||
areChangesPresent(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
formData.timestamp !== null ||
|
||||
formData.amount !== null ||
|
||||
formData.templateName !== null ||
|
||||
formData.currency !== null ||
|
||||
formData.description !== null ||
|
||||
formData.internalTransfer !== null ||
|
||||
formData.vendor !== null ||
|
||||
formData.creditedAccountId !== null ||
|
||||
formData.debitedAccountId !== null ||
|
||||
formData.lineItems.length > 0 ||
|
||||
formData.tags.length > 0 ||
|
||||
formData.attachmentsToUpload.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
initializeFormFields(
|
||||
queryParams: Record<string, string | null | (string | null)[]>,
|
||||
): TransactionEditorFormFields {
|
||||
const fields = defaultEmptyFormFields()
|
||||
fields.timestamp = getDatetimeLocalValueForNow()
|
||||
if ('credited-account' in queryParams) {
|
||||
fields.creditedAccountId = parseInt(queryParams['credited-account'] as string)
|
||||
}
|
||||
if ('debited-account' in queryParams) {
|
||||
fields.debitedAccountId = parseInt(queryParams['debited-account'] as string)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
|
||||
return [
|
||||
{
|
||||
name: 'Save',
|
||||
disabled: !(
|
||||
this.areChangesPresent(formData) && isFormDataValidForTransactionSubmission(formData)
|
||||
),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
// Assume that form data is valid!
|
||||
const data = toTransactionPayload(formData)
|
||||
const txn = await api.addTransaction(data, formData.attachmentsToUpload)
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Save Draft',
|
||||
disabled: !this.areChangesPresent(formData) || !isFormDataValidForDraftSave(formData),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
const data = toDraftPayload(formData)
|
||||
const draft = await api.addDraft(data, formData.attachmentsToUpload)
|
||||
await router.replace(
|
||||
`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
disabled: false,
|
||||
callback: async (_formData, route, router) => {
|
||||
await goBackOrHome(router, route)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor context for when the user is editing a transaction.
|
||||
*/
|
||||
export class TransactionEditorContext implements TransactionEditorContextBase {
|
||||
private existingTransaction: TransactionDetail
|
||||
|
||||
constructor(existingTransaction: TransactionDetail) {
|
||||
this.existingTransaction = existingTransaction
|
||||
}
|
||||
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean {
|
||||
return isFormDataValidForTransactionSubmission(formData)
|
||||
}
|
||||
|
||||
areChangesPresent(formData: TransactionEditorFormFields): boolean {
|
||||
const tx: TransactionDetail = this.existingTransaction
|
||||
const tagsChanged =
|
||||
formData.tags.every((t) => tx.tags.includes(t)) &&
|
||||
tx.tags.every((t) => formData.tags.includes(t))
|
||||
const lineItemsChanged =
|
||||
JSON.stringify(formData.lineItems) !== JSON.stringify(formData.lineItems)
|
||||
const attachmentsChanged =
|
||||
formData.attachmentsToUpload.length > 0 || formData.removedAttachmentIds.length > 0
|
||||
|
||||
const timestampChanged = new Date(formData.timestamp ?? 0).toISOString() !== tx.timestamp
|
||||
const amountChanged =
|
||||
floatMoneyToInteger(formData.amount ?? 0, formData.currency ?? tx.currency) !== tx.amount
|
||||
const currencyChanged = formData.currency?.code !== tx.currency.code
|
||||
const descriptionChanged = formData.description !== tx.description
|
||||
const internalTransferChanged = formData.internalTransfer !== tx.internalTransfer
|
||||
const vendorChanged = formData.vendor?.id !== tx.vendor?.id
|
||||
const categoryChanged = formData.categoryId !== (tx.category?.id ?? null)
|
||||
const creditedAccountChanged = formData.creditedAccountId !== (tx.creditedAccount?.id ?? null)
|
||||
const debitedAccountChanged = formData.debitedAccountId !== (tx.debitedAccount?.id ?? null)
|
||||
|
||||
return (
|
||||
tagsChanged ||
|
||||
lineItemsChanged ||
|
||||
attachmentsChanged ||
|
||||
timestampChanged ||
|
||||
amountChanged ||
|
||||
currencyChanged ||
|
||||
descriptionChanged ||
|
||||
internalTransferChanged ||
|
||||
vendorChanged ||
|
||||
categoryChanged ||
|
||||
creditedAccountChanged ||
|
||||
debitedAccountChanged
|
||||
)
|
||||
}
|
||||
|
||||
initializeFormFields(): TransactionEditorFormFields {
|
||||
const tx = this.existingTransaction
|
||||
return {
|
||||
timestamp: getLocalDateTimeStringFromUTCTimestamp(tx.timestamp),
|
||||
amount: integerMoneyToFloat(tx.amount, tx.currency),
|
||||
templateName: null,
|
||||
currency: tx.currency,
|
||||
description: tx.description,
|
||||
internalTransfer: tx.internalTransfer,
|
||||
vendor: tx.vendor ?? null,
|
||||
categoryId: tx.category?.id ?? null,
|
||||
creditedAccountId: tx.creditedAccount?.id ?? null,
|
||||
debitedAccountId: tx.debitedAccount?.id ?? null,
|
||||
lineItems: [...tx.lineItems],
|
||||
tags: [...tx.tags],
|
||||
attachmentsToUpload: [],
|
||||
removedAttachmentIds: [],
|
||||
existingAttachments: [...tx.attachments],
|
||||
}
|
||||
}
|
||||
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
|
||||
return [
|
||||
{
|
||||
name: 'Save',
|
||||
disabled: !this.areChangesPresent(formData) || !this.isFormDataValid(formData),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
// Assume that form data is valid!
|
||||
const data = toTransactionPayload(formData)
|
||||
const txn = await api.updateTransaction(
|
||||
this.existingTransaction.id,
|
||||
data,
|
||||
formData.attachmentsToUpload,
|
||||
)
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
disabled: false,
|
||||
callback: async (_formData, route, router) => {
|
||||
await goBackOrHome(router, route)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor context for when the user is editing an existing draft.
|
||||
*/
|
||||
export class DraftEditorContext implements TransactionEditorContextBase {
|
||||
private existingDraft: TransactionDraftResponse
|
||||
|
||||
constructor(existingDraft: TransactionDraftResponse) {
|
||||
this.existingDraft = existingDraft
|
||||
}
|
||||
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean {
|
||||
const result =
|
||||
(formData.amount === null || formData.currency !== null) &&
|
||||
(formData.amount === null || formData.amount > 0) &&
|
||||
(formData.creditedAccountId === null ||
|
||||
formData.debitedAccountId === null ||
|
||||
formData.creditedAccountId !== formData.debitedAccountId) &&
|
||||
(formData.templateName === null || formData.templateName.length <= 32)
|
||||
console.log('Checking draft editor valid:', formData, result)
|
||||
return result
|
||||
}
|
||||
|
||||
areChangesPresent(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
initializeFormFields(): TransactionEditorFormFields {
|
||||
const d = this.existingDraft
|
||||
const fields = defaultEmptyFormFields()
|
||||
if (d.templateName !== null && d.templateName.length > 0) {
|
||||
fields.templateName = d.templateName
|
||||
}
|
||||
if (d.timestamp !== null) {
|
||||
fields.timestamp = getLocalDateTimeStringFromUTCTimestamp(d.timestamp)
|
||||
}
|
||||
if (d.amount !== null && d.currency !== null) {
|
||||
fields.currency = d.currency
|
||||
fields.amount = integerMoneyToFloat(d.amount, d.currency)
|
||||
}
|
||||
fields.description = d.description
|
||||
fields.internalTransfer = d.internalTransfer
|
||||
if (d.vendor !== null) {
|
||||
fields.vendor = {
|
||||
id: d.vendor.id,
|
||||
name: d.vendor.name,
|
||||
description: '',
|
||||
}
|
||||
// TODO: Update TransactionDraftResponse format to include full vendor data.
|
||||
}
|
||||
if (d.category !== null) {
|
||||
fields.categoryId = d.category.id
|
||||
}
|
||||
if (d.creditedAccount !== null) {
|
||||
fields.creditedAccountId = d.creditedAccount.id
|
||||
}
|
||||
if (d.debitedAccount !== null) {
|
||||
fields.debitedAccountId = d.debitedAccount.id
|
||||
}
|
||||
fields.lineItems = [...d.lineItems]
|
||||
fields.tags = [...d.tags]
|
||||
fields.existingAttachments = [...d.attachments]
|
||||
return fields
|
||||
}
|
||||
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
|
||||
return [
|
||||
{
|
||||
name: 'Save Draft',
|
||||
disabled: !this.areChangesPresent() || !this.isFormDataValid(formData),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
const data = toDraftPayload(formData)
|
||||
const draft = await api.updateDraft(
|
||||
this.existingDraft.id,
|
||||
data,
|
||||
formData.attachmentsToUpload,
|
||||
)
|
||||
await router.replace(
|
||||
`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Submit Transaction',
|
||||
disabled: !this.areChangesPresent() || !isFormDataValidForTransactionSubmission(formData),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
// First call the normal "Save" callback from the NewTransactionEditorContext.
|
||||
const tec = new NewTransactionEditorContext()
|
||||
const saveTxnAction = tec.getActions(formData).find((a) => a.name === 'Save')!
|
||||
await saveTxnAction.callback(formData, route, router)
|
||||
// Then if this is not a template draft, delete it.
|
||||
if (formData.templateName === null || formData.templateName.length === 0) {
|
||||
await api.deleteDraft(this.existingDraft.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
disabled: false,
|
||||
callback: async (_formData, route, router) => {
|
||||
await goBackOrHome(router, route)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions below here!
|
||||
|
||||
/**
|
||||
* Obtains an editor context by determining what the user is doing based on the
|
||||
* route they've navigated to.
|
||||
* @param route The current route (which tells us what intent the user has).
|
||||
* @returns A promise that resolves to an editor context.
|
||||
*/
|
||||
export async function loadEditorContextFromRoute(
|
||||
route: RouteLocation,
|
||||
): Promise<TransactionEditorContextBase> {
|
||||
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
||||
if (route.name === 'edit-transaction') {
|
||||
const transactionIdStr = route.params.id
|
||||
if (transactionIdStr && typeof transactionIdStr === 'string') {
|
||||
const transactionId = parseInt(transactionIdStr)
|
||||
const existingTransaction = await transactionApi.getTransaction(transactionId)
|
||||
return new TransactionEditorContext(existingTransaction)
|
||||
}
|
||||
} else if (route.name === 'edit-draft') {
|
||||
const draftIdStr = route.params.id
|
||||
if (draftIdStr && typeof draftIdStr === 'string') {
|
||||
const draftId = parseInt(draftIdStr)
|
||||
const existingDraft = await transactionApi.getDraft(draftId)
|
||||
return new DraftEditorContext(existingDraft)
|
||||
}
|
||||
}
|
||||
|
||||
return new NewTransactionEditorContext()
|
||||
}
|
||||
|
||||
function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
date.setMilliseconds(0)
|
||||
const timezoneOffset = new Date().getTimezoneOffset() * 60_000
|
||||
return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, -1)
|
||||
}
|
||||
|
||||
function toDraftPayload(formData: TransactionEditorFormFields): TransactionDraftPayload {
|
||||
if (typeof formData.amount === 'string') {
|
||||
formData.amount = null
|
||||
}
|
||||
if (formData.amount !== null && formData.currency !== null) {
|
||||
formData.amount = floatMoneyToInteger(formData.amount, formData.currency)
|
||||
} else {
|
||||
formData.amount = null
|
||||
formData.currency = null
|
||||
}
|
||||
let isoTimestamp = null
|
||||
if (formData.timestamp !== null && formData.timestamp.length > 0) {
|
||||
isoTimestamp = new Date(formData.timestamp).toISOString()
|
||||
}
|
||||
return {
|
||||
templateName: formData.templateName,
|
||||
timestamp: isoTimestamp,
|
||||
amount: formData.amount,
|
||||
currencyCode: formData.currency?.code ?? null,
|
||||
description: formData.description,
|
||||
internalTransfer: formData.internalTransfer,
|
||||
vendorId: formData.vendor?.id ?? null,
|
||||
categoryId: formData.categoryId,
|
||||
creditedAccountId: formData.creditedAccountId,
|
||||
debitedAccountId: formData.debitedAccountId,
|
||||
tags: [...formData.tags],
|
||||
lineItems: [...formData.lineItems].map((li) => {
|
||||
return {
|
||||
valuePerItem: li.valuePerItem,
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
categoryId: li.category?.id ?? null,
|
||||
}
|
||||
}),
|
||||
attachmentIdsToRemove: [...formData.removedAttachmentIds],
|
||||
}
|
||||
}
|
||||
|
||||
function toTransactionPayload(formData: TransactionEditorFormFields): AddTransactionPayload {
|
||||
const payload: AddTransactionPayload = {
|
||||
timestamp: new Date(formData.timestamp!).toISOString(),
|
||||
amount: floatMoneyToInteger(formData.amount!, formData.currency!),
|
||||
currencyCode: formData.currency!.code,
|
||||
description: formData.description ?? '',
|
||||
internalTransfer: formData.internalTransfer ?? false,
|
||||
vendorId: formData.vendor?.id ?? null,
|
||||
categoryId: formData.categoryId,
|
||||
creditedAccountId: formData.creditedAccountId,
|
||||
debitedAccountId: formData.debitedAccountId,
|
||||
tags: formData.tags,
|
||||
lineItems: formData.lineItems.map((li) => {
|
||||
return { ...li, categoryId: li.category?.id ?? null }
|
||||
}),
|
||||
attachmentIdsToRemove: formData.removedAttachmentIds,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function goBackOrHome(router: Router, route: RouteLocation) {
|
||||
if (window.history.length > 0) {
|
||||
await router.back()
|
||||
} else {
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isFormDataValidForTransactionSubmission(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
formData.timestamp !== null &&
|
||||
formData.timestamp.length > 0 &&
|
||||
formData.amount !== null &&
|
||||
formData.amount > 0 &&
|
||||
formData.currency !== null &&
|
||||
formData.description !== null &&
|
||||
formData.description.length > 0 &&
|
||||
(formData.creditedAccountId !== null || formData.debitedAccountId !== null) &&
|
||||
formData.creditedAccountId !== formData.debitedAccountId
|
||||
)
|
||||
}
|
||||
|
||||
function isFormDataValidForDraftSave(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
formData.amount !== null &&
|
||||
formData.amount > 0 &&
|
||||
formData.timestamp !== null &&
|
||||
formData.currency !== null
|
||||
)
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ const router = createRouter({
|
|||
},
|
||||
{
|
||||
path: 'accounts/:id/edit',
|
||||
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||
component: () => import('@/pages/EditAccountPage.vue'),
|
||||
meta: { title: 'Edit Account' },
|
||||
},
|
||||
{
|
||||
|
|
@ -54,7 +54,7 @@ const router = createRouter({
|
|||
},
|
||||
{
|
||||
path: 'add-account',
|
||||
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||
component: () => import('@/pages/EditAccountPage.vue'),
|
||||
meta: { title: 'Add Account' },
|
||||
},
|
||||
{
|
||||
|
|
@ -63,20 +63,32 @@ const router = createRouter({
|
|||
meta: { title: 'Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'transaction-drafts/:id',
|
||||
component: () => import('@/pages/TransactionDraftPage.vue'),
|
||||
meta: { title: 'Draft' },
|
||||
},
|
||||
{
|
||||
name: 'edit-transaction',
|
||||
path: 'transactions/:id/edit',
|
||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||
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',
|
||||
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||
meta: { title: 'Search Transactions' },
|
||||
},
|
||||
{
|
||||
path: 'add-transaction',
|
||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
meta: { title: 'Add Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'vendors',
|
||||
component: () => import('@/pages/VendorsPage.vue'),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue