taler-typescript-core

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

commit 8b1b040ce159c371a6b9ffe57f9b85b4e7deb987
parent e09adaa044f648e5d0e5b7a74756b5944f2dd6e2
Author: Florian Dold <dold@taler.net>
Date:   Wed, 26 Aug 2026 01:56:25 +0200

wallet-webui: follow repurchase payment replays

Diffstat:
Mpackages/wallet-webui/src/routes/App.tsx | 68+++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/wallet-webui/src/routes/payment-model.ts | 34++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/payment-model.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 134 insertions(+), 19 deletions(-)

diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -110,6 +110,8 @@ import { import { connectionStatus } from "../stores/ui.js"; import { asynchronousConfirmPayRequest, + paymentRepurchaseTarget, + paymentResultState, paymentReviewView, safeWebUrl, } from "./payment-model.js"; @@ -3677,11 +3679,33 @@ function PaymentRoute() { WalletApiOperation.GetTransactionById, { transactionId, includeContractTerms: true }, ); + const payment = + query.data?.type === TransactionType.Payment ? query.data : undefined; + const repurchaseTransactionId = paymentRepurchaseTarget(payment); + const repurchaseQuery = useWalletQuery( + connection, + WalletApiOperation.GetTransactionById, + { + transactionId: repurchaseTransactionId ?? "", + includeContractTerms: false, + }, + { enabled: repurchaseTransactionId !== undefined }, + ); + const replayPayment = + repurchaseQuery.data?.type === TransactionType.Payment + ? repurchaseQuery.data + : undefined; + const replayDone = + repurchaseTransactionId !== undefined && + replayPayment?.txState.major === TransactionMajorState.Done; + useEffect(() => { + if (!replayDone) return; + void completeActionBestEffort(platform.actionInbox, actionId); + }, [actionId, platform, replayDone]); const waitingForContract = - query.data?.type === TransactionType.Payment && - query.data.txState.minor === TransactionMinorState.ClaimProposal && - query.data.txState.working === true && - !query.data.contractTerms; + payment?.txState.minor === TransactionMinorState.ClaimProposal && + payment.txState.working === true && + !payment.contractTerms; const refreshPayment = query.mutate; useEffect(() => { if (!waitingForContract) return; @@ -3748,15 +3772,28 @@ function PaymentRoute() { ); if (transaction.txState.major === TransactionMajorState.Dialog) return <PaymentDialog transaction={transaction} actionId={actionId} />; - const done = transaction.txState.major === TransactionMajorState.Done; - const pending = transaction.txState.major === TransactionMajorState.Pending; - const terminalError = transactionErrorPresentation( - transaction, - i18n.str`Payment is ${transactionStateView(transaction).label}.`, - ); + const resultState = paymentResultState(transaction, replayPayment); + const statusTransaction = repurchaseTransactionId + ? (replayPayment ?? transaction) + : transaction; + const statusFallback = i18n.str`Payment is ${transactionStateView(statusTransaction).label}.`; + const repurchaseError = + repurchaseQuery.error && repurchaseTransactionId + ? errorFromException( + repurchaseQuery.error, + i18n.str`The payment transaction is unavailable.`, + ) + : repurchaseTransactionId && repurchaseQuery.data && !replayPayment + ? localError(i18n.str`The payment transaction is unavailable.`) + : undefined; + const terminalError = + resultState === "error" + ? (transactionErrorPresentation(statusTransaction, statusFallback) ?? + localError(statusFallback)) + : undefined; return ( <PaymentScreen - state={done ? `done` : pending ? "pending" : "error"} + state={repurchaseError ? "error" : resultState} merchantName={terms.merchant.name} merchantWebsite={terms.merchant.website} summary={terms.summary} @@ -3766,14 +3803,7 @@ function PaymentRoute() { safeWebUrl(terms.fulfillment_url) ? terms.fulfillment_url : undefined } posConfirmation={transaction.posConfirmation} - error={ - done || pending - ? undefined - : (terminalError ?? - localError( - i18n.str`Payment is ${transactionStateView(transaction).label}.`, - )) - } + error={repurchaseError ?? terminalError} onSelectChoice={() => {}} onConfirm={() => {}} onWithdraw={() => navigate("/withdraw")} diff --git a/packages/wallet-webui/src/routes/payment-model.ts b/packages/wallet-webui/src/routes/payment-model.ts @@ -5,10 +5,13 @@ import { MerchantContractOutputType, MerchantContractVersion, TokenAvailabilityHint, + TransactionMajorState, + TransactionMinorState, isSafeExternalUrl, type ConfirmPayRequest, type PaymentInsufficientBalanceDetails, type GetChoicesForPaymentResult, + type TransactionIdStr, type TransactionPayment, } from "@gnu-taler/taler-util"; import type { @@ -41,6 +44,37 @@ export function asynchronousConfirmPayRequest( return { ...request, noWait: true }; } +/** Return the original payment that wallet-core is replaying for a repurchase. */ +export function paymentRepurchaseTarget( + transaction: TransactionPayment | undefined, +): TransactionIdStr | undefined { + if ( + transaction?.txState.major !== TransactionMajorState.Failed || + transaction.txState.minor !== TransactionMinorState.Repurchase + ) { + return undefined; + } + return transaction.repurchaseTransactionId; +} + +/** Map a payment and its optional repurchase replay to the payment screen. */ +export function paymentResultState( + transaction: TransactionPayment, + replay: TransactionPayment | undefined, +): "pending" | "done" | "error" { + if (paymentRepurchaseTarget(transaction)) { + if (!replay) return "pending"; + if (replay.txState.major === TransactionMajorState.Done) return "done"; + if (replay.txState.major === TransactionMajorState.Pending) + return "pending"; + return "error"; + } + if (transaction.txState.major === TransactionMajorState.Done) return "done"; + if (transaction.txState.major === TransactionMajorState.Pending) + return "pending"; + return "error"; +} + export function paymentReviewView( transaction: TransactionPayment, result: GetChoicesForPaymentResult, diff --git a/packages/wallet-webui/test/payment-model.test.ts b/packages/wallet-webui/test/payment-model.test.ts @@ -7,12 +7,17 @@ import { MerchantContractOutputType, MerchantContractTokenKind, TokenAvailabilityHint, + TransactionMajorState, + TransactionMinorState, + TransactionType, type GetChoicesForPaymentResult, type TransactionIdStr, type TransactionPayment, } from "@gnu-taler/taler-util"; import { asynchronousConfirmPayRequest, + paymentRepurchaseTarget, + paymentResultState, paymentReviewView, safeWebUrl, } from "../src/routes/payment-model.js"; @@ -31,6 +36,52 @@ test("payment confirmation always returns immediately", () => { ); }); +test("repurchases follow the original payment replay instead of failing", () => { + const originalId = "txn:payment:original" as TransactionIdStr; + const repurchase = { + type: TransactionType.Payment, + txState: { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Repurchase, + }, + repurchaseTransactionId: originalId, + } as TransactionPayment; + const replay = (major: TransactionMajorState) => + ({ + type: TransactionType.Payment, + txState: { major }, + }) as TransactionPayment; + + assert.equal(paymentRepurchaseTarget(repurchase), originalId); + assert.equal(paymentResultState(repurchase, undefined), "pending"); + assert.equal( + paymentResultState(repurchase, replay(TransactionMajorState.Pending)), + "pending", + ); + assert.equal( + paymentResultState(repurchase, replay(TransactionMajorState.Done)), + "done", + ); + assert.equal( + paymentResultState(repurchase, replay(TransactionMajorState.Failed)), + "error", + ); + assert.equal( + paymentRepurchaseTarget({ + ...repurchase, + repurchaseTransactionId: undefined, + }), + undefined, + ); + assert.equal( + paymentResultState( + { ...repurchase, repurchaseTransactionId: undefined }, + undefined, + ), + "error", + ); +}); + test("payment review preserves core choice availability and rejects unsafe fulfillment URLs", () => { const transaction = { contractTerms: {