diff options
author | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
commit | e951075d2ef52fa8e9e7489c62031777c3a7e66b (patch) | |
tree | 64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/pay-merchant.ts | |
parent | e975740ac4e9ba4bc531226784d640a018c00833 (diff) | |
download | wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.gz wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.bz2 wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.zip |
wallet-core: flatten directory structure
Diffstat (limited to 'packages/taler-wallet-core/src/pay-merchant.ts')
-rw-r--r-- | packages/taler-wallet-core/src/pay-merchant.ts | 3232 |
1 files changed, 3232 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts new file mode 100644 index 000000000..f294035e6 --- /dev/null +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -0,0 +1,3232 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Implementation of the payment operation, including downloading and + * claiming of proposals. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { + AbortingCoin, + AbortRequest, + AbsoluteTime, + AmountJson, + Amounts, + AmountString, + AsyncFlag, + codecForAbortResponse, + codecForMerchantContractTerms, + codecForMerchantOrderRefundPickupResponse, + codecForMerchantOrderStatusPaid, + codecForMerchantPayResponse, + codecForMerchantPostOrderResponse, + codecForProposal, + CoinDepositPermission, + CoinRefreshRequest, + ConfirmPayResult, + ConfirmPayResultType, + ContractTermsUtil, + Duration, + encodeCrock, + ForcedCoinSel, + getRandomBytes, + HttpStatusCode, + j2s, + Logger, + makeErrorDetail, + makePendingOperationFailedError, + MerchantCoinRefundStatus, + MerchantContractTerms, + MerchantPayResponse, + MerchantUsingTemplateDetails, + NotificationType, + parsePayTemplateUri, + parsePayUri, + parseTalerUri, + PayCoinSelection, + PreparePayResult, + PreparePayResultType, + PreparePayTemplateRequest, + randomBytes, + RefreshReason, + 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 { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { + CoinRecord, + DenominationRecord, + PurchaseRecord, + PurchaseStatus, + RefundReason, + WalletStoresV1, +} from "./db.js"; +import { + getCandidateWithdrawalDenomsTx, + PendingTaskType, + RefundGroupRecord, + RefundGroupStatus, + RefundItemRecord, + RefundItemStatus, + TaskId, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, +} 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 { DbReadWriteTransaction, StoreNames } from "./query.js"; +import { + constructTaskIdentifier, + DbRetryInfo, + spendCoins, + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + TransitionResult, +} from "./common.js"; +import { + calculateRefreshOutput, + createRefreshGroup, + getTotalRefreshCost, +} from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; + +/** + * Logger. + */ +const logger = new Logger("pay-merchant.ts"); + +export class PayMerchantTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskId; + + constructor( + public ws: InternalWalletState, + 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<TransitionResult>, + ): Promise<void> { + return this.transitionExtra( + { + extraStores: [], + }, + f, + ); + } + + /** + * Transition a payment transition. + * Extra object stores may be accessed during the transition. + */ + async transitionExtra< + StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [], + >( + opts: { extraStores: StoreNameArray }, + f: ( + rec: PurchaseRecord, + tx: DbReadWriteTransaction< + typeof WalletStoresV1, + ["purchases", ...StoreNameArray] + >, + ) => Promise<TransitionResult>, + ): Promise<void> { + const ws = this.ws; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await ws.db.runReadWriteTx( + ["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 TransitionResult.Transition: { + await tx.purchases.put(purchaseRec); + const newTxState = computePayMerchantTransactionState(purchaseRec); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + notifyTransition(ws, this.transactionId, transitionInfo); + } + + async deleteTransaction(): Promise<void> { + const { ws, proposalId } = this; + await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => { + let found = false; + const purchase = await tx.purchases.get(proposalId); + if (purchase) { + found = true; + await tx.purchases.delete(proposalId); + } + if (found) { + await tx.tombstones.put({ + id: TombstoneTag.DeletePayment + ":" + proposalId, + }); + } + }); + } + + async suspendTransaction(): Promise<void> { + const { ws, proposalId, transactionId } = this; + ws.taskScheduler.stopShepherdTask(this.taskId); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { ws, proposalId, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + [ + "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); + 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; + } + switch (oldStatus) { + case PurchaseStatus.Done: + return; + case PurchaseStatus.PendingPaying: + case PurchaseStatus.SuspendedPaying: { + purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + 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, + this.transactionId, + ); + } + break; + } + case PurchaseStatus.DialogProposed: + purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused; + break; + } + await tx.purchases.put(purchase); + await tx.operationRetries.delete(this.taskId); + const newTxState = computePayMerchantTransactionState(purchase); + return { oldTxState, newTxState }; + }, + ); + ws.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } + + async resumeTransaction(): Promise<void> { + const { ws, proposalId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } + + async failTransaction(): Promise<void> { + const { ws, proposalId, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + [ + "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(ws, transactionId, transitionInfo); + ws.taskScheduler.stopShepherdTask(this.taskId); + } +} + +export class RefundTransactionContext implements TransactionContext { + public transactionId: string; + constructor( + public ws: InternalWalletState, + public refundGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refund, + refundGroupId, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, refundGroupId, transactionId } = this; + await ws.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => { + const refundRecord = await tx.refundGroups.get(refundGroupId); + if (!refundRecord) { + return; + } + await tx.refundGroups.delete(refundGroupId); + await tx.tombstones.put({ id: transactionId }); + // FIXME: Also tombstone the refund items, so that they won't reappear. + }); + } + + suspendTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } + + abortTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } + + resumeTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } + + failTransaction(): Promise<void> { + 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( + ws: InternalWalletState, + pcs: PayCoinSelection, +): Promise<AmountJson> { + const currency = Amounts.currencyOf(pcs.paymentAmount); + return ws.db.runReadOnlyTx(["coins", "denominations"], 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 getCandidateWithdrawalDenomsTx( + ws, + tx, + coin.exchangeBaseUrl, + currency, + ); + const amountLeft = Amounts.sub( + denom.value, + 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<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); +} + +function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration { + return Duration.clamp({ + lower: Duration.fromSpec({ seconds: 1 }), + upper: Duration.fromSpec({ seconds: 60 }), + value: retryInfo + ? DbRetryInfo.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?: 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<typeof parentTx, undefined>, + ): Promise<ReturnType<typeof expectProposalDownload>> { + 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.runReadOnlyTx(["contractTerms"], 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, + summaryI18n: parsedContractTerms.summary_i18n, + minimumAge: parsedContractTerms.minimum_age, + }; +} + +async function processDownloadProposal( + ws: InternalWalletState, + proposalId: string, +): Promise<TaskRunResult> { + const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return await tx.purchases.get(proposalId); + }); + + if (!proposal) { + return TaskRunResult.finished(); + } + + const ctx = new PayMerchantTransactionContext(ws, 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 opId = TaskIdentifiers.forPay(proposal); + const retryRecord = await ws.db.runReadOnlyTx( + ["operationRetries"], + 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.runReadWriteTx( + ["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.refundAmountAwaiting === undefined) { + 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(ws, 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( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, + sessionId: string | undefined, + claimToken: string | undefined, + noncePriv: string | undefined, +): Promise<string> { + const oldProposals = await ws.db.runReadOnlyTx(["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(ws, oldProposal); + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + 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 ws.db.runReadWriteTx( + ["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.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: oldProposal.proposalId, + }); + notifyTransition(ws, transactionId, transitionInfo); + } + } + return oldProposal.proposalId; + } + + let noncePair: EddsaKeypair; + let shared = false; + if (noncePriv) { + shared = true; + noncePair = { + priv: noncePriv, + pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, + }; + } 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: 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return proposalId; +} + +async function storeFirstPaySuccess( + ws: InternalWalletState, + proposalId: string, + sessionId: string | undefined, + payResponse: MerchantPayResponse, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); +} + +async function storePayReplaySuccess( + ws: InternalWalletState, + proposalId: string, + sessionId: string | undefined, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(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<void> { + logger.trace("handling insufficient funds, trying to re-select coins"); + + const proposal = await ws.db.runReadOnlyTx(["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(ws, proposal); + + const prevPayCoins: PreviousPayCoins = []; + + const payInfo = proposal.payInfo; + if (!payInfo) { + return; + } + + const payCoinSelection = payInfo.payCoinSelection; + + await ws.db.runReadOnlyTx(["coins", "denominations"], 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.runReadWriteTx( + [ + "purchases", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + ], + 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, + }); + }, + ); + + 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( + ws: InternalWalletState, + proposalId: string, + sessionId?: string, +): Promise<PreparePayResult> { + let proposal = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx( + ["purchases"], + async (tx) => { + return tx.purchases.get(existingProposalId); + }, + ); + if (oldProposal) { + proposal = oldProposal; + } + } + } + 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 ctx = new PayMerchantTransactionContext(ws, 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 ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return tx.purchases.get(proposalId); + }); + + if ( + !purchase || + purchase.purchaseStatus === PurchaseStatus.DialogProposed || + purchase.purchaseStatus === PurchaseStatus.DialogShared + ) { + // If not already paid, check if we could pay for it. + const res = await selectPayCoinsNew(ws, { + auditors: [], + 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, + 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, + 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.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Consider changing the API here so that we don't have to + // wait inline for the repurchase. + + await waitPaymentResult(ws, proposalId, sessionId); + 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: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, + 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: 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(ws, 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( + ws: InternalWalletState, + proposalId: string, +): Promise<WalletContractData> { + const proposal = await ws.db.runReadOnlyTx(["purchases"], 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<PreparePayResult> { + 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( + ws, + uriResult.merchantBaseUrl, + uriResult.orderId, + uriResult.sessionId, + uriResult.claimToken, + uriResult.noncePriv, + ); + + await waitProposalDownloaded(ws, proposalId); + + return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); +} + +/** + * Wait until a proposal is at least downloaded. + */ +async function waitProposalDownloaded( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const ctx = new PayMerchantTransactionContext(ws, proposalId); + + logger.info(`waiting for ${ctx.transactionId} to be downloaded`); + + ws.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 = 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<void> { + while (true) { + const { purchase, retryInfo } = await ctx.ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + req: PreparePayTemplateRequest, +): Promise<PreparePayResult> { + const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri); + const templateDetails: MerchantUsingTemplateDetails = {}; + if (!parsedUri) { + throw Error("invalid taler-template URI"); + } + logger.trace(`parsed URI: ${j2s(parsedUri)}`); + + const 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 ws.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(ws, payUri); +} + +/** + * Generate deposit permissions for a purchase. + * + * Accesses the database and the crypto worker. + */ +export async function generateDepositPermissions( + ws: InternalWalletState, + payCoinSel: PayCoinSelection, + contractData: WalletContractData, +): Promise<CoinDepositPermission[]> { + const depositPermissions: CoinDepositPermission[] = []; + const coinWithDenom: Array<{ + coin: CoinRecord; + denom: DenominationRecord; + }> = []; + await ws.db.runReadOnlyTx(["coins", "denominations"], 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; +} + +async function internalWaitPaymentResult( + ctx: PayMerchantTransactionContext, + purchaseNotifFlag: AsyncFlag, + waitSessionId?: string, +): Promise<ConfirmPayResult> { + while (true) { + const txRes = await ctx.ws.db.runReadOnlyTx( + ["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.ws, 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( + ws: InternalWalletState, + proposalId: string, + waitSessionId?: string, +): Promise<ConfirmPayResult> { + const ctx = new PayMerchantTransactionContext(ws, proposalId); + + ws.ensureTaskLoopRunning(); + + ws.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 = 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( + ws: InternalWalletState, + transactionId: string, + sessionIdOverride?: string, + forcedCoinSel?: ForcedCoinSel, +): Promise<ConfirmPayResult> { + 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 ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const d = await expectProposalDownload(ws, proposal); + if (!d) { + throw Error("proposal is in invalid state"); + } + + const existingPurchase = await ws.db.runReadWriteTx( + ["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( + ws, + existingPurchase.proposalId, + ); + await ws.taskScheduler.resetTaskRetries(ctx.taskId); + return waitPaymentResult(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.runReadWriteTx( + [ + "purchases", + "coins", + "refreshGroups", + "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 = { + payCoinSelection: coinSelection, + payCoinSelectionUid: encodeCrock(getRandomBytes(16)), + totalPayCost: Amounts.stringify(payCostInfo), + }; + p.lastSessionId = sessionId; + p.timestampAccept = timestampPreciseToDb(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.BalanceChange, + hintTransactionId: transactionId, + }); + + // Wait until we have completed the first attempt to pay. + return waitPaymentResult(ws, proposalId); +} + +export async function processPurchase( + ws: InternalWalletState, + proposalId: string, +): Promise<TaskRunResult> { + const purchase = await ws.db.runReadOnlyTx(["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(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.DialogShared: + return processPurchaseDialogShared(ws, 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: + return TaskRunResult.finished(); + default: + assertUnreachable(purchase.purchaseStatus); + // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`); + } +} + +async function processPurchasePay( + ws: InternalWalletState, + proposalId: string, +): Promise<TaskRunResult> { + const purchase = await ws.db.runReadOnlyTx(["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 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.shared) { + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + + if (paid) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, { + orderId: purchase.orderId, + fulfillmentUrl: download.contractData.fulfillmentUrl, + }), + }; + } + } + + 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: 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 + handleInsufficientFunds(ws, 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); + 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); + } 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.fetch(payAgainUrl, { method: "POST", body: 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); + } + + return TaskRunResult.progress(); +} + +export async function refuseProposal( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, 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, + }; + } +} + +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]; + } +} + +export async function sharePayment( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, +): Promise<SharePaymentResult> { + const result = await ws.db.runReadWriteTx(["purchases"], async (tx) => { + const p = await tx.purchases.indexes.byUrlAndOrderId.get([ + merchantBaseUrl, + orderId, + ]); + if (!p) { + logger.warn("purchase does not exist anymore"); + return undefined; + } + if ( + p.purchaseStatus !== PurchaseStatus.DialogProposed && + p.purchaseStatus !== PurchaseStatus.DialogShared + ) { + // FIXME: purchase can be shared before being paid + return undefined; + } + if (p.purchaseStatus === PurchaseStatus.DialogProposed) { + p.purchaseStatus = PurchaseStatus.DialogShared; + p.shared = true; + tx.purchases.put(p); + } + + return { + nonce: p.noncePriv, + session: p.lastSessionId ?? p.downloadSessionId, + token: p.claimToken, + }; + }); + + if (result === undefined) { + throw Error("This purchase can't be shared"); + } + const privatePayUri = stringifyPayUri({ + merchantBaseUrl, + orderId, + sessionId: result.session ?? "", + noncePriv: result.nonce, + claimToken: result.token, + }); + return { privatePayUri }; +} + +async function checkIfOrderIsAlreadyPaid( + ws: InternalWalletState, + contract: WalletContractData, +) { + const requestUrl = new URL( + `orders/${contract.orderId}`, + contract.merchantBaseUrl, + ); + requestUrl.searchParams.set("h_contract", contract.contractTermsHash); + + requestUrl.searchParams.set("timeout_ms", "1000"); + + const resp = await ws.http.fetch(requestUrl.href); + if ( + resp.status === HttpStatusCode.Ok || + resp.status === HttpStatusCode.Accepted || + resp.status === HttpStatusCode.Found + ) { + return true; + } else if (resp.status === HttpStatusCode.PaymentRequired) { + return false; + } + //forbidden, not found, not acceptable + throw Error(`this order cant be paid: ${resp.status}`); +} + +async function processPurchaseDialogShared( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + const proposalId = purchase.proposalId; + logger.trace(`processing dialog-shared for proposal ${proposalId}`); + const download = await expectProposalDownload(ws, purchase); + + if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { + return TaskRunResult.finished(); + } + + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + if (paid) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + } + + return TaskRunResult.backoff(); +} + +async function processPurchaseAutoRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + 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, + }); + + const download = await expectProposalDownload(ws, purchase); + + if ( + !purchase.autoRefundDeadline || + AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(purchase.autoRefundDeadline), + ), + ) + ) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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.purchaseStatus = PurchaseStatus.Done; + p.refundAmountAwaiting = undefined; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(ws, 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", "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.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + } + + return TaskRunResult.backoff(); +} + +async function processPurchaseAbortingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + 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.runReadOnlyTx(["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 ws.http.fetch(requestUrl.href, { + method: "POST", + body: abortReq, + }); + + 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(ws, proposalId); + await ctx.transition(async (rec) => { + if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) { + rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + return TransitionResult.Transition; + } + return TransitionResult.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(ws, purchase, refunds, RefundReason.AbortRefund); +} + +async function processPurchaseQueryRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + 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.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.progress(); + } else { + const refundAwaiting = Amounts.sub( + Amounts.parseOrThrow(orderStatus.refund_amount), + Amounts.parseOrThrow(orderStatus.refund_taken), + ).amount; + + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.progress(); + } +} + +async function processPurchaseAcceptRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + 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.fetch(requestUrl.href, { + method: "POST", + body: { + 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<StartRefundQueryForUriResponse> { + 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.runReadOnlyTx( + ["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(ws, proposalId); + return { + transactionId, + }; +} + +export async function startQueryRefund( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const ctx = new PayMerchantTransactionContext(ws, proposalId); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, ctx.transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(ctx.taskId); +} + +async function computeRefreshRequest( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction<["coins", "denominations"]>, + items: RefundItemRecord[], +): Promise<CoinRefreshRequest[]> { + 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; +} + +/** + * 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( + ws: InternalWalletState, + purchase: PurchaseRecord, + refunds: MerchantCoinRefundStatus[], + reason: RefundReason, +): Promise<TaskRunResult> { + 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 result = await ws.db.runReadWriteTx( + [ + "coins", + "denominations", + "purchases", + "refundItems", + "refundGroups", + "denominations", + "coins", + "coinAvailability", + "refreshGroups", + ], + 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( + ws, + tx, + 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, + ); + + 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(ws, tx, items); + await createRefreshGroup( + ws, + 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); + if (numPendingItemsTotal === 0) { + if (isAborting) { + myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; + } 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(ws, 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, + }; + } +} |