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:
|
paths:
|
||||||
- 'finnow-api/**'
|
- 'finnow-api/**'
|
||||||
- '.gitea/workflows/api.yaml'
|
- '.gitea/workflows/api.yaml'
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -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:
|
paths:
|
||||||
- 'web-app/**'
|
- 'web-app/**'
|
||||||
- '.gitea/workflows/web-app.yaml'
|
- '.gitea/workflows/web-app.yaml'
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cronexp": ">=0.1.0-beta3 <0.2.0-0",
|
||||||
"d2sqlite3": "~>1.0",
|
"d2sqlite3": "~>1.0",
|
||||||
"handy-http-starter": "~>1.6",
|
"handy-http-starter": "~>1.6",
|
||||||
"jwt4d": "~>0.0.2",
|
"jwt4d": "~>0.0.2",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"asdf": "0.8.0",
|
"asdf": "0.8.0",
|
||||||
|
"cronexp": "0.1.0-beta3",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"dxml": "0.4.5",
|
"dxml": "0.4.5",
|
||||||
"handy-http-data": "1.3.0",
|
"handy-http-data": "1.3.2",
|
||||||
"handy-http-handlers": "1.3.0",
|
"handy-http-handlers": "1.3.0",
|
||||||
"handy-http-primitives": "1.8.1",
|
"handy-http-primitives": "1.10.0",
|
||||||
"handy-http-starter": "1.7.0",
|
"handy-http-starter": "1.7.1",
|
||||||
"handy-http-transport": "1.10.1",
|
"handy-http-transport": "1.10.1",
|
||||||
"handy-http-websockets": "1.2.0",
|
"handy-http-websockets": "1.2.0",
|
||||||
"jwt4d": "0.0.2",
|
"jwt4d": "0.0.2",
|
||||||
|
|
@ -15,7 +16,7 @@
|
||||||
"mir-core": "1.7.4",
|
"mir-core": "1.7.4",
|
||||||
"openssl": "3.3.4",
|
"openssl": "3.3.4",
|
||||||
"path-matcher": "1.2.0",
|
"path-matcher": "1.2.0",
|
||||||
"photon": "0.18.11",
|
"photon": "0.18.12",
|
||||||
"scheduled": "1.4.0",
|
"scheduled": "1.4.0",
|
||||||
"secured": "3.0.0",
|
"secured": "3.0.0",
|
||||||
"sharded-map": "2.7.0",
|
"sharded-map": "2.7.0",
|
||||||
|
|
|
||||||
|
|
@ -202,12 +202,12 @@ SQL",
|
||||||
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
|
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
|
||||||
return util.sqlite.findOne(
|
return util.sqlite.findOne(
|
||||||
db,
|
db,
|
||||||
"SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~
|
"SELECT vr.id, vr.type, vr.value, vr.currency, vr.timestamp FROM history_item_linked_value_record h " ~
|
||||||
"LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~
|
"LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~
|
||||||
"WHERE h.item_id = ?",
|
"WHERE h.item_id = ?",
|
||||||
(row) {
|
(row) {
|
||||||
auto obj = new AccountHistoryValueRecordItemResponse();
|
auto obj = new AccountHistoryValueRecordItemResponse();
|
||||||
obj.timestamp = item.timestamp;
|
obj.timestamp = row.peek!string(4);
|
||||||
obj.type = item.type;
|
obj.type = item.type;
|
||||||
obj.valueRecordId = row.peek!ulong(0);
|
obj.valueRecordId = row.peek!ulong(0);
|
||||||
obj.valueRecordType = row.peek!string(1);
|
obj.valueRecordType = row.peek!string(1);
|
||||||
|
|
@ -222,13 +222,14 @@ SQL",
|
||||||
private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
|
private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
|
||||||
return util.sqlite.findOne(
|
return util.sqlite.findOne(
|
||||||
db,
|
db,
|
||||||
"SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~
|
"SELECT je.type, je.amount, je.currency, tx.id, tx.description, tx.timestamp " ~
|
||||||
|
"FROM history_item_linked_journal_entry h " ~
|
||||||
"LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~
|
"LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~
|
||||||
"LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~
|
"LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~
|
||||||
"WHERE h.item_id = ?",
|
"WHERE h.item_id = ?",
|
||||||
(row) {
|
(row) {
|
||||||
auto obj = new AccountHistoryJournalEntryItemResponse();
|
auto obj = new AccountHistoryJournalEntryItemResponse();
|
||||||
obj.timestamp = item.timestamp;
|
obj.timestamp = row.peek!string(5);
|
||||||
obj.type = item.type;
|
obj.type = item.type;
|
||||||
obj.journalEntryType = row.peek!string(0);
|
obj.journalEntryType = row.peek!string(0);
|
||||||
obj.amount = row.peek!ulong(1);
|
obj.amount = row.peek!ulong(1);
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,9 @@ import account.model;
|
||||||
import attachment.data;
|
import attachment.data;
|
||||||
import attachment.dto;
|
import attachment.dto;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.data : serializeOptional;
|
|
||||||
|
|
||||||
/// The data the API provides for an Account entity.
|
/// The data the API provides for an Account entity.
|
||||||
struct AccountResponse {
|
struct AccountResponse {
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
|
|
||||||
ulong id;
|
ulong id;
|
||||||
string createdAt;
|
string createdAt;
|
||||||
bool archived;
|
bool archived;
|
||||||
|
|
@ -19,7 +16,6 @@ struct AccountResponse {
|
||||||
string name;
|
string name;
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!long currentBalance;
|
Optional!long currentBalance;
|
||||||
|
|
||||||
static AccountResponse of(in Account account, Optional!long currentBalance) {
|
static AccountResponse of(in Account account, Optional!long currentBalance) {
|
||||||
|
|
@ -37,6 +33,14 @@ struct AccountResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A limited set of account data, usually for use in other responses.
|
||||||
|
struct SimpleAccountResponse {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
string type;
|
||||||
|
string numberSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
// The data provided by a user to create a new account.
|
// The data provided by a user to create a new account.
|
||||||
struct AccountCreationPayload {
|
struct AccountCreationPayload {
|
||||||
string type;
|
string type;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ module analytics.data;
|
||||||
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional;
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
|
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.data;
|
import util.data;
|
||||||
|
|
@ -36,7 +35,6 @@ struct CategorySpendData {
|
||||||
ulong categoryId;
|
ulong categoryId;
|
||||||
string categoryName;
|
string categoryName;
|
||||||
string categoryColor;
|
string categoryColor;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!ulong parentCategoryId;
|
Optional!ulong parentCategoryId;
|
||||||
long amount;
|
long amount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import std.algorithm;
|
||||||
import std.array;
|
import std.array;
|
||||||
import std.conv;
|
import std.conv;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import asdf;
|
|
||||||
|
|
||||||
import profile.data;
|
import profile.data;
|
||||||
import profile.model;
|
import profile.model;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
publicHandler.registerHandlers!(auth.api_public);
|
publicHandler.registerHandlers!(auth.api_public);
|
||||||
|
|
||||||
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
|
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
|
||||||
// h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint);
|
publicHandler.addMapping(HttpMethod.POST, "/api/sample-data", HttpRequestHandler.of(&sampleDataEndpoint));
|
||||||
|
|
||||||
// Authenticated endpoints:
|
// Authenticated endpoints:
|
||||||
import auth.api;
|
import auth.api;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface AttachmentRepository {
|
||||||
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
|
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
|
||||||
Attachment[] findAllByTransactionId(ulong transactionId);
|
Attachment[] findAllByTransactionId(ulong transactionId);
|
||||||
Attachment[] findAllByValueRecordId(ulong valueRecordId);
|
Attachment[] findAllByValueRecordId(ulong valueRecordId);
|
||||||
|
Attachment[] findAllByTransactionDraftId(ulong draftId);
|
||||||
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
||||||
void remove(ulong id);
|
void remove(ulong id);
|
||||||
Optional!(ubyte[]) getContent(ulong id);
|
Optional!(ubyte[]) getContent(ulong id);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,13 @@ class SqliteAttachmentRepository : AttachmentRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Attachment[] findAllByTransactionDraftId(ulong draftId) {
|
||||||
|
return findAllByLinkedEntity(
|
||||||
|
"SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?",
|
||||||
|
draftId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) {
|
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ interface ProfileDataSource {
|
||||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
TransactionTagRepository getTransactionTagRepository();
|
TransactionTagRepository getTransactionTagRepository();
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
|
TransactionDraftRepository getTransactionDraftRepository();
|
||||||
|
RecurringTransactionRepository getRecurringTransactionRepository();
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository();
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
|
|
@ -93,6 +95,12 @@ version(unittest) {
|
||||||
TransactionRepository getTransactionRepository() {
|
TransactionRepository getTransactionRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
TransactionDraftRepository getTransactionDraftRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
RecurringTransactionRepository getRecurringTransactionRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
AnalyticsRepository getAnalyticsRepository() {
|
AnalyticsRepository getAnalyticsRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const SCHEMA = import("sql/schema.sql");
|
private const SCHEMA = import("sql/schema.sql");
|
||||||
private const uint SCHEMA_VERSION = 1;
|
private const uint SCHEMA_VERSION = 2;
|
||||||
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
|
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,6 +215,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
TransactionCategoryRepository transactionCategoryRepo;
|
TransactionCategoryRepository transactionCategoryRepo;
|
||||||
TransactionTagRepository transactionTagRepo;
|
TransactionTagRepository transactionTagRepo;
|
||||||
TransactionRepository transactionRepo;
|
TransactionRepository transactionRepo;
|
||||||
|
TransactionDraftRepository transactionDraftRepo;
|
||||||
|
RecurringTransactionRepository recurringTransactionRepo;
|
||||||
AnalyticsRepository analyticsRepo;
|
AnalyticsRepository analyticsRepo;
|
||||||
|
|
||||||
this(string path) {
|
this(string path) {
|
||||||
|
|
@ -297,6 +299,20 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
return transactionRepo;
|
return transactionRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TransactionDraftRepository getTransactionDraftRepository() {
|
||||||
|
if (transactionDraftRepo is null) {
|
||||||
|
transactionDraftRepo = new SqliteTransactionDraftRepository(db);
|
||||||
|
}
|
||||||
|
return transactionDraftRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionRepository getRecurringTransactionRepository() {
|
||||||
|
if (recurringTransactionRepo is null) {
|
||||||
|
recurringTransactionRepo = new SqliteRecurringTransactionRepository(db);
|
||||||
|
}
|
||||||
|
return recurringTransactionRepo;
|
||||||
|
}
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository() {
|
AnalyticsRepository getAnalyticsRepository() {
|
||||||
if (analyticsRepo is null) {
|
if (analyticsRepo is null) {
|
||||||
analyticsRepo = new SqliteAnalyticsRepository(db);
|
analyticsRepo = new SqliteAnalyticsRepository(db);
|
||||||
|
|
@ -322,7 +338,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
if (currentVersion == SCHEMA_VERSION) return;
|
if (currentVersion == SCHEMA_VERSION) return;
|
||||||
|
|
||||||
static const migrations = [
|
static const migrations = [
|
||||||
import("sql/migrations/1.sql")
|
import("sql/migrations/1.sql"),
|
||||||
|
import("sql/migrations/2.sql")
|
||||||
];
|
];
|
||||||
static if (migrations.length != SCHEMA_VERSION) {
|
static if (migrations.length != SCHEMA_VERSION) {
|
||||||
static assert(false, "Schema version doesn't match the list of defined migrations.");
|
static assert(false, "Schema version doesn't match the list of defined migrations.");
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ void startScheduledJobs() {
|
||||||
);
|
);
|
||||||
|
|
||||||
JobScheduler jobScheduler = new TaskPoolScheduler();
|
JobScheduler jobScheduler = new TaskPoolScheduler();
|
||||||
|
|
||||||
jobScheduler.addJob(() {
|
jobScheduler.addJob(() {
|
||||||
// Clear old analytics data from profiles.
|
// Clear old analytics data from profiles.
|
||||||
import profile.data;
|
import profile.data;
|
||||||
|
|
@ -38,5 +39,32 @@ void startScheduledJobs() {
|
||||||
);
|
);
|
||||||
|
|
||||||
}, analyticsSchedule);
|
}, analyticsSchedule);
|
||||||
|
|
||||||
|
// Add a scheduled job to regularly check for and create recurring transaction drafts.
|
||||||
|
jobScheduler.addJob(() {
|
||||||
|
import profile.data;
|
||||||
|
import profile.data_impl_sqlite;
|
||||||
|
import profile.model;
|
||||||
|
import transaction.dto;
|
||||||
|
import transaction.data;
|
||||||
|
import util.pagination;
|
||||||
|
import std.stdio;
|
||||||
|
import std.datetime;
|
||||||
|
import cronexp;
|
||||||
|
FileSystemProfileRepository.doForAllUserProfiles((Profile profile, ProfileRepository profileRepo) {
|
||||||
|
writefln!"Recurring transaction check: %s / %s"(profile.username, profile.name);
|
||||||
|
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||||
|
RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
|
||||||
|
Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
|
||||||
|
foreach (rt; result.items) {
|
||||||
|
writeln(rt.scheduleExpr);
|
||||||
|
DateTime now = cast(DateTime) Clock.currTime();
|
||||||
|
auto cron = CronExpr(rt.scheduleExpr);
|
||||||
|
writefln!"scheduleExpr = %s -> %s"(rt.scheduleExpr, cron.getNext(now));
|
||||||
|
// TODO: Figure out how to actually create the transactions!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, new FixedIntervalSchedule(minutes(1), Clock.currTime(UTC())));
|
||||||
|
|
||||||
jobScheduler.start();
|
jobScheduler.start();
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +185,7 @@ struct CategoryPayload {
|
||||||
string name;
|
string name;
|
||||||
string description;
|
string description;
|
||||||
string color;
|
string color;
|
||||||
Nullable!ulong parentId;
|
Optional!ulong parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(PROFILE_PATH ~ "/categories")
|
@PostMapping(PROFILE_PATH ~ "/categories")
|
||||||
|
|
@ -215,3 +215,95 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
private ulong getCategoryId(in ServerHttpRequest request) {
|
private ulong getCategoryId(in ServerHttpRequest request) {
|
||||||
return getPathParamOrThrow!ulong(request, "categoryId");
|
return getPathParamOrThrow!ulong(request, "categoryId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drafts & Templates & Recurring Transactions
|
||||||
|
|
||||||
|
immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("draft.id", SortDir.DESC)]);
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/transaction-drafts")
|
||||||
|
void handleGetDrafts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE);
|
||||||
|
Page!TransactionDraftListItem page = getDrafts(ds, pr);
|
||||||
|
writeJsonBody(response, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
|
||||||
|
void handleGetDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
TransactionDraftResponse draft = getDraft(ds, getDraftId(request));
|
||||||
|
import asdf : serializeToJson;
|
||||||
|
string jsonStr = serializeToJson(draft);
|
||||||
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(PROFILE_PATH ~ "/transaction-drafts")
|
||||||
|
void handleAddDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
auto fullPayload = parseMultipartFilesAndBody!TransactionDraftPayload(request);
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
TransactionDraftResponse draft = addDraft(ds, fullPayload.payload, fullPayload.files);
|
||||||
|
import asdf : serializeToJson;
|
||||||
|
string jsonStr = serializeToJson(draft);
|
||||||
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
|
||||||
|
void handleUpdateDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
auto fullPayload = parseMultipartFilesAndBody!TransactionDraftPayload(request);
|
||||||
|
TransactionDraftResponse draft = updateDraft(ds, getDraftId(request), fullPayload.payload, fullPayload.files);
|
||||||
|
import asdf : serializeToJson;
|
||||||
|
string jsonStr = serializeToJson(draft);
|
||||||
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
|
||||||
|
void handleDeleteDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong draftId = getDraftId(request);
|
||||||
|
deleteDraft(ds, draftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong/recurring-transactions")
|
||||||
|
void handleGetDraftRecurringTransactions(
|
||||||
|
ref ServerHttpRequest request,
|
||||||
|
ref ServerHttpResponse response
|
||||||
|
) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong draftId = getDraftId(request);
|
||||||
|
RecurringTransactionResponse[] results = getRecurringTransactionsForDraft(ds, draftId);
|
||||||
|
writeJsonBody(response, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong getDraftId(in ServerHttpRequest request) {
|
||||||
|
return getPathParamOrThrow!ulong(request, "draftId");
|
||||||
|
}
|
||||||
|
|
||||||
|
immutable DEFAULT_RECURRING_TRANSACTIONS_PAGE = PageRequest(1, 10, [Sort("id", SortDir.DESC)]);
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/recurring-transactions")
|
||||||
|
void handleGetRecurringTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
PageRequest pr = PageRequest.parse(request, DEFAULT_RECURRING_TRANSACTIONS_PAGE);
|
||||||
|
Page!RecurringTransactionResponse page = getRecurringTransactions(ds, pr);
|
||||||
|
writeJsonBody(response, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(PROFILE_PATH ~ "/recurring-transactions")
|
||||||
|
void handlePostRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
RecurringTransactionPayload payload = readJsonBodyAs!RecurringTransactionPayload(request);
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
RecurringTransactionResponse result = createRecurringTransaction(ds, payload);
|
||||||
|
writeJsonBody(response, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping(PROFILE_PATH ~ "/recurring-transactions/:recurringTransactionId:ulong")
|
||||||
|
void handleDeleteRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong id = getRecurringTransactionId(request);
|
||||||
|
deleteRecurringTransaction(ds, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong getRecurringTransactionId(in ServerHttpRequest request) {
|
||||||
|
return getPathParamOrThrow!ulong(request, "recurringTransactionId");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface TransactionCategoryRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Migrate into transaction repo, similar to drafts!
|
||||||
interface TransactionTagRepository {
|
interface TransactionTagRepository {
|
||||||
string[] findAllByTransactionId(ulong transactionId);
|
string[] findAllByTransactionId(ulong transactionId);
|
||||||
void updateTags(ulong transactionId, in string[] tags);
|
void updateTags(ulong transactionId, in string[] tags);
|
||||||
|
|
@ -52,3 +53,25 @@ interface TransactionRepository {
|
||||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TransactionDraftRepository {
|
||||||
|
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr);
|
||||||
|
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr);
|
||||||
|
Page!TransactionDraftListItem findAll(in PageRequest pr);
|
||||||
|
Optional!TransactionDraftResponse findById(ulong id);
|
||||||
|
TransactionDraftResponse insert(in TransactionDraftPayload data);
|
||||||
|
void linkAttachment(ulong draftId, ulong attachmentId);
|
||||||
|
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data);
|
||||||
|
void deleteById(ulong id);
|
||||||
|
|
||||||
|
void updateTags(ulong draftId, in string[] tags);
|
||||||
|
string[] findAllTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecurringTransactionRepository {
|
||||||
|
Page!RecurringTransactionResponse findAll(in PageRequest pr);
|
||||||
|
RecurringTransactionResponse[] findAllByDraftId(ulong draftId);
|
||||||
|
Optional!RecurringTransactionResponse findById(ulong id);
|
||||||
|
RecurringTransactionResponse insert(in RecurringTransactionPayload data);
|
||||||
|
void deleteById(ulong id);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
module transaction.data_impl_sqlite;
|
module transaction.data_impl_sqlite;
|
||||||
|
|
||||||
import handy_http_primitives : Optional, StringMultiValueMap;
|
import handy_http_primitives : Optional, StringMultiValueMap, mapIfPresent, toOptional;
|
||||||
|
import util.data;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
import d2sqlite3;
|
import d2sqlite3;
|
||||||
|
|
@ -13,6 +14,8 @@ import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import util.data;
|
import util.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
|
import account.dto;
|
||||||
|
import attachment.dto;
|
||||||
|
|
||||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||||
private Database db;
|
private Database db;
|
||||||
|
|
@ -228,7 +231,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
return TransactionCategory(
|
return TransactionCategory(
|
||||||
row.peek!ulong(0),
|
row.peek!ulong(0),
|
||||||
toOptional(row.peek!(Nullable!ulong)(1)),
|
row.parseOptional!ulong(1),
|
||||||
row.peek!string(2),
|
row.peek!string(2),
|
||||||
row.peek!string(3),
|
row.peek!string(3),
|
||||||
row.peek!string(4)
|
row.peek!string(4)
|
||||||
|
|
@ -283,12 +286,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
Page!TransactionsListItem findAll(in PageRequest pr) {
|
Page!TransactionsListItem findAll(in PageRequest pr) {
|
||||||
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql();
|
string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql();
|
||||||
QueryBuilder qb = getBuilderForTransactionsList();
|
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||||
addSelectsForTransactionsList(qb);
|
|
||||||
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
|
|
||||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
|
||||||
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
|
|
||||||
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
||||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||||
}
|
}
|
||||||
|
|
@ -328,10 +327,10 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
qb.conditions = [];
|
qb.conditions = [];
|
||||||
qb.argBinders = [];
|
qb.argBinders = [];
|
||||||
addSelectsForTransactionsList(qb);
|
addSelectsForTransactionsList(qb);
|
||||||
|
qb.groupBy("txn.id");
|
||||||
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
||||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||||
Statement stmt = db.prepare(query);
|
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||||
auto results = parseListItems(stmt.execute());
|
|
||||||
return Page!TransactionsListItem.of(results, pr, count);
|
return Page!TransactionsListItem.of(results, pr, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,7 +369,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
Optional!TransactionDetail findById(ulong id) {
|
Optional!TransactionDetail findById(ulong id) {
|
||||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||||
db,
|
db,
|
||||||
import("sql/get_transaction.sql"),
|
import("sql/query/get_transaction.sql"),
|
||||||
(row) {
|
(row) {
|
||||||
TransactionDetail item;
|
TransactionDetail item;
|
||||||
item.id = row.peek!ulong(0);
|
item.id = row.peek!ulong(0);
|
||||||
|
|
@ -383,43 +382,41 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
|
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||||
if (!vendorId.isNull) {
|
if (!vendorId.isNull) {
|
||||||
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
item.vendor = Optional!TransactionVendor.of(TransactionVendor(
|
||||||
TransactionDetail.Vendor(
|
vendorId.get,
|
||||||
vendorId.get,
|
row.peek!string(8),
|
||||||
row.peek!string(8),
|
row.peek!string(9)
|
||||||
row.peek!string(9)
|
));
|
||||||
)).toNullable;
|
|
||||||
}
|
}
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
|
||||||
if (!categoryId.isNull) {
|
if (!categoryId.isNull) {
|
||||||
item.category = Optional!(TransactionDetail.Category).of(
|
item.category = Optional!TransactionCategory.of(TransactionCategory(
|
||||||
TransactionDetail.Category(
|
categoryId.get,
|
||||||
categoryId.get,
|
row.parseOptional!ulong(11),
|
||||||
row.peek!(Nullable!ulong)(11),
|
row.peek!string(12),
|
||||||
row.peek!string(12),
|
row.peek!string(13),
|
||||||
row.peek!string(13),
|
row.peek!string(14)
|
||||||
row.peek!string(14)
|
));
|
||||||
)).toNullable;
|
|
||||||
}
|
}
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
|
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
|
||||||
if (!creditedAccountId.isNull) {
|
if (!creditedAccountId.isNull) {
|
||||||
item.creditedAccount = Optional!(TransactionDetail.Account).of(
|
item.creditedAccount = Optional!SimpleAccountResponse.of(
|
||||||
TransactionDetail.Account(
|
SimpleAccountResponse(
|
||||||
creditedAccountId.get,
|
creditedAccountId.get,
|
||||||
row.peek!string(16),
|
row.peek!string(16),
|
||||||
row.peek!string(17),
|
row.peek!string(17),
|
||||||
row.peek!string(18)
|
row.peek!string(18)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
|
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
|
||||||
if (!debitedAccountId.isNull) {
|
if (!debitedAccountId.isNull) {
|
||||||
item.debitedAccount = Optional!(TransactionDetail.Account).of(
|
item.debitedAccount = Optional!SimpleAccountResponse.of(
|
||||||
TransactionDetail.Account(
|
SimpleAccountResponse(
|
||||||
debitedAccountId.get,
|
debitedAccountId.get,
|
||||||
row.peek!string(20),
|
row.peek!string(20),
|
||||||
row.peek!string(21),
|
row.peek!string(21),
|
||||||
row.peek!string(22)
|
row.peek!string(22)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
string tagsStr = row.peek!string(23);
|
string tagsStr = row.peek!string(23);
|
||||||
if (tagsStr !is null && tagsStr.length > 0) {
|
if (tagsStr !is null && tagsStr.length > 0) {
|
||||||
|
|
@ -435,23 +432,23 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
if (item.isNull) return item;
|
if (item.isNull) return item;
|
||||||
item.value.lineItems = util.sqlite.findAll(
|
item.value.lineItems = util.sqlite.findAll(
|
||||||
db,
|
db,
|
||||||
import("sql/get_line_items.sql"),
|
import("sql/query/get_line_items.sql"),
|
||||||
(row) {
|
(row) {
|
||||||
TransactionDetail.LineItem li;
|
TransactionLineItemResponse li;
|
||||||
li.idx = row.peek!uint(0);
|
li.idx = row.peek!uint(0);
|
||||||
li.valuePerItem = row.peek!long(1);
|
li.valuePerItem = row.peek!long(1);
|
||||||
li.quantity = row.peek!ulong(2);
|
li.quantity = row.peek!ulong(2);
|
||||||
li.description = row.peek!string(3);
|
li.description = row.peek!string(3);
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4);
|
Optional!ulong categoryId = row.parseOptional!ulong(4);
|
||||||
if (!categoryId.isNull) {
|
if (categoryId) {
|
||||||
li.category = Optional!(TransactionDetail.Category).of(
|
li.category = Optional!TransactionCategory.of(
|
||||||
TransactionDetail.Category(
|
TransactionCategory(
|
||||||
categoryId.get,
|
categoryId.value,
|
||||||
row.peek!(Nullable!ulong)(5),
|
row.parseOptional!ulong(5),
|
||||||
row.peek!string(6),
|
row.peek!string(6),
|
||||||
row.peek!string(7),
|
row.peek!string(7),
|
||||||
row.peek!string(8)
|
row.peek!string(8)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
return li;
|
return li;
|
||||||
},
|
},
|
||||||
|
|
@ -470,8 +467,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
data.internalTransfer,
|
data.internalTransfer,
|
||||||
data.vendorId,
|
data.vendorId.toNullable(),
|
||||||
data.categoryId
|
data.categoryId.toNullable()
|
||||||
);
|
);
|
||||||
ulong transactionId = db.lastInsertRowid();
|
ulong transactionId = db.lastInsertRowid();
|
||||||
insertLineItems(transactionId, data);
|
insertLineItems(transactionId, data);
|
||||||
|
|
@ -496,8 +493,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
data.internalTransfer,
|
data.internalTransfer,
|
||||||
data.vendorId,
|
data.vendorId.toNullable(),
|
||||||
data.categoryId,
|
data.categoryId.toNullable(),
|
||||||
transactionId
|
transactionId
|
||||||
);
|
);
|
||||||
// Re-write all line items:
|
// Re-write all line items:
|
||||||
|
|
@ -521,97 +518,56 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static TransactionsListItem parseListItem(Row row) {
|
||||||
* Function to parse a list of transaction list items as obtained from the
|
|
||||||
* `get_transactions.sql` query. Because there are possibly multiple rows
|
|
||||||
* per transaction, we have to deal with the result range as a whole.
|
|
||||||
* Params:
|
|
||||||
* r = The result range to read.
|
|
||||||
* Returns: The list of transaction list items.
|
|
||||||
*/
|
|
||||||
private static TransactionsListItem[] parseListItems(ResultRange r) {
|
|
||||||
import std.array : appender;
|
|
||||||
auto app = appender!(TransactionsListItem[]);
|
|
||||||
TransactionsListItem item;
|
TransactionsListItem item;
|
||||||
|
item.id = row.peek!ulong(0);
|
||||||
/// Helper function that appends the current item to the list, and resets state.
|
item.timestamp = row.peek!string(1);
|
||||||
void appendItem() {
|
item.addedAt = row.peek!string(2);
|
||||||
|
item.amount = row.peek!ulong(3);
|
||||||
|
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||||
|
item.description = row.peek!string(5);
|
||||||
|
item.internalTransfer = row.peek!bool(6);
|
||||||
|
Optional!ulong vendorId = row.parseOptional!ulong(7);
|
||||||
|
if (vendorId) {
|
||||||
|
item.vendor = SimpleVendorResponse(
|
||||||
|
vendorId.value,
|
||||||
|
row.peek!string(8)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
Optional!ulong categoryId = row.parseOptional!ulong(9);
|
||||||
|
if (categoryId) {
|
||||||
|
item.category = SimpleCategoryResponse(
|
||||||
|
categoryId.value,
|
||||||
|
row.peek!string(10),
|
||||||
|
row.peek!string(11)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
|
||||||
|
if (creditedAccountId) {
|
||||||
|
item.creditedAccount = SimpleAccountResponse(
|
||||||
|
creditedAccountId.value,
|
||||||
|
row.peek!string(13),
|
||||||
|
row.peek!string(14),
|
||||||
|
row.peek!string(15)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
|
||||||
|
if (debitedAccountId) {
|
||||||
|
item.debitedAccount = SimpleAccountResponse(
|
||||||
|
debitedAccountId.value,
|
||||||
|
row.peek!string(17),
|
||||||
|
row.peek!string(18),
|
||||||
|
row.peek!string(19)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
string aggregateTags = row.peek!string(20);
|
||||||
|
if (aggregateTags !is null) {
|
||||||
|
import std.string : split;
|
||||||
import std.algorithm : sort;
|
import std.algorithm : sort;
|
||||||
|
item.tags = aggregateTags.split(",");
|
||||||
sort(item.tags);
|
sort(item.tags);
|
||||||
app ~= item;
|
|
||||||
item.id = 0;
|
|
||||||
item.tags = [];
|
|
||||||
item.vendor = Optional!(TransactionsListItem.Vendor).empty();
|
|
||||||
item.category = Optional!(TransactionsListItem.Category).empty();
|
|
||||||
item.creditedAccount = Optional!(TransactionsListItem.Account).empty();
|
|
||||||
item.debitedAccount = Optional!(TransactionsListItem.Account).empty();
|
|
||||||
}
|
}
|
||||||
|
return item;
|
||||||
size_t rowCount = 0;
|
|
||||||
foreach (Row row; r) {
|
|
||||||
rowCount++;
|
|
||||||
ulong txnId = row.peek!ulong(0);
|
|
||||||
if (item.id != txnId) {
|
|
||||||
// We're parsing a new item. First, append the current one if there is one.
|
|
||||||
if (item.id != 0) {
|
|
||||||
appendItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
item.id = txnId;
|
|
||||||
item.timestamp = row.peek!string(1);
|
|
||||||
item.addedAt = row.peek!string(2);
|
|
||||||
item.amount = row.peek!ulong(3);
|
|
||||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
|
||||||
item.description = row.peek!string(5);
|
|
||||||
item.internalTransfer = row.peek!bool(6);
|
|
||||||
// Read the nullable Vendor information.
|
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
|
||||||
if (!vendorId.isNull) {
|
|
||||||
string vendorName = row.peek!string(8);
|
|
||||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
|
||||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
|
||||||
}
|
|
||||||
// Read the nullable Category information.
|
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
|
|
||||||
if (!categoryId.isNull) {
|
|
||||||
string categoryName = row.peek!string(10);
|
|
||||||
string categoryColor = row.peek!string(11);
|
|
||||||
item.category = Optional!(TransactionsListItem.Category).of(
|
|
||||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
|
||||||
}
|
|
||||||
// Read the nullable creditedAccount.
|
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
|
|
||||||
if (!creditedAccountId.isNull) {
|
|
||||||
ulong id = creditedAccountId.get;
|
|
||||||
string name = row.peek!string(13);
|
|
||||||
string type = row.peek!string(14);
|
|
||||||
string suffix = row.peek!string(15);
|
|
||||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
|
||||||
}
|
|
||||||
// Read the nullable debitedAccount.
|
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
|
|
||||||
if (!debitedAccountId.isNull) {
|
|
||||||
ulong id = debitedAccountId.get;
|
|
||||||
string name = row.peek!string(17);
|
|
||||||
string type = row.peek!string(18);
|
|
||||||
string suffix = row.peek!string(19);
|
|
||||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read multi-row properties, like tags, to the current item.
|
|
||||||
string tag = row.peek!string(20);
|
|
||||||
if (tag !is null) {
|
|
||||||
item.tags ~= tag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If there's one last cached item, append it to the list.
|
|
||||||
if (item.id != 0) {
|
|
||||||
appendItem();
|
|
||||||
}
|
|
||||||
return app[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||||
|
|
@ -624,7 +580,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
lineItem.valuePerItem,
|
lineItem.valuePerItem,
|
||||||
lineItem.quantity,
|
lineItem.quantity,
|
||||||
lineItem.description,
|
lineItem.description,
|
||||||
lineItem.categoryId
|
lineItem.categoryId.toNullable()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +620,348 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
.select("account_debit.name")
|
.select("account_debit.name")
|
||||||
.select("account_debit.type")
|
.select("account_debit.type")
|
||||||
.select("account_debit.number_suffix")
|
.select("account_debit.number_suffix")
|
||||||
.select("tags.tag");
|
.select("GROUP_CONCAT(tags.tag)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||||
|
private Database db;
|
||||||
|
this(Database db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr) {
|
||||||
|
return findAllInternal(pr, DraftType.DRAFT.toOptional);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) {
|
||||||
|
return findAllInternal(pr, DraftType.TEMPLATE.toOptional);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page!TransactionDraftListItem findAll(in PageRequest pr) {
|
||||||
|
return findAllInternal(pr, Optional!DraftType.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static enum DraftType {
|
||||||
|
DRAFT,
|
||||||
|
TEMPLATE
|
||||||
|
}
|
||||||
|
|
||||||
|
private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, Optional!DraftType type) {
|
||||||
|
QueryBuilder qb = getBuilderForDraftsList();
|
||||||
|
addSelectsForDraftsList(qb);
|
||||||
|
qb.groupBy("draft.id");
|
||||||
|
if (type) {
|
||||||
|
if (type.value == DraftType.DRAFT) {
|
||||||
|
qb.where("template_name IS NULL");
|
||||||
|
} else {
|
||||||
|
qb.where("template_name IS NOT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string query = qb.build() ~ "\n" ~ pr.toSql();
|
||||||
|
TransactionDraftListItem[] results = util.sqlite.findAll(db, query, &parseDraftListItem);
|
||||||
|
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM transaction_draft");
|
||||||
|
return Page!(TransactionDraftListItem).of(results, pr, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!TransactionDraftResponse findById(ulong id) {
|
||||||
|
// First fetch the draft list item (contains all basic properties).
|
||||||
|
QueryBuilder qb = getBuilderForDraftsList();
|
||||||
|
addSelectsForDraftsList(qb);
|
||||||
|
qb.groupBy("draft.id");
|
||||||
|
qb.where("draft.id = ?");
|
||||||
|
string query = qb.build();
|
||||||
|
Optional!TransactionDraftListItem li = util.sqlite.findOne(db, query, &parseDraftListItem, id);
|
||||||
|
if (li.isNull) return Optional!TransactionDraftResponse.empty();
|
||||||
|
TransactionDraftListItem draft = li.value;
|
||||||
|
// Then fetch line items.
|
||||||
|
TransactionDraftResponse response;
|
||||||
|
response.id = draft.id;
|
||||||
|
response.addedAt = draft.addedAt;
|
||||||
|
response.templateName = draft.templateName;
|
||||||
|
response.timestamp = draft.timestamp;
|
||||||
|
response.amount = draft.amount;
|
||||||
|
response.currency = draft.currency;
|
||||||
|
response.description = draft.description;
|
||||||
|
response.internalTransfer = draft.internalTransfer;
|
||||||
|
response.vendor = draft.vendor;
|
||||||
|
response.category = draft.category;
|
||||||
|
response.creditedAccount = draft.creditedAccount;
|
||||||
|
response.debitedAccount = draft.debitedAccount;
|
||||||
|
response.tags = li.value.tags;
|
||||||
|
response.lineItems = util.sqlite.findAll(
|
||||||
|
db,
|
||||||
|
import("sql/query/get_line_items_draft.sql"),
|
||||||
|
(row) {
|
||||||
|
TransactionLineItemResponse item;
|
||||||
|
item.idx = row.peek!uint(0);
|
||||||
|
item.valuePerItem = row.peek!long(1);
|
||||||
|
item.quantity = row.peek!ulong(2);
|
||||||
|
item.description = row.peek!string(3);
|
||||||
|
Optional!ulong categoryId = row.parseOptional!ulong(4);
|
||||||
|
if (categoryId) {
|
||||||
|
item.category = Optional!TransactionCategory.of(
|
||||||
|
TransactionCategory(
|
||||||
|
categoryId.value,
|
||||||
|
row.parseOptional!ulong(5),
|
||||||
|
row.peek!string(6),
|
||||||
|
row.peek!string(7),
|
||||||
|
row.peek!string(8)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
draft.id
|
||||||
|
);
|
||||||
|
// Return the response, excluding attachments (they are fetched using the attachment repo).
|
||||||
|
return Optional!TransactionDraftResponse.of(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionDraftResponse insert(in TransactionDraftPayload data) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
import("sql/insert_transaction_draft.sql"),
|
||||||
|
Clock.currTime(UTC()).toISOExtString(),
|
||||||
|
data.templateName.toNullable(),
|
||||||
|
data.timestamp.toNullable(),
|
||||||
|
data.amount.toNullable(),
|
||||||
|
data.currencyCode.toNullable(),
|
||||||
|
data.description.toNullable(),
|
||||||
|
data.internalTransfer.toNullable(),
|
||||||
|
data.vendorId.toNullable(),
|
||||||
|
data.categoryId.toNullable(),
|
||||||
|
data.creditedAccountId.toNullable(),
|
||||||
|
data.debitedAccountId.toNullable()
|
||||||
|
);
|
||||||
|
ulong draftId = db.lastInsertRowid();
|
||||||
|
insertLineItems(draftId, data);
|
||||||
|
return findById(draftId).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void linkAttachment(ulong draftId, ulong attachmentId) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO transaction_draft_attachment (draft_id, attachment_id) VALUES (?, ?)",
|
||||||
|
draftId,
|
||||||
|
attachmentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
import("sql/update_transaction_draft.sql"),
|
||||||
|
data.templateName.toNullable(),
|
||||||
|
data.timestamp.toNullable(),
|
||||||
|
data.amount.toNullable(),
|
||||||
|
data.currencyCode.toNullable(),
|
||||||
|
data.description.toNullable(),
|
||||||
|
data.internalTransfer.toNullable(),
|
||||||
|
data.vendorId.toNullable(),
|
||||||
|
data.categoryId.toNullable(),
|
||||||
|
data.creditedAccountId.toNullable(),
|
||||||
|
data.debitedAccountId.toNullable(),
|
||||||
|
draftId
|
||||||
|
);
|
||||||
|
// Re-write all line items:
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM transaction_draft_line_item WHERE draft_id = ?",
|
||||||
|
draftId
|
||||||
|
);
|
||||||
|
insertLineItems(draftId, data);
|
||||||
|
return findById(draftId).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteById(ulong id) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM attachment WHERE id IN (
|
||||||
|
SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?
|
||||||
|
)",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
util.sqlite.deleteById(db, "transaction_draft", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTags(ulong draftId, in string[] tags) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM transaction_draft_tag WHERE draft_id = ?",
|
||||||
|
draftId
|
||||||
|
);
|
||||||
|
foreach (tag; tags) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO transaction_draft_tag (draft_id, tag) VALUES (?, ?)",
|
||||||
|
draftId, tag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] findAllTags() {
|
||||||
|
return util.sqlite.findAll(
|
||||||
|
db,
|
||||||
|
"SELECT DISTINCT tag FROM transaction_draft_tag ORDER BY tag",
|
||||||
|
r => r.peek!string(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private QueryBuilder getBuilderForDraftsList() {
|
||||||
|
return QueryBuilder("transaction_draft draft")
|
||||||
|
.join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id")
|
||||||
|
.join("LEFT JOIN transaction_category category ON category.id = draft.category_id")
|
||||||
|
.join("LEFT JOIN account account_credit ON account_credit.id = draft.credited_account_id")
|
||||||
|
.join("LEFT JOIN account account_debit ON account_debit.id = draft.debited_account_id")
|
||||||
|
.join("LEFT JOIN transaction_draft_tag tags ON tags.draft_id = draft.id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSelectsForDraftsList(ref QueryBuilder qb) {
|
||||||
|
qb
|
||||||
|
.select("draft.id")
|
||||||
|
.select("draft.added_at")
|
||||||
|
.select("draft.template_name")
|
||||||
|
.select("draft.timestamp")
|
||||||
|
.select("draft.amount")// 5
|
||||||
|
.select("draft.currency")
|
||||||
|
.select("draft.description")
|
||||||
|
.select("draft.internal_transfer")
|
||||||
|
.select("vendor.id")
|
||||||
|
.select("vendor.name")// 10
|
||||||
|
.select("category.id")
|
||||||
|
.select("category.name")
|
||||||
|
.select("category.color")
|
||||||
|
.select("account_credit.id")
|
||||||
|
.select("account_credit.name")// 15
|
||||||
|
.select("account_credit.type")
|
||||||
|
.select("account_credit.number_suffix")
|
||||||
|
.select("account_debit.id")
|
||||||
|
.select("account_debit.name")
|
||||||
|
.select("account_debit.type")// 20
|
||||||
|
.select("account_debit.number_suffix")
|
||||||
|
.select("group_concat(tags.tag)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TransactionDraftListItem parseDraftListItem(Row row) {
|
||||||
|
TransactionDraftListItem item;
|
||||||
|
item.id = row.peek!ulong(0);
|
||||||
|
item.addedAt = row.peek!string(1);
|
||||||
|
item.templateName = row.parseOptional!string(2);
|
||||||
|
item.timestamp = row.parseOptional!string(3);
|
||||||
|
item.amount = row.parseOptional!ulong(4);
|
||||||
|
item.currency = row.parseOptional!(string, PeekMode.slice)(5)
|
||||||
|
.mapIfPresent!(s => Currency.ofCode(s));
|
||||||
|
item.description = row.parseOptional!string(6);
|
||||||
|
item.internalTransfer = row.parseOptional!bool(7);
|
||||||
|
Optional!ulong vendorId = row.parseOptional!ulong(8);
|
||||||
|
if (vendorId) {
|
||||||
|
item.vendor = SimpleVendorResponse(
|
||||||
|
vendorId.value,
|
||||||
|
row.peek!string(9)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
Optional!ulong categoryId = row.parseOptional!ulong(10);
|
||||||
|
if (categoryId) {
|
||||||
|
item.category = SimpleCategoryResponse(
|
||||||
|
categoryId.value,
|
||||||
|
row.peek!string(11),
|
||||||
|
row.peek!string(12),
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
Optional!ulong creditedAccountId = row.parseOptional!ulong(13);
|
||||||
|
if (creditedAccountId) {
|
||||||
|
item.creditedAccount = SimpleAccountResponse(
|
||||||
|
creditedAccountId.value,
|
||||||
|
row.peek!string(14),
|
||||||
|
row.peek!string(15),
|
||||||
|
row.peek!string(16)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
Optional!ulong debitedAccountId = row.parseOptional!ulong(17);
|
||||||
|
if (debitedAccountId) {
|
||||||
|
item.debitedAccount = SimpleAccountResponse(
|
||||||
|
debitedAccountId.value,
|
||||||
|
row.peek!string(18),
|
||||||
|
row.peek!string(19),
|
||||||
|
row.peek!string(20)
|
||||||
|
).toOptional;
|
||||||
|
}
|
||||||
|
string aggregateTags = row.peek!string(21);
|
||||||
|
if (aggregateTags !is null) {
|
||||||
|
import std.string : split;
|
||||||
|
item.tags = aggregateTags.split(",");
|
||||||
|
}
|
||||||
|
import std.algorithm : sort;
|
||||||
|
sort(item.tags);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertLineItems(ulong draftId, in TransactionDraftPayload payload) {
|
||||||
|
foreach (size_t idx, lineItem; payload.lineItems) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
import("sql/insert_line_item_draft.sql"),
|
||||||
|
draftId,
|
||||||
|
idx,
|
||||||
|
lineItem.valuePerItem,
|
||||||
|
lineItem.quantity,
|
||||||
|
lineItem.description,
|
||||||
|
lineItem.categoryId.toNullable()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SqliteRecurringTransactionRepository : RecurringTransactionRepository {
|
||||||
|
private Database db;
|
||||||
|
this(Database db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
Page!RecurringTransactionResponse findAll(in PageRequest pr) {
|
||||||
|
string query = "SELECT * FROM recurring_transaction " ~ pr.toSql();
|
||||||
|
RecurringTransactionResponse[] results = util.sqlite.findAll(db, query, &parseRecurringTransaction);
|
||||||
|
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM recurring_transaction");
|
||||||
|
return Page!(RecurringTransactionResponse).of(results, pr, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse[] findAllByDraftId(ulong draftId) {
|
||||||
|
string query = "SELECT * FROM recurring_transaction WHERE draft_id = ? ORDER BY id";
|
||||||
|
return util.sqlite.findAll(db, query, &parseRecurringTransaction, draftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!RecurringTransactionResponse findById(ulong id) {
|
||||||
|
return util.sqlite.findOne(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM recurring_transaction WHERE id = ?",
|
||||||
|
&parseRecurringTransaction,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse insert(in RecurringTransactionPayload data) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO recurring_transaction " ~
|
||||||
|
"(draft_id, schedule_expr) " ~
|
||||||
|
"VALUES (?, ?)",
|
||||||
|
data.draftId,
|
||||||
|
data.scheduleExpr
|
||||||
|
);
|
||||||
|
return findById(db.lastInsertRowid()).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteById(ulong id) {
|
||||||
|
util.sqlite.deleteById(db, "recurring_transaction", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecurringTransactionResponse parseRecurringTransaction(Row row) {
|
||||||
|
return RecurringTransactionResponse(
|
||||||
|
row.peek!ulong(0),
|
||||||
|
row.peek!ulong(1),
|
||||||
|
row.peek!string(2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,36 @@
|
||||||
module transaction.dto;
|
module transaction.dto;
|
||||||
|
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional;
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
|
||||||
import transaction.model : TransactionCategory;
|
import transaction.model : TransactionCategory, TransactionVendor;
|
||||||
import attachment.dto;
|
import attachment.dto;
|
||||||
|
import account.dto;
|
||||||
import util.data;
|
import util.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
|
||||||
|
/// A simple selection of vendor data to be included in other responses.
|
||||||
|
struct SimpleVendorResponse {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple selection of category data to be included in other responses.
|
||||||
|
struct SimpleCategoryResponse {
|
||||||
|
ulong id;
|
||||||
|
string name;
|
||||||
|
string color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response data for a transaction's line item.
|
||||||
|
struct TransactionLineItemResponse {
|
||||||
|
uint idx;
|
||||||
|
long valuePerItem;
|
||||||
|
ulong quantity;
|
||||||
|
string description;
|
||||||
|
Optional!TransactionCategory category;
|
||||||
|
}
|
||||||
|
|
||||||
/// The transaction data provided when a list of transactions is requested.
|
/// The transaction data provided when a list of transactions is requested.
|
||||||
struct TransactionsListItem {
|
struct TransactionsListItem {
|
||||||
ulong id;
|
ulong id;
|
||||||
|
|
@ -18,33 +40,11 @@ struct TransactionsListItem {
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
bool internalTransfer;
|
bool internalTransfer;
|
||||||
@serdeTransformOut!serializeOptional
|
Optional!SimpleVendorResponse vendor;
|
||||||
Optional!Vendor vendor;
|
Optional!SimpleCategoryResponse category;
|
||||||
@serdeTransformOut!serializeOptional
|
Optional!SimpleAccountResponse creditedAccount;
|
||||||
Optional!Category category;
|
Optional!SimpleAccountResponse debitedAccount;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!Account creditedAccount;
|
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!Account debitedAccount;
|
|
||||||
string[] tags;
|
string[] tags;
|
||||||
|
|
||||||
static struct Account {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
string type;
|
|
||||||
string numberSuffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct Vendor {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct Category {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
string color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transaction data provided when fetching a single transaction.
|
/// Transaction data provided when fetching a single transaction.
|
||||||
|
|
@ -56,42 +56,13 @@ struct TransactionDetail {
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
bool internalTransfer;
|
bool internalTransfer;
|
||||||
Nullable!Vendor vendor;
|
Optional!TransactionVendor vendor;
|
||||||
Nullable!Category category;
|
Optional!TransactionCategory category;
|
||||||
Nullable!Account creditedAccount;
|
Optional!SimpleAccountResponse creditedAccount;
|
||||||
Nullable!Account debitedAccount;
|
Optional!SimpleAccountResponse debitedAccount;
|
||||||
string[] tags;
|
string[] tags;
|
||||||
LineItem[] lineItems;
|
TransactionLineItemResponse[] lineItems;
|
||||||
AttachmentResponse[] attachments;
|
AttachmentResponse[] attachments;
|
||||||
|
|
||||||
static struct Vendor {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
string description;
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct Category {
|
|
||||||
ulong id;
|
|
||||||
Nullable!ulong parentId;
|
|
||||||
string name;
|
|
||||||
string description;
|
|
||||||
string color;
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct LineItem {
|
|
||||||
uint idx;
|
|
||||||
long valuePerItem;
|
|
||||||
ulong quantity;
|
|
||||||
string description;
|
|
||||||
Nullable!Category category;
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct Account {
|
|
||||||
ulong id;
|
|
||||||
string name;
|
|
||||||
string type;
|
|
||||||
string numberSuffix;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data provided when a new transaction is added by a user.
|
/// Data provided when a new transaction is added by a user.
|
||||||
|
|
@ -101,26 +72,25 @@ struct AddTransactionPayload {
|
||||||
string currencyCode;
|
string currencyCode;
|
||||||
string description;
|
string description;
|
||||||
bool internalTransfer;
|
bool internalTransfer;
|
||||||
Nullable!ulong vendorId;
|
Optional!ulong vendorId;
|
||||||
Nullable!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
Nullable!ulong creditedAccountId;
|
Optional!ulong creditedAccountId;
|
||||||
Nullable!ulong debitedAccountId;
|
Optional!ulong debitedAccountId;
|
||||||
string[] tags;
|
string[] tags;
|
||||||
LineItem[] lineItems;
|
LineItemPayload[] lineItems;
|
||||||
ulong[] attachmentIdsToRemove;
|
ulong[] attachmentIdsToRemove;
|
||||||
|
|
||||||
static struct LineItem {
|
static struct LineItemPayload {
|
||||||
long valuePerItem;
|
long valuePerItem;
|
||||||
ulong quantity;
|
ulong quantity;
|
||||||
string description;
|
string description;
|
||||||
Nullable!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Structure for depicting an entire hierarchical tree structure of categories.
|
/// Structure for depicting an entire hierarchical tree structure of categories.
|
||||||
struct TransactionCategoryTree {
|
struct TransactionCategoryTree {
|
||||||
ulong id;
|
ulong id;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!ulong parentId;
|
Optional!ulong parentId;
|
||||||
string name;
|
string name;
|
||||||
string description;
|
string description;
|
||||||
|
|
@ -131,7 +101,6 @@ struct TransactionCategoryTree {
|
||||||
|
|
||||||
struct TransactionCategoryResponse {
|
struct TransactionCategoryResponse {
|
||||||
ulong id;
|
ulong id;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!ulong parentId;
|
Optional!ulong parentId;
|
||||||
string name;
|
string name;
|
||||||
string description;
|
string description;
|
||||||
|
|
@ -170,3 +139,82 @@ struct AggregateTransactionData {
|
||||||
}
|
}
|
||||||
CurrencyData[] currencies;
|
CurrencyData[] currencies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response data for drafts as they'd appear in a list, with less data.
|
||||||
|
struct TransactionDraftListItem {
|
||||||
|
ulong id;
|
||||||
|
string addedAt;
|
||||||
|
Optional!string templateName;
|
||||||
|
Optional!string timestamp;
|
||||||
|
Optional!ulong amount;
|
||||||
|
Optional!Currency currency;
|
||||||
|
Optional!string description;
|
||||||
|
Optional!bool internalTransfer;
|
||||||
|
|
||||||
|
Optional!SimpleVendorResponse vendor;
|
||||||
|
Optional!SimpleCategoryResponse category;
|
||||||
|
Optional!SimpleAccountResponse creditedAccount;
|
||||||
|
Optional!SimpleAccountResponse debitedAccount;
|
||||||
|
|
||||||
|
string[] tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data representing a draft (or template).
|
||||||
|
struct TransactionDraftResponse {
|
||||||
|
ulong id;
|
||||||
|
string addedAt;
|
||||||
|
Optional!string templateName;
|
||||||
|
Optional!string timestamp;
|
||||||
|
Optional!ulong amount;
|
||||||
|
Optional!Currency currency;
|
||||||
|
Optional!string description;
|
||||||
|
Optional!bool internalTransfer;
|
||||||
|
|
||||||
|
Optional!SimpleVendorResponse vendor;
|
||||||
|
Optional!SimpleCategoryResponse category;
|
||||||
|
Optional!SimpleAccountResponse creditedAccount;
|
||||||
|
Optional!SimpleAccountResponse debitedAccount;
|
||||||
|
|
||||||
|
string[] tags;
|
||||||
|
TransactionLineItemResponse[] lineItems;
|
||||||
|
AttachmentResponse[] attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data provided by users when creating or updating drafts.
|
||||||
|
struct TransactionDraftPayload {
|
||||||
|
Optional!string templateName;
|
||||||
|
Optional!string timestamp;
|
||||||
|
Optional!ulong amount;
|
||||||
|
Optional!string currencyCode;
|
||||||
|
Optional!string description;
|
||||||
|
Optional!bool internalTransfer;
|
||||||
|
|
||||||
|
Optional!ulong vendorId;
|
||||||
|
Optional!ulong categoryId;
|
||||||
|
Optional!ulong creditedAccountId;
|
||||||
|
Optional!ulong debitedAccountId;
|
||||||
|
|
||||||
|
string[] tags;
|
||||||
|
LineItemPayload[] lineItems;
|
||||||
|
ulong[] attachmentIdsToRemove;
|
||||||
|
|
||||||
|
static struct LineItemPayload {
|
||||||
|
long valuePerItem;
|
||||||
|
ulong quantity;
|
||||||
|
string description;
|
||||||
|
Optional!ulong categoryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring Transaction stuff:
|
||||||
|
|
||||||
|
struct RecurringTransactionPayload {
|
||||||
|
ulong draftId;
|
||||||
|
string scheduleExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecurringTransactionResponse {
|
||||||
|
ulong id;
|
||||||
|
ulong draftId;
|
||||||
|
string scheduleExpr;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import std.datetime;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
|
||||||
struct TransactionVendor {
|
struct TransactionVendor {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
immutable string name;
|
string name;
|
||||||
immutable string description;
|
string description;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionCategory {
|
struct TransactionCategory {
|
||||||
|
|
@ -20,23 +20,23 @@ struct TransactionCategory {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Transaction {
|
struct Transaction {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
/// The time at which the transaction happened.
|
/// The time at which the transaction happened.
|
||||||
immutable SysTime timestamp;
|
SysTime timestamp;
|
||||||
/// The time at which the transaction entity was saved.
|
/// The time at which the transaction entity was saved.
|
||||||
immutable SysTime addedAt;
|
SysTime addedAt;
|
||||||
immutable ulong amount;
|
ulong amount;
|
||||||
immutable Currency currency;
|
Currency currency;
|
||||||
immutable string description;
|
string description;
|
||||||
immutable Optional!ulong vendorId;
|
Optional!ulong vendorId;
|
||||||
immutable Optional!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionLineItem {
|
struct TransactionLineItem {
|
||||||
immutable ulong transactionId;
|
ulong transactionId;
|
||||||
immutable uint idx;
|
uint idx;
|
||||||
immutable long valuePerItem;
|
long valuePerItem;
|
||||||
immutable ulong quantity;
|
ulong quantity;
|
||||||
immutable string description;
|
string description;
|
||||||
immutable Optional!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,16 @@ import account.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import util.data;
|
import util.data;
|
||||||
|
import util.validation.transaction;
|
||||||
|
import util.validation.draft;
|
||||||
import attachment.data;
|
import attachment.data;
|
||||||
import attachment.dto;
|
import attachment.dto;
|
||||||
|
|
||||||
// Transactions Services
|
// Transactions Services
|
||||||
|
|
||||||
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
||||||
Page!TransactionsListItem page = ds.getTransactionRepository()
|
return ds.getTransactionRepository()
|
||||||
.findAll(pageRequest);
|
.findAll(pageRequest);
|
||||||
return page;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
|
|
@ -46,6 +47,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
|
||||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||||
|
SysTime now = Clock.currTime(UTC());
|
||||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
|
||||||
// Add the transaction:
|
// Add the transaction:
|
||||||
|
|
@ -53,20 +55,20 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
ds.doTransaction(() {
|
ds.doTransaction(() {
|
||||||
TransactionDetail txn = txnRepo.insert(payload);
|
TransactionDetail txn = txnRepo.insert(payload);
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
if (!payload.creditedAccountId.isNull) {
|
if (payload.creditedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.creditedAccountId.get,
|
payload.creditedAccountId.value,
|
||||||
txn.id,
|
txn.id,
|
||||||
txn.amount,
|
txn.amount,
|
||||||
AccountJournalEntryType.CREDIT,
|
AccountJournalEntryType.CREDIT,
|
||||||
txn.currency
|
txn.currency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!payload.debitedAccountId.isNull) {
|
if (payload.debitedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.debitedAccountId.get,
|
payload.debitedAccountId.value,
|
||||||
txn.id,
|
txn.id,
|
||||||
txn.amount,
|
txn.amount,
|
||||||
AccountJournalEntryType.DEBIT,
|
AccountJournalEntryType.DEBIT,
|
||||||
|
|
@ -75,7 +77,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
}
|
}
|
||||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||||
tagRepo.updateTags(txn.id, payload.tags);
|
tagRepo.updateTags(txn.id, payload.tags);
|
||||||
updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
|
updateAttachments(txn.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
|
||||||
txnId = txn.id;
|
txnId = txn.id;
|
||||||
});
|
});
|
||||||
return getTransaction(ds, txnId);
|
return getTransaction(ds, txnId);
|
||||||
|
|
@ -96,6 +98,7 @@ TransactionDetail updateTransaction(
|
||||||
|
|
||||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
SysTime now = Clock.currTime(UTC());
|
||||||
|
|
||||||
const TransactionDetail prev = transactionRepo.findById(transactionId)
|
const TransactionDetail prev = transactionRepo.findById(transactionId)
|
||||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
|
@ -105,7 +108,7 @@ TransactionDetail updateTransaction(
|
||||||
TransactionDetail curr = transactionRepo.update(transactionId, payload);
|
TransactionDetail curr = transactionRepo.update(transactionId, payload);
|
||||||
updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp);
|
updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp);
|
||||||
tagRepo.updateTags(transactionId, payload.tags);
|
tagRepo.updateTags(transactionId, payload.tags);
|
||||||
updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
|
updateAttachments(curr.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
|
||||||
});
|
});
|
||||||
return getTransaction(ds, transactionId);
|
return getTransaction(ds, transactionId);
|
||||||
}
|
}
|
||||||
|
|
@ -120,45 +123,45 @@ private void updateLinkedAccountJournalEntries(
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
||||||
const bool updateCreditEntry = amountOrCurrencyChanged || (
|
const bool updateCreditEntry = amountOrCurrencyChanged || (
|
||||||
(prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
(!prev.creditedAccount && payload.creditedAccountId) ||
|
||||||
(!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) ||
|
(prev.creditedAccount && !payload.creditedAccountId) ||
|
||||||
(
|
(
|
||||||
!prev.creditedAccount.isNull &&
|
prev.creditedAccount &&
|
||||||
!payload.creditedAccountId.isNull &&
|
payload.creditedAccountId &&
|
||||||
prev.creditedAccount.get.id != payload.creditedAccountId.get
|
prev.creditedAccount.value.id != payload.creditedAccountId.value
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
||||||
(prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
(!prev.debitedAccount && payload.creditedAccountId) ||
|
||||||
(!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) ||
|
(prev.debitedAccount && !payload.debitedAccountId) ||
|
||||||
(
|
(
|
||||||
!prev.debitedAccount.isNull &&
|
prev.debitedAccount &&
|
||||||
!payload.debitedAccountId.isNull &&
|
payload.debitedAccountId &&
|
||||||
prev.debitedAccount.get.id != payload.debitedAccountId.get
|
prev.debitedAccount.value.id != payload.debitedAccountId.value
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update journal entries if necessary:
|
// Update journal entries if necessary:
|
||||||
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
if (updateCreditEntry && prev.creditedAccount) {
|
||||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id);
|
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
|
||||||
}
|
}
|
||||||
if (updateCreditEntry && !payload.creditedAccountId.isNull) {
|
if (updateCreditEntry && payload.creditedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.creditedAccountId.get,
|
payload.creditedAccountId.value,
|
||||||
curr.id,
|
curr.id,
|
||||||
curr.amount,
|
curr.amount,
|
||||||
AccountJournalEntryType.CREDIT,
|
AccountJournalEntryType.CREDIT,
|
||||||
curr.currency
|
curr.currency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
if (updateDebitEntry && prev.debitedAccount) {
|
||||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id);
|
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
|
||||||
}
|
}
|
||||||
if (updateDebitEntry && !payload.debitedAccountId.isNull) {
|
if (updateDebitEntry && payload.debitedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.debitedAccountId.get,
|
payload.debitedAccountId.value,
|
||||||
curr.id,
|
curr.id,
|
||||||
curr.amount,
|
curr.amount,
|
||||||
AccountJournalEntryType.DEBIT,
|
AccountJournalEntryType.DEBIT,
|
||||||
|
|
@ -181,76 +184,6 @@ void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateTransactionPayload(
|
|
||||||
TransactionVendorRepository vendorRepo,
|
|
||||||
TransactionCategoryRepository categoryRepo,
|
|
||||||
AccountRepository accountRepo,
|
|
||||||
in AddTransactionPayload payload
|
|
||||||
) {
|
|
||||||
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!payload.creditedAccountId.isNull &&
|
|
||||||
!payload.debitedAccountId.isNull &&
|
|
||||||
payload.creditedAccountId.get == payload.debitedAccountId.get
|
|
||||||
) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
|
|
||||||
}
|
|
||||||
if (payload.amount == 0) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
|
|
||||||
}
|
|
||||||
SysTime now = Clock.currTime(UTC());
|
|
||||||
SysTime timestamp;
|
|
||||||
try {
|
|
||||||
timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
|
||||||
} catch (TimeException e) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime.");
|
|
||||||
}
|
|
||||||
if (timestamp > now) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
|
||||||
}
|
|
||||||
if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
|
|
||||||
}
|
|
||||||
if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
|
|
||||||
}
|
|
||||||
if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
|
|
||||||
}
|
|
||||||
if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
|
|
||||||
}
|
|
||||||
foreach (tag; payload.tags) {
|
|
||||||
import std.regex;
|
|
||||||
auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`);
|
|
||||||
if (!matchFirst(tag, r)) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (payload.lineItems.length > 0) {
|
|
||||||
long lineItemsTotal = 0;
|
|
||||||
foreach (lineItem; payload.lineItems) {
|
|
||||||
if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
|
|
||||||
}
|
|
||||||
if (lineItem.quantity == 0) {
|
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
|
|
||||||
}
|
|
||||||
for (ulong i = 0; i < lineItem.quantity; i++) {
|
|
||||||
lineItemsTotal += lineItem.valuePerItem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lineItemsTotal != payload.amount) {
|
|
||||||
throw new HttpStatusException(
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
"Total of all line items doesn't equal the transaction's total."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to add / remove attachments for a transaction.
|
* Helper function to add / remove attachments for a transaction.
|
||||||
*/
|
*/
|
||||||
|
|
@ -454,7 +387,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
||||||
if (repo.existsByName(payload.name)) {
|
if (repo.existsByName(payload.name)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
||||||
}
|
}
|
||||||
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
if (payload.parentId && !repo.existsById(payload.parentId.value)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||||
}
|
}
|
||||||
import std.regex;
|
import std.regex;
|
||||||
|
|
@ -463,7 +396,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
|
||||||
}
|
}
|
||||||
auto category = repo.insert(
|
auto category = repo.insert(
|
||||||
toOptional(payload.parentId),
|
payload.parentId,
|
||||||
payload.name,
|
payload.name,
|
||||||
payload.description,
|
payload.description,
|
||||||
payload.color
|
payload.color
|
||||||
|
|
@ -481,7 +414,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
||||||
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
||||||
}
|
}
|
||||||
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
if (payload.parentId && !repo.existsById(payload.parentId.value)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||||
}
|
}
|
||||||
TransactionCategory curr = repo.updateById(
|
TransactionCategory curr = repo.updateById(
|
||||||
|
|
@ -489,7 +422,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
||||||
payload.name,
|
payload.name,
|
||||||
payload.description,
|
payload.description,
|
||||||
payload.color,
|
payload.color,
|
||||||
toOptional!ulong(payload.parentId)
|
payload.parentId
|
||||||
);
|
);
|
||||||
return TransactionCategoryResponse.of(curr);
|
return TransactionCategoryResponse.of(curr);
|
||||||
}
|
}
|
||||||
|
|
@ -497,3 +430,124 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
||||||
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
||||||
ds.getTransactionCategoryRepository().deleteById(categoryId);
|
ds.getTransactionCategoryRepository().deleteById(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draft services
|
||||||
|
|
||||||
|
Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) {
|
||||||
|
return ds.getTransactionDraftRepository().findAll(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionDraftResponse getDraft(ProfileDataSource ds, ulong draftId) {
|
||||||
|
return ds.getTransactionDraftRepository()
|
||||||
|
.findById(draftId)
|
||||||
|
// Populate the list of attachments for the draft.
|
||||||
|
.mapIfPresent!((draft) {
|
||||||
|
import std.algorithm : map;
|
||||||
|
import std.array : array;
|
||||||
|
draft.attachments = ds.getAttachmentRepository()
|
||||||
|
.findAllByTransactionDraftId(draft.id)
|
||||||
|
.map!(AttachmentResponse.of)
|
||||||
|
.array;
|
||||||
|
return draft;
|
||||||
|
})
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionDraftResponse addDraft(ProfileDataSource ds, in TransactionDraftPayload payload, in MultipartFile[] files) {
|
||||||
|
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
|
||||||
|
validateDraftPayload(
|
||||||
|
ds.getTransactionVendorRepository(),
|
||||||
|
ds.getTransactionCategoryRepository(),
|
||||||
|
ds.getAccountRepository(),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
SysTime now = Clock.currTime(UTC());
|
||||||
|
|
||||||
|
ulong draftId;
|
||||||
|
ds.doTransaction(() {
|
||||||
|
TransactionDraftResponse draft = draftRepo.insert(payload);
|
||||||
|
draftRepo.updateTags(draft.id, payload.tags);
|
||||||
|
updateDraftAttachments(draft.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo);
|
||||||
|
draftId = draft.id;
|
||||||
|
});
|
||||||
|
return getDraft(ds, draftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionDraftResponse updateDraft(
|
||||||
|
ProfileDataSource ds,
|
||||||
|
ulong draftId,
|
||||||
|
in TransactionDraftPayload payload,
|
||||||
|
in MultipartFile[] files
|
||||||
|
) {
|
||||||
|
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
|
||||||
|
validateDraftPayload(
|
||||||
|
ds.getTransactionVendorRepository(),
|
||||||
|
ds.getTransactionCategoryRepository(),
|
||||||
|
ds.getAccountRepository(),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
SysTime now = Clock.currTime(UTC());
|
||||||
|
|
||||||
|
ds.doTransaction(() {
|
||||||
|
draftRepo.update(draftId, payload);
|
||||||
|
draftRepo.updateTags(draftId, payload.tags);
|
||||||
|
updateDraftAttachments(draftId, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo);
|
||||||
|
});
|
||||||
|
return getDraft(ds, draftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteDraft(ProfileDataSource ds, ulong draftId) {
|
||||||
|
TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
TransactionDraftResponse draft = draftRepo.findById(draftId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
ds.doTransaction(() {
|
||||||
|
// First delete all attachments.
|
||||||
|
foreach (a; attachmentRepo.findAllByTransactionDraftId(draft.id)) {
|
||||||
|
attachmentRepo.remove(a.id);
|
||||||
|
}
|
||||||
|
draftRepo.deleteById(draft.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDraftAttachments(
|
||||||
|
ulong draftId,
|
||||||
|
SysTime timestamp,
|
||||||
|
in ulong[] attachmentIdsToRemove,
|
||||||
|
in MultipartFile[] attachmentsToAdd,
|
||||||
|
AttachmentRepository attachmentRepo,
|
||||||
|
TransactionDraftRepository draftRepo
|
||||||
|
) {
|
||||||
|
foreach (file; attachmentsToAdd) {
|
||||||
|
ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
|
||||||
|
draftRepo.linkAttachment(draftId, attachmentId);
|
||||||
|
}
|
||||||
|
foreach (idToRemove; attachmentIdsToRemove) {
|
||||||
|
attachmentRepo.remove(idToRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring Transactions:
|
||||||
|
|
||||||
|
Page!RecurringTransactionResponse getRecurringTransactions(ProfileDataSource ds, PageRequest pr) {
|
||||||
|
return ds.getRecurringTransactionRepository().findAll(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse createRecurringTransaction(ProfileDataSource ds, in RecurringTransactionPayload payload) {
|
||||||
|
return ds.getRecurringTransactionRepository()
|
||||||
|
.insert(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteRecurringTransaction(ProfileDataSource ds, ulong id) {
|
||||||
|
ds.getRecurringTransactionRepository()
|
||||||
|
.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse[] getRecurringTransactionsForDraft(ProfileDataSource ds, ulong draftId) {
|
||||||
|
return ds.getRecurringTransactionRepository()
|
||||||
|
.findAllByDraftId(draftId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,6 @@ Nullable!T toNullable(T)(Optional!T value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto serializeOptional(T)(Optional!T value) {
|
|
||||||
if (value.isNull) {
|
|
||||||
return Nullable!T();
|
|
||||||
}
|
|
||||||
return Nullable!T(value.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) {
|
T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) {
|
||||||
import handy_http_handlers.path_handler;
|
import handy_http_handlers.path_handler;
|
||||||
import std.conv : to, ConvException;
|
import std.conv : to, ConvException;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
module util.sample_data;
|
module util.sample_data;
|
||||||
|
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import handy_http_primitives : Optional, mapIfPresent;
|
import handy_http_primitives : Optional, mapIfPresent, toOptional;
|
||||||
|
|
||||||
import auth;
|
import auth;
|
||||||
import profile;
|
import profile;
|
||||||
|
|
@ -143,10 +143,10 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
AddTransactionPayload data;
|
AddTransactionPayload data;
|
||||||
data.timestamp = timestamp.toISOExtString();
|
data.timestamp = timestamp.toISOExtString();
|
||||||
if (uniform01() < 0.7) {
|
if (uniform01() < 0.7) {
|
||||||
data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable;
|
data.vendorId = Optional!ulong.of(choice(vendors).id);
|
||||||
}
|
}
|
||||||
if (uniform01() < 0.8) {
|
if (uniform01() < 0.8) {
|
||||||
data.categoryId = Optional!ulong.of(choice(categories).id).toNullable;
|
data.categoryId = Optional!ulong.of(choice(categories).id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly choose an account to credit / debit the transaction to.
|
// Randomly choose an account to credit / debit the transaction to.
|
||||||
|
|
@ -162,11 +162,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uniform01() < 0.5) {
|
if (uniform01() < 0.5) {
|
||||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
data.creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
|
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId;
|
||||||
} else {
|
} else {
|
||||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
data.debitedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
|
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly choose some tags to add.
|
// Randomly choose some tags to add.
|
||||||
|
|
@ -185,25 +185,25 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
if (uniform01 < 0.5) {
|
if (uniform01 < 0.5) {
|
||||||
long lineItemTotal = 0;
|
long lineItemTotal = 0;
|
||||||
foreach (n; 1..uniform(1, 20)) {
|
foreach (n; 1..uniform(1, 20)) {
|
||||||
AddTransactionPayload.LineItem item;
|
AddTransactionPayload.LineItemPayload item;
|
||||||
item.valuePerItem = uniform(1, 10_000);
|
item.valuePerItem = uniform(1, 10_000);
|
||||||
item.quantity = uniform(1, 5);
|
item.quantity = uniform(1, 5);
|
||||||
lineItemTotal += item.quantity * item.valuePerItem;
|
lineItemTotal += item.quantity * item.valuePerItem;
|
||||||
item.description = "Sample item " ~ n.to!string;
|
item.description = "Sample item " ~ n.to!string;
|
||||||
if (uniform01 < 0.5) {
|
if (uniform01 < 0.5) {
|
||||||
TransactionCategory category = choice(categories);
|
TransactionCategory category = choice(categories);
|
||||||
item.categoryId = category.id;
|
item.categoryId = Optional!ulong.of(category.id);
|
||||||
}
|
}
|
||||||
data.lineItems ~= item;
|
data.lineItems ~= item;
|
||||||
}
|
}
|
||||||
long diff = data.amount - lineItemTotal;
|
long diff = data.amount - lineItemTotal;
|
||||||
// Add one final line item that adds up to the transaction total.
|
// Add one final line item that adds up to the transaction total.
|
||||||
if (diff != 0) {
|
if (diff != 0) {
|
||||||
data.lineItems ~= AddTransactionPayload.LineItem(
|
data.lineItems ~= AddTransactionPayload.LineItemPayload(
|
||||||
diff,
|
diff,
|
||||||
1,
|
1,
|
||||||
"Last item which reconciles line items total with transaction amount.",
|
"Last item which reconciles line items total with transaction amount.",
|
||||||
Nullable!ulong.init
|
Optional!ulong.empty()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,11 +192,35 @@ immutable(ubyte[]) parseBlob(Row row, size_t idx) {
|
||||||
return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
|
return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an Optional primitive value from a result row.
|
||||||
|
* Params:
|
||||||
|
* row = The row to read from.
|
||||||
|
* idx = The column index in the row to read.
|
||||||
|
* Returns: An optional containing the data, if present.
|
||||||
|
*/
|
||||||
|
Optional!T parseOptional(T, PeekMode mode = PeekMode.copy)(Row row, size_t idx) {
|
||||||
|
import std.typecons : Nullable;
|
||||||
|
import std.traits : isSomeString;
|
||||||
|
static if (isSomeString!T) {
|
||||||
|
Nullable!T n = row.peek!(Nullable!T, mode)(idx);
|
||||||
|
} else {
|
||||||
|
Nullable!T n = row.peek!(Nullable!T)(idx);
|
||||||
|
}
|
||||||
|
if (n.isNull) return Optional!(T).empty();
|
||||||
|
static if (isSomeString!T) {
|
||||||
|
// If the string value is null, return empty as well.
|
||||||
|
if (n.get() is null) return Optional!(T).empty();
|
||||||
|
}
|
||||||
|
return Optional!(T).of(n.get());
|
||||||
|
}
|
||||||
|
|
||||||
struct QueryBuilder {
|
struct QueryBuilder {
|
||||||
string fromTable;
|
string fromTable;
|
||||||
string[] selections;
|
string[] selections;
|
||||||
string[] joins;
|
string[] joins;
|
||||||
string[] conditions;
|
string[] conditions;
|
||||||
|
string[] groupings;
|
||||||
void delegate(ref Statement, ref int)[] argBinders;
|
void delegate(ref Statement, ref int)[] argBinders;
|
||||||
|
|
||||||
this(string fromTable) {
|
this(string fromTable) {
|
||||||
|
|
@ -223,6 +247,11 @@ struct QueryBuilder {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ref groupBy(string grouping) {
|
||||||
|
groupings ~= grouping;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
string build() const {
|
string build() const {
|
||||||
import std.algorithm : map;
|
import std.algorithm : map;
|
||||||
import std.string : join;
|
import std.string : join;
|
||||||
|
|
@ -240,6 +269,10 @@ struct QueryBuilder {
|
||||||
app ~= "\nWHERE\n";
|
app ~= "\nWHERE\n";
|
||||||
app ~= conditions.map!(s => " " ~ s).join(" AND\n");
|
app ~= conditions.map!(s => " " ~ s).join(" AND\n");
|
||||||
}
|
}
|
||||||
|
if (groupings.length > 0) {
|
||||||
|
app ~= "\nGROUP BY\n";
|
||||||
|
app ~= groupings.map!(s => " " ~ s).join(",\n");
|
||||||
|
}
|
||||||
return app[];
|
return app[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.value_per_item,
|
||||||
i.quantity,
|
i.quantity,
|
||||||
i.description,
|
i.description,
|
||||||
|
|
||||||
i.category_id,
|
i.category_id,
|
||||||
category.parent_id,
|
category.parent_id,
|
||||||
category.name,
|
category.name,
|
||||||
category.description,
|
category.description,
|
||||||
category.color
|
category.color
|
||||||
|
|
||||||
FROM transaction_line_item i
|
FROM transaction_line_item i
|
||||||
LEFT JOIN transaction_category category
|
LEFT JOIN transaction_category category
|
||||||
ON category.id = i.category_id
|
ON category.id = i.category_id
|
||||||
|
|
@ -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
|
-- Basic/Utility Entities
|
||||||
|
|
||||||
|
|
@ -218,3 +223,82 @@ CREATE TABLE history_item_linked_journal_entry (
|
||||||
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
|
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Drafts / Templates / Recurring Transactions
|
||||||
|
-- Generally, draft tables are copies of their normal transaction counterparts,
|
||||||
|
-- with looser nullability constraints in some cases.
|
||||||
|
|
||||||
|
CREATE TABLE transaction_draft (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
template_name TEXT,
|
||||||
|
timestamp TEXT,
|
||||||
|
amount INTEGER,
|
||||||
|
currency TEXT,
|
||||||
|
description TEXT,
|
||||||
|
internal_transfer BOOLEAN DEFAULT FALSE,
|
||||||
|
vendor_id INTEGER,
|
||||||
|
category_id INTEGER,
|
||||||
|
credited_account_id INTEGER,
|
||||||
|
debited_account_id INTEGER,
|
||||||
|
CONSTRAINT fk_transaction_draft_vendor
|
||||||
|
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_transaction_draft_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_transaction_draft_credited_account
|
||||||
|
FOREIGN KEY (credited_account_id) REFERENCES account(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_transaction_draft_debited_account
|
||||||
|
FOREIGN KEY (debited_account_id) REFERENCES account(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||||
|
CONSTRAINT ck_transaction_amount_positive
|
||||||
|
CHECK (amount IS NULL OR amount > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transaction_draft_tag (
|
||||||
|
draft_id INTEGER NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag),
|
||||||
|
CONSTRAINT fk_transaction_draft_tag_draft
|
||||||
|
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transaction_draft_attachment (
|
||||||
|
draft_id INTEGER NOT NULL,
|
||||||
|
attachment_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (draft_id, attachment_id),
|
||||||
|
CONSTRAINT fk_transaction_draft_attachment_transaction
|
||||||
|
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_transaction_draft_attachment_attachment
|
||||||
|
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transaction_draft_line_item (
|
||||||
|
draft_id INTEGER NOT NULL,
|
||||||
|
idx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value_per_item INTEGER NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
category_id INTEGER,
|
||||||
|
CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx),
|
||||||
|
CONSTRAINT fk_transaction_draft_line_item_transaction
|
||||||
|
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_transaction_draft_line_item_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE recurring_transaction (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
draft_id INTEGER NOT NULL,
|
||||||
|
schedule_expr TEXT NOT NULL,
|
||||||
|
CONSTRAINT fk_recurring_transaction_draft
|
||||||
|
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"@idle-observer/vue3": "^0.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"cron-parser": "^5.6.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ export interface Account {
|
||||||
currentBalance: number | null
|
currentBalance: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimpleAccountResponse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
numberSuffix: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountCreationPayload {
|
export interface AccountCreationPayload {
|
||||||
type: string
|
type: string
|
||||||
numberSuffix: string
|
numberSuffix: string
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
|
import type { SimpleAccountResponse } from './account'
|
||||||
import type { Attachment } from './attachment'
|
import type { Attachment } from './attachment'
|
||||||
import { ApiClient } from './base'
|
import { ApiClient } from './base'
|
||||||
import type { Currency } from './data'
|
import type { Currency } from './data'
|
||||||
import { type Page, type PageRequest } from './pagination'
|
import { type Page, type PageRequest } from './pagination'
|
||||||
|
|
||||||
|
export interface SimpleVendorResponse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleCategoryResponse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionLineItemResponse {
|
||||||
|
idx: number
|
||||||
|
valuePerItem: number
|
||||||
|
quantity: number
|
||||||
|
description: string
|
||||||
|
category: TransactionCategory | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransactionVendor {
|
export interface TransactionVendor {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -47,10 +67,10 @@ export interface TransactionsListItem {
|
||||||
currency: Currency
|
currency: Currency
|
||||||
description: string
|
description: string
|
||||||
internalTransfer: boolean
|
internalTransfer: boolean
|
||||||
vendor: TransactionsListItemVendor | null
|
vendor: SimpleVendorResponse | null
|
||||||
category: TransactionsListItemCategory | null
|
category: SimpleCategoryResponse | null
|
||||||
creditedAccount: TransactionsListItemAccount | null
|
creditedAccount: SimpleAccountResponse | null
|
||||||
debitedAccount: TransactionsListItemAccount | null
|
debitedAccount: SimpleAccountResponse | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,28 +102,13 @@ export interface TransactionDetail {
|
||||||
internalTransfer: boolean
|
internalTransfer: boolean
|
||||||
vendor: TransactionVendor | null
|
vendor: TransactionVendor | null
|
||||||
category: TransactionCategory | null
|
category: TransactionCategory | null
|
||||||
creditedAccount: TransactionDetailAccount | null
|
creditedAccount: SimpleAccountResponse | null
|
||||||
debitedAccount: TransactionDetailAccount | null
|
debitedAccount: SimpleAccountResponse | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
lineItems: TransactionDetailLineItem[]
|
lineItems: TransactionLineItemResponse[]
|
||||||
attachments: Attachment[]
|
attachments: Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionDetailAccount {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
numberSuffix: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransactionDetailLineItem {
|
|
||||||
idx: number
|
|
||||||
valuePerItem: number
|
|
||||||
quantity: number
|
|
||||||
description: string
|
|
||||||
category: TransactionCategory | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddTransactionPayload {
|
export interface AddTransactionPayload {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
amount: number
|
amount: number
|
||||||
|
|
@ -144,6 +149,67 @@ export interface AggregateTransactionData {
|
||||||
currencies: AggregateTransactionCurrencyData[]
|
currencies: AggregateTransactionCurrencyData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionDraftListItem {
|
||||||
|
id: number
|
||||||
|
addedAt: string
|
||||||
|
templateName: string | null
|
||||||
|
timestamp: string | null
|
||||||
|
amount: number | null
|
||||||
|
currency: Currency | null
|
||||||
|
description: string | null
|
||||||
|
internalTransfer: boolean | null
|
||||||
|
vendor: SimpleVendorResponse | null
|
||||||
|
category: SimpleCategoryResponse | null
|
||||||
|
creditedAccount: SimpleAccountResponse | null
|
||||||
|
debitedAccount: SimpleAccountResponse | null
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionDraftResponse {
|
||||||
|
id: number
|
||||||
|
addedAt: string
|
||||||
|
templateName: string | null
|
||||||
|
timestamp: string | null
|
||||||
|
amount: number | null
|
||||||
|
currency: Currency | null
|
||||||
|
description: string | null
|
||||||
|
internalTransfer: boolean | null
|
||||||
|
vendor: SimpleVendorResponse | null
|
||||||
|
category: SimpleCategoryResponse | null
|
||||||
|
creditedAccount: SimpleAccountResponse | null
|
||||||
|
debitedAccount: SimpleAccountResponse | null
|
||||||
|
tags: string[]
|
||||||
|
lineItems: TransactionLineItemResponse[]
|
||||||
|
attachments: Attachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionDraftPayload {
|
||||||
|
templateName: string | null
|
||||||
|
timestamp: string | null
|
||||||
|
amount: number | null
|
||||||
|
currencyCode: string | null
|
||||||
|
description: string | null
|
||||||
|
internalTransfer: boolean | null
|
||||||
|
vendorId: number | null
|
||||||
|
categoryId: number | null
|
||||||
|
creditedAccountId: number | null
|
||||||
|
debitedAccountId: number | null
|
||||||
|
tags: string[]
|
||||||
|
lineItems: AddTransactionPayloadLineItem[]
|
||||||
|
attachmentIdsToRemove: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringTransactionPayload {
|
||||||
|
draftId: number
|
||||||
|
scheduleExpr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringTransactionResponse {
|
||||||
|
id: number
|
||||||
|
draftId: number
|
||||||
|
scheduleExpr: string
|
||||||
|
}
|
||||||
|
|
||||||
export class TransactionApiClient extends ApiClient {
|
export class TransactionApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
|
@ -277,4 +343,77 @@ export class TransactionApiClient extends ApiClient {
|
||||||
getAllTags(): Promise<string[]> {
|
getAllTags(): Promise<string[]> {
|
||||||
return super.getJson(this.path + '/transaction-tags')
|
return super.getJson(this.path + '/transaction-tags')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drafts:
|
||||||
|
|
||||||
|
getDrafts(
|
||||||
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
|
): Promise<Page<TransactionDraftListItem>> {
|
||||||
|
return super.getJsonPage(this.path + '/transaction-drafts', paginationOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTemplateDrafts(
|
||||||
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
|
): Promise<Page<TransactionDraftListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('template', 'true')
|
||||||
|
if (paginationOptions !== undefined) {
|
||||||
|
params.append('page', paginationOptions.page + '')
|
||||||
|
params.append('size', paginationOptions.size + '')
|
||||||
|
for (const sort of paginationOptions.sorts) {
|
||||||
|
params.append('sort', sort.attribute + ',' + sort.dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.getJson(this.path + '/transaction-drafts?' + params.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
getDraft(id: number): Promise<TransactionDraftResponse> {
|
||||||
|
return super.getJson(this.path + '/transaction-drafts/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
addDraft(data: TransactionDraftPayload, files: File[] = []): Promise<TransactionDraftResponse> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('payload', JSON.stringify(data))
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('file', file)
|
||||||
|
}
|
||||||
|
return super.postFormData(this.path + '/transaction-drafts', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraft(
|
||||||
|
id: number,
|
||||||
|
data: TransactionDraftPayload,
|
||||||
|
files: File[] = [],
|
||||||
|
): Promise<TransactionDraftResponse> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('payload', JSON.stringify(data))
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('file', file)
|
||||||
|
}
|
||||||
|
return super.putFormData(this.path + '/transaction-drafts/' + id, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDraft(id: number): Promise<void> {
|
||||||
|
return super.delete(this.path + '/transaction-drafts/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecurringTransactionsForDraft(draftId: number): Promise<RecurringTransactionResponse[]> {
|
||||||
|
return super.getJson(`${this.path}/transaction-drafts/${draftId}/recurring-transactions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecurringTransactions(
|
||||||
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
|
): Promise<Page<RecurringTransactionResponse>> {
|
||||||
|
return super.getJsonPage(this.path + '/recurring-transactions', paginationOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRecurringTransaction(
|
||||||
|
data: RecurringTransactionPayload,
|
||||||
|
): Promise<RecurringTransactionResponse> {
|
||||||
|
return super.postJson(this.path + '/recurring-transactions', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRecurringTransaction(id: number): Promise<void> {
|
||||||
|
return super.delete(`${this.path}/recurring-transactions/${id}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<script setup lang="ts">
|
||||||
import type { TransactionDetailLineItem } from '@/api/transaction'
|
import type { TransactionLineItemResponse } from '@/api/transaction'
|
||||||
import AppButton from './common/AppButton.vue'
|
import AppButton from './common/AppButton.vue'
|
||||||
import { formatMoney, type Currency } from '@/api/data'
|
import { formatMoney, type Currency } from '@/api/data'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
lineItem: TransactionDetailLineItem
|
lineItem: TransactionLineItemResponse
|
||||||
currency: Currency
|
currency: Currency
|
||||||
totalCount?: number
|
totalCount?: number
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ transaction. This editor shows a table of current line items, and includes a
|
||||||
modal for adding a new one.
|
modal for adding a new one.
|
||||||
-->
|
-->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction'
|
import { type TransactionCategoryTree, type TransactionLineItemResponse } from '@/api/transaction'
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||||
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'
|
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'
|
||||||
|
|
@ -15,7 +15,7 @@ import CategorySelect from './CategorySelect.vue'
|
||||||
import LineItemCard from './LineItemCard.vue'
|
import LineItemCard from './LineItemCard.vue'
|
||||||
import AppBadge from './common/AppBadge.vue'
|
import AppBadge from './common/AppBadge.vue'
|
||||||
|
|
||||||
const model = defineModel<TransactionDetailLineItem[]>({ required: true })
|
const model = defineModel<TransactionLineItemResponse[]>({ required: true })
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
transactionAmount: number
|
transactionAmount: number
|
||||||
currency: Currency
|
currency: Currency
|
||||||
|
|
|
||||||
|
|
@ -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 ProfileModule from './home/ProfileModule.vue'
|
||||||
import AccountsModule from './home/AccountsModule.vue'
|
import AccountsModule from './home/AccountsModule.vue'
|
||||||
import TransactionsModule from './home/TransactionsModule.vue'
|
import TransactionsModule from './home/TransactionsModule.vue'
|
||||||
|
import DraftsModule from './home/DraftsModule.vue'
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-module-container">
|
<div class="app-module-container">
|
||||||
<ProfileModule />
|
<ProfileModule />
|
||||||
<AccountsModule />
|
<AccountsModule />
|
||||||
<TransactionsModule />
|
<TransactionsModule />
|
||||||
|
<DraftsModule />
|
||||||
<!-- <AnalyticsModule /> -->
|
<!-- <AnalyticsModule /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
path: 'accounts/:id/edit',
|
||||||
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
component: () => import('@/pages/EditAccountPage.vue'),
|
||||||
meta: { title: 'Edit Account' },
|
meta: { title: 'Edit Account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -54,7 +54,7 @@ const router = createRouter({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'add-account',
|
path: 'add-account',
|
||||||
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
component: () => import('@/pages/EditAccountPage.vue'),
|
||||||
meta: { title: 'Add Account' },
|
meta: { title: 'Add Account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -63,20 +63,32 @@ const router = createRouter({
|
||||||
meta: { title: 'Transaction' },
|
meta: { title: 'Transaction' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
path: 'transaction-drafts/:id',
|
||||||
|
component: () => import('@/pages/TransactionDraftPage.vue'),
|
||||||
|
meta: { title: 'Draft' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'edit-transaction',
|
||||||
path: 'transactions/:id/edit',
|
path: 'transactions/:id/edit',
|
||||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||||
meta: { title: 'Edit Transaction' },
|
meta: { title: 'Edit Transaction' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'add-transaction',
|
||||||
|
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||||
|
meta: { title: 'Add Transaction' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'edit-draft',
|
||||||
|
path: 'transaction-drafts/:id/edit',
|
||||||
|
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||||
|
meta: { title: 'Edit Draft' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'transactions/search',
|
path: 'transactions/search',
|
||||||
component: () => import('@/pages/TransactionSearchPage.vue'),
|
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||||
meta: { title: 'Search Transactions' },
|
meta: { title: 'Search Transactions' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'add-transaction',
|
|
||||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
|
||||||
meta: { title: 'Add Transaction' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'vendors',
|
path: 'vendors',
|
||||||
component: () => import('@/pages/VendorsPage.vue'),
|
component: () => import('@/pages/VendorsPage.vue'),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue