diff options
author | Florian Dold <florian@dold.me> | 2022-10-08 20:56:57 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2022-10-08 23:07:07 +0200 |
commit | 526f4eba9554f27e33afb0e02d19d870825b038c (patch) | |
tree | c35e41a20a3bc90da3beb81fa7831505ee64cfee | |
parent | eace0e0e7aad9113af758b829fffd873826e36e3 (diff) |
wallet-core: Clean up merchant payments DB schema
30 files changed, 1833 insertions, 2076 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json index d8e616936..97596d26c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,53 +1,59 @@ // Place your settings in this file to overwrite default and user settings. { - // Use latest language servicesu - "typescript.tsdk": "./node_modules/typescript/lib", - // Defines space handling after a comma delimiter - "typescript.format.insertSpaceAfterCommaDelimiter": true, - // Defines space handling after a semicolon in a for statement - "typescript.format.insertSpaceAfterSemicolonInForStatements": true, - // Defines space handling after a binary operator - "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true, - // Defines space handling after keywords in control flow statement - "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true, - // Defines space handling after function keyword for anonymous functions - "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, - // Defines space handling after opening and before closing non empty parenthesis - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, - // Defines space handling after opening and before closing non empty brackets - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, - // Defines whether an open brace is put onto a new line for functions or not - "typescript.format.placeOpenBraceOnNewLineForFunctions": false, - // Defines whether an open brace is put onto a new line for control blocks or not - "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false, - // Files hidden in the explorer - "files.exclude": { - // include the defaults from VS Code - "**/.git": true, - "**/.DS_Store": true, - // exclude .js and .js.map files, when in a TypeScript project - "**/*.js": { - "when": "$(basename).ts" - }, - "**/*?.js": { - "when": "$(basename).tsx" - }, - "**/*.js.map": true + // Use latest language servicesu + "typescript.tsdk": "./node_modules/typescript/lib", + // Defines space handling after a comma delimiter + "typescript.format.insertSpaceAfterCommaDelimiter": true, + // Defines space handling after a semicolon in a for statement + "typescript.format.insertSpaceAfterSemicolonInForStatements": true, + // Defines space handling after a binary operator + "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true, + // Defines space handling after keywords in control flow statement + "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true, + // Defines space handling after function keyword for anonymous functions + "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, + // Defines space handling after opening and before closing non empty parenthesis + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, + // Defines space handling after opening and before closing non empty brackets + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, + // Defines whether an open brace is put onto a new line for functions or not + "typescript.format.placeOpenBraceOnNewLineForFunctions": false, + // Defines whether an open brace is put onto a new line for control blocks or not + "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false, + "typescript.preferences.autoImportFileExcludePatterns": [ + "index.js", + "index.*.js", + "index.ts", + "index.*.ts" + ], + // Files hidden in the explorer + "files.exclude": { + // include the defaults from VS Code + "**/.git": true, + "**/.DS_Store": true, + // exclude .js and .js.map files, when in a TypeScript project + "**/*.js": { + "when": "$(basename).ts" }, - "editor.wrappingIndent": "same", - "editor.tabSize": 2, - "search.exclude": { - "dist": true, - "prebuilt": true, - "src/i18n/*.po": true, - "vendor": true + "**/*?.js": { + "when": "$(basename).tsx" }, - "search.collapseResults": "auto", - "files.associations": { - "api-extractor.json": "jsonc" - }, - "typescript.preferences.importModuleSpecifierEnding": "js", - "typescript.preferences.importModuleSpecifier": "project-relative", - "javascript.preferences.importModuleSpecifier": "project-relative", - "javascript.preferences.importModuleSpecifierEnding": "js" -}
\ No newline at end of file + "**/*.js.map": true + }, + "editor.wrappingIndent": "same", + "editor.tabSize": 2, + "search.exclude": { + "dist": true, + "prebuilt": true, + "src/i18n/*.po": true, + "vendor": true + }, + "search.collapseResults": "auto", + "files.associations": { + "api-extractor.json": "jsonc" + }, + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.preferences.importModuleSpecifier": "project-relative", + "javascript.preferences.importModuleSpecifier": "project-relative", + "javascript.preferences.importModuleSpecifierEnding": "js" +} diff --git a/packages/idb-bridge/src/MemoryBackend.ts b/packages/idb-bridge/src/MemoryBackend.ts index 3919cdf97..f40f1c98b 100644 --- a/packages/idb-bridge/src/MemoryBackend.ts +++ b/packages/idb-bridge/src/MemoryBackend.ts @@ -378,9 +378,9 @@ export class MemoryBackend implements Backend { } } - private makeObjectStoreMap( - database: Database, - ): { [currentName: string]: ObjectStoreMapEntry } { + private makeObjectStoreMap(database: Database): { + [currentName: string]: ObjectStoreMapEntry; + } { let map: { [currentName: string]: ObjectStoreMapEntry } = {}; for (let objectStoreName in database.committedObjectStores) { const store = database.committedObjectStores[objectStoreName]; @@ -1088,9 +1088,8 @@ export class MemoryBackend implements Backend { if (!existingIndexRecord) { throw Error("db inconsistent: expected index entry missing"); } - const newPrimaryKeys = existingIndexRecord.primaryKeys.without( - primaryKey, - ); + const newPrimaryKeys = + existingIndexRecord.primaryKeys.without(primaryKey); if (newPrimaryKeys.size === 0) { index.modifiedData = indexData.without(indexKey); } else { @@ -1357,7 +1356,20 @@ export class MemoryBackend implements Backend { // Remove old index entry first! if (oldStoreRecord) { - this.deleteFromIndex(index, key, oldStoreRecord.value, indexProperties); + try { + this.deleteFromIndex( + index, + key, + oldStoreRecord.value, + indexProperties, + ); + } catch (e) { + if (e instanceof DataError) { + // Do nothing + } else { + throw e; + } + } } try { this.insertIntoIndex(index, key, value, indexProperties); diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts index 19d478178..777086599 100644 --- a/packages/taler-util/src/backupTypes.ts +++ b/packages/taler-util/src/backupTypes.ts @@ -181,15 +181,6 @@ export interface WalletBackupContentV1 { tips: BackupTip[]; /** - * Proposals from merchants. The proposal may - * be deleted as soon as it has been accepted (and thus - * turned into a purchase). - * - * Sorted by the proposal ID. - */ - proposals: BackupProposal[]; - - /** * Accepted purchases. * * Sorted by the proposal ID. @@ -838,29 +829,10 @@ export type BackupRefundItem = | BackupRefundPendingItem | BackupRefundAppliedItem; -export interface BackupPurchase { - /** - * Proposal ID for this purchase. Uniquely identifies the - * purchase and the proposal. - */ - proposal_id: string; - - /** - * Contract terms we got from the merchant. - */ - contract_terms_raw: RawContractTerms; - - /** - * Signature on the contract terms. - */ - merchant_sig: string; - - /** - * Private key for the nonce. Might eventually be used - * to prove ownership of the contract. - */ - nonce_priv: string; - +/** + * Data we store when the payment was accepted. + */ +export interface BackupPayInfo { pay_coins: { /** * Public keys of the coins that were selected. @@ -890,6 +862,63 @@ export interface BackupPurchase { * We might show adjustments to this later, but currently we don't do so. */ total_pay_cost: BackupAmountString; +} + +export interface BackupPurchase { + /** + * Proposal ID for this purchase. Uniquely identifies the + * purchase and the proposal. + */ + proposal_id: string; + + /** + * Status of the proposal. + */ + proposal_status: BackupProposalStatus; + + /** + * Proposal that this one got "redirected" to as part of + * the repurchase detection. + */ + repurchase_proposal_id: string | undefined; + + /** + * Session ID we got when downloading the contract. + */ + download_session_id?: string; + + /** + * Merchant-assigned order ID of the proposal. + */ + order_id: string; + + /** + * Base URL of the merchant that proposed the purchase. + */ + merchant_base_url: string; + + /** + * Claim token initially given by the merchant. + */ + claim_token: string | undefined; + + /** + * Contract terms we got from the merchant. + */ + contract_terms_raw?: RawContractTerms; + + /** + * Signature on the contract terms. + */ + merchant_sig?: string; + + /** + * Private key for the nonce. Might eventually be used + * to prove ownership of the contract. + */ + nonce_priv: string; + + pay_info: BackupPayInfo | undefined; /** * Timestamp of the first time that sending a payment to the merchant @@ -902,11 +931,13 @@ export interface BackupPurchase { */ merchant_pay_sig: string | undefined; + timestamp_proposed: TalerProtocolTimestamp; + /** * When was the purchase made? * Refers to the time that the user accepted. */ - timestamp_accept: TalerProtocolTimestamp; + timestamp_accepted: TalerProtocolTimestamp | undefined; /** * Pending refunds for the purchase. A refund is pending @@ -915,11 +946,6 @@ export interface BackupPurchase { refunds: BackupRefundItem[]; /** - * Abort status of the payment. - */ - abort_status?: "abort-refund" | "abort-finished"; - - /** * Continue querying the refund status until this deadline has expired. */ auto_refund_deadline: TalerProtocolTimestamp | undefined; @@ -1218,70 +1244,8 @@ export enum BackupProposalStatus { * Downloaded proposal was detected as a re-purchase. */ Repurchase = "repurchase", -} - -/** - * Proposal by a merchant. - */ -export interface BackupProposal { - /** - * Base URL of the merchant that proposed the purchase. - */ - merchant_base_url: string; - - /** - * Downloaded data from the merchant. - */ - contract_terms_raw?: RawContractTerms; - - /** - * Signature on the contract terms. - * - * Must be present if contract_terms_raw is present. - */ - merchant_sig?: string; - - /** - * Unique ID when the order is stored in the wallet DB. - */ - proposal_id: string; - - /** - * Merchant-assigned order ID of the proposal. - */ - order_id: string; - - /** - * Timestamp of when the record - * was created. - */ - timestamp: TalerProtocolTimestamp; - - /** - * Private key for the nonce. - */ - nonce_priv: string; - - /** - * Claim token initially given by the merchant. - */ - claim_token: string | undefined; - - /** - * Status of the proposal. - */ - proposal_status: BackupProposalStatus; - - /** - * Proposal that this one got "redirected" to as part of - * the repurchase detection. - */ - repurchase_proposal_id: string | undefined; - /** - * Session ID we got when downloading the contract. - */ - download_session_id?: string; + Paid = "paid", } export interface BackupRecovery { diff --git a/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts b/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts index ec1d9f64b..b5ecbee4a 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts @@ -17,13 +17,8 @@ /** * Imports. */ -import { - PreparePayResultType, - TalerErrorCode, - TalerErrorDetail, -} from "@gnu-taler/taler-util"; +import { PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { makeEventId } from "@gnu-taler/taler-wallet-core"; import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js"; import { createSimpleTestkudosEnvironment, diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts index 9378465a0..1099a8188 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts @@ -18,7 +18,10 @@ * Imports. */ import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js"; -import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js"; +import { + createSimpleTestkudosEnvironment, + withdrawViaBank, +} from "../harness/helpers.js"; import { PreparePayResultType } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -29,12 +32,8 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; export async function runPaymentIdempotencyTest(t: GlobalTestState) { // Set up test environment - const { - wallet, - bank, - exchange, - merchant, - } = await createSimpleTestkudosEnvironment(t); + const { wallet, bank, exchange, merchant } = + await createSimpleTestkudosEnvironment(t); // Withdraw digital cash into the wallet. @@ -83,10 +82,16 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) { const proposalId = preparePayResult.proposalId; - await wallet.client.call(WalletApiOperation.ConfirmPay, { - // FIXME: should be validated, don't cast! - proposalId: proposalId, - }); + const confirmPayResult = await wallet.client.call( + WalletApiOperation.ConfirmPay, + { + proposalId: proposalId, + }, + ); + + console.log("confirm pay result", confirmPayResult); + + await wallet.runUntilDone(); // Check if payment was successful. @@ -103,6 +108,8 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) { }, ); + console.log("result after:", preparePayResultAfter); + t.assertTrue( preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed, ); diff --git a/packages/taler-wallet-core/src/bank-api-client.ts b/packages/taler-wallet-core/src/bank-api-client.ts index 8f82d7e7f..557b8c315 100644 --- a/packages/taler-wallet-core/src/bank-api-client.ts +++ b/packages/taler-wallet-core/src/bank-api-client.ts @@ -34,11 +34,7 @@ import { TalerErrorCode, } from "@gnu-taler/taler-util"; import { TalerError } from "./errors.js"; -import { - HttpRequestLibrary, - readSuccessResponseJsonOrErrorCode, - readSuccessResponseJsonOrThrow, -} from "./index.browser.js"; +import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js"; const logger = new Logger("bank-api-client.ts"); diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 16ae2cf8d..5d344319f 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -98,11 +98,11 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; */ export const WALLET_DB_MINOR_VERSION = 2; -export namespace OperationStatusRange { - export const ACTIVE_START = 10; - export const ACTIVE_END = 29; - export const DORMANT_START = 50; - export const DORMANT_END = 69; +export enum OperationStatusRange { + ACTIVE_START = 10, + ACTIVE_END = 29, + DORMANT_START = 50, + DORMANT_END = 69, } /** @@ -741,93 +741,6 @@ export interface CoinAllocation { amount: AmountString; } -export enum ProposalStatus { - /** - * Not downloaded yet. - */ - Downloading = "downloading", - /** - * Proposal downloaded, but the user needs to accept/reject it. - */ - Proposed = "proposed", - /** - * The user has accepted the proposal. - */ - Accepted = "accepted", - /** - * The user has rejected the proposal. - */ - Refused = "refused", - /** - * Downloading or processing the proposal has failed permanently. - */ - PermanentlyFailed = "permanently-failed", - /** - * Downloaded proposal was detected as a re-purchase. - */ - Repurchase = "repurchase", -} - -export interface ProposalDownload { - /** - * The contract that was offered by the merchant. - */ - contractTermsRaw: any; - - /** - * Extracted / parsed data from the contract terms. - * - * FIXME: Do we need to store *all* that data in duplicate? - */ - contractData: WalletContractData; -} - -/** - * Record for a downloaded order, stored in the wallet's database. - */ -export interface ProposalRecord { - orderId: string; - - merchantBaseUrl: string; - - /** - * Downloaded data from the merchant. - */ - download: ProposalDownload | undefined; - - /** - * Unique ID when the order is stored in the wallet DB. - */ - proposalId: string; - - /** - * Timestamp (in ms) of when the record - * was created. - */ - timestamp: TalerProtocolTimestamp; - - /** - * Private key for the nonce. - */ - noncePriv: string; - - /** - * Public key for the nonce. - */ - noncePub: string; - - claimToken: string | undefined; - - proposalStatus: ProposalStatus; - - repurchaseProposalId: string | undefined; - - /** - * Session ID we got when downloading the contract. - */ - downloadSessionId: string | undefined; -} - /** * Status of a tip we got from a merchant. */ @@ -1113,24 +1026,133 @@ export interface WalletContractData { deliveryLocation: Location | undefined; } -export enum AbortStatus { - None = "none", - AbortRefund = "abort-refund", - AbortFinished = "abort-finished", +export enum ProposalStatus { + /** + * Not downloaded yet. + */ + DownloadingProposal = OperationStatusRange.ACTIVE_START, + + /** + * The user has accepted the proposal. + */ + Paying = OperationStatusRange.ACTIVE_START + 1, + + AbortingWithRefund = OperationStatusRange.ACTIVE_START + 2, + + /** + * Paying a second time, likely with different session ID + */ + PayingReplay = OperationStatusRange.ACTIVE_START + 3, + + /** + * Query for refunds (until query succeeds). + */ + QueryingRefund = OperationStatusRange.ACTIVE_START + 4, + + /** + * Query for refund (until auto-refund deadline is reached). + */ + QueryingAutoRefund = OperationStatusRange.ACTIVE_START + 5, + + /** + * Proposal downloaded, but the user needs to accept/reject it. + */ + Proposed = OperationStatusRange.DORMANT_START, + + /** + * The user has rejected the proposal. + */ + ProposalRefused = OperationStatusRange.DORMANT_START + 1, + + /** + * Downloading or processing the proposal has failed permanently. + */ + ProposalDownloadFailed = OperationStatusRange.DORMANT_START + 2, + + /** + * Downloaded proposal was detected as a re-purchase. + */ + RepurchaseDetected = OperationStatusRange.DORMANT_START + 3, + + /** + * The payment has been aborted. + */ + PaymentAbortFinished = OperationStatusRange.DORMANT_START + 4, + + /** + * Payment was successful. + */ + Paid = OperationStatusRange.DORMANT_START + 5, +} + +export interface ProposalDownload { + /** + * The contract that was offered by the merchant. + */ + contractTermsRaw: any; + + /** + * Extracted / parsed data from the contract terms. + * + * FIXME: Do we need to store *all* that data in duplicate? + */ + contractData: WalletContractData; +} + +export interface PurchasePayInfo { + payCoinSelection: PayCoinSelection; + totalPayCost: AmountJson; + payCoinSelectionUid: string; + + /** + * Deposit permissions, available once the user has accepted the payment. + * + * This value is cached and derived from payCoinSelection. + * + * FIXME: Should probably be cached somewhere else, maybe not even in DB! + */ + coinDepositPermissions: CoinDepositPermission[] | undefined; } /** * Record that stores status information about one purchase, starting from when * the customer accepts a proposal. Includes refund status if applicable. + * + * FIXME: Should have a single "status" field. */ export interface PurchaseRecord { /** * Proposal ID for this purchase. Uniquely identifies the * purchase and the proposal. + * Assigned by the wallet. */ proposalId: string; /** + * Order ID, assigned by the merchant. + */ + orderId: string; + + merchantBaseUrl: string; + + /** + * Claim token used when downloading the contract terms. + */ + claimToken: string | undefined; + + /** + * Session ID we got when downloading the contract. + */ + downloadSessionId: string | undefined; + + /** + * If this purchase is a repurchase, this field identifies the original purchase. + */ + repurchaseProposalId: string | undefined; + + status: ProposalStatus; + + /** * Private key for the nonce. */ noncePriv: string; @@ -1146,18 +1168,9 @@ export interface PurchaseRecord { * FIXME: Move this into another object store, * to improve read/write perf on purchases. */ - download: ProposalDownload; - - /** - * Deposit permissions, available once the user has accepted the payment. - * - * This value is cached and derived from payCoinSelection. - */ - coinDepositPermissions: CoinDepositPermission[] | undefined; - - payCoinSelection: PayCoinSelection; + download: ProposalDownload | undefined; - payCoinSelectionUid: string; + payInfo: PurchasePayInfo | undefined; /** * Pending removals from pay coin selection. @@ -1169,8 +1182,6 @@ export interface PurchaseRecord { */ pendingRemovedCoinPubs?: string[]; - totalPayCost: AmountJson; - /** * Timestamp of the first time that sending a payment to the merchant * for this purchase was successful. @@ -1182,10 +1193,15 @@ export interface PurchaseRecord { merchantPaySig: string | undefined; /** + * When was the purchase record created? + */ + timestamp: TalerProtocolTimestamp; + + /** * When was the purchase made? * Refers to the time that the user accepted. */ - timestampAccept: TalerProtocolTimestamp; + timestampAccept: TalerProtocolTimestamp | undefined; /** * Pending refunds for the purchase. A refund is pending @@ -1207,18 +1223,6 @@ export interface PurchaseRecord { lastSessionId: string | undefined; /** - * Do we still need to post the deposit permissions to the merchant? - * Set for the first payment, or on re-plays. - */ - paymentSubmitPending: boolean; - - /** - * Do we need to query the merchant for the refund status - * of the payment? - */ - refundQueryRequested: boolean; - - /** * Continue querying the refund status until this deadline has expired. */ autoRefundDeadline: TalerProtocolTimestamp | undefined; @@ -1227,18 +1231,7 @@ export interface PurchaseRecord { * How much merchant has refund to be taken but the wallet * did not picked up yet */ - refundAwaiting: AmountJson | undefined; - - /** - * Is the payment frozen? I.e. did we encounter - * an error where it doesn't make sense to retry. - */ - payFrozen?: boolean; - - /** - * FIXME: How does this interact with payFrozen? - */ - abortStatus: AbortStatus; + refundAmountAwaiting: AmountJson | undefined; } export const WALLET_BACKUP_STATE_KEY = "walletBackupState"; @@ -1923,16 +1916,6 @@ export const WalletStoresV1 = { }), {}, ), - proposals: describeStore( - "proposals", - describeContents<ProposalRecord>({ keyPath: "proposalId" }), - { - byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ - "merchantBaseUrl", - "orderId", - ]), - }, - ), refreshGroups: describeStore( "refreshGroups", describeContents<RefreshGroupRecord>({ @@ -1953,14 +1936,20 @@ export const WalletStoresV1 = { "purchases", describeContents<PurchaseRecord>({ keyPath: "proposalId" }), { + byStatus: describeIndex("byStatus", "operationStatus"), byFulfillmentUrl: describeIndex( "byFulfillmentUrl", "download.contractData.fulfillmentUrl", ), + // FIXME: Deduplicate! byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [ "download.contractData.merchantBaseUrl", "download.contractData.orderId", ]), + byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ + "merchantBaseUrl", + "orderId", + ]), }, ), tips: describeStore( diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index ff7870435..9d709e8e3 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -26,6 +26,9 @@ * Imports. */ import { + AbsoluteTime, + AgeRestriction, + AmountJson, Amounts, AmountString, codecForAny, @@ -35,7 +38,6 @@ import { codecForExchangeRevealResponse, codecForWithdrawResponse, DenominationPubKey, - eddsaGetPublic, encodeCrock, ExchangeMeltRequest, ExchangeProtocolVersion, @@ -44,29 +46,15 @@ import { hashWire, Logger, parsePaytoUri, - AbsoluteTime, UnblindedSignature, - BankWithdrawDetails, - parseWithdrawUri, - AmountJson, - AgeRestriction, } from "@gnu-taler/taler-util"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; -import { - assembleRefreshRevealRequest, - ExchangeInfo, - getBankWithdrawalInfo, - HttpRequestLibrary, - isWithdrawableDenom, - readSuccessResponseJsonOrThrow, -} from "./index.browser.js"; -import { - BankAccessApi, - BankApi, - BankServiceHandle, - getBankStatusUrl, -} from "./index.js"; +import { BankAccessApi, BankApi, BankServiceHandle } from "./bank-api-client.js"; +import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js"; +import { getBankStatusUrl, getBankWithdrawalInfo, isWithdrawableDenom } from "./operations/withdraw.js"; +import { ExchangeInfo } from "./operations/exchanges.js"; +import { assembleRefreshRevealRequest } from "./operations/refresh.js"; const logger = new Logger("dbless.ts"); diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index 4e419503b..0e01e3517 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -47,7 +47,6 @@ export * from "./wallet-api-types.js"; export * from "./wallet.js"; export * from "./operations/backup/index.js"; -export { makeEventId } from "./operations/transactions.js"; export * from "./operations/exchanges.js"; diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts b/packages/taler-wallet-core/src/internal-wallet-state.ts index b8415a469..6c7d943cb 100644 --- a/packages/taler-wallet-core/src/internal-wallet-state.ts +++ b/packages/taler-wallet-core/src/internal-wallet-state.ts @@ -37,6 +37,9 @@ import { TalerProtocolTimestamp, CancellationToken, DenominationInfo, + RefreshGroupId, + CoinPublicKey, + RefreshReason, } from "@gnu-taler/taler-util"; import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; @@ -74,6 +77,20 @@ export interface MerchantOperations { ): Promise<MerchantInfo>; } +export interface RefreshOperations { + createRefreshGroup( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + denominations: typeof WalletStoresV1.denominations; + coins: typeof WalletStoresV1.coins; + refreshGroups: typeof WalletStoresV1.refreshGroups; + coinAvailability: typeof WalletStoresV1.coinAvailability; + }>, + oldCoinPubs: CoinPublicKey[], + reason: RefreshReason, + ): Promise<RefreshGroupId>; +} + /** * Interface for exchange-related operations. */ @@ -172,6 +189,7 @@ export interface InternalWalletState { exchangeOps: ExchangeOperations; recoupOps: RecoupOperations; merchantOps: MerchantOperations; + refreshOps: RefreshOperations; getDenomInfo( ws: InternalWalletState, diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index c8454a62f..04fac7d38 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -37,7 +37,7 @@ import { BackupExchangeDetails, BackupExchangeWireFee, BackupOperationStatus, - BackupProposal, + BackupPayInfo, BackupProposalStatus, BackupPurchase, BackupRecoupGroup, @@ -62,11 +62,9 @@ import { WalletBackupContentV1, } from "@gnu-taler/taler-util"; import { - AbortStatus, CoinSourceType, CoinStatus, DenominationRecord, - OperationStatus, ProposalStatus, RefreshCoinStatus, RefundState, @@ -92,7 +90,6 @@ export async function exportBackup( x.coins, x.denominations, x.purchases, - x.proposals, x.refreshGroups, x.backupProviders, x.tips, @@ -109,7 +106,6 @@ export async function exportBackup( [url: string]: BackupDenomination[]; } = {}; const backupPurchases: BackupPurchase[] = []; - const backupProposals: BackupProposal[] = []; const backupRefreshGroups: BackupRefreshGroup[] = []; const backupBackupProviders: BackupBackupProvider[] = []; const backupTips: BackupTip[] = []; @@ -385,65 +381,61 @@ export async function exportBackup( } } - backupPurchases.push({ - contract_terms_raw: purch.download.contractTermsRaw, - auto_refund_deadline: purch.autoRefundDeadline, - merchant_pay_sig: purch.merchantPaySig, - pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({ - coin_pub: x, - contribution: Amounts.stringify( - purch.payCoinSelection.coinContributions[i], - ), - })), - proposal_id: purch.proposalId, - refunds, - timestamp_accept: purch.timestampAccept, - timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, - abort_status: - purch.abortStatus === AbortStatus.None - ? undefined - : purch.abortStatus, - nonce_priv: purch.noncePriv, - merchant_sig: purch.download.contractData.merchantSig, - total_pay_cost: Amounts.stringify(purch.totalPayCost), - pay_coins_uid: purch.payCoinSelectionUid, - }); - }); - - await tx.proposals.iter().forEach((prop) => { - if (purchaseProposalIdSet.has(prop.proposalId)) { - return; - } let propStatus: BackupProposalStatus; - switch (prop.proposalStatus) { - case ProposalStatus.Accepted: + switch (purch.status) { + case ProposalStatus.Paid: + propStatus = BackupProposalStatus.Paid; return; - case ProposalStatus.Downloading: + case ProposalStatus.DownloadingProposal: case ProposalStatus.Proposed: propStatus = BackupProposalStatus.Proposed; break; - case ProposalStatus.PermanentlyFailed: + case ProposalStatus.ProposalDownloadFailed: propStatus = BackupProposalStatus.PermanentlyFailed; break; - case ProposalStatus.Refused: + case ProposalStatus.ProposalRefused: propStatus = BackupProposalStatus.Refused; break; - case ProposalStatus.Repurchase: + case ProposalStatus.RepurchaseDetected: propStatus = BackupProposalStatus.Repurchase; break; + default: + throw Error(); } - backupProposals.push({ - claim_token: prop.claimToken, - nonce_priv: prop.noncePriv, - proposal_id: prop.noncePriv, + + const payInfo = purch.payInfo; + let backupPayInfo: BackupPayInfo | undefined = undefined; + if (payInfo) { + backupPayInfo = { + pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({ + coin_pub: x, + contribution: Amounts.stringify( + payInfo.payCoinSelection.coinContributions[i], + ), + })), + total_pay_cost: Amounts.stringify(payInfo.totalPayCost), + pay_coins_uid: payInfo.payCoinSelectionUid, + }; + } + + backupPurchases.push({ + contract_terms_raw: purch.download?.contractTermsRaw, + auto_refund_deadline: purch.autoRefundDeadline, + merchant_pay_sig: purch.merchantPaySig, + pay_info: backupPayInfo, + proposal_id: purch.proposalId, + refunds, + timestamp_accepted: purch.timestampAccept, + timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, + nonce_priv: purch.noncePriv, + merchant_sig: purch.download?.contractData.merchantSig, + claim_token: purch.claimToken, + merchant_base_url: purch.merchantBaseUrl, + order_id: purch.orderId, proposal_status: propStatus, - repurchase_proposal_id: prop.repurchaseProposalId, - timestamp: prop.timestamp, - contract_terms_raw: prop.download?.contractTermsRaw, - download_session_id: prop.downloadSessionId, - merchant_base_url: prop.merchantBaseUrl, - order_id: prop.orderId, - merchant_sig: prop.download?.contractData.merchantSig, + repurchase_proposal_id: purch.repurchaseProposalId, + download_session_id: purch.downloadSessionId, + timestamp_proposed: purch.timestamp, }); }); @@ -498,7 +490,6 @@ export async function exportBackup( wallet_root_pub: bs.walletRootPub, backup_providers: backupBackupProviders, current_device_id: bs.deviceId, - proposals: backupProposals, purchases: backupPurchases, recoup_groups: backupRecoupGroups, refresh_groups: backupRefreshGroups, diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index fb747ef1c..00dbf6fa8 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -21,8 +21,8 @@ import { BackupCoin, BackupCoinSourceType, BackupDenomSel, + BackupPayInfo, BackupProposalStatus, - BackupPurchase, BackupRefreshReason, BackupRefundState, BackupWgType, @@ -37,7 +37,6 @@ import { WireInfo, } from "@gnu-taler/taler-util"; import { - AbortStatus, CoinRecord, CoinSource, CoinSourceType, @@ -48,28 +47,23 @@ import { OperationStatus, ProposalDownload, ProposalStatus, + PurchasePayInfo, RefreshCoinStatus, RefreshSessionRecord, RefundState, - ReserveBankInfo, - WithdrawalGroupStatus, WalletContractData, WalletRefundItem, WalletStoresV1, WgInfo, + WithdrawalGroupStatus, WithdrawalRecordType, } from "../../db.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; import { assertUnreachable } from "../../util/assertUnreachable.js"; -import { - checkDbInvariant, - checkLogicInvariant, -} from "../../util/invariants.js"; +import { checkLogicInvariant } from "../../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; -import { RetryInfo } from "../../util/retries.js"; -import { makeCoinAvailable } from "../../wallet.js"; +import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js"; import { getExchangeDetails } from "../exchanges.js"; -import { makeEventId, TombstoneTag } from "../transactions.js"; import { provideBackupState } from "./state.js"; const logger = new Logger("operations/backup/import.ts"); @@ -95,10 +89,10 @@ async function recoverPayCoinSelection( denominations: typeof WalletStoresV1.denominations; }>, contractData: WalletContractData, - backupPurchase: BackupPurchase, + payInfo: BackupPayInfo, ): Promise<PayCoinSelection> { - const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub); - const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) => + const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub); + const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) => Amounts.parseOrThrow(x.contribution), ); @@ -316,7 +310,6 @@ export async function importBackup( x.coinAvailability, x.denominations, x.purchases, - x.proposals, x.refreshGroups, x.backupProviders, x.tips, @@ -560,113 +553,6 @@ export async function importBackup( } } - for (const backupProposal of backupBlob.proposals) { - const ts = makeEventId( - TombstoneTag.DeletePayment, - backupProposal.proposal_id, - ); - if (tombstoneSet.has(ts)) { - continue; - } - const existingProposal = await tx.proposals.get( - backupProposal.proposal_id, - ); - if (!existingProposal) { - let download: ProposalDownload | undefined; - let proposalStatus: ProposalStatus; - switch (backupProposal.proposal_status) { - case BackupProposalStatus.Proposed: - if (backupProposal.contract_terms_raw) { - proposalStatus = ProposalStatus.Proposed; - } else { - proposalStatus = ProposalStatus.Downloading; - } - break; - case BackupProposalStatus.Refused: - proposalStatus = ProposalStatus.Refused; - break; - case BackupProposalStatus.Repurchase: - proposalStatus = ProposalStatus.Repurchase; - break; - case BackupProposalStatus.PermanentlyFailed: - proposalStatus = ProposalStatus.PermanentlyFailed; - break; - } - if (backupProposal.contract_terms_raw) { - checkDbInvariant(!!backupProposal.merchant_sig); - const parsedContractTerms = codecForContractTerms().decode( - backupProposal.contract_terms_raw, - ); - const amount = Amounts.parseOrThrow(parsedContractTerms.amount); - const contractTermsHash = - cryptoComp.proposalIdToContractTermsHash[ - backupProposal.proposal_id - ]; - let maxWireFee: AmountJson; - if (parsedContractTerms.max_wire_fee) { - maxWireFee = Amounts.parseOrThrow( - parsedContractTerms.max_wire_fee, - ); - } else { - maxWireFee = Amounts.getZero(amount.currency); - } - download = { - contractData: { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupProposal.merchant_sig, - orderId: parsedContractTerms.order_id, - summary: parsedContractTerms.summary, - autoRefund: parsedContractTerms.auto_refund, - maxWireFee, - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: - parsedContractTerms.wire_fee_amortization || 1, - allowedAuditors: parsedContractTerms.auditors.map((x) => ({ - auditorBaseUrl: x.url, - auditorPub: x.auditor_pub, - })), - allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - })), - timestamp: parsedContractTerms.timestamp, - wireMethod: parsedContractTerms.wire_method, - wireInfoHash: parsedContractTerms.h_wire, - maxDepositFee: Amounts.parseOrThrow( - parsedContractTerms.max_fee, - ), - merchant: parsedContractTerms.merchant, - products: parsedContractTerms.products, - summaryI18n: parsedContractTerms.summary_i18n, - deliveryDate: parsedContractTerms.delivery_date, - deliveryLocation: parsedContractTerms.delivery_location, - }, - contractTermsRaw: backupProposal.contract_terms_raw, - }; - } - await tx.proposals.put({ - claimToken: backupProposal.claim_token, - merchantBaseUrl: backupProposal.merchant_base_url, - timestamp: backupProposal.timestamp, - orderId: backupProposal.order_id, - noncePriv: backupProposal.nonce_priv, - noncePub: - cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv], - proposalId: backupProposal.proposal_id, - repurchaseProposalId: backupProposal.repurchase_proposal_id, - download, - proposalStatus, - // FIXME! - downloadSessionId: undefined, - }); - } - } - for (const backupPurchase of backupBlob.purchases) { const ts = makeEventId( TombstoneTag.DeletePayment, @@ -678,6 +564,14 @@ export async function importBackup( const existingPurchase = await tx.purchases.get( backupPurchase.proposal_id, ); + let proposalStatus: ProposalStatus; + switch (backupPurchase.proposal_status) { + case BackupProposalStatus.Paid: + proposalStatus = ProposalStatus.Paid; + break; + default: + throw Error(); + } if (!existingPurchase) { const refunds: { [refundKey: string]: WalletRefundItem } = {}; for (const backupRefund of backupPurchase.refunds) { @@ -721,25 +615,6 @@ export async function importBackup( break; } } - let abortStatus: AbortStatus; - switch (backupPurchase.abort_status) { - case "abort-finished": - abortStatus = AbortStatus.AbortFinished; - break; - case "abort-refund": - abortStatus = AbortStatus.AbortRefund; - break; - case undefined: - abortStatus = AbortStatus.None; - break; - default: - logger.warn( - `got backup purchase abort_status ${j2s( - backupPurchase.abort_status, - )}`, - ); - throw Error("not reachable"); - } const parsedContractTerms = codecForContractTerms().decode( backupPurchase.contract_terms_raw, ); @@ -761,7 +636,7 @@ export async function importBackup( fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", merchantBaseUrl: parsedContractTerms.merchant_base_url, merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupPurchase.merchant_sig, + merchantSig: backupPurchase.merchant_sig!, orderId: parsedContractTerms.order_id, summary: parsedContractTerms.summary, autoRefund: parsedContractTerms.auto_refund, @@ -790,33 +665,46 @@ export async function importBackup( }, contractTermsRaw: backupPurchase.contract_terms_raw, }; + + let payInfo: PurchasePayInfo | undefined = undefined; + if (backupPurchase.pay_info) { + payInfo = { + coinDepositPermissions: undefined, + payCoinSelection: await recoverPayCoinSelection( + tx, + download.contractData, + backupPurchase.pay_info, + ), + payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid, + totalPayCost: Amounts.parseOrThrow( + backupPurchase.pay_info.total_pay_cost, + ), + }; + } + await tx.purchases.put({ proposalId: backupPurchase.proposal_id, noncePriv: backupPurchase.nonce_priv, noncePub: cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv], autoRefundDeadline: TalerProtocolTimestamp.never(), - refundAwaiting: undefined, - timestampAccept: backupPurchase.timestamp_accept, + timestampAccept: backupPurchase.timestamp_accepted, timestampFirstSuccessfulPay: backupPurchase.timestamp_first_successful_pay, timestampLastRefundStatus: undefined, merchantPaySig: backupPurchase.merchant_pay_sig, lastSessionId: undefined, - abortStatus, download, - paymentSubmitPending: - !backupPurchase.timestamp_first_successful_pay, - refundQueryRequested: false, - payCoinSelection: await recoverPayCoinSelection( - tx, - download.contractData, - backupPurchase, - ), - coinDepositPermissions: undefined, - totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost), refunds, - payCoinSelectionUid: backupPurchase.pay_coins_uid, + claimToken: backupPurchase.claim_token, + downloadSessionId: backupPurchase.download_session_id, + merchantBaseUrl: backupPurchase.merchant_base_url, + orderId: backupPurchase.order_id, + payInfo, + refundAmountAwaiting: undefined, + repurchaseProposalId: backupPurchase.repurchase_proposal_id, + status: proposalStatus, + timestamp: backupPurchase.timestamp_proposed, }); } } @@ -948,7 +836,6 @@ export async function importBackup( await tx.depositGroups.delete(rest[0]); } else if (type === TombstoneTag.DeletePayment) { await tx.purchases.delete(rest[0]); - await tx.proposals.delete(rest[0]); } else if (type === TombstoneTag.DeleteRefreshGroup) { await tx.refreshGroups.delete(rest[0]); } else if (type === TombstoneTag.DeleteRefund) { diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index fc84ce4ef..3d3ebf04a 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -96,7 +96,7 @@ import { checkPaymentByProposalId, confirmPay, preparePayForUri, -} from "../pay.js"; +} from "../pay-merchant.js"; import { exportBackup } from "./export.js"; import { BackupCryptoPrecomputedData, importBackup } from "./import.js"; import { getWalletBackupState, provideBackupState } from "./state.js"; @@ -193,15 +193,6 @@ async function computeBackupCryptoData( eddsaGetPublic(decodeCrock(backupWg.reserve_priv)), ); } - for (const prop of backupContent.proposals) { - const { h: contractTermsHash } = await cryptoApi.hashString({ - str: canonicalJson(prop.contract_terms_raw), - }); - const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv))); - cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub; - cryptoData.proposalIdToContractTermsHash[prop.proposal_id] = - contractTermsHash; - } for (const purch of backupContent.purchases) { const { h: contractTermsHash } = await cryptoApi.hashString({ str: canonicalJson(purch.contract_terms_raw), diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index 6d54503a1..9f235c9b4 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -17,38 +17,272 @@ /** * Imports. */ -import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util"; -import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js"; -import { TalerError, getErrorDetailFromException } from "../errors.js"; +import { + AmountJson, + Amounts, + j2s, + Logger, + RefreshReason, + TalerErrorCode, + TalerErrorDetail, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js"; +import { makeErrorDetail, TalerError } from "../errors.js"; +import { InternalWalletState } from "../internal-wallet-state.js"; +import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; +import { GetReadWriteAccess } from "../util/query.js"; +import { + OperationAttemptResult, + OperationAttemptResultType, + RetryInfo, +} from "../util/retries.js"; +import { createRefreshGroup } from "./refresh.js"; -/** - * Run an operation and call the onOpError callback - * when there was an exception or operation error that must be reported. - * The cause will be re-thrown to the caller. - */ -export async function guardOperationException<T>( - op: () => Promise<T>, - onOpError: (e: TalerErrorDetail) => Promise<void>, -): Promise<T> { +const logger = new Logger("operations/common.ts"); + +export interface CoinsSpendInfo { + coinPubs: string[]; + contributions: AmountJson[]; + refreshReason: RefreshReason; + /** + * Identifier for what the coin has been spent for. + */ + allocationId: string; +} + +export async function makeCoinAvailable( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + coinAvailability: typeof WalletStoresV1.coinAvailability; + denominations: typeof WalletStoresV1.denominations; + }>, + coinRecord: CoinRecord, +): Promise<void> { + checkLogicInvariant(coinRecord.status === CoinStatus.Fresh); + const existingCoin = await tx.coins.get(coinRecord.coinPub); + if (existingCoin) { + return; + } + const denom = await tx.denominations.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ]); + checkDbInvariant(!!denom); + const ageRestriction = coinRecord.maxAge; + let car = await tx.coinAvailability.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ageRestriction, + ]); + if (!car) { + car = { + maxAge: ageRestriction, + amountFrac: denom.amountFrac, + amountVal: denom.amountVal, + currency: denom.currency, + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: denom.exchangeBaseUrl, + freshCoinCount: 0, + }; + } + car.freshCoinCount++; + await tx.coins.put(coinRecord); + await tx.coinAvailability.put(car); +} + +export async function spendCoins( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + coinAvailability: typeof WalletStoresV1.coinAvailability; + refreshGroups: typeof WalletStoresV1.refreshGroups; + denominations: typeof WalletStoresV1.denominations; + }>, + csi: CoinsSpendInfo, +): Promise<void> { + for (let i = 0; i < csi.coinPubs.length; i++) { + const coin = await tx.coins.get(csi.coinPubs[i]); + if (!coin) { + throw Error("coin allocated for payment doesn't exist anymore"); + } + const coinAvailability = await tx.coinAvailability.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + coin.maxAge, + ]); + checkDbInvariant(!!coinAvailability); + const contrib = csi.contributions[i]; + if (coin.status !== CoinStatus.Fresh) { + const alloc = coin.allocation; + if (!alloc) { + continue; + } + if (alloc.id !== csi.allocationId) { + // FIXME: assign error code + throw Error("conflicting coin allocation (id)"); + } + if (0 !== Amounts.cmp(alloc.amount, contrib)) { + // FIXME: assign error code + throw Error("conflicting coin allocation (contrib)"); + } + continue; + } + coin.status = CoinStatus.Dormant; + coin.allocation = { + id: csi.allocationId, + amount: Amounts.stringify(contrib), + }; + const remaining = Amounts.sub(coin.currentAmount, contrib); + if (remaining.saturated) { + throw Error("not enough remaining balance on coin for payment"); + } + coin.currentAmount = remaining.amount; + checkDbInvariant(!!coinAvailability); + if (coinAvailability.freshCoinCount === 0) { + throw Error( + `invalid coin count ${coinAvailability.freshCoinCount} in DB`, + ); + } + coinAvailability.freshCoinCount--; + await tx.coins.put(coin); + await tx.coinAvailability.put(coinAvailability); + } + const refreshCoinPubs = csi.coinPubs.map((x) => ({ + coinPub: x, + })); + await ws.refreshOps.createRefreshGroup( + ws, + tx, + refreshCoinPubs, + RefreshReason.PayMerchant, + ); +} + +export async function storeOperationError( + ws: InternalWalletState, + pendingTaskId: string, + e: TalerErrorDetail, +): Promise<void> { + await ws.db + .mktx((x) => [x.operationRetries]) + .runReadWrite(async (tx) => { + let retryRecord = await tx.operationRetries.get(pendingTaskId); + if (!retryRecord) { + retryRecord = { + id: pendingTaskId, + lastError: e, + retryInfo: RetryInfo.reset(), + }; + } else { + retryRecord.lastError = e; + retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); + } + await tx.operationRetries.put(retryRecord); + }); +} + +export async function storeOperationPending( + ws: InternalWalletState, + pendingTaskId: string, +): Promise<void> { + await ws.db + .mktx((x) => [x.operationRetries]) + .runReadWrite(async (tx) => { + let retryRecord = await tx.operationRetries.get(pendingTaskId); + if (!retryRecord) { + retryRecord = { + id: pendingTaskId, + retryInfo: RetryInfo.reset(), + }; + } else { + delete retryRecord.lastError; + retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); + } + await tx.operationRetries.put(retryRecord); + }); +} + +export async function runOperationWithErrorReporting( + ws: InternalWalletState, + opId: string, + f: () => Promise<OperationAttemptResult>, +): Promise<void> { + let maybeError: TalerErrorDetail | undefined; try { - return await op(); - } catch (e: any) { - if (e instanceof CryptoApiStoppedError) { - throw e; + const resp = await f(); + switch (resp.type) { + case OperationAttemptResultType.Error: + return await storeOperationError(ws, opId, resp.errorDetail); + case OperationAttemptResultType.Finished: + return await storeOperationFinished(ws, opId); + case OperationAttemptResultType.Pending: + return await storeOperationPending(ws, opId); + case OperationAttemptResultType.Longpoll: + break; } - if ( - e instanceof TalerError && - e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED) - ) { - throw e; + } catch (e) { + if (e instanceof TalerError) { + logger.warn("operation processed resulted in error"); + logger.warn(`error was: ${j2s(e.errorDetail)}`); + maybeError = e.errorDetail; + return await storeOperationError(ws, opId, maybeError!); + } else if (e instanceof Error) { + // This is a bug, as we expect pending operations to always + // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED + // or return something. + logger.error(`Uncaught exception: ${e.message}`); + logger.error(`Stack: ${e.stack}`); + maybeError = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + { + stack: e.stack, + }, + `unexpected exception (message: ${e.message})`, + ); + return await storeOperationError(ws, opId, maybeError); + } else { + logger.error("Uncaught exception, value is not even an error."); + maybeError = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + `unexpected exception (not even an error)`, + ); + return await storeOperationError(ws, opId, maybeError); } - const opErr = getErrorDetailFromException(e); - await onOpError(opErr); - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PENDING_OPERATION_FAILED, - { - innerError: opErr, - }, - ); } } + +export async function storeOperationFinished( + ws: InternalWalletState, + pendingTaskId: string, +): Promise<void> { + await ws.db + .mktx((x) => [x.operationRetries]) + .runReadWrite(async (tx) => { + await tx.operationRetries.delete(pendingTaskId); + }); +} + +export enum TombstoneTag { + DeleteWithdrawalGroup = "delete-withdrawal-group", + DeleteReserve = "delete-reserve", + DeletePayment = "delete-payment", + DeleteTip = "delete-tip", + DeleteRefreshGroup = "delete-refresh-group", + DeleteDepositGroup = "delete-deposit-group", + DeleteRefund = "delete-refund", + DeletePeerPullDebit = "delete-peer-pull-debit", + DeletePeerPushDebit = "delete-peer-push-debit", +} + +/** + * Create an event ID from the type and the primary key for the event. + */ +export function makeEventId( + type: TransactionType | TombstoneTag, + ...args: string[] +): string { + return type + ":" + args.map((x) => encodeURIComponent(x)).join(":"); +} diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 625bc0828..1f7d05d29 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -53,16 +53,15 @@ import { import { InternalWalletState } from "../internal-wallet-state.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { OperationAttemptResult } from "../util/retries.js"; -import { spendCoins } from "../wallet.js"; +import { makeEventId, spendCoins } from "./common.js"; import { getExchangeDetails } from "./exchanges.js"; import { extractContractData, generateDepositPermissions, getTotalPaymentCost, selectPayCoinsNew, -} from "./pay.js"; +} from "./pay-merchant.js"; import { getTotalRefreshCost } from "./refresh.js"; -import { makeEventId } from "./transactions.js"; /** * Logger. diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 1dd8660b5..9a6c72577 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -40,7 +40,6 @@ import { parsePaytoUri, Recoup, TalerErrorCode, - TalerErrorDetail, TalerProtocolDuration, TalerProtocolTimestamp, URL, @@ -71,11 +70,9 @@ import { import { OperationAttemptResult, OperationAttemptResultType, - RetryInfo, runOperationHandlerForResult, } from "../util/retries.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; -import { guardOperationException } from "./common.js"; const logger = new Logger("exchanges.ts"); diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts index 614478715..f5b3ca38c 100644 --- a/packages/taler-wallet-core/src/operations/merchants.ts +++ b/packages/taler-wallet-core/src/operations/merchants.ts @@ -25,7 +25,7 @@ import { LibtoolVersion, } from "@gnu-taler/taler-util"; import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js"; -import { readSuccessResponseJsonOrThrow } from "../index.js"; +import { readSuccessResponseJsonOrThrow } from "../util/http.js"; const logger = new Logger("taler-wallet-core:merchants.ts"); @@ -40,7 +40,7 @@ export async function getMerchantInfo( return existingInfo; } - const configUrl = new URL("config", canonBaseUrl); +const configUrl = new URL("config", canonBaseUrl); const resp = await ws.http.get(configUrl.href); const configResp = await readSuccessResponseJsonOrThrow( diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 6757b79b4..97901c71e 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -26,14 +26,21 @@ */ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { + AbortingCoin, + AbortRequest, AbsoluteTime, AgeRestriction, AmountJson, Amounts, + ApplyRefundResponse, + codecForAbortResponse, codecForContractTerms, + codecForMerchantOrderRefundPickupResponse, + codecForMerchantOrderStatusPaid, codecForMerchantPayResponse, codecForProposal, CoinDepositPermission, + CoinPublicKey, ConfirmPayResult, ConfirmPayResultType, ContractTerms, @@ -46,12 +53,17 @@ import { HttpStatusCode, j2s, Logger, + MerchantCoinRefundFailureStatus, + MerchantCoinRefundStatus, + MerchantCoinRefundSuccessStatus, NotificationType, parsePaytoUri, parsePayUri, + parseRefundUri, PayCoinSelection, PreparePayResult, PreparePayResultType, + PrepareRefundResult, RefreshReason, strcmp, TalerErrorCode, @@ -62,17 +74,19 @@ import { } from "@gnu-taler/taler-util"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { - AbortStatus, AllowedAuditorInfo, AllowedExchangeInfo, BackupProviderStateTag, CoinRecord, CoinStatus, DenominationRecord, - ProposalRecord, + ProposalDownload, ProposalStatus, PurchaseRecord, + RefundReason, + RefundState, WalletContractData, + WalletStoresV1, } from "../db.js"; import { makeErrorDetail, @@ -80,6 +94,7 @@ import { TalerError, TalerProtocolViolationError, } from "../errors.js"; +import { GetReadWriteAccess } from "../index.browser.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState, @@ -109,12 +124,12 @@ import { } from "../util/retries.js"; import { spendCoins, - storeOperationError, storeOperationPending, -} from "../wallet.js"; + storeOperationError, + makeEventId, +} from "./common.js"; import { getExchangeDetails } from "./exchanges.js"; -import { getTotalRefreshCost } from "./refresh.js"; -import { makeEventId } from "./transactions.js"; +import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; /** * Logger. @@ -203,98 +218,20 @@ export interface CoinSelectionRequest { minimumAge?: number; } -/** - * Record all information that is necessary to - * pay for a proposal in the wallet's database. - */ -async function recordConfirmPay( - ws: InternalWalletState, - proposal: ProposalRecord, - coinSelection: PayCoinSelection, - coinDepositPermissions: CoinDepositPermission[], - sessionIdOverride: string | undefined, -): Promise<PurchaseRecord> { - const d = proposal.download; - if (!d) { - throw Error("proposal is in invalid state"); - } - let sessionId; - if (sessionIdOverride) { - sessionId = sessionIdOverride; - } else { - sessionId = proposal.downloadSessionId; - } - logger.trace( - `recording payment on ${proposal.orderId} with session ID ${sessionId}`, - ); - const payCostInfo = await getTotalPaymentCost(ws, coinSelection); - const t: PurchaseRecord = { - abortStatus: AbortStatus.None, - download: d, - lastSessionId: sessionId, - payCoinSelection: coinSelection, - payCoinSelectionUid: encodeCrock(getRandomBytes(32)), - totalPayCost: payCostInfo, - coinDepositPermissions, - timestampAccept: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - timestampLastRefundStatus: undefined, - proposalId: proposal.proposalId, - refundQueryRequested: false, - timestampFirstSuccessfulPay: undefined, - autoRefundDeadline: undefined, - refundAwaiting: undefined, - paymentSubmitPending: true, - refunds: {}, - merchantPaySig: undefined, - noncePriv: proposal.noncePriv, - noncePub: proposal.noncePub, - }; - - await ws.db - .mktx((x) => [ - x.proposals, - x.purchases, - x.coins, - x.refreshGroups, - x.denominations, - x.coinAvailability, - ]) - .runReadWrite(async (tx) => { - const p = await tx.proposals.get(proposal.proposalId); - if (p) { - p.proposalStatus = ProposalStatus.Accepted; - await tx.proposals.put(p); - } - await tx.purchases.put(t); - await spendCoins(ws, tx, { - allocationId: `proposal:${t.proposalId}`, - coinPubs: coinSelection.coinPubs, - contributions: coinSelection.coinContributions, - refreshReason: RefreshReason.PayMerchant, - }); - }); - - ws.notify({ - type: NotificationType.ProposalAccepted, - proposalId: proposal.proposalId, - }); - return t; -} - async function failProposalPermanently( ws: InternalWalletState, proposalId: string, err: TalerErrorDetail, ): Promise<void> { await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { - const p = await tx.proposals.get(proposalId); + const p = await tx.purchases.get(proposalId); if (!p) { return; } - p.proposalStatus = ProposalStatus.PermanentlyFailed; - await tx.proposals.put(p); + p.status = ProposalStatus.ProposalDownloadFailed; + await tx.purchases.put(p); }); } @@ -309,10 +246,24 @@ function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration { function getPayRequestTimeout(purchase: PurchaseRecord): Duration { return Duration.multiply( { d_ms: 15000 }, - 1 + purchase.payCoinSelection.coinPubs.length / 5, + 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5, ); } +/** + * Return the proposal download data for a purchase, throw if not available. + * + * (Async since in the future this will query the DB.) + */ +export async function expectProposalDownload( + p: PurchaseRecord, +): Promise<ProposalDownload> { + if (!p.download) { + throw Error("expected proposal to be downloaded"); + } + return p.download; +} + export function extractContractData( parsedContractTerms: ContractTerms, contractTermsHash: string, @@ -366,9 +317,9 @@ export async function processDownloadProposal( options: object = {}, ): Promise<OperationAttemptResult> { const proposal = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return await tx.proposals.get(proposalId); + return await tx.purchases.get(proposalId); }); if (!proposal) { @@ -378,7 +329,7 @@ export async function processDownloadProposal( }; } - if (proposal.proposalStatus != ProposalStatus.Downloading) { + if (proposal.status != ProposalStatus.DownloadingProposal) { return { type: OperationAttemptResultType.Finished, result: undefined, @@ -401,7 +352,7 @@ export async function processDownloadProposal( requestBody.token = proposal.claimToken; } - const opId = RetryTags.forProposalClaim(proposal); + const opId = RetryTags.forPay(proposal); const retryRecord = await ws.db .mktx((x) => [x.operationRetries]) .runReadOnly(async (tx) => { @@ -543,13 +494,13 @@ export async function processDownloadProposal( logger.trace(`extracted contract data: ${j2s(contractData)}`); await ws.db - .mktx((x) => [x.purchases, x.proposals]) + .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { - const p = await tx.proposals.get(proposalId); + const p = await tx.purchases.get(proposalId); if (!p) { return; } - if (p.proposalStatus !== ProposalStatus.Downloading) { + if (p.status !== ProposalStatus.DownloadingProposal) { return; } p.download = { @@ -565,14 +516,14 @@ export async function processDownloadProposal( await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); if (differentPurchase) { logger.warn("repurchase detected"); - p.proposalStatus = ProposalStatus.Repurchase; + p.status = ProposalStatus.RepurchaseDetected; p.repurchaseProposalId = differentPurchase.proposalId; - await tx.proposals.put(p); + await tx.purchases.put(p); return; } } - p.proposalStatus = ProposalStatus.Proposed; - await tx.proposals.put(p); + p.status = ProposalStatus.Proposed; + await tx.purchases.put(p); }); ws.notify({ @@ -602,9 +553,9 @@ async function startDownloadProposal( noncePriv: string | undefined, ): Promise<string> { const oldProposal = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.proposals.indexes.byUrlAndOrderId.get([ + return tx.purchases.indexes.byUrlAndOrderId.get([ merchantBaseUrl, orderId, ]); @@ -635,7 +586,7 @@ async function startDownloadProposal( const { priv, pub } = noncePair; const proposalId = encodeCrock(getRandomBytes(32)); - const proposalRecord: ProposalRecord = { + const proposalRecord: PurchaseRecord = { download: undefined, noncePriv: priv, noncePub: pub, @@ -644,15 +595,25 @@ async function startDownloadProposal( merchantBaseUrl, orderId, proposalId: proposalId, - proposalStatus: ProposalStatus.Downloading, + status: ProposalStatus.DownloadingProposal, repurchaseProposalId: undefined, downloadSessionId: sessionId, + autoRefundDeadline: undefined, + lastSessionId: undefined, + merchantPaySig: undefined, + payInfo: undefined, + refundAmountAwaiting: undefined, + refunds: {}, + timestampAccept: undefined, + timestampFirstSuccessfulPay: undefined, + timestampLastRefundStatus: undefined, + pendingRemovedCoinPubs: undefined, }; await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { - const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([ + const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([ merchantBaseUrl, orderId, ]); @@ -660,7 +621,7 @@ async function startDownloadProposal( // Created concurrently return; } - await tx.proposals.put(proposalRecord); + await tx.purchases.put(proposalRecord); }); await processDownloadProposal(ws, proposalId); @@ -688,15 +649,17 @@ async function storeFirstPaySuccess( logger.warn("payment success already stored"); return; } + if (purchase.status === ProposalStatus.Paying) { + purchase.status = ProposalStatus.Paid; + } purchase.timestampFirstSuccessfulPay = now; - purchase.paymentSubmitPending = false; purchase.lastSessionId = sessionId; purchase.merchantPaySig = paySig; - const protoAr = purchase.download.contractData.autoRefund; + const protoAr = purchase.download!.contractData.autoRefund; if (protoAr) { const ar = Duration.fromTalerProtocolDuration(protoAr); logger.info("auto_refund present"); - purchase.refundQueryRequested = true; + purchase.status = ProposalStatus.QueryingAutoRefund; purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), ar), ); @@ -723,7 +686,9 @@ async function storePayReplaySuccess( if (isFirst) { throw Error("invalid payment state"); } - purchase.paymentSubmitPending = false; + if (purchase.status === ProposalStatus.Paying) { + purchase.status = ProposalStatus.Paid; + } purchase.lastSessionId = sessionId; await tx.purchases.put(purchase); }); @@ -774,19 +739,26 @@ async function handleInsufficientFunds( throw new TalerProtocolViolationError(); } - const { contractData } = proposal.download; + const { contractData } = proposal.download!; const prevPayCoins: PreviousPayCoins = []; + const payInfo = proposal.payInfo; + if (!payInfo) { + return; + } + + const payCoinSelection = payInfo.payCoinSelection; + await ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { - for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) { - const coinPub = proposal.payCoinSelection.coinPubs[i]; + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; if (coinPub === brokenCoinPub) { continue; } - const contrib = proposal.payCoinSelection.coinContributions[i]; + const contrib = payCoinSelection.coinContributions[i]; const coin = await tx.coins.get(coinPub); if (!coin) { continue; @@ -839,14 +811,19 @@ async function handleInsufficientFunds( if (!p) { return; } - p.payCoinSelection = res; - p.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); - p.coinDepositPermissions = undefined; + const payInfo = p.payInfo; + if (!payInfo) { + return; + } + payInfo.payCoinSelection = res; + payInfo.payCoinSelection = res; + payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + payInfo.coinDepositPermissions = undefined; await tx.purchases.put(p); await spendCoins(ws, tx, { allocationId: `proposal:${p.proposalId}`, - coinPubs: p.payCoinSelection.coinPubs, - contributions: p.payCoinSelection.coinContributions, + coinPubs: payInfo.payCoinSelection.coinPubs, + contributions: payInfo.payCoinSelection.coinContributions, refreshReason: RefreshReason.PayMerchant, }); }); @@ -1255,23 +1232,23 @@ export async function checkPaymentByProposalId( sessionId?: string, ): Promise<PreparePayResult> { let proposal = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.proposals.get(proposalId); + return tx.purchases.get(proposalId); }); if (!proposal) { throw Error(`could not get proposal ${proposalId}`); } - if (proposal.proposalStatus === ProposalStatus.Repurchase) { + if (proposal.status === ProposalStatus.RepurchaseDetected) { const existingProposalId = proposal.repurchaseProposalId; if (!existingProposalId) { throw Error("invalid proposal state"); } logger.trace("using existing purchase for same product"); proposal = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.proposals.get(existingProposalId); + return tx.purchases.get(existingProposalId); }); if (!proposal) { throw Error("existing proposal is in wrong state"); @@ -1297,7 +1274,7 @@ export async function checkPaymentByProposalId( return tx.purchases.get(proposalId); }); - if (!purchase) { + if (!purchase || purchase.status === ProposalStatus.Proposed) { // If not already paid, check if we could pay for it. const res = await selectPayCoinsNew(ws, { auditors: contractData.allowedAuditors, @@ -1337,10 +1314,14 @@ export async function checkPaymentByProposalId( }; } - if (purchase.lastSessionId !== sessionId) { + if ( + purchase.status === ProposalStatus.Paid && + purchase.lastSessionId !== sessionId + ) { logger.trace( "automatically re-submitting payment with different session ID", ); + logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { @@ -1349,7 +1330,7 @@ export async function checkPaymentByProposalId( return; } p.lastSessionId = sessionId; - p.paymentSubmitPending = true; + p.status = ProposalStatus.PayingReplay; await tx.purchases.put(p); }); const r = await processPurchasePay(ws, proposalId, { forceNow: true }); @@ -1357,35 +1338,41 @@ export async function checkPaymentByProposalId( // FIXME: This does not surface the original error throw Error("submitting pay failed"); } + const download = await expectProposalDownload(purchase); return { status: PreparePayResultType.AlreadyConfirmed, - contractTerms: purchase.download.contractTermsRaw, - contractTermsHash: purchase.download.contractData.contractTermsHash, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, paid: true, - amountRaw: Amounts.stringify(purchase.download.contractData.amount), - amountEffective: Amounts.stringify(purchase.totalPayCost), + amountRaw: Amounts.stringify(download.contractData.amount), + amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), proposalId, }; } else if (!purchase.timestampFirstSuccessfulPay) { + const download = await expectProposalDownload(purchase); return { status: PreparePayResultType.AlreadyConfirmed, - contractTerms: purchase.download.contractTermsRaw, - contractTermsHash: purchase.download.contractData.contractTermsHash, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, paid: false, - amountRaw: Amounts.stringify(purchase.download.contractData.amount), - amountEffective: Amounts.stringify(purchase.totalPayCost), + amountRaw: Amounts.stringify(download.contractData.amount), + amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), proposalId, }; } else { - const paid = !purchase.paymentSubmitPending; + const paid = + purchase.status === ProposalStatus.Paid || + purchase.status === ProposalStatus.QueryingRefund || + purchase.status === ProposalStatus.QueryingAutoRefund; + const download = await expectProposalDownload(purchase); return { status: PreparePayResultType.AlreadyConfirmed, - contractTerms: purchase.download.contractTermsRaw, - contractTermsHash: purchase.download.contractData.contractTermsHash, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, paid, - amountRaw: Amounts.stringify(purchase.download.contractData.amount), - amountEffective: Amounts.stringify(purchase.totalPayCost), - ...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}), + amountRaw: Amounts.stringify(download.contractData.amount), + amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + ...(paid ? { nextUrl: download.contractData.orderId } : {}), proposalId, }; } @@ -1396,9 +1383,9 @@ export async function getContractTermsDetails( proposalId: string, ): Promise<WalletContractData> { const proposal = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.proposals.get(proposalId); + return tx.purchases.get(proposalId); }); if (!proposal) { @@ -1574,7 +1561,10 @@ export async function runPayForConfirmPay( } } case OperationAttemptResultType.Pending: - await storeOperationPending(ws, `${PendingTaskType.Pay}:${proposalId}`); + await storeOperationPending( + ws, + `${PendingTaskType.Purchase}:${proposalId}`, + ); return { type: ConfirmPayResultType.Pending, transactionId: makeEventId(TransactionType.Payment, proposalId), @@ -1600,9 +1590,9 @@ export async function confirmPay( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, ); const proposal = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.proposals.get(proposalId); + return tx.purchases.get(proposalId); }); if (!proposal) { @@ -1625,13 +1615,12 @@ export async function confirmPay( ) { logger.trace(`changing session ID to ${sessionIdOverride}`); purchase.lastSessionId = sessionIdOverride; - purchase.paymentSubmitPending = true; await tx.purchases.put(purchase); } return purchase; }); - if (existingPurchase) { + if (existingPurchase && existingPurchase.payInfo) { logger.trace("confirmPay: submitting payment for existing purchase"); return runPayForConfirmPay(ws, proposalId); } @@ -1640,9 +1629,9 @@ export async function confirmPay( const contractData = d.contractData; - let res: PayCoinSelection | undefined = undefined; + let maybeCoinSelection: PayCoinSelection | undefined = undefined; - res = await selectPayCoinsNew(ws, { + maybeCoinSelection = await selectPayCoinsNew(ws, { auditors: contractData.allowedAuditors, exchanges: contractData.allowedExchanges, wireMethod: contractData.wireMethod, @@ -1655,9 +1644,9 @@ export async function confirmPay( forcedSelection: forcedCoinSel, }); - logger.trace("coin selection result", res); + logger.trace("coin selection result", maybeCoinSelection); - if (!res) { + if (!maybeCoinSelection) { // Should not happen, since checkPay should be called first // FIXME: Actually, this should be handled gracefully, // and the status should be stored in the DB. @@ -1665,23 +1654,121 @@ export async function confirmPay( throw Error("insufficient balance"); } + const coinSelection = maybeCoinSelection; + const depositPermissions = await generateDepositPermissions( ws, - res, + coinSelection, d.contractData, ); - await recordConfirmPay( - ws, - proposal, - res, - depositPermissions, - sessionIdOverride, + const payCostInfo = await getTotalPaymentCost(ws, coinSelection); + + let sessionId: string | undefined; + if (sessionIdOverride) { + sessionId = sessionIdOverride; + } else { + sessionId = proposal.downloadSessionId; + } + + logger.trace( + `recording payment on ${proposal.orderId} with session ID ${sessionId}`, ); + await ws.db + .mktx((x) => [ + x.purchases, + x.coins, + x.refreshGroups, + x.denominations, + x.coinAvailability, + ]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposal.proposalId); + if (!p) { + return; + } + switch (p.status) { + case ProposalStatus.Proposed: + p.payInfo = { + payCoinSelection: coinSelection, + payCoinSelectionUid: encodeCrock(getRandomBytes(16)), + totalPayCost: payCostInfo, + coinDepositPermissions: depositPermissions, + }; + p.lastSessionId = sessionId; + p.timestampAccept = TalerProtocolTimestamp.now(); + p.status = ProposalStatus.Paying; + await tx.purchases.put(p); + await spendCoins(ws, tx, { + allocationId: `proposal:${p.proposalId}`, + coinPubs: coinSelection.coinPubs, + contributions: coinSelection.coinContributions, + refreshReason: RefreshReason.PayMerchant, + }); + break; + case ProposalStatus.Paid: + case ProposalStatus.Paying: + default: + break; + } + }); + + ws.notify({ + type: NotificationType.ProposalAccepted, + proposalId: proposal.proposalId, + }); + return runPayForConfirmPay(ws, proposalId); } +export async function processPurchase( + ws: InternalWalletState, + proposalId: string, + options: { + forceNow?: boolean; + } = {}, +): Promise<OperationAttemptResult> { + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!purchase) { + return { + type: OperationAttemptResultType.Error, + errorDetail: { + // FIXME: allocate more specific error code + code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + hint: `trying to pay for purchase that is not in the database`, + proposalId: proposalId, + }, + }; + } + + switch (purchase.status) { + case ProposalStatus.DownloadingProposal: + return processDownloadProposal(ws, proposalId, options); + case ProposalStatus.Paying: + case ProposalStatus.PayingReplay: + return processPurchasePay(ws, proposalId, options); + case ProposalStatus.QueryingAutoRefund: + case ProposalStatus.QueryingAutoRefund: + case ProposalStatus.AbortingWithRefund: + return processPurchaseQueryRefund(ws, proposalId, options); + case ProposalStatus.ProposalDownloadFailed: + case ProposalStatus.Paid: + case ProposalStatus.AbortingWithRefund: + case ProposalStatus.RepurchaseDetected: + return { + type: OperationAttemptResultType.Finished, + result: undefined, + }; + default: + throw Error(`unexpected purchase status (${purchase.status})`); + } +} + export async function processPurchasePay( ws: InternalWalletState, proposalId: string, @@ -1705,31 +1792,38 @@ export async function processPurchasePay( }, }; } - if (!purchase.paymentSubmitPending) { - OperationAttemptResult.finishedEmpty(); + switch (purchase.status) { + case ProposalStatus.Paying: + case ProposalStatus.PayingReplay: + break; + default: + return OperationAttemptResult.finishedEmpty(); } logger.trace(`processing purchase pay ${proposalId}`); const sessionId = purchase.lastSessionId; - logger.trace("paying with session ID", sessionId); + logger.trace(`paying with session ID ${sessionId}`); + const payInfo = purchase.payInfo; + checkDbInvariant(!!payInfo, "payInfo"); + const download = await expectProposalDownload(purchase); if (!purchase.merchantPaySig) { const payUrl = new URL( - `orders/${purchase.download.contractData.orderId}/pay`, - purchase.download.contractData.merchantBaseUrl, + `orders/${download.contractData.orderId}/pay`, + download.contractData.merchantBaseUrl, ).href; let depositPermissions: CoinDepositPermission[]; - if (purchase.coinDepositPermissions) { - depositPermissions = purchase.coinDepositPermissions; + if (purchase.payInfo?.coinDepositPermissions) { + depositPermissions = purchase.payInfo.coinDepositPermissions; } else { // FIXME: also cache! depositPermissions = await generateDepositPermissions( ws, - purchase.payCoinSelection, - purchase.download.contractData, + payInfo.payCoinSelection, + download.contractData, ); } @@ -1775,7 +1869,8 @@ export async function processPurchasePay( if (!purch) { return; } - purch.payFrozen = true; + // FIXME: Should be some "PayPermanentlyFailed" and error info should be stored + purch.status = ProposalStatus.PaymentAbortFinished; await tx.purchases.put(purch); }); throw makePendingOperationFailedError( @@ -1819,9 +1914,9 @@ export async function processPurchasePay( logger.trace("got success from pay URL", merchantResp); - const merchantPub = purchase.download.contractData.merchantPub; + const merchantPub = download.contractData.merchantPub; const { valid } = await ws.cryptoApi.isValidPaymentSignature({ - contractHash: purchase.download.contractData.contractTermsHash, + contractHash: download.contractData.contractTermsHash, merchantPub, sig: merchantResp.sig, }); @@ -1836,17 +1931,19 @@ export async function processPurchasePay( await unblockBackup(ws, proposalId); } else { const payAgainUrl = new URL( - `orders/${purchase.download.contractData.orderId}/paid`, - purchase.download.contractData.merchantBaseUrl, + `orders/${download.contractData.orderId}/paid`, + download.contractData.merchantBaseUrl, ).href; const reqBody = { sig: purchase.merchantPaySig, - h_contract: purchase.download.contractData.contractTermsHash, + h_contract: download.contractData.contractTermsHash, session_id: sessionId ?? "", }; + logger.trace(`/paid request body: ${j2s(reqBody)}`); const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => ws.http.postJson(payAgainUrl, reqBody), ); + logger.trace(`/paid response status: ${resp.status}`); if (resp.status !== 204) { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, @@ -1871,18 +1968,18 @@ export async function refuseProposal( proposalId: string, ): Promise<void> { const success = await ws.db - .mktx((x) => [x.proposals]) + .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { - const proposal = await tx.proposals.get(proposalId); + const proposal = await tx.purchases.get(proposalId); if (!proposal) { logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); return false; } - if (proposal.proposalStatus !== ProposalStatus.Proposed) { + if (proposal.status !== ProposalStatus.Proposed) { return false; } - proposal.proposalStatus = ProposalStatus.Refused; - await tx.proposals.put(proposal); + proposal.status = ProposalStatus.ProposalRefused; + await tx.purchases.put(proposal); return true; }); if (success) { @@ -1891,3 +1988,771 @@ export async function refuseProposal( }); } } + +export async function prepareRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise<PrepareRefundResult> { + 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.byMerchantUrlAndOrderId.get([ + parseResult.merchantBaseUrl, + parseResult.orderId, + ]); + }); + + if (!purchase) { + throw Error( + `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, + ); + } + + const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); + const summary = await calculateRefundSummary(purchase); + const proposalId = purchase.proposalId; + + const { contractData: c } = await expectProposalDownload(purchase); + + return { + 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}`; +} + +async function applySuccessfulRefund( + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + denominations: typeof WalletStoresV1.denominations; + }>, + p: PurchaseRecord, + refreshCoinsMap: Record<string, { coinPub: string }>, + r: MerchantCoinRefundSuccessStatus, +): Promise<void> { + // FIXME: check signature before storing it as valid! + + const refundKey = getRefundKey(r); + const coin = await tx.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"); + } + refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; + const refundAmount = Amounts.parseOrThrow(r.refund_amount); + const refundFee = denom.fees.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.coins.put(coin); + + const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .toArray(); + + const amountLeft = Amounts.sub( + Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) + .amount, + denom.fees.feeRefund, + ).amount; + + const totalRefreshCostBound = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + + p.refunds[refundKey] = { + type: RefundState.Applied, + obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), + executionTime: r.execution_time, + refundAmount: Amounts.parseOrThrow(r.refund_amount), + refundFee: denom.fees.feeRefund, + totalRefreshCostBound, + coinPub: r.coin_pub, + rtransactionId: r.rtransaction_id, + }; +} + +async function storePendingRefund( + tx: GetReadWriteAccess<{ + denominations: typeof WalletStoresV1.denominations; + coins: typeof WalletStoresV1.coins; + }>, + p: PurchaseRecord, + r: MerchantCoinRefundFailureStatus, +): Promise<void> { + 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 allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .toArray(); + + const amountLeft = Amounts.sub( + Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) + .amount, + denom.fees.feeRefund, + ).amount; + + const totalRefreshCostBound = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + + p.refunds[refundKey] = { + type: RefundState.Pending, + obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), + executionTime: r.execution_time, + refundAmount: Amounts.parseOrThrow(r.refund_amount), + refundFee: denom.fees.feeRefund, + totalRefreshCostBound, + coinPub: r.coin_pub, + rtransactionId: r.rtransaction_id, + }; +} + +async function storeFailedRefund( + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + denominations: typeof WalletStoresV1.denominations; + }>, + p: PurchaseRecord, + refreshCoinsMap: Record<string, { coinPub: string }>, + r: MerchantCoinRefundFailureStatus, +): Promise<void> { + 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 allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .toArray(); + + const amountLeft = Amounts.sub( + Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) + .amount, + denom.fees.feeRefund, + ).amount; + + const totalRefreshCostBound = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + + p.refunds[refundKey] = { + type: RefundState.Failed, + obtainedTime: TalerProtocolTimestamp.now(), + executionTime: r.execution_time, + refundAmount: Amounts.parseOrThrow(r.refund_amount), + refundFee: denom.fees.feeRefund, + totalRefreshCostBound, + coinPub: r.coin_pub, + rtransactionId: r.rtransaction_id, + }; + + if (p.status === ProposalStatus.AbortingWithRefund) { + // Refund failed because the merchant didn't even try to deposit + // the coin yet, so we try to refresh. + 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 = payCoinSelection.coinContributions[i]; + } + } + if (contrib) { + coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount; + coin.currentAmount = Amounts.sub( + coin.currentAmount, + denom.fees.feeRefund, + ).amount; + } + refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; + await tx.coins.put(coin); + } + } +} + +async function acceptRefunds( + ws: InternalWalletState, + proposalId: string, + refunds: MerchantCoinRefundStatus[], + reason: RefundReason, +): Promise<void> { + logger.trace("handling refunds", refunds); + const now = TalerProtocolTimestamp.now(); + + 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<string, CoinPublicKey> = {}; + + 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; + } + + // Already applied. + if (existingRefundInfo?.type === RefundState.Applied) { + continue; + } + + // Still pending. + if ( + refundStatus.type === "failure" && + !isPermanentFailure && + existingRefundInfo?.type === RefundState.Pending + ) { + continue; + } + + // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) + + if (refundStatus.type === "success") { + await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); + } else if (isPermanentFailure) { + await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus); + } else { + await storePendingRefund(tx, p, refundStatus); + } + } + + const refreshCoinsPubs = Object.values(refreshCoinsMap); + if (refreshCoinsPubs.length > 0) { + 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; + + let numPendingRefunds = 0; + for (const ri of Object.values(p.refunds)) { + switch (ri.type) { + case RefundState.Pending: + numPendingRefunds++; + break; + } + } + + if (numPendingRefunds > 0) { + queryDone = false; + } + + if (queryDone) { + p.timestampLastRefundStatus = now; + if (p.status === ProposalStatus.AbortingWithRefund) { + p.status = ProposalStatus.PaymentAbortFinished; + } else if (p.status === ProposalStatus.QueryingAutoRefund) { + const autoRefundDeadline = p.autoRefundDeadline; + checkDbInvariant(!!autoRefundDeadline); + if ( + AbsoluteTime.isExpired( + AbsoluteTime.fromTimestamp(autoRefundDeadline), + ) + ) { + p.status = ProposalStatus.Paid; + } + } else if (p.status === ProposalStatus.QueryingRefund) { + p.status = ProposalStatus.Paid; + } + logger.trace("refund query done"); + } 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, + }); +} + +async function calculateRefundSummary( + p: PurchaseRecord, +): Promise<RefundSummary> { + const download = await expectProposalDownload(p); + let amountRefundGranted = Amounts.getZero( + download.contractData.amount.currency, + ); + let amountRefundGone = Amounts.getZero(download.contractData.amount.currency); + + let pendingAtExchange = false; + + const payInfo = p.payInfo; + if (!payInfo) { + throw Error("can't calculate refund summary without payInfo"); + } + + 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; + } + }); + return { + amountEffectivePaid: payInfo.totalPayCost, + amountRefundGone, + amountRefundGranted, + pendingAtExchange, + }; +} + +/** + * Summary of the refund status of a purchase. + */ +export interface RefundSummary { + pendingAtExchange: boolean; + amountEffectivePaid: AmountJson; + amountRefundGranted: AmountJson; + amountRefundGone: AmountJson; +} + +/** + * Accept a refund, return the contract hash for the contract + * that was involved in the refund. + */ +export async function applyRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise<ApplyRefundResponse> { + const parseResult = parseRefundUri(talerRefundUri); + + logger.trace("applying refund", parseResult); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ + parseResult.merchantBaseUrl, + parseResult.orderId, + ]); + }); + + if (!purchase) { + throw Error( + `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, + ); + } + + return applyRefundFromPurchaseId(ws, purchase.proposalId); +} + +export async function applyRefundFromPurchaseId( + ws: InternalWalletState, + proposalId: string, +): Promise<ApplyRefundResponse> { + logger.trace("applying refund for purchase", proposalId); + + logger.info("processing purchase for refund"); + const success = 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; + } + if (p.status === ProposalStatus.Paid) { + p.status = ProposalStatus.QueryingRefund; + } + await tx.purchases.put(p); + return true; + }); + + if (success) { + ws.notify({ + type: NotificationType.RefundStarted, + }); + await processPurchaseQueryRefund(ws, proposalId, { + forceNow: true, + waitForAutoRefund: false, + }); + } + + 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(purchase); + const download = await expectProposalDownload(purchase); + + return { + contractTermsHash: download.contractData.contractTermsHash, + proposalId: purchase.proposalId, + transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund + 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, + }, + }; +} + +async function queryAndSaveAwaitingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, + waitForAutoRefund?: boolean, +): Promise<AmountJson> { + const download = await expectProposalDownload(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.getZero(download.contractData.amount.currency); + } + + 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 = refundAwaiting; + await tx.purchases.put(p); + }); + } + + return refundAwaiting; +} + +export async function processPurchaseQueryRefund( + ws: InternalWalletState, + proposalId: string, + options: { + forceNow?: boolean; + waitForAutoRefund?: boolean; + } = {}, +): Promise<OperationAttemptResult> { + 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(); + } + + if ( + !( + purchase.status === ProposalStatus.QueryingAutoRefund || + purchase.status === ProposalStatus.QueryingRefund || + purchase.status === ProposalStatus.AbortingWithRefund + ) + ) { + return OperationAttemptResult.finishedEmpty(); + } + + const download = await expectProposalDownload(purchase); + + if (purchase.timestampFirstSuccessfulPay) { + if ( + !purchase.autoRefundDeadline || + !AbsoluteTime.isExpired( + AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), + ) + ) { + const awaitingAmount = await queryAndSaveAwaitingRefund( + ws, + purchase, + waitForAutoRefund, + ); + if (Amounts.isZero(awaitingAmount)) { + return OperationAttemptResult.finishedEmpty(); + } + } + + 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.status === ProposalStatus.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 abortFailedPayWithRefund( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + if (purchase.timestampFirstSuccessfulPay) { + // No point in aborting it. We don't even report an error. + logger.warn(`tried to abort successful payment`); + return; + } + if (purchase.status === ProposalStatus.Paying) { + purchase.status = ProposalStatus.AbortingWithRefund; + } + await tx.purchases.put(purchase); + }); + processPurchaseQueryRefund(ws, proposalId, { + forceNow: true, + }).catch((e) => { + logger.trace(`error during refund processing after abort pay: ${e}`); + }); +} diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/pay-peer.ts index d30cb294d..e9185a9d4 100644 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer.ts @@ -73,9 +73,8 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { checkDbInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess } from "../util/query.js"; -import { spendCoins } from "../wallet.js"; +import { spendCoins, makeEventId } from "../operations/common.js"; import { updateExchangeFromUrl } from "./exchanges.js"; -import { makeEventId } from "./transactions.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; const logger = new Logger("operations/peer-to-peer.ts"); diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index e4c270d85..db7a85432 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -23,7 +23,6 @@ */ import { ProposalStatus, - AbortStatus, WalletStoresV1, BackupProviderStateTag, RefreshCoinStatus, @@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { RetryTags } from "../util/retries.js"; -import { Wallet } from "../wallet.js"; import { GlobalIDB } from "@gnu-taler/idb-bridge"; function getPendingCommon( @@ -184,38 +182,6 @@ async function gatherWithdrawalPending( } } -async function gatherProposalPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - proposals: typeof WalletStoresV1.proposals; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await tx.proposals.iter().forEachAsync(async (proposal) => { - if (proposal.proposalStatus == ProposalStatus.Proposed) { - // Nothing to do, user needs to choose. - } else if (proposal.proposalStatus == ProposalStatus.Downloading) { - const opId = RetryTags.forProposalClaim(proposal); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.ProposalDownload, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - merchantBaseUrl: proposal.merchantBaseUrl, - orderId: proposal.orderId, - proposalId: proposal.proposalId, - proposalTimestamp: proposal.timestamp, - lastError: retryRecord?.lastError, - retryInfo: retryRecord?.retryInfo, - }); - } - }); -} - async function gatherDepositPending( ws: InternalWalletState, tx: GetReadOnlyAccess<{ @@ -287,44 +253,27 @@ async function gatherPurchasePending( resp: PendingOperationsResponse, ): Promise<void> { // FIXME: Only iter purchases with some "active" flag! - await tx.purchases.iter().forEachAsync(async (pr) => { - if ( - pr.paymentSubmitPending && - pr.abortStatus === AbortStatus.None && - !pr.payFrozen - ) { - const payOpId = RetryTags.forPay(pr); - const payRetryRecord = await tx.operationRetries.get(payOpId); - - const timestampDue = - payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Pay, - ...getPendingCommon(ws, payOpId, timestampDue), - givesLifeness: true, - isReplay: false, - proposalId: pr.proposalId, - retryInfo: payRetryRecord?.retryInfo, - lastError: payRetryRecord?.lastError, - }); - } - if (pr.refundQueryRequested) { - const refundQueryOpId = RetryTags.forRefundQuery(pr); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); + const keyRange = GlobalIDB.KeyRange.bound( + OperationStatusRange.ACTIVE_START, + OperationStatusRange.ACTIVE_END, + ); + await tx.purchases.indexes.byStatus + .iter(keyRange) + .forEachAsync(async (pr) => { + const opId = RetryTags.forPay(pr); + const retryRecord = await tx.operationRetries.get(opId); const timestampDue = - refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(); + retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(); resp.pendingOperations.push({ - type: PendingTaskType.RefundQuery, - ...getPendingCommon(ws, refundQueryOpId, timestampDue), + type: PendingTaskType.Purchase, + ...getPendingCommon(ws, opId, timestampDue), givesLifeness: true, + statusStr: ProposalStatus[pr.status], proposalId: pr.proposalId, - retryInfo: refundQueryRetryRecord?.retryInfo, - lastError: refundQueryRetryRecord?.lastError, + retryInfo: retryRecord?.retryInfo, + lastError: retryRecord?.lastError, }); - } - }); + }); } async function gatherRecoupPending( @@ -404,7 +353,6 @@ export async function getPendingOperations( x.refreshGroups, x.coins, x.withdrawalGroups, - x.proposals, x.tips, x.purchases, x.planchets, @@ -419,7 +367,6 @@ export async function getPendingOperations( await gatherExchangePending(ws, tx, now, resp); await gatherRefreshPending(ws, tx, now, resp); await gatherWithdrawalPending(ws, tx, now, resp); - await gatherProposalPending(ws, tx, now, resp); await gatherDepositPending(ws, tx, now, resp); await gatherTipPending(ws, tx, now, resp); await gatherPurchasePending(ws, tx, now, resp); diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index 2d92ff8ba..ff6bb4efc 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -27,16 +27,15 @@ import { Amounts, codecForRecoupConfirmation, + codecForReserveStatus, encodeCrock, getRandomBytes, j2s, Logger, NotificationType, RefreshReason, - TalerErrorDetail, TalerProtocolTimestamp, URL, - codecForReserveStatus, } from "@gnu-taler/taler-util"; import { CoinRecord, @@ -44,8 +43,8 @@ import { CoinStatus, RecoupGroupRecord, RefreshCoinSource, - WithdrawalGroupStatus, WalletStoresV1, + WithdrawalGroupStatus, WithdrawalRecordType, WithdrawCoinSource, } from "../db.js"; @@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { GetReadWriteAccess } from "../util/query.js"; import { OperationAttemptResult, - RetryInfo, runOperationHandlerForResult, } from "../util/retries.js"; -import { guardOperationException } from "./common.js"; import { createRefreshGroup, processRefreshGroup } from "./refresh.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 9fe2e6a8f..a5951ea53 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -78,7 +78,7 @@ import { OperationAttemptResult, OperationAttemptResultType, } from "../util/retries.js"; -import { makeCoinAvailable } from "../wallet.js"; +import { makeCoinAvailable } from "./common.js"; import { updateExchangeFromUrl } from "./exchanges.js"; import { isWithdrawableDenom, diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts deleted file mode 100644 index 0d86b92ab..000000000 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ /dev/null @@ -1,815 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019-2019 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Implementation of the refund operation. - * - * @author Florian Dold - */ - -/** - * Imports. - */ -import { - AbortingCoin, - AbortRequest, - AbsoluteTime, - AmountJson, - Amounts, - ApplyRefundResponse, - codecForAbortResponse, - codecForMerchantOrderRefundPickupResponse, - codecForMerchantOrderStatusPaid, - CoinPublicKey, - Duration, - Logger, - MerchantCoinRefundFailureStatus, - MerchantCoinRefundStatus, - MerchantCoinRefundSuccessStatus, - NotificationType, - parseRefundUri, - PrepareRefundResult, - RefreshReason, - TalerErrorCode, - TalerProtocolTimestamp, - TransactionType, - URL, -} from "@gnu-taler/taler-util"; -import { - AbortStatus, - CoinStatus, - DenominationRecord, - PurchaseRecord, - RefundReason, - RefundState, - WalletStoresV1, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { readSuccessResponseJsonOrThrow } from "../util/http.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadWriteAccess } from "../util/query.js"; -import { OperationAttemptResult } from "../util/retries.js"; -import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; -import { makeEventId } from "./transactions.js"; - -const logger = new Logger("refund.ts"); - -export async function prepareRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise<PrepareRefundResult> { - 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.byMerchantUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); - }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); - } - - const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); - const summary = calculateRefundSummary(purchase); - const proposalId = purchase.proposalId; - - const { contractData: c } = purchase.download; - - return { - 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}`; -} - -async function applySuccessfulRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record<string, { coinPub: string }>, - r: MerchantCoinRefundSuccessStatus, -): Promise<void> { - // FIXME: check signature before storing it as valid! - - const refundKey = getRefundKey(r); - const coin = await tx.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"); - } - refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; - const refundAmount = Amounts.parseOrThrow(r.refund_amount); - const refundFee = denom.fees.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.coins.put(coin); - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Applied, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.fees.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} - -async function storePendingRefund( - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - }>, - p: PurchaseRecord, - r: MerchantCoinRefundFailureStatus, -): Promise<void> { - 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 allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Pending, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.fees.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} - -async function storeFailedRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record<string, { coinPub: string }>, - r: MerchantCoinRefundFailureStatus, -): Promise<void> { - 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 allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Failed, - obtainedTime: TalerProtocolTimestamp.now(), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.fees.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; - - if (p.abortStatus === AbortStatus.AbortRefund) { - // Refund failed because the merchant didn't even try to deposit - // the coin yet, so we try to refresh. - 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; - } - let contrib: AmountJson | undefined; - for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) { - if (p.payCoinSelection.coinPubs[i] === r.coin_pub) { - contrib = p.payCoinSelection.coinContributions[i]; - } - } - if (contrib) { - coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount; - coin.currentAmount = Amounts.sub( - coin.currentAmount, - denom.fees.feeRefund, - ).amount; - } - refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; - await tx.coins.put(coin); - } - } -} - -async function acceptRefunds( - ws: InternalWalletState, - proposalId: string, - refunds: MerchantCoinRefundStatus[], - reason: RefundReason, -): Promise<void> { - logger.trace("handling refunds", refunds); - const now = TalerProtocolTimestamp.now(); - - 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<string, CoinPublicKey> = {}; - - 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; - } - - // Already applied. - if (existingRefundInfo?.type === RefundState.Applied) { - continue; - } - - // Still pending. - if ( - refundStatus.type === "failure" && - !isPermanentFailure && - existingRefundInfo?.type === RefundState.Pending - ) { - continue; - } - - // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) - - if (refundStatus.type === "success") { - await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); - } else if (isPermanentFailure) { - await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus); - } else { - await storePendingRefund(tx, p, refundStatus); - } - } - - const refreshCoinsPubs = Object.values(refreshCoinsMap); - if (refreshCoinsPubs.length > 0) { - 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; - - if ( - p.timestampFirstSuccessfulPay && - p.autoRefundDeadline && - AbsoluteTime.cmp( - AbsoluteTime.fromTimestamp(p.autoRefundDeadline), - AbsoluteTime.fromTimestamp(now), - ) > 0 - ) { - queryDone = false; - } - - let numPendingRefunds = 0; - for (const ri of Object.values(p.refunds)) { - switch (ri.type) { - case RefundState.Pending: - numPendingRefunds++; - break; - } - } - - if (numPendingRefunds > 0) { - queryDone = false; - } - - if (queryDone) { - p.timestampLastRefundStatus = now; - p.refundQueryRequested = false; - if (p.abortStatus === AbortStatus.AbortRefund) { - p.abortStatus = AbortStatus.AbortFinished; - } - logger.trace("refund query done"); - } 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, - }); -} - -function calculateRefundSummary(p: PurchaseRecord): RefundSummary { - let amountRefundGranted = Amounts.getZero( - p.download.contractData.amount.currency, - ); - let amountRefundGone = Amounts.getZero( - p.download.contractData.amount.currency, - ); - - let pendingAtExchange = false; - - 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; - } - }); - return { - amountEffectivePaid: p.totalPayCost, - amountRefundGone, - amountRefundGranted, - pendingAtExchange, - }; -} - -/** - * Summary of the refund status of a purchase. - */ -export interface RefundSummary { - pendingAtExchange: boolean; - amountEffectivePaid: AmountJson; - amountRefundGranted: AmountJson; - amountRefundGone: AmountJson; -} - -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise<ApplyRefundResponse> { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("applying refund", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); - }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); - } - - return applyRefundFromPurchaseId(ws, purchase.proposalId); -} - -export async function applyRefundFromPurchaseId( - ws: InternalWalletState, - proposalId: string, -): Promise<ApplyRefundResponse> { - logger.trace("applying refund for purchase", proposalId); - - logger.info("processing purchase for refund"); - const success = 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; - } - p.refundQueryRequested = true; - await tx.purchases.put(p); - return true; - }); - - if (success) { - ws.notify({ - type: NotificationType.RefundStarted, - }); - await processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, - waitForAutoRefund: false, - }); - } - - 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 = calculateRefundSummary(purchase); - - return { - contractTermsHash: purchase.download.contractData.contractTermsHash, - proposalId: purchase.proposalId, - transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund - amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), - amountRefundGone: Amounts.stringify(summary.amountRefundGone), - amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), - pendingAtExchange: summary.pendingAtExchange, - info: { - contractTermsHash: purchase.download.contractData.contractTermsHash, - merchant: purchase.download.contractData.merchant, - orderId: purchase.download.contractData.orderId, - products: purchase.download.contractData.products, - summary: purchase.download.contractData.summary, - fulfillmentMessage: purchase.download.contractData.fulfillmentMessage, - summary_i18n: purchase.download.contractData.summaryI18n, - fulfillmentMessage_i18n: - purchase.download.contractData.fulfillmentMessageI18n, - }, - }; -} - -async function queryAndSaveAwaitingRefund( - ws: InternalWalletState, - purchase: PurchaseRecord, - waitForAutoRefund?: boolean, -): Promise<AmountJson> { - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}`, - purchase.download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - purchase.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.getZero(purchase.totalPayCost.currency); - } - - const refundAwaiting = Amounts.sub( - Amounts.parseOrThrow(orderStatus.refund_amount), - Amounts.parseOrThrow(orderStatus.refund_taken), - ).amount; - - if ( - purchase.refundAwaiting === undefined || - Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 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.refundAwaiting = refundAwaiting; - await tx.purchases.put(p); - }); - } - - return refundAwaiting; -} - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - options: { - forceNow?: boolean; - waitForAutoRefund?: boolean; - } = {}, -): Promise<OperationAttemptResult> { - 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(); - } - - if (!purchase.refundQueryRequested) { - return OperationAttemptResult.finishedEmpty(); - } - - if (purchase.timestampFirstSuccessfulPay) { - if ( - !purchase.autoRefundDeadline || - !AbsoluteTime.isExpired( - AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), - ) - ) { - const awaitingAmount = await queryAndSaveAwaitingRefund( - ws, - purchase, - waitForAutoRefund, - ); - if (Amounts.isZero(awaitingAmount)) { - return OperationAttemptResult.finishedEmpty(); - } - } - - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}/refund`, - purchase.download.contractData.merchantBaseUrl, - ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, { - h_contract: purchase.download.contractData.contractTermsHash, - }); - - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForMerchantOrderRefundPickupResponse(), - ); - - await acceptRefunds( - ws, - proposalId, - refundResponse.refunds, - RefundReason.NormalRefund, - ); - } else if (purchase.abortStatus === AbortStatus.AbortRefund) { - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}/abort`, - purchase.download.contractData.merchantBaseUrl, - ); - - const abortingCoins: AbortingCoin[] = []; - - await ws.db - .mktx((x) => [x.coins]) - .runReadOnly(async (tx) => { - for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) { - const coinPub = purchase.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( - purchase.payCoinSelection.coinContributions[i], - ), - exchange_url: coin.exchangeBaseUrl, - }); - } - }); - - const abortReq: AbortRequest = { - h_contract: purchase.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: purchase.payCoinSelection.coinPubs[i], - refund_amount: Amounts.stringify( - purchase.payCoinSelection.coinContributions[i], - ), - rtransaction_id: 0, - execution_time: AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.fromTimestamp( - purchase.download.contractData.timestamp, - ), - Duration.fromSpec({ seconds: 1 }), - ), - ), - }); - } - await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); - } - return OperationAttemptResult.finishedEmpty(); -} - -export async function abortFailedPayWithRefund( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if (!purchase) { - throw Error("purchase not found"); - } - if (purchase.timestampFirstSuccessfulPay) { - // No point in aborting it. We don't even report an error. - logger.warn(`tried to abort successful payment`); - return; - } - if (purchase.abortStatus !== AbortStatus.None) { - return; - } - purchase.refundQueryRequested = true; - purchase.paymentSubmitPending = false; - purchase.abortStatus = AbortStatus.AbortRefund; - await tx.purchases.put(purchase); - }); - processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, - }).catch((e) => { - logger.trace(`error during refund processing after abort pay: ${e}`); - }); -} diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index 598a88502..9a11af8bb 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -40,9 +40,8 @@ import { PreparePayResultType, } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { confirmPay, preparePayForUri } from "./pay.js"; +import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js"; import { getBalances } from "./balance.js"; -import { applyRefund } from "./refund.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; @@ -471,6 +470,6 @@ export async function testPay( }); checkLogicInvariant(!!purchase); return { - payCoinSelection: purchase.payCoinSelection, + payCoinSelection: purchase.payInfo?.payCoinSelection!, }; } diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts index bd5ff51e7..a83867f55 100644 --- a/packages/taler-wallet-core/src/operations/tip.ts +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -18,8 +18,8 @@ * Imports. */ import { - AgeRestriction, AcceptTipResponse, + AgeRestriction, Amounts, BlindedDenominationSignature, codecForMerchantTipResponseV2, @@ -56,9 +56,8 @@ import { OperationAttemptResult, OperationAttemptResultType, } from "../util/retries.js"; -import { makeCoinAvailable } from "../wallet.js"; +import { makeCoinAvailable, makeEventId } from "./common.js"; import { updateExchangeFromUrl } from "./exchanges.js"; -import { makeEventId } from "./transactions.js"; import { getCandidateWithdrawalDenoms, getExchangeWithdrawalInfo, diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 4086fc9b3..6ddf14f98 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -36,12 +36,12 @@ import { WithdrawalType, } from "@gnu-taler/taler-util"; import { - AbortStatus, DepositGroupRecord, ExchangeDetailsRecord, OperationRetryRecord, PeerPullPaymentIncomingRecord, PeerPushPaymentInitiationRecord, + ProposalStatus, PurchaseRecord, RefundState, TipRecord, @@ -50,10 +50,12 @@ import { WithdrawalRecordType, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; +import { checkDbInvariant } from "../util/invariants.js"; import { RetryTags } from "../util/retries.js"; +import { makeEventId, TombstoneTag } from "./common.js"; import { processDepositGroup } from "./deposits.js"; import { getExchangeDetails } from "./exchanges.js"; -import { processPurchasePay } from "./pay.js"; +import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js"; import { processRefreshGroup } from "./refresh.js"; import { processTip } from "./tip.js"; import { @@ -63,28 +65,6 @@ import { const logger = new Logger("taler-wallet-core:transactions.ts"); -export enum TombstoneTag { - DeleteWithdrawalGroup = "delete-withdrawal-group", - DeleteReserve = "delete-reserve", - DeletePayment = "delete-payment", - DeleteTip = "delete-tip", - DeleteRefreshGroup = "delete-refresh-group", - DeleteDepositGroup = "delete-deposit-group", - DeleteRefund = "delete-refund", - DeletePeerPullDebit = "delete-peer-pull-debit", - DeletePeerPushDebit = "delete-peer-push-debit", -} - -/** - * Create an event ID from the type and the primary key for the event. - */ -export function makeEventId( - type: TransactionType | TombstoneTag, - ...args: string[] -): string { - return type + ":" + args.map((x) => encodeURIComponent(x)).join(":"); -} - function shouldSkipCurrency( transactionsRequest: TransactionsRequest | undefined, currency: string, @@ -219,29 +199,22 @@ export async function getTransactionById( }), ); + const download = await expectProposalDownload(purchase); + const cleanRefunds = filteredRefunds.filter( (x): x is WalletRefundItem => !!x, ); - const contractData = purchase.download.contractData; + const contractData = download.contractData; const refunds = mergeRefundByExecutionTime( cleanRefunds, Amounts.getZero(contractData.amount.currency), ); const payOpId = RetryTags.forPay(purchase); - const refundQueryOpId = RetryTags.forRefundQuery(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); - - const err = - payRetryRecord !== undefined - ? payRetryRecord - : refundQueryRetryRecord; - return buildTransactionForPurchase(purchase, refunds, err); + return buildTransactionForPurchase(purchase, refunds, payRetryRecord); }); } else if (type === TransactionType.Refresh) { const refreshGroupId = rest[0]; @@ -295,23 +268,14 @@ export async function getTransactionById( ), ); if (t) throw Error("deleted"); - - const contractData = purchase.download.contractData; + const download = await expectProposalDownload(purchase); + const contractData = download.contractData; const refunds = mergeRefundByExecutionTime( [theRefund], Amounts.getZero(contractData.amount.currency), ); - const refundQueryOpId = RetryTags.forRefundQuery(purchase); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); - - return buildTransactionForRefund( - purchase, - refunds[0], - refundQueryRetryRecord, - ); + return buildTransactionForRefund(purchase, refunds[0], undefined); }); } else if (type === TransactionType.PeerPullDebit) { const peerPullPaymentIncomingId = rest[0]; @@ -606,12 +570,13 @@ function mergeRefundByExecutionTime( return Array.from(refundByExecTime.values()); } -function buildTransactionForRefund( +async function buildTransactionForRefund( purchaseRecord: PurchaseRecord, refundInfo: MergedRefundInfo, ort?: OperationRetryRecord, -): Transaction { - const contractData = purchaseRecord.download.contractData; +): Promise<Transaction> { + const download = await expectProposalDownload(purchaseRecord); + const contractData = download.contractData; const info: OrderShortInfo = { merchant: contractData.merchant, @@ -641,21 +606,22 @@ function buildTransactionForRefund( amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective), amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw), refundPending: - purchaseRecord.refundAwaiting === undefined + purchaseRecord.refundAmountAwaiting === undefined ? undefined - : Amounts.stringify(purchaseRecord.refundAwaiting), + : Amounts.stringify(purchaseRecord.refundAmountAwaiting), pending: false, frozen: false, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } -function buildTransactionForPurchase( +async function buildTransactionForPurchase( purchaseRecord: PurchaseRecord, refundsInfo: MergedRefundInfo[], ort?: OperationRetryRecord, -): Transaction { - const contractData = purchaseRecord.download.contractData; +): Promise<Transaction> { + const download = await expectProposalDownload(purchaseRecord); + const contractData = download.contractData; const zero = Amounts.getZero(contractData.amount.currency); const info: OrderShortInfo = { @@ -696,31 +662,34 @@ function buildTransactionForPurchase( ), })); + const timestamp = purchaseRecord.timestampAccept; + checkDbInvariant(!!timestamp); + checkDbInvariant(!!purchaseRecord.payInfo); + return { type: TransactionType.Payment, amountRaw: Amounts.stringify(contractData.amount), - amountEffective: Amounts.stringify(purchaseRecord.totalPayCost), + amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost), totalRefundRaw: Amounts.stringify(totalRefund.raw), totalRefundEffective: Amounts.stringify(totalRefund.effective), refundPending: - purchaseRecord.refundAwaiting === undefined + purchaseRecord.refundAmountAwaiting === undefined ? undefined - : Amounts.stringify(purchaseRecord.refundAwaiting), + : Amounts.stringify(purchaseRecord.refundAmountAwaiting), status: purchaseRecord.timestampFirstSuccessfulPay ? PaymentStatus.Paid : PaymentStatus.Accepted, - pending: - !purchaseRecord.timestampFirstSuccessfulPay && - purchaseRecord.abortStatus === AbortStatus.None, + pending: purchaseRecord.status === ProposalStatus.Paying, refunds, - timestamp: purchaseRecord.timestampAccept, + timestamp, transactionId: makeEventId( TransactionType.Payment, purchaseRecord.proposalId, ), proposalId: purchaseRecord.proposalId, info, - frozen: purchaseRecord.payFrozen ?? false, + frozen: + purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } @@ -745,7 +714,6 @@ export async function getTransactions( x.peerPullPaymentIncoming, x.peerPushPaymentInitiations, x.planchets, - x.proposals, x.purchases, x.recoupGroups, x.tips, @@ -838,30 +806,33 @@ export async function getTransactions( transactions.push(buildTransactionForDeposit(dg, retryRecord)); }); - tx.purchases.iter().forEachAsync(async (pr) => { + tx.purchases.iter().forEachAsync(async (purchase) => { + const download = purchase.download; + if (!download) { + return; + } + if (!purchase.payInfo) { + return; + } if ( shouldSkipCurrency( transactionsRequest, - pr.download.contractData.amount.currency, + download.contractData.amount.currency, ) ) { return; } - const contractData = pr.download.contractData; + const contractData = download.contractData; if (shouldSkipSearch(transactionsRequest, [contractData.summary])) { return; } - const proposal = await tx.proposals.get(pr.proposalId); - if (!proposal) { - return; - } const filteredRefunds = await Promise.all( - Object.values(pr.refunds).map(async (r) => { + Object.values(purchase.refunds).map(async (r) => { const t = await tx.tombstones.get( makeEventId( TombstoneTag.DeleteRefund, - pr.proposalId, + purchase.proposalId, `${r.executionTime.t_s}`, ), ); @@ -880,29 +851,16 @@ export async function getTransactions( ); refunds.forEach(async (refundInfo) => { - const refundQueryOpId = RetryTags.forRefundQuery(pr); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); - transactions.push( - buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord), + await buildTransactionForRefund(purchase, refundInfo, undefined), ); }); - const payOpId = RetryTags.forPay(pr); - const refundQueryOpId = RetryTags.forRefundQuery(pr); + const payOpId = RetryTags.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, + transactions.push( + await buildTransactionForPurchase(purchase, refunds, payRetryRecord), ); - - const err = - payRetryRecord !== undefined - ? payRetryRecord - : refundQueryRetryRecord; - - transactions.push(buildTransactionForPurchase(pr, refunds, err)); }); tx.tips.iter().forEachAsync(async (tipRecord) => { @@ -1020,14 +978,9 @@ export async function deleteTransaction( } else if (type === TransactionType.Payment) { const proposalId = rest[0]; await ws.db - .mktx((x) => [x.proposals, x.purchases, x.tombstones]) + .mktx((x) => [x.purchases, x.tombstones]) .runReadWrite(async (tx) => { let found = false; - const proposal = await tx.proposals.get(proposalId); - if (proposal) { - found = true; - await tx.proposals.delete(proposalId); - } const purchase = await tx.purchases.get(proposalId); if (purchase) { found = true; @@ -1083,7 +1036,7 @@ export async function deleteTransaction( const executionTimeStr = rest[1]; await ws.db - .mktx((x) => [x.proposals, x.purchases, x.tombstones]) + .mktx((x) => [x.purchases, x.tombstones]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (purchase) { diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index fb5e2c70a..3c2541e9a 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -70,12 +70,11 @@ import { DenomSelectionState, ExchangeDetailsRecord, ExchangeRecord, - OperationStatus, PlanchetRecord, - WithdrawalGroupStatus, WalletStoresV1, WgInfo, WithdrawalGroupRecord, + WithdrawalGroupStatus, WithdrawalRecordType, } from "../db.js"; import { @@ -84,7 +83,10 @@ import { TalerError, } from "../errors.js"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; +import { + makeCoinAvailable, + runOperationWithErrorReporting, +} from "../operations/common.js"; import { walletCoreDebugFlags } from "../util/debugFlags.js"; import { HttpRequestLibrary, @@ -108,18 +110,16 @@ import { WALLET_EXCHANGE_PROTOCOL_VERSION, } from "../versions.js"; import { - makeCoinAvailable, - runOperationWithErrorReporting, + makeEventId, storeOperationError, storeOperationPending, -} from "../wallet.js"; +} from "./common.js"; import { getExchangeDetails, getExchangePaytoUri, getExchangeTrust, updateExchangeFromUrl, } from "./exchanges.js"; -import { makeEventId } from "./transactions.js"; /** * Logger for this file. diff --git a/packages/taler-wallet-core/src/pending-types.ts b/packages/taler-wallet-core/src/pending-types.ts index 5e0000b53..862bbf4f9 100644 --- a/packages/taler-wallet-core/src/pending-types.ts +++ b/packages/taler-wallet-core/src/pending-types.ts @@ -34,11 +34,9 @@ import { RetryInfo } from "./util/retries.js"; export enum PendingTaskType { ExchangeUpdate = "exchange-update", ExchangeCheckRefresh = "exchange-check-refresh", - Pay = "pay", - ProposalDownload = "proposal-download", + Purchase = "purchase", Refresh = "refresh", Recoup = "recoup", - RefundQuery = "refund-query", TipPickup = "tip-pickup", Withdraw = "withdraw", Deposit = "deposit", @@ -52,10 +50,8 @@ export type PendingTaskInfo = PendingTaskInfoCommon & ( | PendingExchangeUpdateTask | PendingExchangeCheckRefreshTask - | PendingPayTask - | PendingProposalDownloadTask + | PendingPurchaseTask | PendingRefreshTask - | PendingRefundQueryTask | PendingTipPickupTask | PendingWithdrawTask | PendingRecoupTask @@ -110,19 +106,6 @@ export interface PendingRefreshTask { } /** - * Status of downloading signed contract terms from a merchant. - */ -export interface PendingProposalDownloadTask { - type: PendingTaskType.ProposalDownload; - merchantBaseUrl: string; - proposalTimestamp: TalerProtocolTimestamp; - proposalId: string; - orderId: string; - lastError?: TalerErrorDetail; - retryInfo?: RetryInfo; -} - -/** * The wallet is picking up a tip that the user has accepted. */ export interface PendingTipPickupTask { @@ -133,25 +116,16 @@ export interface PendingTipPickupTask { } /** - * The wallet is signing coins and then sending them to - * the merchant. + * A purchase needs to be processed (i.e. for download / payment / refund). */ -export interface PendingPayTask { - type: PendingTaskType.Pay; - proposalId: string; - isReplay: boolean; - retryInfo?: RetryInfo; - lastError: TalerErrorDetail | undefined; -} - -/** - * The wallet is querying the merchant about whether any refund - * permissions are available for a purchase. - */ -export interface PendingRefundQueryTask { - type: PendingTaskType.RefundQuery; +export interface PendingPurchaseTask { + type: PendingTaskType.Purchase; proposalId: string; retryInfo?: RetryInfo; + /** + * Status of the payment as string, used only for debugging. + */ + statusStr: string; lastError: TalerErrorDetail | undefined; } diff --git a/packages/taler-wallet-core/src/util/retries.ts b/packages/taler-wallet-core/src/util/retries.ts index cef9e072c..697d6531e 100644 --- a/packages/taler-wallet-core/src/util/retries.ts +++ b/packages/taler-wallet-core/src/util/retries.ts @@ -30,7 +30,6 @@ import { BackupProviderRecord, DepositGroupRecord, ExchangeRecord, - ProposalRecord, PurchaseRecord, RecoupGroupRecord, RefreshGroupRecord, @@ -181,9 +180,6 @@ export namespace RetryTags { export function forExchangeCheckRefresh(exch: ExchangeRecord): string { return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`; } - export function forProposalClaim(pr: ProposalRecord): string { - return `${PendingTaskType.ProposalDownload}:${pr.proposalId}`; - } export function forTipPickup(tipRecord: TipRecord): string { return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`; } @@ -191,10 +187,7 @@ export namespace RetryTags { return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`; } export function forPay(purchaseRecord: PurchaseRecord): string { - return `${PendingTaskType.Pay}:${purchaseRecord.proposalId}`; - } - export function forRefundQuery(purchaseRecord: PurchaseRecord): string { - return `${PendingTaskType.RefundQuery}:${purchaseRecord.proposalId}`; + return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}`; } export function forRecoup(recoupRecord: RecoupGroupRecord): string { return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`; @@ -206,7 +199,7 @@ export namespace RetryTags { return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`; } export function byPaymentProposalId(proposalId: string): string { - return `${PendingTaskType.Pay}:${proposalId}`; + return `${PendingTaskType.Purchase}:${proposalId}`; } } diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index aa3810035..07dd1fcda 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -26,7 +26,6 @@ import { AbsoluteTime, AmountJson, Amounts, - BalancesResponse, codecForAbortPayWithRefundRequest, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, @@ -35,6 +34,7 @@ import { codecForAcceptPeerPushPaymentRequest, codecForAcceptTipRequest, codecForAddExchangeRequest, + codecForAddKnownBankAccounts, codecForAny, codecForApplyRefundFromPurchaseIdRequest, codecForApplyRefundRequest, @@ -44,6 +44,7 @@ import { codecForCreateDepositGroupRequest, codecForDeleteTransactionRequest, codecForForceRefreshRequest, + codecForForgetKnownBankAccounts, codecForGetContractTermsDetails, codecForGetExchangeTosRequest, codecForGetExchangeWithdrawalInfo, @@ -81,6 +82,7 @@ import { GetExchangeTosResult, j2s, KnownBankAccounts, + KnownBankAccountsInfo, Logger, ManualWithdrawalDetails, NotificationType, @@ -89,9 +91,6 @@ import { RefreshReason, TalerErrorCode, TalerErrorDetail, - KnownBankAccountsInfo, - codecForAddKnownBankAccounts, - codecForForgetKnownBankAccounts, URL, WalletCoreVersion, WalletNotification, @@ -125,6 +124,7 @@ import { MerchantOperations, NotificationListener, RecoupOperations, + RefreshOperations, } from "./internal-wallet-state.js"; import { exportBackup } from "./operations/backup/export.js"; import { @@ -143,6 +143,11 @@ import { import { setWalletDeviceId } from "./operations/backup/state.js"; import { getBalances } from "./operations/balance.js"; import { + runOperationWithErrorReporting, + storeOperationError, + storeOperationPending, +} from "./operations/common.js"; +import { createDepositGroup, getFeeForDeposit, prepareDepositGroup, @@ -162,12 +167,15 @@ import { } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; import { + abortFailedPayWithRefund, + applyRefund, + applyRefundFromPurchaseId, confirmPay, getContractTermsDetails, preparePayForUri, - processDownloadProposal, - processPurchasePay, -} from "./operations/pay.js"; + prepareRefund, + processPurchase, +} from "./operations/pay-merchant.js"; import { acceptPeerPullPayment, acceptPeerPushPayment, @@ -175,7 +183,7 @@ import { checkPeerPushPayment, initiatePeerRequestForPay, initiatePeerToPeerPush, -} from "./operations/peer-to-peer.js"; +} from "./operations/pay-peer.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, @@ -188,13 +196,6 @@ import { processRefreshGroup, } from "./operations/refresh.js"; import { - abortFailedPayWithRefund, - applyRefund, - applyRefundFromPurchaseId, - prepareRefund, - processPurchaseQueryRefund, -} from "./operations/refund.js"; -import { runIntegrationTest, testPay, withdrawTestBalance, @@ -213,13 +214,8 @@ import { getWithdrawalDetailsForUri, processWithdrawalGroup, } from "./operations/withdraw.js"; -import { - PendingOperationsResponse, - PendingTaskInfo, - PendingTaskType, -} from "./pending-types.js"; +import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; -import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js"; import { createDenominationTimeline } from "./util/denominations.js"; import { HttpRequestLibrary, @@ -306,18 +302,10 @@ async function callOperationHandler( return await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow, }); - case PendingTaskType.ProposalDownload: - return await processDownloadProposal(ws, pending.proposalId, { - forceNow, - }); case PendingTaskType.TipPickup: return await processTip(ws, pending.tipId, { forceNow }); - case PendingTaskType.Pay: - return await processPurchasePay(ws, pending.proposalId, { forceNow }); - case PendingTaskType.RefundQuery: - return await processPurchaseQueryRefund(ws, pending.proposalId, { - forceNow, - }); + case PendingTaskType.Purchase: + return await processPurchase(ws, pending.proposalId, { forceNow }); case PendingTaskType.Recoup: return await processRecoupGroupHandler(ws, pending.recoupGroupId, { forceNow, @@ -337,111 +325,6 @@ async function callOperationHandler( throw Error(`not reached ${pending.type}`); } -export async function storeOperationError( - ws: InternalWalletState, - pendingTaskId: string, - e: TalerErrorDetail, -): Promise<void> { - await ws.db - .mktx((x) => [x.operationRetries]) - .runReadWrite(async (tx) => { - let retryRecord = await tx.operationRetries.get(pendingTaskId); - if (!retryRecord) { - retryRecord = { - id: pendingTaskId, - lastError: e, - retryInfo: RetryInfo.reset(), - }; - } else { - retryRecord.lastError = e; - retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); - } - await tx.operationRetries.put(retryRecord); - }); -} - -export async function storeOperationFinished( - ws: InternalWalletState, - pendingTaskId: string, -): Promise<void> { - await ws.db - .mktx((x) => [x.operationRetries]) - .runReadWrite(async (tx) => { - await tx.operationRetries.delete(pendingTaskId); - }); -} - -export async function storeOperationPending( - ws: InternalWalletState, - pendingTaskId: string, -): Promise<void> { - await ws.db - .mktx((x) => [x.operationRetries]) - .runReadWrite(async (tx) => { - let retryRecord = await tx.operationRetries.get(pendingTaskId); - if (!retryRecord) { - retryRecord = { - id: pendingTaskId, - retryInfo: RetryInfo.reset(), - }; - } else { - delete retryRecord.lastError; - retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); - } - await tx.operationRetries.put(retryRecord); - }); -} - -export async function runOperationWithErrorReporting( - ws: InternalWalletState, - opId: string, - f: () => Promise<OperationAttemptResult>, -): Promise<void> { - let maybeError: TalerErrorDetail | undefined; - try { - const resp = await f(); - switch (resp.type) { - case OperationAttemptResultType.Error: - return await storeOperationError(ws, opId, resp.errorDetail); - case OperationAttemptResultType.Finished: - return await storeOperationFinished(ws, opId); - case OperationAttemptResultType.Pending: - return await storeOperationPending(ws, opId); - case OperationAttemptResultType.Longpoll: - break; - } - } catch (e) { - if (e instanceof TalerError) { - logger.warn("operation processed resulted in error"); - logger.warn(`error was: ${j2s(e.errorDetail)}`); - maybeError = e.errorDetail; - return await storeOperationError(ws, opId, maybeError!); - } else if (e instanceof Error) { - // This is a bug, as we expect pending operations to always - // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED - // or return something. - logger.error(`Uncaught exception: ${e.message}`); - logger.error(`Stack: ${e.stack}`); - maybeError = makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - { - stack: e.stack, - }, - `unexpected exception (message: ${e.message})`, - ); - return await storeOperationError(ws, opId, maybeError); - } else { - logger.error("Uncaught exception, value is not even an error."); - maybeError = makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - {}, - `unexpected exception (not even an error)`, - ); - return await storeOperationError(ws, opId, maybeError); - } - } -} - /** * Process pending operations. */ @@ -857,120 +740,6 @@ async function getExchangeDetailedInfo( }; } -export async function makeCoinAvailable( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - coinAvailability: typeof WalletStoresV1.coinAvailability; - denominations: typeof WalletStoresV1.denominations; - }>, - coinRecord: CoinRecord, -): Promise<void> { - checkLogicInvariant(coinRecord.status === CoinStatus.Fresh); - const existingCoin = await tx.coins.get(coinRecord.coinPub); - if (existingCoin) { - return; - } - const denom = await tx.denominations.get([ - coinRecord.exchangeBaseUrl, - coinRecord.denomPubHash, - ]); - checkDbInvariant(!!denom); - const ageRestriction = coinRecord.maxAge; - let car = await tx.coinAvailability.get([ - coinRecord.exchangeBaseUrl, - coinRecord.denomPubHash, - ageRestriction, - ]); - if (!car) { - car = { - maxAge: ageRestriction, - amountFrac: denom.amountFrac, - amountVal: denom.amountVal, - currency: denom.currency, - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - freshCoinCount: 0, - }; - } - car.freshCoinCount++; - await tx.coins.put(coinRecord); - await tx.coinAvailability.put(car); -} - -export interface CoinsSpendInfo { - coinPubs: string[]; - contributions: AmountJson[]; - refreshReason: RefreshReason; - /** - * Identifier for what the coin has been spent for. - */ - allocationId: string; -} - -export async function spendCoins( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - coinAvailability: typeof WalletStoresV1.coinAvailability; - refreshGroups: typeof WalletStoresV1.refreshGroups; - denominations: typeof WalletStoresV1.denominations; - }>, - csi: CoinsSpendInfo, -): Promise<void> { - for (let i = 0; i < csi.coinPubs.length; i++) { - const coin = await tx.coins.get(csi.coinPubs[i]); - if (!coin) { - throw Error("coin allocated for payment doesn't exist anymore"); - } - const coinAvailability = await tx.coinAvailability.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - coin.maxAge, - ]); - checkDbInvariant(!!coinAvailability); - const contrib = csi.contributions[i]; - if (coin.status !== CoinStatus.Fresh) { - const alloc = coin.allocation; - if (!alloc) { - continue; - } - if (alloc.id !== csi.allocationId) { - // FIXME: assign error code - throw Error("conflicting coin allocation (id)"); - } - if (0 !== Amounts.cmp(alloc.amount, contrib)) { - // FIXME: assign error code - throw Error("conflicting coin allocation (contrib)"); - } - continue; - } - coin.status = CoinStatus.Dormant; - coin.allocation = { - id: csi.allocationId, - amount: Amounts.stringify(contrib), - }; - const remaining = Amounts.sub(coin.currentAmount, contrib); - if (remaining.saturated) { - throw Error("not enough remaining balance on coin for payment"); - } - coin.currentAmount = remaining.amount; - checkDbInvariant(!!coinAvailability); - if (coinAvailability.freshCoinCount === 0) { - throw Error( - `invalid coin count ${coinAvailability.freshCoinCount} in DB`, - ); - } - coinAvailability.freshCoinCount--; - await tx.coins.put(coin); - await tx.coinAvailability.put(coinAvailability); - } - const refreshCoinPubs = csi.coinPubs.map((x) => ({ - coinPub: x, - })); - await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant); -} - async function setCoinSuspended( ws: InternalWalletState, coinPub: string, @@ -1649,6 +1418,10 @@ class InternalWalletStateImpl implements InternalWalletState { getMerchantInfo, }; + refreshOps: RefreshOperations = { + createRefreshGroup, + }; + // FIXME: Use an LRU cache here. private denomCache: Record<string, DenominationInfo> = {}; |