towards refunds with updated protocol

This commit is contained in:
Florian Dold 2020-07-23 17:35:17 +05:30
parent e60563fb54
commit d88829cfa8
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
11 changed files with 573 additions and 396 deletions

View File

@ -421,66 +421,66 @@ export async function getHistory(
}
});
tx.iter(Stores.refundEvents).forEachAsync(async (re) => {
const proposal = await tx.get(Stores.proposals, re.proposalId);
if (!proposal) {
return;
}
const purchase = await tx.get(Stores.purchases, re.proposalId);
if (!purchase) {
return;
}
const orderShortInfo = getOrderShortInfo(proposal);
if (!orderShortInfo) {
return;
}
const purchaseAmount = purchase.contractData.amount;
let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency);
let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency);
let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency);
Object.keys(purchase.refundsDone).forEach((x, i) => {
const r = purchase.refundsDone[x];
if (r.refundGroupId !== re.refundGroupId) {
return;
}
const refundAmount = Amounts.parseOrThrow(r.perm.refund_amount);
const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
amountRefundedRaw = Amounts.add(amountRefundedRaw, refundAmount)
.amount;
amountRefundedEffective = Amounts.add(
amountRefundedEffective,
refundAmount,
).amount;
amountRefundedEffective = Amounts.sub(
amountRefundedEffective,
refundFee,
).amount;
});
Object.keys(purchase.refundsFailed).forEach((x, i) => {
const r = purchase.refundsFailed[x];
if (r.refundGroupId !== re.refundGroupId) {
return;
}
const ra = Amounts.parseOrThrow(r.perm.refund_amount);
const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
amountRefundedRaw = Amounts.add(amountRefundedRaw, ra).amount;
amountRefundedInvalid = Amounts.add(amountRefundedInvalid, ra).amount;
amountRefundedEffective = Amounts.sub(
amountRefundedEffective,
refundFee,
).amount;
});
history.push({
type: HistoryEventType.Refund,
eventId: makeEventId(HistoryEventType.Refund, re.refundGroupId),
refundGroupId: re.refundGroupId,
orderShortInfo,
timestamp: re.timestamp,
amountRefundedEffective: Amounts.stringify(amountRefundedEffective),
amountRefundedRaw: Amounts.stringify(amountRefundedRaw),
amountRefundedInvalid: Amounts.stringify(amountRefundedInvalid),
});
});
// tx.iter(Stores.refundEvents).forEachAsync(async (re) => {
// const proposal = await tx.get(Stores.proposals, re.proposalId);
// if (!proposal) {
// return;
// }
// const purchase = await tx.get(Stores.purchases, re.proposalId);
// if (!purchase) {
// return;
// }
// const orderShortInfo = getOrderShortInfo(proposal);
// if (!orderShortInfo) {
// return;
// }
// const purchaseAmount = purchase.contractData.amount;
// let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency);
// let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency);
// let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency);
// Object.keys(purchase.refundsDone).forEach((x, i) => {
// const r = purchase.refundsDone[x];
// if (r.refundGroupId !== re.refundGroupId) {
// return;
// }
// const refundAmount = Amounts.parseOrThrow(r.perm.refund_amount);
// const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
// amountRefundedRaw = Amounts.add(amountRefundedRaw, refundAmount)
// .amount;
// amountRefundedEffective = Amounts.add(
// amountRefundedEffective,
// refundAmount,
// ).amount;
// amountRefundedEffective = Amounts.sub(
// amountRefundedEffective,
// refundFee,
// ).amount;
// });
// Object.keys(purchase.refundsFailed).forEach((x, i) => {
// const r = purchase.refundsFailed[x];
// if (r.refundGroupId !== re.refundGroupId) {
// return;
// }
// const ra = Amounts.parseOrThrow(r.perm.refund_amount);
// const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
// amountRefundedRaw = Amounts.add(amountRefundedRaw, ra).amount;
// amountRefundedInvalid = Amounts.add(amountRefundedInvalid, ra).amount;
// amountRefundedEffective = Amounts.sub(
// amountRefundedEffective,
// refundFee,
// ).amount;
// });
// history.push({
// type: HistoryEventType.Refund,
// eventId: makeEventId(HistoryEventType.Refund, re.refundGroupId),
// refundGroupId: re.refundGroupId,
// orderShortInfo,
// timestamp: re.timestamp,
// amountRefundedEffective: Amounts.stringify(amountRefundedEffective),
// amountRefundedRaw: Amounts.stringify(amountRefundedRaw),
// amountRefundedInvalid: Amounts.stringify(amountRefundedInvalid),
// });
// });
tx.iter(Stores.recoupGroups).forEach((rg) => {
if (rg.timestampFinished) {

View File

@ -59,7 +59,6 @@ import { InternalWalletState } from "./state";
import { getTimestampNow, timestampAddDuration } from "../util/time";
import { strcmp, canonicalJson } from "../util/helpers";
import {
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
} from "../util/http";
@ -455,11 +454,7 @@ async function recordConfirmPay(
timestampFirstSuccessfulPay: undefined,
autoRefundDeadline: undefined,
paymentSubmitPending: true,
refundGroups: [],
refundsDone: {},
refundsFailed: {},
refundsPending: {},
refundsRefreshCost: {},
refunds: {},
};
await ws.db.runWithWriteTransaction(
@ -492,7 +487,7 @@ async function recordConfirmPay(
const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
coinPub: x,
}));
await createRefreshGroup(tx, refreshCoinPubs, RefreshReason.Pay);
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
},
);

