Added transactions CSV / JSON file export.
Build and Deploy Web App / build-and-deploy (push) Successful in 1m0s Details
Build and Deploy API / build-and-deploy (push) Failing after 1m32s Details

This commit is contained in:
Andrew Lalis 2026-03-14 22:26:35 -04:00
parent 1ff3ac5058
commit 9cb2d562d8
6 changed files with 182 additions and 88 deletions

View File

@ -22,7 +22,7 @@ import util.data;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("txn.timestamp", SortDir.DESC)]); 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) { void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
@ -30,7 +30,7 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, responsePage); writeJsonBody(response, responsePage);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search") @GetMapping(PROFILE_PATH ~ "/transactions/search")
void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
@ -38,7 +38,13 @@ void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpRespo
writeJsonBody(response, page); 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) { void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request)); TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
@ -47,7 +53,7 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
response.writeBodyString(jsonStr, "application/json"); response.writeBodyString(jsonStr, "application/json");
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/transactions") @PostMapping(PROFILE_PATH ~ "/transactions")
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import asdf : serializeToJson; import asdf : serializeToJson;
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request); auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
@ -57,7 +63,7 @@ void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
response.writeBodyString(jsonStr, "application/json"); 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) { void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import asdf : serializeToJson; import asdf : serializeToJson;
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -68,14 +74,14 @@ void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpRespon
response.writeBodyString(jsonStr, "application/json"); 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) { void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
ulong txnId = getTransactionIdOrThrow(request); ulong txnId = getTransactionIdOrThrow(request);
deleteTransaction(ds, txnId); deleteTransaction(ds, txnId);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags") @GetMapping(PROFILE_PATH ~ "/transaction-tags")
void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
string[] tags = ds.getTransactionTagRepository().findAll(); string[] tags = ds.getTransactionTagRepository().findAll();
@ -88,14 +94,14 @@ private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
// Vendors API // Vendors API
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors") @GetMapping(PROFILE_PATH ~ "/vendors")
void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
TransactionVendor[] vendors = getAllVendors(ds); TransactionVendor[] vendors = getAllVendors(ds);
writeJsonBody(response, vendors); 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) { void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
TransactionVendor vendor = getVendor(ds, getVendorId(request)); TransactionVendor vendor = getVendor(ds, getVendorId(request));
@ -107,7 +113,7 @@ struct VendorPayload {
string description; string description;
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/vendors") @PostMapping(PROFILE_PATH ~ "/vendors")
void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request); VendorPayload payload = readJsonBodyAs!VendorPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -115,7 +121,7 @@ void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re
writeJsonBody(response, vendor); 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) { void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request); VendorPayload payload = readJsonBodyAs!VendorPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -123,7 +129,7 @@ void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re
writeJsonBody(response, updated); 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) { void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
deleteVendor(ds, getVendorId(request)); deleteVendor(ds, getVendorId(request));
@ -172,7 +178,7 @@ struct CategoryPayload {
Nullable!ulong parentId; Nullable!ulong parentId;
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/categories") @PostMapping(PROFILE_PATH ~ "/categories")
void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); CategoryPayload payload = readJsonBodyAs!CategoryPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -180,7 +186,7 @@ void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, category); 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) { void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); CategoryPayload payload = readJsonBodyAs!CategoryPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -189,7 +195,7 @@ void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, category); 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) { void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
ulong categoryId = getCategoryId(request); ulong categoryId = getCategoryId(request);

View File

@ -1,6 +1,7 @@
module transaction.service; module transaction.service;
import handy_http_primitives; import handy_http_primitives;
import streams : isByteOutputStream;
import std.datetime; import std.datetime;
import slf4d; 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 // Vendors Services
TransactionVendor[] getAllVendors(ProfileDataSource ds) { TransactionVendor[] getAllVendors(ProfileDataSource ds) {

View File

@ -136,3 +136,47 @@ struct TimeRange {
Optional!SysTime fromTime; Optional!SysTime fromTime;
Optional!SysTime toTime; 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);
}

View File

@ -151,6 +151,7 @@ struct Page(T) {
} }
private ulong getTotalPageCount(ulong totalElements, ulong pageSize) { private ulong getTotalPageCount(ulong totalElements, ulong pageSize) {
if (pageSize == 0) return totalElements > 0 ? 1 : 0;
return totalElements / pageSize + (totalElements % pageSize > 0 ? 1 : 0); return totalElements / pageSize + (totalElements % pageSize > 0 ? 1 : 0);
} }
@ -163,4 +164,6 @@ unittest {
assert(getTotalPageCount(5, 6) == 1); assert(getTotalPageCount(5, 6) == 1);
assert(getTotalPageCount(5, 123) == 1); assert(getTotalPageCount(5, 123) == 1);
assert(getTotalPageCount(250, 100) == 3); assert(getTotalPageCount(250, 100) == 3);
assert(getTotalPageCount(25, 0) == 1);
assert(getTotalPageCount(0, 0) == 0);
} }

View File

