separate operations for pay, refund status query and refund submission
This commit is contained in:
parent
7b54439fd6
commit
65bccbd139
@ -970,18 +970,6 @@ export interface WireFee {
|
|||||||
sig: string;
|
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
|
* Record that stores status information about one purchase, starting from when
|
||||||
@ -994,11 +982,6 @@ export interface PurchaseRecord {
|
|||||||
*/
|
*/
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Status of this purchase.
|
|
||||||
*/
|
|
||||||
status: PurchaseStatus;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hash of the contract terms.
|
* Hash of the contract terms.
|
||||||
*/
|
*/
|
||||||
@ -1021,10 +1004,9 @@ export interface PurchaseRecord {
|
|||||||
merchantSig: string;
|
merchantSig: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The purchase isn't active anymore, it's either successfully paid or
|
* A successful payment has been made.
|
||||||
* refunded/aborted.
|
|
||||||
*/
|
*/
|
||||||
finished: boolean;
|
payFinished: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending refunds for the purchase.
|
* Pending refunds for the purchase.
|
||||||
@ -1046,13 +1028,15 @@ export interface PurchaseRecord {
|
|||||||
* When was the last refund made?
|
* When was the last refund made?
|
||||||
* Set to 0 if no refund was made on the purchase.
|
* 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).
|
* Last session signature that we submitted to /pay (if any).
|
||||||
*/
|
*/
|
||||||
lastSessionId: string | undefined;
|
lastSessionId: string | undefined;
|
||||||
|
|
||||||
|
refundStatusRequested: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An abort (with refund) was requested for this (incomplete!) purchase.
|
* An abort (with refund) was requested for this (incomplete!) purchase.
|
||||||
*/
|
*/
|
||||||
@ -1063,9 +1047,29 @@ export interface PurchaseRecord {
|
|||||||
*/
|
*/
|
||||||
abortDone: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -138,7 +138,7 @@ export async function getBalances(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await tx.iter(Stores.purchases).forEach(t => {
|
await tx.iter(Stores.purchases).forEach(t => {
|
||||||
if (t.finished) {
|
if (t.payFinished) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const c of t.payReq.coins) {
|
for (const c of t.payReq.coins) {
|
||||||
|
@ -44,7 +44,10 @@ import {
|
|||||||
} from "../util/query";
|
} from "../util/query";
|
||||||
import * as Amounts from "../util/amounts";
|
import * as Amounts from "../util/amounts";
|
||||||
import { parsePaytoUri } from "../util/payto";
|
import { parsePaytoUri } from "../util/payto";
|
||||||
import { OperationFailedAndReportedError } from "./errors";
|
import {
|
||||||
|
OperationFailedAndReportedError,
|
||||||
|
guardOperationException,
|
||||||
|
} from "./errors";
|
||||||
|
|
||||||
async function denominationRecordFromKeys(
|
async function denominationRecordFromKeys(
|
||||||
ws: InternalWalletState,
|
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.
|
* Update or add exchange DB entry by fetching the /keys and /wire information.
|
||||||
* Optionally link the reserve entry to the new or existing
|
* Optionally link the reserve entry to the new or existing
|
||||||
* exchange entry in then DB.
|
* exchange entry in then DB.
|
||||||
*/
|
*/
|
||||||
export async function updateExchangeFromUrl(
|
async function updateExchangeFromUrlImpl(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
|
@ -82,7 +82,7 @@ export async function getHistory(
|
|||||||
type: "pay",
|
type: "pay",
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
if (p.lastRefundTimestamp) {
|
if (p.lastRefundStatusTimestamp) {
|
||||||
const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount);
|
const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount);
|
||||||
const amountsPending = Object.keys(p.refundsPending).map(x =>
|
const amountsPending = Object.keys(p.refundsPending).map(x =>
|
||||||
Amounts.parseOrThrow(p.refundsPending[x].refund_amount),
|
Amounts.parseOrThrow(p.refundsPending[x].refund_amount),
|
||||||
@ -103,7 +103,7 @@ export async function getHistory(
|
|||||||
merchantName: p.contractTerms.merchant.name,
|
merchantName: p.contractTerms.merchant.name,
|
||||||
refundAmount: amount,
|
refundAmount: amount,
|
||||||
},
|
},
|
||||||
timestamp: p.lastRefundTimestamp,
|
timestamp: p.lastRefundStatusTimestamp,
|
||||||
type: "refund",
|
type: "refund",
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
|
@ -55,7 +55,6 @@ import {
|
|||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
initRetryInfo,
|
initRetryInfo,
|
||||||
updateRetryInfoTimeout,
|
updateRetryInfoTimeout,
|
||||||
PurchaseStatus,
|
|
||||||
} from "../dbTypes";
|
} from "../dbTypes";
|
||||||
import * as Amounts from "../util/amounts";
|
import * as Amounts from "../util/amounts";
|
||||||
import {
|
import {
|
||||||
@ -344,18 +343,22 @@ async function recordConfirmPay(
|
|||||||
abortRequested: false,
|
abortRequested: false,
|
||||||
contractTerms: d.contractTerms,
|
contractTerms: d.contractTerms,
|
||||||
contractTermsHash: d.contractTermsHash,
|
contractTermsHash: d.contractTermsHash,
|
||||||
finished: false,
|
payFinished: false,
|
||||||
lastSessionId: undefined,
|
lastSessionId: undefined,
|
||||||
merchantSig: d.merchantSig,
|
merchantSig: d.merchantSig,
|
||||||
payReq,
|
payReq,
|
||||||
refundsDone: {},
|
refundsDone: {},
|
||||||
refundsPending: {},
|
refundsPending: {},
|
||||||
acceptTimestamp: getTimestampNow(),
|
acceptTimestamp: getTimestampNow(),
|
||||||
lastRefundTimestamp: undefined,
|
lastRefundStatusTimestamp: undefined,
|
||||||
proposalId: proposal.proposalId,
|
proposalId: proposal.proposalId,
|
||||||
retryInfo: initRetryInfo(),
|
lastPayError: undefined,
|
||||||
lastError: undefined,
|
lastRefundStatusError: undefined,
|
||||||
status: PurchaseStatus.SubmitPay,
|
payRetryInfo: initRetryInfo(),
|
||||||
|
refundStatusRetryInfo: initRetryInfo(),
|
||||||
|
refundStatusRequested: false,
|
||||||
|
lastRefundApplyError: undefined,
|
||||||
|
refundApplyRetryInfo: initRetryInfo(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await runWithWriteTransaction(
|
await runWithWriteTransaction(
|
||||||
@ -402,7 +405,7 @@ export async function abortFailedPayment(
|
|||||||
if (!purchase) {
|
if (!purchase) {
|
||||||
throw Error("Purchase not found, unable to abort with refund");
|
throw Error("Purchase not found, unable to abort with refund");
|
||||||
}
|
}
|
||||||
if (purchase.finished) {
|
if (purchase.payFinished) {
|
||||||
throw Error("Purchase already finished, not aborting");
|
throw Error("Purchase already finished, not aborting");
|
||||||
}
|
}
|
||||||
if (purchase.abortDone) {
|
if (purchase.abortDone) {
|
||||||
@ -464,23 +467,65 @@ async function incrementProposalRetry(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function incrementPurchaseRetry(
|
async function incrementPurchasePayRetry(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
err: OperationError | undefined,
|
err: OperationError | undefined,
|
||||||
): Promise<void> {
|
): 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 => {
|
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) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pr.retryInfo) {
|
if (!pr.payRetryInfo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pr.retryInfo.retryCounter++;
|
pr.payRetryInfo.retryCounter++;
|
||||||
updateRetryInfoTimeout(pr.retryInfo);
|
updateRetryInfoTimeout(pr.payRetryInfo);
|
||||||
pr.lastError = err;
|
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);
|
await tx.put(Stores.purchases, pr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -652,10 +697,9 @@ export async function submitPay(
|
|||||||
// FIXME: properly display error
|
// FIXME: properly display error
|
||||||
throw Error("merchant payment signature invalid");
|
throw Error("merchant payment signature invalid");
|
||||||
}
|
}
|
||||||
purchase.finished = true;
|
purchase.payFinished = true;
|
||||||
purchase.status = PurchaseStatus.Dormant;
|
purchase.lastPayError = undefined;
|
||||||
purchase.lastError = undefined;
|
purchase.payRetryInfo = 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) {
|
||||||
const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub);
|
const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub);
|
||||||
@ -986,7 +1030,204 @@ export async function getFullRefundFees(
|
|||||||
return feeAcc;
|
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,
|
ws: InternalWalletState,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -1037,9 +1278,8 @@ async function submitRefundsToExchange(
|
|||||||
delete p.refundsPending[pk];
|
delete p.refundsPending[pk];
|
||||||
}
|
}
|
||||||
if (Object.keys(p.refundsPending).length === 0) {
|
if (Object.keys(p.refundsPending).length === 0) {
|
||||||
p.retryInfo = initRetryInfo();
|
p.refundStatusRetryInfo = initRetryInfo();
|
||||||
p.lastError = undefined;
|
p.lastRefundStatusError = undefined;
|
||||||
p.status = PurchaseStatus.Dormant;
|
|
||||||
allRefundsProcessed = true;
|
allRefundsProcessed = true;
|
||||||
}
|
}
|
||||||
await tx.put(Stores.purchases, p);
|
await tx.put(Stores.purchases, p);
|
||||||
@ -1059,7 +1299,7 @@ async function submitRefundsToExchange(
|
|||||||
if (allRefundsProcessed) {
|
if (allRefundsProcessed) {
|
||||||
ws.notify({
|
ws.notify({
|
||||||
type: NotificationType.RefundFinished,
|
type: NotificationType.RefundFinished,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
await refresh(ws, perm.coin_pub);
|
await refresh(ws, perm.coin_pub);
|
||||||
}
|
}
|
||||||
@ -1069,185 +1309,3 @@ async function submitRefundsToExchange(
|
|||||||
proposalId,
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -32,9 +32,7 @@ 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,
|
||||||
@ -355,28 +353,54 @@ 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.status === PurchaseStatus.Dormant) {
|
if (!pr.payFinished) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
resp.nextRetryDelay = updateRetryDelay(
|
resp.nextRetryDelay = updateRetryDelay(
|
||||||
resp.nextRetryDelay,
|
resp.nextRetryDelay,
|
||||||
now,
|
now,
|
||||||
pr.retryInfo.nextRetry,
|
pr.payRetryInfo.nextRetry,
|
||||||
);
|
);
|
||||||
if (onlyDue && pr.retryInfo.nextRetry.t_ms > now.t_ms) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resp.pendingOperations.push({
|
resp.pendingOperations.push({
|
||||||
type: "pay",
|
type: "pay",
|
||||||
givesLifeness: true,
|
givesLifeness: true,
|
||||||
isReplay: false,
|
isReplay: false,
|
||||||
proposalId: pr.proposalId,
|
proposalId: pr.proposalId,
|
||||||
status: pr.status,
|
retryInfo: pr.payRetryInfo,
|
||||||
retryInfo: pr.retryInfo,
|
lastError: pr.lastPayError,
|
||||||
lastError: pr.lastError,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
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(
|
export async function getPendingOperations(
|
||||||
|
@ -49,7 +49,9 @@ import {
|
|||||||
processDownloadProposal,
|
processDownloadProposal,
|
||||||
applyRefund,
|
applyRefund,
|
||||||
getFullRefundFees,
|
getFullRefundFees,
|
||||||
processPurchase,
|
processPurchasePay,
|
||||||
|
processPurchaseQueryRefund,
|
||||||
|
processPurchaseApplyRefund,
|
||||||
} from "./wallet-impl/pay";
|
} from "./wallet-impl/pay";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -210,7 +212,13 @@ export class Wallet {
|
|||||||
await processTip(this.ws, pending.tipId);
|
await processTip(this.ws, pending.tipId);
|
||||||
break;
|
break;
|
||||||
case "pay":
|
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;
|
break;
|
||||||
default:
|
default:
|
||||||
assertUnreachable(pending);
|
assertUnreachable(pending);
|
||||||
@ -710,7 +718,7 @@ export class Wallet {
|
|||||||
const totalFees = totalRefundFees;
|
const totalFees = totalRefundFees;
|
||||||
return {
|
return {
|
||||||
contractTerms: purchase.contractTerms,
|
contractTerms: purchase.contractTerms,
|
||||||
hasRefund: purchase.lastRefundTimestamp !== undefined,
|
hasRefund: purchase.lastRefundStatusTimestamp !== undefined,
|
||||||
totalRefundAmount: totalRefundAmount,
|
totalRefundAmount: totalRefundAmount,
|
||||||
totalRefundAndRefreshFees: totalFees,
|
totalRefundAndRefreshFees: totalFees,
|
||||||
};
|
};
|
||||||
|
@ -37,7 +37,6 @@ import {
|
|||||||
ExchangeWireInfo,
|
ExchangeWireInfo,
|
||||||
WithdrawalSource,
|
WithdrawalSource,
|
||||||
RetryInfo,
|
RetryInfo,
|
||||||
PurchaseStatus,
|
|
||||||
} from "./dbTypes";
|
} from "./dbTypes";
|
||||||
import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes";
|
import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes";
|
||||||
|
|
||||||
@ -681,11 +680,26 @@ export interface PendingPayOperation {
|
|||||||
type: "pay";
|
type: "pay";
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
isReplay: boolean;
|
isReplay: boolean;
|
||||||
status: PurchaseStatus;
|
|
||||||
retryInfo: RetryInfo,
|
retryInfo: RetryInfo,
|
||||||
lastError: OperationError | undefined;
|
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 {
|
export interface PendingOperationInfoCommon {
|
||||||
type: string;
|
type: string;
|
||||||
givesLifeness: boolean;
|
givesLifeness: boolean;
|
||||||
@ -703,6 +717,8 @@ export type PendingOperationInfo = PendingOperationInfoCommon &
|
|||||||
| PendingProposalDownloadOperation
|
| PendingProposalDownloadOperation
|
||||||
| PendingProposalChoiceOperation
|
| PendingProposalChoiceOperation
|
||||||
| PendingPayOperation
|
| PendingPayOperation
|
||||||
|
| PendingRefundQueryOperation
|
||||||
|
| PendingRefundApplyOperation
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface PendingOperationsResponse {
|
export interface PendingOperationsResponse {
|
||||||
|
Loading…
Reference in New Issue
Block a user