From d7630f3c15e43089920ea1320df6f9f1e6b2493b Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Wed, 18 Feb 2026 06:58:58 -0500 Subject: [PATCH] Added account export --- finnow-api/source/account/api.d | 62 ++++++++++++++++++++--- finnow-api/source/profile/api.d | 1 - finnow-api/source/util/csv.d | 62 +++++++++++++++++++++++ finnow-api/source/util/money.d | 8 +++ web-app/src/api/account.ts | 4 ++ web-app/src/pages/home/AccountsModule.vue | 36 +++++-------- 6 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 finnow-api/source/util/csv.d diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index 7320c02..4372286 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -24,7 +24,7 @@ import attachment.dto; const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong"; -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/accounts") +@GetMapping(PROFILE_PATH ~ "/accounts") void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) { import std.algorithm; import std.array; @@ -34,7 +34,7 @@ void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse res writeJsonBody(response, accounts); } -@PathMapping(HttpMethod.GET, ACCOUNT_PATH) +@GetMapping(ACCOUNT_PATH) void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); auto ds = getProfileDataSource(request); @@ -43,7 +43,7 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id))); } -@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/accounts") +@PostMapping(PROFILE_PATH ~ "/accounts") void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto ds = getProfileDataSource(request); AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request); @@ -60,7 +60,7 @@ void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id))); } -@PathMapping(HttpMethod.PUT, ACCOUNT_PATH) +@PutMapping(ACCOUNT_PATH) void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request); @@ -80,14 +80,14 @@ void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r writeJsonBody(response, AccountResponse.of(updated, getBalance(ds, updated.id))); } -@PathMapping(HttpMethod.DELETE, ACCOUNT_PATH) +@DeleteMapping(ACCOUNT_PATH) void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); auto ds = getProfileDataSource(request); ds.getAccountRepository().deleteById(accountId); } -@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/balance") +@GetMapping(ACCOUNT_PATH ~ "/balance") void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); auto ds = getProfileDataSource(request); @@ -149,6 +149,56 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons writeJsonBody(response, balances); } +@GetMapping(PROFILE_PATH ~ "/accounts/export") +void handleGetAccountsExport(ref ServerHttpRequest request, ref ServerHttpResponse response) { + import std.algorithm; + import std.array; + import std.conv : to; + import util.csv; + import streams; + auto pc = getProfileContextOrThrow(request); + auto ds = getProfileDataSource(pc); + AccountResponse[] accounts = ds.getAccountRepository().findAll() + .map!(a => AccountResponse.of(a, getBalance(ds, a.id))).array; + + response.headers.add("Content-Type", "text/csv"); + response.headers.add("Content-Disposition", "attachment; filename=accounts.csv"); + CsvStreamWriter!(OutputStream!(ubyte)*) csv = CsvStreamWriter!(OutputStream!(ubyte)*)(&response.outputStream); + csv + .append("ID") + .append("Name") + .append("Number Suffix") + .append("Currency") + .append("Type") + .append("Balance") + .append("Created At") + .append("Archived") + .append("Description") + .newLine(); + + foreach (account; accounts) { + csv + .append(account.id) + .append(account.name) + .append(account.numberSuffix) + .append(account.currency.code) + .append(account.type) + .append(account.currentBalance + .mapIfPresent!((b) { + auto money = MoneyValue(account.currency, b); + auto n = money.toFloatingPoint(); + import std.format; + return format("%." ~ account.currency.fractionalDigits.to!string ~ "f", n); + }) + .orElse("") + ) + .append(account.createdAt) + .append(account.archived) + .append(account.description) + .newLine(); + } +} + // Value records: const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); diff --git a/finnow-api/source/profile/api.d b/finnow-api/source/profile/api.d index 5c70411..38a9fef 100644 --- a/finnow-api/source/profile/api.d +++ b/finnow-api/source/profile/api.d @@ -1,7 +1,6 @@ module profile.api; import std.json; -import asdf; import handy_http_primitives; import handy_http_data.json; import handy_http_handlers.path_handler : getPathParamAs, PathMapping; diff --git a/finnow-api/source/util/csv.d b/finnow-api/source/util/csv.d new file mode 100644 index 0000000..2458e52 --- /dev/null +++ b/finnow-api/source/util/csv.d @@ -0,0 +1,62 @@ +module util.csv; + +import streams; +import handy_http_primitives : Optional; +import std.conv : to; + +/** + * A utility that allows for convenient writing of data to an output + * stream in CSV format. + */ +struct CsvStreamWriter(S) if (isByteOutputStream!S) { + S stream; + private bool atStartOfRow = true; + private StreamResult result; + + this(S outputStream) { + this.stream = outputStream; + } + + /** + * Appends a value to the current row. + * Params: + * value = The value to append. + */ + ref append(T)(T value) { + if (!atStartOfRow) { + result = stream.writeToStream([',']); + throwIfError(result); + } + string s = value.to!string(); + result = stream.writeToStream(cast(ubyte[]) s); + throwIfError(result); + atStartOfRow = false; + return this; + } + + /** + * Appends a new line, moving to the next row. + */ + ref newLine() { + result = stream.writeToStream(['\n']); + throwIfError(result); + static if (isFlushableStream!S) { + auto optionalError = stream.flushStream(); + throwIfError(optionalError); + } + atStartOfRow = true; + return this; + } + + private void throwIfError(StreamResult result) { + if (result.hasError) { + throw new Exception("Stream error: " ~ result.error.message.to!string); + } + } + + private void throwIfError(Optional!StreamError result) { + if (!result.isNull) { + throw new Exception("Stream error: " ~ result.value.message.to!string); + } + } +} \ No newline at end of file diff --git a/finnow-api/source/util/money.d b/finnow-api/source/util/money.d index 94fc7bc..f45bf5e 100644 --- a/finnow-api/source/util/money.d +++ b/finnow-api/source/util/money.d @@ -149,4 +149,12 @@ struct MoneyValue { static if (op == "-") return MoneyValue(currency, -this.value); static assert(false, "Operator " ~ op ~ " is not supported."); } + + double toFloatingPoint() const { + double factor = 1; + for (int i = 0; i < currency.fractionalDigits; i++) { + factor *= 10; + } + return value / factor; + } } \ No newline at end of file diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts index ea54543..fcc57c4 100644 --- a/web-app/src/api/account.ts +++ b/web-app/src/api/account.ts @@ -213,4 +213,8 @@ export class AccountApiClient extends ApiClient { deleteValueRecord(accountId: number, valueRecordId: number): Promise { return super.delete(this.path + '/' + accountId + '/value-records/' + valueRecordId) } + + downloadAccountsExport(): Promise { + return super.getFile(this.path + '/export') + } } diff --git a/web-app/src/pages/home/AccountsModule.vue b/web-app/src/pages/home/AccountsModule.vue index 42b8089..6ea070a 100644 --- a/web-app/src/pages/home/AccountsModule.vue +++ b/web-app/src/pages/home/AccountsModule.vue @@ -47,45 +47,37 @@ onMounted(async () => { }) .catch((err) => console.error(err)) }) + +function exportAccounts() { + const api = new AccountApiClient(route) + api.downloadAccountsExport() +}