@ -225,6 +225,10 @@ export class TransactionApiClient extends ApiClient {
return super.getJson(this.path + '/transactions/search?' + params.toString()) return super.getJson(this.path + '/transactions/search?' + params.toString())
} }
exportTransactions(params: URLSearchParams): Promise<void> {
return super.getFile(this.path + '/transactions/export?' + params.toString())
}
getTransaction(id: number): Promise<TransactionDetail> { getTransaction(id: number): Promise<TransactionDetail> {
return super.getJson(this.path + '/transactions/' + id) return super.getJson(this.path + '/transactions/' + id)
} }

View File

@ -181,6 +181,13 @@ function clearFilters() {
maxAmountFilter.value = undefined 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() { function goToHome() {
router.push(`/profiles/${getSelectedProfile(route)}`) router.push(`/profiles/${getSelectedProfile(route)}`)
} }
@ -226,83 +233,42 @@ function loadAllParamValues(key: string): string[] {
<AppPage title="Transactions"> <AppPage title="Transactions">
<AppForm> <AppForm>
<FormGroup> <FormGroup>
<FormControl <FormControl label="Search" hint="Free-form text search against description, tags, vendor, category, account.">
label="Search" <input v-model="searchQuery" type="text" placeholder="Search for transactions..." />
hint="Free-form text search against description, tags, vendor, category, account."
>
<input
v-model="searchQuery"
type="text"
placeholder="Search for transactions..."
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<div class="vueselect-control"> <div class="vueselect-control">
<h5>Tag</h5> <h5>Tag</h5>
<VueSelect <VueSelect v-model="tagFilters" :options="tagOptions" placeholder="Select tags" is-multi />
v-model="tagFilters"
:options="tagOptions"
placeholder="Select tags"
is-multi
/>
</div> </div>
<div class="vueselect-control"> <div class="vueselect-control">
<h5>Vendor</h5> <h5>Vendor</h5>
<VueSelect <VueSelect v-model="vendorFilters" :options="vendorOptions" placeholder="Select vendors" is-multi />
v-model="vendorFilters"
:options="vendorOptions"
placeholder="Select vendors"
is-multi
/>
</div> </div>
<div class="vueselect-control"> <div class="vueselect-control">
<h5>Category</h5> <h5>Category</h5>
<VueSelect <VueSelect v-model="categoryFilters" :options="categoryOptions" placeholder="Select categories" is-multi />
v-model="categoryFilters"
:options="categoryOptions"
placeholder="Select categories"
is-multi
/>
</div> </div>
<div class="vueselect-control"> <div class="vueselect-control">
<h5>Account</h5> <h5>Account</h5>
<VueSelect <VueSelect v-model="accountFilters" :options="accountOptions" placeholder="Select accounts" is-multi />
v-model="accountFilters"
:options="accountOptions"
placeholder="Select accounts"
is-multi
/>
</div> </div>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormControl label="Max Amount"> <FormControl label="Max Amount">
<input <input v-model="maxAmountFilter" type="number" min="0" step="1" />
v-model="maxAmountFilter"
type="number"
min="0"
step="1"
/>
</FormControl> </FormControl>
<FormControl label="Min Amount"> <FormControl label="Min Amount">
<input <input v-model="minAmountFilter" type="number" min="0" step="1" />
v-model="minAmountFilter"
type="number"
min="0"
step="1"
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormControl label="Sort By"> <FormControl label="Sort By">
<select v-model="selectedSort"> <select v-model="selectedSort">
<option <option v-for="sortOpt in SORT_PROPERTIES" :key="sortOpt.property" :value="sortOpt.property">
v-for="sortOpt in SORT_PROPERTIES"
:key="sortOpt.property"
:value="sortOpt.property"
>
{{ sortOpt.label }} {{ sortOpt.label }}
</option> </option>
</select> </select>
@ -315,36 +281,19 @@ function loadAllParamValues(key: string): string[] {
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<ButtonBar> <ButtonBar>
<AppButton <AppButton size="sm" icon="home" @click="goToHome()">Back to Homepage</AppButton>
size="sm" <AppButton size="sm" icon="trash" @click="clearFilters()">Clear Filters</AppButton>
icon="home" <AppButton size="sm" icon="file-export" @click="exportToFile()">Export to CSV</AppButton>
@click="goToHome()"
>Back to Homepage</AppButton
>
<AppButton
size="sm"
icon="trash"
@click="clearFilters()"
>Clear Filters</AppButton
>
</ButtonBar> </ButtonBar>
</AppForm> </AppForm>
<PaginationControls <PaginationControls :page="page" @update="(pr) => fetchPage(pr.page, pr.size)" class="align-right" />
:page="page"
@update="(pr) => fetchPage(pr.page, pr.size)"
class="align-right"
/>
<AppBadge size="sm"> <AppBadge size="sm">
{{ page.totalElements }} search {{ page.totalElements }} search
{{ page.totalElements == 1 ? 'result' : 'results' }} {{ page.totalElements == 1 ? 'result' : 'results' }}
in {{ lastFetchTime }} milliseconds in {{ lastFetchTime }} milliseconds
</AppBadge> </AppBadge>
<TransactionCard <TransactionCard v-for="txn in page.items" :key="txn.id" :tx="txn" />
v-for="txn in page.items"
:key="txn.id"
:tx="txn"
/>
</AppPage> </AppPage>
</template> </template>
<style lang="css" scoped> <style lang="css" scoped>