diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/pay-merchant.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/pay-merchant.ts | 262 |
1 files changed, 244 insertions, 18 deletions
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<string> { - 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<SharePaymentResult> { + 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<TaskRunResult> { + 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, |