From 5d76573ac054c4204e95a26dc286eb0af1f2d10d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 3 Jul 2023 12:42:44 -0300 Subject: #7741 share payment save shared state in backup if purchase is shared check before making the payment of before claim the order already confirmed order can return without effective if coin selection was not made sharePayment operation --- .../src/operations/pay-merchant.ts | 262 +++++++++++++++++++-- 1 file changed, 244 insertions(+), 18 deletions(-) (limited to 'packages/taler-wallet-core/src/operations/pay-merchant.ts') diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index c74fcedcf..d53ee1b43 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -61,7 +61,10 @@ import { PreparePayResultType, randomBytes, RefreshReason, + SharePaymentResult, StartRefundQueryForUriResponse, + stringifyPaytoUri, + stringifyPayUri, stringifyTalerUri, TalerError, TalerErrorCode, @@ -542,7 +545,9 @@ async function processDownloadProposal( p.repurchaseProposalId = otherPurchase.proposalId; await tx.purchases.put(p); } else { - p.purchaseStatus = PurchaseStatus.DialogProposed; + p.purchaseStatus = p.shared + ? PurchaseStatus.DialogShared + : PurchaseStatus.DialogProposed; await tx.purchases.put(p); } const newTxState = computePayMerchantTransactionState(p); @@ -570,15 +575,22 @@ async function createPurchase( claimToken: string | undefined, noncePriv: string | undefined, ): Promise { - const oldProposal = await ws.db + const oldProposals = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.purchases.indexes.byUrlAndOrderId.get([ + return tx.purchases.indexes.byUrlAndOrderId.getAll([ merchantBaseUrl, orderId, ]); }); + const oldProposal = oldProposals.find((p) => { + return ( + p.downloadSessionId === sessionId && + (!noncePriv || p.noncePriv === noncePriv) && + p.claimToken === claimToken + ); + }); /* If we have already claimed this proposal with the same sessionId * nonce and claim token, reuse it. */ if ( @@ -589,11 +601,42 @@ async function createPurchase( ) { // FIXME: This lacks proper error handling await processDownloadProposal(ws, oldProposal.proposalId); + + if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) { + const download = await expectProposalDownload(ws, oldProposal); + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + if (paid) { + //if this transaction was shared and the order is paid then it + //means that another wallet already paid the proposal + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(oldProposal.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: oldProposal.proposalId, + }); + notifyTransition(ws, transactionId, transitionInfo); + } + } return oldProposal.proposalId; } let noncePair: EddsaKeypair; + let shared = false; if (noncePriv) { + shared = true; noncePair = { priv: noncePriv, pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, @@ -627,19 +670,12 @@ async function createPurchase( timestampLastRefundStatus: undefined, pendingRemovedCoinPubs: undefined, posConfirmation: undefined, + shared: shared, }; const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { - const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([ - merchantBaseUrl, - orderId, - ]); - if (existingRecord) { - // Created concurrently - return undefined; - } await tx.purchases.put(proposalRecord); const oldTxState: TransactionState = { major: TransactionMajorState.None, @@ -983,7 +1019,11 @@ export async function checkPaymentByProposalId( return tx.purchases.get(proposalId); }); - if (!purchase || purchase.purchaseStatus === PurchaseStatus.DialogProposed) { + if ( + !purchase || + purchase.purchaseStatus === PurchaseStatus.DialogProposed || + purchase.purchaseStatus === PurchaseStatus.DialogShared + ) { // If not already paid, check if we could pay for it. const res = await selectPayCoinsNew(ws, { auditors: [], @@ -1007,7 +1047,6 @@ export async function checkPaymentByProposalId( contractTerms: d.contractTermsRaw, proposalId: proposal.proposalId, transactionId, - noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), talerUri, balanceDetails: res.insufficientBalanceDetails, @@ -1023,7 +1062,6 @@ export async function checkPaymentByProposalId( contractTerms: d.contractTermsRaw, transactionId, proposalId: proposal.proposalId, - noncePriv: proposal.noncePriv, amountEffective: Amounts.stringify(totalCost), amountRaw: Amounts.stringify(res.coinSel.paymentAmount), contractTermsHash: d.contractData.contractTermsHash, @@ -1067,7 +1105,9 @@ export async function checkPaymentByProposalId( contractTermsHash: download.contractData.contractTermsHash, paid: true, amountRaw: Amounts.stringify(download.contractData.amount), - amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + amountEffective: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, transactionId, proposalId, talerUri, @@ -1080,7 +1120,9 @@ export async function checkPaymentByProposalId( contractTermsHash: download.contractData.contractTermsHash, paid: false, amountRaw: Amounts.stringify(download.contractData.amount), - amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + amountEffective: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, transactionId, proposalId, talerUri, @@ -1097,7 +1139,9 @@ export async function checkPaymentByProposalId( contractTermsHash: download.contractData.contractTermsHash, paid, amountRaw: Amounts.stringify(download.contractData.amount), - amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + amountEffective: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, ...(paid ? { nextUrl: download.contractData.orderId } : {}), transactionId, proposalId, @@ -1406,6 +1450,7 @@ export async function confirmPay( } const oldTxState = computePayMerchantTransactionState(p); switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: p.payInfo = { payCoinSelection: coinSelection, @@ -1480,6 +1525,8 @@ export async function processPurchase( return processPurchaseAbortingRefund(ws, purchase); case PurchaseStatus.PendingAcceptRefund: return processPurchaseAcceptRefund(ws, purchase); + case PurchaseStatus.DialogShared: + return processPurchaseDialogShared(ws, purchase); case PurchaseStatus.FailedClaim: case PurchaseStatus.Done: case PurchaseStatus.RepurchaseDetected: @@ -1540,6 +1587,41 @@ export async function processPurchasePay( checkDbInvariant(!!payInfo, "payInfo"); const download = await expectProposalDownload(ws, purchase); + + if (purchase.shared) { + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + + if (paid) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, { + orderId: purchase.orderId, + }), + }; + } + } + if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, @@ -1681,7 +1763,10 @@ export async function refuseProposal( logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); return undefined; } - if (proposal.purchaseStatus !== PurchaseStatus.DialogProposed) { + if ( + proposal.purchaseStatus !== PurchaseStatus.DialogProposed && + proposal.purchaseStatus !== PurchaseStatus.DialogShared + ) { return undefined; } const oldTxState = computePayMerchantTransactionState(proposal); @@ -1996,6 +2081,11 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Dialog, minor: TransactionMinorState.MerchantOrderProposed, }; + case PurchaseStatus.DialogShared: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.MerchantOrderProposed, + }; // Final States case PurchaseStatus.AbortedProposalRefused: return { @@ -2078,6 +2168,8 @@ export function computePayMerchantTransactionActions( // Dialog States case PurchaseStatus.DialogProposed: return []; + case PurchaseStatus.DialogShared: + return []; // Final States case PurchaseStatus.AbortedProposalRefused: return [TransactionAction.Delete]; @@ -2096,6 +2188,140 @@ export function computePayMerchantTransactionActions( } } +export async function sharePayment( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, +): Promise { + const result = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.indexes.byUrlAndOrderId.get([ + merchantBaseUrl, + orderId, + ]); + if (!p) { + logger.warn("purchase does not exist anymore"); + return undefined; + } + if ( + p.purchaseStatus !== PurchaseStatus.DialogProposed && + p.purchaseStatus !== PurchaseStatus.DialogShared + ) { + //FIXME: purchase can be shared before being paid + return undefined; + } + if (p.purchaseStatus === PurchaseStatus.DialogProposed) { + p.purchaseStatus = PurchaseStatus.DialogShared; + p.shared = true; + tx.purchases.put(p); + } + + return { + nonce: p.noncePriv, + session: p.lastSessionId, + token: p.claimToken, + }; + }); + + if (result === undefined) { + throw Error("This purchase can't be shared"); + } + const privatePayUri = stringifyPayUri({ + merchantBaseUrl, + orderId, + sessionId: result.session ?? "", + noncePriv: result.nonce, + claimToken: result.token, + }); + return { privatePayUri }; +} + +async function checkIfOrderIsAlreadyPaid( + ws: InternalWalletState, + contract: WalletContractData, +) { + const requestUrl = new URL( + `orders/${contract.orderId}`, + contract.merchantBaseUrl, + ); + requestUrl.searchParams.set("h_contract", contract.contractTermsHash); + + requestUrl.searchParams.set("timeout_ms", "1000"); + + const resp = await ws.http.fetch(requestUrl.href); + if ( + resp.status === HttpStatusCode.Ok || + resp.status === HttpStatusCode.Accepted || + resp.status === HttpStatusCode.Found + ) { + return true; + } else if (resp.status === HttpStatusCode.PaymentRequired) { + return false; + } + //forbidden, not found, not acceptable + throw Error(`this order cant be paid: ${resp.status}`); +} + +async function processPurchaseDialogShared( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; + logger.trace(`processing dialog-shared for proposal ${proposalId}`); + + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); + + // FIXME: Put this logic into runLongpollAsync? + if (ws.activeLongpoll[taskId]) { + return TaskRunResult.longpoll(); + } + const download = await expectProposalDownload(ws, purchase); + + if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { + return TaskRunResult.finished(); + } + + runLongpollAsync(ws, taskId, async (ct) => { + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + if (paid) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + return { + ready: true, + }; + } + + return { + ready: false, + }; + }); + + return TaskRunResult.longpoll(); +} + async function processPurchaseAutoRefund( ws: InternalWalletState, purchase: PurchaseRecord, -- cgit v1.2.3