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