taler-typescript-core

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

commit 84277a208445ef67e869cd3c43fa1e9989477806
parent 020ec4b9859295c04b5debef7069352739ace8c2
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 14:13:06 +0200

wallet-core: keep payment preparation local

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 48+++++++++++++++++++++++-------------------------
2 files changed, 78 insertions(+), 25 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -39,6 +39,7 @@ import { WalletDbTransaction } from "./db/transaction.js"; import { applyFirstPaySuccessState, computePayMerchantTransactionActions, + preparePayForUriV2, getCoinsToSpendForMerchantRepair, getAlreadyPaidRefundRequests, getPayMerchantAbortTransition, @@ -47,6 +48,7 @@ import { getPayMerchantSuspendTransition, getRefundTotals, isPaymentSessionComplete, + isSharedPurchase, isStableMerchantPayFailure, recoveredPayFailureStatus, releasePaymentTokensInTx, @@ -56,6 +58,59 @@ import { validateClaimResponseBindings, } from "./pay-merchant.js"; import { makeIdbRunner } from "./db/testing/runners.js"; +import type { WalletExecutionContext } from "./wallet.js"; + +test("payment-share flags use the shared transaction path", () => { + assert.strictEqual( + isSharedPurchase({ shared: true, createdFromShared: false }), + true, + ); + assert.strictEqual( + isSharedPurchase({ shared: false, createdFromShared: true }), + true, + ); + assert.strictEqual( + isSharedPurchase({ shared: false, createdFromShared: false }), + false, + ); +}); + +test("preparing a reused shared payment only wakes its transaction task", async () => { + const purchase = { + proposalId: "shared-proposal", + purchaseStatus: PurchaseStatus.PendingDownloadingProposal, + timestampFirstSuccessfulPay: undefined, + claimToken: undefined, + shared: false, + createdFromShared: true, + } as WalletPurchase; + let taskResets = 0; + const tx = { + async getPurchasesByUrlAndOrderId(): Promise<WalletPurchase[]> { + return [purchase]; + }, + } as unknown as WalletDbTransaction; + const wex = { + async runWalletDbTx<T>( + f: (walletTx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await f(tx); + }, + taskScheduler: { + async resetTask(): Promise<void> { + taskResets++; + }, + }, + } as unknown as WalletExecutionContext; + + const result = await preparePayForUriV2( + wex, + "taler://pay/merchant.example/order/session", + ); + + assert.strictEqual(result.transactionId, "txn:payment:shared-proposal"); + assert.strictEqual(taskResets, 1); +}); function makeSelectedCoin( coinPub: string, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1552,7 +1552,7 @@ async function processDownloadProposal( p.repurchaseProposalId = repurchase.proposalId; await startPayReplay(wex, tx, repurchase.proposalId, p.downloadSessionId); } else { - p.purchaseStatus = p.shared + p.purchaseStatus = isSharedPurchase(p) ? PurchaseStatus.DialogShared : PurchaseStatus.DialogProposed; } @@ -1699,6 +1699,12 @@ async function generateSlate( * record for the provided arguments already exists, * return the old proposal ID. */ +export function isSharedPurchase( + purchase: Pick<WalletPurchase, "shared" | "createdFromShared">, +): boolean { + return purchase.shared || purchase.createdFromShared === true; +} + export async function createOrReusePurchase( wex: WalletExecutionContext, merchantBaseUrl: string, @@ -1792,29 +1798,10 @@ export async function createOrReusePurchase( } } } - if (oldProposal.shared || oldProposal.createdFromShared) { - const download = await expectProposalDownload(wex, oldProposal); - const orderStatus = await checkIfOrderIsAlreadyPaid(wex, download, false); - logger.info(`old proposal order status: ${orderStatus}`); - // if this transaction was shared and the order is paid then it - // means that another wallet already paid the proposal - if (orderStatus === "paid") { - await wex.runWalletDbTx(async (tx) => { - const [rec, h] = await oldCtx.getRecordHandle(tx); - // The order is only paid by another wallet - // if the merchant says it's paid but the local - // wallet is still in a dialog state. - switch (rec?.purchaseStatus) { - case PurchaseStatus.DialogProposed: - case PurchaseStatus.DialogShared: - break; - default: - return; - } - rec.purchaseStatus = PurchaseStatus.FailedPaidByOther; - await h.update(rec, "paid-by-other"); - }); - } + if (isSharedPurchase(oldProposal)) { + // Preparing a payment is deliberately local-only. Wake the transaction + // task so that it can check the merchant asynchronously. + await wex.taskScheduler.resetTask(oldCtx.taskId); } return { proposalId: oldProposal.proposalId, @@ -3485,7 +3472,7 @@ async function processPurchasePay( const download = await expectProposalDownload(wex, purchase); - if (purchase.shared) { + if (isSharedPurchase(purchase)) { const orderStatus = await checkIfOrderIsAlreadyPaid(wex, download, false); if (orderStatus === "gone") { @@ -4628,6 +4615,17 @@ async function processPurchaseDialogProposed( ): Promise<TaskRunResult> { const proposalId = purchase.proposalId; const ctx = new PayMerchantTransactionContext(wex, proposalId); + if (purchase.createdFromShared) { + await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (rec?.purchaseStatus !== PurchaseStatus.DialogProposed) { + return; + } + rec.purchaseStatus = PurchaseStatus.DialogShared; + await h.update(rec, "upgrade-shared-dialog"); + }); + return TaskRunResult.progress(); + } const txRes = await wex.runWalletDbTx(async (tx) => { const rec = await tx.getPurchase(proposalId); if (!rec) {