Added account export
Build and Deploy Web App / build-and-deploy (push) Successful in 29s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m46s Details

This commit is contained in:
Andrew Lalis 2026-02-18 06:58:58 -05:00
parent 78ebbac9ca
commit d7630f3c15
6 changed files with 144 additions and 29 deletions

View File

@ -24,7 +24,7 @@ import attachment.dto;
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong"; 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) { void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import std.algorithm; import std.algorithm;
import std.array; import std.array;
@ -34,7 +34,7 @@ void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse res
writeJsonBody(response, accounts); writeJsonBody(response, accounts);
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH) @GetMapping(ACCOUNT_PATH)
void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); 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))); 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) { void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(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))); 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) { void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request); 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))); 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) { void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
ds.getAccountRepository().deleteById(accountId); ds.getAccountRepository().deleteById(accountId);
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/balance") @GetMapping(ACCOUNT_PATH ~ "/balance")
void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
@ -149,6 +149,56 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons
writeJsonBody(response, balances); 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: // Value records:
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);

View File

@ -1,7 +1,6 @@
module profile.api; module profile.api;
import std.json; import std.json;
import asdf;
import handy_http_primitives; import handy_http_primitives;
import handy_http_data.json; import handy_http_data.json;
import handy_http_handlers.path_handler : getPathParamAs, PathMapping; import handy_http_handlers.path_handler : getPathParamAs, PathMapping;

View File

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

View File

@ -149,4 +149,12 @@ struct MoneyValue {
static if (op == "-") return MoneyValue(currency, -this.value); static if (op == "-") return MoneyValue(currency, -this.value);
static assert(false, "Operator " ~ op ~ " is not supported."); 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;
}
} }

View File

@ -213,4 +213,8 @@ export class AccountApiClient extends ApiClient {
deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> { deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> {
return super.delete(this.path + '/' + accountId + '/value-records/' + valueRecordId) return super.delete(this.path + '/' + accountId + '/value-records/' + valueRecordId)
} }
downloadAccountsExport(): Promise<void> {
return super.getFile(this.path + '/export')
}
} }

View File

@ -47,45 +47,37 @@ onMounted(async () => {
}) })
.catch((err) => console.error(err)) .catch((err) => console.error(err))
}) })
function exportAccounts() {
const api = new AccountApiClient(route)
api.downloadAccountsExport()
}
</script> </script>
<template> <template>
<HomeModule title="Accounts"> <HomeModule title="Accounts">
<template v-slot:default> <template v-slot:default>
<AccountCard <AccountCard v-for="a in accounts" :account="a" :key="a.id" />
v-for="a in accounts"
:account="a"
:key="a.id"
/>
<p v-if="accounts.length === 0"> <p v-if="accounts.length === 0">
You haven't added any accounts. Add one to start tracking your finances. You haven't added any accounts. Add one to start tracking your finances.
</p> </p>
<div> <div>
<AppBadge <AppBadge v-for="bal in totalBalances" :key="bal.currency.code">
v-for="bal in totalBalances"
:key="bal.currency.code"
>
{{ bal.currency.code }} Total: {{ bal.currency.code }} Total:
<span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span> <span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span>
</AppBadge> </AppBadge>
<AppBadge <AppBadge v-for="debt in totalOwed" :key="debt.currency.code">
v-for="debt in totalOwed"
:key="debt.currency.code"
>
{{ debt.currency.code }} Debt: {{ debt.currency.code }} Debt:
<span <span class="font-mono" :class="{ 'text-negative': debt.balance > 0 }">{{ formatMoney(debt.balance,
class="font-mono" debt.currency) }}</span>
:class="{ 'text-negative': debt.balance > 0 }"
>{{ formatMoney(debt.balance, debt.currency) }}</span
>
</AppBadge> </AppBadge>
</div> </div>
</template> </template>
<template v-slot:actions> <template v-slot:actions>
<AppButton <AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)">Add Account
icon="plus" </AppButton>
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)" <AppButton icon="download" size="sm" @click="exportAccounts()">
>Add Account Export
</AppButton> </AppButton>
</template> </template>
</HomeModule> </HomeModule>