introduce refund groups, react correctly to 410 Gone for /refund

This commit is contained in:
Florian Dold 2019-12-15 19:04:14 +01:00
parent 7cc3b10824
commit 857c0ab4cd
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 131 additions and 450 deletions

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler This file is part of GNU Taler
(C) 2019 GNUnet e.V. (C) Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software terms of the GNU General Public License as published by the Free Software
@ -14,6 +14,16 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
/**
* Implementation of the payment operation, including downloading and
* claiming of proposals.
*
* @author Florian Dold
*/
/**
* Imports.
*/
import { AmountJson } from "../util/amounts"; import { AmountJson } from "../util/amounts";
import { import {
Auditor, Auditor,
@ -36,9 +46,6 @@ import {
OperationError, OperationError,
RefreshReason, RefreshReason,
} from "../types/walletTypes"; } from "../types/walletTypes";
import {
Database
} from "../util/query";
import { import {
Stores, Stores,
CoinStatus, CoinStatus,
@ -49,6 +56,7 @@ import {
ProposalStatus, ProposalStatus,
initRetryInfo, initRetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
RefundReason,
} from "../types/dbTypes"; } from "../types/dbTypes";
import * as Amounts from "../util/amounts"; import * as Amounts from "../util/amounts";
import { import {
@ -56,7 +64,6 @@ import {
strcmp, strcmp,
canonicalJson, canonicalJson,
extractTalerStampOrThrow, extractTalerStampOrThrow,
extractTalerDurationOrThrow,
extractTalerDuration, extractTalerDuration,
} from "../util/helpers"; } from "../util/helpers";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
@ -69,8 +76,8 @@ import {
import { getTotalRefreshCost, createRefreshGroup } from "./refresh"; import { getTotalRefreshCost, createRefreshGroup } from "./refresh";
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { guardOperationException } from "./errors"; import { guardOperationException } from "./errors";
import { assertUnreachable } from "../util/assertUnreachable";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { acceptRefundResponse } from "./refund";
export interface SpeculativePayData { export interface SpeculativePayData {
payCoinInfo: PayCoinInfo; payCoinInfo: PayCoinInfo;
@ -237,15 +244,13 @@ async function getCoinsForPayment(
continue; continue;
} }
const coins = await ws.db.iterIndex( const coins = await ws.db
Stores.coins.exchangeBaseUrlIndex, .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
exchange.baseUrl, .toArray();
).toArray();
const denoms = await ws.db.iterIndex( const denoms = await ws.db
Stores.denominations.exchangeBaseUrlIndex, .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
exchange.baseUrl, .toArray();
).toArray();
if (!coins || coins.length === 0) { if (!coins || coins.length === 0) {
continue; continue;
@ -353,8 +358,6 @@ async function recordConfirmPay(
lastSessionId: sessionId, lastSessionId: sessionId,
merchantSig: d.merchantSig, merchantSig: d.merchantSig,
payReq, payReq,
refundsDone: {},
refundsPending: {},
acceptTimestamp: getTimestampNow(), acceptTimestamp: getTimestampNow(),
lastRefundStatusTimestamp: undefined, lastRefundStatusTimestamp: undefined,
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
@ -368,6 +371,12 @@ async function recordConfirmPay(
firstSuccessfulPayTimestamp: undefined, firstSuccessfulPayTimestamp: undefined,
autoRefundDeadline: undefined, autoRefundDeadline: undefined,
paymentSubmitPending: true, paymentSubmitPending: true,
refundState: {
refundGroups: [],
refundsDone: {},
refundsFailed: {},
refundsPending: {},
},
}; };
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
@ -447,7 +456,12 @@ export async function abortFailedPayment(
} }
const refundResponse = MerchantRefundResponse.checked(await resp.json()); const refundResponse = MerchantRefundResponse.checked(await resp.json());
await acceptRefundResponse(ws, purchase.proposalId, refundResponse); await acceptRefundResponse(
ws,
purchase.proposalId,
refundResponse,
RefundReason.AbortRefund,
);
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => { await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
const p = await tx.get(Stores.purchases, proposalId); const p = await tx.get(Stores.purchases, proposalId);
@ -502,50 +516,6 @@ async function incrementPurchasePayRetry(
ws.notify({ type: NotificationType.PayOperationError }); ws.notify({ type: NotificationType.PayOperationError });
} }
async function incrementPurchaseQueryRefundRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationError | undefined,
): Promise<void> {
console.log("incrementing purchase refund query retry with error", err);
await ws.db.runWithWriteTransaction([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);
});
ws.notify({ type: NotificationType.RefundStatusOperationError });
}
async function incrementPurchaseApplyRefundRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationError | undefined,
): Promise<void> {
console.log("incrementing purchase refund apply retry with error", err);
await ws.db.runWithWriteTransaction([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);
});
ws.notify({ type: NotificationType.RefundApplyOperationError });
}
export async function processDownloadProposal( export async function processDownloadProposal(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -695,11 +665,11 @@ async function startDownloadProposal(
downloadSessionId: sessionId, downloadSessionId: sessionId,
}; };
await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => { await ws.db.runWithWriteTransaction([Stores.proposals], async tx => {
const existingRecord = await tx.getIndexed(Stores.proposals.urlAndOrderIdIndex, [ const existingRecord = await tx.getIndexed(
merchantBaseUrl, Stores.proposals.urlAndOrderIdIndex,
orderId, [merchantBaseUrl, orderId],
]); );
if (existingRecord) { if (existingRecord) {
// Created concurrently // Created concurrently
return; return;
@ -793,7 +763,11 @@ export async function submitPay(
for (let c of modifiedCoins) { for (let c of modifiedCoins) {
await tx.put(Stores.coins, c); await tx.put(Stores.coins, c);
} }
await createRefreshGroup(tx, modifiedCoins.map((x) => ({ coinPub: x.coinPub })), RefreshReason.Pay); await createRefreshGroup(
tx,
modifiedCoins.map(x => ({ coinPub: x.coinPub })),
RefreshReason.Pay,
);
await tx.put(Stores.purchases, purchase); await tx.put(Stores.purchases, purchase);
}, },
); );
@ -1069,192 +1043,6 @@ export async function confirmPay(
return submitPay(ws, proposalId); return submitPay(ws, proposalId);
} }
export async function getFullRefundFees(
ws: InternalWalletState,
refundPermissions: MerchantRefundPermission[],
): Promise<AmountJson> {
if (refundPermissions.length === 0) {
throw Error("no refunds given");
}
const coin0 = await ws.db.get(
Stores.coins,
refundPermissions[0].coin_pub,
);
if (!coin0) {
throw Error("coin not found");
}
let feeAcc = Amounts.getZero(
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
);
const denoms = await ws.db.iterIndex(
Stores.denominations.exchangeBaseUrlIndex,
coin0.exchangeBaseUrl,
).toArray();
for (const rp of refundPermissions) {
const coin = await ws.db.get(Stores.coins, rp.coin_pub);
if (!coin) {
throw Error("coin not found");
}
const denom = await ws.db.get(Stores.denominations, [
coin0.exchangeBaseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error(`denom not found (${coin.denomPub})`);
}
// FIXME: this assumes that the refund already happened.
// When it hasn't, the refresh cost is inaccurate. To fix this,
// we need introduce a flag to tell if a coin was refunded or
// refreshed normally (and what about incremental refunds?)
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
const refreshCost = getTotalRefreshCost(
denoms,
denom,
Amounts.sub(refundAmount, refundFee).amount,
);
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
}
return feeAcc;
}
async function acceptRefundResponse(
ws: InternalWalletState,
proposalId: string,
refundResponse: MerchantRefundResponse,
): Promise<void> {
const refundPermissions = refundResponse.refund_permissions;
let numNewRefunds = 0;
await ws.db.runWithWriteTransaction([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;
}
for (const perm of refundPermissions) {
if (
!p.refundsPending[perm.merchant_sig] &&
!p.refundsDone[perm.merchant_sig]
) {
p.refundsPending[perm.merchant_sig] = perm;
numNewRefunds++;
}
}
// Are we done with querying yet, or do we need to do another round
// after a retry delay?
let queryDone = true;
if (numNewRefunds === 0) {
if (
p.autoRefundDeadline &&
p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
) {
queryDone = false;
}
}
if (queryDone) {
p.lastRefundStatusTimestamp = getTimestampNow();
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
p.refundStatusRequested = false;
console.log("refund query done");
} else {
// No error, but we need to try again!
p.lastRefundStatusTimestamp = getTimestampNow();
p.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(p.refundStatusRetryInfo);
p.lastRefundStatusError = undefined;
console.log("refund query not done");
}
if (numNewRefunds) {
p.lastRefundApplyError = undefined;
p.refundApplyRetryInfo = initRetryInfo();
}
await tx.put(Stores.purchases, p);
});
ws.notify({
type: NotificationType.RefundQueried,
});
if (numNewRefunds > 0) {
await processPurchaseApplyRefund(ws, proposalId);
}
}
async function startRefundQuery(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const success = await ws.db.runWithWriteTransaction(
[Stores.purchases],
async tx => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
console.log("no purchase found for refund URL");
return false;
}
p.refundStatusRequested = true;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
await tx.put(Stores.purchases, p);
return true;
},
);
if (!success) {
return;
}
ws.notify({
type: NotificationType.RefundStarted,
});
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 ws.db.getIndexed(
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( export async function processPurchasePay(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -1298,176 +1086,3 @@ async function processPurchasePayImpl(
logger.trace(`processing purchase pay ${proposalId}`); logger.trace(`processing purchase pay ${proposalId}`);
await submitPay(ws, proposalId); await submitPay(ws, proposalId);
} }
export async function processPurchaseQueryRefund(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean = false,
): Promise<void> {
const onOpErr = (e: OperationError) =>
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetPurchaseQueryRefundRetry(
ws: InternalWalletState,
proposalId: string,
) {
await ws.db.mutate(Stores.purchases, proposalId, x => {
if (x.refundStatusRetryInfo.active) {
x.refundStatusRetryInfo = initRetryInfo();
}
return x;
});
}
async function processPurchaseQueryRefundImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
): Promise<void> {
if (forceNow) {
await resetPurchaseQueryRefundRetry(ws, proposalId);
}
const purchase = await ws.db.get(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;
}
if (resp.status !== 200) {
throw Error(`unexpected status code (${resp.status}) for /refund`);
}
const refundResponse = MerchantRefundResponse.checked(await resp.json());
await acceptRefundResponse(ws, proposalId, refundResponse);
}
export async function processPurchaseApplyRefund(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean = false,
): Promise<void> {
const onOpErr = (e: OperationError) =>
incrementPurchaseApplyRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetPurchaseApplyRefundRetry(
ws: InternalWalletState,
proposalId: string,
) {
await ws.db.mutate(Stores.purchases, proposalId, x => {
if (x.refundApplyRetryInfo.active) {
x.refundApplyRetryInfo = initRetryInfo();
}
return x;
});
}
async function processPurchaseApplyRefundImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
): Promise<void> {
if (forceNow) {
await resetPurchaseApplyRefundRetry(ws, proposalId);
}
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
console.error("not submitting refunds, payment not found:");
return;
}
const pendingKeys = Object.keys(purchase.refundsPending);
if (pendingKeys.length === 0) {
console.log("no pending refunds");
return;
}
for (const pk of pendingKeys) {
const perm = purchase.refundsPending[pk];
const req: RefundRequest = {
coin_pub: perm.coin_pub,
h_contract_terms: purchase.contractTermsHash,
merchant_pub: purchase.contractTerms.merchant_pub,
merchant_sig: perm.merchant_sig,
refund_amount: perm.refund_amount,
refund_fee: perm.refund_fee,
rtransaction_id: perm.rtransaction_id,
};
console.log("sending refund permission", perm);
// FIXME: not correct once we support multiple exchanges per payment
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;
}
let allRefundsProcessed = false;
await ws.db.runWithWriteTransaction(
[Stores.purchases, Stores.coins, Stores.refreshGroups],
async tx => {
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.refundStatusRetryInfo = initRetryInfo();
p.lastRefundStatusError = undefined;
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.Dormant;
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
await tx.put(Stores.coins, c);
await createRefreshGroup(tx, [{ coinPub: perm.coin_pub }], RefreshReason.Refund);
},
);
if (allRefundsProcessed) {
ws.notify({
type: NotificationType.RefundFinished,
});
}
}
ws.notify({
type: NotificationType.RefundsSubmitted,
proposalId,
});
}

View File

@ -365,9 +365,9 @@ async function gatherPurchasePending(
}); });
} }
} }
const numRefundsPending = Object.keys(pr.refundsPending).length; const numRefundsPending = Object.keys(pr.refundState.refundsPending).length;
if (numRefundsPending > 0) { if (numRefundsPending > 0) {
const numRefundsDone = Object.keys(pr.refundsDone).length; const numRefundsDone = Object.keys(pr.refundState.refundsDone).length;
resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay, resp.nextRetryDelay,
now, now,

View File

@ -1002,6 +1002,63 @@ export interface WireFee {
sig: string; sig: string;
} }
/**
* Record to store information about a refund event.
*
* All information about a refund is stored with the purchase,
* this event is just for the history.
*
* The event is only present for completed refunds.
*/
export interface RefundEventRecord {
timestamp: Timestamp;
refundGroupId: string;
proposalId: string;
}
export interface RefundInfo {
refundGroupId: string;
perm: MerchantRefundPermission;
}
export const enum RefundReason {
/**
* Normal refund given by the merchant.
*/
NormalRefund = "normal-refund",
/**
* Refund from an aborted payment.
*/
AbortRefund = "abort-refund",
}
export interface RefundGroupInfo {
timestampQueried: Timestamp;
reason: RefundReason;
}
export interface PurchaseRefundState {
/**
* Information regarding each group of refunds we receive at once.
*/
refundGroups: RefundGroupInfo[];
/**
* Pending refunds for the purchase.
*/
refundsPending: { [refundSig: string]: RefundInfo };
/**
* Applied refunds for the purchase.
*/
refundsDone: { [refundSig: string]: RefundInfo };
/**
* Submitted refunds for the purchase.
*/
refundsFailed: { [refundSig: string]: RefundInfo };
}
/** /**
* Record that stores status information about one purchase, starting from when * Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable. * the customer accepts a proposal. Includes refund status if applicable.
@ -1034,24 +1091,23 @@ export interface PurchaseRecord {
*/ */
merchantSig: string; merchantSig: string;
/**
* Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful.
*/
firstSuccessfulPayTimestamp: Timestamp | undefined; firstSuccessfulPayTimestamp: Timestamp | undefined;
/**
* Pending refunds for the purchase.
*/
refundsPending: { [refundSig: string]: MerchantRefundPermission };
/**
* Submitted refunds for the purchase.
*/
refundsDone: { [refundSig: string]: MerchantRefundPermission };
/** /**
* When was the purchase made? * When was the purchase made?
* Refers to the time that the user accepted. * Refers to the time that the user accepted.
*/ */
acceptTimestamp: Timestamp; acceptTimestamp: Timestamp;
/**
* State of refunds for this proposal.
*/
refundState: PurchaseRefundState;
/** /**
* 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.
@ -1370,6 +1426,12 @@ export namespace Stores {
} }
} }
class RefundEventsStore extends Store<RefundEventRecord> {
constructor() {
super("refundEvents", { keyPath: "refundGroupId" });
}
}
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> { class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
constructor() { constructor() {
super("bankWithdrawUris", { keyPath: "talerWithdrawUri" }); super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
@ -1394,6 +1456,7 @@ export namespace Stores {
export const senderWires = new SenderWiresStore(); export const senderWires = new SenderWiresStore();
export const withdrawalSession = new WithdrawalSessionsStore(); export const withdrawalSession = new WithdrawalSessionsStore();
export const bankWithdrawUris = new BankWithdrawUrisStore(); export const bankWithdrawUris = new BankWithdrawUrisStore();
export const refundEvents = new RefundEventsStore();
} }
/* tslint:enable:completed-docs */ /* tslint:enable:completed-docs */

View File

@ -33,6 +33,11 @@ export interface HttpRequestOptions {
headers?: { [name: string]: string }; headers?: { [name: string]: string };
} }
export enum HttpResponseStatus {
Ok = 200,
Gone = 210,
}
/** /**
* Headers, roughly modeled after the fetch API's headers object. * Headers, roughly modeled after the fetch API's headers object.
*/ */

