taler-typescript-core

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

commit e86e3c1100657a0e375dedac77755046ee96d435
parent 1b31cd676ee3d765f19bd646a1d25a36405837b1
Author: Florian Dold <dold@taler.net>
Date:   Fri,  4 Sep 2026 01:01:18 +0200

wallet-core: fix payment abort and refund recovery

Keep replay sessions separate, aggregate refunds per coin, and retry
transient refund failures. Verify merchant abort and Paivana responses
before completing recovery.

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 184++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/pay-merchant.ts | 364++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Apackages/taler-wallet-core/src/pay-paivana-common.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-paivana.ts | 35+++--------------------------------
Mpackages/taler-wallet-core/src/pay-template.test.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-template.ts | 44++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/tokenFamilies.ts | 2++
7 files changed, 641 insertions(+), 123 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -18,6 +18,7 @@ import { AmountString, HttpStatusCode, MerchantContractVersion, + RefreshReason, SelectedCoin, SignedTokenEnvelope, TransactionAction, @@ -34,16 +35,23 @@ import assert from "node:assert"; import { test } from "node:test"; import { PurchaseStatus, + RefreshCoinStatus, + RefreshOperationStatus, RefundGroupStatus, + RefundItemStatus, WalletPurchase, WalletCoinSelection, WalletRefundGroup, + WalletRefundItem, + WalletRefreshGroup, WalletSlate, WalletToken, } from "./db/records.js"; import { WalletDbTransaction } from "./db/transaction.js"; import { applyFirstPaySuccessState, + applyPaySessionRequest, + classifyMerchantAbortRecoveryEvidence, cleanupFailedClaimsOnStartup, computePayMerchantTransactionActions, computePayMerchantTransactionState, @@ -61,6 +69,7 @@ import { isPaymentSessionComplete, isSharedPurchase, isStableMerchantPayFailure, + isTransientRefundStatus, PayMerchantTransactionContext, recoveredPayFailureStatus, releasePaymentTokensInTx, @@ -184,6 +193,36 @@ test("unclaimed payments wait for and finish neutrally in another wallet", () => ); }); +test("deleting a proposed payment only uses local state", async () => { + const runner = await makeIdbRunner(); + const proposalId = "delete-proposed-payment"; + const wex = { + ws: {}, + async runWalletDbTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runner.runReadWriteTx(f); + }, + } as unknown as WalletExecutionContext; + try { + await runner.runReadWriteTx((tx) => + tx.upsertPurchase( + claimPurchase(proposalId, PurchaseStatus.DialogProposed), + ), + ); + + const ctx = new PayMerchantTransactionContext(wex, proposalId); + await ctx.userDeleteTransaction(); + + assert.strictEqual( + await runner.runReadWriteTx((tx) => tx.getPurchase(proposalId)), + undefined, + ); + } finally { + await runner.close(); + } +}); + test("payment transaction amounts follow the selected merchant choice", () => { const contractTerms = { version: MerchantContractVersion.V1, @@ -788,6 +827,50 @@ test("payment replay waits for the requested session to finish", () => { ); }); +test("a concurrent session request is retained for replay", () => { + const purchase = { + lastSessionId: "first-session", + payInfo: { totalPayCost: "TESTKUDOS:1" }, + purchaseStatus: PurchaseStatus.PendingPaying, + } as WalletPurchase; + + assert.strictEqual(applyPaySessionRequest(purchase, "second-session"), true); + assert.strictEqual(purchase.purchaseStatus, PurchaseStatus.PendingPaying); + assert.strictEqual( + getPayMerchantAbortTransition(purchase.purchaseStatus, false), + PurchaseStatus.AbortingWithRefund, + ); + + const completed = applyFirstPaySuccessState( + purchase, + "first-session", + { sig: "first-payment-proof" } as never, + TalerPreciseTimestamp.fromSeconds(1_800_000_000), + ); + assert.strictEqual(completed, false); + assert.strictEqual(purchase.lastSessionId, "second-session"); + assert.strictEqual( + purchase.purchaseStatus, + PurchaseStatus.PendingPayingReplay, + ); +}); + +test("refund retry classification includes timeout and throttling", () => { + assert.strictEqual( + isTransientRefundStatus(HttpStatusCode.RequestTimeout), + true, + ); + assert.strictEqual( + isTransientRefundStatus(HttpStatusCode.TooManyRequests), + true, + ); + assert.strictEqual( + isTransientRefundStatus(HttpStatusCode.InternalServerError), + true, + ); + assert.strictEqual(isTransientRefundStatus(HttpStatusCode.Conflict), false); +}); + test("every advertised merchant action has a state transition", () => { const statuses = Object.values(PurchaseStatus).filter( (value): value is PurchaseStatus => typeof value === "number", @@ -819,7 +902,7 @@ test("every advertised merchant action has a state transition", () => { } }); -test("a delayed payment success preserves a concurrent abort state", () => { +test("a delayed verified payment success wins a concurrent abort race", () => { const purchase = { purchaseStatus: PurchaseStatus.AbortingWithRefund, } as WalletPurchase; @@ -831,11 +914,8 @@ test("a delayed payment success preserves a concurrent abort state", () => { TalerPreciseTimestamp.fromSeconds(1_800_000_000), ); - assert.strictEqual(completed, false); - assert.strictEqual( - purchase.purchaseStatus, - PurchaseStatus.AbortingWithRefund, - ); + assert.strictEqual(completed, true); + assert.strictEqual(purchase.purchaseStatus, PurchaseStatus.Done); assert.strictEqual(purchase.merchantPaySig, "merchant-payment-signature"); assert.strictEqual(purchase.lastSessionId, "session"); }); @@ -963,20 +1043,102 @@ test("merchant repair recovers the contribution of a coin the merchant rejected" ]); }); -test("refund totals leave out refunds that did not happen", () => { +test("refund totals retain successes from a mixed failed group", () => { const done = refundGroup("done", "TESTKUDOS:3", "TESTKUDOS:2.5"); const pending = refundGroup("pending", "TESTKUDOS:1", "TESTKUDOS:0.5"); pending.status = RefundGroupStatus.Pending; - const failed = refundGroup("failed", "TESTKUDOS:7", "TESTKUDOS:0"); + const failed = refundGroup("failed", "TESTKUDOS:7", "TESTKUDOS:6"); failed.status = RefundGroupStatus.Failed; - const aborted = refundGroup("aborted", "TESTKUDOS:5", "TESTKUDOS:0"); + const aborted = refundGroup("aborted", "TESTKUDOS:0", "TESTKUDOS:0"); aborted.status = RefundGroupStatus.Aborted; assert.deepStrictEqual( getRefundTotals([done, pending, failed, aborted], "TESTKUDOS"), { - raw: "TESTKUDOS:4", - effective: "TESTKUDOS:3", + raw: "TESTKUDOS:11", + effective: "TESTKUDOS:9", }, ); }); + +function recoveryRefresh( + reason: RefreshReason, + coinPub: string, + output: string, + status = RefreshCoinStatus.Finished, +): WalletRefreshGroup { + return { + reason, + oldCoinPubs: [coinPub], + expectedOutputPerCoin: [output], + statusPerCoin: [status], + operationStatus: + status === RefreshCoinStatus.Finished + ? RefreshOperationStatus.Finished + : RefreshOperationStatus.Pending, + } as WalletRefreshGroup; +} + +test("merchant abort requires refund recovery for a fully spent coin", () => { + const selection = { + coinPubs: ["coin"], + coinContributions: ["TESTKUDOS:1"], + } as WalletCoinSelection; + const abortRefresh = recoveryRefresh( + RefreshReason.AbortPay, + "coin", + "TESTKUDOS:0", + ); + + assert.strictEqual( + classifyMerchantAbortRecoveryEvidence(selection, abortRefresh, [], []), + "failed", + ); + + const refundItem = { + coinPub: "coin", + rtxid: 0, + status: RefundItemStatus.Done, + } as WalletRefundItem; + assert.strictEqual( + classifyMerchantAbortRecoveryEvidence( + selection, + abortRefresh, + [refundItem], + [ + recoveryRefresh( + RefreshReason.Refund, + "coin", + "TESTKUDOS:0.9", + RefreshCoinStatus.Pending, + ), + ], + ), + "pending", + ); + assert.strictEqual( + classifyMerchantAbortRecoveryEvidence( + selection, + abortRefresh, + [refundItem], + [recoveryRefresh(RefreshReason.Refund, "coin", "TESTKUDOS:0.9")], + ), + "recovered", + ); +}); + +test("merchant abort accepts value recovered directly by abort refresh", () => { + const selection = { + coinPubs: ["coin"], + coinContributions: ["TESTKUDOS:1"], + } as WalletCoinSelection; + assert.strictEqual( + classifyMerchantAbortRecoveryEvidence( + selection, + recoveryRefresh(RefreshReason.AbortPay, "coin", "TESTKUDOS:0.9"), + [], + [], + ), + "recovered", + ); +}); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -143,6 +143,7 @@ import { RefundReason, RefundGroupStatus, RefundItemStatus, + RefreshCoinStatus, timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolFromDb, @@ -155,6 +156,7 @@ import { WalletPurchasePayInfo, WalletRefundGroup, WalletRefundItem, + WalletRefreshGroup, WalletSlate, WalletToken, } from "./db/records.js"; @@ -949,9 +951,8 @@ export class RefundTransactionContext implements TransactionContext { return { type: TransactionType.Refund, scopes, - amountEffective: isUnsuccessfulTransaction(txState) - ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective)) - : refundRecord.amountEffective, + // Failed groups can still contain authenticated successful items. + amountEffective: refundRecord.amountEffective, amountRaw: refundRecord.amountRaw, refundedTransactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, @@ -2186,20 +2187,71 @@ export function applyFirstPaySuccessState( timestamp: TalerPreciseTimestamp, ): boolean { let completePaymentState = false; + let preserveRequestedReplaySession = false; switch (purchase.purchaseStatus) { case PurchaseStatus.PendingPaying: + if (purchase.lastSessionId !== sessionId) { + purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay; + preserveRequestedReplaySession = true; + } else { + purchase.purchaseStatus = PurchaseStatus.Done; + completePaymentState = true; + } + break; case PurchaseStatus.SuspendedPaying: + if (purchase.lastSessionId !== sessionId) { + purchase.purchaseStatus = PurchaseStatus.SuspendedPayingReplay; + preserveRequestedReplaySession = true; + } else { + purchase.purchaseStatus = PurchaseStatus.Done; + completePaymentState = true; + } + break; + case PurchaseStatus.AbortingWithRefund: + case PurchaseStatus.SuspendedAbortingWithRefund: purchase.purchaseStatus = PurchaseStatus.Done; completePaymentState = true; break; + case PurchaseStatus.PendingPayingReplay: + case PurchaseStatus.SuspendedPayingReplay: + preserveRequestedReplaySession = true; + break; } purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(timestamp); - purchase.lastSessionId = sessionId; + if (!preserveRequestedReplaySession) { + purchase.lastSessionId = sessionId; + } purchase.merchantPaySig = payResponse.sig; purchase.posConfirmation = payResponse.pos_confirmation; return completePaymentState; } +export function applyPaySessionRequest( + purchase: WalletPurchase, + sessionId: string, +): boolean { + if (purchase.lastSessionId === sessionId) { + return false; + } + switch (purchase.purchaseStatus) { + case PurchaseStatus.Done: + purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay; + break; + case PurchaseStatus.PendingPaying: + case PurchaseStatus.SuspendedPaying: + // The first /pay is still outstanding. Keep its recovery semantics; + // its success continuation promotes this requested session to replay. + break; + case PurchaseStatus.PendingPayingReplay: + case PurchaseStatus.SuspendedPayingReplay: + break; + default: + return false; + } + purchase.lastSessionId = sessionId; + return true; +} + export async function storeFirstPaySuccess( wex: WalletExecutionContext, proposalId: string, @@ -3045,6 +3097,15 @@ async function waitPaymentResult( } if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) { + if (waitSessionId !== undefined) { + // A terminal payment only proves the session recorded with its + // authenticated /pay or /paid response. A concurrent caller that + // requested another session must not be told that session is done. + return { + type: ConfirmPayResultType.Pending, + transactionId: ctx.transactionId, + }; + } return { type: ConfirmPayResultType.Done, contractTerms: d.contractTermsRaw, @@ -3416,15 +3477,10 @@ export async function confirmPay( const existingPurchase = await wex.runWalletDbTx(async (tx) => { const purchase = await tx.getPurchase(proposalId); if ( - purchase && + purchase?.payInfo && sessionIdOverride !== undefined && - sessionIdOverride != purchase.lastSessionId + applyPaySessionRequest(purchase, sessionIdOverride) ) { - logger.trace(`changing session ID to ${sessionIdOverride}`); - purchase.lastSessionId = sessionIdOverride; - if (purchase.purchaseStatus === PurchaseStatus.Done) { - purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay; - } await tx.upsertPurchase(purchase); await ctx.updateTransactionMeta(tx); } @@ -3529,7 +3585,7 @@ export async function confirmPay( } let sessionId: string | undefined; - if (sessionIdOverride) { + if (sessionIdOverride !== undefined) { sessionId = sessionIdOverride; } else { sessionId = proposal.downloadSessionId; @@ -3555,6 +3611,9 @@ export async function confirmPay( "the selected payment choice changed while confirming", ); } + if (sessionId !== undefined && applyPaySessionRequest(p, sessionId)) { + await h.update(p, "confirm-pay-session-replay", BalanceEffect.None); + } return false; } switch (p.purchaseStatus) { @@ -5669,17 +5728,53 @@ async function waitForRefreshOnAbortedPayment( if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) { return false; } - await releasePaymentTokensInTx( - tx, - ctx.transactionId, - rec.payInfo?.payTokenSelection?.tokenPubs ?? [], - ); if (recovery === "recovered") { + const payCoinSelection = rec.payInfo?.payCoinSelection; + checkDbInvariant(!!payCoinSelection, "aborting payment has no coins"); + checkDbInvariant(!!refreshGroup, "abort recovery refresh disappeared"); + const relatedRefreshes = + await tx.getRefreshGroupsByOriginatingTransaction(ctx.transactionId); + const refundItems: WalletRefundItem[] = []; + const refundGroups = await tx.getRefundGroupsByProposal(rec.proposalId); + for (const refundGroup of refundGroups) { + refundItems.push( + ...(await tx.getRefundItemsByGroup(refundGroup.refundGroupId)), + ); + } + const evidence = classifyMerchantAbortRecoveryEvidence( + payCoinSelection, + refreshGroup, + refundItems, + relatedRefreshes, + ); + if (evidence === "pending") { + return false; + } + if (evidence === "failed") { + rec.purchaseStatus = PurchaseStatus.FailedAbort; + rec.failReason = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + "merchant abort did not prove recovery of every deposited coin", + ); + await h.update(rec, "abort-recovery-unproven"); + return true; + } + await releasePaymentTokensInTx( + tx, + ctx.transactionId, + rec.payInfo?.payTokenSelection?.tokenPubs ?? [], + ); rec.purchaseStatus = rec.failReason ? recoveredPayFailureStatus(rec.failReason) : finalStatus; await h.update(rec, "abort-refresh-done"); } else { + await releasePaymentTokensInTx( + tx, + ctx.transactionId, + rec.payInfo?.payTokenSelection?.tokenPubs ?? [], + ); rec.purchaseStatus = PurchaseStatus.FailedAbort; rec.failReason = makeErrorDetail( TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, @@ -5694,6 +5789,85 @@ async function waitForRefreshOnAbortedPayment( return didTransition ? TaskRunResult.progress() : TaskRunResult.backoff(); } +export function classifyMerchantAbortRecoveryEvidence( + payCoinSelection: WalletCoinSelection, + abortRefresh: WalletRefreshGroup, + refundItems: WalletRefundItem[], + relatedRefreshes: WalletRefreshGroup[], +): "recovered" | "pending" | "failed" { + if ( + abortRefresh.oldCoinPubs.length !== + abortRefresh.expectedOutputPerCoin.length || + abortRefresh.oldCoinPubs.length !== abortRefresh.statusPerCoin.length + ) { + return "failed"; + } + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const abortCoinIndex = abortRefresh.oldCoinPubs.indexOf(coinPub); + if (abortCoinIndex < 0) { + return "failed"; + } + const abortCoinStatus = abortRefresh.statusPerCoin[abortCoinIndex]; + if (abortCoinStatus !== RefreshCoinStatus.Finished) { + return abortCoinStatus === RefreshCoinStatus.Pending || + abortCoinStatus === RefreshCoinStatus.PendingRedenominate + ? "pending" + : "failed"; + } + const abortOutput = abortRefresh.expectedOutputPerCoin[abortCoinIndex]; + if (!abortOutput) { + return "failed"; + } + if (Amounts.isNonZero(abortOutput)) { + continue; + } + const refundItem = refundItems.find( + (x) => + x.coinPub === coinPub && + x.rtxid === 0 && + x.status !== RefundItemStatus.Failed, + ); + if (!refundItem) { + return "failed"; + } + if (refundItem.status === RefundItemStatus.Pending) { + return "pending"; + } + const refundRefresh = relatedRefreshes.find( + (x) => + x.reason === RefreshReason.Refund && x.oldCoinPubs.includes(coinPub), + ); + if (!refundRefresh) { + return "failed"; + } + const refundCoinIndex = refundRefresh.oldCoinPubs.indexOf(coinPub); + if ( + refundCoinIndex >= refundRefresh.statusPerCoin.length || + refundCoinIndex >= refundRefresh.expectedOutputPerCoin.length + ) { + return "failed"; + } + const refundRecovery = classifyRecoveryRefresh( + refundRefresh.operationStatus, + ); + if ( + refundRecovery === "pending" || + refundRefresh.statusPerCoin[refundCoinIndex] === RefreshCoinStatus.Pending + ) { + return "pending"; + } + if ( + refundRecovery !== "recovered" || + refundRefresh.statusPerCoin[refundCoinIndex] !== + RefreshCoinStatus.Finished + ) { + return "failed"; + } + } + return "recovered"; +} + /** * Release token reservations owned by one payment. * @@ -5807,7 +5981,11 @@ async function processPurchaseAcceptRefund( h_contract: download.contractTermsHash, }, ); - if (isOrderUnknown(request) || request.case === HttpStatusCode.NoContent) { + if ( + isOrderUnknown(request) || + request.case === HttpStatusCode.NoContent || + request.case === HttpStatusCode.Gone + ) { // The merchant deleted the order, or has no refund for this payment // after all, so nothing can be collected. The payment itself stays // valid. @@ -5819,7 +5997,12 @@ async function processPurchaseAcceptRefund( } p.purchaseStatus = PurchaseStatus.Done; p.refundAmountAwaiting = undefined; - await h.update(p, "accept-refund-none"); + await h.update( + p, + request.case === HttpStatusCode.Gone + ? "accept-refund-expired" + : "accept-refund-none", + ); }); return TaskRunResult.progress(); } @@ -5906,7 +6089,7 @@ async function computeRefreshRequest( tx: WalletDbTransaction, items: WalletRefundItem[], ): Promise<CoinRefreshRequest[]> { - const refreshCoins: CoinRefreshRequest[] = []; + const refreshAmounts = new Map<string, AmountJson>(); const coins = await tx.getCoinsByPubs(items.map((item) => item.coinPub)); const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); const denoms = await getDenomInfos(wex, tx, coins); @@ -5924,13 +6107,19 @@ async function computeRefreshRequest( item.refundAmount, denomInfo.feeRefund, ).amount; - refreshCoins.push({ - amount: Amounts.stringify(refundedAmount), - coinPub: item.coinPub, - }); + const previous = + refreshAmounts.get(item.coinPub) ?? + Amounts.zeroOfAmount(refundedAmount); + refreshAmounts.set( + item.coinPub, + Amounts.add(previous, refundedAmount).amount, + ); } } - return refreshCoins; + return Array.from(refreshAmounts, ([coinPub, amount]) => ({ + coinPub, + amount: Amounts.stringify(amount), + })); } /** @@ -5940,7 +6129,7 @@ function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus { if (rf.type === "success") { return RefundItemStatus.Done; } else { - if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + if (isTransientRefundStatus(rf.exchange_status)) { return RefundItemStatus.Pending; } else { return RefundItemStatus.Failed; @@ -5948,17 +6137,22 @@ function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus { } } +export function isTransientRefundStatus(status: number): boolean { + return ( + status === HttpStatusCode.RequestTimeout || + status === HttpStatusCode.TooManyRequests || + (status >= 500 && status <= 599) + ); +} + export function getRefundTotals( refundGroups: WalletRefundGroup[], currency: string, ): { raw: AmountString; effective: AmountString } { - // Only refunds that went through, or are still going through, count; - // a failed or aborted group refunded nothing. - const counted = refundGroups.filter( - (x) => - x.status === RefundGroupStatus.Done || - x.status === RefundGroupStatus.Pending, - ); + // Group amounts contain only authenticated successful items. A group can + // therefore be Failed because one sibling failed while still carrying + // value that was successfully refunded and must remain visible. + const counted = refundGroups; return { raw: Amounts.stringify( Amounts.sumOrZero( @@ -6063,9 +6257,10 @@ async function storeRefunds( } let newGroup: WalletRefundGroup | undefined = undefined; - // Pending, but not part of an aborted refund group. - let numPendingItemsTotal = 0; const newGroupRefunds: WalletRefundItem[] = []; + const newlySuccessfulRefunds = new Set<string>(); + const refundKey = (coinPub: string, rtxid: number) => + `${coinPub}\0${rtxid}`; for (const rf of refunds) { const oldItem = await tx.getRefundItemByCoinAndRtxid( @@ -6079,10 +6274,13 @@ async function storeRefunds( } if (rf.type === "success") { oldItem.status = RefundItemStatus.Done; + oldItem.refundAmount = rf.refund_amount; + oldItem.executionTime = timestampProtocolToDb(rf.execution_time); + oldItem.obtainedTime = timestampPreciseToDb(now); + newlySuccessfulRefunds.add(refundKey(oldItem.coinPub, oldItem.rtxid)); } else { - if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + if (isTransientRefundStatus(rf.exchange_status)) { oldItem.status = RefundItemStatus.Pending; - numPendingItemsTotal += 1; } else { oldItem.status = RefundItemStatus.Failed; } @@ -6112,33 +6310,16 @@ async function storeRefunds( rtxid: rf.rtransaction_id, status, }; - if (status === RefundItemStatus.Pending) { - numPendingItemsTotal += 1; - } newGroupRefunds.push(newItem); + if (status === RefundItemStatus.Done) { + newlySuccessfulRefunds.add(refundKey(newItem.coinPub, newItem.rtxid)); + } await tx.upsertRefundItem(newItem); } } - // Now that we know all the refunds for the new refund group, - // we can compute the raw/effective amounts. + // Create the group before the common item-level accounting below. if (newGroup) { - const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); - const refreshCoins = await computeRefreshRequest( - wex, - tx, - newGroupRefunds, - ); - const outInfo = await calculateRefreshOutput( - wex, - tx, - currency, - refreshCoins, - ); - setRefundGroupEffectiveAmount(newGroup, outInfo.outputPerCoin); - newGroup.amountRaw = Amounts.stringify( - Amounts.sumOrZero(currency, amountsRaw).amount, - ); const refundCtx = new RefundTransactionContext( wex, newGroup.refundGroupId, @@ -6159,6 +6340,7 @@ async function storeRefunds( myPurchase.proposalId, ); + let numPendingItemsTotal = 0; for (const refundGroup of refundGroups) { const refundCtx = new RefundTransactionContext( wex, @@ -6167,23 +6349,61 @@ async function storeRefunds( switch (refundGroup.status) { case RefundGroupStatus.Aborted: case RefundGroupStatus.Expired: - case RefundGroupStatus.Failed: case RefundGroupStatus.Done: continue; + case RefundGroupStatus.Failed: case RefundGroupStatus.Pending: break; default: assertUnreachable(refundGroup.status); } const items = await tx.getRefundItemsByGroup(refundGroup.refundGroupId); - const refreshCoins = await computeRefreshRequest(wex, tx, items); - const outInfo = await calculateRefreshOutput( + const successfulItems = items.filter( + (x) => x.status === RefundItemStatus.Done, + ); + refundGroup.amountRaw = Amounts.stringify( + Amounts.sumOrZero( + currency, + successfulItems.map((x) => x.refundAmount), + ).amount, + ); + const newlySuccessfulItems = successfulItems.filter((x) => + newlySuccessfulRefunds.has(refundKey(x.coinPub, x.rtxid)), + ); + const newlyRefundedCoins = await computeRefreshRequest( wex, tx, - currency, - refreshCoins, + newlySuccessfulItems, ); - setRefundGroupEffectiveAmount(refundGroup, outInfo.outputPerCoin); + if (newlyRefundedCoins.length > 0) { + const outInfo = await calculateRefreshOutput( + wex, + tx, + currency, + newlyRefundedCoins, + ); + refundGroup.amountEffective = Amounts.stringify( + Amounts.add( + refundGroup.amountEffective, + Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, + ).amount, + ); + const paymentTransactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: myPurchase.proposalId, + }); + const refreshResult = await createRefreshGroup( + wex, + tx, + currency, + newlyRefundedCoins, + RefreshReason.Refund, + // Refunds are pseudo-transactions, so their recovery refresh is + // associated with the payment transaction. + paymentTransactionId, + ); + refundGroup.refreshGroupId = refreshResult.refreshGroupId; + } let numPending = 0; let numFailed = 0; for (const item of items) { @@ -6194,6 +6414,7 @@ async function storeRefunds( numFailed++; } } + numPendingItemsTotal += numPending; const oldTxState: TransactionState = computeRefundTransactionState(refundGroup); const oldStId = refundGroup.status; @@ -6206,7 +6427,6 @@ async function storeRefunds( } await tx.upsertRefundGroup(refundGroup); await refundCtx.updateTransactionMeta(tx); - const refreshCoins = await computeRefreshRequest(wex, tx, items); const newTxState: TransactionState = computeRefundTransactionState(refundGroup); const newStId = refundGroup.status; @@ -6218,20 +6438,8 @@ async function storeRefunds( newStId, oldStId, }); - await createRefreshGroup( - wex, - tx, - Amounts.currencyOf(amountRaw), - refreshCoins, - RefreshReason.Refund, - // Since refunds are really just pseudo-transactions, - // the originating transaction for the refresh is the payment transaction. - constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: myPurchase.proposalId, - }), - ); } else { + refundGroup.status = RefundGroupStatus.Pending; await tx.upsertRefundGroup(refundGroup); } } diff --git a/packages/taler-wallet-core/src/pay-paivana-common.ts b/packages/taler-wallet-core/src/pay-paivana-common.ts @@ -0,0 +1,63 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. +*/ + +import { + TalerError, + TalerErrorCode, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +/** Validate and canonicalize a protected Paivana resource URL. */ +export function canonicalizePaivanaUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "url" }, + `invalid Paivana URL "${url}"`, + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "url" }, + "Paivana payments require an HTTP(S) URL", + ); + } + if (parsed.username !== "" || parsed.password !== "") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "url" }, + "Paivana URLs must not contain credentials", + ); + } + // Fragments are not sent in HTTP requests and therefore cannot be part of + // the URL reconstructed by the Paivana reverse proxy. + parsed.hash = ""; + return parsed.href; +} + +/** Check the externally visible DD 76 session-id shape and expiration. */ +export function isValidPaivanaSessionId( + sessionId: string, + now: TalerProtocolTimestamp = TalerProtocolTimestamp.now(), +): boolean { + const match = /^([0-9]+)-[A-Za-z0-9_-]{43}$/.exec(sessionId); + if (!match || now.t_s === "never") { + return false; + } + const expiration = Number(match[1]); + return Number.isSafeInteger(expiration) && expiration >= now.t_s; +} diff --git a/packages/taler-wallet-core/src/pay-paivana.ts b/packages/taler-wallet-core/src/pay-paivana.ts @@ -50,6 +50,9 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext } from "./wallet.js"; +import { canonicalizePaivanaUrl } from "./pay-paivana-common.js"; + +export { canonicalizePaivanaUrl } from "./pay-paivana-common.js"; const MAX_PAIVANA_PICKUP_SECONDS = 60 * 60 * 24 * 365 * 100; @@ -66,38 +69,6 @@ function malformedPaivanaResponse( ); } -/** Validate and canonicalize a protected Paivana resource URL. */ -export function canonicalizePaivanaUrl(url: string): string { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, - { parameter: "url" }, - `invalid Paivana URL "${url}"`, - ); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, - { parameter: "url" }, - "Paivana payments require an HTTP(S) URL", - ); - } - if (parsed.username !== "" || parsed.password !== "") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, - { parameter: "url" }, - "Paivana URLs must not contain credentials", - ); - } - // Fragments are not sent in HTTP requests and therefore cannot be part of - // the URL reconstructed by the Paivana reverse proxy. - parsed.hash = ""; - return parsed.href; -} - /** Encode a Paivana expiration as an unsigned big-endian microsecond value. */ export function encodePaivanaExpiration( expiration: TalerProtocolTimestamp, diff --git a/packages/taler-wallet-core/src/pay-template.test.ts b/packages/taler-wallet-core/src/pay-template.test.ts @@ -20,9 +20,13 @@ import { HostPortPath, TalerPayTemplateUri, TalerUriAction, + TalerUris, TemplateContractDetails, TemplateContractDetailsDefaults, TemplateType, + TalerError, + TalerErrorCode, + WalletTemplateDetailsResponse, } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; @@ -143,3 +147,71 @@ test("template order creation is not retried after an ambiguous failure", async ); assert.strictEqual(requestCount, 1); }); + +test("Paivana instantiation requires its session and fulfillment URL", async () => { + await assert.rejects( + instantiateTemplateRaw( + {} as WalletExecutionContext, + { + talerPayTemplateUri: + "taler://pay-template/merchant.example/template-id", + }, + { + template_contract: { + template_type: TemplateType.PAIVANA, + choices: [], + }, + }, + ), + (e: unknown) => + e instanceof TalerError && + e.errorDetail.code === TalerErrorCode.WALLET_TALER_URI_MALFORMED, + ); +}); + +test("Paivana instantiation rejects unredeemable URI bindings", async () => { + const templateInfo: WalletTemplateDetailsResponse = { + template_contract: { + template_type: TemplateType.PAIVANA, + choices: [], + }, + }; + const makeUri = (sessionId: string, fulfillmentUrl: string) => + TalerUris.stringify({ + ...paivanaUri("KUDOS:1" as AmountString), + sessionId, + fulfillmentUrl, + }); + const futureSession = `${Math.floor(Date.now() / 1000) + 3600}-${"A".repeat(43)}`; + + await assert.rejects( + instantiateTemplateRaw( + {} as WalletExecutionContext, + { + talerPayTemplateUri: makeUri( + futureSession, + "https://user@example.com/protected", + ), + }, + templateInfo, + ), + (e: unknown) => + e instanceof TalerError && + e.errorDetail.code === TalerErrorCode.WALLET_TALER_URI_MALFORMED, + ); + await assert.rejects( + instantiateTemplateRaw( + {} as WalletExecutionContext, + { + talerPayTemplateUri: makeUri( + "1-not-a-dd76-session", + "https://example.com/protected", + ), + }, + templateInfo, + ), + (e: unknown) => + e instanceof TalerError && + e.errorDetail.code === TalerErrorCode.WALLET_TALER_URI_MALFORMED, + ); +}); diff --git a/packages/taler-wallet-core/src/pay-template.ts b/packages/taler-wallet-core/src/pay-template.ts @@ -44,6 +44,10 @@ import { runWithProgressRetries, } from "./progress.js"; import { WalletExecutionContext } from "./wallet.js"; +import { + canonicalizePaivanaUrl, + isValidPaivanaSessionId, +} from "./pay-paivana-common.js"; const logger = new Logger("pay-template.ts"); @@ -301,10 +305,46 @@ export async function instantiateTemplateRaw( break; } case TalerMerchantApi.TemplateType.PAIVANA: { + if (!parsedUri.sessionId || !parsedUri.fulfillmentUrl) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { + talerUri: req.talerPayTemplateUri, + expectedAction: TalerUriAction.PayTemplate, + }, + "Paivana payment template URI requires session_id and fulfillment_url", + ); + } + let website: string; + try { + website = canonicalizePaivanaUrl(parsedUri.fulfillmentUrl); + } catch { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { + talerUri: req.talerPayTemplateUri, + expectedAction: TalerUriAction.PayTemplate, + }, + "Paivana payment template URI has an invalid fulfillment_url", + ); + } + if ( + website !== parsedUri.fulfillmentUrl || + !isValidPaivanaSessionId(parsedUri.sessionId) + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { + talerUri: req.talerPayTemplateUri, + expectedAction: TalerUriAction.PayTemplate, + }, + "Paivana template URI has a non-canonical URL or invalid session", + ); + } templateDetails = { template_type: TemplateType.PAIVANA, - paivana_id: parsedUri.sessionId!, - website: parsedUri.fulfillmentUrl!, + paivana_id: parsedUri.sessionId, + website, }; break; } diff --git a/packages/taler-wallet-core/src/tokenFamilies.ts b/packages/taler-wallet-core/src/tokenFamilies.ts @@ -130,6 +130,7 @@ export async function listDiscounts( : await tx.listTokens(); return candidates .filter((t) => isTokenValid(t)) + .filter((t) => !isTokenInUse(t)) .filter((t) => t.kind === MerchantContractTokenKind.Discount) .filter( (t) => !tokenIssuePubHash || t.tokenIssuePubHash === tokenIssuePubHash, @@ -157,6 +158,7 @@ export async function listSubscriptions( : await tx.listTokens(); return candidates .filter((t) => isTokenValid(t)) + .filter((t) => !isTokenInUse(t)) .filter((t) => t.kind === MerchantContractTokenKind.Subscription) .filter( (t) => !tokenIssuePubHash || t.tokenIssuePubHash === tokenIssuePubHash,