diff options
Diffstat (limited to 'packages/taler-wallet-core/src/pay-merchant.ts')
-rw-r--r-- | packages/taler-wallet-core/src/pay-merchant.ts | 889 |
1 files changed, 592 insertions, 297 deletions
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 812d32429..090a11cf0 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -34,13 +34,17 @@ import { assertUnreachable, AsyncFlag, checkDbInvariant, + CheckPaymentResponse, + CheckPayTemplateReponse, + CheckPayTemplateRequest, codecForAbortResponse, codecForMerchantContractTerms, codecForMerchantOrderStatusPaid, codecForMerchantPayResponse, - codecForMerchantPostOrderResponse, + codecForPostOrderResponse, codecForProposal, codecForWalletRefundResponse, + codecForWalletTemplateDetails, CoinDepositPermission, CoinRefreshRequest, ConfirmPayResult, @@ -63,12 +67,12 @@ import { parsePayTemplateUri, parsePayUri, parseTalerUri, - PayCoinSelection, PreparePayResult, PreparePayResultType, PreparePayTemplateRequest, randomBytes, RefreshReason, + SelectedProspectiveCoin, SharePaymentResult, StartRefundQueryForUriResponse, stringifyPayUri, @@ -76,6 +80,8 @@ import { TalerError, TalerErrorCode, TalerErrorDetail, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, TalerPreciseTimestamp, TalerProtocolViolationError, TalerUriAction, @@ -143,7 +149,6 @@ import { getDenomInfo, WalletExecutionContext, } from "./wallet.js"; -import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; /** * Logger. @@ -201,7 +206,7 @@ export class PayMerchantTransactionContext implements TransactionContext { const ws = this.wex; const extraStores = opts.extraStores ?? []; const transitionInfo = await ws.db.runReadWriteTx( - ["purchases", ...extraStores], + { storeNames: ["purchases", ...extraStores] }, async (tx) => { const purchaseRec = await tx.purchases.get(this.proposalId); if (!purchaseRec) { @@ -228,26 +233,29 @@ export class PayMerchantTransactionContext implements TransactionContext { async deleteTransaction(): Promise<void> { const { wex: ws, proposalId } = this; - await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => { - let found = false; - const purchase = await tx.purchases.get(proposalId); - if (purchase) { - found = true; - await tx.purchases.delete(proposalId); - } - if (found) { - await tx.tombstones.put({ - id: TombstoneTag.DeletePayment + ":" + proposalId, - }); - } - }); + await ws.db.runReadWriteTx( + { storeNames: ["purchases", "tombstones"] }, + async (tx) => { + let found = false; + const purchase = await tx.purchases.get(proposalId); + if (purchase) { + found = true; + await tx.purchases.delete(proposalId); + } + if (found) { + await tx.tombstones.put({ + id: TombstoneTag.DeletePayment + ":" + proposalId, + }); + } + }, + ); } async suspendTransaction(): Promise<void> { const { wex, proposalId, transactionId } = this; wex.taskScheduler.stopShepherdTask(this.taskId); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { @@ -269,14 +277,17 @@ export class PayMerchantTransactionContext implements TransactionContext { async abortTransaction(): Promise<void> { const { wex, proposalId, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( - [ - "purchases", - "refreshGroups", - "denominations", - "coinAvailability", - "coins", - "operationRetries", - ], + { + storeNames: [ + "purchases", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + "coins", + "operationRetries", + ], + }, async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { @@ -290,7 +301,7 @@ export class PayMerchantTransactionContext implements TransactionContext { case PurchaseStatus.PendingPaying: case PurchaseStatus.SuspendedPaying: { purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; - if (purchase.payInfo) { + if (purchase.payInfo && purchase.payInfo.payCoinSelection) { const coinSel = purchase.payInfo.payCoinSelection; const currency = Amounts.currencyOf( purchase.payInfo.totalPayCost, @@ -344,7 +355,7 @@ export class PayMerchantTransactionContext implements TransactionContext { async resumeTransaction(): Promise<void> { const { wex, proposalId, transactionId, taskId: retryTag } = this; const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { @@ -367,14 +378,16 @@ export class PayMerchantTransactionContext implements TransactionContext { async failTransaction(): Promise<void> { const { wex, proposalId, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( - [ - "purchases", - "refreshGroups", - "denominations", - "coinAvailability", - "coins", - "operationRetries", - ], + { + storeNames: [ + "purchases", + "refreshGroups", + "denominations", + "coinAvailability", + "coins", + "operationRetries", + ], + }, async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { @@ -415,15 +428,18 @@ export class RefundTransactionContext implements TransactionContext { async deleteTransaction(): Promise<void> { const { wex, refundGroupId, transactionId } = this; - await wex.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => { - const refundRecord = await tx.refundGroups.get(refundGroupId); - if (!refundRecord) { - return; - } - await tx.refundGroups.delete(refundGroupId); - await tx.tombstones.put({ id: transactionId }); - // FIXME: Also tombstone the refund items, so that they won't reappear. - }); + await wex.db.runReadWriteTx( + { storeNames: ["refundGroups", "tombstones"] }, + async (tx) => { + const refundRecord = await tx.refundGroups.get(refundGroupId); + if (!refundRecord) { + return; + } + await tx.refundGroups.delete(refundGroupId); + await tx.tombstones.put({ id: transactionId }); + // FIXME: Also tombstone the refund items, so that they won't reappear. + }, + ); } suspendTransaction(): Promise<void> { @@ -452,47 +468,37 @@ export class RefundTransactionContext implements TransactionContext { */ export async function getTotalPaymentCost( wex: WalletExecutionContext, - pcs: PayCoinSelection, + currency: string, + pcs: SelectedProspectiveCoin[], ): Promise<AmountJson> { - const currency = Amounts.currencyOf(pcs.customerDepositFees); - return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.coins.length; i++) { - const coin = await tx.coins.get(pcs.coins[i].coinPub); - if (!coin) { - throw Error("can't calculate payment cost, coin not found"); - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error( - "can't calculate payment cost, denomination for coin not found", + return wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.length; i++) { + const denom = await tx.denominations.get([ + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, + ]); + if (!denom) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, + tx, + DenominationRecord.toDenomInfo(denom), + amountLeft, ); + costs.push(Amounts.parseOrThrow(pcs[i].contribution)); + costs.push(refreshCost); } - const allDenoms = await getCandidateWithdrawalDenomsTx( - wex, - tx, - coin.exchangeBaseUrl, - currency, - ); - const amountLeft = Amounts.sub( - denom.value, - pcs.coins[i].contribution, - ).amount; - const refreshCost = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - wex.ws.config.testing.denomselAllowLate, - ); - costs.push(Amounts.parseOrThrow(pcs.coins[i].contribution)); - costs.push(refreshCost); - } - const zero = Amounts.zeroOfAmount(pcs.customerDepositFees); - return Amounts.sum([zero, ...costs]).amount; - }); + const zero = Amounts.zeroOfCurrency(currency); + return Amounts.sum([zero, ...costs]).amount; + }, + ); } async function failProposalPermanently( @@ -505,7 +511,7 @@ async function failProposalPermanently( proposalId, }); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { @@ -525,7 +531,7 @@ async function failProposalPermanently( function getPayRequestTimeout(purchase: PurchaseRecord): Duration { return Duration.multiply( { d_ms: 15000 }, - 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5, + 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5, ); } @@ -567,7 +573,10 @@ export async function expectProposalDownload( if (parentTx) { return getFromTransaction(parentTx); } - return await wex.db.runReadOnlyTx(["contractTerms"], getFromTransaction); + return await wex.db.runReadOnlyTx( + { storeNames: ["contractTerms"] }, + getFromTransaction, + ); } export function extractContractData( @@ -606,9 +615,12 @@ async function processDownloadProposal( wex: WalletExecutionContext, proposalId: string, ): Promise<TaskRunResult> { - const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return await tx.purchases.get(proposalId); - }); + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return await tx.purchases.get(proposalId); + }, + ); if (!proposal) { return TaskRunResult.finished(); @@ -779,7 +791,7 @@ async function processDownloadProposal( logger.trace(`extracted contract data: ${j2s(contractData)}`); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases", "contractTerms"], + { storeNames: ["purchases", "contractTerms"] }, async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { @@ -852,12 +864,15 @@ async function createOrReusePurchase( claimToken: string | undefined, noncePriv: string | undefined, ): Promise<string> { - const oldProposals = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.indexes.byUrlAndOrderId.getAll([ - merchantBaseUrl, - orderId, - ]); - }); + const oldProposals = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.indexes.byUrlAndOrderId.getAll([ + merchantBaseUrl, + orderId, + ]); + }, + ); const oldProposal = oldProposals.find((p) => { return ( @@ -891,7 +906,7 @@ async function createOrReusePurchase( // if this transaction was shared and the order is paid then it // means that another wallet already paid the proposal const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(oldProposal.proposalId); if (!p) { @@ -957,7 +972,7 @@ async function createOrReusePurchase( }; const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { await tx.purchases.put(proposalRecord); const oldTxState: TransactionState = { @@ -991,7 +1006,7 @@ async function storeFirstPaySuccess( }); const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); const transitionInfo = await wex.db.runReadWriteTx( - ["contractTerms", "purchases"], + { storeNames: ["contractTerms", "purchases"] }, async (tx) => { const purchase = await tx.purchases.get(proposalId); @@ -1055,7 +1070,7 @@ async function storePayReplaySuccess( proposalId, }); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const purchase = await tx.purchases.get(proposalId); @@ -1098,9 +1113,12 @@ async function handleInsufficientFunds( ): Promise<void> { logger.trace("handling insufficient funds, trying to re-select coins"); - const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if (!proposal) { return; } @@ -1136,17 +1154,23 @@ async function handleInsufficientFunds( } const payCoinSelection = payInfo.payCoinSelection; + if (!payCoinSelection) { + return; + } - await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - const coinPub = payCoinSelection.coinPubs[i]; - const contrib = payCoinSelection.coinContributions[i]; - prevPayCoins.push({ - coinPub, - contribution: Amounts.parseOrThrow(contrib), - }); - } - }); + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const contrib = payCoinSelection.coinContributions[i]; + prevPayCoins.push({ + coinPub, + contribution: Amounts.parseOrThrow(contrib), + }); + } + }, + ); const res = await selectPayCoins(wex, { restrictExchanges: { @@ -1156,26 +1180,35 @@ async function handleInsufficientFunds( restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: 1, // FIXME #8653 prevPayCoins, requiredMinimumAge: contractData.minimumAge, }); - if (res.type !== "success") { - logger.trace("insufficient funds for coin re-selection"); - return; + switch (res.type) { + case "failure": + logger.trace("insufficient funds for coin re-selection"); + return; + case "prospective": + return; + case "success": + break; + default: + assertUnreachable(res); } logger.trace("re-selected coins"); await wex.db.runReadWriteTx( - [ - "purchases", - "coins", - "coinAvailability", - "denominations", - "refreshGroups", - ], + { + storeNames: [ + "purchases", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + ], + }, async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { @@ -1224,9 +1257,12 @@ async function checkPaymentByProposalId( proposalId: string, sessionId?: string, ): Promise<PreparePayResult> { - let proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + let proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if (!proposal) { throw Error(`could not get proposal ${proposalId}`); } @@ -1235,7 +1271,7 @@ async function checkPaymentByProposalId( if (existingProposalId) { logger.trace("using existing purchase for same product"); const oldProposal = await wex.db.runReadOnlyTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(existingProposalId); }, @@ -1254,6 +1290,8 @@ async function checkPaymentByProposalId( proposalId = proposal.proposalId; + const currency = Amounts.currencyOf(contractData.amount); + const ctx = new PayMerchantTransactionContext(wex, proposalId); const transactionId = ctx.transactionId; @@ -1267,9 +1305,12 @@ async function checkPaymentByProposalId( }); // First check if we already paid for it. - const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if ( !purchase || @@ -1285,29 +1326,42 @@ async function checkPaymentByProposalId( }, contractTermsAmount: instructedAmount, depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: 1, // FIXME #8653 prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, restrictWireMethod: contractData.wireMethod, }); - if (res.type !== "success") { - logger.info("not allowing payment, insufficient coins"); - logger.info( - `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`, - ); - return { - status: PreparePayResultType.InsufficientBalance, - contractTerms: d.contractTermsRaw, - proposalId: proposal.proposalId, - transactionId, - amountRaw: Amounts.stringify(d.contractData.amount), - talerUri, - balanceDetails: res.insufficientBalanceDetails, - }; + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (res.type) { + case "failure": { + logger.info("not allowing payment, insufficient coins"); + logger.info( + `insufficient balance details: ${j2s( + res.insufficientBalanceDetails, + )}`, + ); + return { + status: PreparePayResultType.InsufficientBalance, + contractTerms: d.contractTermsRaw, + proposalId: proposal.proposalId, + transactionId, + amountRaw: Amounts.stringify(d.contractData.amount), + talerUri, + balanceDetails: res.insufficientBalanceDetails, + }; + } + case "prospective": + coins = res.result.prospectiveCoins; + break; + case "success": + coins = res.coinSel.coins; + break; + default: + assertUnreachable(res); } - const totalCost = await getTotalPaymentCost(wex, res.coinSel); + const totalCost = await getTotalPaymentCost(wex, currency, coins); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); @@ -1332,7 +1386,7 @@ async function checkPaymentByProposalId( ); logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { @@ -1409,9 +1463,12 @@ export async function getContractTermsDetails( wex: WalletExecutionContext, proposalId: string, ): Promise<WalletContractData> { - const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); @@ -1501,7 +1558,7 @@ async function internalWaitProposalDownloaded( ): Promise<void> { while (true) { const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx( - ["purchases", "operationRetries"], + { storeNames: ["purchases", "operationRetries"] }, async (tx) => { return { purchase: await tx.purchases.get(ctx.proposalId), @@ -1527,39 +1584,92 @@ async function internalWaitProposalDownloaded( } } +async function downloadTemplate( + wex: WalletExecutionContext, + merchantBaseUrl: string, + templateId: string, +): Promise<TalerMerchantApi.WalletTemplateDetails> { + const reqUrl = new URL(`templates/${templateId}`, merchantBaseUrl); + const httpReq = await wex.http.fetch(reqUrl.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + const resp = await readSuccessResponseJsonOrThrow( + httpReq, + codecForWalletTemplateDetails(), + ); + return resp; +} + +export async function checkPayForTemplate( + wex: WalletExecutionContext, + req: CheckPayTemplateRequest, +): Promise<CheckPayTemplateReponse> { + const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri); + if (!parsedUri) { + throw Error("invalid taler-template URI"); + } + const templateDetails = await downloadTemplate( + wex, + parsedUri.merchantBaseUrl, + parsedUri.templateId, + ); + + const merchantApi = new TalerMerchantInstanceHttpClient( + parsedUri.merchantBaseUrl, + wex.http, + ); + + const cfg = await merchantApi.getConfig(); + if (cfg.type === "fail") { + throw TalerError.fromUncheckedDetail(cfg.detail); + } + + return { + templateDetails, + supportedCurrencies: Object.keys(cfg.body.currencies), + }; +} + export async function preparePayForTemplate( wex: WalletExecutionContext, req: PreparePayTemplateRequest, ): Promise<PreparePayResult> { const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri); - const templateDetails: MerchantUsingTemplateDetails = {}; if (!parsedUri) { throw Error("invalid taler-template URI"); } logger.trace(`parsed URI: ${j2s(parsedUri)}`); + const templateDetails: MerchantUsingTemplateDetails = {}; - const amountFromUri = parsedUri.templateParams.amount; - if (amountFromUri != null) { - const templateParamsAmount = req.templateParams?.amount; - if (templateParamsAmount != null) { - templateDetails.amount = templateParamsAmount as AmountString; - } else { - if (Amounts.isCurrency(amountFromUri)) { - throw Error( - "Amount from template URI only has a currency without value. The value must be provided in the templateParams.", - ); - } else { - templateDetails.amount = amountFromUri as AmountString; - } + const templateInfo = await downloadTemplate( + wex, + parsedUri.merchantBaseUrl, + parsedUri.templateId, + ); + + const templateParamsAmount = req.templateParams?.amount as + | AmountString + | undefined; + if (templateParamsAmount === null) { + const amountFromUri = templateInfo.editable_defaults?.amount; + if (amountFromUri != null) { + templateDetails.amount = amountFromUri as AmountString; } + } else { + templateDetails.amount = templateParamsAmount; } - if ( - parsedUri.templateParams.summary !== undefined && - typeof parsedUri.templateParams.summary === "string" - ) { - templateDetails.summary = - req.templateParams?.summary ?? parsedUri.templateParams.summary; + + const templateParamsSummary = req.templateParams?.summary; + if (templateParamsSummary === null) { + const summaryFromUri = templateInfo.editable_defaults?.summary; + if (summaryFromUri != null) { + templateDetails.summary = summaryFromUri; + } + } else { + templateDetails.summary = templateParamsSummary; } + const reqUrl = new URL( `templates/${parsedUri.templateId}`, parsedUri.merchantBaseUrl, @@ -1570,7 +1680,7 @@ export async function preparePayForTemplate( }); const resp = await readSuccessResponseJsonOrThrow( httpReq, - codecForMerchantPostOrderResponse(), + codecForPostOrderResponse(), ); const payUri = stringifyPayUri({ @@ -1598,24 +1708,27 @@ export async function generateDepositPermissions( coin: CoinRecord; denom: DenominationRecord; }> = []; - await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - for (let i = 0; i < payCoinSel.coinContributions.length; i++) { - const coin = await tx.coins.get(payCoinSel.coinPubs[i]); - if (!coin) { - throw Error("can't pay, allocated coin not found anymore"); - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error( - "can't pay, denomination of allocated coin not found anymore", - ); + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { + for (let i = 0; i < payCoinSel.coinContributions.length; i++) { + const coin = await tx.coins.get(payCoinSel.coinPubs[i]); + if (!coin) { + throw Error("can't pay, allocated coin not found anymore"); + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error( + "can't pay, denomination of allocated coin not found anymore", + ); + } + coinWithDenom.push({ coin, denom }); } - coinWithDenom.push({ coin, denom }); - } - }); + }, + ); for (let i = 0; i < payCoinSel.coinContributions.length; i++) { const { coin, denom } = coinWithDenom[i]; @@ -1650,7 +1763,7 @@ async function internalWaitPaymentResult( ): Promise<ConfirmPayResult> { while (true) { const txRes = await ctx.wex.db.runReadOnlyTx( - ["purchases", "operationRetries"], + { storeNames: ["purchases", "operationRetries"] }, async (tx) => { const purchase = await tx.purchases.get(ctx.proposalId); const retryRecord = await tx.operationRetries.get(ctx.taskId); @@ -1764,9 +1877,12 @@ export async function confirmPay( logger.trace( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, ); - const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); @@ -1778,7 +1894,7 @@ export async function confirmPay( } const existingPurchase = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const purchase = await tx.purchases.get(proposalId); if ( @@ -1811,6 +1927,8 @@ export async function confirmPay( const contractData = d.contractData; + const currency = Amounts.currencyOf(contractData.amount); + const selectCoinsResult = await selectPayCoins(wex, { restrictExchanges: { auditors: [], @@ -1819,24 +1937,35 @@ export async function confirmPay( restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: 1, // FIXME #8653 prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, forcedSelection: forcedCoinSel, }); - logger.trace("coin selection result", selectCoinsResult); + let coins: SelectedProspectiveCoin[] | undefined = undefined; - if (selectCoinsResult.type === "failure") { - // Should not happen, since checkPay should be called first - // FIXME: Actually, this should be handled gracefully, - // and the status should be stored in the DB. - logger.warn("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); + switch (selectCoinsResult.type) { + case "failure": { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + case "prospective": { + coins = selectCoinsResult.result.prospectiveCoins; + break; + } + case "success": + coins = selectCoinsResult.coinSel.coins; + break; + default: + assertUnreachable(selectCoinsResult); } - const coinSelection = selectCoinsResult.coinSel; - const payCostInfo = await getTotalPaymentCost(wex, coinSelection); + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCost(wex, currency, coins); let sessionId: string | undefined; if (sessionIdOverride) { @@ -1850,13 +1979,16 @@ export async function confirmPay( ); const transitionInfo = await wex.db.runReadWriteTx( - [ - "purchases", - "coins", - "refreshGroups", - "denominations", - "coinAvailability", - ], + { + storeNames: [ + "purchases", + "coins", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + ], + }, async (tx) => { const p = await tx.purchases.get(proposal.proposalId); if (!p) { @@ -1867,29 +1999,37 @@ export async function confirmPay( case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: p.payInfo = { - payCoinSelection: { - coinContributions: coinSelection.coins.map((x) => x.contribution), - coinPubs: coinSelection.coins.map((x) => x.coinPub), - }, - payCoinSelectionUid: encodeCrock(getRandomBytes(16)), totalPayCost: Amounts.stringify(payCostInfo), }; + if (selectCoinsResult.type === "success") { + p.payInfo.payCoinSelection = { + coinContributions: selectCoinsResult.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + }; + p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); + } p.lastSessionId = sessionId; p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); p.purchaseStatus = PurchaseStatus.PendingPaying; await tx.purchases.put(p); - await spendCoins(wex, tx, { - //`txn:proposal:${p.proposalId}` - allocationId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: proposalId, - }), - coinPubs: coinSelection.coins.map((x) => x.coinPub), - contributions: coinSelection.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayMerchant, - }); + if (p.payInfo.payCoinSelection) { + const sel = p.payInfo.payCoinSelection; + await spendCoins(wex, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: sel.coinPubs, + contributions: sel.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayMerchant, + }); + } + break; case PurchaseStatus.Done: case PurchaseStatus.PendingPaying: @@ -1920,9 +2060,12 @@ export async function processPurchase( wex: WalletExecutionContext, proposalId: string, ): Promise<TaskRunResult> { - const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if (!purchase) { return { type: TaskRunResultType.Error, @@ -1979,9 +2122,12 @@ async function processPurchasePay( wex: WalletExecutionContext, proposalId: string, ): Promise<TaskRunResult> { - const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { + return tx.purchases.get(proposalId); + }, + ); if (!purchase) { return { type: TaskRunResultType.Error, @@ -2003,6 +2149,8 @@ async function processPurchasePay( } logger.trace(`processing purchase pay ${proposalId}`); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const sessionId = purchase.lastSessionId; logger.trace(`paying with session ID ${sessionId}`); @@ -2020,7 +2168,7 @@ async function processPurchasePay( if (paid) { const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { @@ -2051,6 +2199,110 @@ async function processPurchasePay( } } + const contractData = download.contractData; + const currency = Amounts.currencyOf(download.contractData.amount); + + if (!payInfo.payCoinSelection) { + const selectCoinsResult = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + }); + switch (selectCoinsResult.type) { + case "failure": { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + case "prospective": { + throw Error("insufficient balance (pending refresh)"); + } + case "success": + break; + default: + assertUnreachable(selectCoinsResult); + } + + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCost( + wex, + currency, + selectCoinsResult.coinSel.coins, + ); + + const transitionDone = await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "coins", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return false; + } + if (p.payInfo?.payCoinSelection) { + return false; + } + switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: + case PurchaseStatus.DialogProposed: + p.payInfo = { + totalPayCost: Amounts.stringify(payCostInfo), + payCoinSelection: { + coinContributions: selectCoinsResult.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + }, + }; + p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); + p.purchaseStatus = PurchaseStatus.PendingPaying; + await tx.purchases.put(p); + + await spendCoins(wex, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + contributions: selectCoinsResult.coinSel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayMerchant, + }); + return true; + case PurchaseStatus.Done: + case PurchaseStatus.PendingPaying: + default: + break; + } + return false; + }, + ); + + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, @@ -2105,6 +2357,7 @@ async function processPurchasePay( TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS ) { // Do this in the background, as it might take some time + // FIXME: Why? We're already in a (background) task! handleInsufficientFunds(wex, proposalId, err).catch(async (e) => { logger.error("handling insufficient funds failed"); logger.error(`${e.toString()}`); @@ -2190,7 +2443,7 @@ export async function refuseProposal( proposalId, }); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const proposal = await tx.purchases.get(proposalId); if (!proposal) { @@ -2464,42 +2717,45 @@ export async function sharePayment( merchantBaseUrl: string, orderId: string, ): Promise<SharePaymentResult> { - const result = await wex.db.runReadWriteTx(["purchases"], 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; - } - const oldTxState = computePayMerchantTransactionState(p); - if (p.purchaseStatus === PurchaseStatus.DialogProposed) { - p.purchaseStatus = PurchaseStatus.DialogShared; - p.shared = true; - await tx.purchases.put(p); - } + const result = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + 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; + } + const oldTxState = computePayMerchantTransactionState(p); + if (p.purchaseStatus === PurchaseStatus.DialogProposed) { + p.purchaseStatus = PurchaseStatus.DialogShared; + p.shared = true; + await tx.purchases.put(p); + } - const newTxState = computePayMerchantTransactionState(p); + const newTxState = computePayMerchantTransactionState(p); - return { - proposalId: p.proposalId, - nonce: p.noncePriv, - session: p.lastSessionId ?? p.downloadSessionId, - token: p.claimToken, - transitionInfo: { - oldTxState, - newTxState, - }, - }; - }); + return { + proposalId: p.proposalId, + nonce: p.noncePriv, + session: p.lastSessionId ?? p.downloadSessionId, + token: p.claimToken, + transitionInfo: { + oldTxState, + newTxState, + }, + }; + }, + ); if (result === undefined) { throw Error("This purchase can't be shared"); @@ -2574,7 +2830,7 @@ async function processPurchaseDialogShared( ); if (paid) { const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { @@ -2613,23 +2869,47 @@ async function processPurchaseAutoRefund( const download = await expectProposalDownload(wex, purchase); - if ( + const noAutoRefundOrExpired = !purchase.autoRefundDeadline || AbsoluteTime.isExpired( AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(purchase.autoRefundDeadline), ), - ) - ) { + ); + + const totalKnownRefund = await wex.db.runReadOnlyTx( + { storeNames: ["refundGroups"] }, + async (tx) => { + const refunds = await tx.refundGroups.indexes.byProposalId.getAll( + purchase.proposalId, + ); + const am = Amounts.parseOrThrow(download.contractData.amount); + return refunds.reduce((prev, cur) => { + if ( + cur.status === RefundGroupStatus.Done || + cur.status === RefundGroupStatus.Pending + ) { + return Amounts.add(prev, cur.amountEffective).amount; + } + return prev; + }, Amounts.zeroOfAmount(am)); + }, + ); + + const refundedIsLessThanPrice = + Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1; + const nothingMoreToRefund = !refundedIsLessThanPrice; + + if (noAutoRefundOrExpired || nothingMoreToRefund) { const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) { + if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { return; } const oldTxState = computePayMerchantTransactionState(p); @@ -2653,8 +2933,8 @@ async function processPurchaseAutoRefund( download.contractData.contractTermsHash, ); - requestUrl.searchParams.set("timeout_ms", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); + requestUrl.searchParams.set("timeout_ms", "10000"); + requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund)); const resp = await wex.http.fetch(requestUrl.href, { cancellationToken: wex.cancellationToken, @@ -2669,7 +2949,7 @@ async function processPurchaseAutoRefund( if (orderStatus.refund_pending) { const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { @@ -2687,9 +2967,10 @@ async function processPurchaseAutoRefund( }, ); notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); } - return TaskRunResult.backoff(); + return TaskRunResult.longpollReturnedPending(); } async function processPurchaseAbortingRefund( @@ -2712,7 +2993,7 @@ async function processPurchaseAbortingRefund( throw Error("can't abort, no coins selected"); } - await wex.db.runReadOnlyTx(["coins"], async (tx) => { + await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => { for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; const coin = await tx.coins.get(coinPub); @@ -2818,7 +3099,7 @@ async function processPurchaseQueryRefund( if (!orderStatus.refund_pending) { const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { @@ -2845,7 +3126,7 @@ async function processPurchaseQueryRefund( ).amount; const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { @@ -2913,7 +3194,7 @@ export async function startRefundQueryForUri( throw Error("expected taler://refund URI"); } const purchaseRecord = await wex.db.runReadOnlyTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ parsedUri.merchantBaseUrl, @@ -2944,7 +3225,7 @@ export async function startQueryRefund( ): Promise<void> { const ctx = new PayMerchantTransactionContext(wex, proposalId); const transitionInfo = await wex.db.runReadWriteTx( - ["purchases"], + { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { @@ -3037,17 +3318,20 @@ async function storeRefunds( const currency = Amounts.currencyOf(download.contractData.amount); const result = await wex.db.runReadWriteTx( - [ - "coins", - "denominations", - "purchases", - "refundItems", - "refundGroups", - "denominations", - "coins", - "coinAvailability", - "refreshGroups", - ], + { + storeNames: [ + "coins", + "denominations", + "purchases", + "refundItems", + "refundGroups", + "denominations", + "coins", + "coinAvailability", + "refreshGroups", + "refreshSessions", + ], + }, async (tx) => { const myPurchase = await tx.purchases.get(purchase.proposalId); if (!myPurchase) { @@ -3204,9 +3488,20 @@ async function storeRefunds( } const oldTxState = computePayMerchantTransactionState(myPurchase); + + const shouldCheckAutoRefund = + myPurchase.autoRefundDeadline && + !AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(myPurchase.autoRefundDeadline), + ), + ); + if (numPendingItemsTotal === 0) { if (isAborting) { myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; + } else if (shouldCheckAutoRefund) { + myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; } else { myPurchase.purchaseStatus = PurchaseStatus.Done; } |