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:
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);
}
}