From 8115ac660cd9d12ef69ca80fc2e4cf8eec6b1ba1 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 5 Dec 2019 22:17:01 +0100 Subject: [PATCH] fix refunds --- src/dbTypes.ts | 2 +- src/headless/merchant.ts | 3 + src/headless/taler-wallet-cli.ts | 2 +- src/wallet-impl/errors.ts | 5 +- src/wallet-impl/pay.ts | 143 ++++++++++++++++++++----------- src/wallet-impl/pending.ts | 7 +- src/wallet-impl/withdraw.ts | 1 + src/wallet.ts | 5 +- src/walletTypes.ts | 14 ++- 9 files changed, 125 insertions(+), 57 deletions(-) diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 16edbf31a..096c3f04e 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -980,7 +980,7 @@ export enum PurchaseStatus { QueryRefund = "query-refund", ProcessRefund = "process-refund", Abort = "abort", - Done = "done", + Dormant = "dormant", } /** diff --git a/src/headless/merchant.ts b/src/headless/merchant.ts index 1b9630732..5ce50cb53 100644 --- a/src/headless/merchant.ts +++ b/src/headless/merchant.ts @@ -89,12 +89,15 @@ export class MerchantBackendConnection { summary: string, fulfillmentUrl: string, ): Promise<{ orderId: string }> { + const t = Math.floor(new Date().getTime() / 1000) + 15 * 60; const reqUrl = new URL("order", this.merchantBaseUrl).href; const orderReq = { order: { amount, summary, fulfillment_url: fulfillmentUrl, + refund_deadline: `/Date(${t})/`, + wire_transfer_deadline: `/Date(${t})/`, }, }; const resp = await axios({ diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 931cac087..71bccadef 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -137,7 +137,7 @@ async function withWallet( console.error("Operation failed: " + e.message); console.log("Hint: check pending operations for details."); } else { - console.error("caught exception:", e); + console.error("caught unhandled exception (bug?):", e); } process.exit(1); } finally { diff --git a/src/wallet-impl/errors.ts b/src/wallet-impl/errors.ts index 5df99b7d3..803497e66 100644 --- a/src/wallet-impl/errors.ts +++ b/src/wallet-impl/errors.ts @@ -52,8 +52,9 @@ export async function guardOperationException( onOpError: (e: OperationError) => Promise, ): Promise { try { - return op(); + return await op(); } catch (e) { + console.log("guard: caught exception"); if (e instanceof OperationFailedAndReportedError) { throw e; } @@ -62,6 +63,7 @@ export async function guardOperationException( throw new OperationFailedAndReportedError(e.message); } if (e instanceof Error) { + console.log("guard: caught Error"); await onOpError({ type: "exception", message: e.message, @@ -69,6 +71,7 @@ export async function guardOperationException( }); throw new OperationFailedAndReportedError(e.message); } + console.log("guard: caught something else"); await onOpError({ type: "exception", message: "non-error exception thrown", diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts index 9b2da9c7d..f07b0328c 100644 --- a/src/wallet-impl/pay.ts +++ b/src/wallet-impl/pay.ts @@ -365,6 +365,8 @@ async function recordConfirmPay( const p = await tx.get(Stores.proposals, proposal.proposalId); if (p) { p.proposalStatus = ProposalStatus.ACCEPTED; + p.lastError = undefined; + p.retryInfo = initRetryInfo(false); await tx.put(Stores.proposals, p); } await tx.put(Stores.purchases, t); @@ -467,6 +469,7 @@ async function incrementPurchaseRetry( proposalId: string, err: OperationError | undefined, ): Promise { + console.log("incrementing purchase retry with error", err); await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { const pr = await tx.get(Stores.purchases, proposalId); if (!pr) { @@ -650,6 +653,8 @@ export async function submitPay( throw Error("merchant payment signature invalid"); } purchase.finished = true; + purchase.status = PurchaseStatus.Dormant; + purchase.lastError = undefined; purchase.retryInfo = initRetryInfo(false); const modifiedCoins: CoinRecord[] = []; for (const pc of purchase.payReq.coins) { @@ -992,6 +997,7 @@ async function submitRefundsToExchange( } const pendingKeys = Object.keys(purchase.refundsPending); if (pendingKeys.length === 0) { + console.log("no pending refunds"); return; } for (const pk of pendingKeys) { @@ -1010,50 +1016,52 @@ async function submitRefundsToExchange( const exchangeUrl = purchase.payReq.coins[0].exchange_url; const reqUrl = new URL("refund", exchangeUrl); const resp = await ws.http.postJson(reqUrl.href, req); + console.log("sent refund permission"); if (resp.status !== 200) { console.error("refund failed", resp); continue; } - // Transactionally mark successful refunds as done - const transformPurchase = ( - t: PurchaseRecord | undefined, - ): PurchaseRecord | undefined => { - if (!t) { - console.warn("purchase not found, not updating refund"); - return; - } - if (t.refundsPending[pk]) { - t.refundsDone[pk] = t.refundsPending[pk]; - delete t.refundsPending[pk]; - } - return t; - }; - const transformCoin = ( - c: CoinRecord | undefined, - ): CoinRecord | undefined => { - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dirty; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - - return c; - }; + let allRefundsProcessed = false; await runWithWriteTransaction( ws.db, [Stores.purchases, Stores.coins], async tx => { - await tx.mutate(Stores.purchases, proposalId, transformPurchase); - await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + return; + } + if (p.refundsPending[pk]) { + p.refundsDone[pk] = p.refundsPending[pk]; + delete p.refundsPending[pk]; + } + if (Object.keys(p.refundsPending).length === 0) { + p.retryInfo = initRetryInfo(); + p.lastError = undefined; + p.status = PurchaseStatus.Dormant; + allRefundsProcessed = true; + } + await tx.put(Stores.purchases, p); + const c = await tx.get(Stores.coins, perm.coin_pub); + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + const refundAmount = Amounts.parseOrThrow(perm.refund_amount); + const refundFee = Amounts.parseOrThrow(perm.refund_fee); + c.status = CoinStatus.Dirty; + c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; + await tx.put(Stores.coins, c); }, ); - refresh(ws, perm.coin_pub); + if (allRefundsProcessed) { + ws.notify({ + type: NotificationType.RefundFinished, + }) + } + await refresh(ws, perm.coin_pub); } ws.notify({ @@ -1062,7 +1070,6 @@ async function submitRefundsToExchange( }); } - async function acceptRefundResponse( ws: InternalWalletState, proposalId: string, @@ -1086,6 +1093,8 @@ async function acceptRefundResponse( t.lastRefundTimestamp = getTimestampNow(); t.status = PurchaseStatus.ProcessRefund; + t.lastError = undefined; + t.retryInfo = initRetryInfo(); for (const perm of refundPermissions) { if ( @@ -1102,14 +1111,21 @@ async function acceptRefundResponse( await submitRefundsToExchange(ws, proposalId); } - -async function queryRefund(ws: InternalWalletState, proposalId: string): Promise { +async function queryRefund( + ws: InternalWalletState, + proposalId: string, +): Promise { const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); if (purchase?.status !== PurchaseStatus.QueryRefund) { return; } - const refundUrl = new URL("refund", purchase.contractTerms.merchant_base_url).href + 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); @@ -1122,22 +1138,45 @@ async function queryRefund(ws: InternalWalletState, proposalId: string): Promise 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?.status !== PurchaseStatus.Done) { - return false; - } - p.status = PurchaseStatus.QueryRefund; - return true; - }); +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 queryRefund(ws, proposalId); -} + await processPurchase(ws, proposalId); +} /** * Accept a refund, return the contract hash for the contract @@ -1149,6 +1188,8 @@ export async function applyRefund( ): Promise { const parseResult = parseRefundUri(talerRefundUri); + console.log("applying refund"); + if (!parseResult) { throw Error("invalid refund URI"); } @@ -1163,6 +1204,7 @@ export async function applyRefund( 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; @@ -1180,7 +1222,7 @@ export async function processPurchase( ); } -export async function processPurchaseImpl( +async function processPurchaseImpl( ws: InternalWalletState, proposalId: string, ): Promise { @@ -1188,8 +1230,9 @@ export async function processPurchaseImpl( if (!purchase) { return; } + logger.trace(`processing purchase ${proposalId}`); switch (purchase.status) { - case PurchaseStatus.Done: + case PurchaseStatus.Dormant: return; case PurchaseStatus.Abort: // FIXME @@ -1200,7 +1243,9 @@ export async function processPurchaseImpl( 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 bd10538af..c86ed6959 100644 --- a/src/wallet-impl/pending.ts +++ b/src/wallet-impl/pending.ts @@ -32,7 +32,9 @@ import { ReserveRecordStatus, CoinStatus, ProposalStatus, + PurchaseStatus, } from "../dbTypes"; +import { assertUnreachable } from "../util/assertUnreachable"; function updateRetryDelay( oldDelay: Duration, @@ -353,7 +355,7 @@ async function gatherPurchasePending( onlyDue: boolean = false, ): Promise { await tx.iter(Stores.purchases).forEach((pr) => { - if (pr.finished) { + if (pr.status === PurchaseStatus.Dormant) { return; } resp.nextRetryDelay = updateRetryDelay( @@ -369,6 +371,9 @@ async function gatherPurchasePending( givesLifeness: true, isReplay: false, proposalId: pr.proposalId, + status: pr.status, + retryInfo: pr.retryInfo, + lastError: pr.lastError, }); }); diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts index 7b7d0f640..3122a463c 100644 --- a/src/wallet-impl/withdraw.ts +++ b/src/wallet-impl/withdraw.ts @@ -282,6 +282,7 @@ async function processPlanchet( } if (numDone === ws.denoms.length) { ws.finishTimestamp = getTimestampNow(); + ws.lastError = undefined; ws.retryInfo = initRetryInfo(false); withdrawSessionFinished = true; } diff --git a/src/wallet.ts b/src/wallet.ts index 86b3085f4..489bb2af8 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -49,7 +49,7 @@ import { processDownloadProposal, applyRefund, getFullRefundFees, - processPurchaseImpl, + processPurchase, } from "./wallet-impl/pay"; import { @@ -180,6 +180,7 @@ export class Wallet { pending: PendingOperationInfo, forceNow: boolean = false, ): Promise { + console.log("running pending", pending); switch (pending.type) { case "bug": // Nothing to do, will just be displayed to the user @@ -209,7 +210,7 @@ export class Wallet { await processTip(this.ws, pending.tipId); break; case "pay": - await processPurchaseImpl(this.ws, pending.proposalId); + await processPurchase(this.ws, pending.proposalId); break; default: assertUnreachable(pending); diff --git a/src/walletTypes.ts b/src/walletTypes.ts index d78fc8126..2413234eb 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -37,6 +37,7 @@ import { ExchangeWireInfo, WithdrawalSource, RetryInfo, + PurchaseStatus, } from "./dbTypes"; import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; @@ -520,6 +521,7 @@ export const enum NotificationType { ReserveDepleted = "reserve-depleted", WithdrawSessionFinished = "withdraw-session-finished", WaitingForRetry = "waiting-for-retry", + RefundFinished = "refund-finished", } export interface ProposalAcceptedNotification { @@ -585,6 +587,10 @@ export interface WaitingForRetryNotification { numGivingLiveness: number; } +export interface RefundFinishedNotification { + type: NotificationType.RefundFinished; +} + export type WalletNotification = | ProposalAcceptedNotification | ProposalDownloadedNotification @@ -599,7 +605,8 @@ export type WalletNotification = | ReserveConfirmedNotification | WithdrawSessionFinishedNotification | ReserveDepletedNotification - | WaitingForRetryNotification; + | WaitingForRetryNotification + | RefundFinishedNotification; export interface OperationError { type: string; @@ -612,7 +619,7 @@ export interface PendingExchangeUpdateOperation { stage: string; reason: string; exchangeBaseUrl: string; - lastError?: OperationError; + lastError: OperationError | undefined; } export interface PendingBugOperation { @@ -674,6 +681,9 @@ export interface PendingPayOperation { type: "pay"; proposalId: string; isReplay: boolean; + status: PurchaseStatus; + retryInfo: RetryInfo, + lastError: OperationError | undefined; } export interface PendingOperationInfoCommon {