From 9cb2d562d804f3c62bb082a418b7e54e5c44f3f1 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 14 Mar 2026 22:26:35 -0400 Subject: [PATCH] Added transactions CSV / JSON file export. --- finnow-api/source/transaction/api.d | 36 ++++---- finnow-api/source/transaction/service.d | 88 +++++++++++++++++++ finnow-api/source/util/data.d | 44 ++++++++++ finnow-api/source/util/pagination.d | 3 + web-app/src/api/transaction.ts | 4 + web-app/src/pages/TransactionSearchPage.vue | 95 +++++---------------- 6 files changed, 182 insertions(+), 88 deletions(-) diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 8997f83..2dd4e31 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -22,7 +22,7 @@ import util.data; immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("txn.timestamp", SortDir.DESC)]); -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions") +@GetMapping(PROFILE_PATH ~ "/transactions") void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); @@ -30,7 +30,7 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, responsePage); } -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search") +@GetMapping(PROFILE_PATH ~ "/transactions/search") void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); @@ -38,7 +38,13 @@ void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpRespo writeJsonBody(response, page); } -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong") +@GetMapping(PROFILE_PATH ~ "/transactions/export") +void handleExportTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + exportTransactionsToFile(ds, request, response); +} + +@GetMapping(PROFILE_PATH ~ "/transactions/:transactionId:ulong") void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request)); @@ -47,7 +53,7 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response.writeBodyString(jsonStr, "application/json"); } -@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/transactions") +@PostMapping(PROFILE_PATH ~ "/transactions") void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { import asdf : serializeToJson; auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request); @@ -57,7 +63,7 @@ void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response.writeBodyString(jsonStr, "application/json"); } -@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong") +@PutMapping(PROFILE_PATH ~ "/transactions/:transactionId:ulong") void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { import asdf : serializeToJson; ProfileDataSource ds = getProfileDataSource(request); @@ -68,14 +74,14 @@ void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpRespon response.writeBodyString(jsonStr, "application/json"); } -@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong") +@DeleteMapping(PROFILE_PATH ~ "/transactions/:transactionId:ulong") void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); ulong txnId = getTransactionIdOrThrow(request); deleteTransaction(ds, txnId); } -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags") +@GetMapping(PROFILE_PATH ~ "/transaction-tags") void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); string[] tags = ds.getTransactionTagRepository().findAll(); @@ -88,14 +94,14 @@ private ulong getTransactionIdOrThrow(in ServerHttpRequest request) { // Vendors API -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors") +@GetMapping(PROFILE_PATH ~ "/vendors") void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionVendor[] vendors = getAllVendors(ds); writeJsonBody(response, vendors); } -@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong") +@GetMapping(PROFILE_PATH ~ "/vendors/:vendorId:ulong") void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionVendor vendor = getVendor(ds, getVendorId(request)); @@ -107,7 +113,7 @@ struct VendorPayload { string description; } -@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/vendors") +@PostMapping(PROFILE_PATH ~ "/vendors") void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { VendorPayload payload = readJsonBodyAs!VendorPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -115,7 +121,7 @@ void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re writeJsonBody(response, vendor); } -@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong") +@PutMapping(PROFILE_PATH ~ "/vendors/:vendorId:ulong") void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { VendorPayload payload = readJsonBodyAs!VendorPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -123,7 +129,7 @@ void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re writeJsonBody(response, updated); } -@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong") +@DeleteMapping(PROFILE_PATH ~ "/vendors/:vendorId:ulong") void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); deleteVendor(ds, getVendorId(request)); @@ -172,7 +178,7 @@ struct CategoryPayload { Nullable!ulong parentId; } -@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/categories") +@PostMapping(PROFILE_PATH ~ "/categories") void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -180,7 +186,7 @@ void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, category); } -@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong") +@PutMapping(PROFILE_PATH ~ "/categories/:categoryId:ulong") void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -189,7 +195,7 @@ void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, category); } -@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong") +@DeleteMapping(PROFILE_PATH ~ "/categories/:categoryId:ulong") void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); ulong categoryId = getCategoryId(request); diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 9b7f5be..693db06 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -1,6 +1,7 @@ module transaction.service; import handy_http_primitives; +import streams : isByteOutputStream; import std.datetime; import slf4d; @@ -271,6 +272,93 @@ void updateAttachments( } } +/** + * Exports transaction data to a file for download. + * Params: + * ds = The profile datasource to use. + * request = The request to read filter parameters from. + * fileFormat = The file format to write. + */ +void exportTransactionsToFile( + ProfileDataSource ds, + in ServerHttpRequest request, + ref ServerHttpResponse response +) { + Page!TransactionsListItem data = ds.getTransactionRepository() + .search(PageRequest.unpaged(), request); + ExportFileFormat fileFormat = getPreferredExportFileFormat(request); + if (fileFormat == ExportFileFormat.JSON) { + import handy_http_data : writeJsonBody; + addFileExportHeaders(response, "transactions.json", ContentTypes.APPLICATION_JSON); + writeJsonBody(response, data.items); + } else if (fileFormat == ExportFileFormat.CSV) { + addFileExportHeaders(response, "transactions.csv", ContentTypes.TEXT_CSV); + writeTransactionsCsvExport(&response.outputStream, data.items); + } else { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Invalid file export format requested. JSON or CSV are permitted." + ); + } +} + +private void writeTransactionsCsvExport(S)( + S outputStream, + in TransactionsListItem[] items +) if (isByteOutputStream!S) { + import util.csv; + import std.format : format; + import std.conv : to; + import std.string : join; + CsvStreamWriter!S csv = CsvStreamWriter!(S)(outputStream); + csv + .append("ID") + .append("Timestamp") + .append("Added to Finnow") + .append("Amount") + .append("Currency") + .append("Description") + .append("Internal Transfer") + .append("Vendor") + .append("Category") + .append("Credited Account") + .append("Credited Account Type") + .append("Credited Account Number") + .append("Debited Account") + .append("Debited Account Type") + .append("Debited Account Number") + .append("tags") + .newLine(); + + foreach (item; items) { + string tagsStr = join(item.tags, ","); + if (tagsStr.length > 0) { + tagsStr = "\"" ~ tagsStr ~ "\""; + } + csv + .append(item.id) + .append(item.timestamp) + .append(item.addedAt) + .append(format( + "%." ~ item.currency.fractionalDigits.to!string ~ "f", + MoneyValue(item.currency, item.amount).toFloatingPoint() + )) + .append(item.currency.code) + .append(item.description) + .append(item.internalTransfer) + .append(item.vendor.mapIfPresent!(v => v.name).orElse(null)) + .append(item.category.mapIfPresent!(c => c.name).orElse(null)) + .append(item.creditedAccount.mapIfPresent!(a => a.name).orElse(null)) + .append(item.creditedAccount.mapIfPresent!(a => a.type).orElse(null)) + .append(item.creditedAccount.mapIfPresent!(a => "#" ~ a.numberSuffix).orElse(null)) + .append(item.debitedAccount.mapIfPresent!(a => a.name).orElse(null)) + .append(item.debitedAccount.mapIfPresent!(a => a.type).orElse(null)) + .append(item.debitedAccount.mapIfPresent!(a => "#" ~ a.numberSuffix).orElse(null)) + .append(tagsStr) + .newLine(); + } +} + // Vendors Services TransactionVendor[] getAllVendors(ProfileDataSource ds) { diff --git a/finnow-api/source/util/data.d b/finnow-api/source/util/data.d index b5ed6a3..7b82347 100644 --- a/finnow-api/source/util/data.d +++ b/finnow-api/source/util/data.d @@ -136,3 +136,47 @@ struct TimeRange { Optional!SysTime fromTime; Optional!SysTime toTime; } + +enum ExportFileFormat { + JSON, + CSV +} + +ExportFileFormat getPreferredExportFileFormat(in ServerHttpRequest request) { + if ("Accept" in request.headers) { + string acceptStr = request.getHeaderAs!string("Accept"); + if (acceptStr !is null && acceptStr == ContentTypes.TEXT_CSV) { + return ExportFileFormat.CSV; + } + } + string fileFormatStr = request.getParamAs!string("file-format"); + if (fileFormatStr !is null && fileFormatStr == ContentTypes.TEXT_CSV) { + return ExportFileFormat.CSV; + } + return ExportFileFormat.JSON; // JSON is the fallback if CSV is not requested. +} + +void addFileExportHeaders( + ref ServerHttpResponse response, + string filename, + string contentType, + bool includeTimestampSuffix = true +) { + response.headers.add("Content-Type", contentType); + if (includeTimestampSuffix) { + import std.format : format; + import std.string : lastIndexOf; + SysTime now = Clock.currTime(UTC()); + string timeStr = format!"_%04d-%02d-%02d_%02d-%02d-%02d"( + now.year, now.month, now.day, + now.hour, now.minute, now.second + ); + ptrdiff_t dotIdx = lastIndexOf(filename, '.'); + if (dotIdx == -1) { + filename = filename ~ timeStr; + } else { + filename = filename[0..dotIdx] ~ timeStr ~ filename[dotIdx..$]; + } + } + response.headers.add("Content-Disposition", "attachment; filename=" ~ filename); +} diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d index d463f83..68af074 100644 --- a/finnow-api/source/util/pagination.d +++ b/finnow-api/source/util/pagination.d @@ -151,6 +151,7 @@ struct Page(T) { } private ulong getTotalPageCount(ulong totalElements, ulong pageSize) { + if (pageSize == 0) return totalElements > 0 ? 1 : 0; return totalElements / pageSize + (totalElements % pageSize > 0 ? 1 : 0); } @@ -163,4 +164,6 @@ unittest { assert(getTotalPageCount(5, 6) == 1); assert(getTotalPageCount(5, 123) == 1); assert(getTotalPageCount(250, 100) == 3); + assert(getTotalPageCount(25, 0) == 1); + assert(getTotalPageCount(0, 0) == 0); } diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index 6842f9d..a04eedb 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -225,6 +225,10 @@ export class TransactionApiClient extends ApiClient { return super.getJson(this.path + '/transactions/search?' + params.toString()) } + exportTransactions(params: URLSearchParams): Promise { + return super.getFile(this.path + '/transactions/export?' + params.toString()) + } + getTransaction(id: number): Promise { return super.getJson(this.path + '/transactions/' + id) } diff --git a/web-app/src/pages/TransactionSearchPage.vue b/web-app/src/pages/TransactionSearchPage.vue index 18c6c62..79f23a5 100644 --- a/web-app/src/pages/TransactionSearchPage.vue +++ b/web-app/src/pages/TransactionSearchPage.vue @@ -181,6 +181,13 @@ function clearFilters() { maxAmountFilter.value = undefined } +async function exportToFile() { + const params = buildFiltersQuery() + params.append('file-format', 'text/csv') + const api = new TransactionApiClient(getSelectedProfile(route)) + await api.exportTransactions(params) +} + function goToHome() { router.push(`/profiles/${getSelectedProfile(route)}`) } @@ -226,83 +233,42 @@ function loadAllParamValues(key: string): string[] { - - + +
Tag
- +
Vendor
- +
Category
- +
Account
- +
- + - + @@ -315,36 +281,19 @@ function loadAllParamValues(key: string): string[] { - Back to Homepage - Clear Filters + Back to Homepage + Clear Filters + Export to CSV
- + {{ page.totalElements }} search {{ page.totalElements == 1 ? 'result' : 'results' }} in {{ lastFetchTime }} milliseconds - +