wallet-core/packages/taler-wallet-core/src/operations/pay-merchant.ts
Sebastian 5d76573ac0
#7741 share payment
save shared state in backup
if purchase is shared check before making the payment of before claim the order
already confirmed order can return without effective if coin selection was not made
sharePayment operation
2023-07-03 12:42:44 -03:00

2947 lines
90 KiB
TypeScript

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