summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/pay-merchant.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/pay-merchant.ts')
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts3503
1 files changed, 3503 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..49ebc282e
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -0,0 +1,3503 @@
+/*
+ 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,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ codecForAbortResponse,
+ codecForMerchantContractTerms,
+ codecForMerchantOrderStatusPaid,
+ codecForMerchantPayResponse,
+ codecForMerchantPostOrderResponse,
+ codecForProposal,
+ codecForWalletRefundResponse,
+ CoinDepositPermission,
+ CoinRefreshRequest,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ ContractTermsUtil,
+ Duration,
+ encodeCrock,
+ ForcedCoinSel,
+ getRandomBytes,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ makePendingOperationFailedError,
+ MerchantCoinRefundStatus,
+ MerchantContractTerms,
+ MerchantPayResponse,
+ MerchantUsingTemplateDetails,
+ NotificationType,
+ parsePayTemplateUri,
+ parsePayUri,
+ parseTalerUri,
+ PreparePayResult,
+ PreparePayResultType,
+ PreparePayTemplateRequest,
+ randomBytes,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ SharePaymentResult,
+ StartRefundQueryForUriResponse,
+ stringifyPayUri,
+ stringifyTalerUri,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletContractData,
+} from "@gnu-taler/taler-util";
+import {
+ getHttpResponseErrorDetails,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ readUnexpectedResponseDetails,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js";
+import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ spendCoins,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResultType,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ DbCoinSelection,
+ DenominationRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefundGroupRecord,
+ RefundGroupStatus,
+ RefundItemRecord,
+ RefundItemStatus,
+ RefundReason,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+} from "./db.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import {
+ calculateRefreshOutput,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("pay-merchant.ts");
+
+export class PayMerchantTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public proposalId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
+ }
+
+ /**
+ * Transition a payment transition.
+ */
+ async transition(
+ f: (rec: PurchaseRecord) => Promise<TransitionResultType>,
+ ): 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<TransitionResultType>,
+ ): Promise<void> {
+ const ws = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", ...extraStores] },
+ async (tx) => {
+ const purchaseRec = await tx.purchases.get(this.proposalId);
+ if (!purchaseRec) {
+ throw Error("purchase not found anymore");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchaseRec);
+ const res = await f(purchaseRec, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.purchases.put(purchaseRec);
+ const newTxState = computePayMerchantTransactionState(purchaseRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, this.transactionId, transitionInfo);
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, proposalId } = this;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", "tombstones"] },
+ async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionSuspend[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ const oldStatus = purchase.purchaseStatus;
+ switch (oldStatus) {
+ case PurchaseStatus.Done:
+ return;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying: {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(
+ purchase.payInfo.totalPayCost,
+ );
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
+ await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
+ this.transactionId,
+ );
+ }
+ break;
+ }
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.PendingAcceptRefund:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.PendingQueryingRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ if (!purchase.timestampFirstSuccessfulPay) {
+ throw Error("invalid state");
+ }
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ break;
+ case PurchaseStatus.DialogProposed:
+ purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ break;
+ default:
+ return;
+ }
+ await tx.purchases.put(purchase);
+ await tx.operationRetries.delete(this.taskId);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionResume[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newState: PurchaseStatus | undefined = undefined;
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.AbortingWithRefund:
+ newState = PurchaseStatus.FailedAbort;
+ break;
+ }
+ if (newState) {
+ purchase.purchaseStatus = newState;
+ await tx.purchases.put(purchase);
+ }
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+}
+
+export class RefundTransactionContext implements TransactionContext {
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr | undefined = undefined;
+ constructor(
+ public wex: WalletExecutionContext,
+ public refundGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, refundGroupId, transactionId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refundGroups", "tombstones"] },
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ },
+ );
+ }
+
+ suspendTransaction(): Promise<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(
+ wex: WalletExecutionContext,
+ currency: string,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await tx.denominations.get([
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfCurrency(currency);
+ return Amounts.sum([zero, ...costs]).amount;
+ },
+ );
+}
+
+async function failProposalPermanently(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ // FIXME: We don't store the error detail here?!
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
+ return Duration.multiply(
+ { d_ms: 15000 },
+ 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
+ );
+}
+
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ */
+export async function expectProposalDownload(
+ wex: WalletExecutionContext,
+ p: PurchaseRecord,
+ parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
+): Promise<{
+ contractData: WalletContractData;
+ contractTermsRaw: any;
+}> {
+ if (!p.download) {
+ throw Error("expected proposal to be downloaded");
+ }
+ const download = p.download;
+
+ async function getFromTransaction(
+ tx: Exclude<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 wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ getFromTransaction,
+ );
+}
+
+export function extractContractData(
+ parsedContractTerms: MerchantContractTerms,
+ contractTermsHash: string,
+ merchantSig: string,
+): WalletContractData {
+ const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+ return {
+ amount: Amounts.stringify(amount),
+ contractTermsHash: contractTermsHash,
+ fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+ merchantBaseUrl: parsedContractTerms.merchant_base_url,
+ merchantPub: parsedContractTerms.merchant_pub,
+ merchantSig,
+ orderId: parsedContractTerms.order_id,
+ summary: parsedContractTerms.summary,
+ autoRefund: parsedContractTerms.auto_refund,
+ payDeadline: parsedContractTerms.pay_deadline,
+ refundDeadline: parsedContractTerms.refund_deadline,
+ allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+ exchangeBaseUrl: x.url,
+ exchangePub: x.master_pub,
+ })),
+ timestamp: parsedContractTerms.timestamp,
+ wireMethod: parsedContractTerms.wire_method,
+ wireInfoHash: parsedContractTerms.h_wire,
+ maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
+ merchant: parsedContractTerms.merchant,
+ summaryI18n: parsedContractTerms.summary_i18n,
+ minimumAge: parsedContractTerms.minimum_age,
+ };
+}
+
+async function processDownloadProposal(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return await tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
+ logger.error(
+ `unexpected state ${proposal.purchaseStatus}/${
+ PurchaseStatus[proposal.purchaseStatus]
+ } for ${ctx.transactionId} in processDownloadProposal`,
+ );
+ return TaskRunResult.finished();
+ }
+
+ const transactionId = ctx.transactionId;
+
+ const orderClaimUrl = new URL(
+ `orders/${proposal.orderId}/claim`,
+ proposal.merchantBaseUrl,
+ ).href;
+ logger.trace("downloading contract from '" + orderClaimUrl + "'");
+
+ const requestBody: {
+ nonce: string;
+ token?: string;
+ } = {
+ nonce: proposal.noncePub,
+ };
+ if (proposal.claimToken) {
+ requestBody.token = proposal.claimToken;
+ }
+
+ const httpResponse = await wex.http.fetch(orderClaimUrl, {
+ method: "POST",
+ body: requestBody,
+ cancellationToken: wex.cancellationToken,
+ });
+ const r = await readSuccessResponseJsonOrErrorCode(
+ httpResponse,
+ codecForProposal(),
+ );
+ if (r.isError) {
+ switch (r.talerErrorResponse.code) {
+ case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
+ {
+ orderId: proposal.orderId,
+ claimUrl: orderClaimUrl,
+ },
+ "order already claimed (likely by other wallet)",
+ );
+ default:
+ throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
+ }
+ }
+ const proposalResp = r.response;
+
+ // The proposalResp contains the contract terms as raw JSON,
+ // as the code to parse them doesn't necessarily round-trip.
+ // We need this raw JSON to compute the contract terms hash.
+
+ // FIXME: Do better error handling, check if the
+ // contract terms have all their forgettable information still
+ // present. The wallet should never accept contract terms
+ // with missing information from the merchant.
+
+ const isWellFormed = ContractTermsUtil.validateForgettable(
+ proposalResp.contract_terms,
+ );
+
+ if (!isWellFormed) {
+ logger.trace(
+ `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
+ );
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ "validation for well-formedness failed",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ proposalResp.contract_terms,
+ );
+
+ logger.info(`Contract terms hash: ${contractTermsHash}`);
+
+ let parsedContractTerms: MerchantContractTerms;
+
+ try {
+ parsedContractTerms = codecForMerchantContractTerms().decode(
+ proposalResp.contract_terms,
+ );
+ } catch (e) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ `schema validation failed: ${e}`,
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const sigValid = await wex.cryptoApi.isValidContractTermsSignature({
+ contractTermsHash,
+ merchantPub: parsedContractTerms.merchant_pub,
+ sig: proposalResp.sig,
+ });
+
+ if (!sigValid) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
+ {
+ merchantPub: parsedContractTerms.merchant_pub,
+ orderId: parsedContractTerms.order_id,
+ },
+ "merchant's signature on contract terms is invalid",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const fulfillmentUrl = parsedContractTerms.fulfillment_url;
+
+ const baseUrlForDownload = proposal.merchantBaseUrl;
+ const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
+
+ if (baseUrlForDownload !== baseUrlFromContractTerms) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
+ {
+ baseUrlForDownload,
+ baseUrlFromContractTerms,
+ },
+ "merchant base URL mismatch",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractData = extractContractData(
+ parsedContractTerms,
+ contractTermsHash,
+ proposalResp.sig,
+ );
+
+ logger.trace(`extracted contract data: ${j2s(contractData)}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases", "contractTerms"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.download = {
+ contractTermsHash,
+ contractTermsMerchantSig: contractData.merchantSig,
+ currency: Amounts.currencyOf(contractData.amount),
+ fulfillmentUrl: contractData.fulfillmentUrl,
+ };
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: proposalResp.contract_terms,
+ });
+ const isResourceFulfillmentUrl =
+ fulfillmentUrl &&
+ (fulfillmentUrl.startsWith("http://") ||
+ fulfillmentUrl.startsWith("https://"));
+ let otherPurchase: PurchaseRecord | undefined;
+ if (isResourceFulfillmentUrl) {
+ otherPurchase =
+ await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
+ }
+ // FIXME: Adjust this to account for refunds, don't count as repurchase
+ // if original order is refunded.
+ if (
+ otherPurchase &&
+ (otherPurchase.purchaseStatus == PurchaseStatus.Done ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay)
+ ) {
+ logger.warn("repurchase detected");
+ p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
+ p.repurchaseProposalId = otherPurchase.proposalId;
+ await tx.purchases.put(p);
+ } else {
+ p.purchaseStatus = p.shared
+ ? PurchaseStatus.DialogShared
+ : PurchaseStatus.DialogProposed;
+ await tx.purchases.put(p);
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.progress();
+}
+
+/**
+ * Create a new purchase transaction if necessary. If a purchase
+ * record for the provided arguments already exists,
+ * return the old proposal ID.
+ */
+async function createOrReusePurchase(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string | undefined,
+ claimToken: string | undefined,
+ noncePriv: string | undefined,
+): Promise<string> {
+ const oldProposals = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.getAll([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ },
+ );
+
+ const oldProposal = oldProposals.find((p) => {
+ return (
+ p.downloadSessionId === sessionId &&
+ (!noncePriv || p.noncePriv === noncePriv) &&
+ p.claimToken === claimToken
+ );
+ });
+ // If we have already claimed this proposal with the same sessionId
+ // nonce and claim token, reuse it. */
+ if (
+ oldProposal &&
+ oldProposal.downloadSessionId === sessionId &&
+ (!noncePriv || oldProposal.noncePriv === noncePriv) &&
+ oldProposal.claimToken === claimToken
+ ) {
+ logger.info(
+ `Found old proposal (status=${
+ PurchaseStatus[oldProposal.purchaseStatus]
+ }) for order ${orderId} at ${merchantBaseUrl}`,
+ );
+ if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
+ const download = await expectProposalDownload(wex, oldProposal);
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+ logger.info(`old proposal paid: ${paid}`);
+ if (paid) {
+ // if this transaction was shared and the order is paid then it
+ // means that another wallet already paid the proposal
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(oldProposal.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: oldProposal.proposalId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+ }
+ return oldProposal.proposalId;
+ }
+
+ let noncePair: EddsaKeypair;
+ let shared = false;
+ if (noncePriv) {
+ shared = true;
+ noncePair = {
+ priv: noncePriv,
+ pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
+ };
+ } else {
+ noncePair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
+ const { priv, pub } = noncePair;
+ const proposalId = encodeCrock(getRandomBytes(32));
+
+ const proposalRecord: PurchaseRecord = {
+ download: undefined,
+ noncePriv: priv,
+ noncePub: pub,
+ claimToken,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
+ repurchaseProposalId: undefined,
+ downloadSessionId: sessionId,
+ autoRefundDeadline: undefined,
+ lastSessionId: undefined,
+ merchantPaySig: undefined,
+ payInfo: undefined,
+ refundAmountAwaiting: undefined,
+ timestampAccept: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ timestampLastRefundStatus: undefined,
+ pendingRemovedCoinPubs: undefined,
+ posConfirmation: undefined,
+ shared: shared,
+ };
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ await tx.purchases.put(proposalRecord);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePayMerchantTransactionState(proposalRecord);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ return proposalId;
+}
+
+async function storeFirstPaySuccess(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId: string | undefined,
+ payResponse: MerchantPayResponse,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (!isFirst) {
+ logger.warn("payment success already stored");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
+ purchase.lastSessionId = sessionId;
+ purchase.merchantPaySig = payResponse.sig;
+ purchase.posConfirmation = payResponse.pos_confirmation;
+ const dl = purchase.download;
+ checkDbInvariant(!!dl);
+ const contractTermsRecord = await tx.contractTerms.get(
+ dl.contractTermsHash,
+ );
+ checkDbInvariant(!!contractTermsRecord);
+ const contractData = extractContractData(
+ contractTermsRecord.contractTermsRaw,
+ dl.contractTermsHash,
+ dl.contractTermsMerchantSig,
+ );
+ const protoAr = contractData.autoRefund;
+ if (protoAr) {
+ const ar = Duration.fromTalerProtocolDuration(protoAr);
+ logger.info("auto_refund present");
+ purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ purchase.autoRefundDeadline = timestampProtocolToDb(
+ AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
+ ),
+ );
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+async function storePayReplaySuccess(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (isFirst) {
+ throw Error("invalid payment state");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (
+ purchase.purchaseStatus === PurchaseStatus.PendingPaying ||
+ purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
+ ) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.lastSessionId = sessionId;
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+/**
+ * Handle a 409 Conflict response from the merchant.
+ *
+ * We do this by going through the coin history provided by the exchange and
+ * (1) verifying the signatures from the exchange
+ * (2) adjusting the remaining coin value and refreshing it
+ * (3) re-do coin selection with the bad coin removed
+ */
+async function handleInsufficientFunds(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ logger.trace("handling insufficient funds, trying to re-select coins");
+
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!proposal) {
+ return;
+ }
+
+ logger.trace(`got error details: ${j2s(err)}`);
+
+ const exchangeReply = (err as any).exchange_reply;
+ if (
+ exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
+ ) {
+ // FIXME: set as failed
+ if (logger.shouldLogTrace()) {
+ logger.trace("got exchange error reply (see below)");
+ logger.trace(j2s(exchangeReply));
+ }
+ throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
+ }
+
+ const brokenCoinPub = (exchangeReply as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ throw new TalerProtocolViolationError();
+ }
+
+ const { contractData } = await expectProposalDownload(wex, proposal);
+
+ const prevPayCoins: PreviousPayCoins = [];
+
+ const payInfo = proposal.payInfo;
+ if (!payInfo) {
+ return;
+ }
+
+ const payCoinSelection = payInfo.payCoinSelection;
+ if (!payCoinSelection) {
+ return;
+ }
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const contrib = payCoinSelection.coinContributions[i];
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ });
+ }
+ },
+ );
+
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+
+ switch (res.type) {
+ case "failure":
+ logger.trace("insufficient funds for coin re-selection");
+ return;
+ case "prospective":
+ return;
+ case "success":
+ break;
+ default:
+ assertUnreachable(res);
+ }
+
+ logger.trace("re-selected coins");
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ return;
+ }
+ // Convert to DB format
+ payInfo.payCoinSelection = {
+ coinContributions: res.coinSel.coins.map((x) => x.contribution),
+ coinPubs: res.coinSel.coins.map((x) => x.coinPub),
+ };
+ payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ await tx.purchases.put(p);
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:proposal:${p.proposalId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: payInfo.payCoinSelection.coinPubs,
+ contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ }),
+ });
+}
+
+// FIXME: Should take a transaction ID instead of a proposal ID
+// FIXME: Does way more than checking the payment
+// FIXME: Should return immediately.
+async function checkPaymentByProposalId(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId?: string,
+): Promise<PreparePayResult> {
+ let proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!proposal) {
+ throw Error(`could not get proposal ${proposalId}`);
+ }
+ if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) {
+ const existingProposalId = proposal.repurchaseProposalId;
+ if (existingProposalId) {
+ logger.trace("using existing purchase for same product");
+ const oldProposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(existingProposalId);
+ },
+ );
+ if (oldProposal) {
+ proposal = oldProposal;
+ }
+ }
+ }
+ const d = await expectProposalDownload(wex, proposal);
+ const contractData = d.contractData;
+ const merchantSig = d.contractData.merchantSig;
+ if (!merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const transactionId = ctx.transactionId;
+
+ const talerUri = stringifyTalerUri({
+ type: TalerUriAction.Pay,
+ merchantBaseUrl: proposal.merchantBaseUrl,
+ orderId: proposal.orderId,
+ sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
+ claimToken: proposal.claimToken,
+ });
+
+ // First check if we already paid for it.
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (
+ !purchase ||
+ purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
+ purchase.purchaseStatus === PurchaseStatus.DialogShared
+ ) {
+ const instructedAmount = Amounts.parseOrThrow(contractData.amount);
+ // If not already paid, check if we could pay for it.
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ contractTermsAmount: instructedAmount,
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ restrictWireMethod: contractData.wireMethod,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (res.type) {
+ case "failure": {
+ logger.info("not allowing payment, insufficient coins");
+ logger.info(
+ `insufficient balance details: ${j2s(
+ res.insufficientBalanceDetails,
+ )}`,
+ );
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ transactionId,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ talerUri,
+ balanceDetails: res.insufficientBalanceDetails,
+ };
+ }
+ case "prospective":
+ coins = res.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = res.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(res);
+ }
+
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
+ logger.trace("costInfo", totalCost);
+ logger.trace("coinsForPayment", res);
+
+ return {
+ status: PreparePayResultType.PaymentPossible,
+ contractTerms: d.contractTermsRaw,
+ transactionId,
+ proposalId: proposal.proposalId,
+ amountEffective: Amounts.stringify(totalCost),
+ amountRaw: Amounts.stringify(instructedAmount),
+ contractTermsHash: d.contractData.contractTermsHash,
+ talerUri,
+ };
+ }
+
+ if (
+ purchase.purchaseStatus === PurchaseStatus.Done &&
+ purchase.lastSessionId !== sessionId
+ ) {
+ logger.trace(
+ "automatically re-submitting payment with different session ID",
+ );
+ logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.lastSessionId = sessionId;
+ p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ await tx.purchases.put(p);
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Consider changing the API here so that we don't have to
+ // wait inline for the repurchase.
+
+ await waitPaymentResult(wex, proposalId, sessionId);
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: true,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else if (!purchase.timestampFirstSuccessfulPay) {
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else {
+ const paid =
+ purchase.purchaseStatus === PurchaseStatus.Done ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ ...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ }
+}
+
+export async function getContractTermsDetails(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<WalletContractData> {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+
+ return d.contractData;
+}
+
+/**
+ * Check if a payment for the given taler://pay/ URI is possible.
+ *
+ * If the payment is possible, the signature are already generated but not
+ * yet send to the merchant.
+ */
+export async function preparePayForUri(
+ wex: WalletExecutionContext,
+ talerPayUri: string,
+): Promise<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(
+ wex,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ uriResult.claimToken,
+ uriResult.noncePriv,
+ );
+
+ await waitProposalDownloaded(wex, proposalId);
+
+ return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId);
+}
+
+/**
+ * Wait until a proposal is at least downloaded.
+ */
+async function waitProposalDownloaded(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ // FIXME: This doesn't support cancellation yet
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const payNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ payNotifFlag.raise();
+ }
+ });
+
+ try {
+ await internalWaitProposalDownloaded(ctx, payNotifFlag);
+ logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function internalWaitProposalDownloaded(
+ ctx: PayMerchantTransactionContext,
+ payNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ return {
+ purchase: await tx.purchases.get(ctx.proposalId),
+ retryInfo: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+ if (!purchase) {
+ throw Error("purchase does not exist anymore");
+ }
+ if (purchase.download) {
+ return;
+ }
+ if (retryInfo) {
+ if (retryInfo.lastError) {
+ throw TalerError.fromUncheckedDetail(retryInfo.lastError);
+ } else {
+ throw Error("transient error while waiting for proposal download");
+ }
+ }
+ await payNotifFlag.wait();
+ payNotifFlag.reset();
+ }
+}
+
+export async function preparePayForTemplate(
+ wex: WalletExecutionContext,
+ req: PreparePayTemplateRequest,
+): Promise<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 wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: templateDetails,
+ });
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpReq,
+ codecForMerchantPostOrderResponse(),
+ );
+
+ const payUri = stringifyPayUri({
+ merchantBaseUrl: parsedUri.merchantBaseUrl,
+ orderId: resp.order_id,
+ sessionId: "",
+ claimToken: resp.token,
+ });
+
+ return await preparePayForUri(wex, payUri);
+}
+
+/**
+ * Generate deposit permissions for a purchase.
+ *
+ * Accesses the database and the crypto worker.
+ */
+export async function generateDepositPermissions(
+ wex: WalletExecutionContext,
+ payCoinSel: DbCoinSelection,
+ contractData: WalletContractData,
+): Promise<CoinDepositPermission[]> {
+ const depositPermissions: CoinDepositPermission[] = [];
+ const coinWithDenom: Array<{
+ coin: CoinRecord;
+ denom: DenominationRecord;
+ }> = [];
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't pay, denomination of allocated coin not found anymore",
+ );
+ }
+ coinWithDenom.push({ coin, denom });
+ }
+ },
+ );
+
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const { coin, denom } = coinWithDenom[i];
+ let wireInfoHash: string;
+ wireInfoHash = contractData.wireInfoHash;
+ const dp = await wex.cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash: contractData.contractTermsHash,
+ denomPubHash: coin.denomPubHash,
+ denomKeyType: denom.denomPub.cipher,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ merchantPub: contractData.merchantPub,
+ refundDeadline: contractData.refundDeadline,
+ spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
+ timestamp: contractData.timestamp,
+ wireInfoHash,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ depositPermissions.push(dp);
+ }
+ return depositPermissions;
+}
+
+async function internalWaitPaymentResult(
+ ctx: PayMerchantTransactionContext,
+ purchaseNotifFlag: AsyncFlag,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ while (true) {
+ const txRes = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(ctx.proposalId);
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+ return { purchase, retryRecord };
+ },
+ );
+
+ if (!txRes.purchase) {
+ throw Error("purchase gone");
+ }
+
+ const purchase = txRes.purchase;
+
+ logger.info(
+ `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
+ );
+
+ const d = await expectProposalDownload(ctx.wex, purchase);
+
+ if (txRes.purchase.timestampFirstSuccessfulPay) {
+ if (
+ waitSessionId == null ||
+ txRes.purchase.lastSessionId === waitSessionId
+ ) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+ }
+
+ if (txRes.retryRecord) {
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: txRes.retryRecord.lastError,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ await purchaseNotifFlag.wait();
+ purchaseNotifFlag.reset();
+ }
+}
+
+/**
+ * Wait until either:
+ * a) the payment succeeded (if provided under the {@param waitSessionId}), or
+ * b) the attempt to pay failed (merchant unavailable, etc.)
+ */
+async function waitPaymentResult(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ // FIXME: We don't support cancelletion yet!
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const purchaseNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our purchase.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ purchaseNotifFlag.raise();
+ }
+ });
+
+ try {
+ logger.info(`waiting for first payment success on ${ctx.transactionId}`);
+ const res = await internalWaitPaymentResult(
+ ctx,
+ purchaseNotifFlag,
+ waitSessionId,
+ );
+ logger.info(
+ `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
+ );
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+/**
+ * Confirm payment for a proposal previously claimed by the wallet.
+ */
+export async function confirmPay(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ sessionIdOverride?: string,
+ forcedCoinSel?: ForcedCoinSel,
+): Promise<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 wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ const existingPurchase = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (
+ purchase &&
+ sessionIdOverride !== undefined &&
+ sessionIdOverride != purchase.lastSessionId
+ ) {
+ logger.trace(`changing session ID to ${sessionIdOverride}`);
+ purchase.lastSessionId = sessionIdOverride;
+ if (purchase.purchaseStatus === PurchaseStatus.Done) {
+ purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ }
+ await tx.purchases.put(purchase);
+ }
+ return purchase;
+ },
+ );
+
+ if (existingPurchase && existingPurchase.payInfo) {
+ logger.trace("confirmPay: submitting payment for existing purchase");
+ const ctx = new PayMerchantTransactionContext(
+ wex,
+ existingPurchase.proposalId,
+ );
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ return waitPaymentResult(wex, proposalId);
+ }
+
+ logger.trace("confirmPay: purchase record does not exist yet");
+
+ const contractData = d.contractData;
+
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ forcedSelection: forcedCoinSel,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ coins = selectCoinsResult.result.prospectiveCoins;
+ break;
+ }
+ case "success":
+ coins = selectCoinsResult.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
+
+ let sessionId: string | undefined;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+
+ logger.trace(
+ `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
+ );
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposal.proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ };
+ if (selectCoinsResult.type === "success") {
+ p.payInfo.payCoinSelection = {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ }
+ p.lastSessionId = sessionId;
+ p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+ if (p.payInfo.payCoinSelection) {
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: sel.coinPubs,
+ contributions: sel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ }
+
+ break;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ // In case we're sharing the payment and we're long-polling
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+
+ // Wait until we have completed the first attempt to pay.
+ return waitPaymentResult(wex, proposalId);
+}
+
+export async function processPurchase(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingDownloadingProposal:
+ return processDownloadProposal(wex, proposalId);
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ return processPurchasePay(wex, proposalId);
+ case PurchaseStatus.PendingQueryingRefund:
+ return processPurchaseQueryRefund(wex, purchase);
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return processPurchaseAutoRefund(wex, purchase);
+ case PurchaseStatus.AbortingWithRefund:
+ return processPurchaseAbortingRefund(wex, purchase);
+ case PurchaseStatus.PendingAcceptRefund:
+ return processPurchaseAcceptRefund(wex, purchase);
+ case PurchaseStatus.DialogShared:
+ return processPurchaseDialogShared(wex, purchase);
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Done:
+ case PurchaseStatus.DoneRepurchaseDetected:
+ case PurchaseStatus.DialogProposed:
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedIncompletePayment:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ case PurchaseStatus.SuspendedPaying:
+ case PurchaseStatus.SuspendedPayingReplay:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ case PurchaseStatus.FailedAbort:
+ case PurchaseStatus.FailedPaidByOther:
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(purchase.purchaseStatus);
+ }
+}
+
+async function processPurchasePay(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ break;
+ default:
+ return TaskRunResult.finished();
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const sessionId = purchase.lastSessionId;
+
+ logger.trace(`paying with session ID ${sessionId}`);
+ const payInfo = purchase.payInfo;
+ checkDbInvariant(!!payInfo, "payInfo");
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ if (purchase.shared) {
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
+ orderId: purchase.orderId,
+ fulfillmentUrl: download.contractData.fulfillmentUrl,
+ }),
+ };
+ }
+ }
+
+ const contractData = download.contractData;
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ if (!payInfo.payCoinSelection) {
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ throw Error("insufficient balance (pending refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ selectCoinsResult.coinSel.coins,
+ );
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return false;
+ }
+ if (p.payInfo?.payCoinSelection) {
+ return false;
+ }
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ payCoinSelection: {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ },
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ contributions: selectCoinsResult.coinSel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ return true;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ return false;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (!purchase.merchantPaySig) {
+ const payUrl = new URL(
+ `orders/${download.contractData.orderId}/pay`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+
+ let depositPermissions: CoinDepositPermission[];
+ // FIXME: Cache!
+ depositPermissions = await generateDepositPermissions(
+ wex,
+ payInfo.payCoinSelection,
+ download.contractData,
+ );
+
+ const reqBody = {
+ coins: depositPermissions,
+ session_id: purchase.lastSessionId,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`making pay request ... ${j2s(reqBody)}`);
+ }
+
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getPayRequestTimeout(purchase),
+ cancellationToken: wex.cancellationToken,
+ }),
+ );
+
+ logger.trace(`got resp ${JSON.stringify(resp)}`);
+
+ if (resp.status >= 500 && resp.status <= 599) {
+ const errDetails = await readUnexpectedResponseDetails(resp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ {
+ requestError: errDetails,
+ },
+ ),
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ const err = await readTalerErrorResponse(resp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
+ ) {
+ // Do this in the background, as it might take some time
+ // FIXME: Why? We're already in a (background) task!
+ handleInsufficientFunds(wex, proposalId, err).catch(async (e) => {
+ logger.error("handling insufficient funds failed");
+ logger.error(`${e.toString()}`);
+ });
+
+ // FIXME: Should we really consider this to be pending?
+
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (resp.status >= 400 && resp.status <= 499) {
+ logger.trace("got generic 4xx from merchant");
+ const err = await readTalerErrorResponse(resp);
+ if (logger.shouldLogTrace()) {
+ logger.trace(`error body: ${j2s(err)}`);
+ }
+ throwUnexpectedRequestError(resp, err);
+ }
+
+ const merchantResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPayResponse(),
+ );
+
+ logger.trace("got success from pay URL", merchantResp);
+
+ const merchantPub = download.contractData.merchantPub;
+ const { valid } = await wex.cryptoApi.isValidPaymentSignature({
+ contractHash: download.contractData.contractTermsHash,
+ merchantPub,
+ sig: merchantResp.sig,
+ });
+
+ if (!valid) {
+ logger.error("merchant payment signature invalid");
+ // FIXME: properly display error
+ throw Error("merchant payment signature invalid");
+ }
+
+ await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
+ } else {
+ const payAgainUrl = new URL(
+ `orders/${download.contractData.orderId}/paid`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+ const reqBody = {
+ sig: purchase.merchantPaySig,
+ h_contract: download.contractData.contractTermsHash,
+ session_id: sessionId ?? "",
+ };
+ logger.trace(`/paid request body: ${j2s(reqBody)}`);
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payAgainUrl, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ }),
+ );
+ logger.trace(`/paid response status: ${resp.status}`);
+ if (
+ resp.status !== HttpStatusCode.NoContent &&
+ resp.status != HttpStatusCode.Ok
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ getHttpResponseErrorDetails(resp),
+ "/paid failed",
+ );
+ }
+ await storePayReplaySuccess(wex, proposalId, sessionId);
+ }
+
+ return TaskRunResult.progress();
+}
+
+export async function refuseProposal(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const proposal = await tx.purchases.get(proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return undefined;
+ }
+ if (
+ proposal.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ proposal.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(proposal);
+ proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ const newTxState = computePayMerchantTransactionState(proposal);
+ await tx.purchases.put(proposal);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+const transitionSuspend: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.PendingDownloadingProposal]: {
+ next: PurchaseStatus.SuspendedDownloadingProposal,
+ },
+ [PurchaseStatus.AbortingWithRefund]: {
+ next: PurchaseStatus.SuspendedAbortingWithRefund,
+ },
+ [PurchaseStatus.PendingPaying]: {
+ next: PurchaseStatus.SuspendedPaying,
+ },
+ [PurchaseStatus.PendingPayingReplay]: {
+ next: PurchaseStatus.SuspendedPayingReplay,
+ },
+ [PurchaseStatus.PendingQueryingAutoRefund]: {
+ next: PurchaseStatus.SuspendedQueryingAutoRefund,
+ },
+};
+
+const transitionResume: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.SuspendedDownloadingProposal]: {
+ next: PurchaseStatus.PendingDownloadingProposal,
+ },
+ [PurchaseStatus.SuspendedAbortingWithRefund]: {
+ next: PurchaseStatus.AbortingWithRefund,
+ },
+ [PurchaseStatus.SuspendedPaying]: {
+ next: PurchaseStatus.PendingPaying,
+ },
+ [PurchaseStatus.SuspendedPayingReplay]: {
+ next: PurchaseStatus.PendingPayingReplay,
+ },
+ [PurchaseStatus.SuspendedQueryingAutoRefund]: {
+ next: PurchaseStatus.PendingQueryingAutoRefund,
+ },
+};
+
+export function computePayMerchantTransactionState(
+ purchaseRecord: PurchaseRecord,
+): TransactionState {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.PendingPaying:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.PendingPayingReplay:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.PendingQueryingRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.PendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.SuspendedPaying:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.SuspendedPayingReplay:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.SuspendedQueryingRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ // Suspended Aborting States
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ case PurchaseStatus.DialogShared:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Refused,
+ };
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ };
+ case PurchaseStatus.AbortedIncompletePayment:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.FailedClaim:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.FailedAbort:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case PurchaseStatus.FailedPaidByOther:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.PaidByOther,
+ };
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
+ }
+}
+
+export function computePayMerchantTransactionActions(
+ purchaseRecord: PurchaseRecord,
+): TransactionAction[] {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPaying:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPaying:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Resume];
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return [];
+ case PurchaseStatus.DialogShared:
+ return [];
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.Done:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.AbortedIncompletePayment:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedClaim:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedAbort:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedPaidByOther:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
+ }
+}
+
+export async function sharePayment(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ orderId: string,
+): Promise<SharePaymentResult> {
+ const result = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (
+ p.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ p.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ // FIXME: purchase can be shared before being paid
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
+ p.purchaseStatus = PurchaseStatus.DialogShared;
+ p.shared = true;
+ await tx.purchases.put(p);
+ }
+
+ const newTxState = computePayMerchantTransactionState(p);
+
+ return {
+ proposalId: p.proposalId,
+ nonce: p.noncePriv,
+ session: p.lastSessionId ?? p.downloadSessionId,
+ token: p.claimToken,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (result === undefined) {
+ throw Error("This purchase can't be shared");
+ }
+
+ const ctx = new PayMerchantTransactionContext(wex, result.proposalId);
+
+ notifyTransition(wex, ctx.transactionId, result.transitionInfo);
+
+ // schedule a task to watch for the status
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ const privatePayUri = stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId: result.session ?? "",
+ noncePriv: result.nonce,
+ claimToken: result.token,
+ });
+
+ return { privatePayUri };
+}
+
+async function checkIfOrderIsAlreadyPaid(
+ wex: WalletExecutionContext,
+ contract: WalletContractData,
+ doLongPolling: boolean,
+) {
+ const requestUrl = new URL(
+ `orders/${contract.orderId}`,
+ contract.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
+
+ if (doLongPolling) {
+ requestUrl.searchParams.set("timeout_ms", "30000");
+ }
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ resp.status === HttpStatusCode.Ok ||
+ resp.status === HttpStatusCode.Accepted ||
+ resp.status === HttpStatusCode.Found
+ ) {
+ return true;
+ } else if (resp.status === HttpStatusCode.PaymentRequired) {
+ return false;
+ }
+ // forbidden, not found, not acceptable
+ throw Error(`this order cant be paid: ${resp.status}`);
+}
+
+async function processPurchaseDialogShared(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing dialog-shared for proposal ${proposalId}`);
+ const download = await expectProposalDownload(wex, purchase);
+
+ if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
+ return TaskRunResult.finished();
+ }
+
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ true,
+ );
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processPurchaseAutoRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing auto-refund for proposal ${proposalId}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ const noAutoRefundOrExpired =
+ !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(purchase.autoRefundDeadline),
+ ),
+ );
+
+ const totalKnownRefund = await wex.db.runReadOnlyTx(
+ { storeNames: ["refundGroups"] },
+ async (tx) => {
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+ const am = Amounts.parseOrThrow(download.contractData.amount);
+ return refunds.reduce((prev, cur) => {
+ if (
+ cur.status === RefundGroupStatus.Done ||
+ cur.status === RefundGroupStatus.Pending
+ ) {
+ return Amounts.add(prev, cur.amountEffective).amount;
+ }
+ return prev;
+ }, Amounts.zeroOfAmount(am));
+ },
+ );
+
+ const refundedIsLessThanPrice =
+ Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1;
+ const nothingMoreToRefund = !refundedIsLessThanPrice;
+
+ if (noAutoRefundOrExpired || nothingMoreToRefund) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ requestUrl.searchParams.set("timeout_ms", "10000");
+ requestUrl.searchParams.set("await_refund_obtained", "yes");
+ requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ // FIXME: Check other status codes!
+
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+
+ return TaskRunResult.longpollReturnedPending();
+}
+
+async function processPurchaseAbortingRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ const download = await expectProposalDownload(wex, purchase);
+ logger.trace(`processing aborting-refund for proposal ${proposalId}`);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
+ }
+
+ await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+ });
+
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
+
+ logger.trace(`making order abort request to ${requestUrl.href}`);
+
+ const abortHttpResp = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: abortReq,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (abortHttpResp.status === HttpStatusCode.NotFound) {
+ const err = await readTalerErrorResponse(abortHttpResp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+ ) {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await ctx.transition(async (rec) => {
+ if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
+ rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ return TransitionResultType.Transition;
+ }
+ return TransitionResultType.Stay;
+ });
+ }
+ }
+
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ abortHttpResp,
+ codecForAbortResponse(),
+ );
+
+ const refunds: MerchantCoinRefundStatus[] = [];
+
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund);
+}
+
+async function processPurchaseQueryRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing query-refund for proposal ${proposalId}`);
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ if (!orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else {
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+}
+
+async function processPurchaseAcceptRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const download = await expectProposalDownload(wex, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ logger.trace(`making refund request to ${requestUrl.href}`);
+
+ const request = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: {
+ h_contract: download.contractData.contractTermsHash,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForWalletRefundResponse(),
+ );
+ return await storeRefunds(
+ wex,
+ purchase,
+ refundResponse.refunds,
+ RefundReason.AbortRefund,
+ );
+}
+
+export async function startRefundQueryForUri(
+ wex: WalletExecutionContext,
+ talerUri: string,
+): Promise<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 wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ parsedUri.merchantBaseUrl,
+ parsedUri.orderId,
+ ]);
+ },
+ );
+ if (!purchaseRecord) {
+ logger.error(
+ `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
+ );
+ throw Error("no purchase found, can't refund");
+ }
+ const proposalId = purchaseRecord.proposalId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ await startQueryRefund(wex, proposalId);
+ return {
+ transactionId,
+ };
+}
+
+export async function startQueryRefund(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.warn(`purchase ${proposalId} does not exist anymore`);
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.Done) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function computeRefreshRequest(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
+ items: RefundItemRecord[],
+): Promise<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 getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+}
+
+/**
+ * Compute the refund item status based on the merchant's response.
+ */
+function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
+}
+
+/**
+ * Store refunds, possibly creating a new refund group.
+ */
+async function storeRefunds(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<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(wex, purchase);
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ const result = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
+ const myPurchase = await tx.purchases.get(purchase.proposalId);
+ if (!myPurchase) {
+ logger.warn("purchase group not found anymore");
+ return;
+ }
+ let isAborting: boolean;
+ switch (myPurchase.purchaseStatus) {
+ case PurchaseStatus.PendingAcceptRefund:
+ isAborting = false;
+ break;
+ case PurchaseStatus.AbortingWithRefund:
+ isAborting = true;
+ break;
+ default:
+ logger.warn("wrong state, not accepting refund");
+ return;
+ }
+
+ let newGroup: RefundGroupRecord | undefined = undefined;
+ // Pending, but not part of an aborted refund group.
+ let numPendingItemsTotal = 0;
+ const newGroupRefunds: RefundItemRecord[] = [];
+
+ for (const rf of refunds) {
+ const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
+ rf.coin_pub,
+ rf.rtransaction_id,
+ ]);
+ if (oldItem) {
+ logger.info("already have refund in database");
+ if (oldItem.status === RefundItemStatus.Done) {
+ continue;
+ }
+ if (rf.type === "success") {
+ oldItem.status = RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ oldItem.status = RefundItemStatus.Pending;
+ numPendingItemsTotal += 1;
+ } else {
+ oldItem.status = RefundItemStatus.Failed;
+ }
+ }
+ await tx.refundItems.put(oldItem);
+ } else {
+ // Put refund item into a new group!
+ if (!newGroup) {
+ newGroup = {
+ proposalId: purchase.proposalId,
+ refundGroupId: newRefundGroupId,
+ status: RefundGroupStatus.Pending,
+ timestampCreated: timestampPreciseToDb(now),
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ ),
+ amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ const status: RefundItemStatus = getItemStatus(rf);
+ const newItem: RefundItemRecord = {
+ coinPub: rf.coin_pub,
+ executionTime: timestampProtocolToDb(rf.execution_time),
+ obtainedTime: timestampPreciseToDb(now),
+ refundAmount: rf.refund_amount,
+ refundGroupId: newGroup.refundGroupId,
+ rtxid: rf.rtransaction_id,
+ status,
+ };
+ if (status === RefundItemStatus.Pending) {
+ numPendingItemsTotal += 1;
+ }
+ newGroupRefunds.push(newItem);
+ await tx.refundItems.put(newItem);
+ }
+ }
+
+ // Now that we know all the refunds for the new refund group,
+ // we can compute the raw/effective amounts.
+ if (newGroup) {
+ const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
+ const refreshCoins = await computeRefreshRequest(
+ wex,
+ tx,
+ newGroupRefunds,
+ );
+ const outInfo = await calculateRefreshOutput(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ );
+ newGroup.amountEffective = Amounts.stringify(
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ );
+ newGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(currency, amountsRaw).amount,
+ );
+ await tx.refundGroups.put(newGroup);
+ }
+
+ const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
+ myPurchase.proposalId,
+ );
+
+ for (const refundGroup of refundGroups) {
+ switch (refundGroup.status) {
+ case RefundGroupStatus.Aborted:
+ case RefundGroupStatus.Expired:
+ case RefundGroupStatus.Failed:
+ case RefundGroupStatus.Done:
+ continue;
+ case RefundGroupStatus.Pending:
+ break;
+ default:
+ assertUnreachable(refundGroup.status);
+ }
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
+ refundGroup.refundGroupId,
+ ]);
+ let numPending = 0;
+ let numFailed = 0;
+ for (const item of items) {
+ if (item.status === RefundItemStatus.Pending) {
+ numPending++;
+ }
+ if (item.status === RefundItemStatus.Failed) {
+ numFailed++;
+ }
+ }
+ if (numPending === 0) {
+ // We're done for this refund group!
+ if (numFailed === 0) {
+ refundGroup.status = RefundGroupStatus.Done;
+ } else {
+ refundGroup.status = RefundGroupStatus.Failed;
+ }
+ await tx.refundGroups.put(refundGroup);
+ const refreshCoins = await computeRefreshRequest(wex, tx, items);
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(download.contractData.amount),
+ refreshCoins,
+ RefreshReason.Refund,
+ // Since refunds are really just pseudo-transactions,
+ // the originating transaction for the refresh is the payment transaction.
+ constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: myPurchase.proposalId,
+ }),
+ );
+ }
+ }
+
+ const oldTxState = computePayMerchantTransactionState(myPurchase);
+
+ const shouldCheckAutoRefund =
+ myPurchase.autoRefundDeadline &&
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(myPurchase.autoRefundDeadline),
+ ),
+ );
+
+ if (numPendingItemsTotal === 0) {
+ if (isAborting) {
+ myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else if (shouldCheckAutoRefund) {
+ myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ } else {
+ myPurchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ myPurchase.refundAmountAwaiting = undefined;
+ }
+ await tx.purchases.put(myPurchase);
+ const newTxState = computePayMerchantTransactionState(myPurchase);
+
+ return {
+ numPendingItemsTotal,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (!result) {
+ return TaskRunResult.finished();
+ }
+
+ notifyTransition(wex, transactionId, result.transitionInfo);
+
+ if (result.numPendingItemsTotal > 0) {
+ return TaskRunResult.backoff();
+ } else {
+ return TaskRunResult.progress();
+ }
+}
+
+export function computeRefundTransactionState(
+ refundGroupRecord: RefundGroupRecord,
+): TransactionState {
+ switch (refundGroupRecord.status) {
+ case RefundGroupStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case RefundGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefundGroupStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefundGroupStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefundGroupStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}