fix refunds

This commit is contained in:
Florian Dold 2019-12-05 22:17:01 +01:00
parent f67d7f54f9
commit 8115ac660c
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
9 changed files with 125 additions and 57 deletions

View File

@ -980,7 +980,7 @@ export enum PurchaseStatus {
QueryRefund = "query-refund", QueryRefund = "query-refund",
ProcessRefund = "process-refund", ProcessRefund = "process-refund",
Abort = "abort", Abort = "abort",
Done = "done", Dormant = "dormant",
} }
/** /**

View File

@ -89,12 +89,15 @@ export class MerchantBackendConnection {
summary: string, summary: string,
fulfillmentUrl: string, fulfillmentUrl: string,
): Promise<{ orderId: string }> { ): Promise<{ orderId: string }> {
const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
const reqUrl = new URL("order", this.merchantBaseUrl).href; const reqUrl = new URL("order", this.merchantBaseUrl).href;
const orderReq = { const orderReq = {
order: { order: {
amount, amount,
summary, summary,
fulfillment_url: fulfillmentUrl, fulfillment_url: fulfillmentUrl,
refund_deadline: `/Date(${t})/`,
wire_transfer_deadline: `/Date(${t})/`,
}, },
}; };
const resp = await axios({ const resp = await axios({

View File

@ -137,7 +137,7 @@ async function withWallet<T>(
console.error("Operation failed: " + e.message); console.error("Operation failed: " + e.message);
console.log("Hint: check pending operations for details."); console.log("Hint: check pending operations for details.");
} else { } else {
console.error("caught exception:", e); console.error("caught unhandled exception (bug?):", e);
} }
process.exit(1); process.exit(1);
} finally { } finally {

View File

@ -52,8 +52,9 @@ export async function guardOperationException<T>(
onOpError: (e: OperationError) => Promise<void>, onOpError: (e: OperationError) => Promise<void>,
): Promise<T> { ): Promise<T> {
try { try {
return op(); return await op();
} catch (e) { } catch (e) {
console.log("guard: caught exception");
if (e instanceof OperationFailedAndReportedError) { if (e instanceof OperationFailedAndReportedError) {
throw e; throw e;
} }
@ -62,6 +63,7 @@ export async function guardOperationException<T>(
throw new OperationFailedAndReportedError(e.message); throw new OperationFailedAndReportedError(e.message);
} }
if (e instanceof Error) { if (e instanceof Error) {
console.log("guard: caught Error");
await onOpError({ await onOpError({
type: "exception", type: "exception",
message: e.message, message: e.message,
@ -69,6 +71,7 @@ export async function guardOperationException<T>(
}); });
throw new OperationFailedAndReportedError(e.message); throw new OperationFailedAndReportedError(e.message);
} }
console.log("guard: caught something else");
await onOpError({ await onOpError({
type: "exception", type: "exception",
message: "non-error exception thrown", message: "non-error exception thrown",

View File

@ -365,6 +365,8 @@ async function recordConfirmPay(
const p = await tx.get(Stores.proposals, proposal.proposalId); const p = await tx.get(Stores.proposals, proposal.proposalId);
if (p) { if (p) {
p.proposalStatus = ProposalStatus.ACCEPTED; p.proposalStatus = ProposalStatus.ACCEPTED;
p.lastError = undefined;
p.retryInfo = initRetryInfo(false);
await tx.put(Stores.proposals, p); await tx.put(Stores.proposals, p);
} }
await tx.put(Stores.purchases, t); await tx.put(Stores.purchases, t);
@ -467,6 +469,7 @@ async function incrementPurchaseRetry(
proposalId: string, proposalId: string,
err: OperationError | undefined, err: OperationError | undefined,
): Promise<void> { ): Promise<void> {
console.log("incrementing purchase retry with error", err);
await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
const pr = await tx.get(Stores.purchases, proposalId); const pr = await tx.get(Stores.purchases, proposalId);
if (!pr) { if (!pr) {
@ -650,6 +653,8 @@ export async function submitPay(
throw Error("merchant payment signature invalid"); throw Error("merchant payment signature invalid");
} }
purchase.finished = true; purchase.finished = true;
purchase.status = PurchaseStatus.Dormant;
purchase.lastError = undefined;
purchase.retryInfo = initRetryInfo(false); purchase.retryInfo = initRetryInfo(false);
const modifiedCoins: CoinRecord[] = []; const modifiedCoins: CoinRecord[] = [];
for (const pc of purchase.payReq.coins) { for (const pc of purchase.payReq.coins) {
@ -992,6 +997,7 @@ async function submitRefundsToExchange(
} }
const pendingKeys = Object.keys(purchase.refundsPending); const pendingKeys = Object.keys(purchase.refundsPending);
if (pendingKeys.length === 0) { if (pendingKeys.length === 0) {
console.log("no pending refunds");
return; return;
} }
for (const pk of pendingKeys) { for (const pk of pendingKeys) {
@ -1010,28 +1016,34 @@ async function submitRefundsToExchange(
const exchangeUrl = purchase.payReq.coins[0].exchange_url; const exchangeUrl = purchase.payReq.coins[0].exchange_url;
const reqUrl = new URL("refund", exchangeUrl); const reqUrl = new URL("refund", exchangeUrl);
const resp = await ws.http.postJson(reqUrl.href, req); const resp = await ws.http.postJson(reqUrl.href, req);
console.log("sent refund permission");
if (resp.status !== 200) { if (resp.status !== 200) {
console.error("refund failed", resp); console.error("refund failed", resp);
continue; continue;
} }
// Transactionally mark successful refunds as done let allRefundsProcessed = false;
const transformPurchase = (
t: PurchaseRecord | undefined, await runWithWriteTransaction(
): PurchaseRecord | undefined => { ws.db,
if (!t) { [Stores.purchases, Stores.coins],
console.warn("purchase not found, not updating refund"); async tx => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
return; return;
} }
if (t.refundsPending[pk]) { if (p.refundsPending[pk]) {
t.refundsDone[pk] = t.refundsPending[pk]; p.refundsDone[pk] = p.refundsPending[pk];
delete t.refundsPending[pk]; delete p.refundsPending[pk];
} }
return t; if (Object.keys(p.refundsPending).length === 0) {
}; p.retryInfo = initRetryInfo();
const transformCoin = ( p.lastError = undefined;
c: CoinRecord | undefined, p.status = PurchaseStatus.Dormant;
): CoinRecord | undefined => { allRefundsProcessed = true;
}
await tx.put(Stores.purchases, p);
const c = await tx.get(Stores.coins, perm.coin_pub);
if (!c) { if (!c) {
console.warn("coin not found, can't apply refund"); console.warn("coin not found, can't apply refund");
return; return;
@ -1041,19 +1053,15 @@ async function submitRefundsToExchange(
c.status = CoinStatus.Dirty; c.status = CoinStatus.Dirty;
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
await tx.put(Stores.coins, c);
return c;
};
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);
}, },
); );
refresh(ws, perm.coin_pub); if (allRefundsProcessed) {
ws.notify({
type: NotificationType.RefundFinished,
})
}
await refresh(ws, perm.coin_pub);
} }
ws.notify({ ws.notify({
@ -1062,7 +1070,6 @@ async function submitRefundsToExchange(
}); });
} }
async function acceptRefundResponse( async function acceptRefundResponse(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -1086,6 +1093,8 @@ async function acceptRefundResponse(
t.lastRefundTimestamp = getTimestampNow(); t.lastRefundTimestamp = getTimestampNow();
t.status = PurchaseStatus.ProcessRefund; t.status = PurchaseStatus.ProcessRefund;
t.lastError = undefined;
t.retryInfo = initRetryInfo();
for (const perm of refundPermissions) { for (const perm of refundPermissions) {
if ( if (
@ -1102,14 +1111,21 @@ async function acceptRefundResponse(
await submitRefundsToExchange(ws, proposalId); await submitRefundsToExchange(ws, proposalId);
} }
async function queryRefund(
async function queryRefund(ws: InternalWalletState, proposalId: string): Promise<void> { ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
if (purchase?.status !== PurchaseStatus.QueryRefund) { if (purchase?.status !== PurchaseStatus.QueryRefund) {
return; 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; let resp;
try { try {
resp = await ws.http.get(refundUrl); resp = await ws.http.get(refundUrl);
@ -1122,22 +1138,45 @@ async function queryRefund(ws: InternalWalletState, proposalId: string): Promise
await acceptRefundResponse(ws, proposalId, refundResponse); await acceptRefundResponse(ws, proposalId, refundResponse);
} }
async function startRefundQuery(ws: InternalWalletState, proposalId: string): Promise<void> { async function startRefundQuery(
const success = await runWithWriteTransaction(ws.db, [Stores.purchases], async (tx) => { ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const success = await runWithWriteTransaction(
ws.db,
[Stores.purchases],
async tx => {
const p = await tx.get(Stores.purchases, proposalId); const p = await tx.get(Stores.purchases, proposalId);
if (p?.status !== PurchaseStatus.Done) { if (!p) {
console.log("no purchase found for refund URL");
return false; return false;
} }
p.status = PurchaseStatus.QueryRefund; if (p.status === PurchaseStatus.QueryRefund) {
return true; 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) { if (!success) {
return; return;
} }
await queryRefund(ws, proposalId);
}
await processPurchase(ws, proposalId);
}
/** /**
* Accept a refund, return the contract hash for the contract * Accept a refund, return the contract hash for the contract
@ -1149,6 +1188,8 @@ export async function applyRefund(
): Promise<string> { ): Promise<string> {
const parseResult = parseRefundUri(talerRefundUri); const parseResult = parseRefundUri(talerRefundUri);
console.log("applying refund");
if (!parseResult) { if (!parseResult) {
throw Error("invalid refund URI"); throw Error("invalid refund URI");
} }
@ -1163,6 +1204,7 @@ export async function applyRefund(
throw Error("no purchase for the taler://refund/ URI was found"); throw Error("no purchase for the taler://refund/ URI was found");
} }
console.log("processing purchase for refund");
await startRefundQuery(ws, purchase.proposalId); await startRefundQuery(ws, purchase.proposalId);
return purchase.contractTermsHash; return purchase.contractTermsHash;
@ -1180,7 +1222,7 @@ export async function processPurchase(
); );
} }
export async function processPurchaseImpl( async function processPurchaseImpl(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
): Promise<void> { ): Promise<void> {
@ -1188,8 +1230,9 @@ export async function processPurchaseImpl(
if (!purchase) { if (!purchase) {
return; return;
} }
logger.trace(`processing purchase ${proposalId}`);
switch (purchase.status) { switch (purchase.status) {
case PurchaseStatus.Done: case PurchaseStatus.Dormant:
return; return;
case PurchaseStatus.Abort: case PurchaseStatus.Abort:
// FIXME // FIXME
@ -1200,7 +1243,9 @@ export async function processPurchaseImpl(
await queryRefund(ws, proposalId); await queryRefund(ws, proposalId);
break; break;
case PurchaseStatus.ProcessRefund: case PurchaseStatus.ProcessRefund:
console.log("submitting refunds to exchange (toplvl)");
await submitRefundsToExchange(ws, proposalId); await submitRefundsToExchange(ws, proposalId);
console.log("after submitting refunds to exchange (toplvl)");
break; break;
default: default:
throw assertUnreachable(purchase.status); throw assertUnreachable(purchase.status);

View File

@ -32,7 +32,9 @@ import {
ReserveRecordStatus, ReserveRecordStatus,
CoinStatus, CoinStatus,
ProposalStatus, ProposalStatus,
PurchaseStatus,
} from "../dbTypes"; } from "../dbTypes";
import { assertUnreachable } from "../util/assertUnreachable";
function updateRetryDelay( function updateRetryDelay(
oldDelay: Duration, oldDelay: Duration,
@ -353,7 +355,7 @@ async function gatherPurchasePending(
onlyDue: boolean = false, onlyDue: boolean = false,
): Promise<void> { ): Promise<void> {
await tx.iter(Stores.purchases).forEach((pr) => { await tx.iter(Stores.purchases).forEach((pr) => {
if (pr.finished) { if (pr.status === PurchaseStatus.Dormant) {
return; return;
} }
resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay = updateRetryDelay(
@ -369,6 +371,9 @@ async function gatherPurchasePending(
givesLifeness: true, givesLifeness: true,
isReplay: false, isReplay: false,
proposalId: pr.proposalId, proposalId: pr.proposalId,
status: pr.status,
retryInfo: pr.retryInfo,
lastError: pr.lastError,
}); });
}); });

View File

@ -282,6 +282,7 @@ async function processPlanchet(
} }
if (numDone === ws.denoms.length) { if (numDone === ws.denoms.length) {
ws.finishTimestamp = getTimestampNow(); ws.finishTimestamp = getTimestampNow();
ws.lastError = undefined;
ws.retryInfo = initRetryInfo(false); ws.retryInfo = initRetryInfo(false);
withdrawSessionFinished = true; withdrawSessionFinished = true;
} }

View File

@ -49,7 +49,7 @@ import {
processDownloadProposal, processDownloadProposal,
applyRefund, applyRefund,
getFullRefundFees, getFullRefundFees,
processPurchaseImpl, processPurchase,
} from "./wallet-impl/pay"; } from "./wallet-impl/pay";
import { import {
@ -180,6 +180,7 @@ export class Wallet {
pending: PendingOperationInfo, pending: PendingOperationInfo,
forceNow: boolean = false, forceNow: boolean = false,
): Promise<void> { ): Promise<void> {
console.log("running pending", pending);
switch (pending.type) { switch (pending.type) {
case "bug": case "bug":
// Nothing to do, will just be displayed to the user // Nothing to do, will just be displayed to the user
@ -209,7 +210,7 @@ export class Wallet {
await processTip(this.ws, pending.tipId); await processTip(this.ws, pending.tipId);
break; break;
case "pay": case "pay":
await processPurchaseImpl(this.ws, pending.proposalId); await processPurchase(this.ws, pending.proposalId);
break; break;
default: default:
assertUnreachable(pending); assertUnreachable(pending);

View File

@ -37,6 +37,7 @@ import {
ExchangeWireInfo, ExchangeWireInfo,
WithdrawalSource, WithdrawalSource,
RetryInfo, RetryInfo,
PurchaseStatus,
} from "./dbTypes"; } from "./dbTypes";
import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes";
@ -520,6 +521,7 @@ export const enum NotificationType {
ReserveDepleted = "reserve-depleted", ReserveDepleted = "reserve-depleted",
WithdrawSessionFinished = "withdraw-session-finished", WithdrawSessionFinished = "withdraw-session-finished",
WaitingForRetry = "waiting-for-retry", WaitingForRetry = "waiting-for-retry",
RefundFinished = "refund-finished",
} }
export interface ProposalAcceptedNotification { export interface ProposalAcceptedNotification {
@ -585,6 +587,10 @@ export interface WaitingForRetryNotification {
numGivingLiveness: number; numGivingLiveness: number;
} }
export interface RefundFinishedNotification {
type: NotificationType.RefundFinished;
}
export type WalletNotification = export type WalletNotification =
| ProposalAcceptedNotification | ProposalAcceptedNotification
| ProposalDownloadedNotification | ProposalDownloadedNotification
@ -599,7 +605,8 @@ export type WalletNotification =
| ReserveConfirmedNotification | ReserveConfirmedNotification
| WithdrawSessionFinishedNotification | WithdrawSessionFinishedNotification
| ReserveDepletedNotification | ReserveDepletedNotification
| WaitingForRetryNotification; | WaitingForRetryNotification
| RefundFinishedNotification;
export interface OperationError { export interface OperationError {
type: string; type: string;
@ -612,7 +619,7 @@ export interface PendingExchangeUpdateOperation {
stage: string; stage: string;
reason: string; reason: string;
exchangeBaseUrl: string; exchangeBaseUrl: string;
lastError?: OperationError; lastError: OperationError | undefined;
} }
export interface PendingBugOperation { export interface PendingBugOperation {
@ -674,6 +681,9 @@ export interface PendingPayOperation {
type: "pay"; type: "pay";
proposalId: string; proposalId: string;
isReplay: boolean; isReplay: boolean;
status: PurchaseStatus;
retryInfo: RetryInfo,
lastError: OperationError | undefined;
} }
export interface PendingOperationInfoCommon { export interface PendingOperationInfoCommon {