taler-typescript-core

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

commit 88b4748e9af7921101045d57919bdbbb0e1a6cae
parent 4b71391d0baab19a2d3803007808a66eecd6fec7
Author: Florian Dold <dold@taler.net>
Date:   Tue,  1 Sep 2026 15:33:24 +0200

wallet: pause legally refused merchant payments

Issue: https://bugs.taler.net/n/11416

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/wallet-webui/src/routes/App.tsx | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/wallet-webui/src/routes/payment-model.ts | 6+++++-
Mpackages/wallet-webui/src/screens/PaymentScreen.tsx | 32++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/src/ui/error.ts | 7+++++++
Mpackages/wallet-webui/test/error-presentation.test.tsx | 29+++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/payment-model.test.ts | 11+++++++++++
Mpackages/wallet-webui/test/screens.test.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 384 insertions(+), 18 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -61,6 +61,7 @@ import { isPaymentSessionComplete, isSharedPurchase, isStableMerchantPayFailure, + PayMerchantTransactionContext, recoveredPayFailureStatus, releasePaymentTokensInTx, setRefundGroupEffectiveAmount, @@ -486,6 +487,61 @@ test("merchant pay failures distinguish stable outcomes from retryable outages", } }); +test("legal refusal pauses payment without replacing its coins", async () => { + const runner = await makeIdbRunner(); + const proposalId = "legal-refusal"; + const purchase = claimPurchase(proposalId, PurchaseStatus.PendingPaying); + purchase.payInfo = { + payCoinSelection: { + coinPubs: ["coin-one", "coin-two"], + coinContributions: ["TESTKUDOS:1", "TESTKUDOS:2"], + }, + payCoinSelectionUid: "selection-one", + totalPayCost: "TESTKUDOS:3", + }; + const originalPayInfo = structuredClone(purchase.payInfo); + let stoppedTaskId: string | undefined; + const wex = { + async runWalletDbTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runner.runReadWriteTx(f); + }, + taskScheduler: { + stopShepherdTask(taskId: string): void { + stoppedTaskId = taskId; + }, + }, + } as unknown as WalletExecutionContext; + try { + await runner.runReadWriteTx((tx) => tx.upsertPurchase(purchase)); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const reason = { + code: TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED, + exchangeBaseUrls: ["https://exchange.example/"], + }; + + assert.strictEqual(await ctx.suspendPayForLegalRefusal(reason), true); + + const stored = await runner.runReadWriteTx(async (tx) => ({ + purchase: await tx.getPurchase(proposalId), + retry: await tx.getOperationRetry(ctx.taskId), + refreshGroups: await tx.listAllRefreshGroups(), + })); + assert.strictEqual( + stored.purchase?.purchaseStatus, + PurchaseStatus.SuspendedPaying, + ); + assert.deepStrictEqual(stored.purchase?.payInfo, originalPayInfo); + assert.deepStrictEqual(stored.retry?.lastError, reason); + assert.strictEqual(stored.retry?.retryInfo.retryCounter, 0); + assert.deepStrictEqual(stored.refreshGroups, []); + assert.strictEqual(stoppedTaskId, ctx.taskId); + } finally { + await runner.close(); + } +}); + test("already-paid recovery retains the paid-by-other terminal state", () => { assert.strictEqual( recoveredPayFailureStatus({ diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -121,6 +121,7 @@ import { } from "./coinSelection.js"; import { constructTaskIdentifier, + DbRetryInfo, genericWaitForState, genericWaitForStateVal, getGenericRecordHandle, @@ -580,6 +581,47 @@ export class PayMerchantTransactionContext implements TransactionContext { }); } + /** + * Pause a payment after the merchant reports that an exchange cannot + * process it for legal reasons. + * + * A 451 response is not evidence that the selected coins are unusable. + * Refreshing or reselecting them here would prevent an idempotent retry of + * the same deposit permissions once the merchant has resolved the issue. + * Instead, retain the exact payInfo and record the refusal as the current + * transaction error. The task remains stopped until the user explicitly + * resumes (or aborts) the payment. + */ + async suspendPayForLegalRefusal(reason: TalerErrorDetail): Promise<boolean> { + const { wex } = this; + return await wex.runWalletDbTx(async (tx) => { + const [purchase, h] = await this.getRecordHandle(tx); + if (!purchase) { + return false; + } + const nextStatus = getPayMerchantSuspendTransition( + purchase.purchaseStatus, + ); + if ( + nextStatus !== PurchaseStatus.SuspendedPaying && + nextStatus !== PurchaseStatus.SuspendedPayingReplay + ) { + return false; + } + purchase.purchaseStatus = nextStatus; + await tx.upsertOperationRetry({ + id: this.taskId, + lastError: reason, + retryInfo: DbRetryInfo.reset(), + }); + await h.update(purchase, "legal-refusal-suspend"); + tx.scheduleOnCommit(() => + wex.taskScheduler.stopShepherdTask(this.taskId), + ); + return true; + }); + } + async failTransaction( fromSt: PurchaseStatus, reason?: TalerErrorDetail, @@ -2922,6 +2964,18 @@ async function waitPaymentResult( }; } + if ( + (purchase.purchaseStatus === PurchaseStatus.SuspendedPaying || + purchase.purchaseStatus === PurchaseStatus.SuspendedPayingReplay) && + txRes.retryRecord?.lastError + ) { + return { + type: ConfirmPayResultType.Pending, + lastError: txRes.retryRecord.lastError, + transactionId: ctx.transactionId, + }; + } + if (txRes.retryRecord && txRes.retryRecord.retryInfo.retryCounter > 0) { logger.info( `stopping waiting for payment result, got retry record: ${j2s( @@ -4104,24 +4158,29 @@ async function processPurchasePay( } case HttpStatusCode.UnavailableForLegalReasons: { logger.warn(`exchange refused merchant payment for legal reasons`); - const legalReason: TalerErrorDetail = + if ( resp.body.code === TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED || resp.body.code === TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION - ? { - code: resp.body.code, - exchangeBaseUrls: resp.body.exchange_base_urls, - } - : { - code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, - message: `merchant returned invalid legal-refusal error code ${resp.body.code}`, - }; + ) { + const legalReason: TalerErrorDetail = { + code: resp.body.code, + exchangeBaseUrls: resp.body.exchange_base_urls, + }; + const suspended = await ctx.suspendPayForLegalRefusal(legalReason); + return suspended + ? TaskRunResult.cancelled() + : TaskRunResult.finished(); + } return repairPayAfterExchangeFailure( wex, ctx, resp.body.exchange_base_urls, - legalReason, + { + code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + message: `merchant returned invalid legal-refusal error code ${resp.body.code}`, + }, ); } case HttpStatusCode.Gone: diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -3732,12 +3732,16 @@ function PaymentTemplateRoute() { function PaymentRoute() { const { connection, platform } = useServices(); + const callMutation = useWalletMutation(connection); const [, params] = useRoute("/pay/:transactionId/:actionId"); const [, navigate] = useLocation(); const transactionId = params?.transactionId ?? ""; const actionId = params?.actionId ?? ""; const [fulfillmentTarget, setFulfillmentTarget] = useState<ExternalNavigationTarget>(); + const [actionError, setActionError] = useState<ErrorPresentation>(); + const [resuming, setResuming] = useState(false); + const [cancelling, setCancelling] = useState(false); useEffect(() => { let active = true; setFulfillmentTarget(undefined); @@ -3774,6 +3778,8 @@ function PaymentRoute() { repurchaseQuery.data?.type === TransactionType.Payment ? repurchaseQuery.data : undefined; + const paymentActionTransactionId = + replayPayment?.transactionId ?? transactionId; const replayDone = repurchaseTransactionId !== undefined && replayPayment?.txState.major === TransactionMajorState.Done; @@ -3807,6 +3813,65 @@ function PaymentRoute() { void completeActionBestEffort(platform.actionInbox, actionId); navigate("/"); }; + const resumePayment = async () => { + if (resuming || cancelling) return; + setResuming(true); + setActionError(undefined); + try { + const result = await callMutation( + WalletApiOperation.ResumeTransaction, + { transactionId: paymentActionTransactionId }, + ["transactions"], + ); + if (Result.isError(result)) { + setActionError( + walletCoreError( + result.detail, + i18n.str`The payment could not be resumed.`, + ), + ); + } + } catch (cause) { + setActionError( + errorFromException(cause, i18n.str`Payment resume failed`), + ); + } finally { + setResuming(false); + } + }; + const cancelPausedPayment = async () => { + if (resuming || cancelling) return; + setCancelling(true); + setActionError(undefined); + try { + const outcome = await abortTransactionBeforeCompleting( + () => + callMutation( + WalletApiOperation.AbortTransaction, + { transactionId: paymentActionTransactionId }, + ["balances", "transactions"], + ), + () => void completeActionBestEffort(platform.actionInbox, actionId), + ); + if (!outcome.cancelled) { + setActionError( + outcome.kind === "wallet" + ? walletCoreError( + outcome.detail, + i18n.str`The payment could not be cancelled.`, + ) + : errorFromException( + outcome.cause, + i18n.str`Payment cancellation failed`, + ), + ); + return; + } + navigate("/"); + } finally { + setCancelling(false); + } + }; if (query.isLoading || waitingForContract || fulfillmentTarget === undefined) return ( <PaymentScreen @@ -3896,6 +3961,12 @@ function PaymentRoute() { ? (transactionErrorPresentation(statusTransaction, statusFallback) ?? localError(statusFallback)) : undefined; + const pausedError = + resultState === "paused" + ? (actionError ?? + transactionErrorPresentation(statusTransaction, statusFallback) ?? + localError(statusFallback)) + : undefined; return ( <PaymentScreen state={repurchaseError ? "error" : resultState} @@ -3908,11 +3979,18 @@ function PaymentRoute() { safeWebUrl(terms.fulfillment_url) ? terms.fulfillment_url : undefined } posConfirmation={transaction.posConfirmation} - error={repurchaseError ?? terminalError} + error={repurchaseError ?? pausedError ?? terminalError} + resuming={resuming} + cancelling={cancelling} onSelectChoice={() => {}} onConfirm={() => {}} onWithdraw={() => navigate("/withdraw")} - onCancel={completeAndReturn} + onResume={() => void resumePayment()} + onCancel={ + resultState === "paused" + ? () => void cancelPausedPayment() + : completeAndReturn + } onOpenFulfillment={() => { if (safeWebUrl(terms.fulfillment_url)) void platform.openExternal(terms.fulfillment_url, fulfillmentTarget); @@ -3962,10 +4040,11 @@ function PaymentDialog(props: { const [selectedChoice, setSelectedChoice] = useState<number>(); const [useDonau, setUseDonau] = useState(false); const [state, setState] = useState< - "ready" | "paying" | "handoff" | "pending" | "done" | "error" + "ready" | "paying" | "handoff" | "pending" | "paused" | "done" | "error" >("ready"); const [error, setError] = useState<ErrorPresentation>(); const [cancelling, setCancelling] = useState(false); + const [resuming, setResuming] = useState(false); const [unclaiming, setUnclaiming] = useState(false); const [talerPayUri, setTalerPayUri] = useState<string>(); const automaticStarted = useRef(false); @@ -3975,6 +4054,7 @@ function PaymentDialog(props: { setState("ready"); setError(undefined); setCancelling(false); + setResuming(false); setUnclaiming(false); setTalerPayUri(undefined); automaticStarted.current = false; @@ -4010,10 +4090,21 @@ function PaymentDialog(props: { setState("error"); return; } - setState( - result.value.type === ConfirmPayResultType.Done ? "done" : "pending", - ); - void completeActionBestEffort(platform.actionInbox, props.actionId); + if (result.value.type === ConfirmPayResultType.Done) { + setState("done"); + void completeActionBestEffort(platform.actionInbox, props.actionId); + } else if (result.value.lastError) { + setError( + walletCoreError( + result.value.lastError, + i18n.str`The merchant cannot process this payment right now.`, + ), + ); + setState("paused"); + } else { + setState("pending"); + void completeActionBestEffort(platform.actionInbox, props.actionId); + } } catch (cause) { setError( errorFromException(cause, i18n.str`Payment confirmation failed`), @@ -4076,6 +4167,34 @@ function PaymentDialog(props: { } navigate("/"); }; + const resume = async () => { + if (resuming || cancelling) return; + setResuming(true); + setError(undefined); + try { + const result = await callMutation( + WalletApiOperation.ResumeTransaction, + { transactionId: props.transaction.transactionId }, + ["transactions"], + ); + if (Result.isError(result)) { + setError( + walletCoreError( + result.detail, + i18n.str`The payment could not be resumed.`, + ), + ); + setState("paused"); + return; + } + setState("pending"); + } catch (cause) { + setError(errorFromException(cause, i18n.str`Payment resume failed`)); + setState("paused"); + } finally { + setResuming(false); + } + }; const unclaim = async () => { if (unclaiming) return; setUnclaiming(true); @@ -4146,6 +4265,7 @@ function PaymentDialog(props: { fulfillmentUrl={fulfillmentUrl} useDonau={useDonau} cancelling={cancelling} + resuming={resuming} unclaiming={unclaiming} onSelectChoice={setSelectedChoice} onToggleDonau={setUseDonau} @@ -4155,6 +4275,7 @@ function PaymentDialog(props: { ) } onConfirm={(collect) => void confirm(activeChoice, collect)} + onResume={() => void resume()} onUnclaim={() => void unclaim()} onWithdraw={() => navigate("/withdraw")} onCancel={ diff --git a/packages/wallet-webui/src/routes/payment-model.ts b/packages/wallet-webui/src/routes/payment-model.ts @@ -91,12 +91,14 @@ export function paymentRepurchaseTarget( export function paymentResultState( transaction: TransactionPayment, replay: TransactionPayment | undefined, -): "pending" | "done" | "handed-off" | "error" { +): "pending" | "paused" | "done" | "handed-off" | "error" { if (paymentRepurchaseTarget(transaction)) { if (!replay) return "pending"; if (replay.txState.major === TransactionMajorState.Done) return "done"; if (replay.txState.major === TransactionMajorState.Pending) return "pending"; + if (replay.txState.major === TransactionMajorState.Suspended) + return "paused"; return "error"; } if ( @@ -106,6 +108,8 @@ export function paymentResultState( if (transaction.txState.major === TransactionMajorState.Done) return "done"; if (transaction.txState.major === TransactionMajorState.Pending) return "pending"; + if (transaction.txState.major === TransactionMajorState.Suspended) + return "paused"; return "error"; } diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx @@ -157,6 +157,7 @@ export type PaymentScreenState = | "handoff" | "handed-off" | "pending" + | "paused" | "done" | "error"; @@ -402,6 +403,7 @@ export function PaymentScreen(props: { posConfirmation?: string; useDonau?: boolean; cancelling?: boolean; + resuming?: boolean; unclaiming?: boolean; talerPayUri?: string; onSelectChoice: (index: number) => void; @@ -410,6 +412,7 @@ export function PaymentScreen(props: { onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw: () => void; onCancel: () => void; + onResume?: () => void; onUnclaim?: () => void; onOpenFulfillment: () => void; onCopyPosConfirmation?: (code: string) => void; @@ -589,6 +592,35 @@ export function PaymentScreen(props: { </Card> )} + {props.state === "paused" && ( + <ErrorCard + title={i18n.str`Payment paused`} + error={ + props.error ?? { + message: i18n.str`The merchant cannot process this payment right now.`, + } + } + > + <div class="mt-4 flex flex-wrap gap-3"> + <Button + onClick={props.onResume} + disabled={props.resuming || props.cancelling} + > + {props.resuming ? i18n.str`Resuming…` : i18n.str`Resume payment`} + </Button> + <Button + tone="secondary" + onClick={props.onCancel} + disabled={props.resuming || props.cancelling} + > + {props.cancelling + ? i18n.str`Cancelling…` + : i18n.str`Cancel payment`} + </Button> + </div> + </ErrorCard> + )} + {props.state === "done" && ( <> <Card class="border-success bg-successContainer text-onSuccessContainer"> diff --git a/packages/wallet-webui/src/ui/error.ts b/packages/wallet-webui/src/ui/error.ts @@ -63,6 +63,10 @@ function errorMessage(detail: TalerErrorDetail, fallback: string): string { return i18n.str`The exchange requires identity verification before this withdrawal can continue.`; case TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED: return i18n.str`This amount exceeds the limit available without identity verification.`; + case TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED: + return i18n.str`The merchant cannot process this payment right now due to legal requirements.`; + case TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION: + return i18n.str`The merchant cannot process this payment right now because an exchange transaction limit was reached.`; case TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE: return i18n.str`No compatible exchange is available for this operation.`; case TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE: @@ -87,6 +91,9 @@ function errorGuidance(detail: TalerErrorDetail): string | undefined { case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: case TalerErrorCode.GENERIC_TIMEOUT: return i18n.str`Check your connection and try again.`; + case TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED: + case TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION: + return i18n.str`After the merchant resolves the issue, resume this payment to retry with the same funds.`; default: return undefined; } diff --git a/packages/wallet-webui/test/error-presentation.test.tsx b/packages/wallet-webui/test/error-presentation.test.tsx @@ -51,6 +51,35 @@ test("wallet errors use friendly mappings and retain the exact detail", () => { assert.equal(stringifyErrorDetail(detail), JSON.stringify(detail, null, 2)); }); +test("merchant legal refusals have distinct actionable messages", () => { + const legallyRefused = { + code: TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED, + exchangeBaseUrls: ["https://exchange.example/"], + }; + const limitViolation = { + code: TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION, + exchangeBaseUrls: ["https://exchange.example/"], + }; + const legalError = walletCoreError(legallyRefused, "fallback"); + const limitError = walletCoreError(limitViolation, "fallback"); + + assert.equal( + legalError.message, + "The merchant cannot process this payment right now due to legal requirements.", + ); + assert.match(limitError.message, /transaction limit/i); + assert.notEqual(legalError.message, limitError.message); + assert.equal( + legalError.guidance, + "After the merchant resolves the issue, resume this payment to retry with the same funds.", + ); + assert.equal(limitError.guidance, legalError.guidance); + assert.strictEqual(legalError.detail, legallyRefused); + assert.strictEqual(limitError.detail, limitViolation); + assert.notEqual(legalError.message, "fallback"); + assert.notEqual(limitError.message, "fallback"); +}); + test("insufficient-balance causes produce distinct actionable messages", () => { const base = { amountRequested: "TESTKUDOS:10", diff --git a/packages/wallet-webui/test/payment-model.test.ts b/packages/wallet-webui/test/payment-model.test.ts @@ -158,6 +158,17 @@ test("a payment handed to another wallet has a neutral result", () => { assert.equal(paymentResultState(transaction, undefined), "handed-off"); }); +test("a suspended payment is shown as paused", () => { + const transaction = { + type: TransactionType.Payment, + txState: { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.SubmitPayment, + }, + } as TransactionPayment; + assert.equal(paymentResultState(transaction, undefined), "paused"); +}); + test("payment review preserves core choice availability and rejects unsafe fulfillment URLs", () => { const transaction = { contractTerms: { diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx @@ -1573,6 +1573,53 @@ test("payment handoff shows a QR code and has a neutral completion screen", asyn await window.happyDOM.abort(); }); +test("a legally paused payment can be resumed or cancelled", async () => { + const window = installDom(); + const { render, cleanup } = await import("@testing-library/preact"); + const userEvent = (await import("@testing-library/user-event")) + .default as unknown as { + setup(options: { document: Document }): { + click(element: Element): Promise<void>; + }; + }; + let resumed = false; + let cancelled = false; + const view = render( + <main> + <PaymentScreen + state="paused" + error={{ + message: + "The merchant cannot process this payment right now due to legal requirements.", + guidance: + "After the merchant resolves the issue, resume this payment to retry with the same funds.", + }} + onSelectChoice={() => {}} + onConfirm={() => {}} + onResume={() => { + resumed = true; + }} + onWithdraw={() => {}} + onCancel={() => { + cancelled = true; + }} + onOpenFulfillment={() => {}} + /> + </main>, + ); + const user = userEvent.setup({ + document: window.document as unknown as Document, + }); + assert.ok(view.getByText(/due to legal requirements/i)); + assert.ok(view.getByText(/retry with the same funds/i)); + await user.click(view.getByRole("button", { name: "Resume payment" })); + await user.click(view.getByRole("button", { name: "Cancel payment" })); + assert.equal(resumed, true); + assert.equal(cancelled, true); + cleanup(); + await window.happyDOM.abort(); +}); + test("payment donation receipt collection is an explicit opt-in", async () => { const window = installDom(); const { render, cleanup } = await import("@testing-library/preact");