diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 096c3f04e..e39d73672 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -970,18 +970,6 @@ export interface WireFee { sig: string; } -export enum PurchaseStatus { - /** - * We're currently paying, either for the first - * time or as a re-play potentially with a different - * session ID. - */ - SubmitPay = "submit-pay", - QueryRefund = "query-refund", - ProcessRefund = "process-refund", - Abort = "abort", - Dormant = "dormant", -} /** * Record that stores status information about one purchase, starting from when @@ -994,11 +982,6 @@ export interface PurchaseRecord { */ proposalId: string; - /** - * Status of this purchase. - */ - status: PurchaseStatus; - /** * Hash of the contract terms. */ @@ -1021,10 +1004,9 @@ export interface PurchaseRecord { merchantSig: string; /** - * The purchase isn't active anymore, it's either successfully paid or - * refunded/aborted. + * A successful payment has been made. */ - finished: boolean; + payFinished: boolean; /** * Pending refunds for the purchase. @@ -1046,13 +1028,15 @@ export interface PurchaseRecord { * When was the last refund made? * Set to 0 if no refund was made on the purchase. */ - lastRefundTimestamp: Timestamp | undefined; + lastRefundStatusTimestamp: Timestamp | undefined; /** * Last session signature that we submitted to /pay (if any). */ lastSessionId: string | undefined; + refundStatusRequested: boolean; + /** * An abort (with refund) was requested for this (incomplete!) purchase. */ @@ -1063,9 +1047,29 @@ export interface PurchaseRecord { */ abortDone: boolean; - retryInfo: RetryInfo; + payRetryInfo: RetryInfo; - lastError: OperationError | undefined; + lastPayError: OperationError | undefined; + + /** + * Retry information for querying the refund status with the merchant. + */ + refundStatusRetryInfo: RetryInfo; + + /** + * Last error (or undefined) for querying the refund status with the merchant. + */ + lastRefundStatusError: OperationError | undefined; + + /** + * Retry information for querying the refund status with the merchant. + */ + refundApplyRetryInfo: RetryInfo; + + /** + * Last error (or undefined) for querying the refund status with the merchant. + */ + lastRefundApplyError: OperationError | undefined; } /** diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts index a1351014c..082e62563 100644 --- a/src/wallet-impl/balance.ts +++ b/src/wallet-impl/balance.ts @@ -138,7 +138,7 @@ export async function getBalances( }); await tx.iter(Stores.purchases).forEach(t => { - if (t.finished) { + if (t.payFinished) { return; } for (const c of t.payReq.coins) { diff --git a/src/wallet-impl/exchanges.ts b/src/wallet-impl/exchanges.ts index b6a2f1c8a..3814971a3 100644 --- a/src/wallet-impl/exchanges.ts +++ b/src/wallet-impl/exchanges.ts @@ -44,7 +44,10 @@ import { } from "../util/query"; import * as Amounts from "../util/amounts"; import { parsePaytoUri } from "../util/payto"; -import { OperationFailedAndReportedError } from "./errors"; +import { + OperationFailedAndReportedError, + guardOperationException, +} from "./errors"; async function denominationRecordFromKeys( ws: InternalWalletState, @@ -307,12 +310,24 @@ async function updateExchangeWithWireInfo( }); } +export async function updateExchangeFromUrl( + ws: InternalWalletState, + baseUrl: string, + force: boolean = false, +): Promise { + const onOpErr = (e: OperationError) => setExchangeError(ws, baseUrl, e); + return await guardOperationException( + () => updateExchangeFromUrlImpl(ws, baseUrl, force), + onOpErr, + ); +} + /** * Update or add exchange DB entry by fetching the /keys and /wire information. * Optionally link the reserve entry to the new or existing * exchange entry in then DB. */ -export async function updateExchangeFromUrl( +async function updateExchangeFromUrlImpl( ws: InternalWalletState, baseUrl: string, force: boolean = false, diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts index 5e93ab878..23887e895 100644 --- a/src/wallet-impl/history.ts +++ b/src/wallet-impl/history.ts @@ -82,7 +82,7 @@ export async function getHistory( type: "pay", explicit: false, }); - if (p.lastRefundTimestamp) { + if (p.lastRefundStatusTimestamp) { const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); const amountsPending = Object.keys(p.refundsPending).map(x => Amounts.parseOrThrow(p.refundsPending[x].refund_amount), @@ -103,7 +103,7 @@ export async function getHistory( merchantName: p.contractTerms.merchant.name, refundAmount: amount, }, - timestamp: p.lastRefundTimestamp, + timestamp: p.lastRefundStatusTimestamp, type: "refund", explicit: false, }); diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts index f07b0328c..cec1b6bc5 100644 --- a/src/wallet-impl/pay.ts +++ b/src/wallet-impl/pay.ts @@ -55,7 +55,6 @@ import { ProposalStatus, initRetryInfo, updateRetryInfoTimeout, - PurchaseStatus, } from "../dbTypes"; import * as Amounts from "../util/amounts"; import { @@ -344,18 +343,22 @@ async function recordConfirmPay( abortRequested: false, contractTerms: d.contractTerms, contractTermsHash: d.contractTermsHash, - finished: false, + payFinished: false, lastSessionId: undefined, merchantSig: d.merchantSig, payReq, refundsDone: {}, refundsPending: {}, acceptTimestamp: getTimestampNow(), - lastRefundTimestamp: undefined, + lastRefundStatusTimestamp: undefined, proposalId: proposal.proposalId, - retryInfo: initRetryInfo(), - lastError: undefined, - status: PurchaseStatus.SubmitPay, + lastPayError: undefined, + lastRefundStatusError: undefined, + payRetryInfo: initRetryInfo(), + refundStatusRetryInfo: initRetryInfo(), + refundStatusRequested: false, + lastRefundApplyError: undefined, + refundApplyRetryInfo: initRetryInfo(), }; await runWithWriteTransaction( @@ -402,7 +405,7 @@ export async function abortFailedPayment( if (!purchase) { throw Error("Purchase not found, unable to abort with refund"); } - if (purchase.finished) { + if (purchase.payFinished) { throw Error("Purchase already finished, not aborting"); } if (purchase.abortDone) { @@ -464,23 +467,65 @@ async function incrementProposalRetry( }); } -async function incrementPurchaseRetry( +async function incrementPurchasePayRetry( ws: InternalWalletState, proposalId: string, err: OperationError | undefined, ): Promise { - console.log("incrementing purchase retry with error", err); + console.log("incrementing purchase pay retry with error", err); await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { const pr = await tx.get(Stores.purchases, proposalId); if (!pr) { return; } - if (!pr.retryInfo) { + if (!pr.payRetryInfo) { return; } - pr.retryInfo.retryCounter++; - updateRetryInfoTimeout(pr.retryInfo); - pr.lastError = err; + pr.payRetryInfo.retryCounter++; + updateRetryInfoTimeout(pr.payRetryInfo); + pr.lastPayError = err; + await tx.put(Stores.purchases, pr); + }); +} + +async function incrementPurchaseQueryRefundRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise { + console.log("incrementing purchase refund query retry with error", err); + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const pr = await tx.get(Stores.purchases, proposalId); + if (!pr) { + return; + } + if (!pr.refundStatusRetryInfo) { + return; + } + pr.refundStatusRetryInfo.retryCounter++; + updateRetryInfoTimeout(pr.refundStatusRetryInfo); + pr.lastRefundStatusError = err; + await tx.put(Stores.purchases, pr); + }); +} + +async function incrementPurchaseApplyRefundRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise { + console.log("incrementing purchase refund apply retry with error", err); + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const pr = await tx.get(Stores.purchases, proposalId); + if (!pr) { + return; + } + if (!pr.refundApplyRetryInfo) { + return; + } + pr.refundApplyRetryInfo.retryCounter++; + updateRetryInfoTimeout(pr.refundStatusRetryInfo); + pr.lastRefundApplyError = err; await tx.put(Stores.purchases, pr); }); } @@ -652,10 +697,9 @@ export async function submitPay( // FIXME: properly display error throw Error("merchant payment signature invalid"); } - purchase.finished = true; - purchase.status = PurchaseStatus.Dormant; - purchase.lastError = undefined; - purchase.retryInfo = initRetryInfo(false); + purchase.payFinished = true; + purchase.lastPayError = undefined; + purchase.payRetryInfo = initRetryInfo(false); const modifiedCoins: CoinRecord[] = []; for (const pc of purchase.payReq.coins) { const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub); @@ -986,7 +1030,204 @@ export async function getFullRefundFees( return feeAcc; } -async function submitRefundsToExchange( +async function acceptRefundResponse( + ws: InternalWalletState, + proposalId: string, + refundResponse: MerchantRefundResponse, +): Promise { + const refundPermissions = refundResponse.refund_permissions; + + if (!refundPermissions.length) { + console.warn("got empty refund list"); + throw Error("empty refund"); + } + + + let numNewRefunds = 0; + + await runWithWriteTransaction(ws.db, [Stores.purchases], async (tx) => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + console.error("purchase not found, not adding refunds"); + return; + } + + if (!p.refundStatusRequested) { + return; + } + + p.lastRefundStatusTimestamp = getTimestampNow(); + p.lastRefundStatusError = undefined; + p.refundStatusRetryInfo = initRetryInfo(); + p.refundStatusRequested = false; + + for (const perm of refundPermissions) { + if ( + !p.refundsPending[perm.merchant_sig] && + !p.refundsDone[perm.merchant_sig] + ) { + p.refundsPending[perm.merchant_sig] = perm; + numNewRefunds++; + } + } + + if (numNewRefunds) { + p.lastRefundApplyError = undefined; + p.refundApplyRetryInfo = initRetryInfo(); + } + + await tx.put(Stores.purchases, p); + }); + if (numNewRefunds > 0) { + await processPurchaseApplyRefund(ws, proposalId); + } +} + +async function startRefundQuery( + ws: InternalWalletState, + proposalId: string, +): Promise { + const success = await runWithWriteTransaction( + ws.db, + [Stores.purchases], + async tx => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + console.log("no purchase found for refund URL"); + return false; + } + if (p.refundStatusRequested) { + + } + p.refundStatusRequested = true; + p.lastRefundStatusError = undefined; + p.refundStatusRetryInfo = initRetryInfo(); + await tx.put(Stores.purchases, p); + return true; + }, + ); + + if (!success) { + return; + } + + await processPurchaseQueryRefund(ws, proposalId); +} + +/** + * Accept a refund, return the contract hash for the contract + * that was involved in the refund. + */ +export async function applyRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise { + const parseResult = parseRefundUri(talerRefundUri); + + console.log("applying refund"); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const purchase = await oneShotGetIndexed( + ws.db, + Stores.purchases.orderIdIndex, + [parseResult.merchantBaseUrl, parseResult.orderId], + ); + + if (!purchase) { + throw Error("no purchase for the taler://refund/ URI was found"); + } + + console.log("processing purchase for refund"); + await startRefundQuery(ws, purchase.proposalId); + + return purchase.contractTermsHash; +} + +export async function processPurchasePay( + ws: InternalWalletState, + proposalId: string, +): Promise { + const onOpErr = (e: OperationError) => + incrementPurchasePayRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchasePayImpl(ws, proposalId), + onOpErr, + ); +} + +async function processPurchasePayImpl( + ws: InternalWalletState, + proposalId: string, +): Promise { + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + return; + } + logger.trace(`processing purchase pay ${proposalId}`); + if (purchase.payFinished) { + return; + } + await submitPay(ws, proposalId, purchase.lastSessionId); +} + +export async function processPurchaseQueryRefund( + ws: InternalWalletState, + proposalId: string, +): Promise { + const onOpErr = (e: OperationError) => + incrementPurchaseQueryRefundRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchaseQueryRefundImpl(ws, proposalId), + onOpErr, + ); +} + +async function processPurchaseQueryRefundImpl( + ws: InternalWalletState, + proposalId: string, +): Promise { + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + return; + } + if (!purchase.refundStatusRequested) { + return; + } + + const refundUrlObj = new URL( + "refund", + purchase.contractTerms.merchant_base_url, + ); + refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id); + const refundUrl = refundUrlObj.href; + let resp; + try { + resp = await ws.http.get(refundUrl); + } catch (e) { + console.error("error downloading refund permission", e); + throw e; + } + + const refundResponse = MerchantRefundResponse.checked(resp.responseJson); + await acceptRefundResponse(ws, proposalId, refundResponse); +} + +export async function processPurchaseApplyRefund( + ws: InternalWalletState, + proposalId: string, +): Promise { + const onOpErr = (e: OperationError) => + incrementPurchaseApplyRefundRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchaseApplyRefundImpl(ws, proposalId), + onOpErr, + ); +} + +async function processPurchaseApplyRefundImpl( ws: InternalWalletState, proposalId: string, ): Promise { @@ -1037,9 +1278,8 @@ async function submitRefundsToExchange( delete p.refundsPending[pk]; } if (Object.keys(p.refundsPending).length === 0) { - p.retryInfo = initRetryInfo(); - p.lastError = undefined; - p.status = PurchaseStatus.Dormant; + p.refundStatusRetryInfo = initRetryInfo(); + p.lastRefundStatusError = undefined; allRefundsProcessed = true; } await tx.put(Stores.purchases, p); @@ -1059,7 +1299,7 @@ async function submitRefundsToExchange( if (allRefundsProcessed) { ws.notify({ type: NotificationType.RefundFinished, - }) + }); } await refresh(ws, perm.coin_pub); } @@ -1069,185 +1309,3 @@ async function submitRefundsToExchange( proposalId, }); } - -async function acceptRefundResponse( - ws: InternalWalletState, - proposalId: string, - refundResponse: MerchantRefundResponse, -): Promise { - const refundPermissions = refundResponse.refund_permissions; - - if (!refundPermissions.length) { - console.warn("got empty refund list"); - throw Error("empty refund"); - } - - /** - * Add refund to purchase if not already added. - */ - function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { - if (!t) { - console.error("purchase not found, not adding refunds"); - return; - } - - t.lastRefundTimestamp = getTimestampNow(); - t.status = PurchaseStatus.ProcessRefund; - t.lastError = undefined; - t.retryInfo = initRetryInfo(); - - for (const perm of refundPermissions) { - if ( - !t.refundsPending[perm.merchant_sig] && - !t.refundsDone[perm.merchant_sig] - ) { - t.refundsPending[perm.merchant_sig] = perm; - } - } - return t; - } - // Add the refund permissions to the purchase within a DB transaction - await oneShotMutate(ws.db, Stores.purchases, proposalId, f); - await submitRefundsToExchange(ws, proposalId); -} - -async function queryRefund( - ws: InternalWalletState, - proposalId: string, -): Promise { - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (purchase?.status !== PurchaseStatus.QueryRefund) { - return; - } - - const refundUrlObj = new URL( - "refund", - purchase.contractTerms.merchant_base_url, - ); - refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id); - const refundUrl = refundUrlObj.href; - let resp; - try { - resp = await ws.http.get(refundUrl); - } catch (e) { - console.error("error downloading refund permission", e); - throw e; - } - - const refundResponse = MerchantRefundResponse.checked(resp.responseJson); - await acceptRefundResponse(ws, proposalId, refundResponse); -} - -async function startRefundQuery( - ws: InternalWalletState, - proposalId: string, -): Promise { - const success = await runWithWriteTransaction( - ws.db, - [Stores.purchases], - async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - console.log("no purchase found for refund URL"); - return false; - } - if (p.status === PurchaseStatus.QueryRefund) { - return true; - } - if (p.status === PurchaseStatus.ProcessRefund) { - return true; - } - if (p.status !== PurchaseStatus.Dormant) { - console.log( - `can't apply refund, as payment isn't done (status ${p.status})`, - ); - return false; - } - p.lastError = undefined; - p.status = PurchaseStatus.QueryRefund; - p.retryInfo = initRetryInfo(); - await tx.put(Stores.purchases, p); - return true; - }, - ); - - if (!success) { - return; - } - - await processPurchase(ws, proposalId); -} - -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - console.log("applying refund"); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await oneShotGetIndexed( - ws.db, - Stores.purchases.orderIdIndex, - [parseResult.merchantBaseUrl, parseResult.orderId], - ); - - if (!purchase) { - throw Error("no purchase for the taler://refund/ URI was found"); - } - - console.log("processing purchase for refund"); - await startRefundQuery(ws, purchase.proposalId); - - return purchase.contractTermsHash; -} - -export async function processPurchase( - ws: InternalWalletState, - proposalId: string, -): Promise { - const onOpErr = (e: OperationError) => - incrementPurchaseRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseImpl(ws, proposalId), - onOpErr, - ); -} - -async function processPurchaseImpl( - ws: InternalWalletState, - proposalId: string, -): Promise { - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - return; - } - logger.trace(`processing purchase ${proposalId}`); - switch (purchase.status) { - case PurchaseStatus.Dormant: - return; - case PurchaseStatus.Abort: - // FIXME - break; - case PurchaseStatus.SubmitPay: - break; - case PurchaseStatus.QueryRefund: - await queryRefund(ws, proposalId); - break; - case PurchaseStatus.ProcessRefund: - console.log("submitting refunds to exchange (toplvl)"); - await submitRefundsToExchange(ws, proposalId); - console.log("after submitting refunds to exchange (toplvl)"); - break; - default: - throw assertUnreachable(purchase.status); - } -} diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts index c86ed6959..169c7e291 100644 --- a/src/wallet-impl/pending.ts +++ b/src/wallet-impl/pending.ts @@ -32,9 +32,7 @@ import { ReserveRecordStatus, CoinStatus, ProposalStatus, - PurchaseStatus, } from "../dbTypes"; -import { assertUnreachable } from "../util/assertUnreachable"; function updateRetryDelay( oldDelay: Duration, @@ -355,28 +353,54 @@ async function gatherPurchasePending( onlyDue: boolean = false, ): Promise { await tx.iter(Stores.purchases).forEach((pr) => { - if (pr.status === PurchaseStatus.Dormant) { - return; + if (!pr.payFinished) { + resp.nextRetryDelay = updateRetryDelay( + resp.nextRetryDelay, + now, + pr.payRetryInfo.nextRetry, + ); + resp.pendingOperations.push({ + type: "pay", + givesLifeness: true, + isReplay: false, + proposalId: pr.proposalId, + retryInfo: pr.payRetryInfo, + lastError: pr.lastPayError, + }); } - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - pr.retryInfo.nextRetry, - ); - if (onlyDue && pr.retryInfo.nextRetry.t_ms > now.t_ms) { - return; + if (pr.refundStatusRequested) { + resp.nextRetryDelay = updateRetryDelay( + resp.nextRetryDelay, + now, + pr.refundStatusRetryInfo.nextRetry, + ); + resp.pendingOperations.push({ + type: "refund-query", + givesLifeness: true, + proposalId: pr.proposalId, + retryInfo: pr.refundStatusRetryInfo, + lastError: pr.lastRefundStatusError, + }); + } + const numRefundsPending = Object.keys(pr.refundsPending).length; + if (numRefundsPending > 0) { + const numRefundsDone = Object.keys(pr.refundsDone).length; + resp.nextRetryDelay = updateRetryDelay( + resp.nextRetryDelay, + now, + pr.refundApplyRetryInfo.nextRetry, + ); + resp.pendingOperations.push({ + type: "refund-apply", + numRefundsDone, + numRefundsPending, + givesLifeness: true, + proposalId: pr.proposalId, + retryInfo: pr.refundApplyRetryInfo, + lastError: pr.lastRefundApplyError, + }); } - resp.pendingOperations.push({ - type: "pay", - givesLifeness: true, - isReplay: false, - proposalId: pr.proposalId, - status: pr.status, - retryInfo: pr.retryInfo, - lastError: pr.lastError, - }); }); - } export async function getPendingOperations( diff --git a/src/wallet.ts b/src/wallet.ts index 489bb2af8..276e3c371 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -49,7 +49,9 @@ import { processDownloadProposal, applyRefund, getFullRefundFees, - processPurchase, + processPurchasePay, + processPurchaseQueryRefund, + processPurchaseApplyRefund, } from "./wallet-impl/pay"; import { @@ -210,7 +212,13 @@ export class Wallet { await processTip(this.ws, pending.tipId); break; case "pay": - await processPurchase(this.ws, pending.proposalId); + await processPurchasePay(this.ws, pending.proposalId); + break; + case "refund-query": + await processPurchaseQueryRefund(this.ws, pending.proposalId); + break; + case "refund-apply": + await processPurchaseApplyRefund(this.ws, pending.proposalId); break; default: assertUnreachable(pending); @@ -710,7 +718,7 @@ export class Wallet { const totalFees = totalRefundFees; return { contractTerms: purchase.contractTerms, - hasRefund: purchase.lastRefundTimestamp !== undefined, + hasRefund: purchase.lastRefundStatusTimestamp !== undefined, totalRefundAmount: totalRefundAmount, totalRefundAndRefreshFees: totalFees, }; diff --git a/src/walletTypes.ts b/src/walletTypes.ts index 2413234eb..f27970330 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -37,7 +37,6 @@ import { ExchangeWireInfo, WithdrawalSource, RetryInfo, - PurchaseStatus, } from "./dbTypes"; import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; @@ -681,11 +680,26 @@ export interface PendingPayOperation { type: "pay"; proposalId: string; isReplay: boolean; - status: PurchaseStatus; retryInfo: RetryInfo, lastError: OperationError | undefined; } +export interface PendingRefundQueryOperation { + type: "refund-query"; + proposalId: string; + retryInfo: RetryInfo, + lastError: OperationError | undefined; +} + +export interface PendingRefundApplyOperation { + type: "refund-apply"; + proposalId: string; + retryInfo: RetryInfo, + lastError: OperationError | undefined; + numRefundsPending: number; + numRefundsDone: number; +} + export interface PendingOperationInfoCommon { type: string; givesLifeness: boolean; @@ -703,6 +717,8 @@ export type PendingOperationInfo = PendingOperationInfoCommon & | PendingProposalDownloadOperation | PendingProposalChoiceOperation | PendingPayOperation + | PendingRefundQueryOperation + | PendingRefundApplyOperation ); export interface PendingOperationsResponse {