diff options
author | Florian Dold <florian@dold.me> | 2024-04-04 14:48:29 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-04-04 14:48:37 +0200 |
commit | cf31b263ae249979cea8d64ee524cfff4b50f04b (patch) | |
tree | 606dc5ceb3991472d5ab94bffffc5f158776ae0c /packages/taler-wallet-core/src | |
parent | c7e68c254aa93778b8201227d6db1ac57e081e81 (diff) | |
download | wallet-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.ts | 10 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/pay-merchant.ts | 193 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/testing.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/transactions.ts | 7 |
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); |