summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/pay-merchant.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/pay-merchant.ts')
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts889
1 files changed, 592 insertions, 297 deletions
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index 812d32429..090a11cf0 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -34,13 +34,17 @@ import {
assertUnreachable,
AsyncFlag,
checkDbInvariant,
+ CheckPaymentResponse,
+ CheckPayTemplateReponse,
+ CheckPayTemplateRequest,
codecForAbortResponse,
codecForMerchantContractTerms,
codecForMerchantOrderStatusPaid,
codecForMerchantPayResponse,
- codecForMerchantPostOrderResponse,
+ codecForPostOrderResponse,
codecForProposal,
codecForWalletRefundResponse,
+ codecForWalletTemplateDetails,
CoinDepositPermission,
CoinRefreshRequest,
ConfirmPayResult,
@@ -63,12 +67,12 @@ import {
parsePayTemplateUri,
parsePayUri,
parseTalerUri,
- PayCoinSelection,
PreparePayResult,
PreparePayResultType,
PreparePayTemplateRequest,
randomBytes,
RefreshReason,
+ SelectedProspectiveCoin,
SharePaymentResult,
StartRefundQueryForUriResponse,
stringifyPayUri,
@@ -76,6 +80,8 @@ import {
TalerError,
TalerErrorCode,
TalerErrorDetail,
+ TalerMerchantApi,
+ TalerMerchantInstanceHttpClient,
TalerPreciseTimestamp,
TalerProtocolViolationError,
TalerUriAction,
@@ -143,7 +149,6 @@ import {
getDenomInfo,
WalletExecutionContext,
} from "./wallet.js";
-import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
/**
* Logger.
@@ -201,7 +206,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
const ws = this.wex;
const extraStores = opts.extraStores ?? [];
const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases", ...extraStores],
+ { storeNames: ["purchases", ...extraStores] },
async (tx) => {
const purchaseRec = await tx.purchases.get(this.proposalId);
if (!purchaseRec) {
@@ -228,26 +233,29 @@ export class PayMerchantTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex: ws, proposalId } = this;
- await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => {
- let found = false;
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
+ await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", "tombstones"] },
+ async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ },
+ );
}
async suspendTransaction(): Promise<void> {
const { wex, proposalId, transactionId } = this;
wex.taskScheduler.stopShepherdTask(this.taskId);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -269,14 +277,17 @@ export class PayMerchantTransactionContext implements TransactionContext {
async abortTransaction(): Promise<void> {
const { wex, proposalId, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -290,7 +301,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
case PurchaseStatus.PendingPaying:
case PurchaseStatus.SuspendedPaying: {
purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
- if (purchase.payInfo) {
+ if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
const coinSel = purchase.payInfo.payCoinSelection;
const currency = Amounts.currencyOf(
purchase.payInfo.totalPayCost,
@@ -344,7 +355,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
async resumeTransaction(): Promise<void> {
const { wex, proposalId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -367,14 +378,16 @@ export class PayMerchantTransactionContext implements TransactionContext {
async failTransaction(): Promise<void> {
const { wex, proposalId, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -415,15 +428,18 @@ export class RefundTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex, refundGroupId, transactionId } = this;
- await wex.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => {
- const refundRecord = await tx.refundGroups.get(refundGroupId);
- if (!refundRecord) {
- return;
- }
- await tx.refundGroups.delete(refundGroupId);
- await tx.tombstones.put({ id: transactionId });
- // FIXME: Also tombstone the refund items, so that they won't reappear.
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refundGroups", "tombstones"] },
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ },
+ );
}
suspendTransaction(): Promise<void> {
@@ -452,47 +468,37 @@ export class RefundTransactionContext implements TransactionContext {
*/
export async function getTotalPaymentCost(
wex: WalletExecutionContext,
- pcs: PayCoinSelection,
+ currency: string,
+ pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs.customerDepositFees);
- return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.coins.length; i++) {
- const coin = await tx.coins.get(pcs.coins[i].coinPub);
- 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",
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await tx.denominations.get([
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
);
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
}
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- wex,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coins[i].contribution,
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- wex.ws.config.testing.denomselAllowLate,
- );
- costs.push(Amounts.parseOrThrow(pcs.coins[i].contribution));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfAmount(pcs.customerDepositFees);
- return Amounts.sum([zero, ...costs]).amount;
- });
+ const zero = Amounts.zeroOfCurrency(currency);
+ return Amounts.sum([zero, ...costs]).amount;
+ },
+ );
}
async function failProposalPermanently(
@@ -505,7 +511,7 @@ async function failProposalPermanently(
proposalId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -525,7 +531,7 @@ async function failProposalPermanently(
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
return Duration.multiply(
{ d_ms: 15000 },
- 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
+ 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
);
}
@@ -567,7 +573,10 @@ export async function expectProposalDownload(
if (parentTx) {
return getFromTransaction(parentTx);
}
- return await wex.db.runReadOnlyTx(["contractTerms"], getFromTransaction);
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ getFromTransaction,
+ );
}
export function extractContractData(
@@ -606,9 +615,12 @@ async function processDownloadProposal(
wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return await tx.purchases.get(proposalId);
- });
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return await tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
return TaskRunResult.finished();
@@ -779,7 +791,7 @@ async function processDownloadProposal(
logger.trace(`extracted contract data: ${j2s(contractData)}`);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases", "contractTerms"],
+ { storeNames: ["purchases", "contractTerms"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -852,12 +864,15 @@ async function createOrReusePurchase(
claimToken: string | undefined,
noncePriv: string | undefined,
): Promise<string> {
- const oldProposals = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.getAll([
- merchantBaseUrl,
- orderId,
- ]);
- });
+ const oldProposals = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.getAll([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ },
+ );
const oldProposal = oldProposals.find((p) => {
return (
@@ -891,7 +906,7 @@ async function createOrReusePurchase(
// if this transaction was shared and the order is paid then it
// means that another wallet already paid the proposal
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(oldProposal.proposalId);
if (!p) {
@@ -957,7 +972,7 @@ async function createOrReusePurchase(
};
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
await tx.purchases.put(proposalRecord);
const oldTxState: TransactionState = {
@@ -991,7 +1006,7 @@ async function storeFirstPaySuccess(
});
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
const transitionInfo = await wex.db.runReadWriteTx(
- ["contractTerms", "purchases"],
+ { storeNames: ["contractTerms", "purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
@@ -1055,7 +1070,7 @@ async function storePayReplaySuccess(
proposalId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
@@ -1098,9 +1113,12 @@ async function handleInsufficientFunds(
): Promise<void> {
logger.trace("handling insufficient funds, trying to re-select coins");
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
return;
}
@@ -1136,17 +1154,23 @@ async function handleInsufficientFunds(
}
const payCoinSelection = payInfo.payCoinSelection;
+ if (!payCoinSelection) {
+ return;
+ }
- await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const contrib = payCoinSelection.coinContributions[i];
- prevPayCoins.push({
- coinPub,
- contribution: Amounts.parseOrThrow(contrib),
- });
- }
- });
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const contrib = payCoinSelection.coinContributions[i];
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ });
+ }
+ },
+ );
const res = await selectPayCoins(wex, {
restrictExchanges: {
@@ -1156,26 +1180,35 @@ async function handleInsufficientFunds(
restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: 1, // FIXME #8653
prevPayCoins,
requiredMinimumAge: contractData.minimumAge,
});
- if (res.type !== "success") {
- logger.trace("insufficient funds for coin re-selection");
- return;
+ switch (res.type) {
+ case "failure":
+ logger.trace("insufficient funds for coin re-selection");
+ return;
+ case "prospective":
+ return;
+ case "success":
+ break;
+ default:
+ assertUnreachable(res);
}
logger.trace("re-selected coins");
await wex.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -1224,9 +1257,12 @@ async function checkPaymentByProposalId(
proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> {
- let proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ let proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
}
@@ -1235,7 +1271,7 @@ async function checkPaymentByProposalId(
if (existingProposalId) {
logger.trace("using existing purchase for same product");
const oldProposal = await wex.db.runReadOnlyTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
return tx.purchases.get(existingProposalId);
},
@@ -1254,6 +1290,8 @@ async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
+ const currency = Amounts.currencyOf(contractData.amount);
+
const ctx = new PayMerchantTransactionContext(wex, proposalId);
const transactionId = ctx.transactionId;
@@ -1267,9 +1305,12 @@ async function checkPaymentByProposalId(
});
// First check if we already paid for it.
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (
!purchase ||
@@ -1285,29 +1326,42 @@ async function checkPaymentByProposalId(
},
contractTermsAmount: instructedAmount,
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: 1, // FIXME #8653
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
restrictWireMethod: contractData.wireMethod,
});
- if (res.type !== "success") {
- logger.info("not allowing payment, insufficient coins");
- logger.info(
- `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
- );
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- transactionId,
- amountRaw: Amounts.stringify(d.contractData.amount),
- talerUri,
- balanceDetails: res.insufficientBalanceDetails,
- };
+ 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, res.coinSel);
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@@ -1332,7 +1386,7 @@ async function checkPaymentByProposalId(
);
logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -1409,9 +1463,12 @@ export async function getContractTermsDetails(
wex: WalletExecutionContext,
proposalId: string,
): Promise<WalletContractData> {
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ 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`);
@@ -1501,7 +1558,7 @@ async function internalWaitProposalDownloaded(
): Promise<void> {
while (true) {
const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
+ { storeNames: ["purchases", "operationRetries"] },
async (tx) => {
return {
purchase: await tx.purchases.get(ctx.proposalId),
@@ -1527,39 +1584,92 @@ async function internalWaitProposalDownloaded(
}
}
+async function downloadTemplate(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ templateId: string,
+): Promise<TalerMerchantApi.WalletTemplateDetails> {
+ 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<CheckPayTemplateReponse> {
+ 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") {
+ throw TalerError.fromUncheckedDetail(cfg.detail);
+ }
+
+ return {
+ templateDetails,
+ supportedCurrencies: Object.keys(cfg.body.currencies),
+ };
+}
+
export async function preparePayForTemplate(
wex: WalletExecutionContext,
req: PreparePayTemplateRequest,
): Promise<PreparePayResult> {
const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
- const templateDetails: MerchantUsingTemplateDetails = {};
if (!parsedUri) {
throw Error("invalid taler-template URI");
}
logger.trace(`parsed URI: ${j2s(parsedUri)}`);
+ const templateDetails: MerchantUsingTemplateDetails = {};
- const amountFromUri = parsedUri.templateParams.amount;
- if (amountFromUri != null) {
- const templateParamsAmount = req.templateParams?.amount;
- if (templateParamsAmount != null) {
- templateDetails.amount = templateParamsAmount as AmountString;
- } else {
- if (Amounts.isCurrency(amountFromUri)) {
- throw Error(
- "Amount from template URI only has a currency without value. The value must be provided in the templateParams.",
- );
- } else {
- templateDetails.amount = amountFromUri as AmountString;
- }
+ 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;
}
- if (
- parsedUri.templateParams.summary !== undefined &&
- typeof parsedUri.templateParams.summary === "string"
- ) {
- templateDetails.summary =
- req.templateParams?.summary ?? parsedUri.templateParams.summary;
+
+ 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,
@@ -1570,7 +1680,7 @@ export async function preparePayForTemplate(
});
const resp = await readSuccessResponseJsonOrThrow(
httpReq,
- codecForMerchantPostOrderResponse(),
+ codecForPostOrderResponse(),
);
const payUri = stringifyPayUri({
@@ -1598,24 +1708,27 @@ export async function generateDepositPermissions(
coin: CoinRecord;
denom: DenominationRecord;
}> = [];
- await wex.db.runReadOnlyTx(["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",
- );
+ 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 });
}
- coinWithDenom.push({ coin, denom });
- }
- });
+ },
+ );
for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
const { coin, denom } = coinWithDenom[i];
@@ -1650,7 +1763,7 @@ async function internalWaitPaymentResult(
): Promise<ConfirmPayResult> {
while (true) {
const txRes = await ctx.wex.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
+ { storeNames: ["purchases", "operationRetries"] },
async (tx) => {
const purchase = await tx.purchases.get(ctx.proposalId);
const retryRecord = await tx.operationRetries.get(ctx.taskId);
@@ -1764,9 +1877,12 @@ export async function confirmPay(
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ 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`);
@@ -1778,7 +1894,7 @@ export async function confirmPay(
}
const existingPurchase = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (
@@ -1811,6 +1927,8 @@ export async function confirmPay(
const contractData = d.contractData;
+ const currency = Amounts.currencyOf(contractData.amount);
+
const selectCoinsResult = await selectPayCoins(wex, {
restrictExchanges: {
auditors: [],
@@ -1819,24 +1937,35 @@ export async function confirmPay(
restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: 1, // FIXME #8653
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
forcedSelection: forcedCoinSel,
});
- logger.trace("coin selection result", selectCoinsResult);
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
- if (selectCoinsResult.type === "failure") {
- // Should not happen, since checkPay should be called first
- // FIXME: Actually, this should be handled gracefully,
- // and the status should be stored in the DB.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
+ 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);
}
- const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(wex, coinSelection);
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
let sessionId: string | undefined;
if (sessionIdOverride) {
@@ -1850,13 +1979,16 @@ export async function confirmPay(
);
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const p = await tx.purchases.get(proposal.proposalId);
if (!p) {
@@ -1867,29 +1999,37 @@ export async function confirmPay(
case PurchaseStatus.DialogShared:
case PurchaseStatus.DialogProposed:
p.payInfo = {
- payCoinSelection: {
- coinContributions: coinSelection.coins.map((x) => x.contribution),
- coinPubs: coinSelection.coins.map((x) => x.coinPub),
- },
- payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
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 spendCoins(wex, tx, {
- //`txn:proposal:${p.proposalId}`
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: coinSelection.coins.map((x) => x.coinPub),
- contributions: coinSelection.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
+ if (p.payInfo.payCoinSelection) {
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: sel.coinPubs,
+ contributions: sel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ }
+
break;
case PurchaseStatus.Done:
case PurchaseStatus.PendingPaying:
@@ -1920,9 +2060,12 @@ export async function processPurchase(
wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!purchase) {
return {
type: TaskRunResultType.Error,
@@ -1979,9 +2122,12 @@ async function processPurchasePay(
wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!purchase) {
return {
type: TaskRunResultType.Error,
@@ -2003,6 +2149,8 @@ async function processPurchasePay(
}
logger.trace(`processing purchase pay ${proposalId}`);
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
const sessionId = purchase.lastSessionId;
logger.trace(`paying with session ID ${sessionId}`);
@@ -2020,7 +2168,7 @@ async function processPurchasePay(
if (paid) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2051,6 +2199,110 @@ async function processPurchasePay(
}
}
+ const contractData = download.contractData;
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ if (!payInfo.payCoinSelection) {
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ throw Error("insufficient balance (pending refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ selectCoinsResult.coinSel.coins,
+ );
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return false;
+ }
+ if (p.payInfo?.payCoinSelection) {
+ return false;
+ }
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ payCoinSelection: {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ },
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ contributions: selectCoinsResult.coinSel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ return true;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ return false;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${download.contractData.orderId}/pay`,
@@ -2105,6 +2357,7 @@ async function processPurchasePay(
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()}`);
@@ -2190,7 +2443,7 @@ export async function refuseProposal(
proposalId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const proposal = await tx.purchases.get(proposalId);
if (!proposal) {
@@ -2464,42 +2717,45 @@ export async function sharePayment(
merchantBaseUrl: string,
orderId: string,
): Promise<SharePaymentResult> {
- const result = await wex.db.runReadWriteTx(["purchases"], async (tx) => {
- const p = await tx.purchases.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return undefined;
- }
- if (
- p.purchaseStatus !== PurchaseStatus.DialogProposed &&
- p.purchaseStatus !== PurchaseStatus.DialogShared
- ) {
- // FIXME: purchase can be shared before being paid
- return undefined;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
- p.purchaseStatus = PurchaseStatus.DialogShared;
- p.shared = true;
- await tx.purchases.put(p);
- }
+ const result = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (
+ p.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ p.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ // FIXME: purchase can be shared before being paid
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
+ p.purchaseStatus = PurchaseStatus.DialogShared;
+ p.shared = true;
+ await tx.purchases.put(p);
+ }
- const newTxState = computePayMerchantTransactionState(p);
+ const newTxState = computePayMerchantTransactionState(p);
- return {
- proposalId: p.proposalId,
- nonce: p.noncePriv,
- session: p.lastSessionId ?? p.downloadSessionId,
- token: p.claimToken,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- });
+ 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");
@@ -2574,7 +2830,7 @@ async function processPurchaseDialogShared(
);
if (paid) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2613,23 +2869,47 @@ async function processPurchaseAutoRefund(
const download = await expectProposalDownload(wex, purchase);
- if (
+ const noAutoRefundOrExpired =
!purchase.autoRefundDeadline ||
AbsoluteTime.isExpired(
AbsoluteTime.fromProtocolTimestamp(
timestampProtocolFromDb(purchase.autoRefundDeadline),
),
- )
- ) {
+ );
+
+ const totalKnownRefund = await wex.db.runReadOnlyTx(
+ { storeNames: ["refundGroups"] },
+ async (tx) => {
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+ const am = Amounts.parseOrThrow(download.contractData.amount);
+ return refunds.reduce((prev, cur) => {
+ if (
+ cur.status === RefundGroupStatus.Done ||
+ cur.status === RefundGroupStatus.Pending
+ ) {
+ return Amounts.add(prev, cur.amountEffective).amount;
+ }
+ return prev;
+ }, Amounts.zeroOfAmount(am));
+ },
+ );
+
+ const refundedIsLessThanPrice =
+ Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1;
+ const nothingMoreToRefund = !refundedIsLessThanPrice;
+
+ if (noAutoRefundOrExpired || nothingMoreToRefund) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
return;
}
const oldTxState = computePayMerchantTransactionState(p);
@@ -2653,8 +2933,8 @@ async function processPurchaseAutoRefund(
download.contractData.contractTermsHash,
);
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
+ requestUrl.searchParams.set("timeout_ms", "10000");
+ requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
const resp = await wex.http.fetch(requestUrl.href, {
cancellationToken: wex.cancellationToken,
@@ -2669,7 +2949,7 @@ async function processPurchaseAutoRefund(
if (orderStatus.refund_pending) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2687,9 +2967,10 @@ async function processPurchaseAutoRefund(
},
);
notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
}
- return TaskRunResult.backoff();
+ return TaskRunResult.longpollReturnedPending();
}
async function processPurchaseAbortingRefund(
@@ -2712,7 +2993,7 @@ async function processPurchaseAbortingRefund(
throw Error("can't abort, no coins selected");
}
- await wex.db.runReadOnlyTx(["coins"], async (tx) => {
+ 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);
@@ -2818,7 +3099,7 @@ async function processPurchaseQueryRefund(
if (!orderStatus.refund_pending) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2845,7 +3126,7 @@ async function processPurchaseQueryRefund(
).amount;
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2913,7 +3194,7 @@ export async function startRefundQueryForUri(
throw Error("expected taler://refund URI");
}
const purchaseRecord = await wex.db.runReadOnlyTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
return tx.purchases.indexes.byUrlAndOrderId.get([
parsedUri.merchantBaseUrl,
@@ -2944,7 +3225,7 @@ export async function startQueryRefund(
): Promise<void> {
const ctx = new PayMerchantTransactionContext(wex, proposalId);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -3037,17 +3318,20 @@ async function storeRefunds(
const currency = Amounts.currencyOf(download.contractData.amount);
const result = await wex.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "purchases",
- "refundItems",
- "refundGroups",
- "denominations",
- "coins",
- "coinAvailability",
- "refreshGroups",
- ],
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
async (tx) => {
const myPurchase = await tx.purchases.get(purchase.proposalId);
if (!myPurchase) {
@@ -3204,9 +3488,20 @@ async function storeRefunds(
}
const oldTxState = computePayMerchantTransactionState(myPurchase);
+
+ const shouldCheckAutoRefund =
+ myPurchase.autoRefundDeadline &&
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(myPurchase.autoRefundDeadline),
+ ),
+ );
+
if (numPendingItemsTotal === 0) {
if (isAborting) {
myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else if (shouldCheckAutoRefund) {
+ myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
} else {
myPurchase.purchaseStatus = PurchaseStatus.Done;
}