From d88829cfa8dc7bf2967fb494af0290e068466828 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 23 Jul 2020 17:35:17 +0530 Subject: towards refunds with updated protocol --- src/operations/history.ts | 120 +++++++-------- src/operations/pay.ts | 9 +- src/operations/recoup.ts | 1 + src/operations/refresh.ts | 12 ++ src/operations/refund.ts | 340 +++++++++++++++++++---------------------- src/operations/transactions.ts | 121 ++++----------- src/types/dbTypes.ts | 76 +++++---- src/types/talerTypes.ts | 185 +++++++++++++++++++++- src/util/codec.ts | 56 +++++++ src/util/taleruri.ts | 2 +- src/wallet.ts | 27 ++-- 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 => { + 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, + r: MerchantCoinRefundSuccessStatus, ): Promise { - 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 { + 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 { + 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 = {}; - 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). @@ -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? 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 => .build("ContractTerms"); export const codecForMerchantRefundPermission = (): Codec< - MerchantRefundDetails + MerchantAbortPayRefundDetails > => - makeCodecForObject() + makeCodecForObject() .property("refund_amount", codecForString) .property("refund_fee", codecForString) .property("coin_pub", codecForString) @@ -1094,3 +1207,67 @@ export const codecForExchangeRevealResponse = (): Codec< makeCodecForObject() .property("ev_sigs", makeCodecForList(codecForExchangeRevealItem())) .build("ExchangeRevealResponse"); + +export const codecForMerchantCoinRefundSuccessStatus = (): Codec< + MerchantCoinRefundSuccessStatus +> => + makeCodecForObject() + .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() + .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() + .discriminateOn("success") + .alternative(true, codecForMerchantCoinRefundSuccessStatus()) + .alternative(false, codecForMerchantCoinRefundFailureStatus()) + .build("MerchantCoinRefundStatus"); + +export const codecForMerchantOrderStatusPaid = (): Codec< + MerchantOrderStatusPaid +> => + makeCodecForObject() + .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() + .property("paid", makeCodecForConstFalse()) + .property("taler_pay_uri", codecForString) + .property("already_paid_order_id", makeCodecOptional(codecForString)) + .build("MerchantOrderStatusUnpaid"); + +export const codecForMerchantOrderStatus = (): Codec => + makeCodecForUnion() + .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(s: V): Codec { }; } +/** + * Return a codec for a boolean true constant. + */ +export function makeCodecForConstTrue(): Codec { + 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 { + 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(n: V): Codec { + 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( innerCodec: Codec, ): Codec { 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, -- cgit v1.2.3