separate operations for pay, refund status query and refund submission

This commit is contained in:
Florian Dold 2019-12-06 00:24:34 +01:00
parent 7b54439fd6
commit 65bccbd139
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
8 changed files with 383 additions and 258 deletions

View File

@ -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;
}
/**

View File

@ -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) {

View File

@ -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<ExchangeRecord> {
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,

View File

@ -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,
});

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
const onOpErr = (e: OperationError) =>
incrementPurchasePayRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchasePayImpl(ws, proposalId),
onOpErr,
);
}
async function processPurchasePayImpl(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
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<void> {
const onOpErr = (e: OperationError) =>
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseQueryRefundImpl(ws, proposalId),
onOpErr,
);
}
async function processPurchaseQueryRefundImpl(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
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<void> {
const onOpErr = (e: OperationError) =>
incrementPurchaseApplyRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseApplyRefundImpl(ws, proposalId),
onOpErr,
);
}
async function processPurchaseApplyRefundImpl(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
@ -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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
const onOpErr = (e: OperationError) =>
incrementPurchaseRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseImpl(ws, proposalId),
onOpErr,
);
}
async function processPurchaseImpl(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
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);
}
}

View File

@ -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<void> {
await tx.iter(Stores.purchases).forEach((pr) => {
if (pr.status === PurchaseStatus.Dormant) {
return;
}
if (!pr.payFinished) {
resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay,
now,
pr.retryInfo.nextRetry,
pr.payRetryInfo.nextRetry,
);
if (onlyDue && pr.retryInfo.nextRetry.t_ms > now.t_ms) {
return;
}
resp.pendingOperations.push({
type: "pay",
givesLifeness: true,
isReplay: false,
proposalId: pr.proposalId,
status: pr.status,
retryInfo: pr.retryInfo,
lastError: pr.lastError,
retryInfo: pr.payRetryInfo,
lastError: pr.lastPayError,
});
}
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,
});
}
});
}
export async function getPendingOperations(

View File

@ -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,
};

View File

@ -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 {