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)]);
@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);

View File

@ -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) {

View File

@ -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);
}

View File

@ -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);
}

View File

@ -225,6 +225,10 @@ export class TransactionApiClient extends ApiClient {
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> {
return super.getJson(this.path + '/transactions/' + id)
}

View File

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