taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 0f2ab3431cb55f35d37d77ee5ba84e1cdabffa57
parent c88e885096bb5bcb67e76b1bd99f07f71adf166e
Author: Florian Dold <dold@taler.net>
Date:   Fri,  4 Sep 2026 01:02:16 +0200

wallet-core: persist Donau statement totals

Store statement totals, include them in balances, and reject receipts
with invalid donation unit keys.

Diffstat:
Mpackages/taler-wallet-core/src/balance.ts | 3+--
Mpackages/taler-wallet-core/src/donau.test.ts | 20++++++++++++++++++++
Mpackages/taler-wallet-core/src/donau.ts | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
3 files changed, 77 insertions(+), 11 deletions(-)

diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -524,8 +524,7 @@ class BalancesStore { donauBaseUrl: rec.donauBaseUrl, year: rec.year, legalDomain: rec.legalDomain, - // Not yet implemented - amountStatement: undefined, + amountStatement: rec.amountStatement, }); } diff --git a/packages/taler-wallet-core/src/donau.test.ts b/packages/taler-wallet-core/src/donau.test.ts @@ -23,10 +23,29 @@ import assert from "node:assert"; import { test } from "node:test"; import { CandidateDonationUnit, + buildDonauStatementUri, generateDonauPlanchets, selectDonationUnitsExact, } from "./donau.js"; +test("Donau statement URIs preserve the service base path", () => { + assert.strictEqual( + buildDonauStatementUri( + { + donauBaseUrl: "https://donau.example/service/instance/", + year: 2026, + donorTaxId: "tax/id", + donorHashSalt: "salt value", + }, + { + total: "EUR:12.5", + donation_statement_sig: "SIGNATURE", + }, + ), + "donau://donau.example/service/instance/2026/tax%2Fid/salt%20value?total=EUR%3A12.5&sig=ED25519:SIGNATURE", + ); +}); + function candidate(value: string): CandidateDonationUnit { return { value: `EUR:${value}`, @@ -68,6 +87,7 @@ test("Donau planchet generation does not log taxpayer or blinding secrets", asyn const body = { base_url: "https://donau.example/", currency: "EUR", + legal_domain: "donau.example", donation_units: [ { donation_unit_pub: { diff --git a/packages/taler-wallet-core/src/donau.ts b/packages/taler-wallet-core/src/donau.ts @@ -46,6 +46,7 @@ import { GetDonauStatementsResponse, getRandomBytes, HashCodeString, + HttpStatusCode, j2s, Logger, NotificationType, @@ -203,28 +204,74 @@ async function fetchDonauStatements( }); const conf = succeedOrThrow(await donauClient.getConfig()); - const stmt = succeedOrThrow( - await donauClient.getDonationStatement(group.year, group.donorTaxIdHash), + const statementResult = await donauClient.getDonationStatement( + group.year, + group.donorTaxIdHash, ); - const parsedDonauUrl = new URL(group.donauBaseUrl); - const proto = parsedDonauUrl.protocol == "http:" ? "donau+http" : "donau"; - const taxIdEnc = encodeURIComponent(group.donorTaxId); - const saltEnc = encodeURIComponent(group.donorHashSalt); + if (statementResult.case === HttpStatusCode.NoContent) { + await persistDonauStatementTotal(wex, group, undefined); + continue; + } + const stmt = succeedOrThrow(statementResult); + await persistDonauStatementTotal(wex, group, stmt.total); statements.push({ donationStatementSig: stmt.donation_statement_sig, donauPub: stmt.donau_pub, total: stmt.total, year: group.year, legalDomain: conf.legal_domain, - // FIXME: Generate this using some helper - // FIXME: What about a donau not at the root path? - uri: `${proto}://${parsedDonauUrl.host}/${group.year}/${taxIdEnc}/${saltEnc}?total=${stmt.total}&sig=ED25519:${stmt.donation_statement_sig}`, + uri: buildDonauStatementUri(group, stmt), }); } return statements; } +async function persistDonauStatementTotal( + wex: WalletExecutionContext, + group: DonationReceiptGroup, + total: AmountString | undefined, +): Promise<void> { + await wex.runWalletDbTx(async (tx) => { + const summary = await tx.getDonationSummary( + group.donauBaseUrl, + group.year, + group.currency, + ); + if (!summary) { + return; + } + summary.amountStatement = total; + await tx.upsertDonationSummary(summary); + tx.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: "donau", + }); + }); +} + +export function buildDonauStatementUri( + group: Pick< + DonationReceiptGroup, + "donauBaseUrl" | "year" | "donorTaxId" | "donorHashSalt" + >, + stmt: { + total: AmountString; + donation_statement_sig: string; + }, +): string { + const base = new URL(group.donauBaseUrl); + const proto = base.protocol === "http:" ? "donau+http" : "donau"; + const prefix = base.pathname.endsWith("/") + ? base.pathname + : `${base.pathname}/`; + return `${proto}://${base.host}${prefix}${group.year}/${encodeURIComponent( + group.donorTaxId, + )}/${encodeURIComponent(group.donorHashSalt)}?total=${encodeURIComponent( + stmt.total, + )}&sig=ED25519:${stmt.donation_statement_sig}`; +} + function groupDonauReceipts( receipts: WalletDonationReceipt[], ): DonationReceiptGroup[] {