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:
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) {