View File

@ -96,6 +96,7 @@ async function putGroupAsFinished(
recoupGroup.lastError = undefined;
if (recoupGroup.scheduleRefreshCoins.length > 0) {
const refreshGroupId = await createRefreshGroup(
ws,
tx,
recoupGroup.scheduleRefreshCoins.map((x) => ({ coinPub: x })),
RefreshReason.Recoup,

View File

@ -535,6 +535,7 @@ async function processRefreshSession(
* Create a refresh group for a list of coins.
*/
export async function createRefreshGroup(
ws: InternalWalletState,
tx: TransactionHandle,
oldCoinPubs: CoinPublicKey[],
reason: RefreshReason,
@ -554,6 +555,17 @@ export async function createRefreshGroup(
};
await tx.put(Stores.refreshGroups, refreshGroup);
const processAsync = async (): Promise<void> => {
try {
await processRefreshGroup(ws, refreshGroupId);
} catch (e) {
logger.trace(`Error during refresh: ${e}`)
}
};
processAsync();
return {
refreshGroupId,
};

View File

@ -36,23 +36,24 @@ import {
CoinStatus,
RefundReason,
RefundEventRecord,
RefundState,
PurchaseRecord,
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { Amounts } from "../util/amounts";
import {
MerchantRefundDetails,
MerchantRefundResponse,
codecForMerchantRefundResponse,
codecForMerchantOrderStatus,
MerchantCoinRefundStatus,
MerchantCoinRefundSuccessStatus,
MerchantCoinRefundFailureStatus,
} from "../types/talerTypes";
import { AmountJson } from "../util/amounts";
import { guardOperationException } from "./errors";
import { randomBytes } from "../crypto/primitives/nacl-fast";
import { encodeCrock } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time";
import { Logger } from "../util/logging";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import { TransactionHandle } from "../util/query";
const logger = new Logger("refund.ts");
@ -85,80 +86,122 @@ async function incrementPurchaseQueryRefundRetry(
}
}
function getRefundKey(d: MerchantRefundDetails): string {
function getRefundKey(d: MerchantCoinRefundStatus): string {
return `${d.coin_pub}-${d.rtransaction_id}`;
}
async function acceptRefundResponse(
async function applySuccessfulRefund(
tx: TransactionHandle,
p: PurchaseRecord,
refreshCoinsMap: Record<string, { coinPub: string }>,
r: MerchantCoinRefundSuccessStatus,
): Promise<void> {
// FIXME: check signature before storing it as valid!
const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
console.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.getIndexed(
Stores.denominations.denomPubHashIndex,
coin.denomPubHash,
);
if (!denom) {
throw Error("inconsistent database");
}
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
const refundAmount = Amounts.parseOrThrow(r.refund_amount);
const refundFee = denom.feeRefund;
coin.status = CoinStatus.Dormant;
coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
await tx.put(Stores.coins, coin);
const allDenoms = await tx
.iterIndexed(Stores.denominations.exchangeBaseUrlIndex, coin.exchangeBaseUrl)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
denom,
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Applied,
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.feeRefund,
totalRefreshCostBound,
};
}
async function storePendingRefund(
tx: TransactionHandle,
p: PurchaseRecord,
r: MerchantCoinRefundFailureStatus,
): Promise<void> {
const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
console.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.getIndexed(
Stores.denominations.denomPubHashIndex,
coin.denomPubHash,
);
if (!denom) {
throw Error("inconsistent database");
}
const allDenoms = await tx
.iterIndexed(Stores.denominations.exchangeBaseUrlIndex, coin.exchangeBaseUrl)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
denom,
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Pending,
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.feeRefund,
totalRefreshCostBound,
};
}
async function acceptRefunds(
ws: InternalWalletState,
proposalId: string,
refundResponse: MerchantRefundResponse,
refunds: MerchantCoinRefundStatus[],
reason: RefundReason,
): Promise<void> {
const refunds = refundResponse.refunds;
const refundGroupId = encodeCrock(randomBytes(32));
let numNewRefunds = 0;
const finishedRefunds: MerchantRefundDetails[] = [];
const unfinishedRefunds: MerchantRefundDetails[] = [];
const failedRefunds: MerchantRefundDetails[] = [];
console.log("handling refund response", refundResponse);
const refundsRefreshCost: { [refundKey: string]: AmountJson } = {};
for (const rd of refunds) {
logger.trace(
`Refund ${rd.rtransaction_id} has HTTP status ${rd.exchange_http_status}`,
);
if (rd.exchange_http_status === 200) {
// FIXME: also verify signature if necessary.
finishedRefunds.push(rd);
} else if (
rd.exchange_http_status >= 400 &&
rd.exchange_http_status < 400
) {
failedRefunds.push(rd);
} else {
unfinishedRefunds.push(rd);
}
}
// Compute cost.
// FIXME: Optimize, don't always recompute.
for (const rd of [...finishedRefunds, ...unfinishedRefunds]) {
const key = getRefundKey(rd);
const coin = await ws.db.get(Stores.coins, rd.coin_pub);
if (!coin) {
continue;
}
const denom = await ws.db.getIndexed(
Stores.denominations.denomPubHashIndex,
coin.denomPubHash,
);
if (!denom) {
throw Error("inconsistent database");
}
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(rd.refund_amount))
.amount,
Amounts.parseOrThrow(rd.refund_fee),
).amount;
const allDenoms = await ws.db
.iterIndex(
Stores.denominations.exchangeBaseUrlIndex,
coin.exchangeBaseUrl,
)
.toArray();
refundsRefreshCost[key] = getTotalRefreshCost(allDenoms, denom, amountLeft);
}
console.log("handling refunds", refunds);
const now = getTimestampNow();
await ws.db.runWithWriteTransaction(
[Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents],
[Stores.purchases, Stores.coins, Stores.denominations, Stores.refreshGroups, Stores.refundEvents],
async (tx) => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
@ -166,103 +209,60 @@ async function acceptRefundResponse(
return;
}
// Groups that newly failed/succeeded
const changedGroups: { [refundGroupId: string]: boolean } = {};
const refreshCoinsMap: Record<string, CoinPublicKey> = {};
for (const rd of failedRefunds) {
const refundKey = getRefundKey(rd);
if (p.refundsFailed[refundKey]) {
for (const refundStatus of refunds) {
const refundKey = getRefundKey(refundStatus);
const existingRefundInfo = p.refunds[refundKey];
// Already failed.
if (existingRefundInfo?.type === RefundState.Failed) {
continue;
}
if (!p.refundsFailed[refundKey]) {
p.refundsFailed[refundKey] = {
perm: rd,
refundGroupId,
};
numNewRefunds++;
changedGroups[refundGroupId] = true;
}
const oldPending = p.refundsPending[refundKey];
if (oldPending) {
delete p.refundsPending[refundKey];
changedGroups[oldPending.refundGroupId] = true;
}
}
for (const rd of unfinishedRefunds) {
const refundKey = getRefundKey(rd);
if (!p.refundsPending[refundKey]) {
p.refundsPending[refundKey] = {
perm: rd,
refundGroupId,
};
numNewRefunds++;
}
}
// Avoid duplicates
const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
for (const rd of finishedRefunds) {
const refundKey = getRefundKey(rd);
if (p.refundsDone[refundKey]) {
// Already applied.
if (existingRefundInfo?.type === RefundState.Applied) {
continue;
}
p.refundsDone[refundKey] = {
perm: rd,
refundGroupId,
};
const oldPending = p.refundsPending[refundKey];
if (oldPending) {
delete p.refundsPending[refundKey];
changedGroups[oldPending.refundGroupId] = true;
// Still pending.
if (
refundStatus.success === false &&
existingRefundInfo?.type === RefundState.Pending
) {
continue;
}
// Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
if (refundStatus.success === true) {
await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
} else {
numNewRefunds++;
await storePendingRefund(tx, p, refundStatus);
}
const c = await tx.get(Stores.coins, rd.coin_pub);
if (!c) {
console.warn("coin not found, can't apply refund");
return;
}
refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub };
logger.trace(`commiting refund ${refundKey} to coin ${c.coinPub}`);
logger.trace(
`coin amount before is ${Amounts.stringify(c.currentAmount)}`,
);
logger.trace(`refund amount (via merchant) is ${refundKey}`);
logger.trace(`refund fee (via merchant) is ${refundKey}`);
const refundAmount = Amounts.parseOrThrow(rd.refund_amount);
const refundFee = Amounts.parseOrThrow(rd.refund_fee);
c.status = CoinStatus.Dormant;
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
logger.trace(
`coin amount after is ${Amounts.stringify(c.currentAmount)}`,
);
await tx.put(Stores.coins, c);
}
const refreshCoinsPubs = Object.values(refreshCoinsMap);
await createRefreshGroup(ws, tx, refreshCoinsPubs, RefreshReason.Refund);
// Are we done with querying yet, or do we need to do another round
// after a retry delay?
let queryDone = true;
logger.trace(`got ${numNewRefunds} new refund permissions`);
if (numNewRefunds === 0) {
if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
queryDone = false;
}
} else {
p.refundGroups.push({
reason: RefundReason.NormalRefund,
refundGroupId,
timestampQueried: getTimestampNow(),
});
if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
queryDone = false;
}
if (Object.keys(unfinishedRefunds).length != 0) {
let numPendingRefunds = 0;
for (const ri of Object.values(p.refunds)) {
switch (ri.type) {
case RefundState.Pending:
numPendingRefunds++;
break;
}
}
if (numPendingRefunds > 0) {
queryDone = false;
}
@ -281,38 +281,7 @@ async function acceptRefundResponse(
logger.trace("refund query not done");
}
p.refundsRefreshCost = { ...p.refundsRefreshCost, ...refundsRefreshCost };
await tx.put(Stores.purchases, p);
const coinsPubsToBeRefreshed = Object.values(refreshCoinsMap);
if (coinsPubsToBeRefreshed.length > 0) {
await createRefreshGroup(
tx,
coinsPubsToBeRefreshed,
RefreshReason.Refund,
);
}
// Check if any of the refund groups are done, and we
// can emit an corresponding event.
for (const g of Object.keys(changedGroups)) {
let groupDone = true;
for (const pk of Object.keys(p.refundsPending)) {
const r = p.refundsPending[pk];
if (r.refundGroupId == g) {
groupDone = false;
}
}
if (groupDone) {
const refundEvent: RefundEventRecord = {
proposalId,
refundGroupId: g,
timestamp: now,
};
await tx.put(Stores.refundEvents, refundEvent);
}
}
},
);
@ -430,22 +399,33 @@ async function processPurchaseQueryRefundImpl(
return;
}
const request = await ws.http.get(
new URL(
`orders/${purchase.contractData.orderId}`,
purchase.contractData.merchantBaseUrl,
).href,
const requestUrl = new URL(
`orders/${purchase.contractData.orderId}`,
purchase.contractData.merchantBaseUrl,
);
requestUrl.searchParams.set(
"h_contract",
purchase.contractData.contractTermsHash,
);
const request = await ws.http.get(requestUrl.href);
console.log("got json", JSON.stringify(await request.json(), undefined, 2));
const refundResponse = await readSuccessResponseJsonOrThrow(
request,
codecForMerchantRefundResponse(),
codecForMerchantOrderStatus(),
);
await acceptRefundResponse(
if (!refundResponse.paid) {
logger.error("can't refund unpaid order");
return;
}
await acceptRefunds(
ws,
proposalId,
refundResponse,
refundResponse.refunds,
RefundReason.NormalRefund,
);
}

View File

@ -44,68 +44,6 @@ function makeEventId(type: TransactionType, ...args: string[]): string {
return type + ";" + args.map((x) => encodeURIComponent(x)).join(";");
}
interface RefundStats {
amountInvalid: AmountJson;
amountEffective: AmountJson;
amountRaw: AmountJson;
}
function getRefundStats(
pr: PurchaseRecord,
refundGroupId: string,
): RefundStats {
let amountEffective = Amounts.getZero(pr.contractData.amount.currency);
let amountInvalid = Amounts.getZero(pr.contractData.amount.currency);
let amountRaw = Amounts.getZero(pr.contractData.amount.currency);
for (const rk of Object.keys(pr.refundsDone)) {
const perm = pr.refundsDone[rk].perm;
if (pr.refundsDone[rk].refundGroupId !== refundGroupId) {
continue;
}
amountEffective = Amounts.add(
amountEffective,
Amounts.parseOrThrow(perm.refund_amount),
).amount;
amountRaw = Amounts.add(amountRaw, Amounts.parseOrThrow(perm.refund_amount))
.amount;
}
// Subtract fees from effective refund amount
for (const rk of Object.keys(pr.refundsDone)) {
const perm = pr.refundsDone[rk].perm;
if (pr.refundsDone[rk].refundGroupId !== refundGroupId) {
continue;
}
amountEffective = Amounts.sub(
amountEffective,
Amounts.parseOrThrow(perm.refund_fee),
).amount;
if (pr.refundsRefreshCost[rk]) {
amountEffective = Amounts.sub(amountEffective, pr.refundsRefreshCost[rk])
.amount;
}
}
for (const rk of Object.keys(pr.refundsFailed)) {
const perm = pr.refundsDone[rk].perm;
if (pr.refundsDone[rk].refundGroupId !== refundGroupId) {
continue;
}
amountInvalid = Amounts.add(
amountInvalid,
Amounts.parseOrThrow(perm.refund_fee),
).amount;
}
return {
amountEffective,
amountInvalid,
amountRaw,
};
}
function shouldSkipCurrency(
transactionsRequest: TransactionsRequest | undefined,
currency: string,
@ -319,36 +257,37 @@ export async function getTransactions(
},
});
for (const rg of pr.refundGroups) {
const pending = Object.keys(pr.refundsPending).length > 0;
const stats = getRefundStats(pr, rg.refundGroupId);
// for (const rg of pr.refundGroups) {
// const pending = Object.keys(pr.refundsPending).length > 0;
// const stats = getRefundStats(pr, rg.refundGroupId);
// transactions.push({
// type: TransactionType.Refund,
// pending,
// info: {
// fulfillmentUrl: pr.contractData.fulfillmentUrl,
// merchant: pr.contractData.merchant,
// orderId: pr.contractData.orderId,
// products: pr.contractData.products,
// summary: pr.contractData.summary,
// summary_i18n: pr.contractData.summaryI18n,
// },
// timestamp: rg.timestampQueried,
// transactionId: makeEventId(
// TransactionType.Refund,
// pr.proposalId,
// `${rg.timestampQueried.t_ms}`,
// ),
// refundedTransactionId: makeEventId(
// TransactionType.Payment,
// pr.proposalId,
// ),
// amountEffective: Amounts.stringify(stats.amountEffective),
// amountInvalid: Amounts.stringify(stats.amountInvalid),
// amountRaw: Amounts.stringify(stats.amountRaw),
// });
// }
transactions.push({
type: TransactionType.Refund,
pending,
info: {
fulfillmentUrl: pr.contractData.fulfillmentUrl,
merchant: pr.contractData.merchant,
orderId: pr.contractData.orderId,
products: pr.contractData.products,
summary: pr.contractData.summary,
summary_i18n: pr.contractData.summaryI18n,
},
timestamp: rg.timestampQueried,
transactionId: makeEventId(
TransactionType.Refund,
pr.proposalId,
`${rg.timestampQueried.t_ms}`,
),
refundedTransactionId: makeEventId(
TransactionType.Payment,
pr.proposalId,
),
amountEffective: Amounts.stringify(stats.amountEffective),
amountInvalid: Amounts.stringify(stats.amountInvalid),
amountRaw: Amounts.stringify(stats.amountRaw),
});
}
});
},
);

View File

@ -27,7 +27,6 @@ import { AmountJson } from "../util/amounts";
import {
Auditor,
CoinDepositPermission,
MerchantRefundDetails,
TipResponse,
ExchangeSignKeyJson,
MerchantInfo,
@ -1140,13 +1139,54 @@ export interface WireFee {
*/
export interface RefundEventRecord {
timestamp: Timestamp;
merchantExecutionTimestamp: Timestamp;
refundGroupId: string;
proposalId: string;
}
export interface RefundInfo {
refundGroupId: string;
perm: MerchantRefundDetails;
export const enum RefundState {
Failed = "failed",
Applied = "applied",
Pending = "pending",
}
/**
* State of one refund from the merchant, maintained by the wallet.
*/
export type WalletRefundItem =
| WalletRefundFailedItem
| WalletRefundPendingItem
| WalletRefundAppliedItem;
export interface WalletRefundItemCommon {
executionTime: Timestamp;
refundAmount: AmountJson;
refundFee: AmountJson;
/**
* Upper bound on the refresh cost incurred by
* applying this refund.
*
* Might be lower in practice when two refunds on the same
* coin are refreshed in the same refresh operation.
*/
totalRefreshCostBound: AmountJson;
}
/**
* Failed refund, either because the merchant did
* something wrong or it expired.
*/
export interface WalletRefundFailedItem extends WalletRefundItemCommon {
type: RefundState.Failed;
}
export interface WalletRefundPendingItem extends WalletRefundItemCommon {
type: RefundState.Pending;
}
export interface WalletRefundAppliedItem extends WalletRefundItemCommon {
type: RefundState.Applied;
}
export const enum RefundReason {
@ -1160,12 +1200,6 @@ export const enum RefundReason {
AbortRefund = "abort-pay-refund",
}
export interface RefundGroupInfo {
refundGroupId: string;
timestampQueried: Timestamp;
reason: RefundReason;
}
/**
* Record stored for every time we successfully submitted
* a payment to the merchant (both first time and re-play).
@ -1269,31 +1303,11 @@ export interface PurchaseRecord {
*/
timestampAccept: Timestamp;
/**
* Information regarding each group of refunds we receive at once.
*/
refundGroups: RefundGroupInfo[];
/**
* Pending refunds for the purchase. A refund is pending
* when the merchant reports a transient error from the exchange.
*/
refundsPending: { [refundKey: string]: RefundInfo };
/**
* Applied refunds for the purchase.
*/
refundsDone: { [refundKey: string]: RefundInfo };
/**
* Refunds that permanently failed.
*/
refundsFailed: { [refundKey: string]: RefundInfo };
/**
* Refresh cost for each refund permission.
*/
refundsRefreshCost: { [refundKey: string]: AmountJson };
refunds: { [refundKey: string]: WalletRefundItem };
/**
* When was the last refund made?

View File

@ -37,6 +37,10 @@ import {
codecForBoolean,
makeCodecForMap,
Codec,
makeCodecForConstNumber,
makeCodecForUnion,
makeCodecForConstTrue,
makeCodecForConstFalse,
} from "../util/codec";
import {
Timestamp,
@ -436,7 +440,7 @@ export class ContractTerms {
/**
* Refund permission in the format that the merchant gives it to us.
*/
export class MerchantRefundDetails {
export class MerchantAbortPayRefundDetails {
/**
* Amount to be refunded.
*/
@ -502,7 +506,7 @@ export class MerchantRefundResponse {
/**
* The signed refund permissions, to be sent to the exchange.
*/
refunds: MerchantRefundDetails[];
refunds: MerchantAbortPayRefundDetails[];
}
/**
@ -834,6 +838,115 @@ export interface ExchangeRevealResponse {
ev_sigs: ExchangeRevealItem[];
}
export type MerchantOrderStatus =
| MerchantOrderStatusPaid
| MerchantOrderStatusUnpaid;
interface MerchantOrderStatusPaid {
/**
* Has the payment for this order (ever) been completed?
*/
paid: true;
/**
* Was the payment refunded (even partially, via refund or abort)?
*/
refunded: boolean;
/**
* Amount that was refunded in total.
*/
refund_amount: AmountString;
/**
* Successful refunds for this payment, empty array for none.
*/
refunds: MerchantCoinRefundStatus[];
/**
* Public key of the merchant.
*/
merchant_pub: EddsaPublicKeyString;
}
export type MerchantCoinRefundStatus =
| MerchantCoinRefundSuccessStatus
| MerchantCoinRefundFailureStatus;
export interface MerchantCoinRefundSuccessStatus {
success: true;
// HTTP status of the exchange request, 200 (integer) required for refund confirmations.
exchange_status: 200;
// the EdDSA :ref:signature (binary-only) with purpose
// TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
// exchange affirming the successful refund
exchange_sig: EddsaSignatureString;
// public EdDSA key of the exchange that was used to generate the signature.
// Should match one of the exchange's signing keys from /keys. It is given
// explicitly as the client might otherwise be confused by clock skew as to
// which signing key was used.
exchange_pub: EddsaPublicKeyString;
// Refund transaction ID.
rtransaction_id: number;
// public key of a coin that was refunded
coin_pub: EddsaPublicKeyString;
// Amount that was refunded, including refund fee charged by the exchange
// to the customer.
refund_amount: AmountString;
execution_time: Timestamp;
}
export interface MerchantCoinRefundFailureStatus {
success: false;
// HTTP status of the exchange request, must NOT be 200.
exchange_status: number;
// Taler error code from the exchange reply, if available.
exchange_code?: number;
// If available, HTTP reply from the exchange.
exchange_reply?: any;
// Refund transaction ID.
rtransaction_id: number;
// public key of a coin that was refunded
coin_pub: EddsaPublicKeyString;
// Amount that was refunded, including refund fee charged by the exchange
// to the customer.
refund_amount: AmountString;
execution_time: Timestamp;
}
export interface MerchantOrderStatusUnpaid {
/**
* Has the payment for this order (ever) been completed?
*/
paid: false;
/**
* URI that the wallet must process to complete the payment.
*/
taler_pay_uri: string;
/**
* Alternative order ID which was paid for already in the same session.
*
* Only given if the same product was purchased before in the same session.
*/
already_paid_order_id?: string;
}
export type AmountString = string;
export type Base32String = string;
export type EddsaSignatureString = string;
@ -940,9 +1053,9 @@ export const codecForContractTerms = (): Codec<ContractTerms> =>
.build("ContractTerms");
export const codecForMerchantRefundPermission = (): Codec<
MerchantRefundDetails
MerchantAbortPayRefundDetails
> =>
makeCodecForObject<MerchantRefundDetails>()
makeCodecForObject<MerchantAbortPayRefundDetails>()
.property("refund_amount", codecForString)
.property("refund_fee", codecForString)
.property("coin_pub", codecForString)
@ -1094,3 +1207,67 @@ export const codecForExchangeRevealResponse = (): Codec<
makeCodecForObject<ExchangeRevealResponse>()
.property("ev_sigs", makeCodecForList(codecForExchangeRevealItem()))
.build("ExchangeRevealResponse");
export const codecForMerchantCoinRefundSuccessStatus = (): Codec<
MerchantCoinRefundSuccessStatus
> =>
makeCodecForObject<MerchantCoinRefundSuccessStatus>()
.property("success", makeCodecForConstTrue())
.property("coin_pub", codecForString)
.property("exchange_status", makeCodecForConstNumber(200))
.property("exchange_sig", codecForString)
.property("rtransaction_id", codecForNumber)
.property("refund_amount", codecForString)
.property("exchange_pub", codecForString)
.property("execution_time", codecForTimestamp)
.build("MerchantCoinRefundSuccessStatus");
export const codecForMerchantCoinRefundFailureStatus = (): Codec<
MerchantCoinRefundFailureStatus
> =>
makeCodecForObject<MerchantCoinRefundFailureStatus>()
.property("success", makeCodecForConstFalse())
.property("coin_pub", codecForString)
.property("exchange_status", makeCodecForConstNumber(200))
.property("rtransaction_id", codecForNumber)
.property("refund_amount", codecForString)
.property("exchange_code", makeCodecOptional(codecForNumber))
.property("exchange_reply", makeCodecOptional(codecForAny))
.property("execution_time", codecForTimestamp)
.build("MerchantCoinRefundSuccessStatus");
export const codecForMerchantCoinRefundStatus = (): Codec<
MerchantCoinRefundStatus
> =>
makeCodecForUnion<MerchantCoinRefundStatus>()
.discriminateOn("success")
.alternative(true, codecForMerchantCoinRefundSuccessStatus())
.alternative(false, codecForMerchantCoinRefundFailureStatus())
.build("MerchantCoinRefundStatus");
export const codecForMerchantOrderStatusPaid = (): Codec<
MerchantOrderStatusPaid
> =>
makeCodecForObject<MerchantOrderStatusPaid>()
.property("paid", makeCodecForConstTrue())
.property("merchant_pub", codecForString)
.property("refund_amount", codecForString)
.property("refunded", codecForBoolean)
.property("refunds", makeCodecForList(codecForMerchantCoinRefundStatus()))
.build("MerchantOrderStatusPaid");
export const codecForMerchantOrderStatusUnpaid = (): Codec<
MerchantOrderStatusUnpaid
> =>
makeCodecForObject<MerchantOrderStatusUnpaid>()
.property("paid", makeCodecForConstFalse())
.property("taler_pay_uri", codecForString)
.property("already_paid_order_id", makeCodecOptional(codecForString))
.build("MerchantOrderStatusUnpaid");
export const codecForMerchantOrderStatus = (): Codec<MerchantOrderStatus> =>
makeCodecForUnion<MerchantOrderStatus>()
.discriminateOn("paid")
.alternative(true, codecForMerchantOrderStatusPaid())
.alternative(false, codecForMerchantOrderStatusUnpaid())
.build("MerchantOrderStatus");

View File

@ -18,6 +18,8 @@
* Type-safe codecs for converting from/to JSON.
*/
/* eslint-disable @typescript-eslint/ban-types */
/**
* Error thrown when decoding fails.
*/
@ -335,6 +337,60 @@ export function makeCodecForConstString<V extends string>(s: V): Codec<V> {
};
}
/**
* Return a codec for a boolean true constant.
*/
export function makeCodecForConstTrue(): Codec<true> {
return {
decode(x: any, c?: Context): true {
if (x === true) {
return x;
}
throw new DecodingError(
`expected boolean true at ${renderContext(
c,
)} but got ${typeof x}`,
);
},
};
}
/**
* Return a codec for a boolean true constant.
*/
export function makeCodecForConstFalse(): Codec<false> {
return {
decode(x: any, c?: Context): false {
if (x === false) {
return x;
}
throw new DecodingError(
`expected boolean false at ${renderContext(
c,
)} but got ${typeof x}`,
);
},
};
}
/**
* Return a codec for a value that must be a constant number.
*/
export function makeCodecForConstNumber<V extends number>(n: V): Codec<V> {
return {
decode(x: any, c?: Context): V {
if (x === n) {
return x;
}
throw new DecodingError(
`expected number constant "${n}" at ${renderContext(
c,
)} but got ${typeof x}`,
);
},
};
}
export function makeCodecOptional<V>(
innerCodec: Codec<V>,
): Codec<V | undefined> {

View File

@ -220,7 +220,7 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
}
if (maybePath === "-") {
maybePath = "public/";
maybePath = "";
} else {
maybePath = decodeURIComponent(maybePath) + "/";
}

View File

@ -51,6 +51,7 @@ import {
Stores,
ReserveRecordStatus,
CoinSourceType,
RefundState,
} from "./types/dbTypes";
import { CoinDumpJson } from "./types/talerTypes";
import {
@ -534,6 +535,7 @@ export class Wallet {
[Stores.refreshGroups],
async (tx) => {
return await createRefreshGroup(
this.ws,
tx,
[{ coinPub: oldCoinPub }],
RefreshReason.Manual,
@ -785,22 +787,23 @@ export class Wallet {
if (!purchase) {
throw Error("unknown purchase");
}
const refundsDoneAmounts = Object.values(purchase.refundsDone).map((x) =>
Amounts.parseOrThrow(x.perm.refund_amount),
);
const refundsPendingAmounts = Object.values(
purchase.refundsPending,
).map((x) => Amounts.parseOrThrow(x.perm.refund_amount));
const refundsDoneAmounts = Object.values(purchase.refunds)
.filter((x) => x.type === RefundState.Applied)
.map((x) => x.refundAmount);
const refundsPendingAmounts = Object.values(purchase.refunds)
.filter((x) => x.type === RefundState.Pending)
.map((x) => x.refundAmount);
const totalRefundAmount = Amounts.sum([
...refundsDoneAmounts,
...refundsPendingAmounts,
]).amount;
const refundsDoneFees = Object.values(purchase.refundsDone).map((x) =>
Amounts.parseOrThrow(x.perm.refund_amount),
);
const refundsPendingFees = Object.values(purchase.refundsPending).map((x) =>
Amounts.parseOrThrow(x.perm.refund_amount),
);
const refundsDoneFees = Object.values(purchase.refunds)
.filter((x) => x.type === RefundState.Applied)
.map((x) => x.refundFee);
const refundsPendingFees = Object.values(purchase.refunds)
.filter((x) => x.type === RefundState.Pending)
.map((x) => x.refundFee);
const totalRefundFees = Amounts.sum([
...refundsDoneFees,
...refundsPendingFees,