diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 483a9e7ce..3e9d993d8 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -231,6 +231,15 @@ walletCli }); }); +walletCli + .subcommand("", "transactions", { help: "Show transactions." }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + const pending = await wallet.getTransactions({}); + console.log(JSON.stringify(pending, undefined, 2)); + }); + }); + async function asyncSleep(milliSeconds: number): Promise { return new Promise((resolve, reject) => { setTimeout(() => resolve(), milliSeconds); diff --git a/src/operations/history.ts b/src/operations/history.ts index 1271c56ef..4e43596f0 100644 --- a/src/operations/history.ts +++ b/src/operations/history.ts @@ -375,10 +375,10 @@ export async function getHistory( return; } let reserveCreationDetail: ReserveCreationDetail; - if (reserve.bankWithdrawStatusUrl) { + if (reserve.bankInfo) { reserveCreationDetail = { type: ReserveType.TalerBankWithdraw, - bankUrl: reserve.bankWithdrawStatusUrl, + bankUrl: reserve.bankInfo.statusUrl, }; } else { reserveCreationDetail = { diff --git a/src/operations/pending.ts b/src/operations/pending.ts index 14072633c..c793f5f0a 100644 --- a/src/operations/pending.ts +++ b/src/operations/pending.ts @@ -150,7 +150,7 @@ async function gatherReservePending( ): Promise { // FIXME: this should be optimized by using an index for "onlyDue==true". await tx.iter(Stores.reserves).forEach((reserve) => { - const reserveType = reserve.bankWithdrawStatusUrl + const reserveType = reserve.bankInfo ? ReserveType.TalerBankWithdraw : ReserveType.Manual; if (!reserve.retryInfo.active) { diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts index 2bbb085d5..347f6e894 100644 --- a/src/operations/reserves.ts +++ b/src/operations/reserves.ts @@ -108,7 +108,14 @@ export async function createReserve( senderWire: req.senderWire, timestampConfirmed: undefined, timestampReserveInfoPosted: undefined, - bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, + bankInfo: req.bankWithdrawStatusUrl + ? { + statusUrl: req.bankWithdrawStatusUrl, + amount: req.amount, + bankWithdrawalGroupId: encodeCrock(getRandomBytes(32)), + withdrawalStarted: false, + } + : undefined, exchangeWire: req.exchangeWire, reserveStatus, lastSuccessfulStatusQuery: undefined, @@ -173,10 +180,10 @@ export async function createReserve( ], async (tx) => { // Check if we have already created a reserve for that bankWithdrawStatusUrl - if (reserveRecord.bankWithdrawStatusUrl) { + if (reserveRecord.bankInfo?.statusUrl) { const bwi = await tx.get( Stores.bankWithdrawUris, - reserveRecord.bankWithdrawStatusUrl, + reserveRecord.bankInfo.statusUrl, ); if (bwi) { const otherReserve = await tx.get(Stores.reserves, bwi.reservePub); @@ -192,7 +199,7 @@ export async function createReserve( } await tx.put(Stores.bankWithdrawUris, { reservePub: reserveRecord.reservePub, - talerWithdrawUri: reserveRecord.bankWithdrawStatusUrl, + talerWithdrawUri: reserveRecord.bankInfo.statusUrl, }); } await tx.put(Stores.currencies, cr); @@ -279,7 +286,7 @@ async function registerReserveWithBank( default: return; } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; + const bankStatusUrl = reserve.bankInfo?.statusUrl; if (!bankStatusUrl) { return; } @@ -333,7 +340,7 @@ async function processReserveBankStatusImpl( default: return; } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; + const bankStatusUrl = reserve.bankInfo?.statusUrl; if (!bankStatusUrl) { return; } @@ -382,7 +389,9 @@ async function processReserveBankStatusImpl( default: return; } - r.bankWithdrawConfirmUrl = status.confirm_transfer_url; + if (r.bankInfo) { + r.bankInfo.confirmUrl = status.confirm_transfer_url; + } return r; }); await incrementReserveRetry(ws, reservePub, undefined); @@ -673,35 +682,7 @@ async function depleteReserve( logger.trace("selected denominations"); - const withdrawalGroupId = encodeCrock(randomBytes(32)); - - logger.trace("created plachets"); - - const withdrawalRecord: WithdrawalGroupRecord = { - withdrawalGroupId: withdrawalGroupId, - exchangeBaseUrl: reserve.exchangeBaseUrl, - source: { - type: WithdrawalSourceType.Reserve, - reservePub: reserve.reservePub, - }, - rawWithdrawalAmount: withdrawAmount, - timestampStart: getTimestampNow(), - retryInfo: initRetryInfo(), - lastErrorPerCoin: {}, - lastError: undefined, - denomsSel: { - totalCoinValue: denomsForWithdraw.totalCoinValue, - totalWithdrawCost: denomsForWithdraw.totalWithdrawCost, - selectedDenoms: denomsForWithdraw.selectedDenoms.map((x) => { - return { - count: x.count, - denomPubHash: x.denom.denomPubHash, - }; - }), - }, - }; - - const success = await ws.db.runWithWriteTransaction( + const newWithdrawalGroup = await ws.db.runWithWriteTransaction( [ Stores.withdrawalGroups, Stores.reserves, @@ -748,20 +729,55 @@ async function depleteReserve( } newReserve.reserveStatus = ReserveRecordStatus.DORMANT; newReserve.retryInfo = initRetryInfo(false); + + let withdrawalGroupId: string; + + const bankInfo = newReserve.bankInfo; + if (bankInfo && !bankInfo.withdrawalStarted) { + withdrawalGroupId = bankInfo.bankWithdrawalGroupId; + bankInfo.withdrawalStarted = true; + } else { + withdrawalGroupId = encodeCrock(randomBytes(32)); + } + + const withdrawalRecord: WithdrawalGroupRecord = { + withdrawalGroupId: withdrawalGroupId, + exchangeBaseUrl: newReserve.exchangeBaseUrl, + source: { + type: WithdrawalSourceType.Reserve, + reservePub: newReserve.reservePub, + }, + rawWithdrawalAmount: withdrawAmount, + timestampStart: getTimestampNow(), + retryInfo: initRetryInfo(), + lastErrorPerCoin: {}, + lastError: undefined, + denomsSel: { + totalCoinValue: denomsForWithdraw.totalCoinValue, + totalWithdrawCost: denomsForWithdraw.totalWithdrawCost, + selectedDenoms: denomsForWithdraw.selectedDenoms.map((x) => { + return { + count: x.count, + denomPubHash: x.denom.denomPubHash, + }; + }), + }, + }; + await tx.put(Stores.reserves, newReserve); await tx.put(Stores.reserveHistory, newHist); await tx.put(Stores.withdrawalGroups, withdrawalRecord); - return true; + return withdrawalRecord; }, ); - if (success) { + if (newWithdrawalGroup) { console.log("processing new withdraw group"); ws.notify({ type: NotificationType.WithdrawGroupCreated, - withdrawalGroupId: withdrawalGroupId, + withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId, }); - await processWithdrawGroup(ws, withdrawalGroupId); + await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId); } else { console.trace("withdraw session already existed"); } diff --git a/src/operations/transactions.ts b/src/operations/transactions.ts new file mode 100644 index 000000000..8333b66c6 --- /dev/null +++ b/src/operations/transactions.ts @@ -0,0 +1,130 @@ +/* + This file is part of GNU Taler + (C) 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 + */ + +/** + * Imports. + */ +import { InternalWalletState } from "./state"; +import { Stores, ProposalRecord, ReserveRecordStatus } from "../types/dbTypes"; +import { Amounts } from "../util/amounts"; +import { timestampCmp } from "../util/time"; +import { + TransactionsRequest, + TransactionsResponse, + Transaction, + TransactionType, +} from "../types/transactions"; +import { OrderShortInfo } from "../types/history"; + +/** + * Create an event ID from the type and the primary key for the event. + */ +function makeEventId(type: TransactionType, ...args: string[]): string { + return type + ";" + args.map((x) => encodeURIComponent(x)).join(";"); +} + +function getOrderShortInfo( + proposal: ProposalRecord, +): OrderShortInfo | undefined { + const download = proposal.download; + if (!download) { + return undefined; + } + return { + amount: Amounts.stringify(download.contractData.amount), + fulfillmentUrl: download.contractData.fulfillmentUrl, + orderId: download.contractData.orderId, + merchantBaseUrl: download.contractData.merchantBaseUrl, + proposalId: proposal.proposalId, + summary: download.contractData.summary, + }; +} + +/** + * Retrive the full event history for this wallet. + */ +export async function getTransactions( + ws: InternalWalletState, + transactionsRequest?: TransactionsRequest, +): Promise { + const transactions: Transaction[] = []; + + await ws.db.runWithReadTransaction( + [ + Stores.currencies, + Stores.coins, + Stores.denominations, + Stores.proposals, + Stores.purchases, + Stores.refreshGroups, + Stores.reserves, + Stores.reserveHistory, + Stores.tips, + Stores.withdrawalGroups, + Stores.payEvents, + Stores.planchets, + Stores.refundEvents, + Stores.reserveUpdatedEvents, + Stores.recoupGroups, + ], + async (tx) => { + tx.iter(Stores.withdrawalGroups).forEach((wsr) => { + if (wsr.timestampFinish) { + transactions.push({ + type: TransactionType.Withdrawal, + amountEffective: Amounts.stringify(wsr.denomsSel.totalWithdrawCost), + amountRaw: Amounts.stringify(wsr.denomsSel.totalCoinValue), + confirmed: true, + exchangeBaseUrl: wsr.exchangeBaseUrl, + pending: !wsr.timestampFinish, + timestamp: wsr.timestampStart, + transactionId: makeEventId( + TransactionType.Withdrawal, + wsr.withdrawalGroupId, + ), + }); + } + }); + + tx.iter(Stores.reserves).forEach((r) => { + if (r.reserveStatus !== ReserveRecordStatus.WAIT_CONFIRM_BANK) { + return; + } + if (!r.bankInfo) { + return; + } + transactions.push({ + type: TransactionType.Withdrawal, + confirmed: false, + amountRaw: Amounts.stringify(r.bankInfo.amount), + amountEffective: undefined, + exchangeBaseUrl: undefined, + pending: true, + timestamp: r.timestampCreated, + bankConfirmationUrl: r.bankInfo.confirmUrl, + transactionId: makeEventId( + TransactionType.Withdrawal, + r.bankInfo.bankWithdrawalGroupId, + ), + }); + }); + }, + ); + + transactions.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp)); + + return { transactions }; +} diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts index 4cf19a56e..07c59d4d3 100644 --- a/src/types/dbTypes.ts +++ b/src/types/dbTypes.ts @@ -273,13 +273,17 @@ export interface ReserveRecord { */ exchangeWire: string; - bankWithdrawStatusUrl?: string; - /** - * URL that the bank gave us to redirect the customer - * to in order to confirm a withdrawal. + * Extra state for when this is a withdrawal involving + * a Taler-integrated bank. */ - bankWithdrawConfirmUrl?: string; + bankInfo?: { + statusUrl: string; + confirmUrl?: string; + amount: AmountJson; + bankWithdrawalGroupId: string; + withdrawalStarted: boolean; + }; reserveStatus: ReserveRecordStatus; diff --git a/src/types/transactions.ts b/src/types/transactions.ts new file mode 100644 index 000000000..d2f0f6cbc --- /dev/null +++ b/src/types/transactions.ts @@ -0,0 +1,208 @@ +/* + This file is part of GNU Taler + (C) 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 + */ + +/** + * Type and schema definitions for the wallet's transaction list. + */ + +/** + * Imports. + */ +import { Timestamp } from "../util/time"; +import { AmountString } from "./talerTypes"; + +export interface TransactionsRequest { + /** + * return only transactions in the given currency + */ + currency?: string; + + /** + * if present, results will be limited to transactions related to the given search string + */ + search?: string; +} + +export interface TransactionsResponse { + // a list of past and pending transactions sorted by pending, timestamp and transactionId. + // In case two events are both pending and have the same timestamp, + // they are sorted by the transactionId + // (lexically ascending and locale-independent comparison). + transactions: Transaction[]; +} + +export interface TransactionCommon { + // opaque unique ID for the transaction, used as a starting point for paginating queries + // and for invoking actions on the transaction (e.g. deleting/hiding it from the history) + transactionId: string; + + // the type of the transaction; different types might provide additional information + type: TransactionType; + + // main timestamp of the transaction + timestamp: Timestamp; + + // true if the transaction is still pending, false otherwise + // If a transaction is not longer pending, its timestamp will be updated, + // but its transactionId will remain unchanged + pending: boolean; + + // Raw amount of the transaction (exclusive of fees or other extra costs) + amountRaw: AmountString; + + // Amount added or removed from the wallet's balance (including all fees and other costs) + amountEffective?: AmountString; +} + +export type Transaction = ( + TransactionWithdrawal | + TransactionPayment | + TransactionRefund | + TransactionTip | + TransactionRefresh +) + +export const enum TransactionType { + Withdrawal = "withdrawal", + Payment = "payment", + Refund = "refund", + Refresh = "refresh", + Tip = "tip", +} + +// This should only be used for actual withdrawals +// and not for tips that have their own transactions type. +interface TransactionWithdrawal extends TransactionCommon { + type: TransactionType.Withdrawal; + + /** + * Exchange of the withdrawal. + */ + exchangeBaseUrl?: string; + + // true if the bank has confirmed the withdrawal, false if not. + // An unconfirmed withdrawal usually requires user-input and should be highlighted in the UI. + // See also bankConfirmationUrl below. + confirmed: boolean; + + // If the withdrawal is unconfirmed, this can include a URL for user initiated confirmation. + bankConfirmationUrl?: string; + + // Amount that has been subtracted from the reserve's balance for this withdrawal. + amountRaw: AmountString; + + /** + * Amount that actually was (or will be) added to the wallet's balance. + * Only present if an exchange has already been selected. + */ + amountEffective?: AmountString; +} + +interface TransactionPayment extends TransactionCommon { + type: TransactionType.Payment; + + // Additional information about the payment. + info: TransactionInfo; + + // true if the payment failed, false otherwise. + // Note that failed payments with zero effective amount will not be returned by the API. + failed: boolean; + + // Amount that must be paid for the contract + amountRaw: AmountString; + + // Amount that was paid, including deposit, wire and refresh fees. + amountEffective: AmountString; +} + + +interface TransactionInfo { + // Order ID, uniquely identifies the order within a merchant instance + orderId: string; + + // More information about the merchant + merchant: any; + + // Summary of the order, given by the merchant + summary: string; + + // Map from IETF BCP 47 language tags to localized summaries + summary_i18n?: { [lang_tag: string]: string }; + + // List of products that are part of the order + products: any[]; + + // URL of the fulfillment, given by the merchant + fulfillmentUrl: string; +} + + +interface TransactionRefund extends TransactionCommon { + type: TransactionType.Refund; + + // ID for the transaction that is refunded + refundedTransactionId: string; + + // Additional information about the refunded payment + info: TransactionInfo; + + // Part of the refund that couldn't be applied because the refund permissions were expired + amountInvalid: AmountString; + + // Amount that has been refunded by the merchant + amountRaw: AmountString; + + // Amount will be added to the wallet's balance after fees and refreshing + amountEffective: AmountString; +} + +interface TransactionTip extends TransactionCommon { + type: TransactionType.Tip; + + // true if the user still needs to accept/decline this tip + waiting: boolean; + + // true if the user has accepted this top, false otherwise + accepted: boolean; + + // Exchange that the tip will be (or was) withdrawn from + exchangeBaseUrl: string; + + // More information about the merchant that sent the tip + merchant: any; + + // Raw amount of the tip, without extra fees that apply + amountRaw: AmountString; + + // Amount will be (or was) added to the wallet's balance after fees and refreshing + amountEffective: AmountString; +} + +// A transaction shown for refreshes that are not associated to other transactions +// such as a refresh necessary before coin expiration. +// It should only be returned by the API if the effective amount is different from zero. +interface TransactionRefresh extends TransactionCommon { + type: TransactionType.Refresh; + + // Exchange that the coins are refreshed with + exchangeBaseUrl: string; + + // Raw amount that is refreshed + amountRaw: AmountString; + + // Amount that will be paid as fees for the refresh + amountEffective: AmountString; +} \ No newline at end of file diff --git a/src/wallet.ts b/src/wallet.ts index 3558e102b..2d63e2298 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -112,6 +112,8 @@ import { import { durationMin, Duration } from "./util/time"; import { processRecoupGroup } from "./operations/recoup"; import { OperationFailedAndReportedError } from "./operations/errors"; +import { TransactionsRequest, TransactionsResponse } from "./types/transactions"; +import { getTransactions } from "./operations/transactions"; const builtinCurrencies: CurrencyRecord[] = [ { @@ -815,4 +817,8 @@ export class Wallet { } return coinsJson; } + + async getTransactions(request: TransactionsRequest): Promise { + return getTransactions(this.ws, request); + } }