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:
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");