commit 0e5b7484508d8a32207014b5d9a550439eaefa6f
parent 05eba69261fdc8c3a914fe78ed1a8a05b377c6a2
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 13:44:51 +0200
wallet-core: report settled transaction amounts
Preserve the confirmed quote and expose amountEffectiveFinal only when
execution and recovery have settled. Account for consumed coins and actual
refresh or withdrawal outputs, retaining evidence across deletion and
restart. Keep ordinary refunds separate from abort recovery credits.
Issue: https://bugs.taler.net/n/7903
Diffstat:
8 files changed, 862 insertions(+), 7 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -345,10 +345,20 @@ export interface TransactionCommon {
amountRaw: AmountString;
/**
- * Amount added or removed from the wallet's balance (including all fees and other costs).
+ * Amount shown when the transaction was confirmed, including estimated fees.
+ * This amount is preserved when execution fails, expires or is aborted.
*/
amountEffective: AmountString;
+ /**
+ * Settled wallet balance effect, including fees and abort recovery.
+ * Nonnegative; the transaction type determines whether this is a debit or
+ * credit. Absent until the transaction and its recovery have settled, or
+ * when historical records cannot establish the amount. Ordinary merchant
+ * refunds remain separate credits. Associated refreshes have zero effect.
+ */
+ amountEffectiveFinal?: AmountString;
+
error?: TalerErrorDetail;
abortReason?: TalerErrorDetail;
@@ -898,6 +908,8 @@ export interface RefundPaymentInfo {
}
export interface TransactionRefund extends TransactionCommon {
+ /** Recovery already included in the unsuccessful payment's final cost. */
+ isAbortRecovery?: boolean;
type: TransactionType.Refund;
// Amount that has been refunded by the merchant
diff --git a/packages/taler-wallet-core/src/db/indexeddb/transaction.ts b/packages/taler-wallet-core/src/db/indexeddb/transaction.ts
@@ -23,6 +23,7 @@
* transaction.ts and must stay free of them.
*/
+import { updateTransactionAmounts } from "../../transaction-amounts.js";
import {
ContactEntry,
MailboxConfiguration,
@@ -334,6 +335,7 @@ export class IdbWalletTransaction implements WalletDbTransaction {
}
async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> {
+ await updateTransactionAmounts(this, rec.transactionId);
const tx = this.tx;
await tx.transactionsMeta.put({
transactionId: rec.transactionId,
diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.ts
@@ -23,6 +23,7 @@
* are not implemented yet.
*/
+import { updateTransactionAmounts } from "../../transaction-amounts.js";
import {
ResultRow,
Sqlite3Database,
@@ -2800,6 +2801,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
}
async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> {
+ await updateTransactionAmounts(this, rec.transactionId);
await this.ensureLocalTransactionIdentifier(rec.transactionId);
await this.run(
`INSERT INTO transactions_meta (
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -599,6 +599,9 @@ async function initRefreshSession(
)} too small`,
);
refreshGroup.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ refreshGroup.expectedOutputPerCoin[coinIndex] = Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ );
return;
}
@@ -1822,6 +1825,8 @@ async function refreshReveal(
return;
}
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ // Once issued, retain the actual session output for final accounting.
+ rg.expectedOutputPerCoin[coinIndex] = rs.amountRefreshOutput;
const existingCoins = await tx.getCoinsByPubs(
coins.map((coin) => coin.coinPub),
);
diff --git a/packages/taler-wallet-core/src/transaction-amounts.test.ts b/packages/taler-wallet-core/src/transaction-amounts.test.ts
@@ -0,0 +1,328 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ CoinStatus,
+ RefreshReason,
+ TransactionMajorState,
+ TransactionType,
+ type Transaction,
+ type AmountString,
+} from "@gnu-taler/taler-util";
+import {
+ PeerPushDebitStatus,
+ RefreshCoinStatus,
+ RefreshOperationStatus,
+ WithdrawalGroupStatus,
+ type WalletPeerPushDebit,
+ type WalletRefreshGroup,
+ type WalletCoin,
+ type WalletWithdrawalGroup,
+ type WalletDenomination,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import {
+ settledRefreshOutput,
+ updateTransactionAmounts,
+ withTransactionAmounts,
+} from "./transaction-amounts.js";
+
+function fixture() {
+ let record = {
+ pursePub: "purse",
+ totalCost: "TESTKUDOS:5.2",
+ status: PeerPushDebitStatus.PendingReady,
+ coinSel: { coinPubs: ["coin"], contributions: ["TESTKUDOS:5"] },
+ } as WalletPeerPushDebit;
+ const coins = new Map<string, WalletCoin>([
+ [
+ "coin",
+ {
+ coinPub: "coin",
+ denomPubHash: "10",
+ status: CoinStatus.Dormant,
+ } as WalletCoin,
+ ],
+ ]);
+ const refreshes = new Map<string, WalletRefreshGroup>();
+ const notifications: unknown[] = [];
+ const tx = {
+ async getPeerPushDebit() {
+ return structuredClone(record);
+ },
+ async upsertPeerPushDebit(r: WalletPeerPushDebit) {
+ record = structuredClone(r);
+ },
+ async getRefreshGroupsByOriginatingTransaction() {
+ return [...refreshes.values()].map((r) => structuredClone(r));
+ },
+ async getRefreshGroup(id: string) {
+ return structuredClone(refreshes.get(id));
+ },
+ async upsertRefreshGroup(r: WalletRefreshGroup) {
+ refreshes.set(r.refreshGroupId, structuredClone(r));
+ },
+ async getCoin(pub: string) {
+ return coins.get(pub);
+ },
+ async getCoinHistory() {
+ return {
+ history: [
+ { type: "spend", transactionId: "txn:peer-push-debit:purse" },
+ ],
+ };
+ },
+ async getDenomination(coin: WalletCoin) {
+ return { value: `TESTKUDOS:${coin.denomPubHash}` } as WalletDenomination;
+ },
+ notify(n: unknown) {
+ notifications.push(n);
+ },
+ } as unknown as WalletDbTransaction;
+ const detail = {
+ transactionId: "txn:peer-push-debit:purse",
+ type: TransactionType.PeerPushDebit,
+ amountRaw: "TESTKUDOS:5",
+ amountEffective: "TESTKUDOS:0",
+ txState: { major: TransactionMajorState.Aborted },
+ } as unknown as Transaction;
+ function refresh(
+ id: string,
+ output: AmountString,
+ status = RefreshOperationStatus.Finished,
+ ) {
+ const r = {
+ refreshGroupId: id,
+ currency: "TESTKUDOS",
+ originatingTransactionId: detail.transactionId,
+ reason: RefreshReason.AbortPeerPushDebit,
+ operationStatus: status,
+ oldCoinPubs: ["coin"],
+ inputPerCoin: ["TESTKUDOS:5"],
+ expectedOutputPerCoin: [output],
+ statusPerCoin: [
+ status === RefreshOperationStatus.Finished
+ ? RefreshCoinStatus.Finished
+ : RefreshCoinStatus.Pending,
+ ],
+ } as unknown as WalletRefreshGroup;
+ refreshes.set(id, r);
+ return r;
+ }
+ return {
+ tx,
+ detail,
+ coins,
+ refreshes,
+ refresh,
+ notifications,
+ get record() {
+ return record;
+ },
+ update: () => updateTransactionAmounts(tx, detail.transactionId),
+ view: () => withTransactionAmounts(tx, detail),
+ };
+}
+
+test("aborted send retains its quote and counts only settled change and recovery", async () => {
+ const f = fixture();
+ f.refresh("change", "TESTKUDOS:4.8");
+ const recovery = f.refresh(
+ "abort",
+ "TESTKUDOS:4.9",
+ RefreshOperationStatus.Pending,
+ );
+ await f.update();
+ f.record.status = PeerPushDebitStatus.Aborted;
+ await f.update();
+ assert.equal((await f.view()).amountEffective, "TESTKUDOS:5.2");
+ assert.equal((await f.view()).amountEffectiveFinal, undefined);
+ recovery.operationStatus = RefreshOperationStatus.Finished;
+ recovery.statusPerCoin = [RefreshCoinStatus.Finished];
+ await updateTransactionAmounts(f.tx, "txn:refresh:abort");
+ assert.equal((await f.view()).amountEffectiveFinal, "TESTKUDOS:0.3");
+ assert.ok(f.notifications.length);
+ // Completed evidence can disappear without changing the saved final result.
+ f.coins.clear();
+ f.refreshes.clear();
+ assert.equal((await f.view()).amountEffectiveFinal, "TESTKUDOS:0.3");
+});
+
+test("failed recovery counts partial outputs and lost change", async () => {
+ const f = fixture();
+ const r = f.refresh(
+ "recovery",
+ "TESTKUDOS:4.9",
+ RefreshOperationStatus.Failed,
+ );
+ r.statusPerCoin = [RefreshCoinStatus.Finished, RefreshCoinStatus.Failed];
+ r.expectedOutputPerCoin = ["TESTKUDOS:4.9", "TESTKUDOS:0.1"];
+ f.record.status = PeerPushDebitStatus.Failed;
+ await f.update();
+ assert.equal((await f.view()).amountEffectiveFinal, "TESTKUDOS:5.1");
+});
+
+test("suspended recovery is not settled, and reopening invalidates the final amount", async () => {
+ const f = fixture();
+ const r = f.refresh("abort", "TESTKUDOS:9.7");
+ f.record.status = PeerPushDebitStatus.Expired;
+ await f.update();
+ assert.equal((await f.view()).amountEffectiveFinal, "TESTKUDOS:0.3");
+ r.operationStatus = RefreshOperationStatus.Suspended;
+ await updateTransactionAmounts(f.tx, "txn:refresh:abort");
+ assert.equal((await f.view()).amountEffectiveFinal, undefined);
+});
+
+test("cancellation before spending has zero final cost", async () => {
+ const f = fixture();
+ f.coins.get("coin")!.status = CoinStatus.Fresh;
+ f.record.status = PeerPushDebitStatus.Aborted;
+ await f.update();
+ assert.equal((await f.view()).amountEffectiveFinal, "TESTKUDOS:0");
+});
+
+test("missing historical inputs cannot establish a final amount", async () => {
+ const f = fixture();
+ f.coins.clear();
+ f.record.status = PeerPushDebitStatus.Aborted;
+ assert.equal((await f.view()).amountEffectiveFinal, undefined);
+});
+
+test("reselection preserves the quote and counts each consumed coin once", async () => {
+ const f = fixture();
+ f.refresh("change", "TESTKUDOS:4.8");
+ await f.update();
+ f.record.coinSel = { coinPubs: ["second"], contributions: ["TESTKUDOS:1"] };
+ f.record.totalCost = "TESTKUDOS:6.3";
+ f.coins.set("second", {
+ coinPub: "second",
+ denomPubHash: "2",
+ status: CoinStatus.Dormant,
+ } as WalletCoin);
+ const second = f.refresh("second-change", "TESTKUDOS:0.9");
+ second.oldCoinPubs = ["second"];
+ f.record.status = PeerPushDebitStatus.Done;
+ await f.update();
+ const result = await f.view();
+ assert.equal(result.amountEffective, "TESTKUDOS:5.2");
+ assert.equal(result.amountEffectiveFinal, "TESTKUDOS:6.3");
+});
+
+test("inconsistent refresh evidence remains unavailable", () => {
+ const f = fixture();
+ const r = f.refresh("bad", "TESTKUDOS:1");
+ r.statusPerCoin = [];
+ assert.equal(settledRefreshOutput(r), undefined);
+});
+
+test("failed withdrawal reports issued coins rather than the original estimate", async () => {
+ const wg = {
+ withdrawalGroupId: "wg",
+ effectiveWithdrawalAmount: "TESTKUDOS:10",
+ status: WithdrawalGroupStatus.FailedKycHardLimitRecovery,
+ } as WalletWithdrawalGroup;
+ const tx = {
+ async getWithdrawalGroup() {
+ return wg;
+ },
+ async getCoinsBySourceTransaction(id: string) {
+ return id === "txn:withdrawal:wg" ? [{ coinPub: "partial" }] : [];
+ },
+ async getDenomination() {
+ return { value: "TESTKUDOS:3" };
+ },
+ } as unknown as WalletDbTransaction;
+ const result = await withTransactionAmounts(tx, {
+ transactionId: "txn:withdrawal:wg",
+ type: TransactionType.Withdrawal,
+ amountEffective: "TESTKUDOS:0",
+ } as unknown as Transaction);
+ assert.equal(result.amountEffective, "TESTKUDOS:10");
+ assert.equal(result.amountEffectiveFinal, "TESTKUDOS:3");
+});
+
+test("ordinary merchant refunds remain separate from a successful payment cost", async () => {
+ const f = fixture();
+ const id = "txn:payment:payment";
+ let purchase = {
+ purchaseStatus: 0x01000001,
+ payInfo: {
+ totalPayCost: "TESTKUDOS:5.2",
+ payCoinSelection: { coinPubs: ["coin"] },
+ },
+ } as import("./db/records.js").WalletPurchase;
+ f.tx.getPurchase = async () => structuredClone(purchase);
+ f.tx.upsertPurchase = async (r) => {
+ purchase = structuredClone(r);
+ };
+ f.tx.getCoinHistory = async () => ({
+ coinPub: "coin",
+ history: [
+ { type: "spend", transactionId: id as never, amount: "TESTKUDOS:5" },
+ ],
+ });
+ const change = f.refresh("change", "TESTKUDOS:4.8");
+ change.reason = RefreshReason.PayMerchant;
+ const refund = f.refresh("refund", "TESTKUDOS:2");
+ refund.reason = RefreshReason.Refund;
+ await updateTransactionAmounts(f.tx, id);
+ purchase.purchaseStatus = 0x05000000;
+ await updateTransactionAmounts(f.tx, id);
+ const result = await withTransactionAmounts(f.tx, {
+ ...f.detail,
+ transactionId: id,
+ type: TransactionType.Payment,
+ } as unknown as Transaction);
+ assert.equal(result.amountEffectiveFinal, "TESTKUDOS:5.2");
+});
+
+test("a missing required recovery group is not proof that its entire input was lost", async () => {
+ const f = fixture();
+ f.record.abortRefreshGroupId = "missing-recovery";
+ f.record.status = PeerPushDebitStatus.Aborted;
+ assert.equal((await f.view()).amountEffectiveFinal, undefined);
+});
+
+test("incremental refunds wait for every recovery and retain abort attribution", async () => {
+ const f = fixture();
+ let refund = {
+ proposalId: "payment",
+ refundGroupId: "refund",
+ amountEffective: "TESTKUDOS:9.8",
+ amountRaw: "TESTKUDOS:10",
+ status: 0x05000000,
+ refreshGroupId: "first",
+ } as import("./db/records.js").WalletRefundGroup;
+ let payment = { purchaseStatus: 0x05030001 } as
+ | import("./db/records.js").WalletPurchase
+ | undefined;
+ f.tx.getRefundGroup = async () => structuredClone(refund);
+ f.tx.upsertRefundGroup = async (r) => {
+ refund = structuredClone(r);
+ };
+ f.tx.getPurchase = async () => payment;
+ const first = f.refresh(
+ "first",
+ "TESTKUDOS:2.4",
+ RefreshOperationStatus.Pending,
+ );
+ await updateTransactionAmounts(f.tx, "txn:refund:refund");
+ f.refresh("second", "TESTKUDOS:7.4");
+ refund.refreshGroupId = "second";
+ await updateTransactionAmounts(f.tx, "txn:refund:refund");
+ assert.equal(refund.transactionAmounts?.final, undefined);
+ first.operationStatus = RefreshOperationStatus.Finished;
+ first.statusPerCoin = [RefreshCoinStatus.Finished];
+ await updateTransactionAmounts(f.tx, "txn:refund:refund");
+ assert.equal(refund.transactionAmounts?.final, "TESTKUDOS:9.8");
+ assert.equal(refund.transactionAmounts?.isAbortRecovery, true);
+ payment = undefined;
+ f.refreshes.clear();
+ await updateTransactionAmounts(f.tx, "txn:refund:refund");
+ assert.equal(refund.transactionAmounts?.final, "TESTKUDOS:9.8");
+ assert.equal(refund.transactionAmounts?.isAbortRecovery, true);
+});
diff --git a/packages/taler-wallet-core/src/transaction-amounts.ts b/packages/taler-wallet-core/src/transaction-amounts.ts
@@ -0,0 +1,493 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import {
+ Amounts,
+ AmountString,
+ CoinStatus,
+ NotificationType,
+ RefreshReason,
+ Transaction,
+ TransactionMajorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletDbTransaction } from "./db/transaction.js";
+import {
+ OPERATION_STATUS_DONE_FIRST,
+ OPERATION_STATUS_DONE_LAST,
+ RefreshCoinStatus,
+ PurchaseStatus,
+ WalletRefreshGroup,
+ WalletTransactionAmounts,
+ WalletWithdrawalGroup,
+} from "./db/records.js";
+
+interface AmountRecord {
+ transactionAmounts?: WalletTransactionAmounts;
+}
+interface Source {
+ record: AmountRecord;
+ save(): Promise<void>;
+ status: number;
+ initial?: AmountString;
+ coinPubs?: string[];
+ withdrawal?: WalletWithdrawalGroup;
+ refresh?: WalletRefreshGroup;
+ refundRefreshId?: string;
+ parentId?: string;
+ refundPaymentId?: string;
+ requiredRefreshId?: string;
+}
+
+const sum = (currency: string, amounts: AmountString[]) =>
+ Amounts.stringify(Amounts.sumOrZero(currency, amounts).amount);
+const terminal = (status: number) => status >= OPERATION_STATUS_DONE_FIRST;
+const successful = (status: number) =>
+ status >= OPERATION_STATUS_DONE_FIRST && status <= OPERATION_STATUS_DONE_LAST;
+
+async function source(
+ tx: WalletDbTransaction,
+ transactionId: string,
+): Promise<Source | undefined> {
+ const [, type, id] = transactionId.split(":");
+ switch (type) {
+ case TransactionType.Payment: {
+ const r = await tx.getPurchase(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertPurchase(r),
+ status: r.purchaseStatus,
+ requiredRefreshId: r.abortRefreshGroupId,
+ initial: r.payInfo?.totalPayCost,
+ coinPubs: r.payInfo?.payCoinSelection?.coinPubs,
+ }
+ );
+ }
+ case TransactionType.Deposit: {
+ const r = await tx.getDepositGroup(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertDepositGroup(r),
+ status: r.operationStatus,
+ requiredRefreshId: r.abortRefreshGroupId,
+ initial: r.totalPayCost,
+ coinPubs: r.payCoinSelection?.coinPubs,
+ }
+ );
+ }
+ case TransactionType.PeerPushDebit: {
+ const r = await tx.getPeerPushDebit(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertPeerPushDebit(r),
+ status: r.status,
+ requiredRefreshId: r.abortRefreshGroupId,
+ initial: r.totalCost,
+ coinPubs: r.coinSel?.coinPubs,
+ }
+ );
+ }
+ case TransactionType.PeerPullDebit: {
+ const r = await tx.getPeerPullDebit(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertPeerPullDebit(r),
+ status: r.status,
+ requiredRefreshId: r.abortRefreshGroupId,
+ initial: r.totalCostEstimated,
+ coinPubs: r.coinSel?.coinPubs,
+ }
+ );
+ }
+ case TransactionType.Withdrawal:
+ case TransactionType.InternalWithdrawal: {
+ const r = await tx.getWithdrawalGroup(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertWithdrawalGroup(r),
+ status: r.status,
+ initial: r.effectiveWithdrawalAmount,
+ withdrawal: r,
+ parentId: r.transactionAmounts?.parentTransactionId,
+ }
+ );
+ }
+ case TransactionType.PeerPushCredit: {
+ const r = await tx.getPeerPushCredit(id);
+ if (!r) return;
+ return {
+ record: r,
+ save: () => tx.upsertPeerPushCredit(r),
+ status: r.status,
+ initial: r.estimatedAmountEffective,
+ withdrawal: r.withdrawalGroupId
+ ? await tx.getWithdrawalGroup(r.withdrawalGroupId)
+ : undefined,
+ };
+ }
+ case TransactionType.PeerPullCredit: {
+ const r = await tx.getPeerPullCredit(id);
+ if (!r) return;
+ return {
+ record: r,
+ save: () => tx.upsertPeerPullCredit(r),
+ status: r.status,
+ initial: r.estimatedAmountEffective,
+ withdrawal: r.withdrawalGroupId
+ ? await tx.getWithdrawalGroup(r.withdrawalGroupId)
+ : undefined,
+ };
+ }
+ case TransactionType.Refresh: {
+ const r = await tx.getRefreshGroup(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertRefreshGroup(r),
+ status: r.operationStatus,
+ refresh: r,
+ parentId: r.originatingTransactionId,
+ initial: r.originatingTransactionId
+ ? sum(r.currency, [])
+ : difference(
+ sum(r.currency, r.inputPerCoin),
+ sum(r.currency, r.expectedOutputPerCoin),
+ ),
+ }
+ );
+ }
+ case TransactionType.Refund: {
+ const r = await tx.getRefundGroup(id);
+ return (
+ r && {
+ record: r,
+ save: () => tx.upsertRefundGroup(r),
+ status: r.status,
+ initial: r.amountEffective,
+ refundRefreshId: r.refreshGroupId,
+ refundPaymentId: r.proposalId,
+ }
+ );
+ }
+ }
+ return undefined;
+}
+
+/** Refuse inconsistent evidence instead of silently saturating a subtraction. */
+function difference(
+ input: AmountString,
+ output: AmountString,
+): AmountString | undefined {
+ if (Amounts.cmp(input, output) < 0) return;
+ return Amounts.stringify(Amounts.sub(input, output).amount);
+}
+
+/** Pending/suspended work is never credited as a settled output. */
+export function settledRefreshOutput(
+ r: WalletRefreshGroup,
+): AmountString | undefined {
+ if (!terminal(r.operationStatus)) return;
+ if (r.statusPerCoin.length !== r.expectedOutputPerCoin.length) return;
+ return sum(
+ r.currency,
+ r.expectedOutputPerCoin.filter(
+ (_, i) => r.statusPerCoin[i] === RefreshCoinStatus.Finished,
+ ),
+ );
+}
+
+/**
+ * Recompute from operation evidence, never from the wallet's current balance.
+ * Saved inputs/outputs survive reselection and deletion of supporting records.
+ */
+async function calculate(
+ tx: WalletDbTransaction,
+ transactionId: string,
+ s: Source,
+): Promise<WalletTransactionAmounts> {
+ const old = s.record.transactionAmounts;
+ const a: WalletTransactionAmounts = {
+ ...old,
+ initial: s.refundPaymentId ? s.initial : (old?.initial ?? s.initial),
+ };
+ if (terminal(s.status) && a.final !== undefined && !s.refundPaymentId)
+ return a;
+ delete a.final;
+ const currency = a.initial ? Amounts.currencyOf(a.initial) : undefined;
+ if (!currency || currency === "UNKNOWN") return a;
+ const zero = sum(currency, []);
+ const type = transactionId.split(":")[1];
+ if (s.refresh) {
+ const output = settledRefreshOutput(s.refresh);
+ if (output !== undefined)
+ a.final = s.parentId
+ ? zero
+ : difference(sum(currency, s.refresh.inputPerCoin), output);
+ return a;
+ }
+ if (type === TransactionType.Refund) {
+ const payment = s.refundPaymentId
+ ? await tx.getPurchase(s.refundPaymentId)
+ : undefined;
+ if (payment)
+ a.isAbortRecovery =
+ payment.purchaseStatus === PurchaseStatus.AbortingWithRefund ||
+ payment.purchaseStatus === PurchaseStatus.SuspendedAbortingWithRefund ||
+ (terminal(payment.purchaseStatus) &&
+ !successful(payment.purchaseStatus));
+ const outputs = { ...a.refreshOutputs };
+ const unsettled = new Set(a.unsettledRefreshIds);
+ const ids = new Set([
+ ...unsettled,
+ ...Object.keys(outputs),
+ ...(s.refundRefreshId ? [s.refundRefreshId] : []),
+ ]);
+ for (const id of ids) {
+ const rg = await tx.getRefreshGroup(id);
+ if (!rg && outputs[id] !== undefined) continue;
+ const output = rg ? settledRefreshOutput(rg) : undefined;
+ if (output === undefined) {
+ unsettled.add(id);
+ delete outputs[id];
+ } else {
+ outputs[id] = output;
+ unsettled.delete(id);
+ }
+ }
+ a.refreshOutputs = outputs;
+ a.unsettledRefreshIds = [...unsettled];
+ if (terminal(s.status) && !unsettled.size) {
+ // Without recovery evidence a nonzero historical estimate isn't final.
+ if (ids.size) a.final = sum(currency, Object.values(outputs));
+ else if (Amounts.isZero(a.initial!)) a.final = zero;
+ }
+ return a;
+ }
+ if (
+ [
+ TransactionType.Withdrawal,
+ TransactionType.InternalWithdrawal,
+ TransactionType.PeerPushCredit,
+ TransactionType.PeerPullCredit,
+ ].includes(type as TransactionType)
+ ) {
+ if (!terminal(s.status) || (s.withdrawal && !terminal(s.withdrawal.status)))
+ return a;
+ if (!s.withdrawal) {
+ if (!successful(s.status)) a.final = zero;
+ return a;
+ }
+ const ids = new Set([
+ transactionId,
+ `txn:withdrawal:${s.withdrawal.withdrawalGroupId}`,
+ `txn:internal-withdrawal:${s.withdrawal.withdrawalGroupId}`,
+ ]);
+ const values = new Map<string, AmountString>();
+ for (const id of ids) {
+ for (const coin of await tx.getCoinsBySourceTransaction(id)) {
+ const denom = await tx.getDenomination(coin);
+ if (!denom) return a;
+ values.set(coin.coinPub, denom.value);
+ }
+ }
+ // A successful withdrawal with missing coins is not evidence of zero.
+ if (
+ !values.size &&
+ successful(s.withdrawal.status) &&
+ Amounts.isNonZero(a.initial!)
+ )
+ return a;
+ a.final = sum(currency, [...values.values()]);
+ return a;
+ }
+ const allRefreshes =
+ await tx.getRefreshGroupsByOriginatingTransaction(transactionId);
+ const includeRefunds =
+ type !== TransactionType.Payment ||
+ s.status === PurchaseStatus.AbortingWithRefund ||
+ s.status === PurchaseStatus.SuspendedAbortingWithRefund ||
+ (terminal(s.status) && !successful(s.status));
+ const refreshes = allRefreshes.filter(
+ (r) => includeRefunds || r.reason !== RefreshReason.Refund,
+ );
+ const inputs = { ...a.inputs };
+ const inputOwners = new Set([
+ transactionId,
+ ...refreshes.map((r) => `txn:refresh:${r.refreshGroupId}`),
+ ]);
+ const missing = new Set(a.missingInputs);
+ let complete = true;
+ for (const pub of new Set([
+ ...missing,
+ ...(s.coinPubs ?? []),
+ ...refreshes.flatMap((r) => r.oldCoinPubs),
+ ])) {
+ if (inputs[pub] !== undefined) continue;
+ const coin = await tx.getCoin(pub);
+ if (!coin) {
+ complete = false;
+ missing.add(pub);
+ continue;
+ }
+ if (
+ coin.status === CoinStatus.Fresh ||
+ coin.status === CoinStatus.FreshSuspended
+ ) {
+ missing.delete(pub);
+ continue;
+ }
+ const history = await tx.getCoinHistory(pub);
+ if (!history) {
+ complete = false;
+ missing.add(pub);
+ continue;
+ }
+ if (
+ !history.history.some(
+ (h) =>
+ (h.type === "spend" || h.type === "refresh") &&
+ inputOwners.has(h.transactionId),
+ )
+ ) {
+ complete = false;
+ missing.add(pub);
+ continue;
+ }
+ const denom = await tx.getDenomination(coin);
+ if (!denom) {
+ complete = false;
+ missing.add(pub);
+ continue;
+ }
+ inputs[pub] = denom.value;
+ missing.delete(pub);
+ }
+ a.inputs = inputs;
+ a.missingInputs = [...missing];
+ const outputs = { ...a.refreshOutputs };
+ const unsettled = new Set(a.unsettledRefreshIds);
+ if (!includeRefunds) {
+ for (const r of allRefreshes.filter(
+ (r) => r.reason === RefreshReason.Refund,
+ )) {
+ delete outputs[r.refreshGroupId];
+ unsettled.delete(r.refreshGroupId);
+ }
+ }
+ if (s.requiredRefreshId && outputs[s.requiredRefreshId] === undefined)
+ unsettled.add(s.requiredRefreshId);
+ for (const r of refreshes) {
+ const output = settledRefreshOutput(r);
+ if (output === undefined) {
+ unsettled.add(r.refreshGroupId);
+ delete outputs[r.refreshGroupId];
+ } else {
+ outputs[r.refreshGroupId] = output;
+ unsettled.delete(r.refreshGroupId);
+ }
+ }
+ a.refreshOutputs = outputs;
+ a.unsettledRefreshIds = [...unsettled];
+ if (terminal(s.status) && !unsettled.size && !missing.size && complete) {
+ // Historical selected coins may already have been deleted: don't guess.
+ a.final = difference(
+ sum(currency, Object.values(inputs)),
+ sum(currency, Object.values(outputs)),
+ );
+ }
+ return a;
+}
+
+/** Called with the operation's metadata update, in the same database transaction. */
+export async function updateTransactionAmounts(
+ tx: WalletDbTransaction,
+ transactionId: string,
+ visited = new Set<string>(),
+): Promise<void> {
+ if (visited.has(transactionId)) return;
+ visited.add(transactionId);
+ const s = await source(tx, transactionId);
+ if (!s) return;
+ const old = s.record.transactionAmounts;
+ s.record.transactionAmounts = await calculate(tx, transactionId, s);
+ if (JSON.stringify(old) !== JSON.stringify(s.record.transactionAmounts)) {
+ await s.save();
+ if (old?.final !== s.record.transactionAmounts.final)
+ tx.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+ if (
+ s.withdrawal &&
+ !transactionId.startsWith("txn:withdrawal:") &&
+ !transactionId.startsWith("txn:internal-withdrawal:")
+ ) {
+ const wg = s.withdrawal;
+ if (wg.transactionAmounts?.parentTransactionId !== transactionId) {
+ wg.transactionAmounts = {
+ ...wg.transactionAmounts,
+ parentTransactionId: transactionId,
+ };
+ await tx.upsertWithdrawalGroup(wg);
+ }
+ }
+ if (s.parentId) {
+ // A reopened recovery operation invalidates the parent's settled snapshot too.
+ const parent = await source(tx, s.parentId);
+ if (parent?.record.transactionAmounts && !terminal(s.status)) {
+ delete parent.record.transactionAmounts.final;
+ await parent.save();
+ }
+ await updateTransactionAmounts(tx, s.parentId, visited);
+ if (s.parentId.startsWith("txn:payment:")) {
+ for (const refund of await tx.getRefundGroupsByProposal(
+ s.parentId.split(":")[2],
+ )) {
+ await updateTransactionAmounts(
+ tx,
+ `txn:refund:${refund.refundGroupId}`,
+ );
+ }
+ }
+ }
+}
+
+/** Also reconstruct amounts for old records without mutating a read operation. */
+export async function withTransactionAmounts(
+ tx: WalletDbTransaction,
+ transaction: Transaction,
+): Promise<Transaction> {
+ const s = await source(tx, transaction.transactionId);
+ if (!s) {
+ if (
+ transaction.txState.major === TransactionMajorState.Done &&
+ [TransactionType.Recoup, TransactionType.DenomLoss].includes(
+ transaction.type,
+ )
+ ) {
+ return {
+ ...transaction,
+ amountEffectiveFinal: transaction.amountEffective,
+ };
+ }
+ return transaction;
+ }
+ const a = await calculate(tx, transaction.transactionId, s);
+ return {
+ ...transaction,
+ amountEffective: a.initial ?? transaction.amountEffective,
+ ...(transaction.type === TransactionType.Refund
+ ? { isAbortRecovery: a.isAbortRecovery }
+ : {}),
+ amountEffectiveFinal: a.final,
+ };
+}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
@@ -17,6 +17,7 @@
/**
* Imports.
*/
+import { withTransactionAmounts } from "./transaction-amounts.js";
import {
AbsoluteTime,
Amounts,
@@ -224,9 +225,12 @@ export async function getTransactionById(
case TransactionType.Refund: {
const ctx = await getContextForTransaction(wex, req.transactionId);
const result = await wex.runWalletDbTx(async (tx) => ({
- txDetails: await ctx.lookupFullTransaction(tx, {
- includeContractTerms: req.includeContractTerms,
- }),
+ txDetails: await (async () => {
+ const details = await ctx.lookupFullTransaction(tx, {
+ includeContractTerms: req.includeContractTerms,
+ });
+ return details && withTransactionAmounts(tx, details);
+ })(),
txMeta: await tx.getTransactionMeta(req.transactionId),
localIdent: (
await tx.getLocalTransactionIdentifiers([req.transactionId])
@@ -463,7 +467,10 @@ async function addFiltered(
numAdded += 1;
target.push(
withLocalTransactionIdentifier(
- withMaterializedLegacyScopes(txDetails, mtx),
+ withMaterializedLegacyScopes(
+ await withTransactionAmounts(tx, txDetails),
+ mtx,
+ ),
localIdentifiers.get(mtx.transactionId),
),
);
@@ -662,7 +669,10 @@ export async function getTransactions(
}
transactions.push(
withLocalTransactionIdentifier(
- withMaterializedLegacyScopes(txDetails, metaTx),
+ withMaterializedLegacyScopes(
+ await withTransactionAmounts(tx, txDetails),
+ metaTx,
+ ),
localIdentifiers.get(metaTx.transactionId),
),
);
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -19,6 +19,7 @@
* bank-integrated and manual.
*/
+import { updateTransactionAmounts } from "./transaction-amounts.js";
import {
AbsoluteTime,
AcceptManualWithdrawalResult,
@@ -494,7 +495,9 @@ export class WithdrawTransactionContext implements TransactionContext {
case WithdrawalRecordType.PeerPullCredit:
case WithdrawalRecordType.PeerPushCredit:
case WithdrawalRecordType.Recoup:
- // These withdrawal transactions are internal/hidden.
+ // These withdrawal transactions are internal/hidden, but their
+ // completion can settle the parent payment's final amount.
+ await updateTransactionAmounts(tx, ctx.transactionId);
return;
default:
assertUnreachable(wgRecord.wgInfo);