View File

@ -42,11 +42,7 @@ import {
preparePay, preparePay,
confirmPay, confirmPay,
processDownloadProposal, processDownloadProposal,
applyRefund,
getFullRefundFees,
processPurchasePay, processPurchasePay,
processPurchaseQueryRefund,
processPurchaseApplyRefund,
} from "./operations/pay"; } from "./operations/pay";
import { import {
@ -107,6 +103,7 @@ import { AsyncOpMemoSingle } from "./util/asyncMemo";
import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType } from "./types/pending"; import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType } from "./types/pending";
import { WalletNotification, NotificationType } from "./types/notifications"; import { WalletNotification, NotificationType } from "./types/notifications";
import { HistoryQuery, HistoryEvent } from "./types/history"; import { HistoryQuery, HistoryEvent } from "./types/history";
import { processPurchaseQueryRefund, processPurchaseApplyRefund, getFullRefundFees, applyRefund } from "./operations/refund";
/** /**
* Wallet protocol version spoken with the exchange * Wallet protocol version spoken with the exchange
@ -695,21 +692,21 @@ export class Wallet {
if (!purchase) { if (!purchase) {
throw Error("unknown purchase"); throw Error("unknown purchase");
} }
const refundsDoneAmounts = Object.values(purchase.refundsDone).map(x => const refundsDoneAmounts = Object.values(purchase.refundState.refundsDone).map(x =>
Amounts.parseOrThrow(x.refund_amount), Amounts.parseOrThrow(x.perm.refund_amount),
); );
const refundsPendingAmounts = Object.values( const refundsPendingAmounts = Object.values(
purchase.refundsPending, purchase.refundState.refundsPending,
).map(x => Amounts.parseOrThrow(x.refund_amount)); ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
const totalRefundAmount = Amounts.sum([ const totalRefundAmount = Amounts.sum([
...refundsDoneAmounts, ...refundsDoneAmounts,
...refundsPendingAmounts, ...refundsPendingAmounts,
]).amount; ]).amount;
const refundsDoneFees = Object.values(purchase.refundsDone).map(x => const refundsDoneFees = Object.values(purchase.refundState.refundsDone).map(x =>
Amounts.parseOrThrow(x.refund_amount), Amounts.parseOrThrow(x.perm.refund_amount),
); );
const refundsPendingFees = Object.values(purchase.refundsPending).map(x => const refundsPendingFees = Object.values(purchase.refundState.refundsPending).map(x =>
Amounts.parseOrThrow(x.refund_amount), Amounts.parseOrThrow(x.perm.refund_amount),
); );
const totalRefundFees = Amounts.sum([ const totalRefundFees = Amounts.sum([
...refundsDoneFees, ...refundsDoneFees,

View File

@ -54,6 +54,7 @@
"src/operations/payback.ts", "src/operations/payback.ts",
"src/operations/pending.ts", "src/operations/pending.ts",
"src/operations/refresh.ts", "src/operations/refresh.ts",
"src/operations/refund.ts",
"src/operations/reserves.ts", "src/operations/reserves.ts",
"src/operations/return.ts", "src/operations/return.ts",
"src/operations/state.ts", "src/operations/state.ts",