taler-typescript-core

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

commit 33508e19b5aef6cffb1e57307679e62f863e5b7f
parent e38439432ef0fbd4ab4cfaf051b928a45deee04e
Author: Florian Dold <florian@dold.me>
Date:   Fri, 14 Nov 2025 17:41:07 +0100

wallet-core: refactor checkPaymentByProposalId, improve repurchase detection

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.ts | 222++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
1 file changed, 140 insertions(+), 82 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1769,6 +1769,60 @@ async function handleInsufficientFunds( return TaskRunResult.progress(); } +async function lookupProposalOrRepurchase( + wex: WalletExecutionContext, + proposalId: string, +): Promise< + | { + proposalId: string; + purchaseRec: PurchaseRecord; + contractTerms: MerchantContractTerms; + contractTermsHash: string; + } + | undefined +> { + return await wex.db.runReadOnlyTx( + { storeNames: ["purchases", "contractTerms"] }, + async (tx) => { + let purchaseRec = await tx.purchases.get(proposalId); + + if (!purchaseRec) { + return undefined; + } + + if ( + purchaseRec.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected + ) { + const existingProposalId = purchaseRec.repurchaseProposalId; + if (existingProposalId) { + logger.trace("using existing purchase for same product"); + const oldProposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(existingProposalId); + }, + ); + if (oldProposal) { + purchaseRec = oldProposal; + } + } + } + + let { contractTerms, contractTermsHash } = + await expectProposalDownloadByIdInTx(wex, tx, proposalId); + + proposalId = purchaseRec.proposalId; + + return { + proposalId, + purchaseRec: purchaseRec, + contractTerms, + contractTermsHash, + }; + }, + ); +} + // FIXME: Should take a transaction ID instead of a proposal ID // FIXME: Does way more than checking the payment // FIXME: Should return immediately. @@ -1777,35 +1831,16 @@ async function checkPaymentByProposalId( proposalId: string, sessionId?: string, ): Promise<PreparePayResult> { - let proposal = await wex.db.runReadOnlyTx( - { storeNames: ["purchases"] }, - async (tx) => { - return tx.purchases.get(proposalId); - }, - ); - if (!proposal) { + const lookupRes = await lookupProposalOrRepurchase(wex, proposalId); + if (!lookupRes) { throw Error(`could not get proposal ${proposalId}`); } - if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) { - const existingProposalId = proposal.repurchaseProposalId; - if (existingProposalId) { - logger.trace("using existing purchase for same product"); - const oldProposal = await wex.db.runReadOnlyTx( - { storeNames: ["purchases"] }, - async (tx) => { - return tx.purchases.get(existingProposalId); - }, - ); - if (oldProposal) { - proposal = oldProposal; - } - } - } - let { contractTerms, contractTermsHash } = await expectProposalDownload( - wex, - proposal, - ); - proposalId = proposal.proposalId; + // Might be redirected to another proposal ID due to repurchase detection. + proposalId = lookupRes.proposalId; + + const purchaseRec = lookupRes.purchaseRec; + let contractTerms = lookupRes.contractTerms; + const contractTermsHash = lookupRes.contractTermsHash; const ctx = new PayMerchantTransactionContext(wex, proposalId); @@ -1813,25 +1848,18 @@ async function checkPaymentByProposalId( const talerUri = stringifyTalerUri({ type: TalerUriAction.Pay, - merchantBaseUrl: proposal.merchantBaseUrl as HostPortPath, // FIXME: change record type - orderId: proposal.orderId, - sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "", - claimToken: proposal.claimToken, + merchantBaseUrl: purchaseRec.merchantBaseUrl as HostPortPath, // FIXME: change record type + orderId: purchaseRec.orderId, + sessionId: purchaseRec.lastSessionId ?? purchaseRec.downloadSessionId ?? "", + claimToken: purchaseRec.claimToken, }); - // First check if we already paid for it. - const purchase = await wex.db.runReadOnlyTx( - { storeNames: ["purchases"] }, - async (tx) => { - return tx.purchases.get(proposalId); - }, - ); + const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + let exchangeUrls = contractTerms.exchanges.map((x) => x.url); + return await getScopeForAllExchanges(tx, exchangeUrls); + }); - if ( - !purchase || - purchase.purchaseStatus === PurchaseStatus.DialogProposed || - purchase.purchaseStatus === PurchaseStatus.DialogShared - ) { + const handleUnconfirmed = async (): Promise<PreparePayResult> => { if (contractTerms.version === MerchantContractVersion.V1) { if (!wex.ws.config.features.enableV1Contracts) { let v0Contract = @@ -1929,21 +1957,13 @@ async function checkPaymentByProposalId( contractTermsHash, talerUri, }; - } - - const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { - let exchangeUrls = contractTerms.exchanges.map((x) => x.url); - return await getScopeForAllExchanges(tx, exchangeUrls); - }); + }; - if ( - purchase.purchaseStatus === PurchaseStatus.Done || - purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay - ) { + const handlePaid = async (): Promise<PreparePayResult> => { logger.trace( "automatically re-submitting payment with different session ID", ); - logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); + logger.trace(`last: ${purchaseRec.lastSessionId}, current: ${sessionId}`); await wex.db.runAllStoresReadWriteTx({}, async (tx) => { const [p, h] = await ctx.getRecordHandle(tx); if (!p) { @@ -1960,10 +1980,10 @@ async function checkPaymentByProposalId( // wait inline for the repurchase. await waitPaymentResult(wex, proposalId, sessionId); - const download = await expectProposalDownload(wex, purchase); + const download = await expectProposalDownload(wex, purchaseRec); const { available, amountRaw } = ContractTermsUtil.extractAmounts( download.contractTerms, - purchase.choiceIndex, + purchaseRec.choiceIndex, ); if (!available) { throw Error("choice index not specified for contract v1"); @@ -1975,19 +1995,21 @@ async function checkPaymentByProposalId( contractTermsHash: download.contractTermsHash, paid: true, amountRaw: Amounts.stringify(amountRaw), - amountEffective: purchase.payInfo - ? Amounts.stringify(purchase.payInfo.totalPayCost) + amountEffective: purchaseRec.payInfo + ? Amounts.stringify(purchaseRec.payInfo.totalPayCost) : undefined, scopes, transactionId, talerUri, }; - } else if (!purchase.timestampFirstSuccessfulPay) { - const download = await expectProposalDownload(wex, purchase); + }; + + const handleConfirmed = async (): Promise<PreparePayResult> => { + const paid = isPurchasePaid(purchaseRec); const { available, amountRaw } = ContractTermsUtil.extractAmounts( contractTerms, - purchase.choiceIndex, + purchaseRec.choiceIndex, ); if (!available) { throw Error("choice index not specified for contract v1"); @@ -1995,43 +2017,79 @@ async function checkPaymentByProposalId( return { status: PreparePayResultType.AlreadyConfirmed, - contractTerms, - contractTermsHash: download.contractTermsHash, - paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther, + contractTerms: contractTerms, + contractTermsHash: contractTermsHash, + paid, amountRaw: Amounts.stringify(amountRaw), - amountEffective: purchase.payInfo - ? Amounts.stringify(purchase.payInfo.totalPayCost) + amountEffective: purchaseRec.payInfo + ? Amounts.stringify(purchaseRec.payInfo.totalPayCost) : undefined, + ...(paid ? { nextUrl: contractTerms.order_id } : {}), scopes, transactionId, talerUri, }; - } else { - const paid = isPurchasePaid(purchase); - const download = await expectProposalDownload(wex, purchase); + }; - const { available, amountRaw } = ContractTermsUtil.extractAmounts( + const handlePaidByOther = async (): Promise<PreparePayResult> => { + const { amountRaw } = ContractTermsUtil.extractAmounts( contractTerms, - purchase.choiceIndex, + purchaseRec.choiceIndex, ); - if (!available) { - throw Error("choice index not specified for contract v1"); - } - return { status: PreparePayResultType.AlreadyConfirmed, - contractTerms: contractTerms, - contractTermsHash: download.contractTermsHash, - paid, - amountRaw: Amounts.stringify(amountRaw), - amountEffective: purchase.payInfo - ? Amounts.stringify(purchase.payInfo.totalPayCost) + contractTerms, + contractTermsHash: contractTermsHash, + paid: true, + amountRaw: amountRaw ? Amounts.stringify(amountRaw) : "UNKNOWN:0", + amountEffective: purchaseRec.payInfo + ? Amounts.stringify(purchaseRec.payInfo.totalPayCost) : undefined, - ...(paid ? { nextUrl: contractTerms.order_id } : {}), scopes, transactionId, talerUri, }; + }; + + if (!purchaseRec) { + return handleUnconfirmed(); + } + + switch (purchaseRec?.purchaseStatus) { + case PurchaseStatus.DialogProposed: + case PurchaseStatus.DialogShared: + return await handleUnconfirmed(); + case PurchaseStatus.Done: + case PurchaseStatus.PendingPayingReplay: + case PurchaseStatus.DoneRepurchaseDetected: + return handlePaid(); + case PurchaseStatus.AbortedIncompletePayment: + case PurchaseStatus.AbortedOrderDeleted: + case PurchaseStatus.AbortedProposalRefused: + case PurchaseStatus.AbortedRefunded: + case PurchaseStatus.AbortingWithRefund: + case PurchaseStatus.FailedAbort: + case PurchaseStatus.FailedClaim: + case PurchaseStatus.FinalizingQueryingAutoRefund: + case PurchaseStatus.PendingAcceptRefund: + case PurchaseStatus.PendingPaying: + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.PendingQueryingRefund: + case PurchaseStatus.SuspendedAbortingWithRefund: + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + case PurchaseStatus.SuspendedPaying: + case PurchaseStatus.SuspendedPayingReplay: + case PurchaseStatus.SuspendedPendingAcceptRefund: + case PurchaseStatus.SuspendedQueryingAutoRefund: + case PurchaseStatus.SuspendedQueryingRefund: + return handleConfirmed(); + case PurchaseStatus.FailedPaidByOther: + return handlePaidByOther(); + case PurchaseStatus.PendingDownloadingProposal: + case PurchaseStatus.SuspendedDownloadingProposal: + throw Error("expected proposal to be downloaded"); + default: + assertUnreachable(purchaseRec.purchaseStatus); } }