/* This file is part of GNU Taler (C) 2019-2023 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * Implementation of the payment operation, including downloading and * claiming of proposals. * * @author Florian Dold */ /** * Imports. */ import { AbortingCoin, AbortRequest, AbsoluteTime, AmountJson, Amounts, codecForAbortResponse, codecForMerchantContractTerms, codecForMerchantOrderRefundPickupResponse, codecForMerchantOrderStatusPaid, codecForMerchantPayResponse, codecForProposal, CoinDepositPermission, CoinRefreshRequest, ConfirmPayResult, ConfirmPayResultType, ContractTermsUtil, Duration, encodeCrock, ForcedCoinSel, getRandomBytes, HttpStatusCode, j2s, Logger, makeErrorDetail, makePendingOperationFailedError, MerchantCoinRefundStatus, MerchantContractTerms, MerchantPayResponse, NotificationType, parsePayUri, parseTalerUri, PayCoinSelection, PreparePayResult, PreparePayResultType, randomBytes, RefreshReason, StartRefundQueryForUriResponse, stringifyTalerUri, TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, TalerProtocolViolationError, TalerUriAction, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, } from "@gnu-taler/taler-util"; import { getHttpResponseErrorDetails, readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, readTalerErrorResponse, readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { BackupProviderStateTag, CoinRecord, DenominationRecord, PurchaseRecord, PurchaseStatus, RefundReason, WalletContractData, WalletStoresV1, } from "../db.js"; import { PendingTaskType, RefundGroupRecord, RefundGroupStatus, RefundItemRecord, RefundItemStatus, } from "../index.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState, } from "../internal-wallet-state.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js"; import { checkDbInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { constructTaskIdentifier, OperationAttemptResult, OperationAttemptResultType, RetryInfo, scheduleRetry, TaskIdentifiers, } from "../util/retries.js"; import { runLongpollAsync, runOperationWithErrorReporting, spendCoins, } from "./common.js"; import { calculateRefreshOutput, createRefreshGroup, getTotalRefreshCost, } from "./refresh.js"; import { constructTransactionIdentifier, notifyTransition, stopLongpolling, } from "./transactions.js"; /** * Logger. */ const logger = new Logger("pay-merchant.ts"); /** * Compute the total cost of a payment to the customer. * * This includes the amount taken by the merchant, fees (wire/deposit) contributed * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings" * of coins that are too small to spend. */ export async function getTotalPaymentCost( ws: InternalWalletState, pcs: PayCoinSelection, ): Promise { return ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { const costs: AmountJson[] = []; for (let i = 0; i < pcs.coinPubs.length; i++) { const coin = await tx.coins.get(pcs.coinPubs[i]); 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", ); } const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl .iter(coin.exchangeBaseUrl) .filter((x) => Amounts.isSameCurrency( DenominationRecord.getValue(x), pcs.coinContributions[i], ), ); const amountLeft = Amounts.sub( DenominationRecord.getValue(denom), pcs.coinContributions[i], ).amount; const refreshCost = getTotalRefreshCost( allDenoms, DenominationRecord.toDenomInfo(denom), amountLeft, ws.config.testing.denomselAllowLate, ); costs.push(Amounts.parseOrThrow(pcs.coinContributions[i])); costs.push(refreshCost); } const zero = Amounts.zeroOfAmount(pcs.paymentAmount); return Amounts.sum([zero, ...costs]).amount; }); } async function failProposalPermanently( ws: InternalWalletState, proposalId: string, err: TalerErrorDetail, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; } // FIXME: We don't store the error detail here?! const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.FailedClaim; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); } function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration { return Duration.clamp({ lower: Duration.fromSpec({ seconds: 1 }), upper: Duration.fromSpec({ seconds: 60 }), value: retryInfo ? RetryInfo.getDuration(retryInfo) : Duration.fromSpec({}), }); } function getPayRequestTimeout(purchase: PurchaseRecord): Duration { return Duration.multiply( { d_ms: 15000 }, 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5, ); } /** * Return the proposal download data for a purchase, throw if not available. */ export async function expectProposalDownload( ws: InternalWalletState, p: PurchaseRecord, parentTx?: GetReadOnlyAccess<{ contractTerms: typeof WalletStoresV1.contractTerms; }>, ): Promise<{ contractData: WalletContractData; contractTermsRaw: any; }> { if (!p.download) { throw Error("expected proposal to be downloaded"); } const download = p.download; async function getFromTransaction( tx: Exclude, ): Promise> { const contractTerms = await tx.contractTerms.get( download.contractTermsHash, ); if (!contractTerms) { throw Error("contract terms not found"); } return { contractData: extractContractData( contractTerms.contractTermsRaw, download.contractTermsHash, download.contractTermsMerchantSig, ), contractTermsRaw: contractTerms.contractTermsRaw, }; } if (parentTx) { return getFromTransaction(parentTx); } return await ws.db .mktx((x) => [x.contractTerms]) .runReadOnly(getFromTransaction); } export function extractContractData( parsedContractTerms: MerchantContractTerms, contractTermsHash: string, merchantSig: string, ): WalletContractData { const amount = Amounts.parseOrThrow(parsedContractTerms.amount); let maxWireFee: AmountJson; if (parsedContractTerms.max_wire_fee) { maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); } else { maxWireFee = Amounts.zeroOfCurrency(amount.currency); } return { amount: Amounts.stringify(amount), contractTermsHash: contractTermsHash, fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", merchantBaseUrl: parsedContractTerms.merchant_base_url, merchantPub: parsedContractTerms.merchant_pub, merchantSig, orderId: parsedContractTerms.order_id, summary: parsedContractTerms.summary, autoRefund: parsedContractTerms.auto_refund, maxWireFee: Amounts.stringify(maxWireFee), payDeadline: parsedContractTerms.pay_deadline, refundDeadline: parsedContractTerms.refund_deadline, wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ exchangeBaseUrl: x.url, exchangePub: x.master_pub, })), timestamp: parsedContractTerms.timestamp, wireMethod: parsedContractTerms.wire_method, wireInfoHash: parsedContractTerms.h_wire, maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee), merchant: parsedContractTerms.merchant, products: parsedContractTerms.products, summaryI18n: parsedContractTerms.summary_i18n, minimumAge: parsedContractTerms.minimum_age, deliveryDate: parsedContractTerms.delivery_date, deliveryLocation: parsedContractTerms.delivery_location, }; } async function processDownloadProposal( ws: InternalWalletState, proposalId: string, ): Promise { const proposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return await tx.purchases.get(proposalId); }); if (!proposal) { return { type: OperationAttemptResultType.Finished, result: undefined, }; } if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) { return { type: OperationAttemptResultType.Finished, result: undefined, }; } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const orderClaimUrl = new URL( `orders/${proposal.orderId}/claim`, proposal.merchantBaseUrl, ).href; logger.trace("downloading contract from '" + orderClaimUrl + "'"); const requestBody: { nonce: string; token?: string; } = { nonce: proposal.noncePub, }; if (proposal.claimToken) { requestBody.token = proposal.claimToken; } const opId = TaskIdentifiers.forPay(proposal); const retryRecord = await ws.db .mktx((x) => [x.operationRetries]) .runReadOnly(async (tx) => { return tx.operationRetries.get(opId); }); const httpResponse = await ws.http.fetch(orderClaimUrl, { method: "POST", body: requestBody, timeout: getProposalRequestTimeout(retryRecord?.retryInfo), }); const r = await readSuccessResponseJsonOrErrorCode( httpResponse, codecForProposal(), ); if (r.isError) { switch (r.talerErrorResponse.code) { case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED: throw TalerError.fromDetail( TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, { orderId: proposal.orderId, claimUrl: orderClaimUrl, }, "order already claimed (likely by other wallet)", ); default: throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); } } const proposalResp = r.response; // The proposalResp contains the contract terms as raw JSON, // as the code to parse them doesn't necessarily round-trip. // We need this raw JSON to compute the contract terms hash. // FIXME: Do better error handling, check if the // contract terms have all their forgettable information still // present. The wallet should never accept contract terms // with missing information from the merchant. const isWellFormed = ContractTermsUtil.validateForgettable( proposalResp.contract_terms, ); if (!isWellFormed) { logger.trace( `malformed contract terms: ${j2s(proposalResp.contract_terms)}`, ); const err = makeErrorDetail( TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, {}, "validation for well-formedness failed", ); await failProposalPermanently(ws, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, proposalId, ); } const contractTermsHash = ContractTermsUtil.hashContractTerms( proposalResp.contract_terms, ); logger.info(`Contract terms hash: ${contractTermsHash}`); let parsedContractTerms: MerchantContractTerms; try { parsedContractTerms = codecForMerchantContractTerms().decode( proposalResp.contract_terms, ); } catch (e) { const err = makeErrorDetail( TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, {}, `schema validation failed: ${e}`, ); await failProposalPermanently(ws, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, proposalId, ); } const sigValid = await ws.cryptoApi.isValidContractTermsSignature({ contractTermsHash, merchantPub: parsedContractTerms.merchant_pub, sig: proposalResp.sig, }); if (!sigValid) { const err = makeErrorDetail( TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID, { merchantPub: parsedContractTerms.merchant_pub, orderId: parsedContractTerms.order_id, }, "merchant's signature on contract terms is invalid", ); await failProposalPermanently(ws, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, proposalId, ); } const fulfillmentUrl = parsedContractTerms.fulfillment_url; const baseUrlForDownload = proposal.merchantBaseUrl; const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url; if (baseUrlForDownload !== baseUrlFromContractTerms) { const err = makeErrorDetail( TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH, { baseUrlForDownload, baseUrlFromContractTerms, }, "merchant base URL mismatch", ); await failProposalPermanently(ws, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, proposalId, ); } const contractData = extractContractData( parsedContractTerms, contractTermsHash, proposalResp.sig, ); logger.trace(`extracted contract data: ${j2s(contractData)}`); const transitionInfo = await ws.db .mktx((x) => [x.purchases, x.contractTerms]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; } if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) { return; } const oldTxState = computePayMerchantTransactionState(p); p.download = { contractTermsHash, contractTermsMerchantSig: contractData.merchantSig, currency: Amounts.currencyOf(contractData.amount), fulfillmentUrl: contractData.fulfillmentUrl, }; await tx.contractTerms.put({ h: contractTermsHash, contractTermsRaw: proposalResp.contract_terms, }); if ( fulfillmentUrl && (fulfillmentUrl.startsWith("http://") || fulfillmentUrl.startsWith("https://")) ) { const differentPurchase = await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); // FIXME: Adjust this to account for refunds, don't count as repurchase // if original order is refunded. if (differentPurchase) { logger.warn("repurchase detected"); p.purchaseStatus = PurchaseStatus.RepurchaseDetected; p.repurchaseProposalId = differentPurchase.proposalId; await tx.purchases.put(p); } } else { p.purchaseStatus = PurchaseStatus.Proposed; await tx.purchases.put(p); } const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState, }; }); notifyTransition(ws, transactionId, transitionInfo); // FIXME: Deprecated pre-DD37 notification, remove eventually ws.notify({ type: NotificationType.ProposalDownloaded, proposalId: proposal.proposalId, }); return { type: OperationAttemptResultType.Finished, result: undefined, }; } /** * Create a new purchase transaction if necessary. If a purchase * record for the provided arguments already exists, * return the old proposal ID. */ async function createPurchase( ws: InternalWalletState, merchantBaseUrl: string, orderId: string, sessionId: string | undefined, claimToken: string | undefined, noncePriv: string | undefined, ): Promise { const oldProposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ merchantBaseUrl, orderId, ]); }); /* If we have already claimed this proposal with the same sessionId * nonce and claim token, reuse it. */ if ( oldProposal && oldProposal.downloadSessionId === sessionId && (!noncePriv || oldProposal.noncePriv === noncePriv) && oldProposal.claimToken === claimToken ) { await processDownloadProposal(ws, oldProposal.proposalId); return oldProposal.proposalId; } let noncePair: EddsaKeypair; if (noncePriv) { noncePair = { priv: noncePriv, pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, }; } else { noncePair = await ws.cryptoApi.createEddsaKeypair({}); } const { priv, pub } = noncePair; const proposalId = encodeCrock(getRandomBytes(32)); const proposalRecord: PurchaseRecord = { download: undefined, noncePriv: priv, noncePub: pub, claimToken, timestamp: TalerPreciseTimestamp.now(), merchantBaseUrl, orderId, proposalId: proposalId, purchaseStatus: PurchaseStatus.PendingDownloadingProposal, repurchaseProposalId: undefined, downloadSessionId: sessionId, autoRefundDeadline: undefined, lastSessionId: undefined, merchantPaySig: undefined, payInfo: undefined, refundAmountAwaiting: undefined, timestampAccept: undefined, timestampFirstSuccessfulPay: undefined, timestampLastRefundStatus: undefined, pendingRemovedCoinPubs: undefined, posConfirmation: undefined, }; 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, }; const newTxState = computePayMerchantTransactionState(proposalRecord); return { oldTxState, newTxState, }; }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); notifyTransition(ws, transactionId, transitionInfo); await processDownloadProposal(ws, proposalId); return proposalId; } async function storeFirstPaySuccess( ws: InternalWalletState, proposalId: string, sessionId: string | undefined, payResponse: MerchantPayResponse, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); const transitionInfo = await ws.db .mktx((x) => [x.purchases, x.contractTerms]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { logger.warn("purchase does not exist anymore"); return; } const isFirst = purchase.timestampFirstSuccessfulPay === undefined; if (!isFirst) { logger.warn("payment success already stored"); return; } const oldTxState = computePayMerchantTransactionState(purchase); if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) { purchase.purchaseStatus = PurchaseStatus.Done; } purchase.timestampFirstSuccessfulPay = now; purchase.lastSessionId = sessionId; purchase.merchantPaySig = payResponse.sig; purchase.posConfirmation = payResponse.pos_confirmation; const dl = purchase.download; checkDbInvariant(!!dl); const contractTermsRecord = await tx.contractTerms.get( dl.contractTermsHash, ); checkDbInvariant(!!contractTermsRecord); const contractData = extractContractData( contractTermsRecord.contractTermsRaw, dl.contractTermsHash, dl.contractTermsMerchantSig, ); const protoAr = contractData.autoRefund; if (protoAr) { const ar = Duration.fromTalerProtocolDuration(protoAr); logger.info("auto_refund present"); purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; purchase.autoRefundDeadline = AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), ar), ); } await tx.purchases.put(purchase); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState, }; }); notifyTransition(ws, transactionId, transitionInfo); } async function storePayReplaySuccess( ws: InternalWalletState, proposalId: string, sessionId: string | undefined, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { logger.warn("purchase does not exist anymore"); return; } const isFirst = purchase.timestampFirstSuccessfulPay === undefined; if (isFirst) { throw Error("invalid payment state"); } const oldTxState = computePayMerchantTransactionState(purchase); if ( purchase.purchaseStatus === PurchaseStatus.PendingPaying || purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay ) { purchase.purchaseStatus = PurchaseStatus.Done; } purchase.lastSessionId = sessionId; await tx.purchases.put(purchase); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); } /** * Handle a 409 Conflict response from the merchant. * * We do this by going through the coin history provided by the exchange and * (1) verifying the signatures from the exchange * (2) adjusting the remaining coin value and refreshing it * (3) re-do coin selection with the bad coin removed */ async function handleInsufficientFunds( ws: InternalWalletState, proposalId: string, err: TalerErrorDetail, ): Promise { logger.trace("handling insufficient funds, trying to re-select coins"); const proposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!proposal) { return; } logger.trace(`got error details: ${j2s(err)}`); const exchangeReply = (err as any).exchange_reply; if ( exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS ) { // FIXME: set as failed if (logger.shouldLogTrace()) { logger.trace("got exchange error reply (see below)"); logger.trace(j2s(exchangeReply)); } throw Error(`unable to handle /pay error response (${exchangeReply.code})`); } const brokenCoinPub = (exchangeReply as any).coin_pub; logger.trace(`excluded broken coin pub=${brokenCoinPub}`); if (!brokenCoinPub) { throw new TalerProtocolViolationError(); } const { contractData } = await expectProposalDownload(ws, proposal); const prevPayCoins: PreviousPayCoins = []; const payInfo = proposal.payInfo; if (!payInfo) { return; } const payCoinSelection = payInfo.payCoinSelection; await ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; if (coinPub === brokenCoinPub) { continue; } const contrib = payCoinSelection.coinContributions[i]; const coin = await tx.coins.get(coinPub); if (!coin) { continue; } const denom = await tx.denominations.get([ coin.exchangeBaseUrl, coin.denomPubHash, ]); if (!denom) { continue; } prevPayCoins.push({ coinPub, contribution: Amounts.parseOrThrow(contrib), exchangeBaseUrl: coin.exchangeBaseUrl, feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), }); } }); const res = await selectPayCoinsNew(ws, { auditors: [], exchanges: contractData.allowedExchanges, wireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), prevPayCoins, requiredMinimumAge: contractData.minimumAge, }); if (res.type !== "success") { logger.trace("insufficient funds for coin re-selection"); return; } logger.trace("re-selected coins"); await ws.db .mktx((x) => [ x.purchases, x.coins, x.coinAvailability, x.denominations, x.refreshGroups, ]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; } const payInfo = p.payInfo; if (!payInfo) { return; } payInfo.payCoinSelection = res.coinSel; payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); await tx.purchases.put(p); await spendCoins(ws, tx, { // allocationId: `txn:proposal:${p.proposalId}`, allocationId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: proposalId, }), coinPubs: payInfo.payCoinSelection.coinPubs, contributions: payInfo.payCoinSelection.coinContributions.map((x) => Amounts.parseOrThrow(x), ), refreshReason: RefreshReason.PayMerchant, }); }); } async function unblockBackup( ws: InternalWalletState, proposalId: string, ): Promise { await ws.db .mktx((x) => [x.backupProviders]) .runReadWrite(async (tx) => { await tx.backupProviders.indexes.byPaymentProposalId .iter(proposalId) .forEachAsync(async (bp) => { bp.state = { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: TalerPreciseTimestamp.now(), }; tx.backupProviders.put(bp); }); }); } // FIXME: Should probably not be exported in its current state // FIXME: Should take a transaction ID instead of a proposal ID // FIXME: Does way more than checking the payment // FIXME: Should return immediately. export async function checkPaymentByProposalId( ws: InternalWalletState, proposalId: string, sessionId?: string, ): Promise { let proposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!proposal) { throw Error(`could not get proposal ${proposalId}`); } if (proposal.purchaseStatus === PurchaseStatus.RepurchaseDetected) { const existingProposalId = proposal.repurchaseProposalId; if (!existingProposalId) { throw Error("invalid proposal state"); } logger.trace("using existing purchase for same product"); proposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(existingProposalId); }); if (!proposal) { throw Error("existing proposal is in wrong state"); } } const d = await expectProposalDownload(ws, proposal); const contractData = d.contractData; const merchantSig = d.contractData.merchantSig; if (!merchantSig) { throw Error("BUG: proposal is in invalid state"); } proposalId = proposal.proposalId; const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const talerUri = stringifyTalerUri({ type: TalerUriAction.Pay, merchantBaseUrl: proposal.merchantBaseUrl, orderId: proposal.orderId, sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "", claimToken: proposal.claimToken, noncePriv: proposal.noncePriv, }); // First check if we already paid for it. const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!purchase || purchase.purchaseStatus === PurchaseStatus.Proposed) { // If not already paid, check if we could pay for it. const res = await selectPayCoinsNew(ws, { auditors: [], exchanges: contractData.allowedExchanges, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, wireMethod: 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, noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), talerUri, balanceDetails: res.insufficientBalanceDetails, }; } const totalCost = await getTotalPaymentCost(ws, res.coinSel); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); return { status: PreparePayResultType.PaymentPossible, contractTerms: d.contractTermsRaw, transactionId, proposalId: proposal.proposalId, noncePriv: proposal.noncePriv, amountEffective: Amounts.stringify(totalCost), amountRaw: Amounts.stringify(res.coinSel.paymentAmount), contractTermsHash: d.contractData.contractTermsHash, talerUri, }; } if ( purchase.purchaseStatus === PurchaseStatus.Done && purchase.lastSessionId !== sessionId ) { logger.trace( "automatically re-submitting payment with different session ID", ); logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; } const oldTxState = computePayMerchantTransactionState(p); p.lastSessionId = sessionId; p.purchaseStatus = PurchaseStatus.PendingPayingReplay; await tx.purchases.put(p); const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); // FIXME: What about error handling?! This doesn't properly store errors in the DB. const r = await processPurchasePay(ws, proposalId, { forceNow: true }); if (r.type !== OperationAttemptResultType.Finished) { // FIXME: This does not surface the original error throw Error("submitting pay failed"); } const download = await expectProposalDownload(ws, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, paid: true, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), transactionId, proposalId, talerUri, }; } else if (!purchase.timestampFirstSuccessfulPay) { const download = await expectProposalDownload(ws, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, paid: false, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), transactionId, proposalId, talerUri, }; } else { const paid = purchase.purchaseStatus === PurchaseStatus.Done || purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund || purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund; const download = await expectProposalDownload(ws, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, paid, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), ...(paid ? { nextUrl: download.contractData.orderId } : {}), transactionId, proposalId, talerUri, }; } } export async function getContractTermsDetails( ws: InternalWalletState, proposalId: string, ): Promise { const proposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } const d = await expectProposalDownload(ws, proposal); return d.contractData; } /** * Check if a payment for the given taler://pay/ URI is possible. * * If the payment is possible, the signature are already generated but not * yet send to the merchant. */ export async function preparePayForUri( ws: InternalWalletState, talerPayUri: string, ): Promise { const uriResult = parsePayUri(talerPayUri); if (!uriResult) { throw TalerError.fromDetail( TalerErrorCode.WALLET_INVALID_TALER_PAY_URI, { talerPayUri, }, `invalid taler://pay URI (${talerPayUri})`, ); } const proposalId = await createPurchase( ws, uriResult.merchantBaseUrl, uriResult.orderId, uriResult.sessionId, uriResult.claimToken, uriResult.noncePriv, ); return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); } /** * Generate deposit permissions for a purchase. * * Accesses the database and the crypto worker. */ export async function generateDepositPermissions( ws: InternalWalletState, payCoinSel: PayCoinSelection, contractData: WalletContractData, ): Promise { const depositPermissions: CoinDepositPermission[] = []; const coinWithDenom: Array<{ coin: CoinRecord; denom: DenominationRecord; }> = []; await ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { for (let i = 0; i < payCoinSel.coinPubs.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 }); } }); for (let i = 0; i < payCoinSel.coinPubs.length; i++) { const { coin, denom } = coinWithDenom[i]; let wireInfoHash: string; wireInfoHash = contractData.wireInfoHash; logger.trace( `signing deposit permission for coin with ageRestriction=${j2s( coin.ageCommitmentProof, )}`, ); const dp = await ws.cryptoApi.signDepositPermission({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, contractTermsHash: contractData.contractTermsHash, denomPubHash: coin.denomPubHash, denomKeyType: denom.denomPub.cipher, denomSig: coin.denomSig, exchangeBaseUrl: coin.exchangeBaseUrl, feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), merchantPub: contractData.merchantPub, refundDeadline: contractData.refundDeadline, spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]), timestamp: contractData.timestamp, wireInfoHash, ageCommitmentProof: coin.ageCommitmentProof, requiredMinimumAge: contractData.minimumAge, }); depositPermissions.push(dp); } return depositPermissions; } /** * Run the operation handler for a payment * and return the result as a {@link ConfirmPayResult}. */ export async function runPayForConfirmPay( ws: InternalWalletState, proposalId: string, ): Promise { logger.trace("processing proposal for confirmPay"); const taskId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); const res = await runOperationWithErrorReporting(ws, taskId, async () => { return await processPurchasePay(ws, proposalId, { forceNow: true }); }); logger.trace(`processPurchasePay response type ${res.type}`); switch (res.type) { case OperationAttemptResultType.Finished: { const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!purchase) { throw Error("purchase record not available anymore"); } const d = await expectProposalDownload(ws, purchase); return { type: ConfirmPayResultType.Done, contractTerms: d.contractTermsRaw, transactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }), }; } case OperationAttemptResultType.Error: { // We hide transient errors from the caller. const opRetry = await ws.db .mktx((x) => [x.operationRetries]) .runReadOnly(async (tx) => tx.operationRetries.get(taskId)); return { type: ConfirmPayResultType.Pending, lastError: opRetry?.lastError, transactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }), }; } case OperationAttemptResultType.Pending: logger.trace("reporting pending as confirmPay response"); return { type: ConfirmPayResultType.Pending, transactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }), lastError: undefined, }; case OperationAttemptResultType.Longpoll: throw Error("unexpected processPurchasePay result (longpoll)"); default: assertUnreachable(res); } } /** * Confirm payment for a proposal previously claimed by the wallet. */ export async function confirmPay( ws: InternalWalletState, proposalId: string, sessionIdOverride?: string, forcedCoinSel?: ForcedCoinSel, ): Promise { logger.trace( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, ); const proposal = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const d = await expectProposalDownload(ws, proposal); if (!d) { throw Error("proposal is in invalid state"); } const existingPurchase = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if ( purchase && sessionIdOverride !== undefined && sessionIdOverride != purchase.lastSessionId ) { logger.trace(`changing session ID to ${sessionIdOverride}`); purchase.lastSessionId = sessionIdOverride; if (purchase.purchaseStatus === PurchaseStatus.Done) { purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay; } await tx.purchases.put(purchase); } return purchase; }); if (existingPurchase && existingPurchase.payInfo) { logger.trace("confirmPay: submitting payment for existing purchase"); return runPayForConfirmPay(ws, proposalId); } logger.trace("confirmPay: purchase record does not exist yet"); const contractData = d.contractData; const selectCoinsResult = await selectPayCoinsNew(ws, { auditors: [], exchanges: contractData.allowedExchanges, wireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), wireFeeAmortization: contractData.wireFeeAmortization ?? 1, wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, forcedSelection: forcedCoinSel, }); logger.trace("coin selection result", selectCoinsResult); 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"); } const coinSelection = selectCoinsResult.coinSel; const payCostInfo = await getTotalPaymentCost(ws, coinSelection); let sessionId: string | undefined; if (sessionIdOverride) { sessionId = sessionIdOverride; } else { sessionId = proposal.downloadSessionId; } logger.trace( `recording payment on ${proposal.orderId} with session ID ${sessionId}`, ); const transitionInfo = await ws.db .mktx((x) => [ x.purchases, x.coins, x.refreshGroups, x.denominations, x.coinAvailability, ]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposal.proposalId); if (!p) { return; } const oldTxState = computePayMerchantTransactionState(p); switch (p.purchaseStatus) { case PurchaseStatus.Proposed: p.payInfo = { payCoinSelection: coinSelection, payCoinSelectionUid: encodeCrock(getRandomBytes(16)), totalPayCost: Amounts.stringify(payCostInfo), }; p.lastSessionId = sessionId; p.timestampAccept = TalerPreciseTimestamp.now(); p.purchaseStatus = PurchaseStatus.PendingPaying; await tx.purchases.put(p); await spendCoins(ws, tx, { //`txn:proposal:${p.proposalId}` allocationId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: proposalId, }), coinPubs: coinSelection.coinPubs, contributions: coinSelection.coinContributions.map((x) => Amounts.parseOrThrow(x), ), refreshReason: RefreshReason.PayMerchant, }); break; case PurchaseStatus.Done: case PurchaseStatus.PendingPaying: default: break; } const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); ws.notify({ type: NotificationType.ProposalAccepted, proposalId: proposal.proposalId, }); return runPayForConfirmPay(ws, proposalId); } export async function processPurchase( ws: InternalWalletState, proposalId: string, ): Promise { const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!purchase) { return { type: OperationAttemptResultType.Error, errorDetail: { // FIXME: allocate more specific error code code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, when: AbsoluteTime.now(), hint: `trying to pay for purchase that is not in the database`, proposalId: proposalId, }, }; } switch (purchase.purchaseStatus) { case PurchaseStatus.PendingDownloadingProposal: return processDownloadProposal(ws, proposalId); case PurchaseStatus.PendingPaying: case PurchaseStatus.PendingPayingReplay: return processPurchasePay(ws, proposalId); case PurchaseStatus.PendingQueryingRefund: return processPurchaseQueryRefund(ws, purchase); case PurchaseStatus.PendingQueryingAutoRefund: return processPurchaseAutoRefund(ws, purchase); case PurchaseStatus.AbortingWithRefund: return processPurchaseAbortingRefund(ws, purchase); case PurchaseStatus.PendingAcceptRefund: return processPurchaseAcceptRefund(ws, purchase); case PurchaseStatus.FailedClaim: case PurchaseStatus.Done: case PurchaseStatus.RepurchaseDetected: case PurchaseStatus.Proposed: case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedIncompletePayment: case PurchaseStatus.SuspendedAbortingWithRefund: case PurchaseStatus.SuspendedDownloadingProposal: case PurchaseStatus.SuspendedPaying: case PurchaseStatus.SuspendedPayingReplay: case PurchaseStatus.SuspendedPendingAcceptRefund: case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingRefund: case PurchaseStatus.FailedAbort: return { type: OperationAttemptResultType.Finished, result: undefined, }; default: assertUnreachable(purchase.purchaseStatus); // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`); } } export async function processPurchasePay( ws: InternalWalletState, proposalId: string, options: unknown = {}, ): Promise { const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); if (!purchase) { return { type: OperationAttemptResultType.Error, errorDetail: { // FIXME: allocate more specific error code code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, when: AbsoluteTime.now(), hint: `trying to pay for purchase that is not in the database`, proposalId: proposalId, }, }; } switch (purchase.purchaseStatus) { case PurchaseStatus.PendingPaying: case PurchaseStatus.PendingPayingReplay: break; default: return OperationAttemptResult.finishedEmpty(); } logger.trace(`processing purchase pay ${proposalId}`); const sessionId = purchase.lastSessionId; logger.trace(`paying with session ID ${sessionId}`); const payInfo = purchase.payInfo; checkDbInvariant(!!payInfo, "payInfo"); const download = await expectProposalDownload(ws, purchase); if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, download.contractData.merchantBaseUrl, ).href; let depositPermissions: CoinDepositPermission[]; // FIXME: Cache! depositPermissions = await generateDepositPermissions( ws, payInfo.payCoinSelection, download.contractData, ); const reqBody = { coins: depositPermissions, session_id: purchase.lastSessionId, }; logger.trace( "making pay request ... ", JSON.stringify(reqBody, undefined, 2), ); const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => ws.http.fetch(payUrl, { method: "POST", body: reqBody, timeout: getPayRequestTimeout(purchase), }), ); logger.trace(`got resp ${JSON.stringify(resp)}`); if (resp.status >= 500 && resp.status <= 599) { const errDetails = await readUnexpectedResponseDetails(resp); return { type: OperationAttemptResultType.Error, errorDetail: makeErrorDetail( TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR, { requestError: errDetails, }, ), }; } if (resp.status === HttpStatusCode.Conflict) { const err = await readTalerErrorResponse(resp); if ( err.code === TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS ) { // Do this in the background, as it might take some time handleInsufficientFunds(ws, proposalId, err).catch(async (e) => { console.log("handling insufficient funds failed"); await scheduleRetry(ws, TaskIdentifiers.forPay(purchase), { code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, when: AbsoluteTime.now(), message: "unexpected exception", hint: "unexpected exception", details: { exception: e.toString(), }, }); }); return { type: OperationAttemptResultType.Pending, result: undefined, }; } } if (resp.status >= 400 && resp.status <= 499) { logger.trace("got generic 4xx from merchant"); const err = await readTalerErrorResponse(resp); throwUnexpectedRequestError(resp, err); } const merchantResp = await readSuccessResponseJsonOrThrow( resp, codecForMerchantPayResponse(), ); logger.trace("got success from pay URL", merchantResp); const merchantPub = download.contractData.merchantPub; const { valid } = await ws.cryptoApi.isValidPaymentSignature({ contractHash: download.contractData.contractTermsHash, merchantPub, sig: merchantResp.sig, }); if (!valid) { logger.error("merchant payment signature invalid"); // FIXME: properly display error throw Error("merchant payment signature invalid"); } await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp); await unblockBackup(ws, proposalId); } else { const payAgainUrl = new URL( `orders/${download.contractData.orderId}/paid`, download.contractData.merchantBaseUrl, ).href; const reqBody = { sig: purchase.merchantPaySig, h_contract: download.contractData.contractTermsHash, session_id: sessionId ?? "", }; logger.trace(`/paid request body: ${j2s(reqBody)}`); const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => ws.http.postJson(payAgainUrl, reqBody), ); logger.trace(`/paid response status: ${resp.status}`); if ( resp.status !== HttpStatusCode.NoContent && resp.status != HttpStatusCode.Ok ) { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, getHttpResponseErrorDetails(resp), "/paid failed", ); } await storePayReplaySuccess(ws, proposalId, sessionId); await unblockBackup(ws, proposalId); } ws.notify({ type: NotificationType.PayOperationSuccess, proposalId: purchase.proposalId, }); return OperationAttemptResult.finishedEmpty(); } export async function refuseProposal( ws: InternalWalletState, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const proposal = await tx.purchases.get(proposalId); if (!proposal) { logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); return undefined; } if (proposal.purchaseStatus !== PurchaseStatus.Proposed) { return undefined; } const oldTxState = computePayMerchantTransactionState(proposal); proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused; const newTxState = computePayMerchantTransactionState(proposal); await tx.purchases.put(proposal); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); } export async function abortPayMerchant( ws: InternalWalletState, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const opId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); const transitionInfo = await ws.db .mktx((x) => [ x.purchases, x.refreshGroups, x.denominations, x.coinAvailability, x.coins, x.operationRetries, ]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); const oldStatus = purchase.purchaseStatus; if (purchase.timestampFirstSuccessfulPay) { // No point in aborting it. We don't even report an error. logger.warn(`tried to abort successful payment`); return; } if (oldStatus === PurchaseStatus.PendingPaying) { purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; } await tx.purchases.put(purchase); if (oldStatus === PurchaseStatus.PendingPaying) { if (purchase.payInfo) { const coinSel = purchase.payInfo.payCoinSelection; const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); const refreshCoins: CoinRefreshRequest[] = []; for (let i = 0; i < coinSel.coinPubs.length; i++) { refreshCoins.push({ amount: coinSel.coinContributions[i], coinPub: coinSel.coinPubs[i], }); } await createRefreshGroup( ws, tx, currency, refreshCoins, RefreshReason.AbortPay, ); } } await tx.operationRetries.delete(opId); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); ws.workAvailable.trigger(); } export async function cancelAbortingPaymentTransaction( ws: InternalWalletState, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const opId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); const transitionInfo = await ws.db .mktx((x) => [ x.purchases, x.refreshGroups, x.denominations, x.coinAvailability, x.coins, x.operationRetries, ]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); let newState: PurchaseStatus | undefined = undefined; switch (purchase.purchaseStatus) { case PurchaseStatus.AbortingWithRefund: newState = PurchaseStatus.FailedAbort; break; } if (newState) { purchase.purchaseStatus = newState; await tx.purchases.put(purchase); } const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); ws.workAvailable.trigger(); } const transitionSuspend: { [x in PurchaseStatus]?: { next: PurchaseStatus | undefined; }; } = { [PurchaseStatus.PendingDownloadingProposal]: { next: PurchaseStatus.SuspendedDownloadingProposal, }, [PurchaseStatus.AbortingWithRefund]: { next: PurchaseStatus.SuspendedAbortingWithRefund, }, [PurchaseStatus.PendingPaying]: { next: PurchaseStatus.SuspendedPaying, }, [PurchaseStatus.PendingPayingReplay]: { next: PurchaseStatus.SuspendedPayingReplay, }, [PurchaseStatus.PendingQueryingAutoRefund]: { next: PurchaseStatus.SuspendedQueryingAutoRefund, }, }; const transitionResume: { [x in PurchaseStatus]?: { next: PurchaseStatus | undefined; }; } = { [PurchaseStatus.SuspendedDownloadingProposal]: { next: PurchaseStatus.PendingDownloadingProposal, }, [PurchaseStatus.SuspendedAbortingWithRefund]: { next: PurchaseStatus.AbortingWithRefund, }, [PurchaseStatus.SuspendedPaying]: { next: PurchaseStatus.PendingPaying, }, [PurchaseStatus.SuspendedPayingReplay]: { next: PurchaseStatus.PendingPayingReplay, }, [PurchaseStatus.SuspendedQueryingAutoRefund]: { next: PurchaseStatus.PendingQueryingAutoRefund, }, }; export async function suspendPayMerchant( ws: InternalWalletState, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const opId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); stopLongpolling(ws, opId); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); let newStatus = transitionSuspend[purchase.purchaseStatus]; if (!newStatus) { return undefined; } await tx.purchases.put(purchase); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); ws.workAvailable.trigger(); } export async function resumePayMerchant( ws: InternalWalletState, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const opId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); stopLongpolling(ws, opId); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); let newStatus = transitionResume[purchase.purchaseStatus]; if (!newStatus) { return undefined; } await tx.purchases.put(purchase); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }); ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); ws.workAvailable.trigger(); } export function computePayMerchantTransactionState( purchaseRecord: PurchaseRecord, ): TransactionState { switch (purchaseRecord.purchaseStatus) { // Pending States case PurchaseStatus.PendingDownloadingProposal: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.ClaimProposal, }; case PurchaseStatus.PendingPaying: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.SubmitPayment, }; case PurchaseStatus.PendingPayingReplay: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.RebindSession, }; case PurchaseStatus.PendingQueryingAutoRefund: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.AutoRefund, }; case PurchaseStatus.PendingQueryingRefund: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.CheckRefund, }; case PurchaseStatus.PendingAcceptRefund: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.AcceptRefund, }; // Suspended Pending States case PurchaseStatus.SuspendedDownloadingProposal: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.ClaimProposal, }; case PurchaseStatus.SuspendedPaying: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.SubmitPayment, }; case PurchaseStatus.SuspendedPayingReplay: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.RebindSession, }; case PurchaseStatus.SuspendedQueryingAutoRefund: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.AutoRefund, }; case PurchaseStatus.SuspendedQueryingRefund: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.CheckRefund, }; case PurchaseStatus.SuspendedPendingAcceptRefund: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.AcceptRefund, }; // Aborting States case PurchaseStatus.AbortingWithRefund: return { major: TransactionMajorState.Aborting, }; // Suspended Aborting States case PurchaseStatus.SuspendedAbortingWithRefund: return { major: TransactionMajorState.SuspendedAborting, }; // Dialog States case PurchaseStatus.Proposed: return { major: TransactionMajorState.Dialog, minor: TransactionMinorState.MerchantOrderProposed, }; // Final States case PurchaseStatus.AbortedProposalRefused: return { major: TransactionMajorState.Failed, minor: TransactionMinorState.Refused, }; case PurchaseStatus.Done: return { major: TransactionMajorState.Done, }; case PurchaseStatus.RepurchaseDetected: return { major: TransactionMajorState.Failed, minor: TransactionMinorState.Repurchase, }; case PurchaseStatus.AbortedIncompletePayment: return { major: TransactionMajorState.Aborted, }; case PurchaseStatus.FailedClaim: return { major: TransactionMajorState.Failed, minor: TransactionMinorState.ClaimProposal, }; case PurchaseStatus.FailedAbort: return { major: TransactionMajorState.Failed, minor: TransactionMinorState.AbortingBank, }; } } async function processPurchaseAutoRefund( ws: InternalWalletState, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; logger.trace(`processing auto-refund for proposal ${proposalId}`); const taskId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); // FIXME: Put this logic into runLongpollAsync? if (ws.activeLongpoll[taskId]) { return OperationAttemptResult.longpoll(); } const download = await expectProposalDownload(ws, purchase); runLongpollAsync(ws, taskId, async (ct) => { if ( !purchase.autoRefundDeadline || AbsoluteTime.isExpired( AbsoluteTime.fromProtocolTimestamp(purchase.autoRefundDeadline), ) ) { 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; } if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) { return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.Done; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); return { ready: true, }; } const requestUrl = new URL( `orders/${download.contractData.orderId}`, download.contractData.merchantBaseUrl, ); requestUrl.searchParams.set( "h_contract", download.contractData.contractTermsHash, ); requestUrl.searchParams.set("timeout_ms", "1000"); requestUrl.searchParams.set("await_refund_obtained", "yes"); const resp = await ws.http.fetch(requestUrl.href); // FIXME: Check other status codes! const orderStatus = await readSuccessResponseJsonOrThrow( resp, codecForMerchantOrderStatusPaid(), ); if (orderStatus.refund_pending) { 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; } if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); return { ready: true, }; } else { return { ready: false, }; } }); return OperationAttemptResult.longpoll(); } async function processPurchaseAbortingRefund( ws: InternalWalletState, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; const download = await expectProposalDownload(ws, purchase); logger.trace(`processing aborting-refund for proposal ${proposalId}`); const requestUrl = new URL( `orders/${download.contractData.orderId}/abort`, download.contractData.merchantBaseUrl, ); const abortingCoins: AbortingCoin[] = []; const payCoinSelection = purchase.payInfo?.payCoinSelection; if (!payCoinSelection) { throw Error("can't abort, no coins selected"); } await ws.db .mktx((x) => [x.coins]) .runReadOnly(async (tx) => { for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; const coin = await tx.coins.get(coinPub); checkDbInvariant(!!coin, "expected coin to be present"); abortingCoins.push({ coin_pub: coinPub, contribution: Amounts.stringify( payCoinSelection.coinContributions[i], ), exchange_url: coin.exchangeBaseUrl, }); } }); const abortReq: AbortRequest = { h_contract: download.contractData.contractTermsHash, coins: abortingCoins, }; logger.trace(`making order abort request to ${requestUrl.href}`); const request = await ws.http.postJson(requestUrl.href, abortReq); const abortResp = await readSuccessResponseJsonOrThrow( request, codecForAbortResponse(), ); const refunds: MerchantCoinRefundStatus[] = []; if (abortResp.refunds.length != abortingCoins.length) { // FIXME: define error code! throw Error("invalid order abort response"); } for (let i = 0; i < abortResp.refunds.length; i++) { const r = abortResp.refunds[i]; refunds.push({ ...r, coin_pub: payCoinSelection.coinPubs[i], refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), rtransaction_id: 0, execution_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration( AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp), Duration.fromSpec({ seconds: 1 }), ), ), }); } return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund); } async function processPurchaseQueryRefund( ws: InternalWalletState, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; logger.trace(`processing query-refund for proposal ${proposalId}`); const download = await expectProposalDownload(ws, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}`, download.contractData.merchantBaseUrl, ); requestUrl.searchParams.set( "h_contract", download.contractData.contractTermsHash, ); const resp = await ws.http.fetch(requestUrl.href); const orderStatus = await readSuccessResponseJsonOrThrow( resp, codecForMerchantOrderStatusPaid(), ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); if (!orderStatus.refund_pending) { 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 undefined; } if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) { return undefined; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.Done; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); return OperationAttemptResult.finishedEmpty(); } else { const refundAwaiting = Amounts.sub( Amounts.parseOrThrow(orderStatus.refund_amount), Amounts.parseOrThrow(orderStatus.refund_taken), ).amount; 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; } if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) { return; } const oldTxState = computePayMerchantTransactionState(p); p.refundAmountAwaiting = Amounts.stringify(refundAwaiting); p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); return OperationAttemptResult.finishedEmpty(); } } async function processPurchaseAcceptRefund( ws: InternalWalletState, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; const download = await expectProposalDownload(ws, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}/refund`, download.contractData.merchantBaseUrl, ); logger.trace(`making refund request to ${requestUrl.href}`); const request = await ws.http.postJson(requestUrl.href, { h_contract: download.contractData.contractTermsHash, }); const refundResponse = await readSuccessResponseJsonOrThrow( request, codecForMerchantOrderRefundPickupResponse(), ); return await storeRefunds( ws, purchase, refundResponse.refunds, RefundReason.AbortRefund, ); } export async function startRefundQueryForUri( ws: InternalWalletState, talerUri: string, ): Promise { const parsedUri = parseTalerUri(talerUri); if (!parsedUri) { throw Error("invalid taler:// URI"); } if (parsedUri.type !== TalerUriAction.Refund) { throw Error("expected taler://refund URI"); } const purchaseRecord = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ parsedUri.merchantBaseUrl, parsedUri.orderId, ]); }); if (!purchaseRecord) { throw Error("no purchase found, can't refund"); } const proposalId = purchaseRecord.proposalId; const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); await startQueryRefund(ws, proposalId); return { transactionId, }; } export async function startQueryRefund( ws: InternalWalletState, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { logger.warn(`purchase ${proposalId} does not exist anymore`); return; } if (p.purchaseStatus !== PurchaseStatus.Done) { return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.PendingQueryingRefund; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); ws.workAvailable.trigger(); } /** * Store refunds, possibly creating a new refund group. */ async function storeRefunds( ws: InternalWalletState, purchase: PurchaseRecord, refunds: MerchantCoinRefundStatus[], reason: RefundReason, ): Promise { logger.info(`storing refunds: ${j2s(refunds)}`); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: purchase.proposalId, }); const newRefundGroupId = encodeCrock(randomBytes(32)); const now = TalerPreciseTimestamp.now(); const download = await expectProposalDownload(ws, purchase); const currency = Amounts.currencyOf(download.contractData.amount); const getItemStatus = (rf: MerchantCoinRefundStatus) => { if (rf.type === "success") { return RefundItemStatus.Done; } else { if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { return RefundItemStatus.Pending; } else { return RefundItemStatus.Failed; } } }; const result = await ws.db .mktx((x) => [ x.purchases, x.refundGroups, x.refundItems, x.coins, x.denominations, x.coinAvailability, x.refreshGroups, ]) .runReadWrite(async (tx) => { const computeRefreshRequest = async (items: RefundItemRecord[]) => { const refreshCoins: CoinRefreshRequest[] = []; for (const item of items) { const coin = await tx.coins.get(item.coinPub); if (!coin) { throw Error("coin not found"); } const denomInfo = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); if (!denomInfo) { throw Error("denom not found"); } if (item.status === RefundItemStatus.Done) { const refundedAmount = Amounts.sub( item.refundAmount, denomInfo.feeRefund, ).amount; refreshCoins.push({ amount: Amounts.stringify(refundedAmount), coinPub: item.coinPub, }); } } return refreshCoins; }; const myPurchase = await tx.purchases.get(purchase.proposalId); if (!myPurchase) { logger.warn("purchase group not found anymore"); return; } if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) { return; } let newGroup: RefundGroupRecord | undefined = undefined; // Pending, but not part of an aborted refund group. let numPendingItemsTotal = 0; const newGroupRefunds: RefundItemRecord[] = []; for (const rf of refunds) { const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([ rf.coin_pub, rf.rtransaction_id, ]); if (oldItem) { logger.info("already have refund in database"); if (oldItem.status === RefundItemStatus.Done) { continue; } if (rf.type === "success") { oldItem.status = RefundItemStatus.Done; } else { if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { oldItem.status = RefundItemStatus.Pending; numPendingItemsTotal += 1; } else { oldItem.status = RefundItemStatus.Failed; } } await tx.refundItems.put(oldItem); } else { // Put refund item into a new group! if (!newGroup) { newGroup = { proposalId: purchase.proposalId, refundGroupId: newRefundGroupId, status: RefundGroupStatus.Pending, timestampCreated: now, amountEffective: Amounts.stringify( Amounts.zeroOfCurrency(currency), ), amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)), }; } const status: RefundItemStatus = getItemStatus(rf); const newItem: RefundItemRecord = { coinPub: rf.coin_pub, executionTime: rf.execution_time, obtainedTime: now, refundAmount: rf.refund_amount, refundGroupId: newGroup.refundGroupId, rtxid: rf.rtransaction_id, status, }; if (status === RefundItemStatus.Pending) { numPendingItemsTotal += 1; } newGroupRefunds.push(newItem); await tx.refundItems.put(newItem); } } // Now that we know all the refunds for the new refund group, // we can compute the raw/effective amounts. if (newGroup) { const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); const refreshCoins = await computeRefreshRequest(newGroupRefunds); const outInfo = await calculateRefreshOutput( ws, tx, currency, refreshCoins, ); newGroup.amountEffective = Amounts.stringify( Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, ); newGroup.amountRaw = Amounts.stringify( Amounts.sumOrZero(currency, amountsRaw).amount, ); await tx.refundGroups.put(newGroup); } const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll( myPurchase.proposalId, ); logger.info( `refund groups for proposal ${myPurchase.proposalId}: ${j2s( refundGroups, )}`, ); for (const refundGroup of refundGroups) { if (refundGroup.status === RefundGroupStatus.Aborted) { continue; } if (refundGroup.status === RefundGroupStatus.Done) { continue; } const items = await tx.refundItems.indexes.byRefundGroupId.getAll( refundGroup.refundGroupId, ); let numPending = 0; for (const item of items) { if (item.status === RefundItemStatus.Pending) { numPending++; } } logger.info(`refund items pending for refund group: ${numPending}`); if (numPending === 0) { logger.info("refund group is done!"); // We're done for this refund group! refundGroup.status = RefundGroupStatus.Done; await tx.refundGroups.put(refundGroup); const refreshCoins = await computeRefreshRequest(items); await createRefreshGroup( ws, tx, Amounts.currencyOf(download.contractData.amount), refreshCoins, RefreshReason.Refund, ); } } const oldTxState = computePayMerchantTransactionState(myPurchase); if (numPendingItemsTotal === 0) { myPurchase.purchaseStatus = PurchaseStatus.Done; } await tx.purchases.put(myPurchase); const newTxState = computePayMerchantTransactionState(myPurchase); return { numPendingItemsTotal, transitionInfo: { oldTxState, newTxState, }, }; }); if (!result) { return OperationAttemptResult.finishedEmpty(); } notifyTransition(ws, transactionId, result.transitionInfo); if (result.numPendingItemsTotal > 0) { return OperationAttemptResult.pendingEmpty(); } return OperationAttemptResult.finishedEmpty(); } export function computeRefundTransactionState( refundGroupRecord: RefundGroupRecord, ): TransactionState { switch (refundGroupRecord.status) { case RefundGroupStatus.Aborted: return { major: TransactionMajorState.Aborted, }; case RefundGroupStatus.Done: return { major: TransactionMajorState.Done, }; case RefundGroupStatus.Failed: return { major: TransactionMajorState.Failed, }; case RefundGroupStatus.Pending: return { major: TransactionMajorState.Pending, }; } }