taler-typescript-core

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

commit 7bae442956ab603886979ff99a6aff816ed1bc19
parent 2a71a72efcdf609cb73180e6b41e9898ddf2692b
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 15:41:17 +0200

wallet-core: refresh what a deposit refund restores on the coin

A full refund credits the coin with the contribution minus the refund
fee plus the waived deposit fee; melting only the contribution
stranded the difference.

Diffstat:
Mpackages/taler-wallet-core/src/deposits.test.ts | 26++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 49+++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 71 insertions(+), 4 deletions(-)

diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts @@ -16,6 +16,7 @@ import { AbsoluteTime, + Amounts, HttpStatusCode, TalerErrorCode, TalerProtocolTimestamp, @@ -38,6 +39,7 @@ import { depositRefundStatusIsRetryable, reconstructDepositRefundRequests, testing_getDepositTrackingTiming, + getDepositAbortRefreshAmount, } from "./deposits.js"; test("deposit tracking waits at a future wire deadline", () => { @@ -252,3 +254,27 @@ test("hard-KYC deposit cleanup remains finalizing until recovery completes", () }, ); }); + +test("deposit abort refreshes the amount the refund restored on the coin", () => { + const denom = { feeDeposit: "TESTKUDOS:0.1", feeRefund: "TESTKUDOS:0.02" }; + assert.strictEqual( + Amounts.stringify( + getDepositAbortRefreshAmount( + "TESTKUDOS:5", + DepositElementStatus.RefundSuccess, + denom, + ), + ), + "TESTKUDOS:5.08", + ); + assert.strictEqual( + Amounts.stringify( + getDepositAbortRefreshAmount( + "TESTKUDOS:5", + DepositElementStatus.RefundNotFound, + denom, + ), + ), + "TESTKUDOS:5", + ); +}); diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -24,6 +24,7 @@ import { AbsoluteTime, AmountJson, + AmountLike, AmountString, Amounts, BatchDepositRequestCoin, @@ -1036,6 +1037,33 @@ function isDepositHardKycFailure(depositGroup: WalletDepositGroup): boolean { * already wired the deposit. Other permanent refund failures prove neither * recovery nor delivery and therefore get their own outcome. */ +/** + * The amount a refresh can claim back from a coin after the deposit abort + * refunded it. + * + * A refund of the full contribution makes the exchange credit the coin with + * the contribution minus the refund fee, plus the deposit fee it waives on a + * full refund. A coin whose deposit the exchange never saw still holds its + * whole contribution. + */ +export function getDepositAbortRefreshAmount( + contribution: AmountLike, + status: + | DepositElementStatus.RefundSuccess + | DepositElementStatus.RefundNotFound, + denom: { feeDeposit: AmountLike; feeRefund: AmountLike }, +): AmountJson { + const contrib = Amounts.parseOrThrow(contribution); + if (status === DepositElementStatus.RefundNotFound) { + return contrib; + } + const afterRefundFee = Amounts.sub(contrib, denom.feeRefund); + if (afterRefundFee.saturated) { + return Amounts.zeroOfAmount(contrib); + } + return Amounts.add(afterRefundFee.amount, denom.feeDeposit).amount; +} + export function classifyDepositAbortOutcome( statusPerCoin: DepositElementStatus[], recovery?: "pending" | "recovered" | "failed", @@ -1360,17 +1388,30 @@ async function refundDepositGroup( if (outcome === "refunding") { return; } + const denoms = await getDenomInfos(wex, tx, coins); const refreshCoins: CoinRefreshRequest[] = []; for (let i = 0; i < newTxPerCoin.length; i++) { + const st = newTxPerCoin[i]; if ( - newTxPerCoin[i] !== DepositElementStatus.RefundSuccess && - newTxPerCoin[i] !== DepositElementStatus.RefundNotFound + st !== DepositElementStatus.RefundSuccess && + st !== DepositElementStatus.RefundNotFound ) { continue; } + const coinPub = payCoinSelection.coinPubs[i]; + const coin = coinsByPub.get(coinPub); + checkDbInvariant(!!coin, `coin ${coinPub} not found in DB`); + const denom = denoms.get(denomRefKey(coin)); + checkDbInvariant(!!denom, `denomination of coin ${coinPub} is missing`); refreshCoins.push({ - amount: payCoinSelection.coinContributions[i], - coinPub: payCoinSelection.coinPubs[i], + amount: Amounts.stringify( + getDepositAbortRefreshAmount( + payCoinSelection.coinContributions[i], + st, + denom, + ), + ), + coinPub, refundRequest: refundReqPerCoin[i], }); }