From 7f0edb6a783d9a50f94f65c815c1280baecaac89 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 5 May 2023 19:03:44 +0200 Subject: wallet-core: refund DD37 refactoring --- packages/taler-wallet-core/src/db.ts | 189 ++- .../src/operations/backup/export.ts | 59 +- .../src/operations/backup/import.ts | 95 +- .../src/operations/pay-merchant.ts | 1492 +++++++++----------- .../taler-wallet-core/src/operations/refresh.ts | 111 +- .../taler-wallet-core/src/operations/testing.ts | 6 +- .../src/operations/transactions.ts | 310 +--- packages/taler-wallet-core/src/util/query.ts | 2 +- packages/taler-wallet-core/src/util/retries.ts | 5 + packages/taler-wallet-core/src/wallet-api-types.ts | 35 +- packages/taler-wallet-core/src/wallet.ts | 33 +- 11 files changed, 1029 insertions(+), 1308 deletions(-) (limited to 'packages/taler-wallet-core/src') diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index d1258f2f9..92781d2ed 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -118,7 +118,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 6; +export const WALLET_DB_MINOR_VERSION = 7; /** * Ranges for operation status fields. @@ -208,7 +208,7 @@ export enum WithdrawalGroupStatus { * talk to the exchange. Money might have been * wired or not. */ - AbortedExchange = 60 + AbortedExchange = 60, } /** @@ -1012,63 +1012,6 @@ export interface RefreshSessionRecord { norevealIndex?: number; } -export 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 { - // Execution time as claimed by the merchant - executionTime: TalerProtocolTimestamp; - - /** - * Time when the wallet became aware of the refund. - */ - obtainedTime: TalerProtocolTimestamp; - - refundAmount: AmountString; - - refundFee: AmountString; - - /** - * 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: AmountString; - - coinPub: string; - - rtransactionId: number; -} - -/** - * 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 enum RefundReason { /** * Normal refund given by the merchant. @@ -1161,6 +1104,8 @@ export enum PurchaseStatus { */ QueryingAutoRefund = 15, + PendingAcceptRefund = 16, + /** * Proposal downloaded, but the user needs to accept/reject it. */ @@ -1169,12 +1114,12 @@ export enum PurchaseStatus { /** * The user has rejected the proposal. */ - ProposalRefused = 50, + AbortedProposalRefused = 50, /** * Downloading or processing the proposal has failed permanently. */ - ProposalDownloadFailed = 51, + FailedClaim = 51, /** * Downloaded proposal was detected as a re-purchase. @@ -1184,12 +1129,12 @@ export enum PurchaseStatus { /** * The payment has been aborted. */ - PaymentAbortFinished = 53, + AbortedIncompletePayment = 53, /** * Payment was successful. */ - Paid = 54, + Done = 54, } /** @@ -1303,7 +1248,7 @@ export interface PurchaseRecord { * * FIXME: Put this into a separate object store? */ - refunds: { [refundKey: string]: WalletRefundItem }; + // refunds: { [refundKey: string]: WalletRefundItem }; /** * When was the last refund made? @@ -2152,6 +2097,97 @@ export interface CurrencySettingsRecord { // Later, we might add stuff related to how the currency is rendered. } +export enum RefundGroupStatus { + Pending = 10, + Done = 50, + Failed = 51, + Aborted = 52, +} + +/** + * Metadata about a group of refunds with the merchant. + */ +export interface RefundGroupRecord { + status: RefundGroupStatus; + + /** + * Timestamp when the refund group was created. + */ + timestampCreated: TalerProtocolTimestamp; + + proposalId: string; + + refundGroupId: string; + + refreshGroupId?: string; + + amountRaw: AmountString; + + /** + * Estimated effective amount, based on + * refund fees and refresh costs. + */ + amountEffective: AmountString; +} + +export enum RefundItemStatus { + /** + * Intermittent error that the merchant is + * reporting from the exchange. + * + * We'll try again! + */ + Pending = 10, + /** + * Refund was obtained successfully. + */ + Done = 50, + /** + * Permanent error reported by the exchange + * for the refund. + */ + Failed = 51, +} + +/** + * Refund for a single coin in a payment with a merchant. + */ +export interface RefundItemRecord { + /** + * Auto-increment DB record ID. + */ + id?: number; + + status: RefundItemStatus; + + refundGroupId: string; + + // Execution time as claimed by the merchant + executionTime: TalerProtocolTimestamp; + + /** + * Time when the wallet became aware of the refund. + */ + obtainedTime: TalerProtocolTimestamp; + + refundAmount: AmountString; + + //refundFee: AmountString; + + /** + * 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: AmountString; + + coinPub: string; + + rtxid: number; +} + /** * Schema definition for the IndexedDB * wallet database. @@ -2494,6 +2530,31 @@ export const WalletStoresV1 = { }), {}, ), + refundGroups: describeStore( + "refundGroups", + describeContents({ + keyPath: "refundGroupId", + versionAdded: 7, + }), + { + byProposalId: describeIndex("byProposalId", "proposalId"), + }, + ), + refundItems: describeStore( + "refundItems", + describeContents({ + keyPath: "id", + versionAdded: 7, + autoIncrement: true, + }), + { + byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [ + "coinPub", + "rtxid", + ]), + byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]), + }, + ), fixups: describeStore( "fixups", describeContents({ diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index 68f8beb93..7b245a4eb 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -69,7 +69,6 @@ import { DenominationRecord, PurchaseStatus, RefreshCoinStatus, - RefundState, WithdrawalGroupStatus, WithdrawalRecordType, } from "../../db.js"; @@ -384,34 +383,34 @@ export async function exportBackup( await tx.purchases.iter().forEachAsync(async (purch) => { const refunds: BackupRefundItem[] = []; purchaseProposalIdSet.add(purch.proposalId); - for (const refundKey of Object.keys(purch.refunds)) { - const ri = purch.refunds[refundKey]; - const common = { - coin_pub: ri.coinPub, - execution_time: ri.executionTime, - obtained_time: ri.obtainedTime, - refund_amount: Amounts.stringify(ri.refundAmount), - rtransaction_id: ri.rtransactionId, - total_refresh_cost_bound: Amounts.stringify( - ri.totalRefreshCostBound, - ), - }; - switch (ri.type) { - case RefundState.Applied: - refunds.push({ type: BackupRefundState.Applied, ...common }); - break; - case RefundState.Failed: - refunds.push({ type: BackupRefundState.Failed, ...common }); - break; - case RefundState.Pending: - refunds.push({ type: BackupRefundState.Pending, ...common }); - break; - } - } + // for (const refundKey of Object.keys(purch.refunds)) { + // const ri = purch.refunds[refundKey]; + // const common = { + // coin_pub: ri.coinPub, + // execution_time: ri.executionTime, + // obtained_time: ri.obtainedTime, + // refund_amount: Amounts.stringify(ri.refundAmount), + // rtransaction_id: ri.rtransactionId, + // total_refresh_cost_bound: Amounts.stringify( + // ri.totalRefreshCostBound, + // ), + // }; + // switch (ri.type) { + // case RefundState.Applied: + // refunds.push({ type: BackupRefundState.Applied, ...common }); + // break; + // case RefundState.Failed: + // refunds.push({ type: BackupRefundState.Failed, ...common }); + // break; + // case RefundState.Pending: + // refunds.push({ type: BackupRefundState.Pending, ...common }); + // break; + // } + // } let propStatus: BackupProposalStatus; switch (purch.purchaseStatus) { - case PurchaseStatus.Paid: + case PurchaseStatus.Done: case PurchaseStatus.QueryingAutoRefund: case PurchaseStatus.QueryingRefund: propStatus = BackupProposalStatus.Paid; @@ -422,19 +421,19 @@ export async function exportBackup( case PurchaseStatus.Paying: propStatus = BackupProposalStatus.Proposed; break; - case PurchaseStatus.ProposalDownloadFailed: - case PurchaseStatus.PaymentAbortFinished: + case PurchaseStatus.FailedClaim: + case PurchaseStatus.AbortedIncompletePayment: propStatus = BackupProposalStatus.PermanentlyFailed; break; case PurchaseStatus.AbortingWithRefund: - case PurchaseStatus.ProposalRefused: + case PurchaseStatus.AbortedProposalRefused: propStatus = BackupProposalStatus.Refused; break; case PurchaseStatus.RepurchaseDetected: propStatus = BackupProposalStatus.Repurchase; break; default: { - const error: never = purch.purchaseStatus; + const error = purch.purchaseStatus; throw Error(`purchase status ${error} is not handled`); } } diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 296517162..5375a58bb 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -49,9 +49,7 @@ import { PurchasePayInfo, RefreshCoinStatus, RefreshSessionRecord, - RefundState, WalletContractData, - WalletRefundItem, WalletStoresV1, WgInfo, WithdrawalGroupStatus, @@ -65,7 +63,6 @@ import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; import { makeCoinAvailable, makeTombstoneId, - makeTransactionId, TombstoneTag, } from "../common.js"; import { getExchangeDetails } from "../exchanges.js"; @@ -576,16 +573,16 @@ export async function importBackup( let proposalStatus: PurchaseStatus; switch (backupPurchase.proposal_status) { case BackupProposalStatus.Paid: - proposalStatus = PurchaseStatus.Paid; + proposalStatus = PurchaseStatus.Done; break; case BackupProposalStatus.Proposed: proposalStatus = PurchaseStatus.Proposed; break; case BackupProposalStatus.PermanentlyFailed: - proposalStatus = PurchaseStatus.PaymentAbortFinished; + proposalStatus = PurchaseStatus.AbortedIncompletePayment; break; case BackupProposalStatus.Refused: - proposalStatus = PurchaseStatus.ProposalRefused; + proposalStatus = PurchaseStatus.AbortedProposalRefused; break; case BackupProposalStatus.Repurchase: proposalStatus = PurchaseStatus.RepurchaseDetected; @@ -596,48 +593,48 @@ export async function importBackup( } } if (!existingPurchase) { - const refunds: { [refundKey: string]: WalletRefundItem } = {}; - for (const backupRefund of backupPurchase.refunds) { - const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`; - const coin = await tx.coins.get(backupRefund.coin_pub); - checkBackupInvariant(!!coin); - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - checkBackupInvariant(!!denom); - const common = { - coinPub: backupRefund.coin_pub, - executionTime: backupRefund.execution_time, - obtainedTime: backupRefund.obtained_time, - refundAmount: Amounts.stringify(backupRefund.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - rtransactionId: backupRefund.rtransaction_id, - totalRefreshCostBound: Amounts.stringify( - backupRefund.total_refresh_cost_bound, - ), - }; - switch (backupRefund.type) { - case BackupRefundState.Applied: - refunds[key] = { - type: RefundState.Applied, - ...common, - }; - break; - case BackupRefundState.Failed: - refunds[key] = { - type: RefundState.Failed, - ...common, - }; - break; - case BackupRefundState.Pending: - refunds[key] = { - type: RefundState.Pending, - ...common, - }; - break; - } - } + //const refunds: { [refundKey: string]: WalletRefundItem } = {}; + // for (const backupRefund of backupPurchase.refunds) { + // const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`; + // const coin = await tx.coins.get(backupRefund.coin_pub); + // checkBackupInvariant(!!coin); + // const denom = await tx.denominations.get([ + // coin.exchangeBaseUrl, + // coin.denomPubHash, + // ]); + // checkBackupInvariant(!!denom); + // const common = { + // coinPub: backupRefund.coin_pub, + // executionTime: backupRefund.execution_time, + // obtainedTime: backupRefund.obtained_time, + // refundAmount: Amounts.stringify(backupRefund.refund_amount), + // refundFee: Amounts.stringify(denom.fees.feeRefund), + // rtransactionId: backupRefund.rtransaction_id, + // totalRefreshCostBound: Amounts.stringify( + // backupRefund.total_refresh_cost_bound, + // ), + // }; + // switch (backupRefund.type) { + // case BackupRefundState.Applied: + // refunds[key] = { + // type: RefundState.Applied, + // ...common, + // }; + // break; + // case BackupRefundState.Failed: + // refunds[key] = { + // type: RefundState.Failed, + // ...common, + // }; + // break; + // case BackupRefundState.Pending: + // refunds[key] = { + // type: RefundState.Pending, + // ...common, + // }; + // break; + // } + // } const parsedContractTerms = codecForMerchantContractTerms().decode( backupPurchase.contract_terms_raw, ); @@ -694,7 +691,7 @@ export async function importBackup( posConfirmation: backupPurchase.pos_confirmation, lastSessionId: undefined, download, - refunds, + //refunds, claimToken: backupPurchase.claim_token, downloadSessionId: backupPurchase.download_session_id, merchantBaseUrl: backupPurchase.merchant_base_url, diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 6aad1d742..99b9a18d2 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -58,19 +58,23 @@ import { MerchantCoinRefundSuccessStatus, MerchantContractTerms, MerchantPayResponse, + MerchantRefundResponse, NotificationType, parsePayUri, parseRefundUri, + parseTalerUri, PayCoinSelection, PreparePayResult, PreparePayResultType, PrepareRefundResult, + randomBytes, RefreshReason, TalerError, TalerErrorCode, TalerErrorDetail, TalerProtocolTimestamp, TalerProtocolViolationError, + TalerUriAction, TransactionMajorState, TransactionMinorState, TransactionState, @@ -93,11 +97,16 @@ import { PurchaseRecord, PurchaseStatus, RefundReason, - RefundState, WalletContractData, WalletStoresV1, } from "../db.js"; -import { GetReadWriteAccess, PendingTaskType } from "../index.js"; +import { + PendingTaskType, + RefundGroupRecord, + RefundGroupStatus, + RefundItemRecord, + RefundItemStatus, +} from "../index.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState, @@ -116,10 +125,19 @@ import { } from "../util/retries.js"; import { makeTransactionId, + runLongpollAsync, runOperationWithErrorReporting, spendCoins, } from "./common.js"; -import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; +import { + calculateRefreshOutput, + createRefreshGroup, + getTotalRefreshCost, +} from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; /** * Logger. @@ -193,7 +211,7 @@ async function failProposalPermanently( if (!p) { return; } - p.purchaseStatus = PurchaseStatus.ProposalDownloadFailed; + p.purchaseStatus = PurchaseStatus.FailedClaim; await tx.purchases.put(p); }); } @@ -601,7 +619,6 @@ async function startDownloadProposal( merchantPaySig: undefined, payInfo: undefined, refundAmountAwaiting: undefined, - refunds: {}, timestampAccept: undefined, timestampFirstSuccessfulPay: undefined, timestampLastRefundStatus: undefined, @@ -649,7 +666,7 @@ async function storeFirstPaySuccess( return; } if (purchase.purchaseStatus === PurchaseStatus.Paying) { - purchase.purchaseStatus = PurchaseStatus.Paid; + purchase.purchaseStatus = PurchaseStatus.Done; } purchase.timestampFirstSuccessfulPay = now; purchase.lastSessionId = sessionId; @@ -701,7 +718,7 @@ async function storePayReplaySuccess( purchase.purchaseStatus === PurchaseStatus.Paying || purchase.purchaseStatus === PurchaseStatus.PayingReplay ) { - purchase.purchaseStatus = PurchaseStatus.Paid; + purchase.purchaseStatus = PurchaseStatus.Done; } purchase.lastSessionId = sessionId; await tx.purchases.put(purchase); @@ -899,6 +916,11 @@ export async function checkPaymentByProposalId( proposalId = proposal.proposalId; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const talerUri = constructPayUri( proposal.merchantBaseUrl, proposal.orderId, @@ -937,6 +959,7 @@ export async function checkPaymentByProposalId( status: PreparePayResultType.InsufficientBalance, contractTerms: d.contractTermsRaw, proposalId: proposal.proposalId, + transactionId, noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), talerUri, @@ -951,6 +974,7 @@ export async function checkPaymentByProposalId( return { status: PreparePayResultType.PaymentPossible, contractTerms: d.contractTermsRaw, + transactionId, proposalId: proposal.proposalId, noncePriv: proposal.noncePriv, amountEffective: Amounts.stringify(totalCost), @@ -961,7 +985,7 @@ export async function checkPaymentByProposalId( } if ( - purchase.purchaseStatus === PurchaseStatus.Paid && + purchase.purchaseStatus === PurchaseStatus.Done && purchase.lastSessionId !== sessionId ) { logger.trace( @@ -992,6 +1016,7 @@ export async function checkPaymentByProposalId( paid: true, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + transactionId, proposalId, talerUri, }; @@ -1004,12 +1029,13 @@ export async function checkPaymentByProposalId( paid: false, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + transactionId, proposalId, talerUri, }; } else { const paid = - purchase.purchaseStatus === PurchaseStatus.Paid || + purchase.purchaseStatus === PurchaseStatus.Done || purchase.purchaseStatus === PurchaseStatus.QueryingRefund || purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund; const download = await expectProposalDownload(ws, purchase); @@ -1021,6 +1047,7 @@ export async function checkPaymentByProposalId( amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), ...(paid ? { nextUrl: download.contractData.orderId } : {}), + transactionId, proposalId, talerUri, }; @@ -1244,7 +1271,7 @@ export async function confirmPay( ) { logger.trace(`changing session ID to ${sessionIdOverride}`); purchase.lastSessionId = sessionIdOverride; - if (purchase.purchaseStatus === PurchaseStatus.Paid) { + if (purchase.purchaseStatus === PurchaseStatus.Done) { purchase.purchaseStatus = PurchaseStatus.PayingReplay; } await tx.purchases.put(purchase); @@ -1331,7 +1358,7 @@ export async function confirmPay( refreshReason: RefreshReason.PayMerchant, }); break; - case PurchaseStatus.Paid: + case PurchaseStatus.Done: case PurchaseStatus.Paying: default: break; @@ -1371,20 +1398,24 @@ export async function processPurchase( switch (purchase.purchaseStatus) { case PurchaseStatus.DownloadingProposal: - return processDownloadProposal(ws, proposalId, options); + return processDownloadProposal(ws, proposalId); case PurchaseStatus.Paying: case PurchaseStatus.PayingReplay: - return processPurchasePay(ws, proposalId, options); + return processPurchasePay(ws, proposalId); case PurchaseStatus.QueryingRefund: + return processPurchaseQueryRefund(ws, purchase); case PurchaseStatus.QueryingAutoRefund: + return processPurchaseAutoRefund(ws, purchase); case PurchaseStatus.AbortingWithRefund: - return processPurchaseQueryRefund(ws, proposalId, options); - case PurchaseStatus.ProposalDownloadFailed: - case PurchaseStatus.Paid: + return processPurchaseAbortingRefund(ws, purchase); + case PurchaseStatus.PendingAcceptRefund: + return processPurchaseAcceptRefund(ws, purchase); + case PurchaseStatus.FailedClaim: + case PurchaseStatus.Done: case PurchaseStatus.RepurchaseDetected: case PurchaseStatus.Proposed: - case PurchaseStatus.ProposalRefused: - case PurchaseStatus.PaymentAbortFinished: + case PurchaseStatus.AbortedProposalRefused: + case PurchaseStatus.AbortedIncompletePayment: return { type: OperationAttemptResultType.Finished, result: undefined, @@ -1588,7 +1619,7 @@ export async function refuseProposal( if (proposal.purchaseStatus !== PurchaseStatus.Proposed) { return false; } - proposal.purchaseStatus = PurchaseStatus.ProposalRefused; + proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused; await tx.purchases.put(proposal); return true; }); @@ -1599,942 +1630,731 @@ export async function refuseProposal( } } -export async function prepareRefund( +export async function abortPayMerchant( ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("preparing refund offer", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.indexes.byUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); + proposalId: string, +): Promise { + const opId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); + await ws.db + .mktx((x) => [ + x.purchases, + x.refreshGroups, + x.denominations, + x.coinAvailability, + x.coins, + x.operationRetries, + ]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + const oldStatus = purchase.purchaseStatus; + if (purchase.timestampFirstSuccessfulPay) { + // No point in aborting it. We don't even report an error. + logger.warn(`tried to abort successful payment`); + return; + } + if (oldStatus === PurchaseStatus.Paying) { + purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + } + await tx.purchases.put(purchase); + if (oldStatus === PurchaseStatus.Paying) { + if (purchase.payInfo) { + const coinSel = purchase.payInfo.payCoinSelection; + const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); + const refreshCoins: CoinRefreshRequest[] = []; + for (let i = 0; i < coinSel.coinPubs.length; i++) { + refreshCoins.push({ + amount: coinSel.coinContributions[i], + coinPub: coinSel.coinPubs[i], + }); + } + await createRefreshGroup( + ws, + tx, + currency, + refreshCoins, + RefreshReason.AbortPay, + ); + } + } + await tx.operationRetries.delete(opId); }); - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); + ws.workAvailable.trigger(); +} + +export function computePayMerchantTransactionState( + purchaseRecord: PurchaseRecord, +): TransactionState { + switch (purchaseRecord.purchaseStatus) { + case PurchaseStatus.DownloadingProposal: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.ClaimProposal, + }; + case PurchaseStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case PurchaseStatus.AbortedIncompletePayment: + return { + major: TransactionMajorState.Aborted, + }; + case PurchaseStatus.Proposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.MerchantOrderProposed, + }; + case PurchaseStatus.FailedClaim: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.ClaimProposal, + }; + case PurchaseStatus.RepurchaseDetected: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Repurchase, + }; + case PurchaseStatus.AbortingWithRefund: + return { + major: TransactionMajorState.Aborting, + }; + case PurchaseStatus.Paying: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Pay, + }; + case PurchaseStatus.PayingReplay: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.RebindSession, + }; + case PurchaseStatus.AbortedProposalRefused: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Refused, + }; + case PurchaseStatus.QueryingAutoRefund: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AutoRefund, + }; + case PurchaseStatus.QueryingRefund: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.CheckRefunds, + }; + case PurchaseStatus.PendingAcceptRefund: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AcceptRefund, + }; } +} - const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); - const summary = await calculateRefundSummary(ws, purchase); +async function processPurchaseAutoRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise { const proposalId = purchase.proposalId; + logger.trace(`processing auto-refund for proposal ${proposalId}`); - const { contractData: c } = await expectProposalDownload(ws, purchase); + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); - return { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, proposalId, - effectivePaid: Amounts.stringify(summary.amountEffectivePaid), - gone: Amounts.stringify(summary.amountRefundGone), - granted: Amounts.stringify(summary.amountRefundGranted), - pending: summary.pendingAtExchange, - awaiting: Amounts.stringify(awaiting), - info: { - contractTermsHash: c.contractTermsHash, - merchant: c.merchant, - orderId: c.orderId, - products: c.products, - summary: c.summary, - fulfillmentMessage: c.fulfillmentMessage, - summary_i18n: c.summaryI18n, - fulfillmentMessage_i18n: c.fulfillmentMessageI18n, - }, - }; -} + }); -function getRefundKey(d: MerchantCoinRefundStatus): string { - return `${d.coin_pub}-${d.rtransaction_id}`; -} + // FIXME: Put this logic into runLongpollAsync? + if (ws.activeLongpoll[taskId]) { + return OperationAttemptResult.longpoll(); + } -async function applySuccessfulRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record, - r: MerchantCoinRefundSuccessStatus, - denomselAllowLate: boolean, -): Promise { - // FIXME: check signature before storing it as valid! + const download = await expectProposalDownload(ws, purchase); - const refundKey = getRefundKey(r); - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error("inconsistent database"); - } - const refundAmount = Amounts.parseOrThrow(r.refund_amount); - const refundFee = denom.fees.feeRefund; - const amountLeft = Amounts.sub(refundAmount, refundFee).amount; - coin.status = CoinStatus.Dormant; - await tx.coins.put(coin); - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - denomselAllowLate, - ); + runLongpollAsync(ws, taskId, async (ct) => { + if ( + !purchase.autoRefundDeadline || + AbsoluteTime.isExpired( + AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), + ) + ) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.Done; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return { + ready: true, + }; + } - refreshCoinsMap[coin.coinPub] = { - coinPub: coin.coinPub, - amount: Amounts.stringify(amountLeft), - }; + const requestUrl = new URL( + `orders/${download.contractData.orderId}`, + download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + download.contractData.contractTermsHash, + ); - p.refunds[refundKey] = { - type: RefundState.Applied, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.stringify(r.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} + requestUrl.searchParams.set("timeout_ms", "1000"); + requestUrl.searchParams.set("await_refund_obtained", "yes"); -async function storePendingRefund( - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - }>, - p: PurchaseRecord, - r: MerchantCoinRefundFailureStatus, - denomselAllowLate: boolean, -): Promise { - const refundKey = getRefundKey(r); + const resp = await ws.http.fetch(requestUrl.href); - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); + // FIXME: Check other status codes! - if (!denom) { - throw Error("inconsistent database"); - } + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - // Refunded amount after fees. - const amountLeft = Amounts.sub( - Amounts.parseOrThrow(r.refund_amount), - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - denomselAllowLate, - ); + if (orderStatus.refund_pending) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + if (p.purchaseStatus !== PurchaseStatus.QueryingAutoRefund) { + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return { + ready: true, + }; + } else { + return { + ready: false, + }; + } + }); - p.refunds[refundKey] = { - type: RefundState.Pending, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.stringify(r.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; + return OperationAttemptResult.longpoll(); } -async function storeFailedRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record, - r: MerchantCoinRefundFailureStatus, - denomselAllowLate: boolean, -): Promise { - const refundKey = getRefundKey(r); +async function processPurchaseAbortingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; + const download = await expectProposalDownload(ws, purchase); + logger.trace(`processing aborting-refund for proposal ${proposalId}`); - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); + const requestUrl = new URL( + `orders/${download.contractData.orderId}/abort`, + download.contractData.merchantBaseUrl, + ); - if (!denom) { - throw Error("inconsistent database"); + const abortingCoins: AbortingCoin[] = []; + + const payCoinSelection = purchase.payInfo?.payCoinSelection; + if (!payCoinSelection) { + throw Error("can't abort, no coins selected"); } - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); + await ws.db + .mktx((x) => [x.coins]) + .runReadOnly(async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const coin = await tx.coins.get(coinPub); + checkDbInvariant(!!coin, "expected coin to be present"); + abortingCoins.push({ + coin_pub: coinPub, + contribution: Amounts.stringify( + payCoinSelection.coinContributions[i], + ), + exchange_url: coin.exchangeBaseUrl, + }); + } + }); + + const abortReq: AbortRequest = { + h_contract: download.contractData.contractTermsHash, + coins: abortingCoins, + }; - const amountLeft = Amounts.sub( - Amounts.parseOrThrow(r.refund_amount), - denom.fees.feeRefund, - ).amount; + logger.trace(`making order abort request to ${requestUrl.href}`); - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - denomselAllowLate, + const request = await ws.http.postJson(requestUrl.href, abortReq); + const abortResp = await readSuccessResponseJsonOrThrow( + request, + codecForAbortResponse(), ); - p.refunds[refundKey] = { - type: RefundState.Failed, - obtainedTime: TalerProtocolTimestamp.now(), - executionTime: r.execution_time, - refundAmount: Amounts.stringify(r.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; + const refunds: MerchantCoinRefundStatus[] = []; - if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - // Refund failed because the merchant didn't even try to deposit - // the coin yet, so we try to refresh. - // FIXME: Is this case tested?! - if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) { - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - logger.warn("denomination for coin missing"); - return; - } - const payCoinSelection = p.payInfo?.payCoinSelection; - if (!payCoinSelection) { - logger.warn("no pay coin selection, can't apply refund"); - return; - } - let contrib: AmountJson | undefined; - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - if (payCoinSelection.coinPubs[i] === r.coin_pub) { - contrib = Amounts.parseOrThrow(payCoinSelection.coinContributions[i]); - } - } - // FIXME: Is this case tested?! - refreshCoinsMap[coin.coinPub] = { - coinPub: coin.coinPub, - amount: Amounts.stringify(amountLeft), - }; - await tx.coins.put(coin); - } + if (abortResp.refunds.length != abortingCoins.length) { + // FIXME: define error code! + throw Error("invalid order abort response"); + } + + for (let i = 0; i < abortResp.refunds.length; i++) { + const r = abortResp.refunds[i]; + refunds.push({ + ...r, + coin_pub: payCoinSelection.coinPubs[i], + refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), + rtransaction_id: 0, + execution_time: AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.fromTimestamp(download.contractData.timestamp), + Duration.fromSpec({ seconds: 1 }), + ), + ), + }); } + return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund); } -async function acceptRefunds( +async function processPurchaseQueryRefund( ws: InternalWalletState, - proposalId: string, - refunds: MerchantCoinRefundStatus[], - reason: RefundReason, -): Promise { - logger.trace("handling refunds", refunds); - const now = TalerProtocolTimestamp.now(); + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; + logger.trace(`processing query-refund for proposal ${proposalId}`); - await ws.db - .mktx((x) => [ - x.purchases, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - ]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.error("purchase not found, not adding refunds"); - return; - } - - const refreshCoinsMap: Record = {}; - for (const refundStatus of refunds) { - const refundKey = getRefundKey(refundStatus); - const existingRefundInfo = p.refunds[refundKey]; - - const isPermanentFailure = - refundStatus.type === "failure" && - refundStatus.exchange_status >= 400 && - refundStatus.exchange_status < 500; - - // Already failed. - if (existingRefundInfo?.type === RefundState.Failed) { - continue; - } + const download = await expectProposalDownload(ws, purchase); - // Already applied. - if (existingRefundInfo?.type === RefundState.Applied) { - continue; - } + const requestUrl = new URL( + `orders/${download.contractData.orderId}`, + download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + download.contractData.contractTermsHash, + ); - // Still pending. - if ( - refundStatus.type === "failure" && - !isPermanentFailure && - existingRefundInfo?.type === RefundState.Pending - ) { - continue; - } + const resp = await ws.http.fetch(requestUrl.href); + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); - // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); - if (refundStatus.type === "success") { - await applySuccessfulRefund( - tx, - p, - refreshCoinsMap, - refundStatus, - ws.config.testing.denomselAllowLate, - ); - } else if (isPermanentFailure) { - await storeFailedRefund( - tx, - p, - refreshCoinsMap, - refundStatus, - ws.config.testing.denomselAllowLate, - ); - } else { - await storePendingRefund( - tx, - p, - refundStatus, - ws.config.testing.denomselAllowLate, - ); + if (!orderStatus.refund_pending) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return undefined; } - } - - if (reason !== RefundReason.AbortRefund) { - // For abort-refunds, the refresh group has already been - // created before the refund was started. - // For other refunds, we need to create it after we know - // the amounts. - const refreshCoinsPubs = Object.values(refreshCoinsMap); - logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`); - if (refreshCoinsPubs.length > 0) { - await createRefreshGroup( - ws, - tx, - Amounts.currencyOf(refreshCoinsPubs[0].amount), - refreshCoinsPubs, - RefreshReason.Refund, - ); + if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { + return undefined; } - } - - // Are we done with querying yet, or do we need to do another round - // after a retry delay? - let queryDone = true; + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.Done; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return OperationAttemptResult.finishedEmpty(); + } else { + const refundAwaiting = Amounts.sub( + Amounts.parseOrThrow(orderStatus.refund_amount), + Amounts.parseOrThrow(orderStatus.refund_taken), + ).amount; - let numPendingRefunds = 0; - for (const ri of Object.values(p.refunds)) { - switch (ri.type) { - case RefundState.Pending: - numPendingRefunds++; - break; + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; } - } - - if (numPendingRefunds > 0) { - queryDone = false; - } - - if (queryDone) { - p.timestampLastRefundStatus = now; - if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - p.purchaseStatus = PurchaseStatus.PaymentAbortFinished; - } else if (p.purchaseStatus === PurchaseStatus.QueryingAutoRefund) { - const autoRefundDeadline = p.autoRefundDeadline; - checkDbInvariant(!!autoRefundDeadline); - if ( - AbsoluteTime.isExpired( - AbsoluteTime.fromTimestamp(autoRefundDeadline), - ) - ) { - p.purchaseStatus = PurchaseStatus.Paid; - } - } else if (p.purchaseStatus === PurchaseStatus.QueryingRefund) { - p.purchaseStatus = PurchaseStatus.Paid; - p.refundAmountAwaiting = undefined; + if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { + return; } - logger.trace("refund query done"); - ws.notify({ - type: NotificationType.RefundFinished, - transactionId: makeTransactionId( - TransactionType.Payment, - p.proposalId, - ), - }); - } else { - // No error, but we need to try again! - p.timestampLastRefundStatus = now; - logger.trace("refund query not done"); - } - - await tx.purchases.put(p); - }); - - ws.notify({ - type: NotificationType.RefundQueried, - transactionId: makeTransactionId(TransactionType.Payment, proposalId), - }); + const oldTxState = computePayMerchantTransactionState(p); + p.refundAmountAwaiting = Amounts.stringify(refundAwaiting); + p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return OperationAttemptResult.finishedEmpty(); + } } -async function calculateRefundSummary( +async function processPurchaseAcceptRefund( ws: InternalWalletState, - p: PurchaseRecord, -): Promise { - const download = await expectProposalDownload(ws, p); - let amountRefundGranted = Amounts.zeroOfAmount(download.contractData.amount); - let amountRefundGone = Amounts.zeroOfAmount(download.contractData.amount); + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; - let pendingAtExchange = false; + const download = await expectProposalDownload(ws, purchase); - const payInfo = p.payInfo; - if (!payInfo) { - throw Error("can't calculate refund summary without payInfo"); - } + const requestUrl = new URL( + `orders/${download.contractData.orderId}/refund`, + download.contractData.merchantBaseUrl, + ); - Object.keys(p.refunds).forEach((rk) => { - const refund = p.refunds[rk]; - if (refund.type === RefundState.Pending) { - pendingAtExchange = true; - } - if ( - refund.type === RefundState.Applied || - refund.type === RefundState.Pending - ) { - amountRefundGranted = Amounts.add( - amountRefundGranted, - Amounts.sub( - refund.refundAmount, - refund.refundFee, - refund.totalRefreshCostBound, - ).amount, - ).amount; - } else { - amountRefundGone = Amounts.add( - amountRefundGone, - refund.refundAmount, - ).amount; - } + logger.trace(`making refund request to ${requestUrl.href}`); + + const request = await ws.http.postJson(requestUrl.href, { + h_contract: download.contractData.contractTermsHash, }); - return { - amountEffectivePaid: Amounts.parseOrThrow(payInfo.totalPayCost), - amountRefundGone, - amountRefundGranted, - pendingAtExchange, - }; -} -/** - * Summary of the refund status of a purchase. - */ -export interface RefundSummary { - pendingAtExchange: boolean; - amountEffectivePaid: AmountJson; - amountRefundGranted: AmountJson; - amountRefundGone: AmountJson; + const refundResponse = await readSuccessResponseJsonOrThrow( + request, + codecForMerchantOrderRefundPickupResponse(), + ); + return await storeRefunds( + ws, + purchase, + refundResponse.refunds, + RefundReason.AbortRefund, + ); } -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( +export async function startRefundQueryForUri( ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("applying refund", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); + talerUri: string, +): Promise { + const parsedUri = parseTalerUri(talerUri); + if (!parsedUri) { + throw Error("invalid taler:// URI"); } - - const purchase = await ws.db + if (parsedUri.type !== TalerUriAction.Refund) { + throw Error("expected taler://refund URI"); + } + const purchaseRecord = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, + parsedUri.merchantBaseUrl, + parsedUri.orderId, ]); }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); + if (!purchaseRecord) { + throw Error("no purchase found, can't refund"); } - - return applyRefundFromPurchaseId(ws, purchase.proposalId); + return startQueryRefund(ws, purchaseRecord.proposalId); } -export async function applyRefundFromPurchaseId( +export async function startQueryRefund( ws: InternalWalletState, proposalId: string, -): Promise { - logger.trace("applying refund for purchase", proposalId); - - logger.info("processing purchase for refund"); - const success = await ws.db +): Promise { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { - logger.error("no purchase found for refund URL"); - return false; + logger.warn(`purchase ${proposalId} does not exist anymore`); + return; } - if (p.purchaseStatus === PurchaseStatus.Paid) { - p.purchaseStatus = PurchaseStatus.QueryingRefund; + if (p.purchaseStatus !== PurchaseStatus.Done) { + return; } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.QueryingRefund; + const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); - return true; - }); - - if (success) { - ws.notify({ - type: NotificationType.RefundStarted, - }); - await processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, - waitForAutoRefund: false, + return { oldTxState, newTxState }; }); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!purchase) { - throw Error("purchase no longer exists"); - } - - const summary = await calculateRefundSummary(ws, purchase); - const download = await expectProposalDownload(ws, purchase); - - const lastExec = Object.values(purchase.refunds).reduce( - (prev, cur) => { - return TalerProtocolTimestamp.max(cur.executionTime, prev); - }, - { t_s: 0 } as TalerProtocolTimestamp, - ); - - const transactionId = - lastExec.t_s === "never" || lastExec.t_s === 0 - ? makeTransactionId(TransactionType.Payment, proposalId) - : makeTransactionId( - TransactionType.Refund, - proposalId, - String(lastExec.t_s), - ); - - return { - contractTermsHash: download.contractData.contractTermsHash, - proposalId: purchase.proposalId, - transactionId, - amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), - amountRefundGone: Amounts.stringify(summary.amountRefundGone), - amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), - pendingAtExchange: summary.pendingAtExchange, - info: { - contractTermsHash: download.contractData.contractTermsHash, - merchant: download.contractData.merchant, - orderId: download.contractData.orderId, - products: download.contractData.products, - summary: download.contractData.summary, - fulfillmentMessage: download.contractData.fulfillmentMessage, - summary_i18n: download.contractData.summaryI18n, - fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n, - }, - }; + notifyTransition(ws, transactionId, transitionInfo); + ws.workAvailable.trigger(); } -async function queryAndSaveAwaitingRefund( +/** + * Store refunds, possibly creating a new refund group. + */ +async function storeRefunds( ws: InternalWalletState, purchase: PurchaseRecord, - waitForAutoRefund?: boolean, -): Promise { - const download = await expectProposalDownload(ws, purchase); - const requestUrl = new URL( - `orders/${download.contractData.orderId}`, - download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - download.contractData.contractTermsHash, - ); - // Long-poll for one second - if (waitForAutoRefund) { - requestUrl.searchParams.set("timeout_ms", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); - logger.trace("making long-polling request for auto-refund"); - } - const resp = await ws.http.get(requestUrl.href); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); - if (!orderStatus.refunded) { - // Wait for retry ... - return Amounts.zeroOfAmount(download.contractData.amount); - } - - const refundAwaiting = Amounts.sub( - Amounts.parseOrThrow(orderStatus.refund_amount), - Amounts.parseOrThrow(orderStatus.refund_taken), - ).amount; - - if ( - purchase.refundAmountAwaiting === undefined || - Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0 - ) { - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(purchase.proposalId); - if (!p) { - logger.warn("purchase does not exist anymore"); - return; - } - p.refundAmountAwaiting = Amounts.stringify(refundAwaiting); - await tx.purchases.put(p); - }); - } - - return refundAwaiting; -} - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - options: { - forceNow?: boolean; - waitForAutoRefund?: boolean; - } = {}, + refunds: MerchantCoinRefundStatus[], + reason: RefundReason, ): Promise { - logger.trace(`processing refund query for proposal ${proposalId}`); - const waitForAutoRefund = options.waitForAutoRefund ?? false; - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase) { - return OperationAttemptResult.finishedEmpty(); - } + logger.info(`storing refunds: ${j2s(refunds)}`); - if ( - !( - purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund || - purchase.purchaseStatus === PurchaseStatus.QueryingRefund || - purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund - ) - ) { - return OperationAttemptResult.finishedEmpty(); - } - - const download = await expectProposalDownload(ws, purchase); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: purchase.proposalId, + }); - if (purchase.timestampFirstSuccessfulPay) { - if ( - !purchase.autoRefundDeadline || - !AbsoluteTime.isExpired( - AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), - ) - ) { - const awaitingAmount = await queryAndSaveAwaitingRefund( - ws, - purchase, - waitForAutoRefund, - ); - if (Amounts.isZero(awaitingAmount)) { - // Maybe the user wanted to check for refund to find out - // that there is no refund pending from merchant - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.warn("purchase does not exist anymore"); - return; - } - p.purchaseStatus = PurchaseStatus.Paid; - await tx.purchases.put(p); - }); + const newRefundGroupId = encodeCrock(randomBytes(32)); + const now = TalerProtocolTimestamp.now(); - // No new refunds, but we still need to notify - // the wallet client that the query finished. - ws.notify({ - type: NotificationType.RefundQueried, - transactionId: makeTransactionId(TransactionType.Payment, proposalId), - }); + const download = await expectProposalDownload(ws, purchase); + const currency = Amounts.currencyOf(download.contractData.amount); - return OperationAttemptResult.finishedEmpty(); + const getItemStatus = (rf: MerchantCoinRefundStatus) => { + if (rf.type === "success") { + return RefundItemStatus.Done; + } else { + if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + return RefundItemStatus.Pending; + } else { + return RefundItemStatus.Failed; } } + }; - const requestUrl = new URL( - `orders/${download.contractData.orderId}/refund`, - download.contractData.merchantBaseUrl, - ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, { - h_contract: download.contractData.contractTermsHash, - }); - - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForMerchantOrderRefundPickupResponse(), - ); - - await acceptRefunds( - ws, - proposalId, - refundResponse.refunds, - RefundReason.NormalRefund, - ); - } else if (purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - const requestUrl = new URL( - `orders/${download.contractData.orderId}/abort`, - download.contractData.merchantBaseUrl, - ); - - const abortingCoins: AbortingCoin[] = []; - - const payCoinSelection = purchase.payInfo?.payCoinSelection; - if (!payCoinSelection) { - throw Error("can't abort, no coins selected"); - } - - await ws.db - .mktx((x) => [x.coins]) - .runReadOnly(async (tx) => { - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - const coinPub = payCoinSelection.coinPubs[i]; - const coin = await tx.coins.get(coinPub); - checkDbInvariant(!!coin, "expected coin to be present"); - abortingCoins.push({ - coin_pub: coinPub, - contribution: Amounts.stringify( - payCoinSelection.coinContributions[i], - ), - exchange_url: coin.exchangeBaseUrl, - }); - } - }); - - const abortReq: AbortRequest = { - h_contract: download.contractData.contractTermsHash, - coins: abortingCoins, - }; - - logger.trace(`making order abort request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, abortReq); - const abortResp = await readSuccessResponseJsonOrThrow( - request, - codecForAbortResponse(), - ); - - const refunds: MerchantCoinRefundStatus[] = []; - - if (abortResp.refunds.length != abortingCoins.length) { - // FIXME: define error code! - throw Error("invalid order abort response"); - } - - for (let i = 0; i < abortResp.refunds.length; i++) { - const r = abortResp.refunds[i]; - refunds.push({ - ...r, - coin_pub: payCoinSelection.coinPubs[i], - refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), - rtransaction_id: 0, - execution_time: AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.fromTimestamp(download.contractData.timestamp), - Duration.fromSpec({ seconds: 1 }), - ), - ), - }); - } - await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); - } - return OperationAttemptResult.finishedEmpty(); -} - -export async function abortPayMerchant( - ws: InternalWalletState, - proposalId: string, - cancelImmediately?: boolean, -): Promise { - const opId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - await ws.db + const result = await ws.db .mktx((x) => [ x.purchases, - x.refreshGroups, + x.refundGroups, + x.refundItems, + x.coins, x.denominations, x.coinAvailability, - x.coins, - x.operationRetries, + x.refreshGroups, ]) .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if (!purchase) { - throw Error("purchase not found"); + const computeRefreshRequest = async (items: RefundItemRecord[]) => { + const refreshCoins: CoinRefreshRequest[] = []; + for (const item of items) { + const coin = await tx.coins.get(item.coinPub); + if (!coin) { + throw Error("coin not found"); + } + const denomInfo = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denomInfo) { + throw Error("denom not found"); + } + if (item.status === RefundItemStatus.Done) { + const refundedAmount = Amounts.sub( + item.refundAmount, + denomInfo.feeRefund, + ).amount; + refreshCoins.push({ + amount: Amounts.stringify(refundedAmount), + coinPub: item.coinPub, + }); + } + } + return refreshCoins; + }; + + const myPurchase = await tx.purchases.get(purchase.proposalId); + if (!myPurchase) { + logger.warn("purchase group not found anymore"); + return; } - const oldStatus = purchase.purchaseStatus; - if (purchase.timestampFirstSuccessfulPay) { - // No point in aborting it. We don't even report an error. - logger.warn(`tried to abort successful payment`); + if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) { return; } - if (oldStatus === PurchaseStatus.Paying) { - purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + + let newGroup: RefundGroupRecord | undefined = undefined; + // Pending, but not part of an aborted refund group. + let numPendingItemsTotal = 0; + const newGroupRefunds: RefundItemRecord[] = []; + + for (const rf of refunds) { + const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([ + rf.coin_pub, + rf.rtransaction_id, + ]); + if (oldItem) { + logger.info("already have refund in database"); + if (oldItem.status === RefundItemStatus.Done) { + continue; + } + if (rf.type === "success") { + oldItem.status = RefundItemStatus.Done; + } else { + if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + oldItem.status = RefundItemStatus.Pending; + numPendingItemsTotal += 1; + } else { + oldItem.status = RefundItemStatus.Failed; + } + } + await tx.refundItems.put(oldItem); + } else { + // Put refund item into a new group! + if (!newGroup) { + newGroup = { + proposalId: purchase.proposalId, + refundGroupId: newRefundGroupId, + status: RefundGroupStatus.Pending, + timestampCreated: now, + amountEffective: Amounts.stringify( + Amounts.zeroOfCurrency(currency), + ), + amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + }; + } + const status: RefundItemStatus = getItemStatus(rf); + const newItem: RefundItemRecord = { + coinPub: rf.coin_pub, + executionTime: rf.execution_time, + obtainedTime: now, + refundAmount: rf.refund_amount, + refundGroupId: newGroup.refundGroupId, + rtxid: rf.rtransaction_id, + status, + }; + if (status === RefundItemStatus.Pending) { + numPendingItemsTotal += 1; + } + newGroupRefunds.push(newItem); + await tx.refundItems.put(newItem); + } } - if ( - cancelImmediately && - oldStatus === PurchaseStatus.AbortingWithRefund - ) { - purchase.purchaseStatus = PurchaseStatus.PaymentAbortFinished; + + // Now that we know all the refunds for the new refund group, + // we can compute the raw/effective amounts. + if (newGroup) { + const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); + const refreshCoins = await computeRefreshRequest(newGroupRefunds); + const outInfo = await calculateRefreshOutput( + ws, + tx, + currency, + refreshCoins, + ); + newGroup.amountEffective = Amounts.stringify( + Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, + ); + newGroup.amountRaw = Amounts.stringify( + Amounts.sumOrZero(currency, amountsRaw).amount, + ); + await tx.refundGroups.put(newGroup); } - await tx.purchases.put(purchase); - if (oldStatus === PurchaseStatus.Paying) { - if (purchase.payInfo) { - const coinSel = purchase.payInfo.payCoinSelection; - const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); - const refreshCoins: CoinRefreshRequest[] = []; - for (let i = 0; i < coinSel.coinPubs.length; i++) { - refreshCoins.push({ - amount: coinSel.coinContributions[i], - coinPub: coinSel.coinPubs[i], - }); + + const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll( + myPurchase.proposalId, + ); + + logger.info( + `refund groups for proposal ${myPurchase.proposalId}: ${j2s( + refundGroups, + )}`, + ); + + for (const refundGroup of refundGroups) { + if (refundGroup.status === RefundGroupStatus.Aborted) { + continue; + } + if (refundGroup.status === RefundGroupStatus.Done) { + continue; + } + const items = await tx.refundItems.indexes.byRefundGroupId.getAll( + refundGroup.refundGroupId, + ); + let numPending = 0; + for (const item of items) { + if (item.status === RefundItemStatus.Pending) { + numPending++; } + } + logger.info(`refund items pending for refund group: ${numPending}`); + if (numPending === 0) { + logger.info("refund group is done!"); + // We're done for this refund group! + refundGroup.status = RefundGroupStatus.Done; + await tx.refundGroups.put(refundGroup); + const refreshCoins = await computeRefreshRequest(items); await createRefreshGroup( ws, tx, - currency, + Amounts.currencyOf(download.contractData.amount), refreshCoins, - RefreshReason.AbortPay, + RefreshReason.Refund, ); } } - await tx.operationRetries.delete(opId); - }); - runOperationWithErrorReporting(ws, opId, async () => { - return await processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, + const oldTxState = computePayMerchantTransactionState(myPurchase); + if (numPendingItemsTotal === 0) { + myPurchase.purchaseStatus = PurchaseStatus.Done; + } + await tx.purchases.put(myPurchase); + const newTxState = computePayMerchantTransactionState(myPurchase); + + return { + numPendingItemsTotal, + transitionInfo: { + oldTxState, + newTxState, + }, + }; }); - }); + + if (!result) { + return OperationAttemptResult.finishedEmpty(); + } + + notifyTransition(ws, transactionId, result.transitionInfo); + + if (result.numPendingItemsTotal > 0) { + return OperationAttemptResult.pendingEmpty(); + } + + return OperationAttemptResult.finishedEmpty(); } -export function computePayMerchantTransactionState( - purchaseRecord: PurchaseRecord, +export function computeRefundTransactionState( + refundGroupRecord: RefundGroupRecord, ): TransactionState { - switch (purchaseRecord.purchaseStatus) { - case PurchaseStatus.DownloadingProposal: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.ClaimProposal, - }; - case PurchaseStatus.Paid: - return { - major: TransactionMajorState.Done, - }; - case PurchaseStatus.PaymentAbortFinished: + switch (refundGroupRecord.status) { + case RefundGroupStatus.Aborted: return { major: TransactionMajorState.Aborted, }; - case PurchaseStatus.Proposed: + case RefundGroupStatus.Done: return { - major: TransactionMajorState.Dialog, - minor: TransactionMinorState.MerchantOrderProposed, - }; - case PurchaseStatus.ProposalDownloadFailed: - return { - major: TransactionMajorState.Failed, - minor: TransactionMinorState.ClaimProposal, - }; - case PurchaseStatus.RepurchaseDetected: - return { - major: TransactionMajorState.Failed, - minor: TransactionMinorState.Repurchase, - }; - case PurchaseStatus.AbortingWithRefund: - return { - major: TransactionMajorState.Aborting, - }; - case PurchaseStatus.Paying: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.Pay, - }; - case PurchaseStatus.PayingReplay: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.RebindSession, + major: TransactionMajorState.Done, }; - case PurchaseStatus.ProposalRefused: + case RefundGroupStatus.Failed: return { major: TransactionMajorState.Failed, - minor: TransactionMinorState.Refused, - }; - case PurchaseStatus.QueryingAutoRefund: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.AutoRefund, }; - case PurchaseStatus.QueryingRefund: + case RefundGroupStatus.Pending: return { major: TransactionMajorState.Pending, - minor: TransactionMinorState.CheckRefunds, - }; + } } } diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index fda9a886a..843f37c8e 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -81,7 +81,7 @@ import { readUnexpectedResponseDetails, } from "@gnu-taler/taler-util/http"; import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadWriteAccess } from "../util/query.js"; +import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; import { constructTaskIdentifier, OperationAttemptResult, @@ -874,18 +874,13 @@ async function processRefreshSession( await refreshReveal(ws, refreshGroupId, coinIndex); } -/** - * Create a refresh group for a list of coins. - * - * Refreshes the remaining amount on the coin, effectively capturing the remaining - * value in the refresh group. - * - * The caller must also ensure that the coins that should be refreshed exist - * in the current database transaction. - */ -export async function createRefreshGroup( +export interface RefreshOutputInfo { + outputPerCoin: AmountJson[]; +} + +export async function calculateRefreshOutput( ws: InternalWalletState, - tx: GetReadWriteAccess<{ + tx: GetReadOnlyAccess<{ denominations: typeof WalletStoresV1.denominations; coins: typeof WalletStoresV1.coins; refreshGroups: typeof WalletStoresV1.refreshGroups; @@ -893,12 +888,7 @@ export async function createRefreshGroup( }>, currency: string, oldCoinPubs: CoinRefreshRequest[], - reason: RefreshReason, - reasonDetails?: RefreshReasonDetails, -): Promise { - const refreshGroupId = encodeCrock(getRandomBytes(32)); - - const inputPerCoin: AmountJson[] = []; +): Promise { const estimatedOutputPerCoin: AmountJson[] = []; const denomsPerExchange: Record = {}; @@ -918,6 +908,47 @@ export async function createRefreshGroup( return allDenoms; }; + for (const ocp of oldCoinPubs) { + const coin = await tx.coins.get(ocp.coinPub); + checkDbInvariant(!!coin, "coin must be in database"); + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant( + !!denom, + "denomination for existing coin must be in database", + ); + const refreshAmount = ocp.amount; + const denoms = await getDenoms(coin.exchangeBaseUrl); + const cost = getTotalRefreshCost( + denoms, + denom, + Amounts.parseOrThrow(refreshAmount), + ws.config.testing.denomselAllowLate, + ); + const output = Amounts.sub(refreshAmount, cost).amount; + estimatedOutputPerCoin.push(output); + } + + return { + outputPerCoin: estimatedOutputPerCoin, + } +} + +async function applyRefresh( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + denominations: typeof WalletStoresV1.denominations; + coins: typeof WalletStoresV1.coins; + refreshGroups: typeof WalletStoresV1.refreshGroups; + coinAvailability: typeof WalletStoresV1.coinAvailability; + }>, + oldCoinPubs: CoinRefreshRequest[], + refreshGroupId: string, +): Promise { for (const ocp of oldCoinPubs) { const coin = await tx.coins.get(ocp.coinPub); checkDbInvariant(!!coin, "coin must be in database"); @@ -962,19 +993,39 @@ export async function createRefreshGroup( id: `txn:refresh:${refreshGroupId}`, }; } - const refreshAmount = ocp.amount; - inputPerCoin.push(Amounts.parseOrThrow(refreshAmount)); await tx.coins.put(coin); - const denoms = await getDenoms(coin.exchangeBaseUrl); - const cost = getTotalRefreshCost( - denoms, - denom, - Amounts.parseOrThrow(refreshAmount), - ws.config.testing.denomselAllowLate, - ); - const output = Amounts.sub(refreshAmount, cost).amount; - estimatedOutputPerCoin.push(output); } +} + +/** + * Create a refresh group for a list of coins. + * + * Refreshes the remaining amount on the coin, effectively capturing the remaining + * value in the refresh group. + * + * The caller must also ensure that the coins that should be refreshed exist + * in the current database transaction. + */ +export async function createRefreshGroup( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + denominations: typeof WalletStoresV1.denominations; + coins: typeof WalletStoresV1.coins; + refreshGroups: typeof WalletStoresV1.refreshGroups; + coinAvailability: typeof WalletStoresV1.coinAvailability; + }>, + currency: string, + oldCoinPubs: CoinRefreshRequest[], + reason: RefreshReason, + reasonDetails?: RefreshReasonDetails, +): Promise { + const refreshGroupId = encodeCrock(getRandomBytes(32)); + + const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs); + + const estimatedOutputPerCoin = outInfo.outputPerCoin; + + await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId); const refreshGroup: RefreshGroupRecord = { operationStatus: RefreshOperationStatus.Pending, @@ -987,7 +1038,7 @@ export async function createRefreshGroup( reason, refreshGroupId, refreshSessionPerCoin: oldCoinPubs.map(() => undefined), - inputPerCoin: inputPerCoin.map((x) => Amounts.stringify(x)), + inputPerCoin: oldCoinPubs.map((x) => x.amount), estimatedOutputPerCoin: estimatedOutputPerCoin.map((x) => Amounts.stringify(x), ), diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index 74cf7b4f2..8341d2f26 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -45,7 +45,7 @@ import { PreparePayResultType, } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js"; +import { confirmPay, preparePayForUri, startRefundQueryForUri } from "./pay-merchant.js"; import { getBalances } from "./balance.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; @@ -416,7 +416,7 @@ export async function runIntegrationTest( logger.trace("refund URI", refundUri); - await applyRefund(ws, refundUri); + await startRefundQueryForUri(ws, refundUri); logger.trace("integration test: applied refund"); @@ -512,7 +512,7 @@ export async function runIntegrationTest2( logger.trace("refund URI", refundUri); - await applyRefund(ws, refundUri); + await startRefundQueryForUri(ws, refundUri); logger.trace("integration test: applied refund"); diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 02f11d82d..d9778f0c2 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -19,7 +19,6 @@ */ import { AbsoluteTime, - AmountJson, Amounts, constructPayPullUri, constructPayPushUri, @@ -51,9 +50,7 @@ import { PeerPushPaymentInitiationRecord, PurchaseStatus, PurchaseRecord, - RefundState, TipRecord, - WalletRefundItem, WithdrawalGroupRecord, WithdrawalRecordType, WalletContractData, @@ -66,6 +63,7 @@ import { PeerPushPaymentIncomingRecord, PeerPushPaymentIncomingStatus, PeerPullPaymentInitiationRecord, + RefundGroupRecord, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { PendingTaskType } from "../pending-types.js"; @@ -89,6 +87,7 @@ import { getExchangeDetails } from "./exchanges.js"; import { abortPayMerchant, computePayMerchantTransactionState, + computeRefundTransactionState, expectProposalDownload, extractContractData, processPurchasePay, @@ -205,40 +204,15 @@ export async function getTransactionById( .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) throw Error("not found"); - - const filteredRefunds = await Promise.all( - Object.values(purchase.refunds).map(async (r) => { - const t = await tx.tombstones.get( - makeTombstoneId( - TombstoneTag.DeleteRefund, - purchase.proposalId, - `${r.executionTime.t_s}`, - ), - ); - if (!t) return r; - return undefined; - }), - ); - const download = await expectProposalDownload(ws, purchase, tx); - - const cleanRefunds = filteredRefunds.filter( - (x): x is WalletRefundItem => !!x, - ); - const contractData = download.contractData; - const refunds = mergeRefundByExecutionTime( - cleanRefunds, - Amounts.zeroOfAmount(contractData.amount), - ); - const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); return buildTransactionForPurchase( purchase, contractData, - refunds, + [], // FIXME: Add refunds from refund group records here. payRetryRecord, ); }); @@ -272,66 +246,8 @@ export async function getTransactionById( return buildTransactionForDeposit(depositRecord, retries); }); } else if (type === TransactionType.Refund) { - const proposalId = rest[0]; - const executionTimeStr = rest[1]; - - return await ws.db - .mktx((x) => [ - x.operationRetries, - x.purchases, - x.tombstones, - x.contractTerms, - ]) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if (!purchase) throw Error("not found"); - - const t = await tx.tombstones.get( - makeTombstoneId( - TombstoneTag.DeleteRefund, - purchase.proposalId, - executionTimeStr, - ), - ); - if (t) throw Error("deleted"); - - const filteredRefunds = await Promise.all( - Object.values(purchase.refunds).map(async (r) => { - const t = await tx.tombstones.get( - makeTombstoneId( - TombstoneTag.DeleteRefund, - purchase.proposalId, - `${r.executionTime.t_s}`, - ), - ); - if (!t) return r; - return undefined; - }), - ); - - const cleanRefunds = filteredRefunds.filter( - (x): x is WalletRefundItem => !!x, - ); - - const download = await expectProposalDownload(ws, purchase, tx); - const contractData = download.contractData; - const refunds = mergeRefundByExecutionTime( - cleanRefunds, - Amounts.zeroOfAmount(contractData.amount), - ); - - const theRefund = refunds.find( - (r) => `${r.executionTime.t_s}` === executionTimeStr, - ); - if (!theRefund) throw Error("not found"); - - return buildTransactionForRefund( - purchase, - contractData, - theRefund, - undefined, - ); - }); + // FIXME! + throw Error("not implemented"); } else if (type === TransactionType.PeerPullDebit) { const peerPullPaymentIncomingId = rest[0]; return await ws.db @@ -730,6 +646,29 @@ function buildTransactionForManualWithdraw( }; } +function buildTransactionForRefund( + refundRecord: RefundGroupRecord, +): Transaction { + return { + type: TransactionType.Refund, + amountEffective: refundRecord.amountEffective, + amountRaw: refundRecord.amountEffective, + refundedTransactionId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: refundRecord.proposalId + }), + timestamp: refundRecord.timestampCreated, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Refund, + refundGroupId: refundRecord.refundGroupId, + }), + txState: computeRefundTransactionState(refundRecord), + extendedStatus: ExtendedStatus.Done, + frozen: false, + pending: false, + } +} + function buildTransactionForRefresh( refreshGroupRecord: RefreshGroupRecord, ort?: OperationRetryRecord, @@ -850,113 +789,11 @@ function buildTransactionForTip( }; } -/** - * For a set of refund with the same executionTime. - */ -interface MergedRefundInfo { - executionTime: TalerProtocolTimestamp; - amountAppliedRaw: AmountJson; - amountAppliedEffective: AmountJson; - firstTimestamp: TalerProtocolTimestamp; -} - -function mergeRefundByExecutionTime( - rs: WalletRefundItem[], - zero: AmountJson, -): MergedRefundInfo[] { - const refundByExecTime = rs.reduce((prev, refund) => { - const key = `${refund.executionTime.t_s}`; - - // refunds count if applied - const effective = - refund.type === RefundState.Applied - ? Amounts.sub( - refund.refundAmount, - refund.refundFee, - refund.totalRefreshCostBound, - ).amount - : zero; - const raw = - refund.type === RefundState.Applied ? refund.refundAmount : zero; - - const v = prev.get(key); - if (!v) { - prev.set(key, { - executionTime: refund.executionTime, - amountAppliedEffective: effective, - amountAppliedRaw: Amounts.parseOrThrow(raw), - firstTimestamp: refund.obtainedTime, - }); - } else { - //v.executionTime is the same - v.amountAppliedEffective = Amounts.add( - v.amountAppliedEffective, - effective, - ).amount; - v.amountAppliedRaw = Amounts.add( - v.amountAppliedRaw, - refund.refundAmount, - ).amount; - v.firstTimestamp = TalerProtocolTimestamp.min( - v.firstTimestamp, - refund.obtainedTime, - ); - } - return prev; - }, new Map()); - - return Array.from(refundByExecTime.values()); -} - -async function buildTransactionForRefund( - purchaseRecord: PurchaseRecord, - contractData: WalletContractData, - refundInfo: MergedRefundInfo, - ort?: OperationRetryRecord, -): Promise { - const info: OrderShortInfo = { - merchant: contractData.merchant, - orderId: contractData.orderId, - products: contractData.products, - summary: contractData.summary, - summary_i18n: contractData.summaryI18n, - contractTermsHash: contractData.contractTermsHash, - }; - if (contractData.fulfillmentUrl !== "") { - info.fulfillmentUrl = contractData.fulfillmentUrl; - } - - return { - type: TransactionType.Refund, - txState: mkTxStateUnknown(), - info, - refundedTransactionId: makeTransactionId( - TransactionType.Payment, - purchaseRecord.proposalId, - ), - transactionId: makeTransactionId( - TransactionType.Refund, - purchaseRecord.proposalId, - `${refundInfo.executionTime.t_s}`, - ), - timestamp: refundInfo.firstTimestamp, - amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective), - amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw), - refundPending: - purchaseRecord.refundAmountAwaiting === undefined - ? undefined - : Amounts.stringify(purchaseRecord.refundAmountAwaiting), - extendedStatus: ExtendedStatus.Done, - pending: false, - frozen: false, - ...(ort?.lastError ? { error: ort.lastError } : {}), - }; -} async function buildTransactionForPurchase( purchaseRecord: PurchaseRecord, contractData: WalletContractData, - refundsInfo: MergedRefundInfo[], + refundsInfo: RefundGroupRecord[], ort?: OperationRetryRecord, ): Promise { const zero = Amounts.zeroOfAmount(contractData.amount); @@ -974,30 +811,7 @@ async function buildTransactionForPurchase( info.fulfillmentUrl = contractData.fulfillmentUrl; } - const totalRefund = refundsInfo.reduce( - (prev, cur) => { - return { - raw: Amounts.add(prev.raw, cur.amountAppliedRaw).amount, - effective: Amounts.add(prev.effective, cur.amountAppliedEffective) - .amount, - }; - }, - { - raw: zero, - effective: zero, - } as { raw: AmountJson; effective: AmountJson }, - ); - - const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({ - amountEffective: Amounts.stringify(r.amountAppliedEffective), - amountRaw: Amounts.stringify(r.amountAppliedRaw), - timestamp: r.executionTime, - transactionId: makeTransactionId( - TransactionType.Refund, - purchaseRecord.proposalId, - `${r.executionTime.t_s}`, - ), - })); + const refunds: RefundInfoShort[] = []; const timestamp = purchaseRecord.timestampAccept; checkDbInvariant(!!timestamp); @@ -1008,7 +822,7 @@ async function buildTransactionForPurchase( case PurchaseStatus.AbortingWithRefund: status = ExtendedStatus.Aborting; break; - case PurchaseStatus.Paid: + case PurchaseStatus.Done: case PurchaseStatus.RepurchaseDetected: status = ExtendedStatus.Done; break; @@ -1018,10 +832,10 @@ async function buildTransactionForPurchase( case PurchaseStatus.Paying: status = ExtendedStatus.Pending; break; - case PurchaseStatus.ProposalDownloadFailed: + case PurchaseStatus.FailedClaim: status = ExtendedStatus.Failed; break; - case PurchaseStatus.PaymentAbortFinished: + case PurchaseStatus.AbortedIncompletePayment: status = ExtendedStatus.Aborted; break; default: @@ -1034,8 +848,8 @@ async function buildTransactionForPurchase( txState: computePayMerchantTransactionState(purchaseRecord), amountRaw: Amounts.stringify(contractData.amount), amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost), - totalRefundRaw: Amounts.stringify(totalRefund.raw), - totalRefundEffective: Amounts.stringify(totalRefund.effective), + totalRefundRaw: Amounts.stringify(zero), // FIXME! + totalRefundEffective: Amounts.stringify(zero), // FIXME! refundPending: purchaseRecord.refundAmountAwaiting === undefined ? undefined @@ -1057,7 +871,7 @@ async function buildTransactionForPurchase( refundQueryActive: purchaseRecord.purchaseStatus === PurchaseStatus.QueryingRefund, frozen: - purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ?? + purchaseRecord.purchaseStatus === PurchaseStatus.AbortedIncompletePayment ?? false, ...(ort?.lastError ? { error: ort.lastError } : {}), }; @@ -1092,6 +906,7 @@ export async function getTransactions( x.tombstones, x.withdrawalGroups, x.refreshGroups, + x.refundGroups, ]) .runReadOnly(async (tx) => { tx.peerPushPaymentInitiations.iter().forEachAsync(async (pi) => { @@ -1202,6 +1017,14 @@ export async function getTransactions( ); }); + tx.refundGroups.iter().forEachAsync(async (refundGroup) => { + const currency = Amounts.currencyOf(refundGroup.amountRaw); + if (shouldSkipCurrency(transactionsRequest, currency)) { + return; + } + transactions.push(buildTransactionForRefund(refundGroup)) + }); + tx.refreshGroups.iter().forEachAsync(async (rg) => { if (shouldSkipCurrency(transactionsRequest, rg.currency)) { return; @@ -1318,47 +1141,13 @@ export async function getTransactions( download.contractTermsMerchantSig, ); - const filteredRefunds = await Promise.all( - Object.values(purchase.refunds).map(async (r) => { - const t = await tx.tombstones.get( - makeTombstoneId( - TombstoneTag.DeleteRefund, - purchase.proposalId, - `${r.executionTime.t_s}`, - ), - ); - if (!t) return r; - return undefined; - }), - ); - - const cleanRefunds = filteredRefunds.filter( - (x): x is WalletRefundItem => !!x, - ); - - const refunds = mergeRefundByExecutionTime( - cleanRefunds, - Amounts.zeroOfCurrency(download.currency), - ); - - refunds.forEach(async (refundInfo) => { - transactions.push( - await buildTransactionForRefund( - purchase, - contractData, - refundInfo, - undefined, - ), - ); - }); - const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); transactions.push( await buildTransactionForPurchase( purchase, contractData, - refunds, + [], // FIXME! payRetryRecord, ), ); @@ -1425,7 +1214,7 @@ export type ParsedTransactionIdentifier = | { tag: TransactionType.PeerPushCredit; peerPushPaymentIncomingId: string } | { tag: TransactionType.PeerPushDebit; pursePub: string } | { tag: TransactionType.Refresh; refreshGroupId: string } - | { tag: TransactionType.Refund; proposalId: string; executionTime: string } + | { tag: TransactionType.Refund; refundGroupId: string } | { tag: TransactionType.Tip; walletTipId: string } | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }; @@ -1448,7 +1237,7 @@ export function constructTransactionIdentifier( case TransactionType.Refresh: return `txn:${pTxId.tag}:${pTxId.refreshGroupId}`; case TransactionType.Refund: - return `txn:${pTxId.tag}:${pTxId.proposalId}:${pTxId.executionTime}`; + return `txn:${pTxId.tag}:${pTxId.refundGroupId}`; case TransactionType.Tip: return `txn:${pTxId.tag}:${pTxId.walletTipId}`; case TransactionType.Withdrawal: @@ -1490,8 +1279,7 @@ export function parseTransactionIdentifier( case TransactionType.Refund: return { tag: TransactionType.Refund, - proposalId: rest[0], - executionTime: rest[1], + refundGroupId: rest[0], }; case TransactionType.Tip: return { diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts index 647e5e4b5..1de1e9a0d 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/util/query.ts @@ -35,7 +35,7 @@ import { IDBKeyPath, IDBKeyRange, } from "@gnu-taler/idb-bridge"; -import { Logger } from "@gnu-taler/taler-util"; +import { Logger, j2s } from "@gnu-taler/taler-util"; const logger = new Logger("query.ts"); diff --git a/packages/taler-wallet-core/src/util/retries.ts b/packages/taler-wallet-core/src/util/retries.ts index 12e1df7e9..7607a6583 100644 --- a/packages/taler-wallet-core/src/util/retries.ts +++ b/packages/taler-wallet-core/src/util/retries.ts @@ -76,6 +76,11 @@ export namespace OperationAttemptResult { result: undefined, }; } + export function longpoll(): OperationAttemptResult { + return { + type: OperationAttemptResultType.Longpoll, + } + } } export interface OperationAttemptFinishedResult { diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index f394aa9ca..f0da6059f 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -36,7 +36,7 @@ import { AddKnownBankAccountsRequest, ApplyDevExperimentRequest, ApplyRefundFromPurchaseIdRequest, - ApplyRefundRequest, + AcceptRefundRequest, ApplyRefundResponse, BackupRecovery, BalancesResponse, @@ -90,6 +90,7 @@ import { RetryTransactionRequest, SetCoinSuspendedRequest, SetWalletDeviceIdRequest, + StartRefundQueryRequest, TestPayArgs, TestPayResult, Transaction, @@ -149,9 +150,8 @@ export enum WalletApiOperation { MarkAttentionRequestAsRead = "markAttentionRequestAsRead", GetPendingOperations = "getPendingOperations", SetExchangeTosAccepted = "setExchangeTosAccepted", - ApplyRefund = "applyRefund", - ApplyRefundFromPurchaseId = "applyRefundFromPurchaseId", - PrepareRefund = "prepareRefund", + StartRefundQueryForUri = "startRefundQueryForUri", + StartRefundQuery = "startRefundQuery", AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal", GetExchangeTos = "getExchangeTos", GetExchangeDetailedInfo = "getExchangeDetailedInfo", @@ -435,22 +435,16 @@ export type ConfirmPayOp = { /** * Check for a refund based on a taler://refund URI. */ -export type ApplyRefundOp = { - op: WalletApiOperation.ApplyRefund; - request: ApplyRefundRequest; - response: ApplyRefundResponse; -}; - -export type ApplyRefundFromPurchaseIdOp = { - op: WalletApiOperation.ApplyRefundFromPurchaseId; - request: ApplyRefundFromPurchaseIdRequest; - response: ApplyRefundResponse; +export type StartRefundQueryForUriOp = { + op: WalletApiOperation.StartRefundQueryForUri; + request: PrepareRefundRequest; + response: EmptyObject; }; -export type PrepareRefundOp = { - op: WalletApiOperation.PrepareRefund; - request: PrepareRefundRequest; - response: PrepareRefundResult; +export type StartRefundQueryOp = { + op: WalletApiOperation.StartRefundQuery; + request: StartRefundQueryRequest; + response: EmptyObject; }; // group: Tipping @@ -954,9 +948,8 @@ export type WalletOperations = { [WalletApiOperation.RetryTransaction]: RetryTransactionOp; [WalletApiOperation.PrepareTip]: PrepareTipOp; [WalletApiOperation.AcceptTip]: AcceptTipOp; - [WalletApiOperation.ApplyRefund]: ApplyRefundOp; - [WalletApiOperation.ApplyRefundFromPurchaseId]: ApplyRefundFromPurchaseIdOp; - [WalletApiOperation.PrepareRefund]: PrepareRefundOp; + [WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp; + [WalletApiOperation.StartRefundQuery]: StartRefundQueryOp; [WalletApiOperation.ListCurrencies]: ListCurrenciesOp; [WalletApiOperation.GetWithdrawalDetailsForAmount]: GetWithdrawalDetailsForAmountOp; [WalletApiOperation.GetWithdrawalDetailsForUri]: GetWithdrawalDetailsForUriOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index ed174e33b..d76aa4ec9 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -48,6 +48,7 @@ import { RefreshReason, TalerError, TalerErrorCode, + TransactionType, URL, ValidateIbanResponse, WalletCoreVersion, @@ -95,6 +96,7 @@ import { codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, + codecForStartRefundQueryRequest, codecForSuspendTransaction, codecForTestPayArgs, codecForTransactionByIdRequest, @@ -188,13 +190,11 @@ import { } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; import { - applyRefund, - applyRefundFromPurchaseId, confirmPay, getContractTermsDetails, preparePayForUri, - prepareRefund, processPurchase, + startRefundQueryForUri, } from "./operations/pay-merchant.js"; import { checkPeerPullPaymentInitiation, @@ -233,6 +233,7 @@ import { deleteTransaction, getTransactionById, getTransactions, + parseTransactionIdentifier, resumeTransaction, retryTransaction, suspendTransaction, @@ -276,6 +277,7 @@ import { WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; +import { startQueryRefund } from "./operations/pay-merchant.js"; const logger = new Logger("wallet.ts"); @@ -1141,14 +1143,6 @@ async function dispatchRequestInternal( await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag); return {}; } - case WalletApiOperation.ApplyRefund: { - const req = codecForApplyRefundRequest().decode(payload); - return await applyRefund(ws, req.talerRefundUri); - } - case WalletApiOperation.ApplyRefundFromPurchaseId: { - const req = codecForApplyRefundFromPurchaseIdRequest().decode(payload); - return await applyRefundFromPurchaseId(ws, req.purchaseId); - } case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); @@ -1292,9 +1286,22 @@ async function dispatchRequestInternal( const req = codecForPrepareTipRequest().decode(payload); return await prepareTip(ws, req.talerTipUri); } - case WalletApiOperation.PrepareRefund: { + case WalletApiOperation.StartRefundQueryForUri: { const req = codecForPrepareRefundRequest().decode(payload); - return await prepareRefund(ws, req.talerRefundUri); + await startRefundQueryForUri(ws, req.talerRefundUri); + return {}; + } + case WalletApiOperation.StartRefundQuery: { + const req = codecForStartRefundQueryRequest().decode(payload); + const txIdParsed = parseTransactionIdentifier(req.transactionId); + if (!txIdParsed) { + throw Error("invalid transaction ID"); + } + if (txIdParsed.tag !== TransactionType.Payment) { + throw Error("expected payment transaction ID"); + } + await startQueryRefund(ws, txIdParsed.proposalId); + return {}; } case WalletApiOperation.AcceptTip: { const req = codecForAcceptTipRequest().decode(payload); -- cgit v1.2.3