taler-typescript-core

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

commit 8cb6e9096a56a39775eb5a5b2488046be39d8ab2
parent 9b715aedfc64af93fa7b7aabb6a099ce1563f02d
Author: Florian Dold <dold@taler.net>
Date:   Sun,  6 Sep 2026 12:41:06 +0200

wallet: include waived deposit fees in refund history balances

Track cumulative gross refunds by merchant and contract. Return the
deposit fee once a deposit is fully refunded, while retaining the
comparison against the exchange balance and rejecting excess refunds.

Diffstat:
Mpackages/taler-wallet-core/src/refresh.test.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/refresh.ts | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 164 insertions(+), 1 deletion(-)

diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts @@ -192,12 +192,18 @@ test("coin history balance is recomputed from typed operations", () => { type: "DEPOSIT", history_offset: 0, amount: "TESTKUDOS:3", + deposit_fee: "TESTKUDOS:0.1", + merchant_pub: "merchant", + h_contract_terms: "contract", h_denom_pub: "denom", }, { type: "REFUND", history_offset: 1, amount: "TESTKUDOS:1", + refund_fee: "TESTKUDOS:0.02", + merchant_pub: "merchant", + h_contract_terms: "contract", }, ], } as never; @@ -262,3 +268,99 @@ test("coin history accounts for the waived fee of refunded purse deposits", () = "TESTKUDOS:10.08", ); }); + +for (const refundAmounts of [["TESTKUDOS:3"], ["TESTKUDOS:1", "TESTKUDOS:2"]]) { + test(`coin history returns the deposit fee once for ${refundAmounts.length} refunds`, () => { + const history = { + h_denom_pub: "denom", + balance: + refundAmounts.length === 1 ? "TESTKUDOS:10.08" : "TESTKUDOS:10.06", + history: [ + { + type: "DEPOSIT", + history_offset: 0, + amount: "TESTKUDOS:3", + deposit_fee: "TESTKUDOS:0.1", + merchant_pub: "merchant", + h_contract_terms: "contract", + }, + ...refundAmounts.map((amount, i) => ({ + type: "REFUND", + history_offset: i + 1, + amount: Amounts.stringify( + Amounts.sub(amount, "TESTKUDOS:0.02").amount, + ), + refund_fee: "TESTKUDOS:0.02", + merchant_pub: "merchant", + h_contract_terms: "contract", + })), + ], + } as never; + assert.strictEqual( + validateAndRecomputeCoinHistoryBalance("denom", "TESTKUDOS:10", history), + refundAmounts.length === 1 ? "TESTKUDOS:10.08" : "TESTKUDOS:10.06", + ); + }); +} + +test("coin history keeps refund fee waivers bound to one deposit", () => { + const response = { + h_denom_pub: "denom", + balance: "TESTKUDOS:7.08", + history: [ + { + type: "DEPOSIT", + history_offset: 0, + amount: "TESTKUDOS:3", + deposit_fee: "TESTKUDOS:0.1", + merchant_pub: "merchant", + h_contract_terms: "first", + }, + { + type: "DEPOSIT", + history_offset: 1, + amount: "TESTKUDOS:3", + deposit_fee: "TESTKUDOS:0.1", + merchant_pub: "merchant", + h_contract_terms: "second", + }, + { + type: "REFUND", + history_offset: 2, + amount: "TESTKUDOS:2.98", + refund_fee: "TESTKUDOS:0.02", + merchant_pub: "merchant", + h_contract_terms: "first", + }, + ], + }; + assert.strictEqual( + validateAndRecomputeCoinHistoryBalance( + "denom", + "TESTKUDOS:10", + response as never, + ), + "TESTKUDOS:7.08", + ); + response.history[2].merchant_pub = "another-merchant"; + assert.throws( + () => + validateAndRecomputeCoinHistoryBalance( + "denom", + "TESTKUDOS:10", + response as never, + ), + /no matching deposit/, + ); + response.history[2].merchant_pub = "merchant"; + response.history[2].amount = "TESTKUDOS:3.01"; + assert.throws( + () => + validateAndRecomputeCoinHistoryBalance( + "denom", + "TESTKUDOS:10", + response as never, + ), + /refunds exceed their deposit/, + ); +}); diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -1683,6 +1683,14 @@ export function validateAndRecomputeCoinHistoryBalance( } let balance = Amounts.parseOrThrow(denomValue); const offsets = new Set<number>(); + const deposits = new Map< + string, + { + amount: AmountString; + fee: AmountString; + refunded: AmountString; + } + >(); for (const item of response.history) { if ( !Number.isInteger(item.history_offset) || @@ -1700,6 +1708,15 @@ export function validateAndRecomputeCoinHistoryBalance( let addsValue = false; switch (item.type) { case "DEPOSIT": + deposits.set(`${item.merchant_pub}:${item.h_contract_terms}`, { + amount: item.amount, + fee: item.deposit_fee, + refunded: Amounts.stringify( + Amounts.zeroOfCurrency(Amounts.currencyOf(denomValue)), + ), + }); + amount = item.amount; + break; case "RECOUP-WITHDRAW": case "RECOUP-REFRESH": case "PURSE-DEPOSIT": @@ -1711,7 +1728,51 @@ export function validateAndRecomputeCoinHistoryBalance( case "RESERVE-OPEN-DEPOSIT": amount = item.coin_contribution; break; - case "REFUND": + case "REFUND": { + const deposit = deposits.get( + `${item.merchant_pub}:${item.h_contract_terms}`, + ); + if (!deposit) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "coin history refund has no matching deposit", + ); + } + // History reports the net refund. The exchange also returns the + // deposit fee once gross cumulative refunds cover this deposit. + const grossRefund = Amounts.add(item.amount, item.refund_fee); + const totalRefund = Amounts.add(deposit.refunded, grossRefund.amount); + if ( + grossRefund.saturated || + totalRefund.saturated || + Amounts.cmp(totalRefund.amount, deposit.amount) > 0 + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "coin history refunds exceed their deposit", + ); + } + amount = item.amount; + if ( + Amounts.cmp(deposit.refunded, deposit.amount) < 0 && + Amounts.cmp(totalRefund.amount, deposit.amount) === 0 + ) { + const withWaivedFee = Amounts.add(amount, deposit.fee); + if (withWaivedFee.saturated) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "refunded deposit fee overflows the coin balance", + ); + } + amount = Amounts.stringify(withWaivedFee.amount); + } + deposit.refunded = Amounts.stringify(totalRefund.amount); + addsValue = true; + break; + } case "RECOUP-REFRESH-RECEIVER": case "PURSE-REFUND": amount = item.amount;