/* 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, AmountString, assertUnreachable, AsyncFlag, checkDbInvariant, codecForAbortResponse, codecForMerchantContractTerms, codecForMerchantOrderStatusPaid, codecForMerchantPayResponse, codecForMerchantPostOrderResponse, codecForProposal, codecForWalletRefundResponse, CoinDepositPermission, CoinRefreshRequest, ConfirmPayResult, ConfirmPayResultType, ContractTermsUtil, Duration, encodeCrock, ForcedCoinSel, getRandomBytes, HttpStatusCode, j2s, Logger, makeErrorDetail, makePendingOperationFailedError, MerchantCoinRefundStatus, MerchantContractTerms, MerchantPayResponse, MerchantUsingTemplateDetails, NotificationType, parsePayTemplateUri, parsePayUri, parseTalerUri, PreparePayResult, PreparePayResultType, PreparePayTemplateRequest, randomBytes, RefreshReason, SelectedProspectiveCoin, SharePaymentResult, StartRefundQueryForUriResponse, stringifyPayUri, stringifyTalerUri, TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolViolationError, TalerUriAction, TransactionAction, TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, WalletContractData, } from "@gnu-taler/taler-util"; import { getHttpResponseErrorDetails, readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, readTalerErrorResponse, readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js"; import { constructTaskIdentifier, PendingTaskType, spendCoins, TaskIdStr, TaskRunResult, TaskRunResultType, TombstoneTag, TransactionContext, TransitionResultType, } from "./common.js"; import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; import { CoinRecord, DbCoinSelection, DenominationRecord, PurchaseRecord, PurchaseStatus, RefundGroupRecord, RefundGroupStatus, RefundItemRecord, RefundItemStatus, RefundReason, timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, WalletStoresV1, } from "./db.js"; import { DbReadWriteTransaction, StoreNames } from "./query.js"; import { calculateRefreshOutput, createRefreshGroup, getTotalRefreshCost, } from "./refresh.js"; import { constructTransactionIdentifier, notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; import { EXCHANGE_COINS_LOCK, getDenomInfo, WalletExecutionContext, } from "./wallet.js"; /** * Logger. */ const logger = new Logger("pay-merchant.ts"); export class PayMerchantTransactionContext implements TransactionContext { readonly transactionId: TransactionIdStr; readonly taskId: TaskIdStr; constructor( public wex: WalletExecutionContext, public proposalId: string, ) { this.transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); this.taskId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId, }); } /** * Transition a payment transition. */ async transition( f: (rec: PurchaseRecord) => Promise, ): Promise { return this.transitionExtra( { extraStores: [], }, f, ); } /** * Transition a payment transition. * Extra object stores may be accessed during the transition. */ async transitionExtra< StoreNameArray extends Array> = [], >( opts: { extraStores: StoreNameArray }, f: ( rec: PurchaseRecord, tx: DbReadWriteTransaction< typeof WalletStoresV1, ["purchases", ...StoreNameArray] >, ) => Promise, ): Promise { const ws = this.wex; const extraStores = opts.extraStores ?? []; const transitionInfo = await ws.db.runReadWriteTx( { storeNames: ["purchases", ...extraStores] }, async (tx) => { const purchaseRec = await tx.purchases.get(this.proposalId); if (!purchaseRec) { throw Error("purchase not found anymore"); } const oldTxState = computePayMerchantTransactionState(purchaseRec); const res = await f(purchaseRec, tx); switch (res) { case TransitionResultType.Transition: { await tx.purchases.put(purchaseRec); const newTxState = computePayMerchantTransactionState(purchaseRec); return { oldTxState, newTxState, }; } default: return undefined; } }, ); notifyTransition(ws, this.transactionId, transitionInfo); } async deleteTransaction(): Promise { const { wex: ws, proposalId } = this; 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 { const { wex, proposalId, transactionId } = this; wex.taskScheduler.stopShepherdTask(this.taskId); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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(wex, transactionId, transitionInfo); } async abortTransaction(): Promise { const { wex, proposalId, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ "purchases", "refreshGroups", "refreshSessions", "denominations", "coinAvailability", "coins", "operationRetries", ], }, async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { throw Error("purchase not found"); } const oldTxState = computePayMerchantTransactionState(purchase); const oldStatus = purchase.purchaseStatus; switch (oldStatus) { case PurchaseStatus.Done: return; case PurchaseStatus.PendingPaying: case PurchaseStatus.SuspendedPaying: { purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; if (purchase.payInfo && purchase.payInfo.payCoinSelection) { 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( wex, tx, currency, refreshCoins, RefreshReason.AbortPay, this.transactionId, ); } break; } case PurchaseStatus.PendingQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.PendingAcceptRefund: case PurchaseStatus.SuspendedPendingAcceptRefund: case PurchaseStatus.PendingQueryingRefund: case PurchaseStatus.SuspendedQueryingRefund: if (!purchase.timestampFirstSuccessfulPay) { throw Error("invalid state"); } purchase.purchaseStatus = PurchaseStatus.Done; break; case PurchaseStatus.DialogProposed: purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused; break; default: return; } await tx.purchases.put(purchase); await tx.operationRetries.delete(this.taskId); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }, ); wex.taskScheduler.stopShepherdTask(this.taskId); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(this.taskId); } async resumeTransaction(): Promise { const { wex, proposalId, transactionId, taskId: retryTag } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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 }; }, ); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(this.taskId); } async failTransaction(): Promise { const { wex, proposalId, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ "purchases", "refreshGroups", "denominations", "coinAvailability", "coins", "operationRetries", ], }, 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(wex, transactionId, transitionInfo); wex.taskScheduler.stopShepherdTask(this.taskId); } } export class RefundTransactionContext implements TransactionContext { public transactionId: TransactionIdStr; public taskId: TaskIdStr | undefined = undefined; constructor( public wex: WalletExecutionContext, public refundGroupId: string, ) { this.transactionId = constructTransactionIdentifier({ tag: TransactionType.Refund, refundGroupId, }); } async deleteTransaction(): Promise { const { wex, refundGroupId, transactionId } = this; 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 { throw new Error("Unsupported operation"); } abortTransaction(): Promise { throw new Error("Unsupported operation"); } resumeTransaction(): Promise { throw new Error("Unsupported operation"); } failTransaction(): Promise { throw new Error("Unsupported operation"); } } /** * 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( wex: WalletExecutionContext, currency: string, pcs: SelectedProspectiveCoin[], ): Promise { 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 zero = Amounts.zeroOfCurrency(currency); return Amounts.sum([zero, ...costs]).amount; }, ); } async function failProposalPermanently( wex: WalletExecutionContext, proposalId: string, err: TalerErrorDetail, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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(wex, transactionId, transitionInfo); } 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( wex: WalletExecutionContext, p: PurchaseRecord, parentTx?: WalletDbReadOnlyTransaction<["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 wex.db.runReadOnlyTx( { storeNames: ["contractTerms"] }, getFromTransaction, ); } export function extractContractData( parsedContractTerms: MerchantContractTerms, contractTermsHash: string, merchantSig: string, ): WalletContractData { const amount = Amounts.parseOrThrow(parsedContractTerms.amount); 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, payDeadline: parsedContractTerms.pay_deadline, refundDeadline: parsedContractTerms.refund_deadline, 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, summaryI18n: parsedContractTerms.summary_i18n, minimumAge: parsedContractTerms.minimum_age, }; } async function processDownloadProposal( wex: WalletExecutionContext, proposalId: string, ): Promise { const proposal = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return await tx.purchases.get(proposalId); }, ); if (!proposal) { return TaskRunResult.finished(); } const ctx = new PayMerchantTransactionContext(wex, proposalId); if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) { logger.error( `unexpected state ${proposal.purchaseStatus}/${ PurchaseStatus[proposal.purchaseStatus] } for ${ctx.transactionId} in processDownloadProposal`, ); return TaskRunResult.finished(); } const transactionId = ctx.transactionId; 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 httpResponse = await wex.http.fetch(orderClaimUrl, { method: "POST", body: requestBody, cancellationToken: wex.cancellationToken, }); 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(wex, 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(wex, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, proposalId, ); } const sigValid = await wex.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(wex, 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(wex, 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 wex.db.runReadWriteTx( { storeNames: ["purchases", "contractTerms"] }, 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, }); const isResourceFulfillmentUrl = fulfillmentUrl && (fulfillmentUrl.startsWith("http://") || fulfillmentUrl.startsWith("https://")); let otherPurchase: PurchaseRecord | undefined; if (isResourceFulfillmentUrl) { otherPurchase = 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 ( otherPurchase && (otherPurchase.purchaseStatus == PurchaseStatus.Done || otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying || otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay) ) { logger.warn("repurchase detected"); p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected; p.repurchaseProposalId = otherPurchase.proposalId; await tx.purchases.put(p); } else { p.purchaseStatus = p.shared ? PurchaseStatus.DialogShared : PurchaseStatus.DialogProposed; await tx.purchases.put(p); } const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState, }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.progress(); } /** * Create a new purchase transaction if necessary. If a purchase * record for the provided arguments already exists, * return the old proposal ID. */ async function createOrReusePurchase( wex: WalletExecutionContext, merchantBaseUrl: string, orderId: string, sessionId: string | undefined, claimToken: string | undefined, noncePriv: string | undefined, ): Promise { const oldProposals = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.getAll([ merchantBaseUrl, orderId, ]); }, ); const oldProposal = oldProposals.find((p) => { return ( p.downloadSessionId === sessionId && (!noncePriv || p.noncePriv === noncePriv) && p.claimToken === claimToken ); }); // If we have already claimed this proposal with the same sessionId // nonce and claim token, reuse it. */ if ( oldProposal && oldProposal.downloadSessionId === sessionId && (!noncePriv || oldProposal.noncePriv === noncePriv) && oldProposal.claimToken === claimToken ) { logger.info( `Found old proposal (status=${ PurchaseStatus[oldProposal.purchaseStatus] }) for order ${orderId} at ${merchantBaseUrl}`, ); if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) { const download = await expectProposalDownload(wex, oldProposal); const paid = await checkIfOrderIsAlreadyPaid( wex, download.contractData, false, ); logger.info(`old proposal paid: ${paid}`); if (paid) { // if this transaction was shared and the order is paid then it // means that another wallet already paid the proposal const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(oldProposal.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.FailedPaidByOther; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }, ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: oldProposal.proposalId, }); notifyTransition(wex, transactionId, transitionInfo); } } return oldProposal.proposalId; } let noncePair: EddsaKeypair; let shared = false; if (noncePriv) { shared = true; noncePair = { priv: noncePriv, pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, }; } else { noncePair = await wex.cryptoApi.createEddsaKeypair({}); } const { priv, pub } = noncePair; const proposalId = encodeCrock(getRandomBytes(32)); const proposalRecord: PurchaseRecord = { download: undefined, noncePriv: priv, noncePub: pub, claimToken, timestamp: timestampPreciseToDb(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, shared: shared, }; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, async (tx) => { 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(wex, transactionId, transitionInfo); return proposalId; } async function storeFirstPaySuccess( wex: WalletExecutionContext, proposalId: string, sessionId: string | undefined, payResponse: MerchantPayResponse, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "purchases"] }, 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 = timestampPreciseToDb(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 = timestampProtocolToDb( AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), ar), ), ); } await tx.purchases.put(purchase); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState, }; }, ); notifyTransition(wex, transactionId, transitionInfo); } async function storePayReplaySuccess( wex: WalletExecutionContext, proposalId: string, sessionId: string | undefined, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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(wex, 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( wex: WalletExecutionContext, proposalId: string, err: TalerErrorDetail, ): Promise { logger.trace("handling insufficient funds, trying to re-select coins"); const proposal = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, 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(wex, proposal); const prevPayCoins: PreviousPayCoins = []; const payInfo = proposal.payInfo; if (!payInfo) { return; } const payCoinSelection = payInfo.payCoinSelection; if (!payCoinSelection) { return; } 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: { auditors: [], exchanges: contractData.allowedExchanges, }, restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), prevPayCoins, requiredMinimumAge: contractData.minimumAge, }); 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( { storeNames: [ "purchases", "coins", "coinAvailability", "denominations", "refreshGroups", "refreshSessions", ], }, async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; } const payInfo = p.payInfo; if (!payInfo) { return; } // Convert to DB format payInfo.payCoinSelection = { coinContributions: res.coinSel.coins.map((x) => x.contribution), coinPubs: res.coinSel.coins.map((x) => x.coinPub), }; payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); await tx.purchases.put(p); await spendCoins(wex, 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, }); }, ); wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }), }); } // FIXME: Should take a transaction ID instead of a proposal ID // FIXME: Does way more than checking the payment // FIXME: Should return immediately. async function checkPaymentByProposalId( wex: WalletExecutionContext, proposalId: string, sessionId?: string, ): Promise { let proposal = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(proposalId); }, ); if (!proposal) { throw Error(`could not get proposal ${proposalId}`); } if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) { const existingProposalId = proposal.repurchaseProposalId; if (existingProposalId) { logger.trace("using existing purchase for same product"); const oldProposal = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(existingProposalId); }, ); if (oldProposal) { proposal = oldProposal; } } } const d = await expectProposalDownload(wex, proposal); const contractData = d.contractData; const merchantSig = d.contractData.merchantSig; if (!merchantSig) { throw Error("BUG: proposal is in invalid state"); } proposalId = proposal.proposalId; const currency = Amounts.currencyOf(contractData.amount); const ctx = new PayMerchantTransactionContext(wex, proposalId); const transactionId = ctx.transactionId; const talerUri = stringifyTalerUri({ type: TalerUriAction.Pay, merchantBaseUrl: proposal.merchantBaseUrl, orderId: proposal.orderId, sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "", claimToken: proposal.claimToken, }); // First check if we already paid for it. const purchase = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(proposalId); }, ); if ( !purchase || purchase.purchaseStatus === PurchaseStatus.DialogProposed || purchase.purchaseStatus === PurchaseStatus.DialogShared ) { const instructedAmount = Amounts.parseOrThrow(contractData.amount); // If not already paid, check if we could pay for it. const res = await selectPayCoins(wex, { restrictExchanges: { auditors: [], exchanges: contractData.allowedExchanges, }, contractTermsAmount: instructedAmount, depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, restrictWireMethod: contractData.wireMethod, }); 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, currency, coins); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); return { status: PreparePayResultType.PaymentPossible, contractTerms: d.contractTermsRaw, transactionId, proposalId: proposal.proposalId, amountEffective: Amounts.stringify(totalCost), amountRaw: Amounts.stringify(instructedAmount), 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 wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(ctx.taskId); // FIXME: Consider changing the API here so that we don't have to // wait inline for the repurchase. await waitPaymentResult(wex, proposalId, sessionId); const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, paid: true, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: purchase.payInfo ? Amounts.stringify(purchase.payInfo.totalPayCost) : undefined, transactionId, proposalId, talerUri, }; } else if (!purchase.timestampFirstSuccessfulPay) { const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: purchase.payInfo ? Amounts.stringify(purchase.payInfo.totalPayCost) : undefined, transactionId, proposalId, talerUri, }; } else { const paid = purchase.purchaseStatus === PurchaseStatus.Done || purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund || purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund; const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, paid, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: purchase.payInfo ? Amounts.stringify(purchase.payInfo.totalPayCost) : undefined, ...(paid ? { nextUrl: download.contractData.orderId } : {}), transactionId, proposalId, talerUri, }; } } export async function getContractTermsDetails( wex: WalletExecutionContext, proposalId: string, ): Promise { 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`); } const d = await expectProposalDownload(wex, 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( wex: WalletExecutionContext, 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 createOrReusePurchase( wex, uriResult.merchantBaseUrl, uriResult.orderId, uriResult.sessionId, uriResult.claimToken, uriResult.noncePriv, ); await waitProposalDownloaded(wex, proposalId); return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId); } /** * Wait until a proposal is at least downloaded. */ async function waitProposalDownloaded( wex: WalletExecutionContext, proposalId: string, ): Promise { // FIXME: This doesn't support cancellation yet const ctx = new PayMerchantTransactionContext(wex, proposalId); logger.info(`waiting for ${ctx.transactionId} to be downloaded`); wex.taskScheduler.startShepherdTask(ctx.taskId); // FIXME: We should use Symbol.dispose magic here for cleanup! const payNotifFlag = new AsyncFlag(); // Raise exchangeNotifFlag whenever we get a notification // about our exchange. const cancelNotif = wex.ws.addNotificationListener((notif) => { if ( notif.type === NotificationType.TransactionStateTransition && notif.transactionId === ctx.transactionId ) { logger.info(`raising update notification: ${j2s(notif)}`); payNotifFlag.raise(); } }); try { await internalWaitProposalDownloaded(ctx, payNotifFlag); logger.info(`done waiting for ${ctx.transactionId} to be downloaded`); } finally { cancelNotif(); } } async function internalWaitProposalDownloaded( ctx: PayMerchantTransactionContext, payNotifFlag: AsyncFlag, ): Promise { while (true) { const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx( { storeNames: ["purchases", "operationRetries"] }, async (tx) => { return { purchase: await tx.purchases.get(ctx.proposalId), retryInfo: await tx.operationRetries.get(ctx.taskId), }; }, ); if (!purchase) { throw Error("purchase does not exist anymore"); } if (purchase.download) { return; } if (retryInfo) { if (retryInfo.lastError) { throw TalerError.fromUncheckedDetail(retryInfo.lastError); } else { throw Error("transient error while waiting for proposal download"); } } await payNotifFlag.wait(); payNotifFlag.reset(); } } export async function preparePayForTemplate( wex: WalletExecutionContext, req: PreparePayTemplateRequest, ): Promise { const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri); const templateDetails: MerchantUsingTemplateDetails = {}; if (!parsedUri) { throw Error("invalid taler-template URI"); } logger.trace(`parsed URI: ${j2s(parsedUri)}`); 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; } } } if ( parsedUri.templateParams.summary !== undefined && typeof parsedUri.templateParams.summary === "string" ) { templateDetails.summary = req.templateParams?.summary ?? parsedUri.templateParams.summary; } const reqUrl = new URL( `templates/${parsedUri.templateId}`, parsedUri.merchantBaseUrl, ); const httpReq = await wex.http.fetch(reqUrl.href, { method: "POST", body: templateDetails, }); const resp = await readSuccessResponseJsonOrThrow( httpReq, codecForMerchantPostOrderResponse(), ); const payUri = stringifyPayUri({ merchantBaseUrl: parsedUri.merchantBaseUrl, orderId: resp.order_id, sessionId: "", claimToken: resp.token, }); return await preparePayForUri(wex, payUri); } /** * Generate deposit permissions for a purchase. * * Accesses the database and the crypto worker. */ export async function generateDepositPermissions( wex: WalletExecutionContext, payCoinSel: DbCoinSelection, contractData: WalletContractData, ): Promise { const depositPermissions: CoinDepositPermission[] = []; const coinWithDenom: Array<{ coin: CoinRecord; denom: DenominationRecord; }> = []; 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 }); } }, ); for (let i = 0; i < payCoinSel.coinContributions.length; i++) { const { coin, denom } = coinWithDenom[i]; let wireInfoHash: string; wireInfoHash = contractData.wireInfoHash; const dp = await wex.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; } async function internalWaitPaymentResult( ctx: PayMerchantTransactionContext, purchaseNotifFlag: AsyncFlag, waitSessionId?: string, ): Promise { while (true) { const txRes = await ctx.wex.db.runReadOnlyTx( { storeNames: ["purchases", "operationRetries"] }, async (tx) => { const purchase = await tx.purchases.get(ctx.proposalId); const retryRecord = await tx.operationRetries.get(ctx.taskId); return { purchase, retryRecord }; }, ); if (!txRes.purchase) { throw Error("purchase gone"); } const purchase = txRes.purchase; logger.info( `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`, ); const d = await expectProposalDownload(ctx.wex, purchase); if (txRes.purchase.timestampFirstSuccessfulPay) { if ( waitSessionId == null || txRes.purchase.lastSessionId === waitSessionId ) { return { type: ConfirmPayResultType.Done, contractTerms: d.contractTermsRaw, transactionId: ctx.transactionId, }; } } if (txRes.retryRecord) { return { type: ConfirmPayResultType.Pending, lastError: txRes.retryRecord.lastError, transactionId: ctx.transactionId, }; } if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) { return { type: ConfirmPayResultType.Done, contractTerms: d.contractTermsRaw, transactionId: ctx.transactionId, }; } await purchaseNotifFlag.wait(); purchaseNotifFlag.reset(); } } /** * Wait until either: * a) the payment succeeded (if provided under the {@param waitSessionId}), or * b) the attempt to pay failed (merchant unavailable, etc.) */ async function waitPaymentResult( wex: WalletExecutionContext, proposalId: string, waitSessionId?: string, ): Promise { // FIXME: We don't support cancelletion yet! const ctx = new PayMerchantTransactionContext(wex, proposalId); wex.taskScheduler.startShepherdTask(ctx.taskId); // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. const purchaseNotifFlag = new AsyncFlag(); // Raise purchaseNotifFlag whenever we get a notification // about our purchase. const cancelNotif = wex.ws.addNotificationListener((notif) => { if ( notif.type === NotificationType.TransactionStateTransition && notif.transactionId === ctx.transactionId ) { purchaseNotifFlag.raise(); } }); try { logger.info(`waiting for first payment success on ${ctx.transactionId}`); const res = await internalWaitPaymentResult( ctx, purchaseNotifFlag, waitSessionId, ); logger.info( `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`, ); return res; } finally { cancelNotif(); } } /** * Confirm payment for a proposal previously claimed by the wallet. */ export async function confirmPay( wex: WalletExecutionContext, transactionId: string, sessionIdOverride?: string, forcedCoinSel?: ForcedCoinSel, ): Promise { const parsedTx = parseTransactionIdentifier(transactionId); if (parsedTx?.tag !== TransactionType.Payment) { throw Error("expected payment transaction ID"); } const proposalId = parsedTx.proposalId; logger.trace( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, ); 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`); } const d = await expectProposalDownload(wex, proposal); if (!d) { throw Error("proposal is in invalid state"); } const existingPurchase = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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"); const ctx = new PayMerchantTransactionContext( wex, existingPurchase.proposalId, ); await wex.taskScheduler.resetTaskRetries(ctx.taskId); return waitPaymentResult(wex, proposalId); } logger.trace("confirmPay: purchase record does not exist yet"); const contractData = d.contractData; const currency = Amounts.currencyOf(contractData.amount); 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, forcedSelection: forcedCoinSel, }); let coins: SelectedProspectiveCoin[] | undefined = undefined; 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); } logger.trace("coin selection result", selectCoinsResult); const payCostInfo = await getTotalPaymentCost(wex, currency, coins); 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 wex.db.runReadWriteTx( { storeNames: [ "purchases", "coins", "refreshGroups", "refreshSessions", "denominations", "coinAvailability", ], }, async (tx) => { const p = await tx.purchases.get(proposal.proposalId); if (!p) { return; } const oldTxState = computePayMerchantTransactionState(p); switch (p.purchaseStatus) { case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: p.payInfo = { 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); 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: default: break; } const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, }); const ctx = new PayMerchantTransactionContext(wex, proposalId); // In case we're sharing the payment and we're long-polling wex.taskScheduler.stopShepherdTask(ctx.taskId); // Wait until we have completed the first attempt to pay. return waitPaymentResult(wex, proposalId); } export async function processPurchase( wex: WalletExecutionContext, proposalId: string, ): Promise { const purchase = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(proposalId); }, ); if (!purchase) { return { type: TaskRunResultType.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(wex, proposalId); case PurchaseStatus.PendingPaying: case PurchaseStatus.PendingPayingReplay: return processPurchasePay(wex, proposalId); case PurchaseStatus.PendingQueryingRefund: return processPurchaseQueryRefund(wex, purchase); case PurchaseStatus.PendingQueryingAutoRefund: return processPurchaseAutoRefund(wex, purchase); case PurchaseStatus.AbortingWithRefund: return processPurchaseAbortingRefund(wex, purchase); case PurchaseStatus.PendingAcceptRefund: return processPurchaseAcceptRefund(wex, purchase); case PurchaseStatus.DialogShared: return processPurchaseDialogShared(wex, purchase); case PurchaseStatus.FailedClaim: case PurchaseStatus.Done: case PurchaseStatus.DoneRepurchaseDetected: case PurchaseStatus.DialogProposed: case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedIncompletePayment: case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: case PurchaseStatus.SuspendedAbortingWithRefund: case PurchaseStatus.SuspendedDownloadingProposal: case PurchaseStatus.SuspendedPaying: case PurchaseStatus.SuspendedPayingReplay: case PurchaseStatus.SuspendedPendingAcceptRefund: case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingRefund: case PurchaseStatus.FailedAbort: case PurchaseStatus.FailedPaidByOther: return TaskRunResult.finished(); default: assertUnreachable(purchase.purchaseStatus); } } async function processPurchasePay( wex: WalletExecutionContext, proposalId: string, ): Promise { const purchase = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(proposalId); }, ); if (!purchase) { return { type: TaskRunResultType.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 TaskRunResult.finished(); } logger.trace(`processing purchase pay ${proposalId}`); const ctx = new PayMerchantTransactionContext(wex, proposalId); const sessionId = purchase.lastSessionId; logger.trace(`paying with session ID ${sessionId}`); const payInfo = purchase.payInfo; checkDbInvariant(!!payInfo, "payInfo"); const download = await expectProposalDownload(wex, purchase); if (purchase.shared) { const paid = await checkIfOrderIsAlreadyPaid( wex, download.contractData, false, ); if (paid) { const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.FailedPaidByOther; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }, ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); notifyTransition(wex, transactionId, transitionInfo); return { type: TaskRunResultType.Error, errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, { orderId: purchase.orderId, fulfillmentUrl: download.contractData.fulfillmentUrl, }), }; } } 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`, download.contractData.merchantBaseUrl, ).href; let depositPermissions: CoinDepositPermission[]; // FIXME: Cache! depositPermissions = await generateDepositPermissions( wex, payInfo.payCoinSelection, download.contractData, ); const reqBody = { coins: depositPermissions, session_id: purchase.lastSessionId, }; if (logger.shouldLogTrace()) { logger.trace(`making pay request ... ${j2s(reqBody)}`); } const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => wex.http.fetch(payUrl, { method: "POST", body: reqBody, timeout: getPayRequestTimeout(purchase), cancellationToken: wex.cancellationToken, }), ); logger.trace(`got resp ${JSON.stringify(resp)}`); if (resp.status >= 500 && resp.status <= 599) { const errDetails = await readUnexpectedResponseDetails(resp); return { type: TaskRunResultType.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 // 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()}`); }); // FIXME: Should we really consider this to be pending? return TaskRunResult.backoff(); } } if (resp.status >= 400 && resp.status <= 499) { logger.trace("got generic 4xx from merchant"); const err = await readTalerErrorResponse(resp); if (logger.shouldLogTrace()) { logger.trace(`error body: ${j2s(err)}`); } 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 wex.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(wex, proposalId, sessionId, merchantResp); } 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 wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => wex.http.fetch(payAgainUrl, { method: "POST", body: reqBody, cancellationToken: wex.cancellationToken, }), ); 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(wex, proposalId, sessionId); } return TaskRunResult.progress(); } export async function refuseProposal( wex: WalletExecutionContext, proposalId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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.DialogProposed && proposal.purchaseStatus !== PurchaseStatus.DialogShared ) { return undefined; } const oldTxState = computePayMerchantTransactionState(proposal); proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused; const newTxState = computePayMerchantTransactionState(proposal); await tx.purchases.put(proposal); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); } 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 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.DialogProposed: return { major: TransactionMajorState.Dialog, minor: TransactionMinorState.MerchantOrderProposed, }; case PurchaseStatus.DialogShared: return { major: TransactionMajorState.Dialog, minor: TransactionMinorState.MerchantOrderProposed, }; // Final States case PurchaseStatus.AbortedProposalRefused: return { major: TransactionMajorState.Failed, minor: TransactionMinorState.Refused, }; case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: return { major: TransactionMajorState.Aborted, }; case PurchaseStatus.Done: return { major: TransactionMajorState.Done, }; case PurchaseStatus.DoneRepurchaseDetected: 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, }; case PurchaseStatus.FailedPaidByOther: return { major: TransactionMajorState.Failed, minor: TransactionMinorState.PaidByOther, }; default: assertUnreachable(purchaseRecord.purchaseStatus); } } export function computePayMerchantTransactionActions( purchaseRecord: PurchaseRecord, ): TransactionAction[] { switch (purchaseRecord.purchaseStatus) { // Pending States case PurchaseStatus.PendingDownloadingProposal: return [TransactionAction.Suspend, TransactionAction.Abort]; case PurchaseStatus.PendingPaying: return [TransactionAction.Suspend, TransactionAction.Abort]; case PurchaseStatus.PendingPayingReplay: // Special "abort" since it goes back to "done". return [TransactionAction.Suspend, TransactionAction.Abort]; case PurchaseStatus.PendingQueryingAutoRefund: // Special "abort" since it goes back to "done". return [TransactionAction.Suspend, TransactionAction.Abort]; case PurchaseStatus.PendingQueryingRefund: // Special "abort" since it goes back to "done". return [TransactionAction.Suspend, TransactionAction.Abort]; case PurchaseStatus.PendingAcceptRefund: // Special "abort" since it goes back to "done". return [TransactionAction.Suspend, TransactionAction.Abort]; // Suspended Pending States case PurchaseStatus.SuspendedDownloadingProposal: return [TransactionAction.Resume, TransactionAction.Abort]; case PurchaseStatus.SuspendedPaying: return [TransactionAction.Resume, TransactionAction.Abort]; case PurchaseStatus.SuspendedPayingReplay: // Special "abort" since it goes back to "done". return [TransactionAction.Resume, TransactionAction.Abort]; case PurchaseStatus.SuspendedQueryingAutoRefund: // Special "abort" since it goes back to "done". return [TransactionAction.Resume, TransactionAction.Abort]; case PurchaseStatus.SuspendedQueryingRefund: // Special "abort" since it goes back to "done". return [TransactionAction.Resume, TransactionAction.Abort]; case PurchaseStatus.SuspendedPendingAcceptRefund: // Special "abort" since it goes back to "done". return [TransactionAction.Resume, TransactionAction.Abort]; // Aborting States case PurchaseStatus.AbortingWithRefund: return [TransactionAction.Fail, TransactionAction.Suspend]; case PurchaseStatus.SuspendedAbortingWithRefund: return [TransactionAction.Fail, TransactionAction.Resume]; // Dialog States case PurchaseStatus.DialogProposed: return []; case PurchaseStatus.DialogShared: return []; // Final States case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: return [TransactionAction.Delete]; case PurchaseStatus.Done: return [TransactionAction.Delete]; case PurchaseStatus.DoneRepurchaseDetected: return [TransactionAction.Delete]; case PurchaseStatus.AbortedIncompletePayment: return [TransactionAction.Delete]; case PurchaseStatus.FailedClaim: return [TransactionAction.Delete]; case PurchaseStatus.FailedAbort: return [TransactionAction.Delete]; case PurchaseStatus.FailedPaidByOther: return [TransactionAction.Delete]; default: assertUnreachable(purchaseRecord.purchaseStatus); } } export async function sharePayment( wex: WalletExecutionContext, merchantBaseUrl: string, orderId: string, ): Promise { 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); 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"); } const ctx = new PayMerchantTransactionContext(wex, result.proposalId); notifyTransition(wex, ctx.transactionId, result.transitionInfo); // schedule a task to watch for the status wex.taskScheduler.startShepherdTask(ctx.taskId); const privatePayUri = stringifyPayUri({ merchantBaseUrl, orderId, sessionId: result.session ?? "", noncePriv: result.nonce, claimToken: result.token, }); return { privatePayUri }; } async function checkIfOrderIsAlreadyPaid( wex: WalletExecutionContext, contract: WalletContractData, doLongPolling: boolean, ) { const requestUrl = new URL( `orders/${contract.orderId}`, contract.merchantBaseUrl, ); requestUrl.searchParams.set("h_contract", contract.contractTermsHash); if (doLongPolling) { requestUrl.searchParams.set("timeout_ms", "30000"); } const resp = await wex.http.fetch(requestUrl.href, { cancellationToken: wex.cancellationToken, }); if ( resp.status === HttpStatusCode.Ok || resp.status === HttpStatusCode.Accepted || resp.status === HttpStatusCode.Found ) { return true; } else if (resp.status === HttpStatusCode.PaymentRequired) { return false; } // forbidden, not found, not acceptable throw Error(`this order cant be paid: ${resp.status}`); } async function processPurchaseDialogShared( wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; logger.trace(`processing dialog-shared for proposal ${proposalId}`); const download = await expectProposalDownload(wex, purchase); if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { return TaskRunResult.finished(); } const paid = await checkIfOrderIsAlreadyPaid( wex, download.contractData, true, ); if (paid) { const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.FailedPaidByOther; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }, ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); notifyTransition(wex, transactionId, transitionInfo); } return TaskRunResult.backoff(); } async function processPurchaseAutoRefund( wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; logger.trace(`processing auto-refund for proposal ${proposalId}`); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); const download = await expectProposalDownload(wex, purchase); 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( { 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.PendingQueryingAutoRefund) { return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.Done; p.refundAmountAwaiting = undefined; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.finished(); } const requestUrl = new URL( `orders/${download.contractData.orderId}`, download.contractData.merchantBaseUrl, ); requestUrl.searchParams.set( "h_contract", download.contractData.contractTermsHash, ); requestUrl.searchParams.set("timeout_ms", "10000"); requestUrl.searchParams.set("await_refund_obtained", "yes"); requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund)); const resp = await wex.http.fetch(requestUrl.href, { cancellationToken: wex.cancellationToken, }); // FIXME: Check other status codes! const orderStatus = await readSuccessResponseJsonOrThrow( resp, codecForMerchantOrderStatusPaid(), ); if (orderStatus.refund_pending) { const transitionInfo = await wex.db.runReadWriteTx( { 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.PendingQueryingAutoRefund) { return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.progress(); } return TaskRunResult.longpollReturnedPending(); } async function processPurchaseAbortingRefund( wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; const download = await expectProposalDownload(wex, 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 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); 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 abortHttpResp = await wex.http.fetch(requestUrl.href, { method: "POST", body: abortReq, cancellationToken: wex.cancellationToken, }); if (abortHttpResp.status === HttpStatusCode.NotFound) { const err = await readTalerErrorResponse(abortHttpResp); if ( err.code === TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND ) { const ctx = new PayMerchantTransactionContext(wex, proposalId); await ctx.transition(async (rec) => { if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) { rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; return TransitionResultType.Transition; } return TransitionResultType.Stay; }); } } const abortResp = await readSuccessResponseJsonOrThrow( abortHttpResp, 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(wex, purchase, refunds, RefundReason.AbortRefund); } async function processPurchaseQueryRefund( wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise { const proposalId = purchase.proposalId; logger.trace(`processing query-refund for proposal ${proposalId}`); const download = await expectProposalDownload(wex, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}`, download.contractData.merchantBaseUrl, ); requestUrl.searchParams.set( "h_contract", download.contractData.contractTermsHash, ); const resp = await wex.http.fetch(requestUrl.href, { cancellationToken: wex.cancellationToken, }); const orderStatus = await readSuccessResponseJsonOrThrow( resp, codecForMerchantOrderStatusPaid(), ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); if (!orderStatus.refund_pending) { const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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; p.refundAmountAwaiting = undefined; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.progress(); } else { const refundAwaiting = Amounts.sub( Amounts.parseOrThrow(orderStatus.refund_amount), Amounts.parseOrThrow(orderStatus.refund_taken), ).amount; const transitionInfo = await wex.db.runReadWriteTx( { 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) { 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(wex, transactionId, transitionInfo); return TaskRunResult.progress(); } } async function processPurchaseAcceptRefund( wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise { const download = await expectProposalDownload(wex, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}/refund`, download.contractData.merchantBaseUrl, ); logger.trace(`making refund request to ${requestUrl.href}`); const request = await wex.http.fetch(requestUrl.href, { method: "POST", body: { h_contract: download.contractData.contractTermsHash, }, cancellationToken: wex.cancellationToken, }); const refundResponse = await readSuccessResponseJsonOrThrow( request, codecForWalletRefundResponse(), ); return await storeRefunds( wex, purchase, refundResponse.refunds, RefundReason.AbortRefund, ); } export async function startRefundQueryForUri( wex: WalletExecutionContext, 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 wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ parsedUri.merchantBaseUrl, parsedUri.orderId, ]); }, ); if (!purchaseRecord) { logger.error( `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`, ); throw Error("no purchase found, can't refund"); } const proposalId = purchaseRecord.proposalId; const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); await startQueryRefund(wex, proposalId); return { transactionId, }; } export async function startQueryRefund( wex: WalletExecutionContext, proposalId: string, ): Promise { const ctx = new PayMerchantTransactionContext(wex, proposalId); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["purchases"] }, 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(wex, ctx.transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(ctx.taskId); } async function computeRefreshRequest( wex: WalletExecutionContext, tx: WalletDbReadWriteTransaction<["coins", "denominations"]>, items: RefundItemRecord[], ): Promise { 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 getDenomInfo( wex, 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; } /** * Compute the refund item status based on the merchant's response. */ function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus { if (rf.type === "success") { return RefundItemStatus.Done; } else { if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { return RefundItemStatus.Pending; } else { return RefundItemStatus.Failed; } } } /** * Store refunds, possibly creating a new refund group. */ async function storeRefunds( wex: WalletExecutionContext, 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(wex, purchase); const currency = Amounts.currencyOf(download.contractData.amount); const result = await wex.db.runReadWriteTx( { storeNames: [ "coins", "denominations", "purchases", "refundItems", "refundGroups", "denominations", "coins", "coinAvailability", "refreshGroups", "refreshSessions", ], }, async (tx) => { const myPurchase = await tx.purchases.get(purchase.proposalId); if (!myPurchase) { logger.warn("purchase group not found anymore"); return; } let isAborting: boolean; switch (myPurchase.purchaseStatus) { case PurchaseStatus.PendingAcceptRefund: isAborting = false; break; case PurchaseStatus.AbortingWithRefund: isAborting = true; break; default: logger.warn("wrong state, not accepting refund"); 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: timestampPreciseToDb(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: timestampProtocolToDb(rf.execution_time), obtainedTime: timestampPreciseToDb(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( wex, tx, newGroupRefunds, ); const outInfo = await calculateRefreshOutput( wex, 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, ); for (const refundGroup of refundGroups) { switch (refundGroup.status) { case RefundGroupStatus.Aborted: case RefundGroupStatus.Expired: case RefundGroupStatus.Failed: case RefundGroupStatus.Done: continue; case RefundGroupStatus.Pending: break; default: assertUnreachable(refundGroup.status); } const items = await tx.refundItems.indexes.byRefundGroupId.getAll([ refundGroup.refundGroupId, ]); let numPending = 0; let numFailed = 0; for (const item of items) { if (item.status === RefundItemStatus.Pending) { numPending++; } if (item.status === RefundItemStatus.Failed) { numFailed++; } } if (numPending === 0) { // We're done for this refund group! if (numFailed === 0) { refundGroup.status = RefundGroupStatus.Done; } else { refundGroup.status = RefundGroupStatus.Failed; } await tx.refundGroups.put(refundGroup); const refreshCoins = await computeRefreshRequest(wex, tx, items); await createRefreshGroup( wex, tx, Amounts.currencyOf(download.contractData.amount), refreshCoins, RefreshReason.Refund, // Since refunds are really just pseudo-transactions, // the originating transaction for the refresh is the payment transaction. constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: myPurchase.proposalId, }), ); } } 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; } myPurchase.refundAmountAwaiting = undefined; } await tx.purchases.put(myPurchase); const newTxState = computePayMerchantTransactionState(myPurchase); return { numPendingItemsTotal, transitionInfo: { oldTxState, newTxState, }, }; }, ); if (!result) { return TaskRunResult.finished(); } notifyTransition(wex, transactionId, result.transitionInfo); if (result.numPendingItemsTotal > 0) { return TaskRunResult.backoff(); } else { return TaskRunResult.progress(); } } 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, }; case RefundGroupStatus.Expired: return { major: TransactionMajorState.Expired, }; } }