aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-07-23 17:35:17 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-07-23 17:35:17 +0530
commitd88829cfa8dc7bf2967fb494af0290e068466828 (patch)
tree682faf6027e572e2c28b548d65b0045692b2da29 /src
parente60563fb540c04d9ba751fea69c1fc0f1de598b5 (diff)
towards refunds with updated protocol
Diffstat (limited to 'src')
-rw-r--r--src/operations/history.ts120
-rw-r--r--src/operations/pay.ts9
-rw-r--r--src/operations/recoup.ts1
-rw-r--r--src/operations/refresh.ts12
-rw-r--r--src/operations/refund.ts340
-rw-r--r--src/operations/transactions.ts121
-rw-r--r--src/types/dbTypes.ts76
-rw-r--r--src/types/talerTypes.ts185
-rw-r--r--src/util/codec.ts56
-rw-r--r--src/util/taleruri.ts2
-rw-r--r--src/wallet.ts27
11 files changed, 563 insertions, 386 deletions
diff --git a/src/operations/history.ts b/src/operations/history.ts
index 9cbbd5163..8fff4f888 100644
--- a/src/operations/history.ts
+++ b/src/operations/history.ts
@@ -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) {
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
index 29b697833..0027bf0f3 100644
--- a/src/operations/pay.ts
+++ b/src/operations/pay.ts
@@ -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);
},
);
diff --git a/src/operations/recoup.ts b/src/operations/recoup.ts
index 445d029cd..e5f14c6ee 100644
--- a/src/operations/recoup.ts
+++ b/src/operations/recoup.ts
@@ -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,
diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts
index 4d477d644..74b032b91 100644
--- a/src/operations/refresh.ts
+++ b/src/operations/refresh.ts
@@ -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,
};
diff --git a/src/operations/refund.ts b/src/operations/refund.ts
index 1d6561bdc..af3325cfd 100644
--- a/src/operations/refund.ts
+++ b/src/operations/refund.ts
@@ -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(
- ws: InternalWalletState,
- proposalId: string,
- refundResponse: MerchantRefundResponse,
- reason: RefundReason,
+async function applySuccessfulRefund(
+ tx: TransactionHandle,
+ p: PurchaseRecord,
+ refreshCoinsMap: Record<string, { coinPub: string }>,
+ r: MerchantCoinRefundSuccessStatus,
): Promise<void> {
- const refunds = refundResponse.refunds;
-
- const refundGroupId = encodeCrock(randomBytes(32));
+ // FIXME: check signature before storing it as valid!
- let numNewRefunds = 0;
-
- const finishedRefunds: MerchantRefundDetails[] = [];
- const unfinishedRefunds: MerchantRefundDetails[] = [];
- const failedRefunds: MerchantRefundDetails[] = [];
+ 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,
+ );
- console.log("handling refund response", refundResponse);
+ p.refunds[refundKey] = {
+ type: RefundState.Applied,
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.feeRefund,
+ totalRefreshCostBound,
+ };
+}
- const refundsRefreshCost: { [refundKey: string]: AmountJson } = {};
+async function storePendingRefund(
+ tx: TransactionHandle,
+ p: PurchaseRecord,
+ r: MerchantCoinRefundFailureStatus,
+): Promise<void> {
+ const refundKey = getRefundKey(r);
- 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);
- }
+ 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,
+ );
- // 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);
+ 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,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<void> {
+ 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++;
+ // Already applied.
+ if (existingRefundInfo?.type === RefundState.Applied) {
+ continue;
}
- }
- // Avoid duplicates
- const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
-
- for (const rd of finishedRefunds) {
- const refundKey = getRefundKey(rd);
- if (p.refundsDone[refundKey]) {
+ // Still pending.
+ if (
+ refundStatus.success === false &&
+ existingRefundInfo?.type === RefundState.Pending
+ ) {
continue;
}
- p.refundsDone[refundKey] = {
- perm: rd,
- refundGroupId,
- };
- const oldPending = p.refundsPending[refundKey];
- if (oldPending) {
- delete p.refundsPending[refundKey];
- changedGroups[oldPending.refundGroupId] = true;
- } else {
- numNewRefunds++;
- }
- const c = await tx.get(Stores.coins, rd.coin_pub);
+ // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
- if (!c) {
- console.warn("coin not found, can't apply refund");
- return;
+ if (refundStatus.success === true) {
+ await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
+ } else {
+ await storePendingRefund(tx, p, refundStatus);
}
- 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 (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
+ queryDone = false;
+ }
- if (numNewRefunds === 0) {
- if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
- queryDone = false;
+ let numPendingRefunds = 0;
+ for (const ri of Object.values(p.refunds)) {
+ switch (ri.type) {
+ case RefundState.Pending:
+ numPendingRefunds++;
+ break;
}
- } else {
- p.refundGroups.push({
- reason: RefundReason.NormalRefund,
- refundGroupId,
- timestampQueried: getTimestampNow(),
- });
}
- if (Object.keys(unfinishedRefunds).length != 0) {
+ 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,
);
}
diff --git a/src/operations/transactions.ts b/src/operations/transactions.ts
index f104f1078..fb0629660 100644
--- a/src/operations/transactions.ts
+++ b/src/operations/transactions.ts
@@ -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),
- });
- }
});
},
);
diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts
index 252649b07..f75d5babe 100644
--- a/src/types/dbTypes.ts
+++ b/src/types/dbTypes.ts
@@ -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).
@@ -1270,30 +1304,10 @@ 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?
diff --git a/src/types/talerTypes.ts b/src/types/talerTypes.ts
index ef14684f9..b2d8f6a37 100644
--- a/src/types/talerTypes.ts
+++ b/src/types/talerTypes.ts
@@ -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");
diff --git a/src/util/codec.ts b/src/util/codec.ts
index 136c5b053..c468704b2 100644
--- a/src/util/codec.ts
+++ b/src/util/codec.ts
@@ -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> {
diff --git a/src/util/taleruri.ts b/src/util/taleruri.ts
index 30209d48a..73280b6c8 100644
--- a/src/util/taleruri.ts
+++ b/src/util/taleruri.ts
@@ -220,7 +220,7 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
}
if (maybePath === "-") {
- maybePath = "public/";
+ maybePath = "";
} else {
maybePath = decodeURIComponent(maybePath) + "/";
}
diff --git a/src/wallet.ts b/src/wallet.ts
index ff72f3c75..60ed695fd 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -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,