wallet-core/packages/taler-wallet-core/src/operations/pay-merchant.ts

2672 lines
81 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
(C) 2019-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Implementation of the payment operation, including downloading and
* claiming of proposals.
*
* @author Florian Dold
*/
/**
* Imports.
*/
2021-03-27 19:35:44 +01:00
import {
AbortingCoin,
AbortRequest,
AbsoluteTime,
2021-03-27 19:35:44 +01:00
AmountJson,
Amounts,
codecForAbortResponse,
2022-11-08 17:00:34 +01:00
codecForMerchantContractTerms,
codecForMerchantOrderRefundPickupResponse,
codecForMerchantOrderStatusPaid,
codecForMerchantPayResponse,
codecForProposal,
CoinDepositPermission,
CoinRefreshRequest,
ConfirmPayResult,
ConfirmPayResultType,
ContractTermsUtil,
Duration,
encodeCrock,
ForcedCoinSel,
getRandomBytes,
HttpStatusCode,
j2s,
Logger,
makeErrorDetail,
makePendingOperationFailedError,
MerchantCoinRefundStatus,
MerchantContractTerms,
2023-04-03 17:13:13 +02:00
MerchantPayResponse,
NotificationType,
parsePayUri,
2023-05-05 19:03:44 +02:00
parseTalerUri,
PayCoinSelection,
PreparePayResult,
PreparePayResultType,
2023-05-05 19:03:44 +02:00
randomBytes,
RefreshReason,
StartRefundQueryForUriResponse,
stringifyTalerUri,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
2022-03-18 15:32:41 +01:00
TalerProtocolTimestamp,
TalerProtocolViolationError,
2023-05-05 19:03:44 +02:00
TalerUriAction,
TransactionMajorState,
TransactionMinorState,
2023-04-25 23:56:57 +02:00
TransactionState,
TransactionType,
URL,
2021-03-27 19:35:44 +01:00
} from "@gnu-taler/taler-util";
import {
getHttpResponseErrorDetails,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
readTalerErrorResponse,
readUnexpectedResponseDetails,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
2022-09-05 18:12:30 +02:00
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
2021-06-14 16:08:58 +02:00
import {
BackupProviderStateTag,
2021-06-14 16:08:58 +02:00
CoinRecord,
DenominationRecord,
PurchaseRecord,
PurchaseStatus,
RefundReason,
2021-06-14 16:08:58 +02:00
WalletContractData,
WalletStoresV1,
2021-06-14 16:08:58 +02:00
} from "../db.js";
2023-05-05 19:03:44 +02:00
import {
PendingTaskType,
RefundGroupRecord,
RefundGroupStatus,
RefundItemRecord,
RefundItemStatus,
} from "../index.js";
2022-09-05 18:12:30 +02:00
import {
EXCHANGE_COINS_LOCK,
InternalWalletState,
} from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js";
import {
constructTaskIdentifier,
2022-09-16 19:27:24 +02:00
OperationAttemptResult,
OperationAttemptResultType,
RetryInfo,
scheduleRetry,
TaskIdentifiers,
2022-09-16 19:27:24 +02:00
} from "../util/retries.js";
2022-09-19 12:13:31 +02:00
import {
2023-05-05 19:03:44 +02:00
runLongpollAsync,
runOperationWithErrorReporting,
2022-09-19 12:13:31 +02:00
spendCoins,
} from "./common.js";
2023-05-05 19:03:44 +02:00
import {
calculateRefreshOutput,
createRefreshGroup,
getTotalRefreshCost,
} from "./refresh.js";
import {
constructTransactionIdentifier,
notifyTransition,
stopLongpolling,
2023-05-05 19:03:44 +02:00
} from "./transactions.js";
2019-12-25 21:47:57 +01:00
/**
* Logger.
*/
const logger = new Logger("pay-merchant.ts");
2019-12-25 21:47:57 +01:00
/**
2019-12-25 19:11:20 +01:00
* Compute the total cost of a payment to the customer.
2020-03-30 12:39:32 +02:00
*
2019-12-25 21:47:57 +01:00
* 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.
2019-12-25 19:11:20 +01:00
*/
export async function getTotalPaymentCost(
ws: InternalWalletState,
pcs: PayCoinSelection,
2020-09-08 17:15:33 +02:00
): Promise<AmountJson> {
2021-06-09 15:14:17 +02:00
return ws.db
.mktx((x) => [x.coins, x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
const costs: AmountJson[] = [];
2021-06-09 15:14:17 +02:00
for (let i = 0; i < pcs.coinPubs.length; i++) {
const coin = await tx.coins.get(pcs.coinPubs[i]);
if (!coin) {
throw Error("can't calculate payment cost, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.filter((x) =>
Amounts.isSameCurrency(
DenominationRecord.getValue(x),
pcs.coinContributions[i],
),
);
2022-01-13 22:01:14 +01:00
const amountLeft = Amounts.sub(
DenominationRecord.getValue(denom),
2022-01-13 22:01:14 +01:00
pcs.coinContributions[i],
).amount;
const refreshCost = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
2023-04-19 17:42:47 +02:00
ws.config.testing.denomselAllowLate,
);
2022-11-02 17:42:14 +01:00
costs.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
2021-06-09 15:14:17 +02:00
costs.push(refreshCost);
}
2022-11-02 17:42:14 +01:00
const zero = Amounts.zeroOfAmount(pcs.paymentAmount);
2021-07-12 15:55:31 +02:00
return Amounts.sum([zero, ...costs]).amount;
2021-06-09 15:14:17 +02:00
});
2019-12-25 19:11:20 +01:00
}
async function failProposalPermanently(
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
2021-06-09 15:14:17 +02:00
if (!p) {
return;
}
// FIXME: We don't store the error detail here?!
const oldTxState = computePayMerchantTransactionState(p);
2023-05-05 19:03:44 +02:00
p.purchaseStatus = PurchaseStatus.FailedClaim;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
2021-06-09 15:14:17 +02:00
});
notifyTransition(ws, transactionId, transitionInfo);
}
2022-09-05 18:12:30 +02:00
function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration {
return Duration.clamp({
2022-05-19 11:01:07 +02:00
lower: Duration.fromSpec({ seconds: 1 }),
upper: Duration.fromSpec({ seconds: 60 }),
2022-09-05 18:12:30 +02:00
value: retryInfo ? RetryInfo.getDuration(retryInfo) : Duration.fromSpec({}),
});
}
2020-09-07 12:24:22 +02:00
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
2022-09-05 18:12:30 +02:00
return Duration.multiply(
2021-02-05 12:10:56 +01:00
{ d_ms: 15000 },
1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
);
}
/**
* Return the proposal download data for a purchase, throw if not available.
*/
export async function expectProposalDownload(
ws: InternalWalletState,
p: PurchaseRecord,
2022-10-21 15:11:41 +02:00
parentTx?: GetReadOnlyAccess<{
contractTerms: typeof WalletStoresV1.contractTerms;
}>,
): Promise<{
contractData: WalletContractData;
contractTermsRaw: any;
}> {
if (!p.download) {
throw Error("expected proposal to be downloaded");
}
const download = p.download;
2022-10-21 15:11:41 +02:00
async function getFromTransaction(
tx: Exclude<typeof parentTx, undefined>,
): Promise<ReturnType<typeof expectProposalDownload>> {
const contractTerms = await tx.contractTerms.get(
download.contractTermsHash,
);
if (!contractTerms) {
throw Error("contract terms not found");
}
return {
contractData: extractContractData(
contractTerms.contractTermsRaw,
download.contractTermsHash,
download.contractTermsMerchantSig,
),
contractTermsRaw: contractTerms.contractTermsRaw,
};
}
if (parentTx) {
return getFromTransaction(parentTx);
}
return await ws.db
.mktx((x) => [x.contractTerms])
2022-10-21 15:11:41 +02:00
.runReadOnly(getFromTransaction);
}
2021-01-18 23:35:41 +01:00
export function extractContractData(
2022-11-01 11:34:20 +01:00
parsedContractTerms: MerchantContractTerms,
2021-01-18 23:35:41 +01:00
contractTermsHash: string,
merchantSig: string,
): WalletContractData {
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) {
maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
} else {
2022-11-02 17:42:14 +01:00
maxWireFee = Amounts.zeroOfCurrency(amount.currency);
2021-01-18 23:35:41 +01:00
}
return {
2022-11-02 17:42:14 +01:00
amount: Amounts.stringify(amount),
2021-01-18 23:35:41 +01:00
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,
2022-11-02 17:42:14 +01:00
maxWireFee: Amounts.stringify(maxWireFee),
2021-01-18 23:35:41 +01:00
payDeadline: parsedContractTerms.pay_deadline,
refundDeadline: parsedContractTerms.refund_deadline,
wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
exchangeBaseUrl: x.url,
exchangePub: x.master_pub,
})),
timestamp: parsedContractTerms.timestamp,
wireMethod: parsedContractTerms.wire_method,
wireInfoHash: parsedContractTerms.h_wire,
2022-11-02 17:42:14 +01:00
maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
2021-01-18 23:35:41 +01:00
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
minimumAge: parsedContractTerms.minimum_age,
deliveryDate: parsedContractTerms.delivery_date,
deliveryLocation: parsedContractTerms.delivery_location,
2021-01-18 23:35:41 +01:00
};
}
async function processDownloadProposal(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
2022-09-05 18:12:30 +02:00
): Promise<OperationAttemptResult> {
2021-06-09 15:14:17 +02:00
const proposal = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return await tx.purchases.get(proposalId);
2021-06-09 15:14:17 +02:00
});
2019-12-03 00:52:15 +01:00
if (!proposal) {
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
2019-12-03 00:52:15 +01:00
}
if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
2019-12-03 00:52:15 +01:00
}
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const orderClaimUrl = new URL(
`orders/${proposal.orderId}/claim`,
proposal.merchantBaseUrl,
).href;
logger.trace("downloading contract from '" + orderClaimUrl + "'");
2020-07-30 13:58:09 +02:00
const requestBody: {
nonce: string;
2020-07-30 13:58:09 +02:00
token?: string;
} = {
nonce: proposal.noncePub,
};
2020-07-30 13:58:09 +02:00
if (proposal.claimToken) {
requestBody.token = proposal.claimToken;
}
const opId = TaskIdentifiers.forPay(proposal);
2022-09-05 18:12:30 +02:00
const retryRecord = await ws.db
.mktx((x) => [x.operationRetries])
2022-09-05 18:12:30 +02:00
.runReadOnly(async (tx) => {
return tx.operationRetries.get(opId);
});
const httpResponse = await ws.http.fetch(orderClaimUrl, {
2023-05-25 18:23:47 +02:00
method: "POST",
body: requestBody,
2022-09-05 18:12:30 +02:00
timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
});
const r = await readSuccessResponseJsonOrErrorCode(
httpResponse,
codecForProposal(),
);
if (r.isError) {
switch (r.talerErrorResponse.code) {
2020-11-08 01:20:50 +01:00
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;
2019-12-09 13:29:11 +01:00
// 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.
2019-12-03 00:52:15 +01:00
2021-04-14 14:36:46 +02:00
// 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,
2019-12-03 00:52:15 +01:00
);
2021-04-14 14:36:46 +02:00
if (!isWellFormed) {
logger.trace(
`malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
);
const err = makeErrorDetail(
2021-04-14 14:36:46 +02:00
TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
{},
"validation for well-formedness failed",
2021-04-14 14:36:46 +02:00
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
2021-04-14 14:36:46 +02:00
}
const contractTermsHash = ContractTermsUtil.hashContractTerms(
proposalResp.contract_terms,
);
logger.info(`Contract terms hash: ${contractTermsHash}`);
2022-11-01 11:34:20 +01:00
let parsedContractTerms: MerchantContractTerms;
2021-04-14 14:36:46 +02:00
try {
2022-11-08 17:00:34 +01:00
parsedContractTerms = codecForMerchantContractTerms().decode(
2021-04-14 14:36:46 +02:00
proposalResp.contract_terms,
);
} catch (e) {
const err = makeErrorDetail(
2021-04-14 14:36:46 +02:00
TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
{},
`schema validation failed: ${e}`,
2021-04-14 14:36:46 +02:00
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
2021-04-14 14:36:46 +02:00
}
2022-03-23 21:24:23 +01:00
const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
contractTermsHash,
2022-03-23 21:24:23 +01:00
merchantPub: parsedContractTerms.merchant_pub,
sig: proposalResp.sig,
});
if (!sigValid) {
const err = makeErrorDetail(
TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
{
merchantPub: parsedContractTerms.merchant_pub,
orderId: parsedContractTerms.order_id,
},
"merchant's signature on contract terms is invalid",
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
}
const fulfillmentUrl = parsedContractTerms.fulfillment_url;
2019-12-03 00:52:15 +01:00
const baseUrlForDownload = proposal.merchantBaseUrl;
const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
if (baseUrlForDownload !== baseUrlFromContractTerms) {
const err = makeErrorDetail(
TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
{
baseUrlForDownload,
baseUrlFromContractTerms,
},
"merchant base URL mismatch",
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
}
2021-01-18 23:35:41 +01:00
const contractData = extractContractData(
parsedContractTerms,
contractTermsHash,
proposalResp.sig,
);
logger.trace(`extracted contract data: ${j2s(contractData)}`);
const transitionInfo = await ws.db
.mktx((x) => [x.purchases, x.contractTerms])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
2019-12-03 00:52:15 +01:00
if (!p) {
return;
}
if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
2019-12-03 00:52:15 +01:00
return;
}
const oldTxState = computePayMerchantTransactionState(p);
2019-12-16 22:42:10 +01:00
p.download = {
contractTermsHash,
contractTermsMerchantSig: contractData.merchantSig,
2022-11-02 17:42:14 +01:00
currency: Amounts.currencyOf(contractData.amount),
fulfillmentUrl: contractData.fulfillmentUrl,
2019-12-16 22:42:10 +01:00
};
await tx.contractTerms.put({
h: contractTermsHash,
contractTermsRaw: proposalResp.contract_terms,
});
2019-12-03 00:52:15 +01:00
if (
fulfillmentUrl &&
(fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://"))
2019-12-03 00:52:15 +01:00
) {
2022-01-13 22:01:14 +01:00
const differentPurchase =
await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
// FIXME: Adjust this to account for refunds, don't count as repurchase
// if original order is refunded.
2019-12-03 00:52:15 +01:00
if (differentPurchase) {
logger.warn("repurchase detected");
2022-10-08 23:45:49 +02:00
p.purchaseStatus = PurchaseStatus.RepurchaseDetected;
2019-12-03 00:52:15 +01:00
p.repurchaseProposalId = differentPurchase.proposalId;
await tx.purchases.put(p);
2019-12-03 00:52:15 +01:00
}
} else {
p.purchaseStatus = PurchaseStatus.Proposed;
await tx.purchases.put(p);
}
const newTxState = computePayMerchantTransactionState(p);
return {
oldTxState,
newTxState,
2023-05-25 11:22:10 +02:00
};
2021-06-09 15:14:17 +02:00
});
2019-12-03 00:52:15 +01:00
notifyTransition(ws, transactionId, transitionInfo);
// FIXME: Deprecated pre-DD37 notification, remove eventually
2019-12-05 19:38:19 +01:00
ws.notify({
type: NotificationType.ProposalDownloaded,
proposalId: proposal.proposalId,
});
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
2019-12-03 00:52:15 +01:00
}
/**
* Create a new purchase transaction if necessary. If a purchase
* record for the provided arguments already exists,
* return the old proposal ID.
*/
async function createPurchase(
ws: InternalWalletState,
2019-12-06 12:47:28 +01:00
merchantBaseUrl: string,
orderId: string,
sessionId: string | undefined,
2020-07-30 13:58:09 +02:00
claimToken: string | undefined,
2021-09-17 20:48:33 +02:00
noncePriv: string | undefined,
): Promise<string> {
2021-06-09 15:14:17 +02:00
const oldProposal = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.indexes.byUrlAndOrderId.get([
2021-06-09 15:14:17 +02:00
merchantBaseUrl,
orderId,
]);
});
2021-11-27 20:56:58 +01:00
/* If we have already claimed this proposal with the same sessionId
* nonce and claim token, reuse it. */
2021-11-27 20:56:58 +01:00
if (
oldProposal &&
oldProposal.downloadSessionId === sessionId &&
(!noncePriv || oldProposal.noncePriv === noncePriv) &&
oldProposal.claimToken === claimToken
) {
2019-12-03 00:52:15 +01:00
await processDownloadProposal(ws, oldProposal.proposalId);
return oldProposal.proposalId;
}
2022-03-23 21:24:23 +01:00
let noncePair: EddsaKeypair;
if (noncePriv) {
noncePair = {
priv: noncePriv,
pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
};
2022-03-23 21:24:23 +01:00
} else {
noncePair = await ws.cryptoApi.createEddsaKeypair({});
}
const { priv, pub } = noncePair;
const proposalId = encodeCrock(getRandomBytes(32));
const proposalRecord: PurchaseRecord = {
2019-12-03 00:52:15 +01:00
download: undefined,
noncePriv: priv,
2019-12-03 00:52:15 +01:00
noncePub: pub,
2020-07-30 13:58:09 +02:00
claimToken,
timestamp: TalerPreciseTimestamp.now(),
2019-12-06 12:47:28 +01:00
merchantBaseUrl,
orderId,
proposalId: proposalId,
purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
2019-12-03 00:52:15 +01:00
repurchaseProposalId: undefined,
downloadSessionId: sessionId,
autoRefundDeadline: undefined,
lastSessionId: undefined,
merchantPaySig: undefined,
payInfo: undefined,
refundAmountAwaiting: undefined,
timestampAccept: undefined,
timestampFirstSuccessfulPay: undefined,
timestampLastRefundStatus: undefined,
pendingRemovedCoinPubs: undefined,
2023-04-03 17:13:13 +02:00
posConfirmation: undefined,
};
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([
2021-06-09 15:14:17 +02:00
merchantBaseUrl,
orderId,
]);
if (existingRecord) {
// Created concurrently
return undefined;
2021-06-09 15:14:17 +02:00
}
await tx.purchases.put(proposalRecord);
const oldTxState: TransactionState = {
major: TransactionMajorState.None,
};
const newTxState = computePayMerchantTransactionState(proposalRecord);
return {
oldTxState,
newTxState,
2023-05-25 11:22:10 +02:00
};
2021-06-09 15:14:17 +02:00
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
notifyTransition(ws, transactionId, transitionInfo);
2019-12-03 00:52:15 +01:00
await processDownloadProposal(ws, proposalId);
return proposalId;
}
2020-08-19 17:25:38 +02:00
async function storeFirstPaySuccess(
ws: InternalWalletState,
proposalId: string,
sessionId: string | undefined,
2023-04-03 17:13:13 +02:00
payResponse: MerchantPayResponse,
2020-08-19 17:25:38 +02:00
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
const transitionInfo = await ws.db
.mktx((x) => [x.purchases, x.contractTerms])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
2020-08-19 17:25:38 +02:00
2021-06-09 15:14:17 +02:00
if (!purchase) {
logger.warn("purchase does not exist anymore");
return;
2020-08-19 17:25:38 +02:00
}
2021-06-09 15:14:17 +02:00
const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
if (!isFirst) {
logger.warn("payment success already stored");
return;
}
const oldTxState = computePayMerchantTransactionState(purchase);
if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
2023-05-05 19:03:44 +02:00
purchase.purchaseStatus = PurchaseStatus.Done;
}
2021-06-09 15:14:17 +02:00
purchase.timestampFirstSuccessfulPay = now;
purchase.lastSessionId = sessionId;
2023-04-03 17:13:13 +02:00
purchase.merchantPaySig = payResponse.sig;
purchase.posConfirmation = payResponse.pos_confirmation;
const dl = purchase.download;
checkDbInvariant(!!dl);
const contractTermsRecord = await tx.contractTerms.get(
dl.contractTermsHash,
);
checkDbInvariant(!!contractTermsRecord);
const contractData = extractContractData(
contractTermsRecord.contractTermsRaw,
dl.contractTermsHash,
dl.contractTermsMerchantSig,
);
const protoAr = contractData.autoRefund;
2022-05-14 23:09:33 +02:00
if (protoAr) {
const ar = Duration.fromTalerProtocolDuration(protoAr);
logger.info("auto_refund present");
purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
purchase.autoRefundDeadline = AbsoluteTime.toProtocolTimestamp(
2022-05-14 23:09:33 +02:00
AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
);
2021-06-09 15:14:17 +02:00
}
await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return {
oldTxState,
newTxState,
2023-05-25 11:22:10 +02:00
};
2021-06-09 15:14:17 +02:00
});
notifyTransition(ws, transactionId, transitionInfo);
2020-08-19 17:25:38 +02:00
}
async function storePayReplaySuccess(
ws: InternalWalletState,
proposalId: string,
sessionId: string | undefined,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
2020-08-19 17:25:38 +02:00
2021-06-09 15:14:17 +02:00
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
) {
2023-05-05 19:03:44 +02:00
purchase.purchaseStatus = PurchaseStatus.Done;
}
2021-06-09 15:14:17 +02:00
purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
2021-06-09 15:14:17 +02:00
});
notifyTransition(ws, transactionId, transitionInfo);
2020-08-19 17:25:38 +02:00
}
/**
* Handle a 409 Conflict response from the merchant.
*
* We do this by going through the coin history provided by the exchange and
* (1) verifying the signatures from the exchange
* (2) adjusting the remaining coin value and refreshing it
* (3) re-do coin selection with the bad coin removed
*/
async function handleInsufficientFunds(
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
logger.trace("handling insufficient funds, trying to re-select coins");
2021-06-09 15:14:17 +02:00
const proposal = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
2021-06-09 15:14:17 +02:00
});
if (!proposal) {
return;
}
logger.trace(`got error details: ${j2s(err)}`);
const exchangeReply = (err as any).exchange_reply;
if (
exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
) {
// FIXME: set as failed
if (logger.shouldLogTrace()) {
logger.trace("got exchange error reply (see below)");
logger.trace(j2s(exchangeReply));
}
throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
}
const brokenCoinPub = (exchangeReply as any).coin_pub;
logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
if (!brokenCoinPub) {
throw new TalerProtocolViolationError();
}
const { contractData } = await expectProposalDownload(ws, proposal);
const prevPayCoins: PreviousPayCoins = [];
const payInfo = proposal.payInfo;
if (!payInfo) {
return;
}
const payCoinSelection = payInfo.payCoinSelection;
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [x.coins, x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
2021-06-09 15:14:17 +02:00
if (coinPub === brokenCoinPub) {
continue;
}
const contrib = payCoinSelection.coinContributions[i];
2021-06-09 15:14:17 +02:00
const coin = await tx.coins.get(coinPub);
if (!coin) {
continue;
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
continue;
}
prevPayCoins.push({
coinPub,
2022-11-02 17:42:14 +01:00
contribution: Amounts.parseOrThrow(contrib),
2021-06-09 15:14:17 +02:00
exchangeBaseUrl: coin.exchangeBaseUrl,
2022-11-02 17:42:14 +01:00
feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
2021-06-09 15:14:17 +02:00
});
}
});
const res = await selectPayCoinsNew(ws, {
auditors: [],
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
2022-11-02 17:42:14 +01:00
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
2022-11-02 17:42:14 +01:00
wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins,
requiredMinimumAge: contractData.minimumAge,
});
if (res.type !== "success") {
logger.trace("insufficient funds for coin re-selection");
return;
}
logger.trace("re-selected coins");
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [
x.purchases,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
}
const payInfo = p.payInfo;
if (!payInfo) {
return;
}
payInfo.payCoinSelection = res.coinSel;
payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
2021-06-09 15:14:17 +02:00
await tx.purchases.put(p);
await spendCoins(ws, tx, {
// allocationId: `txn:proposal:${p.proposalId}`,
allocationId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: proposalId,
}),
coinPubs: payInfo.payCoinSelection.coinPubs,
2022-11-02 17:42:14 +01:00
contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
Amounts.parseOrThrow(x),
),
refreshReason: RefreshReason.PayMerchant,
});
2021-06-09 15:14:17 +02:00
});
}
async function unblockBackup(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.backupProviders])
.runReadWrite(async (tx) => {
await tx.backupProviders.indexes.byPaymentProposalId
.iter(proposalId)
.forEachAsync(async (bp) => {
bp.state = {
tag: BackupProviderStateTag.Ready,
nextBackupTimestamp: TalerPreciseTimestamp.now(),
};
tx.backupProviders.put(bp);
});
});
}
// FIXME: Should probably not be exported in its current state
// FIXME: Should take a transaction ID instead of a proposal ID
// FIXME: Does way more than checking the payment
// FIXME: Should return immediately.
export async function checkPaymentByProposalId(
ws: InternalWalletState,
proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> {
2021-06-09 15:14:17 +02:00
let proposal = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
2021-06-09 15:14:17 +02:00
});
2019-12-03 00:52:15 +01:00
if (!proposal) {
2022-11-25 16:18:52 +01:00
throw Error(`could not get proposal ${proposalId}`);
}
2022-10-08 23:45:49 +02:00
if (proposal.purchaseStatus === PurchaseStatus.RepurchaseDetected) {
2019-12-03 00:52:15 +01:00
const existingProposalId = proposal.repurchaseProposalId;
if (!existingProposalId) {
throw Error("invalid proposal state");
}
logger.trace("using existing purchase for same product");
2021-06-09 15:14:17 +02:00
proposal = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(existingProposalId);
2021-06-09 15:14:17 +02:00
});
2019-12-03 00:52:15 +01:00
if (!proposal) {
throw Error("existing proposal is in wrong state");
}
}
const d = await expectProposalDownload(ws, proposal);
const contractData = d.contractData;
const merchantSig = d.contractData.merchantSig;
if (!merchantSig) {
2019-12-03 00:52:15 +01:00
throw Error("BUG: proposal is in invalid state");
}
2019-12-06 00:56:31 +01:00
proposalId = proposal.proposalId;
2019-12-03 00:52:15 +01:00
2023-05-05 19:03:44 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const talerUri = stringifyTalerUri({
type: TalerUriAction.Pay,
merchantBaseUrl: proposal.merchantBaseUrl,
orderId: proposal.orderId,
sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
claimToken: proposal.claimToken,
noncePriv: proposal.noncePriv,
});
2022-11-25 03:16:01 +01:00
2021-04-27 23:42:25 +02:00
// First check if we already paid for it.
2021-06-09 15:14:17 +02:00
const purchase = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
2022-10-08 23:45:49 +02:00
if (!purchase || purchase.purchaseStatus === PurchaseStatus.Proposed) {
// If not already paid, check if we could pay for it.
const res = await selectPayCoinsNew(ws, {
auditors: [],
exchanges: contractData.allowedExchanges,
2022-11-02 17:42:14 +01:00
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
2022-11-02 17:42:14 +01:00
wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
wireMethod: contractData.wireMethod,
});
if (res.type !== "success") {
logger.info("not allowing payment, insufficient coins");
logger.info(
`insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
);
return {
status: PreparePayResultType.InsufficientBalance,
2020-12-21 13:23:07 +01:00
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
2023-05-05 19:03:44 +02:00
transactionId,
2021-09-17 20:48:33 +02:00
noncePriv: proposal.noncePriv,
2020-08-10 16:35:41 +02:00
amountRaw: Amounts.stringify(d.contractData.amount),
2022-11-25 03:16:01 +01:00
talerUri,
balanceDetails: res.insufficientBalanceDetails,
};
}
const totalCost = await getTotalPaymentCost(ws, res.coinSel);
2020-09-08 17:15:33 +02:00
logger.trace("costInfo", totalCost);
2020-07-29 19:40:41 +02:00
logger.trace("coinsForPayment", res);
2019-12-25 19:11:20 +01:00
return {
status: PreparePayResultType.PaymentPossible,
2020-12-21 13:23:07 +01:00
contractTerms: d.contractTermsRaw,
2023-05-05 19:03:44 +02:00
transactionId,
proposalId: proposal.proposalId,
2021-09-17 20:48:33 +02:00
noncePriv: proposal.noncePriv,
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(totalCost),
amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
2021-08-06 11:45:08 +02:00
contractTermsHash: d.contractData.contractTermsHash,
2022-11-25 03:16:01 +01:00
talerUri,
};
}
if (
2023-05-05 19:03:44 +02:00
purchase.purchaseStatus === PurchaseStatus.Done &&
purchase.lastSessionId !== sessionId
) {
2020-07-29 19:40:41 +02:00
logger.trace(
"automatically re-submitting payment with different session ID",
);
logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
}
const oldTxState = computePayMerchantTransactionState(p);
2021-06-09 15:14:17 +02:00
p.lastSessionId = sessionId;
p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
2021-06-09 15:14:17 +02:00
await tx.purchases.put(p);
const newTxState = computePayMerchantTransactionState(p);
return { oldTxState, newTxState };
2021-06-09 15:14:17 +02:00
});
notifyTransition(ws, transactionId, transitionInfo);
// FIXME: What about error handling?! This doesn't properly store errors in the DB.
const r = await processPurchasePay(ws, proposalId, { forceNow: true });
2022-09-05 18:12:30 +02:00
if (r.type !== OperationAttemptResultType.Finished) {
// FIXME: This does not surface the original error
2020-08-11 14:02:11 +02:00
throw Error("submitting pay failed");
}
const download = await expectProposalDownload(ws, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
paid: true,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
2023-05-05 19:03:44 +02:00
transactionId,
proposalId,
2022-11-25 03:16:01 +01:00
talerUri,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
const download = await expectProposalDownload(ws, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
paid: false,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
2023-05-05 19:03:44 +02:00
transactionId,
proposalId,
2022-11-25 03:16:01 +01:00
talerUri,
2020-07-30 13:58:09 +02:00
};
} else {
const paid =
2023-05-05 19:03:44 +02:00
purchase.purchaseStatus === PurchaseStatus.Done ||
purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
const download = await expectProposalDownload(ws, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
paid,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
...(paid ? { nextUrl: download.contractData.orderId } : {}),
2023-05-05 19:03:44 +02:00
transactionId,
proposalId,
2022-11-25 03:16:01 +01:00
talerUri,
};
}
}
export async function getContractTermsDetails(
ws: InternalWalletState,
proposalId: string,
): Promise<WalletContractData> {
const proposal = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
}
const d = await expectProposalDownload(ws, proposal);
return d.contractData;
}
/**
* Check if a payment for the given taler://pay/ URI is possible.
*
* If the payment is possible, the signature are already generated but not
* yet send to the merchant.
*/
export async function preparePayForUri(
ws: InternalWalletState,
talerPayUri: string,
): Promise<PreparePayResult> {
const uriResult = parsePayUri(talerPayUri);
if (!uriResult) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
{
talerPayUri,
},
`invalid taler://pay URI (${talerPayUri})`,
);
}
const proposalId = await createPurchase(
ws,
uriResult.merchantBaseUrl,
uriResult.orderId,
uriResult.sessionId,
uriResult.claimToken,
2021-09-17 20:48:33 +02:00
uriResult.noncePriv,
);
return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
}
2021-01-04 13:30:38 +01:00
/**
* Generate deposit permissions for a purchase.
*
* Accesses the database and the crypto worker.
*/
2021-01-18 23:35:41 +01:00
export async function generateDepositPermissions(
2021-01-04 13:30:38 +01:00
ws: InternalWalletState,
payCoinSel: PayCoinSelection,
contractData: WalletContractData,
): Promise<CoinDepositPermission[]> {
const depositPermissions: CoinDepositPermission[] = [];
2021-06-09 15:14:17 +02:00
const coinWithDenom: Array<{
coin: CoinRecord;
denom: DenominationRecord;
}> = [];
await ws.db
.mktx((x) => [x.coins, x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
if (!coin) {
throw Error("can't pay, allocated coin not found anymore");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't pay, denomination of allocated coin not found anymore",
);
}
coinWithDenom.push({ coin, denom });
}
});
2021-01-04 13:30:38 +01:00
for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
2021-06-09 15:14:17 +02:00
const { coin, denom } = coinWithDenom[i];
2021-11-27 20:56:58 +01:00
let wireInfoHash: string;
wireInfoHash = contractData.wireInfoHash;
logger.trace(
`signing deposit permission for coin with ageRestriction=${j2s(
coin.ageCommitmentProof,
)}`,
);
2021-01-04 13:30:38 +01:00
const dp = await ws.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: contractData.contractTermsHash,
denomPubHash: coin.denomPubHash,
denomKeyType: denom.denomPub.cipher,
2021-01-04 13:30:38 +01:00
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
2022-11-02 17:42:14 +01:00
feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
2021-01-04 13:30:38 +01:00
merchantPub: contractData.merchantPub,
refundDeadline: contractData.refundDeadline,
2022-11-02 17:42:14 +01:00
spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
2021-01-04 13:30:38 +01:00
timestamp: contractData.timestamp,
2021-11-27 20:56:58 +01:00
wireInfoHash,
ageCommitmentProof: coin.ageCommitmentProof,
requiredMinimumAge: contractData.minimumAge,
2021-01-04 13:30:38 +01:00
});
depositPermissions.push(dp);
}
return depositPermissions;
}
2022-09-05 18:12:30 +02:00
/**
* Run the operation handler for a payment
* and return the result as a {@link ConfirmPayResult}.
*/
export async function runPayForConfirmPay(
ws: InternalWalletState,
proposalId: string,
): Promise<ConfirmPayResult> {
logger.trace("processing proposal for confirmPay");
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId,
});
const res = await runOperationWithErrorReporting(ws, taskId, async () => {
return await processPurchasePay(ws, proposalId, { forceNow: true });
});
logger.trace(`processPurchasePay response type ${res.type}`);
2022-09-05 18:12:30 +02:00
switch (res.type) {
case OperationAttemptResultType.Finished: {
const purchase = await ws.db
.mktx((x) => [x.purchases])
2022-09-05 18:12:30 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!purchase) {
2022-09-05 18:12:30 +02:00
throw Error("purchase record not available anymore");
}
const d = await expectProposalDownload(ws, purchase);
2022-09-05 18:12:30 +02:00
return {
type: ConfirmPayResultType.Done,
contractTerms: d.contractTermsRaw,
transactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
}),
2022-09-05 18:12:30 +02:00
};
}
2022-09-19 12:13:31 +02:00
case OperationAttemptResultType.Error: {
// We hide transient errors from the caller.
const opRetry = await ws.db
.mktx((x) => [x.operationRetries])
.runReadOnly(async (tx) => tx.operationRetries.get(taskId));
return {
type: ConfirmPayResultType.Pending,
lastError: opRetry?.lastError,
transactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
}),
};
2022-09-19 12:13:31 +02:00
}
2022-09-05 18:12:30 +02:00
case OperationAttemptResultType.Pending:
logger.trace("reporting pending as confirmPay response");
2022-09-05 18:12:30 +02:00
return {
type: ConfirmPayResultType.Pending,
transactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
}),
2022-09-05 18:12:30 +02:00
lastError: undefined,
};
case OperationAttemptResultType.Longpoll:
throw Error("unexpected processPurchasePay result (longpoll)");
default:
assertUnreachable(res);
}
}
/**
2022-09-19 12:13:31 +02:00
* Confirm payment for a proposal previously claimed by the wallet.
*/
export async function confirmPay(
ws: InternalWalletState,
proposalId: string,
2021-01-07 19:50:53 +01:00
sessionIdOverride?: string,
forcedCoinSel?: ForcedCoinSel,
): Promise<ConfirmPayResult> {
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
2021-06-09 15:14:17 +02:00
const proposal = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
2021-06-09 15:14:17 +02:00
});
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
}
2023-05-25 11:22:10 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const d = await expectProposalDownload(ws, proposal);
2019-12-03 00:52:15 +01:00
if (!d) {
throw Error("proposal is in invalid state");
}
2021-06-09 15:14:17 +02:00
const existingPurchase = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (
purchase &&
sessionIdOverride !== undefined &&
sessionIdOverride != purchase.lastSessionId
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
purchase.lastSessionId = sessionIdOverride;
2023-05-05 19:03:44 +02:00
if (purchase.purchaseStatus === PurchaseStatus.Done) {
purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
2022-10-08 23:45:49 +02:00
}
2021-06-09 15:14:17 +02:00
await tx.purchases.put(purchase);
}
return purchase;
});
if (existingPurchase && existingPurchase.payInfo) {
logger.trace("confirmPay: submitting payment for existing purchase");
2022-09-05 18:12:30 +02:00
return runPayForConfirmPay(ws, proposalId);
}
logger.trace("confirmPay: purchase record does not exist yet");
const contractData = d.contractData;
const selectCoinsResult = await selectPayCoinsNew(ws, {
auditors: [],
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
2022-11-02 17:42:14 +01:00
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
2022-11-02 17:42:14 +01:00
wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
forcedSelection: forcedCoinSel,
});
logger.trace("coin selection result", selectCoinsResult);
if (selectCoinsResult.type === "failure") {
// Should not happen, since checkPay should be called first
// FIXME: Actually, this should be handled gracefully,
// and the status should be stored in the DB.
2020-07-30 13:58:09 +02:00
logger.warn("not confirming payment, insufficient coins");
throw Error("insufficient balance");
}
const coinSelection = selectCoinsResult.coinSel;
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
let sessionId: string | undefined;
if (sessionIdOverride) {
sessionId = sessionIdOverride;
} else {
sessionId = proposal.downloadSessionId;
}
logger.trace(
`recording payment on ${proposal.orderId} with session ID ${sessionId}`,
2019-12-15 21:40:06 +01:00
);
2023-05-25 11:22:10 +02:00
const transitionInfo = await ws.db
.mktx((x) => [
x.purchases,
x.coins,
x.refreshGroups,
x.denominations,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposal.proposalId);
if (!p) {
return;
}
2023-05-25 11:22:10 +02:00
const oldTxState = computePayMerchantTransactionState(p);
2022-10-08 23:45:49 +02:00
switch (p.purchaseStatus) {
case PurchaseStatus.Proposed:
p.payInfo = {
payCoinSelection: coinSelection,
payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
2022-11-02 17:42:14 +01:00
totalPayCost: Amounts.stringify(payCostInfo),
};
p.lastSessionId = sessionId;
p.timestampAccept = TalerPreciseTimestamp.now();
p.purchaseStatus = PurchaseStatus.PendingPaying;
await tx.purchases.put(p);
await spendCoins(ws, tx, {
//`txn:proposal:${p.proposalId}`
allocationId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: proposalId,
}),
coinPubs: coinSelection.coinPubs,
2022-11-02 17:42:14 +01:00
contributions: coinSelection.coinContributions.map((x) =>
Amounts.parseOrThrow(x),
),
refreshReason: RefreshReason.PayMerchant,
});
break;
2023-05-05 19:03:44 +02:00
case PurchaseStatus.Done:
case PurchaseStatus.PendingPaying:
default:
break;
}
2023-05-25 11:22:10 +02:00
const newTxState = computePayMerchantTransactionState(p);
return { oldTxState, newTxState };
});
2023-05-25 11:22:10 +02:00
notifyTransition(ws, transactionId, transitionInfo);
ws.notify({
type: NotificationType.ProposalAccepted,
proposalId: proposal.proposalId,
});
2022-09-05 18:12:30 +02:00
return runPayForConfirmPay(ws, proposalId);
}
2019-12-03 14:40:05 +01:00
export async function processPurchase(
ws: InternalWalletState,
proposalId: string,
): Promise<OperationAttemptResult> {
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!purchase) {
return {
type: OperationAttemptResultType.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,
},
};
}
2022-10-08 23:45:49 +02:00
switch (purchase.purchaseStatus) {
case PurchaseStatus.PendingDownloadingProposal:
2023-05-05 19:03:44 +02:00
return processDownloadProposal(ws, proposalId);
case PurchaseStatus.PendingPaying:
case PurchaseStatus.PendingPayingReplay:
2023-05-05 19:03:44 +02:00
return processPurchasePay(ws, proposalId);
case PurchaseStatus.PendingQueryingRefund:
2023-05-05 19:03:44 +02:00
return processPurchaseQueryRefund(ws, purchase);
case PurchaseStatus.PendingQueryingAutoRefund:
2023-05-05 19:03:44 +02:00
return processPurchaseAutoRefund(ws, purchase);
2022-10-08 23:45:49 +02:00
case PurchaseStatus.AbortingWithRefund:
2023-05-05 19:03:44 +02:00
return processPurchaseAbortingRefund(ws, purchase);
case PurchaseStatus.PendingAcceptRefund:
return processPurchaseAcceptRefund(ws, purchase);
case PurchaseStatus.FailedClaim:
case PurchaseStatus.Done:
2022-10-08 23:45:49 +02:00
case PurchaseStatus.RepurchaseDetected:
case PurchaseStatus.Proposed:
2023-05-05 19:03:44 +02:00
case PurchaseStatus.AbortedProposalRefused:
case PurchaseStatus.AbortedIncompletePayment:
case PurchaseStatus.SuspendedAbortingWithRefund:
case PurchaseStatus.SuspendedDownloadingProposal:
case PurchaseStatus.SuspendedPaying:
case PurchaseStatus.SuspendedPayingReplay:
case PurchaseStatus.SuspendedPendingAcceptRefund:
case PurchaseStatus.SuspendedQueryingAutoRefund:
case PurchaseStatus.SuspendedQueryingRefund:
case PurchaseStatus.FailedAbort:
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
default:
assertUnreachable(purchase.purchaseStatus);
// throw Error(`unexpected purchase status (${purchase.purchaseStatus})`);
}
}
export async function processPurchasePay(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
options: unknown = {},
2022-09-05 18:12:30 +02:00
): Promise<OperationAttemptResult> {
2021-06-09 15:14:17 +02:00
const purchase = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
2019-12-05 19:38:19 +01:00
if (!purchase) {
return {
2022-09-05 18:12:30 +02:00
type: OperationAttemptResultType.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,
},
};
2019-12-05 19:38:19 +01:00
}
2022-10-08 23:45:49 +02:00
switch (purchase.purchaseStatus) {
case PurchaseStatus.PendingPaying:
case PurchaseStatus.PendingPayingReplay:
break;
default:
return OperationAttemptResult.finishedEmpty();
}
logger.trace(`processing purchase pay ${proposalId}`);
const sessionId = purchase.lastSessionId;
logger.trace(`paying with session ID ${sessionId}`);
const payInfo = purchase.payInfo;
checkDbInvariant(!!payInfo, "payInfo");
const download = await expectProposalDownload(ws, purchase);
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${download.contractData.orderId}/pay`,
download.contractData.merchantBaseUrl,
).href;
let depositPermissions: CoinDepositPermission[];
// FIXME: Cache!
depositPermissions = await generateDepositPermissions(
ws,
payInfo.payCoinSelection,
download.contractData,
);
const reqBody = {
coins: depositPermissions,
session_id: purchase.lastSessionId,
};
logger.trace(
"making pay request ... ",
JSON.stringify(reqBody, undefined, 2),
);
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.fetch(payUrl, {
method: "POST",
body: reqBody,
timeout: getPayRequestTimeout(purchase),
}),
);
logger.trace(`got resp ${JSON.stringify(resp)}`);
2022-09-19 12:13:31 +02:00
if (resp.status >= 500 && resp.status <= 599) {
const errDetails = await readUnexpectedResponseDetails(resp);
return {
type: OperationAttemptResultType.Error,
errorDetail: makeErrorDetail(
TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
{
requestError: errDetails,
},
),
};
}
if (resp.status === HttpStatusCode.Conflict) {
const err = await readTalerErrorResponse(resp);
if (
err.code ===
TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
) {
// Do this in the background, as it might take some time
handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
2022-09-05 18:12:30 +02:00
console.log("handling insufficient funds failed");
await scheduleRetry(ws, TaskIdentifiers.forPay(purchase), {
code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
when: AbsoluteTime.now(),
message: "unexpected exception",
hint: "unexpected exception",
details: {
exception: e.toString(),
},
});
});
return {
2022-09-05 18:12:30 +02:00
type: OperationAttemptResultType.Pending,
result: undefined,
};
}
}
if (resp.status >= 400 && resp.status <= 499) {
logger.trace("got generic 4xx from merchant");
const err = await readTalerErrorResponse(resp);
throwUnexpectedRequestError(resp, err);
}
const merchantResp = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantPayResponse(),
);
logger.trace("got success from pay URL", merchantResp);
const merchantPub = download.contractData.merchantPub;
2022-03-23 21:24:23 +01:00
const { valid } = await ws.cryptoApi.isValidPaymentSignature({
contractHash: download.contractData.contractTermsHash,
merchantPub,
2022-03-23 21:24:23 +01:00
sig: merchantResp.sig,
});
if (!valid) {
logger.error("merchant payment signature invalid");
// FIXME: properly display error
throw Error("merchant payment signature invalid");
}
2023-04-03 17:13:13 +02:00
await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp);
await unblockBackup(ws, proposalId);
} else {
const payAgainUrl = new URL(
`orders/${download.contractData.orderId}/paid`,
download.contractData.merchantBaseUrl,
).href;
const reqBody = {
sig: purchase.merchantPaySig,
h_contract: download.contractData.contractTermsHash,
session_id: sessionId ?? "",
};
logger.trace(`/paid request body: ${j2s(reqBody)}`);
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payAgainUrl, reqBody),
);
logger.trace(`/paid response status: ${resp.status}`);
if (
resp.status !== HttpStatusCode.NoContent &&
resp.status != HttpStatusCode.Ok
) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
getHttpResponseErrorDetails(resp),
"/paid failed",
);
}
await storePayReplaySuccess(ws, proposalId, sessionId);
await unblockBackup(ws, proposalId);
}
ws.notify({
type: NotificationType.PayOperationSuccess,
proposalId: purchase.proposalId,
});
2022-09-05 18:12:30 +02:00
return OperationAttemptResult.finishedEmpty();
}
2019-12-20 01:25:22 +01:00
2019-12-25 19:11:20 +01:00
export async function refuseProposal(
ws: InternalWalletState,
proposalId: string,
2020-04-06 20:02:01 +02:00
): Promise<void> {
2023-05-25 11:22:10 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
2021-06-09 15:26:18 +02:00
.runReadWrite(async (tx) => {
const proposal = await tx.purchases.get(proposalId);
2019-12-25 19:11:20 +01:00
if (!proposal) {
logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
2023-05-25 11:22:10 +02:00
return undefined;
2019-12-25 19:11:20 +01:00
}
2022-10-08 23:45:49 +02:00
if (proposal.purchaseStatus !== PurchaseStatus.Proposed) {
2023-05-25 11:22:10 +02:00
return undefined;
2019-12-25 19:11:20 +01:00
}
2023-05-25 11:22:10 +02:00
const oldTxState = computePayMerchantTransactionState(proposal);
2023-05-05 19:03:44 +02:00
proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
2023-05-25 11:22:10 +02:00
const newTxState = computePayMerchantTransactionState(proposal);
await tx.purchases.put(proposal);
2023-05-25 11:22:10 +02:00
return { oldTxState, newTxState };
2019-12-20 01:25:22 +01:00
});
2023-05-25 11:22:10 +02:00
notifyTransition(ws, transactionId, transitionInfo);
2019-12-25 19:11:20 +01:00
}
2023-05-05 19:03:44 +02:00
export async function abortPayMerchant(
ws: InternalWalletState,
2023-05-05 19:03:44 +02:00
proposalId: string,
): Promise<void> {
2023-05-25 11:22:10 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
2023-05-05 19:03:44 +02:00
const opId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId,
});
2023-05-25 11:22:10 +02:00
const transitionInfo = await ws.db
2023-05-05 19:03:44 +02:00
.mktx((x) => [
x.purchases,
x.refreshGroups,
x.denominations,
x.coinAvailability,
x.coins,
x.operationRetries,
])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
throw Error("purchase not found");
}
2023-05-25 11:22:10 +02:00
const oldTxState = computePayMerchantTransactionState(purchase);
2023-05-05 19:03:44 +02:00
const oldStatus = purchase.purchaseStatus;
if (purchase.timestampFirstSuccessfulPay) {
// No point in aborting it. We don't even report an error.
logger.warn(`tried to abort successful payment`);
return;
}
if (oldStatus === PurchaseStatus.PendingPaying) {
2023-05-05 19:03:44 +02:00
purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
}
await tx.purchases.put(purchase);
if (oldStatus === PurchaseStatus.PendingPaying) {
2023-05-05 19:03:44 +02:00
if (purchase.payInfo) {
const coinSel = purchase.payInfo.payCoinSelection;
const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
const refreshCoins: CoinRefreshRequest[] = [];
for (let i = 0; i < coinSel.coinPubs.length; i++) {
refreshCoins.push({
amount: coinSel.coinContributions[i],
coinPub: coinSel.coinPubs[i],
});
}
await createRefreshGroup(
ws,
tx,
currency,
refreshCoins,
RefreshReason.AbortPay,
);
}
}
await tx.operationRetries.delete(opId);
2023-05-25 11:22:10 +02:00
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
});
2023-05-25 11:22:10 +02:00
notifyTransition(ws, transactionId, transitionInfo);
2023-05-05 19:03:44 +02:00
ws.workAvailable.trigger();
}
export async function cancelAbortingPaymentTransaction(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const opId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [
x.purchases,
x.refreshGroups,
x.denominations,
x.coinAvailability,
x.coins,
x.operationRetries,
])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
throw Error("purchase not found");
}
const oldTxState = computePayMerchantTransactionState(purchase);
let newState: PurchaseStatus | undefined = undefined;
switch (purchase.purchaseStatus) {
case PurchaseStatus.AbortingWithRefund:
newState = PurchaseStatus.FailedAbort;
break;
}
if (newState) {
purchase.purchaseStatus = newState;
await tx.purchases.put(purchase);
}
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
ws.workAvailable.trigger();
}
const transitionSuspend: {
[x in PurchaseStatus]?: {
next: PurchaseStatus | undefined;
};
} = {
[PurchaseStatus.PendingDownloadingProposal]: {
next: PurchaseStatus.SuspendedDownloadingProposal,
},
[PurchaseStatus.AbortingWithRefund]: {
next: PurchaseStatus.SuspendedAbortingWithRefund,
},
[PurchaseStatus.PendingPaying]: {
next: PurchaseStatus.SuspendedPaying,
},
[PurchaseStatus.PendingPayingReplay]: {
next: PurchaseStatus.SuspendedPayingReplay,
},
[PurchaseStatus.PendingQueryingAutoRefund]: {
next: PurchaseStatus.SuspendedQueryingAutoRefund,
},
};
const transitionResume: {
[x in PurchaseStatus]?: {
next: PurchaseStatus | undefined;
};
} = {
[PurchaseStatus.SuspendedDownloadingProposal]: {
next: PurchaseStatus.PendingDownloadingProposal,
},
[PurchaseStatus.SuspendedAbortingWithRefund]: {
next: PurchaseStatus.AbortingWithRefund,
},
[PurchaseStatus.SuspendedPaying]: {
next: PurchaseStatus.PendingPaying,
},
[PurchaseStatus.SuspendedPayingReplay]: {
next: PurchaseStatus.PendingPayingReplay,
},
[PurchaseStatus.SuspendedQueryingAutoRefund]: {
next: PurchaseStatus.PendingQueryingAutoRefund,
},
};
export async function suspendPayMerchant(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const opId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId,
});
stopLongpolling(ws, opId);
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
throw Error("purchase not found");
}
const oldTxState = computePayMerchantTransactionState(purchase);
let newStatus = transitionSuspend[purchase.purchaseStatus];
if (!newStatus) {
return undefined;
}
await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
ws.workAvailable.trigger();
}
export async function resumePayMerchant(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const opId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId,
});
stopLongpolling(ws, opId);
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
throw Error("purchase not found");
}
const oldTxState = computePayMerchantTransactionState(purchase);
let newStatus = transitionResume[purchase.purchaseStatus];
if (!newStatus) {
return undefined;
}
await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
});
ws.workAvailable.trigger();
notifyTransition(ws, transactionId, transitionInfo);
ws.workAvailable.trigger();
}
2023-05-05 19:03:44 +02:00
export function computePayMerchantTransactionState(
purchaseRecord: PurchaseRecord,
): TransactionState {
switch (purchaseRecord.purchaseStatus) {
// Pending States
case PurchaseStatus.PendingDownloadingProposal:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.ClaimProposal,
};
case PurchaseStatus.PendingPaying:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.SubmitPayment,
2023-05-05 19:03:44 +02:00
};
case PurchaseStatus.PendingPayingReplay:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.RebindSession,
2023-05-05 19:03:44 +02:00
};
case PurchaseStatus.PendingQueryingAutoRefund:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.AutoRefund,
2023-05-05 19:03:44 +02:00
};
case PurchaseStatus.PendingQueryingRefund:
2023-05-05 19:03:44 +02:00
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,
2023-05-05 19:03:44 +02:00
minor: TransactionMinorState.ClaimProposal,
};
case PurchaseStatus.SuspendedPaying:
2023-05-05 19:03:44 +02:00
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,
2023-05-05 19:03:44 +02:00
};
// Aborting States
2023-05-05 19:03:44 +02:00
case PurchaseStatus.AbortingWithRefund:
return {
major: TransactionMajorState.Aborting,
};
// Suspended Aborting States
case PurchaseStatus.SuspendedAbortingWithRefund:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.SuspendedAborting,
2023-05-05 19:03:44 +02:00
};
// Dialog States
case PurchaseStatus.Proposed:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Dialog,
minor: TransactionMinorState.MerchantOrderProposed,
2023-05-05 19:03:44 +02:00
};
// Final States
2023-05-05 19:03:44 +02:00
case PurchaseStatus.AbortedProposalRefused:
return {
major: TransactionMajorState.Failed,
minor: TransactionMinorState.Refused,
};
case PurchaseStatus.Done:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Done,
2023-05-05 19:03:44 +02:00
};
case PurchaseStatus.RepurchaseDetected:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Failed,
minor: TransactionMinorState.Repurchase,
2023-05-05 19:03:44 +02:00
};
case PurchaseStatus.AbortedIncompletePayment:
2023-05-05 19:03:44 +02:00
return {
major: TransactionMajorState.Aborted,
};
case PurchaseStatus.FailedClaim:
return {
major: TransactionMajorState.Failed,
minor: TransactionMinorState.ClaimProposal,
2023-05-05 19:03:44 +02:00
};
case PurchaseStatus.FailedAbort:
return {
major: TransactionMajorState.Failed,
minor: TransactionMinorState.AbortingBank,
};
}
2023-05-05 19:03:44 +02:00
}
2023-05-05 19:03:44 +02:00
async function processPurchaseAutoRefund(
ws: InternalWalletState,
purchase: PurchaseRecord,
): Promise<OperationAttemptResult> {
const proposalId = purchase.proposalId;
2023-05-05 19:03:44 +02:00
logger.trace(`processing auto-refund for proposal ${proposalId}`);
2023-05-05 19:03:44 +02:00
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId,
});
2023-05-05 19:03:44 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
2023-05-05 19:03:44 +02:00
});
2023-05-05 19:03:44 +02:00
// FIXME: Put this logic into runLongpollAsync?
if (ws.activeLongpoll[taskId]) {
return OperationAttemptResult.longpoll();
}
2023-05-05 19:03:44 +02:00
const download = await expectProposalDownload(ws, purchase);
2023-05-05 19:03:44 +02:00
runLongpollAsync(ws, taskId, async (ct) => {
if (
!purchase.autoRefundDeadline ||
AbsoluteTime.isExpired(
AbsoluteTime.fromProtocolTimestamp(purchase.autoRefundDeadline),
2023-05-05 19:03:44 +02:00
)
) {
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
2023-05-05 19:03:44 +02:00
return;
}
const oldTxState = computePayMerchantTransactionState(p);
p.purchaseStatus = PurchaseStatus.Done;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
return {
ready: true,
};
}
2023-05-05 19:03:44 +02:00
const requestUrl = new URL(
`orders/${download.contractData.orderId}`,
download.contractData.merchantBaseUrl,
);
requestUrl.searchParams.set(
"h_contract",
download.contractData.contractTermsHash,
);
2023-05-05 19:03:44 +02:00
requestUrl.searchParams.set("timeout_ms", "1000");
requestUrl.searchParams.set("await_refund_obtained", "yes");
2023-05-05 19:03:44 +02:00
const resp = await ws.http.fetch(requestUrl.href);
2023-05-05 19:03:44 +02:00
// FIXME: Check other status codes!
2023-05-05 19:03:44 +02:00
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
);
2023-05-05 19:03:44 +02:00
if (orderStatus.refund_pending) {
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
2023-05-05 19:03:44 +02:00
return;
}
const oldTxState = computePayMerchantTransactionState(p);
p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
return {
ready: true,
};
} else {
return {
ready: false,
};
}
});
2023-05-05 19:03:44 +02:00
return OperationAttemptResult.longpoll();
}
2023-05-05 19:03:44 +02:00
async function processPurchaseAbortingRefund(
ws: InternalWalletState,
purchase: PurchaseRecord,
): Promise<OperationAttemptResult> {
const proposalId = purchase.proposalId;
const download = await expectProposalDownload(ws, purchase);
logger.trace(`processing aborting-refund for proposal ${proposalId}`);
2023-05-05 19:03:44 +02:00
const requestUrl = new URL(
`orders/${download.contractData.orderId}/abort`,
download.contractData.merchantBaseUrl,
);
2023-05-05 19:03:44 +02:00
const abortingCoins: AbortingCoin[] = [];
const payCoinSelection = purchase.payInfo?.payCoinSelection;
if (!payCoinSelection) {
throw Error("can't abort, no coins selected");
}
2023-05-05 19:03:44 +02:00
await ws.db
.mktx((x) => [x.coins])
.runReadOnly(async (tx) => {
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
const coin = await tx.coins.get(coinPub);
checkDbInvariant(!!coin, "expected coin to be present");
abortingCoins.push({
coin_pub: coinPub,
contribution: Amounts.stringify(
payCoinSelection.coinContributions[i],
),
exchange_url: coin.exchangeBaseUrl,
});
}
});
const abortReq: AbortRequest = {
h_contract: download.contractData.contractTermsHash,
coins: abortingCoins,
};
2023-05-05 19:03:44 +02:00
logger.trace(`making order abort request to ${requestUrl.href}`);
2023-05-05 19:03:44 +02:00
const request = await ws.http.postJson(requestUrl.href, abortReq);
const abortResp = await readSuccessResponseJsonOrThrow(
request,
codecForAbortResponse(),
);
2023-05-05 19:03:44 +02:00
const refunds: MerchantCoinRefundStatus[] = [];
2023-05-05 19:03:44 +02:00
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(
2023-05-05 19:03:44 +02:00
AbsoluteTime.addDuration(
AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
2023-05-05 19:03:44 +02:00
Duration.fromSpec({ seconds: 1 }),
),
),
});
}
2023-05-05 19:03:44 +02:00
return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
}
2023-05-05 19:03:44 +02:00
async function processPurchaseQueryRefund(
ws: InternalWalletState,
2023-05-05 19:03:44 +02:00
purchase: PurchaseRecord,
): Promise<OperationAttemptResult> {
const proposalId = purchase.proposalId;
logger.trace(`processing query-refund for proposal ${proposalId}`);
2023-05-05 19:03:44 +02:00
const download = await expectProposalDownload(ws, purchase);
2023-05-05 19:03:44 +02:00
const requestUrl = new URL(
`orders/${download.contractData.orderId}`,
download.contractData.merchantBaseUrl,
);
requestUrl.searchParams.set(
"h_contract",
download.contractData.contractTermsHash,
);
2023-05-05 19:03:44 +02:00
const resp = await ws.http.fetch(requestUrl.href);
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
);
2023-05-05 19:03:44 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
2023-05-05 19:03:44 +02:00
if (!orderStatus.refund_pending) {
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return undefined;
}
if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
2023-05-05 19:03:44 +02:00
return undefined;
}
2023-05-05 19:03:44 +02:00
const oldTxState = computePayMerchantTransactionState(p);
p.purchaseStatus = PurchaseStatus.Done;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
return OperationAttemptResult.finishedEmpty();
} else {
const refundAwaiting = Amounts.sub(
Amounts.parseOrThrow(orderStatus.refund_amount),
Amounts.parseOrThrow(orderStatus.refund_taken),
).amount;
2023-05-05 19:03:44 +02:00
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
2023-05-05 19:03:44 +02:00
return;
}
2023-05-05 19:03:44 +02:00
const oldTxState = computePayMerchantTransactionState(p);
p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
return OperationAttemptResult.finishedEmpty();
}
}
2023-05-05 19:03:44 +02:00
async function processPurchaseAcceptRefund(
ws: InternalWalletState,
2023-05-05 19:03:44 +02:00
purchase: PurchaseRecord,
): Promise<OperationAttemptResult> {
const proposalId = purchase.proposalId;
2023-05-05 19:03:44 +02:00
const download = await expectProposalDownload(ws, purchase);
2023-05-05 19:03:44 +02:00
const requestUrl = new URL(
`orders/${download.contractData.orderId}/refund`,
download.contractData.merchantBaseUrl,
);
2023-05-05 19:03:44 +02:00
logger.trace(`making refund request to ${requestUrl.href}`);
const request = await ws.http.postJson(requestUrl.href, {
h_contract: download.contractData.contractTermsHash,
});
2023-05-05 19:03:44 +02:00
const refundResponse = await readSuccessResponseJsonOrThrow(
request,
codecForMerchantOrderRefundPickupResponse(),
);
return await storeRefunds(
ws,
purchase,
refundResponse.refunds,
RefundReason.AbortRefund,
);
}
2023-05-05 19:03:44 +02:00
export async function startRefundQueryForUri(
ws: InternalWalletState,
2023-05-05 19:03:44 +02:00
talerUri: string,
): Promise<StartRefundQueryForUriResponse> {
2023-05-05 19:03:44 +02:00
const parsedUri = parseTalerUri(talerUri);
if (!parsedUri) {
throw Error("invalid taler:// URI");
}
2023-05-05 19:03:44 +02:00
if (parsedUri.type !== TalerUriAction.Refund) {
throw Error("expected taler://refund URI");
}
const purchaseRecord = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.indexes.byUrlAndOrderId.get([
2023-05-05 19:03:44 +02:00
parsedUri.merchantBaseUrl,
parsedUri.orderId,
]);
});
2023-05-05 19:03:44 +02:00
if (!purchaseRecord) {
throw Error("no purchase found, can't refund");
}
const proposalId = purchaseRecord.proposalId;
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
await startQueryRefund(ws, proposalId);
return {
transactionId,
};
}
2023-05-05 19:03:44 +02:00
export async function startQueryRefund(
ws: InternalWalletState,
proposalId: string,
2023-05-05 19:03:44 +02:00
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
2023-05-05 19:03:44 +02:00
logger.warn(`purchase ${proposalId} does not exist anymore`);
return;
}
2023-05-05 19:03:44 +02:00
if (p.purchaseStatus !== PurchaseStatus.Done) {
return;
}
2023-05-05 19:03:44 +02:00
const oldTxState = computePayMerchantTransactionState(p);
p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
2023-05-05 19:03:44 +02:00
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
2023-05-05 19:03:44 +02:00
return { oldTxState, newTxState };
});
2023-05-05 19:03:44 +02:00
notifyTransition(ws, transactionId, transitionInfo);
ws.workAvailable.trigger();
}
2023-05-05 19:03:44 +02:00
/**
* Store refunds, possibly creating a new refund group.
*/
async function storeRefunds(
ws: InternalWalletState,
purchase: PurchaseRecord,
2023-05-05 19:03:44 +02:00
refunds: MerchantCoinRefundStatus[],
reason: RefundReason,
): Promise<OperationAttemptResult> {
2023-05-05 19:03:44 +02:00
logger.info(`storing refunds: ${j2s(refunds)}`);
2023-05-05 19:03:44 +02:00
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: purchase.proposalId,
});
2023-05-05 19:03:44 +02:00
const newRefundGroupId = encodeCrock(randomBytes(32));
const now = TalerPreciseTimestamp.now();
2023-05-05 19:03:44 +02:00
const download = await expectProposalDownload(ws, purchase);
const currency = Amounts.currencyOf(download.contractData.amount);
2023-05-05 19:03:44 +02:00
const getItemStatus = (rf: MerchantCoinRefundStatus) => {
if (rf.type === "success") {
return RefundItemStatus.Done;
} else {
if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
return RefundItemStatus.Pending;
} else {
return RefundItemStatus.Failed;
}
}
2023-05-05 19:03:44 +02:00
};
2023-05-05 19:03:44 +02:00
const result = await ws.db
.mktx((x) => [
x.purchases,
2023-05-05 19:03:44 +02:00
x.refundGroups,
x.refundItems,
x.coins,
x.denominations,
x.coinAvailability,
2023-05-05 19:03:44 +02:00
x.refreshGroups,
])
.runReadWrite(async (tx) => {
2023-05-05 19:03:44 +02:00
const computeRefreshRequest = async (items: RefundItemRecord[]) => {
const refreshCoins: CoinRefreshRequest[] = [];
for (const item of items) {
const coin = await tx.coins.get(item.coinPub);
if (!coin) {
throw Error("coin not found");
}
const denomInfo = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denomInfo) {
throw Error("denom not found");
}
if (item.status === RefundItemStatus.Done) {
const refundedAmount = Amounts.sub(
item.refundAmount,
denomInfo.feeRefund,
).amount;
refreshCoins.push({
amount: Amounts.stringify(refundedAmount),
coinPub: item.coinPub,
});
}
}
return refreshCoins;
};
const myPurchase = await tx.purchases.get(purchase.proposalId);
if (!myPurchase) {
logger.warn("purchase group not found anymore");
return;
}
2023-05-05 19:03:44 +02:00
if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) {
return;
}
2023-05-05 19:03:44 +02:00
let newGroup: RefundGroupRecord | undefined = undefined;
// Pending, but not part of an aborted refund group.
let numPendingItemsTotal = 0;
const newGroupRefunds: RefundItemRecord[] = [];
for (const rf of refunds) {
const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
rf.coin_pub,
rf.rtransaction_id,
]);
if (oldItem) {
logger.info("already have refund in database");
if (oldItem.status === RefundItemStatus.Done) {
continue;
}
if (rf.type === "success") {
oldItem.status = RefundItemStatus.Done;
} else {
if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
oldItem.status = RefundItemStatus.Pending;
numPendingItemsTotal += 1;
} else {
oldItem.status = RefundItemStatus.Failed;
}
}
await tx.refundItems.put(oldItem);
} else {
// Put refund item into a new group!
if (!newGroup) {
newGroup = {
proposalId: purchase.proposalId,
refundGroupId: newRefundGroupId,
status: RefundGroupStatus.Pending,
timestampCreated: now,
amountEffective: Amounts.stringify(
Amounts.zeroOfCurrency(currency),
),
amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
};
}
const status: RefundItemStatus = getItemStatus(rf);
const newItem: RefundItemRecord = {
coinPub: rf.coin_pub,
executionTime: rf.execution_time,
obtainedTime: now,
refundAmount: rf.refund_amount,
refundGroupId: newGroup.refundGroupId,
rtxid: rf.rtransaction_id,
status,
};
if (status === RefundItemStatus.Pending) {
numPendingItemsTotal += 1;
}
newGroupRefunds.push(newItem);
await tx.refundItems.put(newItem);
}
}
2023-05-05 19:03:44 +02:00
// Now that we know all the refunds for the new refund group,
// we can compute the raw/effective amounts.
if (newGroup) {
const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
const refreshCoins = await computeRefreshRequest(newGroupRefunds);
const outInfo = await calculateRefreshOutput(
ws,
tx,
currency,
refreshCoins,
);
newGroup.amountEffective = Amounts.stringify(
Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
);
newGroup.amountRaw = Amounts.stringify(
Amounts.sumOrZero(currency, amountsRaw).amount,
);
await tx.refundGroups.put(newGroup);
2023-01-11 17:16:15 +01:00
}
2023-05-05 19:03:44 +02:00
const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
myPurchase.proposalId,
);
logger.info(
`refund groups for proposal ${myPurchase.proposalId}: ${j2s(
refundGroups,
)}`,
);
for (const refundGroup of refundGroups) {
if (refundGroup.status === RefundGroupStatus.Aborted) {
continue;
}
if (refundGroup.status === RefundGroupStatus.Done) {
continue;
}
const items = await tx.refundItems.indexes.byRefundGroupId.getAll(
refundGroup.refundGroupId,
);
let numPending = 0;
for (const item of items) {
if (item.status === RefundItemStatus.Pending) {
numPending++;
2023-01-11 17:16:15 +01:00
}
2023-05-05 19:03:44 +02:00
}
logger.info(`refund items pending for refund group: ${numPending}`);
if (numPending === 0) {
logger.info("refund group is done!");
// We're done for this refund group!
refundGroup.status = RefundGroupStatus.Done;
await tx.refundGroups.put(refundGroup);
const refreshCoins = await computeRefreshRequest(items);
2023-01-11 17:16:15 +01:00
await createRefreshGroup(
ws,
tx,
2023-05-05 19:03:44 +02:00
Amounts.currencyOf(download.contractData.amount),
2023-01-11 17:16:15 +01:00
refreshCoins,
2023-05-05 19:03:44 +02:00
RefreshReason.Refund,
2023-01-11 17:16:15 +01:00
);
}
}
2023-05-05 19:03:44 +02:00
const oldTxState = computePayMerchantTransactionState(myPurchase);
if (numPendingItemsTotal === 0) {
myPurchase.purchaseStatus = PurchaseStatus.Done;
}
await tx.purchases.put(myPurchase);
const newTxState = computePayMerchantTransactionState(myPurchase);
return {
numPendingItemsTotal,
transitionInfo: {
oldTxState,
newTxState,
},
};
});
2023-05-05 19:03:44 +02:00
if (!result) {
return OperationAttemptResult.finishedEmpty();
}
notifyTransition(ws, transactionId, result.transitionInfo);
if (result.numPendingItemsTotal > 0) {
return OperationAttemptResult.pendingEmpty();
}
return OperationAttemptResult.finishedEmpty();
}
2023-04-25 23:56:57 +02:00
2023-05-05 19:03:44 +02:00
export function computeRefundTransactionState(
refundGroupRecord: RefundGroupRecord,
2023-04-25 23:56:57 +02:00
): TransactionState {
2023-05-05 19:03:44 +02:00
switch (refundGroupRecord.status) {
case RefundGroupStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
2023-05-05 19:03:44 +02:00
case RefundGroupStatus.Done:
return {
2023-05-05 19:03:44 +02:00
major: TransactionMajorState.Done,
};
2023-05-05 19:03:44 +02:00
case RefundGroupStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
2023-05-05 19:03:44 +02:00
case RefundGroupStatus.Pending:
return {
major: TransactionMajorState.Pending,
};
}
2023-04-25 23:56:57 +02:00
}