summaryrefslogtreecommitdiff
path: root/src/operations/pay.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-08-03 13:00:48 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-08-03 13:01:05 +0530
commitffd2a62c3f7df94365980302fef3bc3376b48182 (patch)
tree270af6f16b4cc7f5da2afdba55c8bc9dbea5eca5 /src/operations/pay.ts
parentaa481e42675fb7c4dcbbeec0ba1c61e1953b9596 (diff)
downloadwallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.gz
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.bz2
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.zip
modularize repo, use pnpm, improve typechecking
Diffstat (limited to 'src/operations/pay.ts')
-rw-r--r--src/operations/pay.ts1147
1 files changed, 0 insertions, 1147 deletions
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
deleted file mode 100644
index 9cbda5ba5..000000000
--- a/src/operations/pay.ts
+++ /dev/null
@@ -1,1147 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 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 { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
-import {
- CoinStatus,
- initRetryInfo,
- ProposalRecord,
- ProposalStatus,
- PurchaseRecord,
- Stores,
- updateRetryInfoTimeout,
- PayEventRecord,
- WalletContractData,
-} from "../types/dbTypes";
-import { NotificationType } from "../types/notifications";
-import {
- codecForProposal,
- codecForContractTerms,
- CoinDepositPermission,
- codecForMerchantPayResponse,
-} from "../types/talerTypes";
-import {
- ConfirmPayResult,
- OperationErrorDetails,
- PreparePayResult,
- RefreshReason,
- PreparePayResultType,
-} from "../types/walletTypes";
-import * as Amounts from "../util/amounts";
-import { AmountJson } from "../util/amounts";
-import { Logger } from "../util/logging";
-import { parsePayUri } from "../util/taleruri";
-import { guardOperationException, OperationFailedError } from "./errors";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
-import { InternalWalletState } from "./state";
-import { getTimestampNow, timestampAddDuration } from "../util/time";
-import { strcmp, canonicalJson } from "../util/helpers";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-import { TalerErrorCode } from "../TalerErrorCode";
-
-/**
- * Logger.
- */
-const logger = new Logger("pay.ts");
-
-/**
- * Result of selecting coins, contains the exchange, and selected
- * coins with their denomination.
- */
-export interface PayCoinSelection {
- /**
- * Amount requested by the merchant.
- */
- paymentAmount: AmountJson;
-
- /**
- * Public keys of the coins that were selected.
- */
- coinPubs: string[];
-
- /**
- * Amount that each coin contributes.
- */
- coinContributions: AmountJson[];
-
- /**
- * How much of the wire fees is the customer paying?
- */
- customerWireFees: AmountJson;
-
- /**
- * How much of the deposit fees is the customer paying?
- */
- customerDepositFees: AmountJson;
-}
-
-/**
- * Structure to describe a coin that is available to be
- * used in a payment.
- */
-export interface AvailableCoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- /**
- * Coin's denomination public key.
- */
- denomPub: string;
-
- /**
- * Amount still remaining (typically the full amount,
- * as coins are always refreshed after use.)
- */
- availableAmount: AmountJson;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-}
-
-export interface PayCostInfo {
- totalCost: AmountJson;
-}
-
-/**
- * Compute the total cost of a payment to the customer.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- */
-export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
-): Promise<PayCostInfo> {
- const costs = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denom = await ws.db.get(Stores.denominations, [
- coin.exchangeBaseUrl,
- coin.denomPub,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await ws.db
- .iterIndex(
- Stores.denominations.exchangeBaseUrlIndex,
- coin.exchangeBaseUrl,
- )
- .toArray();
- const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
- .amount;
- const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
- costs.push(pcs.coinContributions[i]);
- costs.push(refreshCost);
- }
- return {
- totalCost: Amounts.sum(costs).amount,
- };
-}
-
-/**
- * Given a list of available coins, select coins to spend under the merchant's
- * constraints.
- *
- * This function is only exported for the sake of unit tests.
- */
-export function selectPayCoins(
- acis: AvailableCoinInfo[],
- contractTermsAmount: AmountJson,
- customerWireFees: AmountJson,
- depositFeeLimit: AmountJson,
-): PayCoinSelection | undefined {
- if (acis.length === 0) {
- return undefined;
- }
- const coinPubs: string[] = [];
- const coinContributions: AmountJson[] = [];
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- acis.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPub, o2.denomPub),
- );
- const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees)
- .amount;
- const currency = paymentAmount.currency;
- let amountPayRemaining = paymentAmount;
- let amountDepositFeeLimitRemaining = depositFeeLimit;
- const customerDepositFees = Amounts.getZero(currency);
- for (const aci of acis) {
- // Don't use this coin if depositing it is more expensive than
- // the amount it would give the merchant.
- if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
- continue;
- }
- if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
- // We have spent enough!
- break;
- }
-
- // How much does the user spend on deposit fees for this coin?
- const depositFeeSpend = Amounts.sub(
- aci.feeDeposit,
- amountDepositFeeLimitRemaining,
- ).amount;
-
- if (Amounts.isZero(depositFeeSpend)) {
- // Fees are still covered by the merchant.
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- aci.feeDeposit,
- ).amount;
- } else {
- amountDepositFeeLimitRemaining = Amounts.getZero(currency);
- }
-
- let coinSpend: AmountJson;
- const amountActualAvailable = Amounts.sub(
- aci.availableAmount,
- depositFeeSpend,
- ).amount;
-
- if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
- // Partial spending, as the coin is worth more than the remaining
- // amount to pay.
- coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
- // Make sure we contribute at least the deposit fee, otherwise
- // contributing this coin would cause a loss for the merchant.
- if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) {
- coinSpend = aci.feeDeposit;
- }
- amountPayRemaining = Amounts.getZero(currency);
- } else {
- // Spend the full remaining amount on the coin
- coinSpend = aci.availableAmount;
- amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
- .amount;
- amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
- .amount;
- }
-
- coinPubs.push(aci.coinPub);
- coinContributions.push(coinSpend);
- }
- if (Amounts.isZero(amountPayRemaining)) {
- return {
- paymentAmount: contractTermsAmount,
- coinContributions,
- coinPubs,
- customerDepositFees,
- customerWireFees,
- };
- }
- return undefined;
-}
-
-/**
- * Select coins from the wallet's database that can be used
- * to pay for the given contract.
- *
- * If payment is impossible, undefined is returned.
- */
-async function getCoinsForPayment(
- ws: InternalWalletState,
- contractData: WalletContractData,
-): Promise<PayCoinSelection | undefined> {
- const remainingAmount = contractData.amount;
-
- const exchanges = await ws.db.iter(Stores.exchanges).toArray();
-
- for (const exchange of exchanges) {
- let isOkay = false;
- const exchangeDetails = exchange.details;
- if (!exchangeDetails) {
- continue;
- }
- const exchangeFees = exchange.wireInfo;
- if (!exchangeFees) {
- continue;
- }
-
- // is the exchange explicitly allowed?
- for (const allowedExchange of contractData.allowedExchanges) {
- if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- isOkay = true;
- break;
- }
- }
-
- // is the exchange allowed because of one of its auditors?
- if (!isOkay) {
- for (const allowedAuditor of contractData.allowedAuditors) {
- for (const auditor of exchangeDetails.auditors) {
- if (auditor.auditor_pub === allowedAuditor.auditorPub) {
- isOkay = true;
- break;
- }
- }
- if (isOkay) {
- break;
- }
- }
- }
-
- if (!isOkay) {
- continue;
- }
-
- const coins = await ws.db
- .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
- .toArray();
-
- if (!coins || coins.length === 0) {
- continue;
- }
-
- // Denomination of the first coin, we assume that all other
- // coins have the same currency
- const firstDenom = await ws.db.get(Stores.denominations, [
- exchange.baseUrl,
- coins[0].denomPub,
- ]);
- if (!firstDenom) {
- throw Error("db inconsistent");
- }
- const currency = firstDenom.value.currency;
- const acis: AvailableCoinInfo[] = [];
- for (const coin of coins) {
- const denom = await ws.db.get(Stores.denominations, [
- exchange.baseUrl,
- coin.denomPub,
- ]);
- if (!denom) {
- throw Error("db inconsistent");
- }
- if (denom.value.currency !== currency) {
- console.warn(
- `same pubkey for different currencies at exchange ${exchange.baseUrl}`,
- );
- continue;
- }
- if (coin.suspended) {
- continue;
- }
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- acis.push({
- availableAmount: coin.currentAmount,
- coinPub: coin.coinPub,
- denomPub: coin.denomPub,
- feeDeposit: denom.feeDeposit,
- });
- }
-
- let wireFee: AmountJson | undefined;
- for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) {
- if (
- fee.startStamp <= contractData.timestamp &&
- fee.endStamp >= contractData.timestamp
- ) {
- wireFee = fee.wireFee;
- break;
- }
- }
-
- let customerWireFee: AmountJson;
-
- if (wireFee) {
- const amortizedWireFee = Amounts.divide(
- wireFee,
- contractData.wireFeeAmortization,
- );
- if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
- customerWireFee = amortizedWireFee;
- } else {
- customerWireFee = Amounts.getZero(currency);
- }
- } else {
- customerWireFee = Amounts.getZero(currency);
- }
-
- // Try if paying using this exchange works
- const res = selectPayCoins(
- acis,
- remainingAmount,
- customerWireFee,
- contractData.maxDepositFee,
- );
- if (res) {
- return res;
- }
- }
- return undefined;
-}
-
-/**
- * Record all information that is necessary to
- * pay for a proposal in the wallet's database.
- */
-async function recordConfirmPay(
- ws: InternalWalletState,
- proposal: ProposalRecord,
- coinSelection: PayCoinSelection,
- coinDepositPermissions: CoinDepositPermission[],
- sessionIdOverride: string | undefined,
-): Promise<PurchaseRecord> {
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
- let sessionId;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
- logger.trace(`recording payment with session ID ${sessionId}`);
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
- const t: PurchaseRecord = {
- abortDone: false,
- abortRequested: false,
- contractTermsRaw: d.contractTermsRaw,
- contractData: d.contractData,
- lastSessionId: sessionId,
- payCoinSelection: coinSelection,
- payCostInfo,
- coinDepositPermissions,
- timestampAccept: getTimestampNow(),
- timestampLastRefundStatus: undefined,
- proposalId: proposal.proposalId,
- lastPayError: undefined,
- lastRefundStatusError: undefined,
- payRetryInfo: initRetryInfo(),
- refundStatusRetryInfo: initRetryInfo(),
- refundStatusRequested: false,
- timestampFirstSuccessfulPay: undefined,
- autoRefundDeadline: undefined,
- paymentSubmitPending: true,
- refunds: {},
- };
-
- await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups],
- async (tx) => {
- const p = await tx.get(Stores.proposals, proposal.proposalId);
- if (p) {
- p.proposalStatus = ProposalStatus.ACCEPTED;
- p.lastError = undefined;
- p.retryInfo = initRetryInfo(false);
- await tx.put(Stores.proposals, p);
- }
- await tx.put(Stores.purchases, t);
- for (let i = 0; i < coinSelection.coinPubs.length; i++) {
- const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
- if (!coin) {
- throw Error("coin allocated for payment doesn't exist anymore");
- }
- coin.status = CoinStatus.Dormant;
- const remaining = Amounts.sub(
- coin.currentAmount,
- coinSelection.coinContributions[i],
- );
- if (remaining.saturated) {
- throw Error("not enough remaining balance on coin for payment");
- }
- coin.currentAmount = remaining.amount;
- await tx.put(Stores.coins, coin);
- }
- const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
- coinPub: x,
- }));
- await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
- },
- );
-
- ws.notify({
- type: NotificationType.ProposalAccepted,
- proposalId: proposal.proposalId,
- });
- return t;
-}
-
-function getNextUrl(contractData: WalletContractData): string {
- const f = contractData.fulfillmentUrl;
- if (f.startsWith("http://") || f.startsWith("https://")) {
- const fu = new URL(contractData.fulfillmentUrl);
- fu.searchParams.set("order_id", contractData.orderId);
- return fu.href;
- } else {
- return f;
- }
-}
-
-async function incrementProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
- const pr = await tx.get(Stores.proposals, proposalId);
- if (!pr) {
- return;
- }
- if (!pr.retryInfo) {
- return;
- }
- pr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.retryInfo);
- pr.lastError = err;
- await tx.put(Stores.proposals, pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.ProposalOperationError, error: err });
- }
-}
-
-async function incrementPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- console.log("incrementing purchase pay retry with error", err);
- await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
- const pr = await tx.get(Stores.purchases, proposalId);
- if (!pr) {
- return;
- }
- if (!pr.payRetryInfo) {
- return;
- }
- pr.payRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.payRetryInfo);
- pr.lastPayError = err;
- await tx.put(Stores.purchases, pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.PayOperationError, error: err });
- }
-}
-
-export async function processDownloadProposal(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (err: OperationErrorDetails): Promise<void> =>
- incrementProposalRetry(ws, proposalId, err);
- await guardOperationException(
- () => processDownloadProposalImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetDownloadProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.proposals, proposalId, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processDownloadProposalImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetDownloadProposalRetry(ws, proposalId);
- }
- const proposal = await ws.db.get(Stores.proposals, proposalId);
- if (!proposal) {
- return;
- }
- if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
- return;
- }
-
- 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 resp = await ws.http.postJson(orderClaimUrl, requestBody);
- const proposalResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForProposal(),
- );
-
- // The proposalResp contains the contract terms as raw JSON,
- // as the coded to parse them doesn't necessarily round-trip.
- // We need this raw JSON to compute the contract terms hash.
-
- const contractTermsHash = await ws.cryptoApi.hashString(
- canonicalJson(proposalResp.contract_terms),
- );
-
- const parsedContractTerms = codecForContractTerms().decode(
- proposalResp.contract_terms,
- );
- const fulfillmentUrl = parsedContractTerms.fulfillment_url;
-
- await ws.db.runWithWriteTransaction(
- [Stores.proposals, Stores.purchases],
- async (tx) => {
- const p = await tx.get(Stores.proposals, proposalId);
- if (!p) {
- return;
- }
- if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
- return;
- }
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- p.download = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url,
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: proposalResp.sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.master_pub,
- })),
- 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.parseOrThrow(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- },
- contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
- };
- if (
- fulfillmentUrl.startsWith("http://") ||
- fulfillmentUrl.startsWith("https://")
- ) {
- const differentPurchase = await tx.getIndexed(
- Stores.purchases.fulfillmentUrlIndex,
- fulfillmentUrl,
- );
- if (differentPurchase) {
- console.log("repurchase detected");
- p.proposalStatus = ProposalStatus.REPURCHASE;
- p.repurchaseProposalId = differentPurchase.proposalId;
- await tx.put(Stores.proposals, p);
- return;
- }
- }
- p.proposalStatus = ProposalStatus.PROPOSED;
- await tx.put(Stores.proposals, p);
- },
- );
-
- ws.notify({
- type: NotificationType.ProposalDownloaded,
- proposalId: proposal.proposalId,
- });
-}
-
-/**
- * Download a proposal and store it in the database.
- * Returns an id for it to retrieve it later.
- *
- * @param sessionId Current session ID, if the proposal is being
- * downloaded in the context of a session ID.
- */
-async function startDownloadProposal(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string | undefined,
- claimToken: string | undefined,
-): Promise<string> {
- const oldProposal = await ws.db.getIndexed(
- Stores.proposals.urlAndOrderIdIndex,
- [merchantBaseUrl, orderId],
- );
- if (oldProposal) {
- await processDownloadProposal(ws, oldProposal.proposalId);
- return oldProposal.proposalId;
- }
-
- const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
- const proposalId = encodeCrock(getRandomBytes(32));
-
- const proposalRecord: ProposalRecord = {
- download: undefined,
- noncePriv: priv,
- noncePub: pub,
- claimToken,
- timestamp: getTimestampNow(),
- merchantBaseUrl,
- orderId,
- proposalId: proposalId,
- proposalStatus: ProposalStatus.DOWNLOADING,
- repurchaseProposalId: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- downloadSessionId: sessionId,
- };
-
- await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
- const existingRecord = await tx.getIndexed(
- Stores.proposals.urlAndOrderIdIndex,
- [merchantBaseUrl, orderId],
- );
- if (existingRecord) {
- // Created concurrently
- return;
- }
- await tx.put(Stores.proposals, proposalRecord);
- });
-
- await processDownloadProposal(ws, proposalId);
- return proposalId;
-}
-
-export async function submitPay(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<ConfirmPayResult> {
- const purchase = await ws.db.get(Stores.purchases, proposalId);
- if (!purchase) {
- throw Error("Purchase not found: " + proposalId);
- }
- if (purchase.abortRequested) {
- throw Error("not submitting payment for aborted purchase");
- }
- const sessionId = purchase.lastSessionId;
-
- console.log("paying with session ID", sessionId);
-
- const payUrl = new URL(
- `orders/${purchase.contractData.orderId}/pay`,
- purchase.contractData.merchantBaseUrl,
- ).href;
-
- const reqBody = {
- coins: purchase.coinDepositPermissions,
- session_id: purchase.lastSessionId,
- };
-
- logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2));
-
- const resp = await ws.http.postJson(payUrl, reqBody);
-
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
-
- logger.trace("got success from pay URL", merchantResp);
-
- const now = getTimestampNow();
-
- const merchantPub = purchase.contractData.merchantPub;
- const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
- merchantResp.sig,
- purchase.contractData.contractTermsHash,
- merchantPub,
- );
- if (!valid) {
- console.error("merchant payment signature invalid");
- // FIXME: properly display error
- throw Error("merchant payment signature invalid");
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- purchase.timestampFirstSuccessfulPay = now;
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo(false);
- if (isFirst) {
- const ar = purchase.contractData.autoRefund;
- if (ar) {
- console.log("auto_refund present");
- purchase.refundStatusRequested = true;
- purchase.refundStatusRetryInfo = initRetryInfo();
- purchase.lastRefundStatusError = undefined;
- purchase.autoRefundDeadline = timestampAddDuration(now, ar);
- }
- }
-
- await ws.db.runWithWriteTransaction(
- [Stores.purchases, Stores.payEvents],
- async (tx) => {
- await tx.put(Stores.purchases, purchase);
- const payEvent: PayEventRecord = {
- proposalId,
- sessionId,
- timestamp: now,
- isReplay: !isFirst,
- };
- await tx.put(Stores.payEvents, payEvent);
- },
- );
-
- const nextUrl = getNextUrl(purchase.contractData);
- ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = {
- nextUrl,
- lastSessionId: sessionId,
- };
-
- return { nextUrl };
-}
-
-/**
- * Check if a payment for the given taler://pay/ URI is possible.
- *
- * If the payment is possible, the signature are already generated but not
- * yet send to the merchant.
- */
-export async function preparePayForUri(
- ws: InternalWalletState,
- talerPayUri: string,
-): Promise<PreparePayResult> {
- const uriResult = parsePayUri(talerPayUri);
-
- if (!uriResult) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
- `invalid taler://pay URI (${talerPayUri})`,
- {
- talerPayUri,
- },
- );
- }
-
- let proposalId = await startDownloadProposal(
- ws,
- uriResult.merchantBaseUrl,
- uriResult.orderId,
- uriResult.sessionId,
- uriResult.claimToken,
- );
-
- let proposal = await ws.db.get(Stores.proposals, proposalId);
- if (!proposal) {
- throw Error(`could not get proposal ${proposalId}`);
- }
- if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
- const existingProposalId = proposal.repurchaseProposalId;
- if (!existingProposalId) {
- throw Error("invalid proposal state");
- }
- console.log("using existing purchase for same product");
- proposal = await ws.db.get(Stores.proposals, existingProposalId);
- if (!proposal) {
- throw Error("existing proposal is in wrong state");
- }
- }
- const d = proposal.download;
- if (!d) {
- console.error("bad proposal", proposal);
- throw Error("proposal is in invalid state");
- }
- const contractData = d.contractData;
- const merchantSig = d.contractData.merchantSig;
- if (!merchantSig) {
- throw Error("BUG: proposal is in invalid state");
- }
-
- proposalId = proposal.proposalId;
-
- // First check if we already payed for it.
- const purchase = await ws.db.get(Stores.purchases, proposalId);
-
- if (!purchase) {
- // If not already paid, check if we could pay for it.
- const res = await getCoinsForPayment(ws, contractData);
-
- if (!res) {
- logger.info("not confirming payment, insufficient coins");
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: JSON.parse(d.contractTermsRaw),
- proposalId: proposal.proposalId,
- };
- }
-
- const costInfo = await getTotalPaymentCost(ws, res);
- logger.trace("costInfo", costInfo);
- logger.trace("coinsForPayment", res);
-
- return {
- status: PreparePayResultType.PaymentPossible,
- contractTerms: JSON.parse(d.contractTermsRaw),
- proposalId: proposal.proposalId,
- amountEffective: Amounts.stringify(costInfo.totalCost),
- amountRaw: Amounts.stringify(res.paymentAmount),
- };
- }
-
- if (purchase.lastSessionId !== uriResult.sessionId) {
- logger.trace(
- "automatically re-submitting payment with different session ID",
- );
- await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
- const p = await tx.get(Stores.purchases, proposalId);
- if (!p) {
- return;
- }
- p.lastSessionId = uriResult.sessionId;
- await tx.put(Stores.purchases, p);
- });
- const r = await submitPay(ws, proposalId);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: JSON.parse(purchase.contractTermsRaw),
- paid: true,
- nextUrl: r.nextUrl,
- };
- } else if (!purchase.timestampFirstSuccessfulPay) {
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: JSON.parse(purchase.contractTermsRaw),
- paid: false,
- };
- } else if (purchase.paymentSubmitPending) {
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: JSON.parse(purchase.contractTermsRaw),
- paid: false,
- };
- }
- // FIXME: we don't handle aborted payments correctly here.
- throw Error("BUG: invariant violation (purchase status)");
-}
-
-/**
- * Add a contract to the wallet and sign coins, and send them.
- */
-export async function confirmPay(
- ws: InternalWalletState,
- proposalId: string,
- sessionIdOverride: string | undefined,
-): Promise<ConfirmPayResult> {
- logger.trace(
- `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
- );
- const proposal = await ws.db.get(Stores.proposals, proposalId);
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
-
- let purchase = await ws.db.get(
- Stores.purchases,
- d.contractData.contractTermsHash,
- );
-
- if (purchase) {
- if (
- sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
- ) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- await ws.db.mutate(Stores.purchases, purchase.proposalId, (x) => {
- x.lastSessionId = sessionIdOverride;
- x.paymentSubmitPending = true;
- return x;
- });
- }
- logger.trace("confirmPay: submitting payment for existing purchase");
- return submitPay(ws, proposalId);
- }
-
- logger.trace("confirmPay: purchase record does not exist yet");
-
- const res = await getCoinsForPayment(ws, d.contractData);
-
- logger.trace("coin selection result", res);
-
- if (!res) {
- // Should not happen, since checkPay should be called first
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
- }
-
- const depositPermissions: CoinDepositPermission[] = [];
- for (let i = 0; i < res.coinPubs.length; i++) {
- const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await ws.db.get(Stores.denominations, [
- coin.exchangeBaseUrl,
- coin.denomPub,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
- }
- const dp = await ws.cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash: d.contractData.contractTermsHash,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: denom.feeDeposit,
- merchantPub: d.contractData.merchantPub,
- refundDeadline: d.contractData.refundDeadline,
- spendAmount: res.coinContributions[i],
- timestamp: d.contractData.timestamp,
- wireInfoHash: d.contractData.wireInfoHash,
- });
- depositPermissions.push(dp);
- }
- purchase = await recordConfirmPay(
- ws,
- proposal,
- res,
- depositPermissions,
- sessionIdOverride,
- );
-
- return submitPay(ws, proposalId);
-}
-
-export async function processPurchasePay(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchasePayImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.purchases, proposalId, (x) => {
- if (x.payRetryInfo.active) {
- x.payRetryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processPurchasePayImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchasePayRetry(ws, proposalId);
- }
- const purchase = await ws.db.get(Stores.purchases, proposalId);
- if (!purchase) {
- return;
- }
- if (!purchase.paymentSubmitPending) {
- return;
- }
- logger.trace(`processing purchase pay ${proposalId}`);
- await submitPay(ws, proposalId);
-}
-
-export async function refuseProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const success = await ws.db.runWithWriteTransaction(
- [Stores.proposals],
- async (tx) => {
- const proposal = await tx.get(Stores.proposals, proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return false;
- }
- if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
- return false;
- }
- proposal.proposalStatus = ProposalStatus.REFUSED;
- await tx.put(Stores.proposals, proposal);
- return true;
- },
- );
- if (success) {
- ws.notify({
- type: NotificationType.ProposalRefused,
- });
- }
-}