summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-04-04 14:48:29 +0200
committerFlorian Dold <florian@dold.me>2024-04-04 14:48:37 +0200
commitcf31b263ae249979cea8d64ee524cfff4b50f04b (patch)
tree606dc5ceb3991472d5ab94bffffc5f158776ae0c /packages/taler-wallet-core/src
parentc7e68c254aa93778b8201227d6db1ac57e081e81 (diff)
downloadwallet-core-cf31b263ae249979cea8d64ee524cfff4b50f04b.tar.gz
wallet-core-cf31b263ae249979cea8d64ee524cfff4b50f04b.tar.bz2
wallet-core-cf31b263ae249979cea8d64ee524cfff4b50f04b.zip
wallet-core: allow payments to merchant with coins locked behind refresh
Diffstat (limited to 'packages/taler-wallet-core/src')
-rw-r--r--packages/taler-wallet-core/src/db.ts10
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts193
-rw-r--r--packages/taler-wallet-core/src/testing.ts2
-rw-r--r--packages/taler-wallet-core/src/transactions.ts7
4 files changed, 172 insertions, 40 deletions
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index 7b9dfa2a2..a675fa8dd 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -1226,9 +1226,15 @@ export interface DbCoinSelection {
}
export interface PurchasePayInfo {
- payCoinSelection: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelection?: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelectionUid?: string;
totalPayCost: AmountString;
- payCoinSelectionUid: string;
}
/**
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index 25725052c..a2089d843 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -291,7 +291,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,
@@ -519,7 +519,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,
);
}
@@ -1130,6 +1130,9 @@ 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++) {
@@ -1155,9 +1158,16 @@ async function handleInsufficientFunds(
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");
@@ -1288,6 +1298,8 @@ async function checkPaymentByProposalId(
restrictWireMethod: contractData.wireMethod,
});
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
switch (res.type) {
case "failure": {
logger.info("not allowing payment, insufficient coins");
@@ -1307,18 +1319,16 @@ async function checkPaymentByProposalId(
};
}
case "prospective":
- throw Error("insufficient balance (waiting on refresh)");
+ coins = res.result.prospectiveCoins;
+ break;
case "success":
+ coins = res.coinSel.coins;
break;
default:
assertUnreachable(res);
}
- const totalCost = await getTotalPaymentCost(
- wex,
- currency,
- res.coinSel.coins,
- );
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@@ -1838,6 +1848,8 @@ export async function confirmPay(
forcedSelection: forcedCoinSel,
});
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
switch (selectCoinsResult.type) {
case "failure": {
// Should not happen, since checkPay should be called first
@@ -1847,9 +1859,11 @@ export async function confirmPay(
throw Error("insufficient balance");
}
case "prospective": {
- throw Error("insufficient balance (waiting on refresh)");
+ coins = selectCoinsResult.result.prospectiveCoins;
+ break;
}
case "success":
+ coins = selectCoinsResult.coinSel.coins;
break;
default:
assertUnreachable(selectCoinsResult);
@@ -1857,12 +1871,7 @@ export async function confirmPay(
logger.trace("coin selection result", selectCoinsResult);
- const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(
- wex,
- currency,
- coinSelection.coins,
- );
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
let sessionId: string | undefined;
if (sessionIdOverride) {
@@ -1894,29 +1903,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:
@@ -2030,6 +2047,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}`);
@@ -2078,6 +2097,109 @@ 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),
+ wireFeeAmortization: 1, // FIXME #8653
+ 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(
+ [
+ "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);
+ const sel = p.payInfo.payCoinSelection;
+ 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`,
@@ -2132,6 +2254,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()}`);
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
index b192e7b70..32c0765b4 100644
--- a/packages/taler-wallet-core/src/testing.ts
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -912,6 +912,6 @@ export async function testPay(
});
checkLogicInvariant(!!purchase);
return {
- numCoins: purchase.payInfo?.payCoinSelection.coinContributions.length ?? 0,
+ numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
};
}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
index 463aa97ba..536f0de4b 100644
--- a/packages/taler-wallet-core/src/transactions.ts
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -1264,7 +1264,10 @@ export async function getTransactions(
const exchangesInTx: string[] = [];
const p = await tx.purchases.get(refundGroup.proposalId);
- if (!p || !p.payInfo) return; //refund with no payment
+ if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
+ //refund with no payment
+ return;
+ }
// FIXME: This is very slow, should become obsolete with materialized transactions.
for (const cp of p.payInfo.payCoinSelection.coinPubs) {
@@ -1410,7 +1413,7 @@ export async function getTransactions(
}
const exchangesInTx: string[] = [];
- for (const cp of purchase.payInfo.payCoinSelection.coinPubs) {
+ for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
const c = await tx.coins.get(cp);
if (c?.exchangeBaseUrl) {
exchangesInTx.push(c.exchangeBaseUrl);