taler-typescript-core

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

commit 97df58b5a82c651f856188e7ceecc44ad76ac619
parent 928d321bbb19bff96e27c490bb8986b93afcbfd9
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 14:08:38 +0200

wallet: hand payments off to another wallet

Diffstat:
Apackages/taler-harness/src/integrationtests/test-payment-unclaim.ts | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/http-client/merchant.ts | 30++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 16++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 9+++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 28+++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 16++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 27+++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoTypes.ts | 9+++++++++
Mpackages/taler-wallet-core/src/db/records.ts | 9+++++++++
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 24++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 20++++++++++++++++++++
Mpackages/taler-wallet-core/src/versions.ts | 2+-
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 15+++++++++++++++
Mpackages/wallet-webui/src/routes/App.tsx | 148++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/wallet-webui/src/routes/payment-model.ts | 6+++++-
Mpackages/wallet-webui/src/routes/transaction-model.ts | 14++++++++++++--
Mpackages/wallet-webui/src/screens/PaymentScreen.tsx | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/wallet-webui/src/screens/TransactionDetailScreen.tsx | 41+++++++++++++++++++++++++++++++++++++++--
Mpackages/wallet-webui/src/testing/demo-wallet.ts | 16++++++++++++++++
Mpackages/wallet-webui/test/payment-model.test.ts | 11+++++++++++
Mpackages/wallet-webui/test/screens.test.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
23 files changed, 971 insertions(+), 31 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-payment-unclaim.ts b/packages/taler-harness/src/integrationtests/test-payment-unclaim.ts @@ -0,0 +1,129 @@ +/* + 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. +*/ +import { + AmountString, + ConfirmPayResultType, + Result, + succeedOrThrow, + TalerMerchantInstanceHttpClient, + TalerUriAction, + TalerUris, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + createSimpleTestkudosEnvironmentV3, + createWalletDaemonWithClient, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +export async function runPaymentUnclaimTest(t: GlobalTestState) { + const { + walletClient: firstWallet, + bankClient, + exchange, + merchant, + merchantAdminAccessToken, + } = await createSimpleTestkudosEnvironmentV3(t); + const { walletClient: secondWallet } = await createWalletDaemonWithClient(t, { + name: "wallet2", + }); + await withdrawViaBankV3(t, { + walletClient: secondWallet, + bankClient, + exchange, + amount: "TESTKUDOS:10", + }); + await secondWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + const created = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + summary: "Hand this payment to another wallet", + amount: "TESTKUDOS:5" as AmountString, + fulfillment_url: "taler://fulfillment-success/unclaimed", + }, + }), + ); + const order = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + created.order_id, + ), + ); + if (order.order_status !== "unpaid") + throw Error(`new order has unexpected status ${order.order_status}`); + const firstPrepared = await firstWallet.call( + WalletApiOperation.PreparePayForUriV2, + { talerPayUri: order.taler_pay_uri }, + ); + await firstWallet.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: firstPrepared.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + const released = await firstWallet.call(WalletApiOperation.UnclaimPayment, { + transactionId: firstPrepared.transactionId, + }); + const repeated = await firstWallet.call(WalletApiOperation.UnclaimPayment, { + transactionId: firstPrepared.transactionId, + }); + t.assertTrue(released.talerPayUri === repeated.talerPayUri); + const parsed = TalerUris.parse(released.talerPayUri); + t.assertTrue(!Result.isError(parsed)); + if (!Result.isError(parsed)) { + t.assertTrue(parsed.value.type === TalerUriAction.Pay); + if (parsed.value.type === TalerUriAction.Pay) + t.assertTrue(parsed.value.noncePriv === undefined); + } + + const firstWaiting = await firstWallet.call( + WalletApiOperation.GetTransactionById, + { transactionId: firstPrepared.transactionId }, + ); + t.assertTrue( + firstWaiting.txState.minor === TransactionMinorState.WaitingForOtherWallet, + ); + + const secondPrepared = await secondWallet.call( + WalletApiOperation.PreparePayForUriV2, + { talerPayUri: released.talerPayUri }, + ); + await secondWallet.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: secondPrepared.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + // The first wallet stops showing the QR code as soon as the claim moves, + // without waiting for the second wallet to pay. + await firstWallet.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: firstPrepared.transactionId, + txState: { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.ContinuedWithOtherWallet, + }, + }); + + const paid = await secondWallet.call(WalletApiOperation.ConfirmPay, { + transactionId: secondPrepared.transactionId, + choiceIndex: 0, + }); + t.assertTrue(paid.type === ConfirmPayResultType.Done); +} diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -150,6 +150,7 @@ import { runPaymentMultipleTest } from "./test-payment-multiple.js"; import { runPaymentOrderGoneTest } from "./test-payment-order-gone.js"; import { runPaymentShareIdempotencyTest } from "./test-payment-share-idempotency.js"; import { runPaymentShareTest } from "./test-payment-share.js"; +import { runPaymentUnclaimTest } from "./test-payment-unclaim.js"; import { runPaymentTemplateTest } from "./test-payment-template.js"; import { runPaymentTransientTest } from "./test-payment-transient.js"; import { runPaymentTest } from "./test-payment.js"; @@ -340,6 +341,7 @@ const allTests: TestMainFunction[] = [ runPaymentOrderGoneTest, runPaymentTest, runPaymentShareTest, + runPaymentUnclaimTest, runPaymentShareIdempotencyTest, runPaymentTemplateTest, runPaymentAbortTest, diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -460,6 +460,36 @@ export class TalerMerchantInstanceHttpClient { } /** + * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-orders-$ORDER_ID-unclaim + */ + async unclaimOrder(args: { + orderId: string; + body: TalerMerchantApi.UnclaimRequest; + }) { + const { orderId, body } = args; + const url = new URL(`orders/${pathSegment(orderId)}/unclaim`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + body, + cancellationToken: this.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NoContent: + return opEmptySuccess(resp); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.PayloadTooLarge: + case HttpStatusCode.InternalServerError: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-orders-$ORDER_ID-pay */ async makePayment(orderId: string, body: TalerMerchantApi.PayRequest) { diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1371,6 +1371,22 @@ export interface ClaimResponse { sig: EddsaSignatureString; } +/** + * Request body for releasing a wallet's claim on an order. + * + * @since merchant protocol v26 + */ +export interface UnclaimRequest { + /** Signature over h_contract with purpose WALLET_ORDER_UNCLAIM. */ + unclaim_sig: EddsaSignatureString; + + /** Public claim nonce whose private key made unclaim_sig. */ + nonce: EddsaPublicKeyString; + + /** Hash of the contract terms being released. */ + h_contract: HashCodeString; +} + export interface PaymentResponse { // Signature on TALER_PaymentResponsePS with the public // key of the merchant instance. diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -44,6 +44,7 @@ import { TalerProtocolTimestamp, codecForPreciseTimestamp, } from "./time.js"; +import type { TalerUriString } from "./taleruri.js"; import { AmountString, InternationalizedString, @@ -228,6 +229,7 @@ export enum TransactionMinorState { CheckRefund = "check-refund", ClaimProposal = "claim-proposal", CompletedByOtherWallet = "completed-by-other-wallet", + ContinuedWithOtherWallet = "continued-with-other-wallet", CreatePurse = "create-purse", DeletePurse = "delete-purse", Deposit = "deposit", @@ -254,6 +256,7 @@ export enum TransactionMinorState { Track = "track", Unknown = "unknown", Withdraw = "withdraw", + WaitingForOtherWallet = "waiting-for-other-wallet", Abort = "abort", } @@ -752,6 +755,12 @@ export interface TransactionPayment extends TransactionCommon { type: TransactionType.Payment; /** + * Public payment URI shown while this wallet waits for another wallet to + * claim an order that it released. + */ + unclaimedPayUri?: TalerUriString; + + /** * Additional information about the payment. * * Only present if the information about the diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -56,7 +56,12 @@ import { PerformanceTable } from "./performance.js"; import { QrCodeSpec } from "./qr.js"; import { AgeCommitmentProof } from "./taler-crypto.js"; import { TalerErrorCode } from "./taler-error-codes.js"; -import { TalerUri, TemplateParams } from "./taleruri.js"; +import { + TalerUri, + TalerUriString, + TemplateParams, + codecForTalerUriString, +} from "./taleruri.js"; import { AbsoluteTime, DurationUnitSpec, @@ -3128,10 +3133,15 @@ export type GetChoicesForPaymentResult = { contractTerms: MerchantContractTerms; }; +/** + * @deprecated Sharing transfers the wallet's private claim nonce. Use + * UnclaimPaymentRequest to release the order and continue in another wallet. + */ export interface SharePaymentRequest { merchantBaseUrl: string; orderId: string; } + export const codecForSharePaymentRequest = (): Codec<SharePaymentRequest> => buildCodecForObject<SharePaymentRequest>() .property("merchantBaseUrl", codecForCanonBaseUrl()) @@ -3146,6 +3156,22 @@ export const codecForSharePaymentResult = (): Codec<SharePaymentResult> => .property("privatePayUri", codecForString()) .build("SharePaymentResult"); +export interface UnclaimPaymentRequest { + transactionId: TransactionIdStr; +} +export const codecForUnclaimPaymentRequest = (): Codec<UnclaimPaymentRequest> => + buildCodecForObject<UnclaimPaymentRequest>() + .property("transactionId", codecForTransactionIdStr()) + .build("UnclaimPaymentRequest"); + +export interface UnclaimPaymentResult { + talerPayUri: TalerUriString; +} +export const codecForUnclaimPaymentResult = (): Codec<UnclaimPaymentResult> => + buildCodecForObject<UnclaimPaymentResult>() + .property("talerPayUri", codecForTalerUriString()) + .build("UnclaimPaymentResult"); + export interface CheckPayTemplateRequest { talerPayTemplateUri: string; diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -697,3 +697,19 @@ test("global fees claiming an unbounded duration they were not signed with are r ); } }); + +test("order unclaim signs the contract hash with the wallet nonce", async () => { + const nonce = createEddsaKeyPair(); + const contractTermsHash = encodeCrock(hash(stringToBytes("contract"))); + const result = await nativeCryptoR.signOrderUnclaim(nativeCryptoR, { + noncePriv: encodeCrock(nonce.eddsaPriv), + contractTermsHash, + }); + const expectedPayload = buildSigPS(TalerSignaturePurpose.WALLET_ORDER_UNCLAIM) + .put(decodeCrock(contractTermsHash)) + .build(); + assert.deepStrictEqual( + result.sig, + encodeCrock(eddsaSign(expectedPayload, nonce.eddsaPriv)), + ); +}); diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -130,6 +130,8 @@ import { SignCoinHistoryResponse, SignContractTermsHashRequest, SignContractTermsHashResponse, + SignOrderUnclaimRequest, + SignOrderUnclaimResponse, SignDeletePurseRequest, SignDeletePurseResponse, SignPreparedTransferRegisterRequest, @@ -434,6 +436,10 @@ export interface TalerCryptoInterface { req: SignContractTermsHashRequest, ): Promise<SignContractTermsHashResponse>; + signOrderUnclaim( + req: SignOrderUnclaimRequest, + ): Promise<SignOrderUnclaimResponse>; + signPreparedTransferRegister( req: SignPreparedTransferRegisterRequest, ): Promise<SignPreparedTransferRegisterResponse>; @@ -720,6 +726,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignContractTermsHashResponse> { throw new Error("Function not implemented."); }, + signOrderUnclaim: function ( + req: SignOrderUnclaimRequest, + ): Promise<SignOrderUnclaimResponse> { + throw new Error("Function not implemented."); + }, signWithdrawal: function ( req: SignWithdrawalRequest, ): Promise<SignWithdrawalResponse> { @@ -3146,6 +3157,22 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }; }, + async signOrderUnclaim( + tci: TalerCryptoInterfaceR, + req: SignOrderUnclaimRequest, + ): Promise<SignOrderUnclaimResponse> { + const sigData = buildSigPS(TalerSignaturePurpose.WALLET_ORDER_UNCLAIM) + .put(decodeCrock(req.contractTermsHash)) + .build(); + const sigRes = await tci.eddsaSign(tci, { + msg: encodeCrock(sigData), + priv: req.noncePriv, + }); + return { + sig: sigRes.sig, + }; + }, + async signWithdrawal( tci: TalerCryptoInterfaceR, req: SignWithdrawalRequest, diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -348,6 +348,15 @@ export interface SignContractTermsHashResponse { sig: string; } +export interface SignOrderUnclaimRequest { + noncePriv: EddsaPrivateKeyString; + contractTermsHash: HashCodeString; +} + +export interface SignOrderUnclaimResponse { + sig: EddsaSignatureString; +} + export interface SignPurseMergeRequest { mergeTimestamp: TalerProtocolTimestamp; diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -1984,6 +1984,12 @@ export enum PurchaseStatus { DialogShared = 0x0101_0001, /** + * The claim was released and this wallet is waiting for another wallet to + * claim the public payment URI. + */ + DialogUnclaimed = 0x0101_0002, + + /** * Generic failure, check error code. */ Failed = 0x0501_0000, @@ -2025,6 +2031,9 @@ export enum PurchaseStatus { * The payment has been aborted. */ AbortedIncompletePayment = 0x0503_0003, + + /** The released order was claimed by another wallet. */ + AbortedClaimedByOther = 0x0503_0004, } export enum ConfigRecordKey { diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -23,6 +23,8 @@ import { TransactionIdStr, TalerPreciseTimestamp, TalerErrorCode, + TransactionMajorState, + TransactionMinorState, } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; @@ -39,6 +41,7 @@ import { WalletDbTransaction } from "./db/transaction.js"; import { applyFirstPaySuccessState, computePayMerchantTransactionActions, + computePayMerchantTransactionState, preparePayForUriV2, getCoinsToSpendForMerchantRepair, getAlreadyPaidRefundRequests, @@ -75,6 +78,27 @@ test("payment-share flags use the shared transaction path", () => { ); }); +test("unclaimed payments wait for and finish neutrally in another wallet", () => { + assert.deepStrictEqual( + computePayMerchantTransactionState({ + purchaseStatus: PurchaseStatus.DialogUnclaimed, + } as WalletPurchase), + { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.WaitingForOtherWallet, + }, + ); + assert.deepStrictEqual( + computePayMerchantTransactionState({ + purchaseStatus: PurchaseStatus.AbortedClaimedByOther, + } as WalletPurchase), + { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.ContinuedWithOtherWallet, + }, + ); +}); + test("preparing a reused shared payment only wakes its transaction task", async () => { const purchase = { proposalId: "shared-proposal", diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -48,6 +48,7 @@ import { DenomKeyType, DownloadedContractData, Duration, + EddsaPublicKeyString, encodeCrock, ErrorInfoSummary, ExchangeRefundRequest, @@ -105,6 +106,7 @@ import { TransactionMinorState, TransactionState, TransactionType, + UnclaimPaymentResult, URL, WalletNotification, } from "@gnu-taler/taler-util"; @@ -262,6 +264,10 @@ export class PayMerchantTransactionContext implements TransactionContext { } const txState = computePayMerchantTransactionState(purchaseRec); + const unclaimedPayUri = + purchaseRec.purchaseStatus === PurchaseStatus.DialogUnclaimed + ? makePublicPayUri(purchaseRec) + : undefined; const payOpId = TaskIdentifiers.forPay(purchaseRec); const payRetryRec = await tx.getOperationRetry(payOpId); const unk = "UNKNOWN:0"; @@ -279,6 +285,7 @@ export class PayMerchantTransactionContext implements TransactionContext { return { type: TransactionType.Payment, txState, + unclaimedPayUri, stId: purchaseRec.purchaseStatus, scopes: [ { @@ -388,6 +395,7 @@ export class PayMerchantTransactionContext implements TransactionContext { return { type: TransactionType.Payment, txState, + unclaimedPayUri, stId: purchaseRec.purchaseStatus, scopes, txActions: computePayMerchantTransactionActions(purchaseRec), @@ -3407,6 +3415,8 @@ export async function processPurchase( return processPurchaseAcceptRefund(wex, purchase); case PurchaseStatus.DialogShared: return processPurchaseDialogShared(wex, purchase); + case PurchaseStatus.DialogUnclaimed: + return processPurchaseDialogUnclaimed(wex, purchase); case PurchaseStatus.DialogProposed: return processPurchaseDialogProposed(wex, purchase); case PurchaseStatus.FailedClaim: @@ -3414,6 +3424,7 @@ export async function processPurchase( case PurchaseStatus.DoneRepurchaseDetected: case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedIncompletePayment: + case PurchaseStatus.AbortedClaimedByOther: case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: case PurchaseStatus.SuspendedAbortingWithRefund: @@ -4314,6 +4325,11 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Dialog, minor: TransactionMinorState.Proposed, }; + case PurchaseStatus.DialogUnclaimed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.WaitingForOtherWallet, + }; // Final States case PurchaseStatus.AbortedProposalRefused: return { @@ -4338,6 +4354,11 @@ export function computePayMerchantTransactionState( return { major: TransactionMajorState.Aborted, }; + case PurchaseStatus.AbortedClaimedByOther: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.ContinuedWithOtherWallet, + }; case PurchaseStatus.Expired: return { major: TransactionMajorState.Expired, @@ -4452,6 +4473,8 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Retry, TransactionAction.Delete]; case PurchaseStatus.DialogShared: return [TransactionAction.Retry, TransactionAction.Delete]; + case PurchaseStatus.DialogUnclaimed: + return [TransactionAction.Delete]; // Final States case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedOrderDeleted: @@ -4464,6 +4487,7 @@ export function computePayMerchantTransactionActions( case PurchaseStatus.DoneRepurchaseDetected: return [TransactionAction.Delete]; case PurchaseStatus.AbortedIncompletePayment: + case PurchaseStatus.AbortedClaimedByOther: return [TransactionAction.Delete]; case PurchaseStatus.Failed: return [TransactionAction.Delete]; @@ -4486,6 +4510,7 @@ export function computePayMerchantTransactionActions( } } +/** @deprecated Use unclaimPayment() for cross-wallet payment handoff. */ export async function sharePayment( wex: WalletExecutionContext, merchantBaseUrl: string, @@ -4569,6 +4594,100 @@ export async function sharePayment( return { privatePayUri }; } +function makePublicPayUri(purchase: WalletPurchase) { + return TalerUris.stringify({ + type: TalerUriAction.Pay, + merchantBaseUrl: purchase.merchantBaseUrl as HostPortPath, + orderId: purchase.orderId, + sessionId: purchase.lastSessionId ?? purchase.downloadSessionId, + claimToken: purchase.claimToken, + }); +} + +/** + * Release this wallet's claim on an order and return a public payment URI that + * another wallet can claim. Unlike sharePayment(), the URI contains no + * private nonce. + */ +export async function unclaimPayment( + wex: WalletExecutionContext, + proposalId: string, +): Promise<UnclaimPaymentResult> { + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const snapshot = await wex.runWalletDbTx(async (tx) => { + const purchase = await tx.getPurchase(proposalId); + if (!purchase) { + throw makeTransactionNotFoundError(ctx.transactionId); + } + if (purchase.purchaseStatus === PurchaseStatus.DialogUnclaimed) { + return { type: "already-unclaimed" as const, purchase }; + } + if (purchase.purchaseStatus !== PurchaseStatus.DialogProposed) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(purchase), + debugStateNum: purchase.purchaseStatus, + }, + "this payment can only be released before it is paid", + ); + } + const download = await expectProposalDownloadByIdInTx(wex, tx, proposalId); + return { type: "unclaim" as const, purchase, download }; + }); + + if (snapshot.type === "already-unclaimed") { + return { talerPayUri: makePublicPayUri(snapshot.purchase) }; + } + + const { sig } = await wex.cryptoApi.signOrderUnclaim({ + noncePriv: snapshot.purchase.noncePriv, + contractTermsHash: snapshot.download.contractTermsHash, + }); + const merchantClient = walletMerchantClient( + snapshot.purchase.merchantBaseUrl, + wex, + ); + const response = await merchantClient.unclaimOrder({ + orderId: snapshot.purchase.orderId, + body: { + unclaim_sig: sig, + nonce: snapshot.purchase.noncePub as EddsaPublicKeyString, + h_contract: snapshot.download.contractTermsHash, + }, + }); + if (response.case !== "ok") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(response.response), + "/unclaim failed", + ); + } + + const purchase = await wex.runWalletDbTx(async (tx) => { + const [current, handle] = await ctx.getRecordHandle(tx); + if (!current) { + throw makeTransactionNotFoundError(ctx.transactionId); + } + if (current.purchaseStatus === PurchaseStatus.DialogProposed) { + current.purchaseStatus = PurchaseStatus.DialogUnclaimed; + await handle.update(current, "unclaim"); + } else if (current.purchaseStatus !== PurchaseStatus.DialogUnclaimed) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(current), + debugStateNum: current.purchaseStatus, + }, + "payment state changed while releasing the order", + ); + } + return current; + }); + wex.taskScheduler.startShepherdTask(ctx.taskId); + return { talerPayUri: makePublicPayUri(purchase) }; +} + /** * Status of an order from the point of view of a wallet that is not the one * that (possibly) paid it. "gone" means that the merchant does not have the @@ -4749,6 +4868,121 @@ async function processPurchaseDialogShared( return TaskRunResult.progress(); } +async function processPurchaseDialogUnclaimed( + wex: WalletExecutionContext, + purchase: WalletPurchase, +): Promise<TaskRunResult> { + const proposalId = purchase.proposalId; + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const txRes = await wex.runWalletDbTx(async (tx) => { + const rec = await tx.getPurchase(proposalId); + if (!rec) { + return undefined; + } + const download = await expectProposalDownloadByIdInTx(wex, tx, proposalId); + return { download, rec }; + }); + if (!txRes || txRes.rec.purchaseStatus !== PurchaseStatus.DialogUnclaimed) { + return TaskRunResult.finished(); + } + + const expiry = AbsoluteTime.addDuration( + AbsoluteTime.fromProtocolTimestamp( + txRes.download.contractTerms.pay_deadline, + ), + Duration.fromSpec({ seconds: 10 }), + ); + if (AbsoluteTime.isExpired(expiry)) { + await wex.runWalletDbTx(async (tx) => { + const [rec, handle] = await ctx.getRecordHandle(tx); + if (rec?.purchaseStatus !== PurchaseStatus.DialogUnclaimed) { + return; + } + rec.timestampExpired = timestampPreciseToDb(TalerPreciseTimestamp.now()); + rec.purchaseStatus = PurchaseStatus.Expired; + await handle.update(rec, "unclaimed-order-expired"); + }); + return TaskRunResult.progress(); + } + + const merchantClient = walletMerchantClient( + txRes.download.contractTerms.merchant_base_url, + wex, + ); + let response = await merchantClient.getPaymentStatus( + txRes.download.contractTerms.order_id, + { + claimToken: txRes.rec.claimToken, + sessionId: txRes.rec.lastSessionId ?? undefined, + timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS, + }, + ); + + // The claim token keeps the long-poll authorized while the order is + // unclaimed. A 402 response alone does not reveal whether another wallet + // has claimed it, because the token remains valid for the newly generated + // contract. Probe once with the old contract hash after the long-poll + // returns: the merchant distinguishes the unclaimed order from a new claim + // with its error code. + if ( + response.case === HttpStatusCode.PaymentRequired && + response.body.already_paid_order_id == null + ) { + response = await merchantClient.getPaymentStatus( + txRes.download.contractTerms.order_id, + { + contractTermHash: txRes.download.contractTermsHash, + sessionId: txRes.rec.lastSessionId ?? undefined, + }, + ); + } + + if (isOrderUnknown(response)) { + await wex.runWalletDbTx(async (tx) => { + const [rec, handle] = await ctx.getRecordHandle(tx); + if (rec?.purchaseStatus !== PurchaseStatus.DialogUnclaimed) { + return; + } + rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + await handle.update(rec, "unclaimed-order-gone"); + }); + return TaskRunResult.progress(); + } + + if ( + response.case === HttpStatusCode.Forbidden && + response.detail?.code === + TalerErrorCode.MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_HASH + ) { + return TaskRunResult.longpollReturnedPending(); + } + + const claimed = + response.case === "ok" || + response.case === HttpStatusCode.Accepted || + response.case === HttpStatusCode.PaymentRequired || + (response.case === HttpStatusCode.Forbidden && + response.detail?.code === + TalerErrorCode.MERCHANT_GENERIC_CONTRACT_HASH_DOES_NOT_MATCH_ORDER); + if (!claimed) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(response.response), + "checking whether the released order was claimed failed", + ); + } + + await wex.runWalletDbTx(async (tx) => { + const [rec, handle] = await ctx.getRecordHandle(tx); + if (rec?.purchaseStatus !== PurchaseStatus.DialogUnclaimed) { + return; + } + rec.purchaseStatus = PurchaseStatus.AbortedClaimedByOther; + await handle.update(rec, "unclaimed-order-claimed-by-other"); + }); + return TaskRunResult.progress(); +} + async function processPurchaseAutoRefund( wex: WalletExecutionContext, purchase: WalletPurchase, diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -114,6 +114,8 @@ import { ScopeType, SharePaymentRequest, SharePaymentResult, + UnclaimPaymentRequest, + UnclaimPaymentResult, StartRefundQueryRequest, SuspendTransactionRequest, TalerBankIntegrationHttpClient, @@ -241,6 +243,7 @@ import { codecForSetCoinSuspendedRequest, codecForSetDonauRequest, codecForSharePaymentRequest, + codecForUnclaimPaymentRequest, codecForStartExchangeWalletKycRequest, codecForStartRefundQueryRequest, codecForSuspendTransaction, @@ -349,6 +352,7 @@ import { sharePayment, startQueryRefund, startRefundQueryForUri, + unclaimPayment, } from "./pay-merchant.js"; import { getPaivanaCookie, preparePayForPaivana } from "./pay-paivana.js"; import { @@ -407,6 +411,7 @@ import { getTransactionById, getTransactions, getTransactionsV2, + makeInvalidTransactionIdError, parseTransactionIdentifier, rematerializeTransactions, resolveTransactionReference, @@ -696,6 +701,17 @@ async function handleSharePayment( return await sharePayment(wex, req.merchantBaseUrl, req.orderId); } +async function handleUnclaimPayment( + wex: WalletExecutionContext, + req: UnclaimPaymentRequest, +): Promise<UnclaimPaymentResult> { + const parsed = parseTransactionIdentifier(req.transactionId); + if (parsed?.tag !== TransactionType.Payment) { + throw makeInvalidTransactionIdError(req.transactionId); + } + return unclaimPayment(wex, parsed.proposalId); +} + /** * Highest number of HTTP redirects followed while probing one candidate base * URL, so that a redirect loop cannot keep the completion running forever. @@ -2637,6 +2653,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForSharePaymentRequest(), handler: handleSharePayment, }, + [WalletApiOperation.UnclaimPayment]: { + codec: codecForUnclaimPaymentRequest(), + handler: handleUnclaimPayment, + }, [WalletApiOperation.PrepareWithdrawExchange]: { codec: codecForPrepareWithdrawExchangeRequest(), handler: handlePrepareWithdrawExchange, diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts @@ -48,7 +48,7 @@ export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0"; /** * Libtool version of the wallet-core API. */ -export const WALLET_CORE_API_PROTOCOL_VERSION = "8:0:0"; +export const WALLET_CORE_API_PROTOCOL_VERSION = "9:0:1"; /** * Libtool rules: diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -183,6 +183,8 @@ import { SetDonauRequest, SharePaymentRequest, SharePaymentResult, + UnclaimPaymentRequest, + UnclaimPaymentResult, StartExchangeWalletKycRequest, StartRefundQueryForUriResponse, StartRefundQueryRequest, @@ -311,6 +313,7 @@ export enum WalletApiOperation { PreparePayForPaivana = "preparePayForPaivana", GetPaivanaCookie = "getPaivanaCookie", SharePayment = "sharePayment", + UnclaimPayment = "unclaimPayment", CheckPayForTemplate = "checkPayForTemplate", StartRefundQueryForUri = "startRefundQueryForUri", StartRefundQuery = "startRefundQuery", @@ -923,6 +926,12 @@ export type SharePaymentOp = { response: SharePaymentResult; }; +export type UnclaimPaymentOp = { + op: WalletApiOperation.UnclaimPayment; + request: UnclaimPaymentRequest; + response: UnclaimPaymentResult; +}; + export type CheckPayForTemplateOp = { op: WalletApiOperation.CheckPayForTemplate; request: CheckPayTemplateRequest; @@ -1830,6 +1839,11 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, ], + [WalletApiOperation.UnclaimPayment]: [ + TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + ], [WalletApiOperation.StartRefundQueryForUri]: [ TalerErrorCode.WALLET_TALER_URI_MALFORMED, TalerErrorCode.WALLET_PURCHASE_NOT_FOUND, @@ -2030,6 +2044,7 @@ export type WalletOperations = { [WalletApiOperation.PreparePayForPaivana]: PreparePayForPaivanaOp; [WalletApiOperation.GetPaivanaCookie]: GetPaivanaCookieOp; [WalletApiOperation.SharePayment]: SharePaymentOp; + [WalletApiOperation.UnclaimPayment]: UnclaimPaymentOp; [WalletApiOperation.CheckPayForTemplate]: CheckPayForTemplateOp; [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp; [WalletApiOperation.GetChoicesForPayment]: GetChoicesForPaymentOp; diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -3849,6 +3849,23 @@ function PaymentRoute() { onOpenFulfillment={() => {}} /> ); + if ( + transaction.txState.major === TransactionMajorState.Dialog && + transaction.txState.minor === TransactionMinorState.WaitingForOtherWallet && + transaction.unclaimedPayUri + ) + return ( + <PaymentScreen + state="handoff" + merchantName={terms.merchant.name} + talerPayUri={transaction.unclaimedPayUri} + onSelectChoice={() => {}} + onConfirm={() => {}} + onWithdraw={() => navigate("/withdraw")} + onCancel={completeAndReturn} + onOpenFulfillment={() => {}} + /> + ); if (transaction.txState.major === TransactionMajorState.Dialog) return ( <PaymentDialog @@ -3942,10 +3959,12 @@ function PaymentDialog(props: { const [selectedChoice, setSelectedChoice] = useState<number>(); const [useDonau, setUseDonau] = useState(false); const [state, setState] = useState< - "ready" | "paying" | "pending" | "done" | "error" + "ready" | "paying" | "handoff" | "pending" | "done" | "error" >("ready"); const [error, setError] = useState<ErrorPresentation>(); const [cancelling, setCancelling] = useState(false); + const [unclaiming, setUnclaiming] = useState(false); + const [talerPayUri, setTalerPayUri] = useState<string>(); const automaticStarted = useRef(false); useEffect(() => { setSelectedChoice(undefined); @@ -3953,6 +3972,8 @@ function PaymentDialog(props: { setState("ready"); setError(undefined); setCancelling(false); + setUnclaiming(false); + setTalerPayUri(undefined); automaticStarted.current = false; }, [props.actionId, props.transaction.transactionId]); const activeChoice = activePaymentChoiceIndex(review, selectedChoice); @@ -4052,6 +4073,35 @@ function PaymentDialog(props: { } navigate("/"); }; + const unclaim = async () => { + if (unclaiming) return; + setUnclaiming(true); + setError(undefined); + try { + const result = await callMutation( + WalletApiOperation.UnclaimPayment, + { transactionId: props.transaction.transactionId }, + ["transactions"], + ); + if (Result.isError(result)) { + setError( + walletCoreError( + result.detail, + i18n.str`The payment could not be handed off to another wallet.`, + ), + ); + setState("error"); + return; + } + setTalerPayUri(result.value.talerPayUri); + setState("handoff"); + } catch (cause) { + setError(errorFromException(cause, i18n.str`Payment handoff failed`)); + setState("error"); + } finally { + setUnclaiming(false); + } + }; if (choicesQuery.isLoading || !review) return ( <PaymentScreen @@ -4078,6 +4128,7 @@ function PaymentDialog(props: { return ( <PaymentScreen state={state} + talerPayUri={talerPayUri} merchantName={review.merchantName} merchantWebsite={review.merchantWebsite} merchantAddress={review.merchantAddress} @@ -4092,6 +4143,7 @@ function PaymentDialog(props: { fulfillmentUrl={fulfillmentUrl} useDonau={useDonau} cancelling={cancelling} + unclaiming={unclaiming} onSelectChoice={setSelectedChoice} onToggleDonau={setUseDonau} onConfigureDonau={(baseUrl) => @@ -4100,9 +4152,18 @@ function PaymentDialog(props: { ) } onConfirm={(collect) => void confirm(activeChoice, collect)} + onUnclaim={() => void unclaim()} onWithdraw={() => navigate("/withdraw")} onCancel={ - state === "pending" || state === "done" + state === "handoff" + ? () => { + void completeActionBestEffort( + platform.actionInbox, + props.actionId, + ); + navigate("/"); + } + : state === "pending" || state === "done" ? () => navigate("/") : () => void cancel() } @@ -4165,7 +4226,8 @@ function TransactionDetailRoute() { ); const paymentDialog = query.data?.type === TransactionType.Payment && - query.data.txState.major === TransactionMajorState.Dialog + query.data.txState.major === TransactionMajorState.Dialog && + query.data.txState.minor !== TransactionMinorState.WaitingForOtherWallet ? query.data : undefined; const paymentChoicesQuery = useWalletQuery( @@ -4187,6 +4249,8 @@ function TransactionDetailRoute() { const [selectedPaymentChoice, setSelectedPaymentChoice] = useState<number>(); const [useDonau, setUseDonau] = useState(false); const [confirmingPayment, setConfirmingPayment] = useState(false); + const [unclaimingPayment, setUnclaimingPayment] = useState(false); + const [localHandoffUri, setLocalHandoffUri] = useState<string>(); useEffect(() => { setWorkingAction(undefined); setError(undefined); @@ -4195,6 +4259,8 @@ function TransactionDetailRoute() { setSelectedPaymentChoice(undefined); setUseDonau(false); setConfirmingPayment(false); + setUnclaimingPayment(false); + setLocalHandoffUri(undefined); }, [transactionId]); const transaction = useMemo( () => @@ -4266,6 +4332,33 @@ function TransactionDetailRoute() { setConfirmingPayment(false); } }; + const unclaimPayment = async () => { + if (!paymentDialog || unclaimingPayment) return; + setUnclaimingPayment(true); + setError(undefined); + setMessage(undefined); + try { + const result = await callMutation( + WalletApiOperation.UnclaimPayment, + { transactionId }, + ["transactions"], + ); + if (Result.isError(result)) { + setError( + walletCoreError( + result.detail, + i18n.str`The payment could not be handed off to another wallet.`, + ), + ); + return; + } + setLocalHandoffUri(result.value.talerPayUri); + } catch (cause) { + setError(errorFromException(cause, i18n.str`Payment handoff failed`)); + } finally { + setUnclaimingPayment(false); + } + }; const runAction = async (action: TransactionUiAction) => { if ( (action === "retry" || action === "resume" || action === "refund") && @@ -4387,34 +4480,43 @@ function TransactionDetailRoute() { onOpenExternal={(url) => void platform.openExternal(url)} onContinue={() => setContinuingTransfer(true)} paymentReview={ - paymentDialog - ? paymentChoicesQuery.error - ? { - state: "error", - error: errorFromException( - paymentChoicesQuery.error, - i18n.str`Payment options could not be loaded.`, - ), - } - : !paymentDialog.contractTerms + query.data?.type === TransactionType.Payment && + query.data.txState.major === TransactionMajorState.Dialog && + (query.data.unclaimedPayUri ?? localHandoffUri) + ? { + state: "handoff", + talerPayUri: query.data.unclaimedPayUri ?? localHandoffUri!, + } + : paymentDialog + ? paymentChoicesQuery.error ? { state: "error", - error: localError( - i18n.str`The payment contract details are unavailable.`, + error: errorFromException( + paymentChoicesQuery.error, + i18n.str`Payment options could not be loaded.`, ), } - : !paymentReview - ? { state: "loading" } - : { - state: confirmingPayment ? "paying" : "ready", - choices: paymentReview.choices, - selectedChoice: activePaymentChoice, - useDonau, + : !paymentDialog.contractTerms + ? { + state: "error", + error: localError( + i18n.str`The payment contract details are unavailable.`, + ), } - : undefined + : !paymentReview + ? { state: "loading" } + : { + state: confirmingPayment ? "paying" : "ready", + choices: paymentReview.choices, + selectedChoice: activePaymentChoice, + useDonau, + unclaiming: unclaimingPayment, + } + : undefined } onSelectPaymentChoice={setSelectedPaymentChoice} onConfirmPayment={(collect) => void confirmPayment(collect)} + onUnclaimPayment={paymentDialog ? () => void unclaimPayment() : undefined} onToggleDonau={setUseDonau} onConfigureDonau={(baseUrl) => navigate( diff --git a/packages/wallet-webui/src/routes/payment-model.ts b/packages/wallet-webui/src/routes/payment-model.ts @@ -91,7 +91,7 @@ export function paymentRepurchaseTarget( export function paymentResultState( transaction: TransactionPayment, replay: TransactionPayment | undefined, -): "pending" | "done" | "error" { +): "pending" | "done" | "handed-off" | "error" { if (paymentRepurchaseTarget(transaction)) { if (!replay) return "pending"; if (replay.txState.major === TransactionMajorState.Done) return "done"; @@ -99,6 +99,10 @@ export function paymentResultState( return "pending"; return "error"; } + if ( + transaction.txState.minor === TransactionMinorState.ContinuedWithOtherWallet + ) + return "handed-off"; if (transaction.txState.major === TransactionMajorState.Done) return "done"; if (transaction.txState.major === TransactionMajorState.Pending) return "pending"; diff --git a/packages/wallet-webui/src/routes/transaction-model.ts b/packages/wallet-webui/src/routes/transaction-model.ts @@ -232,12 +232,14 @@ function minorLabels(): Partial<Record<TransactionMinorState, string>> { [TransactionMinorState.CheckRefund]: i18n.str`Checking for refund`, [TransactionMinorState.AutoRefund]: i18n.str`Processing automatic refund`, [TransactionMinorState.CompletedByOtherWallet]: i18n.str`Completed by another wallet`, + [TransactionMinorState.ContinuedWithOtherWallet]: i18n.str`Continued in another wallet`, [TransactionMinorState.PaidByOther]: i18n.str`Paid with another wallet`, [TransactionMinorState.Ready]: i18n.str`Ready`, [TransactionMinorState.RebindSession]: i18n.str`Restoring merchant session`, [TransactionMinorState.SubmitPayment]: i18n.str`Submitting payment`, [TransactionMinorState.Refresh]: i18n.str`Refreshing digital cash`, [TransactionMinorState.Track]: i18n.str`Tracking bank transfer`, + [TransactionMinorState.WaitingForOtherWallet]: i18n.str`Waiting for another wallet`, }; } @@ -267,7 +269,11 @@ export function transactionStateView(transaction: Transaction): { case TransactionMajorState.Dialog: return { label: minor ?? i18n.str`Needs confirmation`, - detail: i18n.str`This transaction is waiting for your decision.`, + detail: + transaction.txState.minor === + TransactionMinorState.WaitingForOtherWallet + ? i18n.str`Another wallet can scan the payment QR code and continue.` + : i18n.str`This transaction is waiting for your decision.`, tone: "warning", }; case TransactionMajorState.Finalizing: @@ -285,7 +291,11 @@ export function transactionStateView(transaction: Transaction): { case TransactionMajorState.Aborted: return { label: minor ?? i18n.str`Aborted`, - detail: i18n.str`This transaction was stopped before completion.`, + detail: + transaction.txState.minor === + TransactionMinorState.ContinuedWithOtherWallet + ? i18n.str`The payment was handed off successfully to another wallet.` + : i18n.str`This transaction was stopped before completion.`, tone: "neutral", }; case TransactionMajorState.Suspended: diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx @@ -4,6 +4,9 @@ import { OrderSummary, type OrderSummaryView } from "../ui/OrderSummary.js"; import { i18n } from "../i18n/runtime.js"; import { ErrorCard } from "../ui/ErrorCard.js"; import type { ErrorPresentation } from "../ui/error.js"; +import { ConfirmationDialog } from "../ui/ConfirmationDialog.js"; +import { QrFrame } from "../ui/QrFrame.js"; +import { useState } from "preact/hooks"; export interface PaymentChoiceView { index: number; @@ -151,6 +154,8 @@ export type PaymentScreenState = | "loading" | "ready" | "paying" + | "handoff" + | "handed-off" | "pending" | "done" | "error"; @@ -161,12 +166,14 @@ export function PaymentOptions(props: { selectedChoice?: number; useDonau?: boolean; cancelling?: boolean; + unclaiming?: boolean; onSelectChoice: (index: number) => void; onConfirm: (useDonau?: boolean) => void; onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw: () => void; onCancel?: () => void; + onUnclaim?: () => void; }) { const selected = props.choices?.find( (choice) => choice.index === props.selectedChoice, @@ -299,6 +306,19 @@ export function PaymentOptions(props: { )} <div class="flex flex-wrap justify-end gap-3 border-t border-outlineVariant pt-5"> + {props.onUnclaim && ( + <Button + tone="secondary" + onClick={props.onUnclaim} + disabled={ + props.state === "paying" || props.cancelling || props.unclaiming + } + > + {props.unclaiming + ? i18n.str`Preparing handoff…` + : i18n.str`Continue with another wallet`} + </Button> + )} {props.onCancel && ( <Button tone="secondary" @@ -317,7 +337,10 @@ export function PaymentOptions(props: { <Button onClick={() => props.onConfirm(props.useDonau === true)} disabled={ - !selected?.payable || props.state === "paying" || props.cancelling + !selected?.payable || + props.state === "paying" || + props.cancelling || + props.unclaiming } > {props.state === "paying" @@ -332,6 +355,36 @@ export function PaymentOptions(props: { ); } +export function PaymentHandoff(props: { + talerPayUri: string; + onDone?: () => void; +}) { + return ( + <Card class="border-primary bg-primaryContainer text-onPrimaryContainer"> + <h2 class="text-xl font-semibold">{i18n.str`Continue in another wallet`}</h2> + <p + role="status" + class="mt-2" + >{i18n.str`Scan this QR code with the other wallet. This screen will close the code automatically when that wallet claims the payment.`}</p> + <div class="mt-5"> + <QrFrame + content={props.talerPayUri} + alt={i18n.str`QR code to continue the payment in another wallet`} + /> + </div> + <div class="mt-5 flex flex-wrap justify-end gap-3"> + <Button + tone="secondary" + onClick={() => void navigator.clipboard.writeText(props.talerPayUri)} + >{i18n.str`Copy payment link`}</Button> + {props.onDone && ( + <Button onClick={props.onDone}>{i18n.str`Back to wallet`}</Button> + )} + </div> + </Card> + ); +} + export function PaymentScreen(props: { state: PaymentScreenState; merchantName?: string; @@ -349,15 +402,19 @@ export function PaymentScreen(props: { posConfirmation?: string; useDonau?: boolean; cancelling?: boolean; + unclaiming?: boolean; + talerPayUri?: string; onSelectChoice: (index: number) => void; onConfirm: (useDonau?: boolean) => void; onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw: () => void; onCancel: () => void; + onUnclaim?: () => void; onOpenFulfillment: () => void; onCopyPosConfirmation?: (code: string) => void; }) { + const [confirmUnclaim, setConfirmUnclaim] = useState(false); return ( <div class="mx-auto max-w-2xl space-y-5"> <div class="flex items-center gap-4"> @@ -480,16 +537,40 @@ export function PaymentScreen(props: { selectedChoice={props.selectedChoice} useDonau={props.useDonau} cancelling={props.cancelling} + unclaiming={props.unclaiming} onSelectChoice={props.onSelectChoice} onConfirm={props.onConfirm} onToggleDonau={props.onToggleDonau} onConfigureDonau={props.onConfigureDonau} onWithdraw={props.onWithdraw} onCancel={props.onCancel} + onUnclaim={ + props.onUnclaim ? () => setConfirmUnclaim(true) : undefined + } /> </> )} + {props.state === "handoff" && props.talerPayUri && ( + <PaymentHandoff + talerPayUri={props.talerPayUri} + onDone={props.onCancel} + /> + )} + + {props.state === "handed-off" && ( + <Card class="border-primary bg-primaryContainer text-onPrimaryContainer"> + <h2 class="text-xl font-semibold">{i18n.str`Payment continued in another wallet`}</h2> + <p + role="status" + class="mt-2" + >{i18n.str`The other wallet claimed this payment. You can safely close this screen.`}</p> + <div class="mt-5"> + <Button onClick={props.onCancel}>{i18n.str`Back to wallet`}</Button> + </div> + </Card> + )} + {props.state === "pending" && ( <Card class="border-primary bg-primaryContainer text-onPrimaryContainer"> <h2 class="font-semibold"> @@ -555,6 +636,20 @@ export function PaymentScreen(props: { )} </> )} + {confirmUnclaim && props.onUnclaim && ( + <ConfirmationDialog + title={i18n.str`Continue with another wallet?`} + description={i18n.str`This wallet will release the payment so another wallet can claim it. You will not be able to pay this order here afterward.`} + cancelLabel={i18n.str`Keep payment here`} + confirmLabel={i18n.str`Continue with another wallet`} + working={props.unclaiming} + onCancel={() => setConfirmUnclaim(false)} + onConfirm={() => { + props.onUnclaim?.(); + setConfirmUnclaim(false); + }} + /> + )} </div> ); } diff --git a/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx b/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx @@ -12,16 +12,22 @@ import { ConfirmationDialog } from "../ui/ConfirmationDialog.js"; import { OrderSummary } from "../ui/OrderSummary.js"; import { QrFrame } from "../ui/QrFrame.js"; import { i18n } from "../i18n/runtime.js"; -import { PaymentOptions, type PaymentChoiceView } from "./PaymentScreen.js"; +import { + PaymentHandoff, + PaymentOptions, + type PaymentChoiceView, +} from "./PaymentScreen.js"; export type TransactionPaymentReview = | { state: "loading" } | { state: "error"; error: ErrorPresentation } + | { state: "handoff"; talerPayUri: string } | { state: "ready" | "paying"; choices: PaymentChoiceView[]; selectedChoice?: number; useDonau?: boolean; + unclaiming?: boolean; }; function amountLabel(transaction: TransactionDetailView): string { @@ -43,12 +49,17 @@ export function TransactionDetailScreen(props: { paymentReview?: TransactionPaymentReview; onSelectPaymentChoice?: (index: number) => void; onConfirmPayment?: (useDonau?: boolean) => void; + onUnclaimPayment?: () => void; onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw?: () => void; }) { const [confirmation, setConfirmation] = useState<TransactionActionView>(); - useEffect(() => setConfirmation(undefined), [props.transaction?.id]); + const [confirmUnclaim, setConfirmUnclaim] = useState(false); + useEffect(() => { + setConfirmation(undefined); + setConfirmUnclaim(false); + }, [props.transaction?.id]); if (props.loading) return ( <div class="mx-auto max-w-2xl"> @@ -230,6 +241,9 @@ export function TransactionDetailScreen(props: { error={props.paymentReview.error} /> )} + {props.paymentReview?.state === "handoff" && ( + <PaymentHandoff talerPayUri={props.paymentReview.talerPayUri} /> + )} {props.paymentReview && (props.paymentReview.state === "ready" || props.paymentReview.state === "paying") && ( @@ -238,11 +252,15 @@ export function TransactionDetailScreen(props: { choices={props.paymentReview.choices} selectedChoice={props.paymentReview.selectedChoice} useDonau={props.paymentReview.useDonau} + unclaiming={props.paymentReview.unclaiming} onSelectChoice={(index) => props.onSelectPaymentChoice?.(index)} onConfirm={(collect) => props.onConfirmPayment?.(collect)} onToggleDonau={props.onToggleDonau} onConfigureDonau={props.onConfigureDonau} onWithdraw={() => props.onWithdraw?.()} + onUnclaim={ + props.onUnclaimPayment ? () => setConfirmUnclaim(true) : undefined + } /> )} <Card> @@ -334,6 +352,25 @@ export function TransactionDetailScreen(props: { }} /> )} + {confirmUnclaim && props.onUnclaimPayment && ( + <ConfirmationDialog + title={i18n.str`Continue with another wallet?`} + description={i18n.str`This wallet will release the payment so another wallet can claim it. You will not be able to pay this order here afterward.`} + cancelLabel={i18n.str`Keep payment here`} + confirmLabel={i18n.str`Continue with another wallet`} + working={ + props.paymentReview?.state === "ready" || + props.paymentReview?.state === "paying" + ? props.paymentReview.unclaiming + : false + } + onCancel={() => setConfirmUnclaim(false)} + onConfirm={() => { + props.onUnclaimPayment?.(); + setConfirmUnclaim(false); + }} + /> + )} </div> ); } diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts @@ -943,6 +943,22 @@ export class DemoWalletConnection implements WalletConnection { automaticExecution: false, contractTerms: paymentTerms(), } as never; + case WalletApiOperation.UnclaimPayment: { + const transaction = this.findTransaction(String(request.transactionId)); + if (transaction.type !== TransactionType.Payment) + throw Error("Only payment transactions can be handed off."); + const oldMajor = transaction.txState.major; + const talerPayUri = + "taler://pay/merchant.demo.invalid/orders/DEMO-HANDOFF"; + transaction.txState = { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.WaitingForOtherWallet, + }; + transaction.txActions = [TransactionAction.Delete]; + transaction.unclaimedPayUri = talerPayUri as never; + this.emitTransaction(transaction, oldMajor); + return { talerPayUri } as never; + } case WalletApiOperation.ConfirmPay: { const transaction = this.findTransaction(String(request.transactionId)); const oldMajor = transaction.txState.major; diff --git a/packages/wallet-webui/test/payment-model.test.ts b/packages/wallet-webui/test/payment-model.test.ts @@ -147,6 +147,17 @@ test("repurchases follow the original payment replay instead of failing", () => ); }); +test("a payment handed to another wallet has a neutral result", () => { + const transaction = { + type: TransactionType.Payment, + txState: { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.ContinuedWithOtherWallet, + }, + } as TransactionPayment; + assert.equal(paymentResultState(transaction, undefined), "handed-off"); +}); + 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 @@ -1474,6 +1474,105 @@ test("payment choices are keyboard-operable and accessible", async () => { await window.happyDOM.abort(); }); +test("payment handoff is confirmed even when the payment choice is unavailable", 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 unclaimed = false; + const view = render( + <main> + <PaymentScreen + state="ready" + merchantName="Example Merchant" + choices={[ + { + index: 0, + description: "Unavailable", + amountRaw: "CHF:10", + payable: false, + inputs: [], + outputs: [], + }, + ]} + selectedChoice={0} + onSelectChoice={() => {}} + onConfirm={() => {}} + onUnclaim={() => { + unclaimed = true; + }} + onWithdraw={() => {}} + onCancel={() => {}} + onOpenFulfillment={() => {}} + /> + </main>, + ); + const user = userEvent.setup({ + document: window.document as unknown as Document, + }); + await user.click( + view.getByRole("button", { name: "Continue with another wallet" }), + ); + assert.ok( + view.getByRole("dialog", { name: "Continue with another wallet?" }), + ); + await user.click( + view.getAllByRole("button", { name: "Continue with another wallet" })[1], + ); + assert.equal(unclaimed, true); + cleanup(); + await window.happyDOM.abort(); +}); + +test("payment handoff shows a QR code and has a neutral completion screen", async () => { + const window = installDom(); + const { render, cleanup } = await import("@testing-library/preact"); + const uri = "taler://pay/merchant.example/orders/ORDER"; + const view = render( + <main> + <PaymentScreen + state="handoff" + talerPayUri={uri} + onSelectChoice={() => {}} + onConfirm={() => {}} + onWithdraw={() => {}} + onCancel={() => {}} + onOpenFulfillment={() => {}} + /> + </main>, + ); + assert.ok( + view.getByRole("img", { + name: "QR code to continue the payment in another wallet", + }), + ); + view.rerender( + <main> + <PaymentScreen + state="handed-off" + onSelectChoice={() => {}} + onConfirm={() => {}} + onWithdraw={() => {}} + onCancel={() => {}} + onOpenFulfillment={() => {}} + /> + </main>, + ); + assert.ok(view.getByText("Payment continued in another wallet")); + assert.equal( + view.queryByRole("img", { + name: "QR code to continue the payment in another wallet", + }), + null, + ); + 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");