diff --git a/src/app/accounts/get-transactions-for-account.ts b/src/app/accounts/get-transactions-for-account.ts index 7ab4dcc64..44b9142af 100644 --- a/src/app/accounts/get-transactions-for-account.ts +++ b/src/app/accounts/get-transactions-for-account.ts @@ -1,34 +1,38 @@ -import { AccountValidator } from "@domain/accounts" -import { RepositoryError } from "@domain/errors" -import { WalletsRepository } from "@services/mongoose" - -import { getTransactionsForWallets } from "../wallets" -import { PartialResult } from "../partial-result" - -export const getTransactionsForAccountByWalletIds = async ({ - account, - walletIds, - paginationArgs, -}: { - account: Account - walletIds: WalletId[] - paginationArgs?: PaginationArgs -}): Promise>> => { - const walletsRepo = WalletsRepository() - - const wallets: Wallet[] = [] - for (const walletId of walletIds) { - const wallet = await walletsRepo.findById(walletId) - if (wallet instanceof RepositoryError) return PartialResult.err(wallet) - - const accountValidator = AccountValidator(account) - const isActive = accountValidator.isActive() - if (isActive instanceof Error) return PartialResult.err(isActive) - const validateWallet = accountValidator.validateWalletForAccount(wallet) - if (validateWallet instanceof Error) return PartialResult.err(validateWallet) - - wallets.push(wallet) - } - - return getTransactionsForWallets({ wallets, paginationArgs }) -} +import { AccountValidator } from "@domain/accounts" +import { RepositoryError } from "@domain/errors" +import { WalletsRepository } from "@services/mongoose" + +import { getTransactionsForWallets } from "../wallets" +import { PartialResult } from "../partial-result" + +export const getTransactionsForAccountByWalletIds = async ({ + account, + walletIds, + paginationArgs, +}: { + account: Account + walletIds: WalletId[] + paginationArgs?: PaginationArgs +}): Promise>> => { + const walletsRepo = WalletsRepository() + + const wallets: Wallet[] = [] + for (const walletId of walletIds) { + const wallet = await walletsRepo.findById(walletId) + if (wallet instanceof RepositoryError) return PartialResult.err(wallet) + + const accountValidator = AccountValidator(account) + const isActive = accountValidator.isActive() + if (isActive instanceof Error) return PartialResult.err(isActive) + const validateWallet = accountValidator.validateWalletForAccount(wallet) + if (validateWallet instanceof Error) return PartialResult.err(validateWallet) + + wallets.push(wallet) + } + + return getTransactionsForWallets({ + wallets, + paginationArgs, + displayCurrency: account.displayCurrency, + }) +} diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index f4dfdb186..4fcad117d 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -1,236 +1,287 @@ -import { PartialResult } from "@app/partial-result" -import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" -import Ibex from "@services/ibex/client" -import { IbexError } from "@services/ibex/errors" -import { baseLogger } from "@services/logger" -import { GResponse200 } from "ibex-client" -import { ConnectionArguments } from "graphql-relay" - -export const getTransactionsForWallets = async ({ - wallets, - paginationArgs, -}: { - wallets: Wallet[] - paginationArgs?: PaginationArgs -}): Promise>> => { - const walletIds = wallets.map((wallet) => wallet.id) - - const ibexCalls = await Promise.all( - walletIds.map((id) => - Ibex.getAccountTransactions({ - account_id: id, - ...toIbexPaginationArgs(paginationArgs), - }), - ), - ) - - const transactions = ibexCalls.flatMap((resp) => { - if (resp instanceof IbexError) return [] - else return toWalletTransactions(resp) - }) - - return PartialResult.ok({ - slice: transactions, - total: transactions.length, - }) -} - -const currencyFromIbexCurrencyId = ( - currencyId: number | undefined, -): WalletCurrency | undefined => { - if (currencyId === USDAmount.currencyId) return WalletCurrency.Usd - if (currencyId === USDTAmount.currencyId) return WalletCurrency.Usdt - return undefined -} - -export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] => { - return ibexResp.flatMap((trx) => { - const currency = currencyFromIbexCurrencyId(trx.currencyId) - - if (!currency) { - baseLogger.error( - `Failed to parse Ibex transaction currency. Excluding transaction from list. { WalletId: ${trx.accountId}, TransactionId: ${trx.id}, currencyId: ${trx.currencyId} }`, - ) - // A row with an unrecognized currency cannot be represented truthfully; - // excluding it beats fabricating a USD row or emitting a source the - // NonNull initiationVia/settlementVia unions cannot resolve (which fails - // the whole transaction list query for the account). - return [] - } - - const settlementDisplayPrice: WalletMinorUnitDisplayPrice< - WalletCurrency, - DisplayCurrency - > = { - base: trx.exchangeRateCurrencySats - ? BigInt(Math.floor(trx.exchangeRateCurrencySats)) - : 0n, - offset: 0n, // what is this? - displayCurrency: "USD" as DisplayCurrency, - walletCurrency: currency, - } - - const baseTrx: BaseWalletTransaction = { - walletId: (trx.accountId || "") as WalletId, - settlementAmount: toSettlementAmount(trx.amount, trx.transactionTypeId, currency), - settlementFee: toSettlementMinorUnit(trx.networkFee, currency), - settlementCurrency: currency, - settlementDisplayAmount: toSettlementDisplayAmount( - trx.amount, - trx.transactionTypeId, - ), - settlementDisplayFee: `${trx.networkFee}`, - settlementDisplayPrice: settlementDisplayPrice, - createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), // should always return - id: trx.id || "null", // "LedgerTransactionId" - this is likely unused - status: "success" as TxStatus, // assuming Ibex returns on completed - memo: null, // query transaction details - } - - switch (trx.transactionTypeId) { - case 1: - case 2: - return { - ...baseTrx, - // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, - // we need to query the transaction details for each trx individually. - initiationVia: { type: "lightning", paymentHash: "", pubkey: "" }, - settlementVia: { type: "lightning", revealedPreImage: undefined }, - } as WalletLnSettledTransaction - // IBEX type ids per GET /v2/transaction-types/all: 1/2 Lightning - // receive/send, 3/4 On-Chain receive/send, 5 Fund, 6 Defund, 7 Bank - // Deposit, 8 Bank Withdrawal, 9/10 Crypto receive/send (USDT), 11/12 - // Taproot receive/send. Bank/funding ops (5-8) are org-level and - // intentionally left to the logged fallback below. - case 3: - case 4: - case 9: - case 10: - case 11: - case 12: - return { - ...baseTrx, - // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, - // we need to query the transaction details for each trx individually. - initiationVia: { type: "onchain", address: "" }, - settlementVia: { type: "onchain", transactionHash: "", vout: undefined }, - } as WalletOnChainSettledTransaction // assuming Ibex only gives us settled - default: - baseLogger.error( - `Failed to parse Ibex transaction type. Rendering as intraledger. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId} }`, - ) - // Amounts and currency are valid even when the IBEX type id is not - // recognized; render as a generic intraledger transaction (all union - // fields nullable) rather than hiding money movement or emitting a - // source the unions cannot resolve. Accepted caveat: the sign - // convention (send = negative) is only known for recognized type ids, - // so an unrecognized send type renders positive until its id is - // added to the switch above. - return { - ...baseTrx, - initiationVia: { - type: "intraledger", - counterPartyWalletId: undefined, - counterPartyUsername: undefined, - }, - settlementVia: { - type: "intraledger", - counterPartyWalletId: undefined, - counterPartyUsername: null, - }, - } as IntraLedgerTransaction - } - }) -} - -type SettlementMinorUnitAmount = Satoshis | UsdCents | UsdtCents - -const toUsdtCents = (amount: number): UsdtCents => { - const usdtAmount = USDTAmount.fromNumber(amount.toString()) - if (usdtAmount instanceof Error) { - baseLogger.error({ err: usdtAmount, amount }, "Failed to parse IBEX USDT amount") - return 0 as UsdtCents - } - return Number(usdtAmount.asUsdCents()) as UsdtCents -} - -const zeroSettlementMinorUnit = (currency: WalletCurrency): SettlementMinorUnitAmount => { - if (currency === WalletCurrency.Usd) return 0 as UsdCents - if (currency === WalletCurrency.Usdt) return 0 as UsdtCents - return 0 as Satoshis -} - -const toSettlementMinorUnit = ( - amount: number | undefined, - currency: WalletCurrency, -): SettlementMinorUnitAmount => { - if (amount === undefined) return zeroSettlementMinorUnit(currency) - if (currency === WalletCurrency.Usd) return amount as UsdCents - if (currency === WalletCurrency.Usdt) return toUsdtCents(amount) - return amount as Satoshis -} - -// Sends render negative: Lightning send (2), On-Chain send (4), Crypto send -// (10), Taproot send (12). -const IBEX_SEND_TYPE_IDS = [2, 4, 10, 12] - -const isIbexSendType = (transactionTypeId: number | undefined): boolean => - transactionTypeId !== undefined && IBEX_SEND_TYPE_IDS.includes(transactionTypeId) - -const toSettlementAmount = ( - ibexAmount: number | undefined, - transactionTypeId: number | undefined, - currency: WalletCurrency, -): SettlementMinorUnitAmount => { - if (ibexAmount === undefined) { - baseLogger.warn("Ibex did not return transaction amount") - return toSettlementMinorUnit(ibexAmount, currency) - } - const amt = isIbexSendType(transactionTypeId) ? -1 * ibexAmount : ibexAmount - return toSettlementMinorUnit(amt, currency) -} - -const toSettlementDisplayAmount = ( - ibexAmount: number | undefined, - transactionTypeId: number | undefined, -): string => { - if (ibexAmount === undefined) return `${ibexAmount}` - const amount = isIbexSendType(transactionTypeId) ? -1 * ibexAmount : ibexAmount - return `${amount}` -} - -enum SortOrder { - RECENT = "settledAt", - OLDEST = "-settledAt", -} - -type IbexPaginationArgs = { - page?: number | undefined // ibex default (0) start at page 0 - limit?: number | undefined // ibex default (0) returns all - sort?: SortOrder | undefined // defaults to SortOrder.RECENT -} - -export function toIbexPaginationArgs( - args: ConnectionArguments | undefined, -): IbexPaginationArgs { - const DEFAULTS = { - page: 0, - limit: 0, - sort: SortOrder.RECENT, - } - - // Prefer 'first' over 'last') - if (args && args.first != null) { - return { - ...DEFAULTS, - limit: args.first, - sort: SortOrder.RECENT, - } - } else if (args && args.last != null) { - return { - ...DEFAULTS, - limit: args.last, - sort: SortOrder.OLDEST, - } - } else return DEFAULTS -} +import { PartialResult } from "@app/partial-result" +import { + checkedToDisplayCurrency, + getCurrencyMajorExponent, + UsdDisplayCurrency, +} from "@domain/fiat" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" +import { baseLogger } from "@services/logger" +import { GResponse200 } from "ibex-client" +import { ConnectionArguments } from "graphql-relay" + +export const getTransactionsForWallets = async ({ + wallets, + paginationArgs, + displayCurrency, +}: { + wallets: Wallet[] + paginationArgs?: PaginationArgs + // The account's configured display currency (e.g. "JMD"). Callers that have + // an account in hand pass it so history renders in the currency the user + // picked; it is optional because the CSV export path has no account context. + displayCurrency?: DisplayCurrency +}): Promise>> => { + const walletIds = wallets.map((wallet) => wallet.id) + + const ibexCalls = await Promise.all( + walletIds.map((id) => + Ibex.getAccountTransactions({ + account_id: id, + ...toIbexPaginationArgs(paginationArgs), + }), + ), + ) + + const transactions = ibexCalls.flatMap((resp) => { + if (resp instanceof IbexError) return [] + else return toWalletTransactions(resp, displayCurrency) + }) + + return PartialResult.ok({ + slice: transactions, + total: transactions.length, + }) +} + +const currencyFromIbexCurrencyId = ( + currencyId: number | undefined, +): WalletCurrency | undefined => { + if (currencyId === USDAmount.currencyId) return WalletCurrency.Usd + if (currencyId === USDTAmount.currencyId) return WalletCurrency.Usdt + return undefined +} + +export const toWalletTransactions = ( + ibexResp: GResponse200, + rawDisplayCurrency?: DisplayCurrency, +): IbexTransaction[] => { + const displayCurrency = resolveDisplayCurrency(rawDisplayCurrency) + + return ibexResp.flatMap((trx) => { + const currency = currencyFromIbexCurrencyId(trx.currencyId) + + if (!currency) { + baseLogger.error( + `Failed to parse Ibex transaction currency. Excluding transaction from list. { WalletId: ${trx.accountId}, TransactionId: ${trx.id}, currencyId: ${trx.currencyId} }`, + ) + // A row with an unrecognized currency cannot be represented truthfully; + // excluding it beats fabricating a USD row or emitting a source the + // NonNull initiationVia/settlementVia unions cannot resolve (which fails + // the whole transaction list query for the account). + return [] + } + + const exchangeRate = toExchangeRate(trx.exchangeRateCurrencySats) + + const settlementDisplayPrice: WalletMinorUnitDisplayPrice< + WalletCurrency, + DisplayCurrency + > = { + // Ibex sends the rate that settled this one transaction, so the price is + // read per row: that keeps each transaction paired with the rate it + // actually settled at instead of a rate from elsewhere on the page. + base: BigInt(Math.round(exchangeRate * 10 ** exponentFor(displayCurrency))), + offset: BigInt(exponentFor(displayCurrency)), + displayCurrency, + walletCurrency: currency, + } + + const baseTrx: BaseWalletTransaction = { + walletId: (trx.accountId || "") as WalletId, + settlementAmount: toSettlementAmount(trx.amount, trx.transactionTypeId, currency), + settlementFee: toSettlementMinorUnit(trx.networkFee, currency), + settlementCurrency: currency, + settlementDisplayAmount: toSettlementDisplayAmount( + trx.amount, + trx.transactionTypeId, + exchangeRate, + displayCurrency, + ), + settlementDisplayFee: `${trx.networkFee}`, + settlementDisplayPrice: settlementDisplayPrice, + createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), // should always return + id: trx.id || "null", // "LedgerTransactionId" - this is likely unused + status: "success" as TxStatus, // assuming Ibex returns on completed + memo: null, // query transaction details + } + + switch (trx.transactionTypeId) { + case 1: + case 2: + return { + ...baseTrx, + // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, + // we need to query the transaction details for each trx individually. + initiationVia: { type: "lightning", paymentHash: "", pubkey: "" }, + settlementVia: { type: "lightning", revealedPreImage: undefined }, + } as WalletLnSettledTransaction + // IBEX type ids per GET /v2/transaction-types/all: 1/2 Lightning + // receive/send, 3/4 On-Chain receive/send, 5 Fund, 6 Defund, 7 Bank + // Deposit, 8 Bank Withdrawal, 9/10 Crypto receive/send (USDT), 11/12 + // Taproot receive/send. Bank/funding ops (5-8) are org-level and + // intentionally left to the logged fallback below. + case 3: + case 4: + case 9: + case 10: + case 11: + case 12: + return { + ...baseTrx, + // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, + // we need to query the transaction details for each trx individually. + initiationVia: { type: "onchain", address: "" }, + settlementVia: { type: "onchain", transactionHash: "", vout: undefined }, + } as WalletOnChainSettledTransaction // assuming Ibex only gives us settled + default: + baseLogger.error( + `Failed to parse Ibex transaction type. Rendering as intraledger. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId} }`, + ) + // Amounts and currency are valid even when the IBEX type id is not + // recognized; render as a generic intraledger transaction (all union + // fields nullable) rather than hiding money movement or emitting a + // source the unions cannot resolve. Accepted caveat: the sign + // convention (send = negative) is only known for recognized type ids, + // so an unrecognized send type renders positive until its id is + // added to the switch above. + return { + ...baseTrx, + initiationVia: { + type: "intraledger", + counterPartyWalletId: undefined, + counterPartyUsername: undefined, + }, + settlementVia: { + type: "intraledger", + counterPartyWalletId: undefined, + counterPartyUsername: null, + }, + } as IntraLedgerTransaction + } + }) +} + +type SettlementMinorUnitAmount = Satoshis | UsdCents | UsdtCents + +// The account's configured display currency, or USD when the caller has none +// (the CSV export path) or passes something the scalar would reject. +const resolveDisplayCurrency = (raw?: DisplayCurrency): DisplayCurrency => { + const checked = checkedToDisplayCurrency(raw) + if (checked instanceof Error) { + if (raw !== undefined) { + baseLogger.error( + { err: checked, displayCurrency: raw }, + "Failed to parse display currency. Falling back to USD.", + ) + } + return UsdDisplayCurrency + } + return checked +} + +const exponentFor = (displayCurrency: DisplayCurrency): number => + getCurrencyMajorExponent(displayCurrency) + +// Display-currency major units per USD, as reported by Ibex for one +// transaction. A missing or unusable rate means "no conversion" (1:1) rather +// than the zero that used to wipe every display amount. +const toExchangeRate = (rate: number | undefined): number => + typeof rate === "number" && Number.isFinite(rate) && rate > 0 ? rate : 1 + +const toUsdtCents = (amount: number): UsdtCents => { + const usdtAmount = USDTAmount.fromNumber(amount.toString()) + if (usdtAmount instanceof Error) { + baseLogger.error({ err: usdtAmount, amount }, "Failed to parse IBEX USDT amount") + return 0 as UsdtCents + } + return Number(usdtAmount.asUsdCents()) as UsdtCents +} + +const zeroSettlementMinorUnit = (currency: WalletCurrency): SettlementMinorUnitAmount => { + if (currency === WalletCurrency.Usd) return 0 as UsdCents + if (currency === WalletCurrency.Usdt) return 0 as UsdtCents + return 0 as Satoshis +} + +const toSettlementMinorUnit = ( + amount: number | undefined, + currency: WalletCurrency, +): SettlementMinorUnitAmount => { + if (amount === undefined) return zeroSettlementMinorUnit(currency) + if (currency === WalletCurrency.Usd) return amount as UsdCents + if (currency === WalletCurrency.Usdt) return toUsdtCents(amount) + return amount as Satoshis +} + +// Sends render negative: Lightning send (2), On-Chain send (4), Crypto send +// (10), Taproot send (12). +const IBEX_SEND_TYPE_IDS = [2, 4, 10, 12] + +const isIbexSendType = (transactionTypeId: number | undefined): boolean => + transactionTypeId !== undefined && IBEX_SEND_TYPE_IDS.includes(transactionTypeId) + +const toSettlementAmount = ( + ibexAmount: number | undefined, + transactionTypeId: number | undefined, + currency: WalletCurrency, +): SettlementMinorUnitAmount => { + if (ibexAmount === undefined) { + baseLogger.warn("Ibex did not return transaction amount") + return toSettlementMinorUnit(ibexAmount, currency) + } + const amt = isIbexSendType(transactionTypeId) ? -1 * ibexAmount : ibexAmount + return toSettlementMinorUnit(amt, currency) +} + +const toSettlementDisplayAmount = ( + ibexAmount: number | undefined, + transactionTypeId: number | undefined, + exchangeRate: number, + displayCurrency: DisplayCurrency, +): string => { + if (ibexAmount === undefined) return `${ibexAmount}` + const amount = isIbexSendType(transactionTypeId) ? -1 * ibexAmount : ibexAmount + const converted = amount * exchangeRate + if (!Number.isFinite(converted)) return `${amount}` + // Round, never floor: flooring a sub-cent receipt truncates the display + // amount to 0 and the loss repeats on every page load of that history. + return `${Number(converted.toFixed(exponentFor(displayCurrency)))}` +} + +enum SortOrder { + RECENT = "settledAt", + OLDEST = "-settledAt", +} + +type IbexPaginationArgs = { + page?: number | undefined // ibex default (0) start at page 0 + limit?: number | undefined // ibex default (0) returns all + sort?: SortOrder | undefined // defaults to SortOrder.RECENT +} + +export function toIbexPaginationArgs( + args: ConnectionArguments | undefined, +): IbexPaginationArgs { + const DEFAULTS = { + page: 0, + limit: 0, + sort: SortOrder.RECENT, + } + + // Prefer 'first' over 'last') + if (args && args.first != null) { + return { + ...DEFAULTS, + limit: args.first, + sort: SortOrder.RECENT, + } + } else if (args && args.last != null) { + return { + ...DEFAULTS, + limit: args.last, + sort: SortOrder.OLDEST, + } + } else return DEFAULTS +} diff --git a/src/graphql/shared/types/object/btc-wallet.ts b/src/graphql/shared/types/object/btc-wallet.ts index 04345a8ab..fb1141beb 100644 --- a/src/graphql/shared/types/object/btc-wallet.ts +++ b/src/graphql/shared/types/object/btc-wallet.ts @@ -1,147 +1,148 @@ -import { GT } from "@graphql/index" -import { normalizePaymentAmount } from "@graphql/shared/root/mutation" -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@graphql/connections" -import { mapError } from "@graphql/error-map" - -import { Wallets } from "@app" - -import { WalletCurrency as WalletCurrencyDomain } from "@domain/shared" -import { WalletType } from "@domain/wallets" - -import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" - -import IWallet from "../abstract/wallet" - -import SignedAmount from "../scalar/signed-amount" -import WalletCurrency from "../scalar/wallet-currency" -import OnChainAddress from "../scalar/on-chain-address" - -import Lnurl from "../scalar/lnurl" - -import { TransactionConnection } from "./transaction" - -const BtcWallet = GT.Object({ - name: "BTCWallet", - description: - "A wallet belonging to an account which contains a BTC balance and a list of transactions.", - interfaces: () => [IWallet], - isTypeOf: (source) => source.currency === WalletCurrencyDomain.Btc, - fields: () => ({ - id: { - type: GT.NonNullID, - }, - accountId: { - type: GT.NonNullID, - }, - walletCurrency: { - type: GT.NonNull(WalletCurrency), - resolve: (source) => source.currency, - }, - lnurlp: { - type: Lnurl, - resolve: (source) => source.lnurlp, - }, - isExternal: { - type: GT.NonNull(GT.Boolean), - resolve: (source) => source.type === WalletType.External, - }, - balance: { - type: FractionalCentAmount, - description: - "Not denominated in sats. Null for external BTC wallets; no value shape is guaranteed for other BTC wallets.", - resolve: async (source) => { - if (source.type === WalletType.External) return null - const balanceSats = await Wallets.getBalanceForWallet({ - walletId: source.id, - currency: source.currency, - }) - if (balanceSats instanceof Error) { - throw mapError(balanceSats) - } - return balanceSats - }, - }, - pendingIncomingBalance: { - type: GT.NonNull(SignedAmount), - description: "An unconfirmed incoming onchain balance.", - resolve: async (source) => { - const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) - if (balanceSats instanceof Error) { - throw mapError(balanceSats) - } - return normalizePaymentAmount(balanceSats[source.id]).amount - }, - }, - transactions: { - type: TransactionConnection, - args: connectionArgs, - resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const { result, error } = await Wallets.getTransactionsForWallets({ - wallets: [source], - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } - - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) - }, - description: "A list of BTC transactions associated with this wallet.", - }, - transactionsByAddress: { - type: TransactionConnection, - args: { - ...connectionArgs, - address: { - type: GT.NonNull(OnChainAddress), - description: "Returns the items that include this address.", - }, - }, - resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const { address } = args - if (address instanceof Error) throw address - - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ - wallets: [source], - addresses: [address], - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } - - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) - }, - }, - }), -}) - -export default BtcWallet +import { GT } from "@graphql/index" +import { normalizePaymentAmount } from "@graphql/shared/root/mutation" +import { + connectionArgs, + connectionFromPaginatedArray, + checkedConnectionArgs, +} from "@graphql/connections" +import { mapError } from "@graphql/error-map" + +import { Wallets } from "@app" + +import { WalletCurrency as WalletCurrencyDomain } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" + +import IWallet from "../abstract/wallet" + +import SignedAmount from "../scalar/signed-amount" +import WalletCurrency from "../scalar/wallet-currency" +import OnChainAddress from "../scalar/on-chain-address" + +import Lnurl from "../scalar/lnurl" + +import { TransactionConnection } from "./transaction" + +const BtcWallet = GT.Object({ + name: "BTCWallet", + description: + "A wallet belonging to an account which contains a BTC balance and a list of transactions.", + interfaces: () => [IWallet], + isTypeOf: (source) => source.currency === WalletCurrencyDomain.Btc, + fields: () => ({ + id: { + type: GT.NonNullID, + }, + accountId: { + type: GT.NonNullID, + }, + walletCurrency: { + type: GT.NonNull(WalletCurrency), + resolve: (source) => source.currency, + }, + lnurlp: { + type: Lnurl, + resolve: (source) => source.lnurlp, + }, + isExternal: { + type: GT.NonNull(GT.Boolean), + resolve: (source) => source.type === WalletType.External, + }, + balance: { + type: FractionalCentAmount, + description: + "Not denominated in sats. Null for external BTC wallets; no value shape is guaranteed for other BTC wallets.", + resolve: async (source) => { + if (source.type === WalletType.External) return null + const balanceSats = await Wallets.getBalanceForWallet({ + walletId: source.id, + currency: source.currency, + }) + if (balanceSats instanceof Error) { + throw mapError(balanceSats) + } + return balanceSats + }, + }, + pendingIncomingBalance: { + type: GT.NonNull(SignedAmount), + description: "An unconfirmed incoming onchain balance.", + resolve: async (source) => { + const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) + if (balanceSats instanceof Error) { + throw mapError(balanceSats) + } + return normalizePaymentAmount(balanceSats[source.id]).amount + }, + }, + transactions: { + type: TransactionConnection, + args: connectionArgs, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const { result, error } = await Wallets.getTransactionsForWallets({ + wallets: [source], + paginationArgs, + displayCurrency: ctx?.domainAccount?.displayCurrency, + }) + if (error instanceof Error) { + throw mapError(error) + } + + // Non-null signal to type checker; consider fixing in PartialResult type + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + description: "A list of BTC transactions associated with this wallet.", + }, + transactionsByAddress: { + type: TransactionConnection, + args: { + ...connectionArgs, + address: { + type: GT.NonNull(OnChainAddress), + description: "Returns the items that include this address.", + }, + }, + resolve: async (source, args) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const { address } = args + if (address instanceof Error) throw address + + const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ + wallets: [source], + addresses: [address], + paginationArgs, + }) + if (error instanceof Error) { + throw mapError(error) + } + + // Non-null signal to type checker; consider fixing in PartialResult type + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + }), +}) + +export default BtcWallet diff --git a/src/graphql/shared/types/object/usd-wallet.ts b/src/graphql/shared/types/object/usd-wallet.ts index 137bf3f0b..2da5dc34a 100644 --- a/src/graphql/shared/types/object/usd-wallet.ts +++ b/src/graphql/shared/types/object/usd-wallet.ts @@ -1,183 +1,184 @@ -import { GT } from "@graphql/index" -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@graphql/connections" -import { normalizePaymentAmount } from "@graphql/shared/root/mutation" -import { mapError } from "@graphql/error-map" -import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" - -import { Wallets } from "@app" -import { - resolveCashWalletPresentationForAccount, - usdtMicrosToUsdCents, -} from "@app/cash-wallet-cutover" - -import { WalletCurrency as WalletCurrencyDomain, USDTAmount } from "@domain/shared" -import { WalletType } from "@domain/wallets" - -import IWallet from "../abstract/wallet" - -import WalletCurrency from "../scalar/wallet-currency" -import SignedAmount from "../scalar/signed-amount" -import OnChainAddress from "../scalar/on-chain-address" -import Lnurl from "../scalar/lnurl" - -import { resolveCashWalletHistoryWalletsForWalletObject } from "./cash-wallet-history" -import { TransactionConnection } from "./transaction" - -const UsdWallet = GT.Object({ - name: "UsdWallet", - description: - "A wallet belonging to an account which contains a USD balance and a list of transactions.", - interfaces: () => [IWallet], - isTypeOf: (source) => source.currency === WalletCurrencyDomain.Usd, - fields: () => ({ - id: { - type: GT.NonNullID, - }, - accountId: { - type: GT.NonNullID, - }, - walletCurrency: { - type: GT.NonNull(WalletCurrency), - resolve: (source) => source.currency, - }, - lnurlp: { - type: Lnurl, - resolve: (source) => source.lnurlp, - }, - isExternal: { - type: GT.NonNull(GT.Boolean), - resolve: (source) => source.type === WalletType.External, - }, - balance: { - type: FractionalCentAmount, - description: - "Balance in US cents as a float (up to 8 decimal places). Null for external wallets.", - resolve: async (source, args, ctx) => { - if (source.type === WalletType.External) return null - let balanceWallet = source - - if ( - "cashWalletClientCapabilities" in ctx && - ctx.domainAccount?.id === source.accountId - ) { - const presentation = await resolveCashWalletPresentationForAccount({ - account: ctx.domainAccount, - client: ctx.cashWalletClientCapabilities, - }) - if (presentation instanceof Error) throw mapError(presentation) - - if (source.id === presentation.legacyUsdWallet?.id) { - balanceWallet = presentation.activeSettlementWallet - } - } - - const balance = await Wallets.getBalanceForWallet({ - walletId: balanceWallet.id, - currency: balanceWallet.currency, - }) - if (balance instanceof Error) { - throw mapError(balance) - } - if (balance instanceof USDTAmount) { - const cents = usdtMicrosToUsdCents(balance.asSmallestUnits()) - if (cents instanceof Error) { - throw mapError(cents) - } - return cents - } - return Number(balance.asCents(8)) - }, - }, - pendingIncomingBalance: { - type: GT.NonNull(SignedAmount), - description: "An unconfirmed incoming onchain balance.", - resolve: async (source) => { - const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) - if (balanceSats instanceof Error) { - throw mapError(balanceSats) - } - return normalizePaymentAmount(balanceSats[source.id]).amount - }, - }, - transactions: { - type: TransactionConnection, - args: connectionArgs, - resolve: async (source, args, ctx) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ - source, - ctx, - }) - - const { result, error } = await Wallets.getTransactionsForWallets({ - wallets, - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } - - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) - }, - }, - transactionsByAddress: { - type: TransactionConnection, - args: { - ...connectionArgs, - address: { - type: GT.NonNull(OnChainAddress), - description: "Returns the items that include this address.", - }, - }, - resolve: async (source, args, ctx) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const { address } = args - if (address instanceof Error) throw address - - const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ - source, - ctx, - }) - - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ - wallets, - addresses: [address], - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } - - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) - }, - }, - }), -}) - -export default UsdWallet +import { GT } from "@graphql/index" +import { + connectionArgs, + connectionFromPaginatedArray, + checkedConnectionArgs, +} from "@graphql/connections" +import { normalizePaymentAmount } from "@graphql/shared/root/mutation" +import { mapError } from "@graphql/error-map" +import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" + +import { Wallets } from "@app" +import { + resolveCashWalletPresentationForAccount, + usdtMicrosToUsdCents, +} from "@app/cash-wallet-cutover" + +import { WalletCurrency as WalletCurrencyDomain, USDTAmount } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import IWallet from "../abstract/wallet" + +import WalletCurrency from "../scalar/wallet-currency" +import SignedAmount from "../scalar/signed-amount" +import OnChainAddress from "../scalar/on-chain-address" +import Lnurl from "../scalar/lnurl" + +import { resolveCashWalletHistoryWalletsForWalletObject } from "./cash-wallet-history" +import { TransactionConnection } from "./transaction" + +const UsdWallet = GT.Object({ + name: "UsdWallet", + description: + "A wallet belonging to an account which contains a USD balance and a list of transactions.", + interfaces: () => [IWallet], + isTypeOf: (source) => source.currency === WalletCurrencyDomain.Usd, + fields: () => ({ + id: { + type: GT.NonNullID, + }, + accountId: { + type: GT.NonNullID, + }, + walletCurrency: { + type: GT.NonNull(WalletCurrency), + resolve: (source) => source.currency, + }, + lnurlp: { + type: Lnurl, + resolve: (source) => source.lnurlp, + }, + isExternal: { + type: GT.NonNull(GT.Boolean), + resolve: (source) => source.type === WalletType.External, + }, + balance: { + type: FractionalCentAmount, + description: + "Balance in US cents as a float (up to 8 decimal places). Null for external wallets.", + resolve: async (source, args, ctx) => { + if (source.type === WalletType.External) return null + let balanceWallet = source + + if ( + "cashWalletClientCapabilities" in ctx && + ctx.domainAccount?.id === source.accountId + ) { + const presentation = await resolveCashWalletPresentationForAccount({ + account: ctx.domainAccount, + client: ctx.cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + if (source.id === presentation.legacyUsdWallet?.id) { + balanceWallet = presentation.activeSettlementWallet + } + } + + const balance = await Wallets.getBalanceForWallet({ + walletId: balanceWallet.id, + currency: balanceWallet.currency, + }) + if (balance instanceof Error) { + throw mapError(balance) + } + if (balance instanceof USDTAmount) { + const cents = usdtMicrosToUsdCents(balance.asSmallestUnits()) + if (cents instanceof Error) { + throw mapError(cents) + } + return cents + } + return Number(balance.asCents(8)) + }, + }, + pendingIncomingBalance: { + type: GT.NonNull(SignedAmount), + description: "An unconfirmed incoming onchain balance.", + resolve: async (source) => { + const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) + if (balanceSats instanceof Error) { + throw mapError(balanceSats) + } + return normalizePaymentAmount(balanceSats[source.id]).amount + }, + }, + transactions: { + type: TransactionConnection, + args: connectionArgs, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + + const { result, error } = await Wallets.getTransactionsForWallets({ + wallets, + paginationArgs, + displayCurrency: ctx?.domainAccount?.displayCurrency, + }) + if (error instanceof Error) { + throw mapError(error) + } + + // Non-null signal to type checker; consider fixing in PartialResult type + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + transactionsByAddress: { + type: TransactionConnection, + args: { + ...connectionArgs, + address: { + type: GT.NonNull(OnChainAddress), + description: "Returns the items that include this address.", + }, + }, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const { address } = args + if (address instanceof Error) throw address + + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + + const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ + wallets, + addresses: [address], + paginationArgs, + }) + if (error instanceof Error) { + throw mapError(error) + } + + // Non-null signal to type checker; consider fixing in PartialResult type + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + }), +}) + +export default UsdWallet diff --git a/src/graphql/shared/types/object/usdt-wallet.ts b/src/graphql/shared/types/object/usdt-wallet.ts index 3845ae696..daeea2de6 100644 --- a/src/graphql/shared/types/object/usdt-wallet.ts +++ b/src/graphql/shared/types/object/usdt-wallet.ts @@ -1,173 +1,174 @@ -import { GT } from "@graphql/index" -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@graphql/connections" -import { normalizePaymentAmount } from "@graphql/shared/root/mutation" -import { mapError } from "@graphql/error-map" - -import { Wallets } from "@app" -import { usdtMicrosToUsdCents } from "@app/cash-wallet-cutover" - -import { - USDAmount, - WalletCurrency as WalletCurrencyDomain, - USDTAmount, -} from "@domain/shared" -import { WalletType } from "@domain/wallets" - -import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" - -import IWallet from "../abstract/wallet" - -import WalletCurrency from "../scalar/wallet-currency" -import SignedAmount from "../scalar/signed-amount" -import OnChainAddress from "../scalar/on-chain-address" - -import Lnurl from "../scalar/lnurl" - -import { resolveCashWalletHistoryWalletsForWalletObject } from "./cash-wallet-history" -import { TransactionConnection } from "./transaction" - -const UsdtWallet = GT.Object({ - name: "UsdtWallet", - description: - "A wallet belonging to an account which contains a USDT balance and a list of transactions.", - interfaces: () => [IWallet], - isTypeOf: (source) => source.currency === WalletCurrencyDomain.Usdt, - fields: () => ({ - id: { - type: GT.NonNullID, - }, - accountId: { - type: GT.NonNullID, - }, - walletCurrency: { - type: GT.NonNull(WalletCurrency), - resolve: (source) => source.currency, - }, - - lnurlp: { - type: Lnurl, - resolve: (source) => source.lnurlp, - }, - isExternal: { - type: GT.NonNull(GT.Boolean), - resolve: (source) => source.type === WalletType.External, - }, - - balance: { - type: FractionalCentAmount, - description: - "USDT balance in US cents as a float (1 USDT = 100 cents). Up to 4 decimal places when backed by USDT; up to 8 if the balance is reported in USD.", - resolve: async (source) => { - const balance = await Wallets.getBalanceForWallet({ - walletId: source.id, - currency: source.currency, - }) - if (balance instanceof Error) { - throw mapError(balance) - } - if (balance instanceof USDTAmount) { - // Fractional cents (≤4 dp) — whole-cent rounding overstated the - // balance by up to half a cent; see usdtMicrosToUsdCents. - const cents = usdtMicrosToUsdCents(balance.asSmallestUnits()) - if (cents instanceof Error) { - throw mapError(cents) - } - return cents - } - if (balance instanceof USDAmount) { - return Number(balance.asCents(8)) - } - throw mapError(balance) - }, - }, - pendingIncomingBalance: { - type: GT.NonNull(SignedAmount), - description: "An unconfirmed incoming onchain balance.", - resolve: async (source) => { - const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) - if (balanceSats instanceof Error) { - throw mapError(balanceSats) - } - return normalizePaymentAmount(balanceSats[source.id]).amount - }, - }, - transactions: { - type: TransactionConnection, - args: connectionArgs, - resolve: async (source, args, ctx) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ - source, - ctx, - }) - - const { result, error } = await Wallets.getTransactionsForWallets({ - wallets, - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } - - if (!result?.slice) throw error - - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) - }, - }, - transactionsByAddress: { - type: TransactionConnection, - args: { - ...connectionArgs, - address: { - type: GT.NonNull(OnChainAddress), - description: "Returns the items that include this address.", - }, - }, - resolve: async (source, args, ctx) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const { address } = args - if (address instanceof Error) throw address - - const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ - source, - ctx, - }) - - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ - wallets, - addresses: [address], - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } - - if (!result?.slice) throw error - - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) - }, - }, - }), -}) - -export default UsdtWallet +import { GT } from "@graphql/index" +import { + connectionArgs, + connectionFromPaginatedArray, + checkedConnectionArgs, +} from "@graphql/connections" +import { normalizePaymentAmount } from "@graphql/shared/root/mutation" +import { mapError } from "@graphql/error-map" + +import { Wallets } from "@app" +import { usdtMicrosToUsdCents } from "@app/cash-wallet-cutover" + +import { + USDAmount, + WalletCurrency as WalletCurrencyDomain, + USDTAmount, +} from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" + +import IWallet from "../abstract/wallet" + +import WalletCurrency from "../scalar/wallet-currency" +import SignedAmount from "../scalar/signed-amount" +import OnChainAddress from "../scalar/on-chain-address" + +import Lnurl from "../scalar/lnurl" + +import { resolveCashWalletHistoryWalletsForWalletObject } from "./cash-wallet-history" +import { TransactionConnection } from "./transaction" + +const UsdtWallet = GT.Object({ + name: "UsdtWallet", + description: + "A wallet belonging to an account which contains a USDT balance and a list of transactions.", + interfaces: () => [IWallet], + isTypeOf: (source) => source.currency === WalletCurrencyDomain.Usdt, + fields: () => ({ + id: { + type: GT.NonNullID, + }, + accountId: { + type: GT.NonNullID, + }, + walletCurrency: { + type: GT.NonNull(WalletCurrency), + resolve: (source) => source.currency, + }, + + lnurlp: { + type: Lnurl, + resolve: (source) => source.lnurlp, + }, + isExternal: { + type: GT.NonNull(GT.Boolean), + resolve: (source) => source.type === WalletType.External, + }, + + balance: { + type: FractionalCentAmount, + description: + "USDT balance in US cents as a float (1 USDT = 100 cents). Up to 4 decimal places when backed by USDT; up to 8 if the balance is reported in USD.", + resolve: async (source) => { + const balance = await Wallets.getBalanceForWallet({ + walletId: source.id, + currency: source.currency, + }) + if (balance instanceof Error) { + throw mapError(balance) + } + if (balance instanceof USDTAmount) { + // Fractional cents (≤4 dp) — whole-cent rounding overstated the + // balance by up to half a cent; see usdtMicrosToUsdCents. + const cents = usdtMicrosToUsdCents(balance.asSmallestUnits()) + if (cents instanceof Error) { + throw mapError(cents) + } + return cents + } + if (balance instanceof USDAmount) { + return Number(balance.asCents(8)) + } + throw mapError(balance) + }, + }, + pendingIncomingBalance: { + type: GT.NonNull(SignedAmount), + description: "An unconfirmed incoming onchain balance.", + resolve: async (source) => { + const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) + if (balanceSats instanceof Error) { + throw mapError(balanceSats) + } + return normalizePaymentAmount(balanceSats[source.id]).amount + }, + }, + transactions: { + type: TransactionConnection, + args: connectionArgs, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + + const { result, error } = await Wallets.getTransactionsForWallets({ + wallets, + paginationArgs, + displayCurrency: ctx?.domainAccount?.displayCurrency, + }) + if (error instanceof Error) { + throw mapError(error) + } + + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + transactionsByAddress: { + type: TransactionConnection, + args: { + ...connectionArgs, + address: { + type: GT.NonNull(OnChainAddress), + description: "Returns the items that include this address.", + }, + }, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const { address } = args + if (address instanceof Error) throw address + + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + + const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ + wallets, + addresses: [address], + paginationArgs, + }) + if (error instanceof Error) { + throw mapError(error) + } + + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + }), +}) + +export default UsdtWallet diff --git a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts index deabaa9c7..1fca7f8db 100644 --- a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -1,381 +1,509 @@ -jest.mock("@services/ibex/client", () => ({ - __esModule: true, - default: { - getAccountTransactions: jest.fn(), - }, -})) - -import { - getTransactionsForWallets, - toWalletTransactions, -} from "@app/wallets/get-transactions-for-wallet" -import { WalletCurrency } from "@domain/shared" -import { WalletType } from "@domain/wallets" -import Ibex from "@services/ibex/client" -import { baseLogger } from "@services/logger" -import { GResponse200 } from "ibex-client" - -const accountId = "account-id" as AccountId - -const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => - ({ - id: id as WalletId, - accountId, - currency, - type: WalletType.Checking, - onChainAddressIdentifiers: [], - onChainAddresses: () => [], - lnurlp: `lnurlp-${id}` as Lnurl, - }) as Wallet - -describe("toWalletTransactions", () => { - it("maps IBEX USDT currency id to USDT wallet currency with integer cents", () => { - const transactions = toWalletTransactions([ - { - id: "trx-id-1", - accountId: "wallet-id", - amount: 0.17531, - currencyId: 29, - transactionTypeId: 1, - createdAt: "2026-05-13T00:00:00.000Z", - }, - { - id: "trx-id-2", - accountId: "wallet-id", - amount: 9.824690376349, - currencyId: 29, - transactionTypeId: 1, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transactions).toHaveLength(2) - expect(transactions[0].settlementCurrency).toBe(WalletCurrency.Usdt) - expect(transactions[0].settlementAmount).toBe(18) - expect(transactions[1].settlementAmount).toBe(982) - expect( - transactions.reduce((sum, transaction) => sum + transaction.settlementAmount, 0), - ).toBe(1_000) - }) - - it("maps IBEX USDT send amounts to negative integer cents", () => { - const [transaction] = toWalletTransactions([ - { - id: "trx-id", - accountId: "wallet-id", - amount: 0.5, - networkFee: 0.000001, - currencyId: 29, - transactionTypeId: 2, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) - expect(transaction.settlementAmount).toBe(-50) - expect(transaction.settlementDisplayAmount).toBe("-0.5") - expect(transaction.settlementFee).toBe(0) - }) - - it("maps IBEX crypto send transaction type to outgoing on-chain USDT", () => { - const [transaction] = toWalletTransactions([ - { - id: "crypto-send-trx-id", - accountId: "wallet-id", - amount: 2.5, - networkFee: 0.179554, - currencyId: 29, - transactionTypeId: 10, - createdAt: "2026-06-17T05:42:53.512218Z", - }, - ] as GResponse200) - - expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) - expect(transaction.settlementAmount).toBe(-250) - expect(transaction.settlementDisplayAmount).toBe("-2.5") - expect(transaction.settlementFee).toBe(18) - expect(transaction.settlementDisplayFee).toBe("0.179554") - expect(transaction.initiationVia.type).toBe("onchain") - expect(transaction.settlementVia.type).toBe("onchain") - }) - - it("maps IBEX crypto receive transaction type to incoming on-chain USDT", () => { - const [transaction] = toWalletTransactions([ - { - id: "crypto-receive-trx-id", - accountId: "wallet-id", - amount: 2.5, - currencyId: 29, - transactionTypeId: 9, - createdAt: "2026-07-17T16:30:00.000Z", - }, - ] as GResponse200) - - expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) - expect(transaction.settlementAmount).toBe(250) - expect(transaction.settlementDisplayAmount).toBe("2.5") - expect(transaction.initiationVia.type).toBe("onchain") - expect(transaction.settlementVia.type).toBe("onchain") - }) - - it("maps IBEX taproot types to on-chain with send rendered negative", () => { - const [received, sent] = toWalletTransactions([ - { - id: "taproot-receive", - accountId: "wallet-id", - amount: 100, - currencyId: 3, - transactionTypeId: 11, - createdAt: "2026-07-17T16:30:00.000Z", - }, - { - id: "taproot-send", - accountId: "wallet-id", - amount: 100, - currencyId: 3, - transactionTypeId: 12, - createdAt: "2026-07-17T16:30:00.000Z", - }, - ] as GResponse200) - - expect(received.settlementAmount).toBe(100) - expect(received.initiationVia.type).toBe("onchain") - expect(sent.settlementAmount).toBe(-100) - expect(sent.settlementDisplayAmount).toBe("-100") - expect(sent.initiationVia.type).toBe("onchain") - }) - - it("defaults omitted IBEX USDT amount and network fee to zero cents", () => { - const [transaction] = toWalletTransactions([ - { - id: "trx-id", - accountId: "wallet-id", - currencyId: 29, - transactionTypeId: 1, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) - expect(transaction.settlementAmount).toBe(0) - expect(transaction.settlementFee).toBe(0) - }) - - it("logs USDT conversion errors with error details", () => { - const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() - - const [transaction] = toWalletTransactions([ - { - id: "trx-id", - accountId: "wallet-id", - amount: Number.NaN, - currencyId: 29, - transactionTypeId: 1, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transaction.settlementAmount).toBe(0) - expect(errorSpy).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Error), amount: expect.any(Number) }), - "Failed to parse IBEX USDT amount", - ) - - errorSpy.mockRestore() - }) - - it("keeps IBEX USD amounts in integer cents", () => { - const [transaction] = toWalletTransactions([ - { - id: "trx-id", - accountId: "wallet-id", - amount: 500, - networkFee: 12, - currencyId: 3, - transactionTypeId: 1, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transaction.settlementCurrency).toBe(WalletCurrency.Usd) - expect(transaction.settlementAmount).toBe(500) - expect(transaction.settlementFee).toBe(12) - }) - - it("excludes transactions with unknown IBEX currency ids from the list", () => { - const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() - - const transactions = toWalletTransactions([ - { - id: "trx-id", - accountId: "wallet-id", - amount: 100, - currencyId: 999, - transactionTypeId: 1, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transactions).toHaveLength(0) - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to parse Ibex transaction currency"), - ) - - errorSpy.mockRestore() - }) - - it("maps unknown IBEX transaction type ids to intraledger with valid amounts", () => { - const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() - - const [transaction] = toWalletTransactions([ - { - id: "trx-id", - accountId: "wallet-id", - amount: 250, - networkFee: 1, - currencyId: 3, - transactionTypeId: 99, - createdAt: "2026-05-13T00:00:00.000Z", - }, - ] as GResponse200) - - expect(transaction.settlementCurrency).toBe(WalletCurrency.Usd) - expect(transaction.settlementAmount).toBe(250) - expect(transaction.settlementFee).toBe(1) - expect(transaction.initiationVia.type).toBe("intraledger") - expect(transaction.settlementVia.type).toBe("intraledger") - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to parse Ibex transaction type"), - ) - - errorSpy.mockRestore() - }) - - it("never emits via types outside the GraphQL union members", () => { - const resolvable = ["intraledger", "lightning", "onchain"] - - const transactions = toWalletTransactions( - [3, 29].flatMap((currencyId) => - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 99, undefined].map( - (transactionTypeId, i) => ({ - id: `trx-${currencyId}-${i}`, - accountId: "wallet-id", - amount: 10, - currencyId, - transactionTypeId, - createdAt: "2026-05-13T00:00:00.000Z", - }), - ), - ) as GResponse200, - ) - - expect(transactions).toHaveLength(28) - for (const transaction of transactions) { - expect(resolvable).toContain(transaction.initiationVia.type) - expect(resolvable).toContain(transaction.settlementVia.type) - } - }) - - it("keeps IBEX bank/funding ops (5-8) on the logged intraledger fallback", () => { - const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() - - const transactions = toWalletTransactions( - [5, 6, 7, 8].map((transactionTypeId) => ({ - id: `trx-${transactionTypeId}`, - accountId: "wallet-id", - amount: 10, - currencyId: 3, - transactionTypeId, - createdAt: "2026-05-13T00:00:00.000Z", - })) as GResponse200, - ) - - expect(transactions).toHaveLength(4) - for (const transaction of transactions) { - expect(transaction.initiationVia.type).toBe("intraledger") - expect(transaction.settlementVia.type).toBe("intraledger") - } - - const fallbackLogs = errorSpy.mock.calls.filter( - ([msg]) => - typeof msg === "string" && msg.includes("Failed to parse Ibex transaction type"), - ) - expect(fallbackLogs).toHaveLength(4) - for (const typeId of [5, 6, 7, 8]) { - expect( - fallbackLogs.some(([msg]) => - (msg as string).includes(`transactionTypeId: ${typeId}`), - ), - ).toBe(true) - } - - errorSpy.mockRestore() - }) -}) - -describe("getTransactionsForWallets", () => { - beforeEach(() => { - jest.mocked(Ibex.getAccountTransactions).mockReset() - }) - - it("concatenates active wallet history before the legacy archive wallet history", async () => { - const activeSettlementWallet = wallet({ - id: "33333333-3333-4333-8333-333333333333", - currency: WalletCurrency.Usdt, - }) - const legacyUsdWallet = wallet({ - id: "22222222-2222-4222-8222-222222222222", - currency: WalletCurrency.Usd, - }) - - jest - .mocked(Ibex.getAccountTransactions) - .mockImplementation(async ({ account_id }) => { - if (account_id === activeSettlementWallet.id) { - return [ - { - id: "active-newer", - accountId: activeSettlementWallet.id, - amount: 100, - currencyId: 29, - transactionTypeId: 1, - createdAt: "2026-06-01T00:00:00.000Z", - }, - ] as GResponse200 - } - - return [ - { - id: "legacy-archive-older", - accountId: legacyUsdWallet.id, - amount: 200, - currencyId: 3, - transactionTypeId: 1, - createdAt: "2026-05-01T00:00:00.000Z", - }, - ] as GResponse200 - }) - - const result = await getTransactionsForWallets({ - wallets: [activeSettlementWallet, legacyUsdWallet], - paginationArgs: { first: 20 }, - }) - - expect(Ibex.getAccountTransactions).toHaveBeenNthCalledWith(1, { - account_id: activeSettlementWallet.id, - limit: 20, - page: 0, - sort: "settledAt", - }) - expect(Ibex.getAccountTransactions).toHaveBeenNthCalledWith(2, { - account_id: legacyUsdWallet.id, - limit: 20, - page: 0, - sort: "settledAt", - }) - expect(result.result?.slice.map((transaction) => transaction.id)).toEqual([ - "active-newer", - "legacy-archive-older", - ]) - }) -}) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getAccountTransactions: jest.fn(), + }, +})) + +import { + getTransactionsForWallets, + toWalletTransactions, +} from "@app/wallets/get-transactions-for-wallet" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import Ibex from "@services/ibex/client" +import { baseLogger } from "@services/logger" +import { GResponse200 } from "ibex-client" + +const accountId = "account-id" as AccountId + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id: id as WalletId, + accountId, + currency, + type: WalletType.Checking, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: `lnurlp-${id}` as Lnurl, + }) as Wallet + +describe("toWalletTransactions", () => { + it("maps IBEX USDT currency id to USDT wallet currency with integer cents", () => { + const transactions = toWalletTransactions([ + { + id: "trx-id-1", + accountId: "wallet-id", + amount: 0.17531, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + { + id: "trx-id-2", + accountId: "wallet-id", + amount: 9.824690376349, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transactions).toHaveLength(2) + expect(transactions[0].settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transactions[0].settlementAmount).toBe(18) + expect(transactions[1].settlementAmount).toBe(982) + expect( + transactions.reduce((sum, transaction) => sum + transaction.settlementAmount, 0), + ).toBe(1_000) + }) + + it("maps IBEX USDT send amounts to negative integer cents", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 0.5, + networkFee: 0.000001, + currencyId: 29, + transactionTypeId: 2, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(-50) + expect(transaction.settlementDisplayAmount).toBe("-0.5") + expect(transaction.settlementFee).toBe(0) + }) + + it("maps IBEX crypto send transaction type to outgoing on-chain USDT", () => { + const [transaction] = toWalletTransactions([ + { + id: "crypto-send-trx-id", + accountId: "wallet-id", + amount: 2.5, + networkFee: 0.179554, + currencyId: 29, + transactionTypeId: 10, + createdAt: "2026-06-17T05:42:53.512218Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(-250) + expect(transaction.settlementDisplayAmount).toBe("-2.5") + expect(transaction.settlementFee).toBe(18) + expect(transaction.settlementDisplayFee).toBe("0.179554") + expect(transaction.initiationVia.type).toBe("onchain") + expect(transaction.settlementVia.type).toBe("onchain") + }) + + it("maps IBEX crypto receive transaction type to incoming on-chain USDT", () => { + const [transaction] = toWalletTransactions([ + { + id: "crypto-receive-trx-id", + accountId: "wallet-id", + amount: 2.5, + currencyId: 29, + transactionTypeId: 9, + createdAt: "2026-07-17T16:30:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(250) + expect(transaction.settlementDisplayAmount).toBe("2.5") + expect(transaction.initiationVia.type).toBe("onchain") + expect(transaction.settlementVia.type).toBe("onchain") + }) + + it("maps IBEX taproot types to on-chain with send rendered negative", () => { + const [received, sent] = toWalletTransactions([ + { + id: "taproot-receive", + accountId: "wallet-id", + amount: 100, + currencyId: 3, + transactionTypeId: 11, + createdAt: "2026-07-17T16:30:00.000Z", + }, + { + id: "taproot-send", + accountId: "wallet-id", + amount: 100, + currencyId: 3, + transactionTypeId: 12, + createdAt: "2026-07-17T16:30:00.000Z", + }, + ] as GResponse200) + + expect(received.settlementAmount).toBe(100) + expect(received.initiationVia.type).toBe("onchain") + expect(sent.settlementAmount).toBe(-100) + expect(sent.settlementDisplayAmount).toBe("-100") + expect(sent.initiationVia.type).toBe("onchain") + }) + + it("defaults omitted IBEX USDT amount and network fee to zero cents", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(0) + expect(transaction.settlementFee).toBe(0) + }) + + it("logs USDT conversion errors with error details", () => { + const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() + + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: Number.NaN, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementAmount).toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error), amount: expect.any(Number) }), + "Failed to parse IBEX USDT amount", + ) + + errorSpy.mockRestore() + }) + + it("keeps IBEX USD amounts in integer cents", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 500, + networkFee: 12, + currencyId: 3, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usd) + expect(transaction.settlementAmount).toBe(500) + expect(transaction.settlementFee).toBe(12) + }) + + it("excludes transactions with unknown IBEX currency ids from the list", () => { + const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() + + const transactions = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 100, + currencyId: 999, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transactions).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to parse Ibex transaction currency"), + ) + + errorSpy.mockRestore() + }) + + it("maps unknown IBEX transaction type ids to intraledger with valid amounts", () => { + const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() + + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 250, + networkFee: 1, + currencyId: 3, + transactionTypeId: 99, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usd) + expect(transaction.settlementAmount).toBe(250) + expect(transaction.settlementFee).toBe(1) + expect(transaction.initiationVia.type).toBe("intraledger") + expect(transaction.settlementVia.type).toBe("intraledger") + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to parse Ibex transaction type"), + ) + + errorSpy.mockRestore() + }) + + it("never emits via types outside the GraphQL union members", () => { + const resolvable = ["intraledger", "lightning", "onchain"] + + const transactions = toWalletTransactions( + [3, 29].flatMap((currencyId) => + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 99, undefined].map( + (transactionTypeId, i) => ({ + id: `trx-${currencyId}-${i}`, + accountId: "wallet-id", + amount: 10, + currencyId, + transactionTypeId, + createdAt: "2026-05-13T00:00:00.000Z", + }), + ), + ) as GResponse200, + ) + + expect(transactions).toHaveLength(28) + for (const transaction of transactions) { + expect(resolvable).toContain(transaction.initiationVia.type) + expect(resolvable).toContain(transaction.settlementVia.type) + } + }) + + it("keeps IBEX bank/funding ops (5-8) on the logged intraledger fallback", () => { + const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() + + const transactions = toWalletTransactions( + [5, 6, 7, 8].map((transactionTypeId) => ({ + id: `trx-${transactionTypeId}`, + accountId: "wallet-id", + amount: 10, + currencyId: 3, + transactionTypeId, + createdAt: "2026-05-13T00:00:00.000Z", + })) as GResponse200, + ) + + expect(transactions).toHaveLength(4) + for (const transaction of transactions) { + expect(transaction.initiationVia.type).toBe("intraledger") + expect(transaction.settlementVia.type).toBe("intraledger") + } + + const fallbackLogs = errorSpy.mock.calls.filter( + ([msg]) => + typeof msg === "string" && msg.includes("Failed to parse Ibex transaction type"), + ) + expect(fallbackLogs).toHaveLength(4) + for (const typeId of [5, 6, 7, 8]) { + expect( + fallbackLogs.some(([msg]) => + (msg as string).includes(`transactionTypeId: ${typeId}`), + ), + ).toBe(true) + } + + errorSpy.mockRestore() + }) + + it("labels and converts history with the account display currency", () => { + const [transaction] = toWalletTransactions( + [ + { + id: "trx-id", + accountId: "wallet-id", + amount: 1.5, + currencyId: 3, + transactionTypeId: 1, + exchangeRateCurrencySats: 156.25, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200, + "JMD" as DisplayCurrency, + ) + + expect(transaction.settlementDisplayPrice.displayCurrency).toBe("JMD") + // 1.5 USD at the rate Ibex settled this row at. + expect(transaction.settlementDisplayAmount).toBe("234.38") + expect(transaction.settlementDisplayPrice.base).toBe(15625n) + expect(transaction.settlementDisplayPrice.offset).toBe(2n) + }) + + it("pairs each transaction with its own settlement rate", () => { + const [first, second] = toWalletTransactions( + [ + { + id: "trx-a", + accountId: "wallet-id", + amount: 1, + currencyId: 3, + transactionTypeId: 1, + exchangeRateCurrencySats: 150, + createdAt: "2026-05-13T00:00:00.000Z", + }, + { + id: "trx-b", + accountId: "wallet-id", + amount: 1, + currencyId: 3, + transactionTypeId: 1, + exchangeRateCurrencySats: 160, + createdAt: "2026-05-14T00:00:00.000Z", + }, + ] as GResponse200, + "JMD" as DisplayCurrency, + ) + + expect(first.settlementDisplayAmount).toBe("150") + expect(second.settlementDisplayAmount).toBe("160") + expect(first.settlementDisplayPrice.base).toBe(15000n) + expect(second.settlementDisplayPrice.base).toBe(16000n) + }) + + it("keeps a sub-cent receipt non-zero instead of truncating it away", () => { + const [transaction] = toWalletTransactions( + [ + { + id: "trx-id", + accountId: "wallet-id", + amount: 0.004, + currencyId: 3, + transactionTypeId: 1, + exchangeRateCurrencySats: 150, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200, + "JMD" as DisplayCurrency, + ) + + // 0.004 * 150 = 0.6; Math.floor would have rendered "0". + expect(transaction.settlementDisplayAmount).toBe("0.6") + }) + + it("renders sends negative after conversion", () => { + const [transaction] = toWalletTransactions( + [ + { + id: "trx-id", + accountId: "wallet-id", + amount: 2, + currencyId: 3, + transactionTypeId: 2, + exchangeRateCurrencySats: 150, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200, + "JMD" as DisplayCurrency, + ) + + expect(transaction.settlementAmount).toBe(-2) + expect(transaction.settlementDisplayAmount).toBe("-300") + }) + + it("falls back to USD when the caller has no display currency", () => { + const [withoutCurrency] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 3, + currencyId: 3, + transactionTypeId: 1, + exchangeRateCurrencySats: 150, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(withoutCurrency.settlementDisplayPrice.displayCurrency).toBe("USD") + // No rate context beyond the settled rate: the amount is still converted. + expect(withoutCurrency.settlementDisplayAmount).toBe("450") + + const [tooLong] = toWalletTransactions( + [ + { + id: "trx-id", + accountId: "wallet-id", + amount: 3, + currencyId: 3, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200, + "NOT_A_CURRENCY" as DisplayCurrency, + ) + + expect(tooLong.settlementDisplayPrice.displayCurrency).toBe("USD") + }) +}) + +describe("getTransactionsForWallets", () => { + beforeEach(() => { + jest.mocked(Ibex.getAccountTransactions).mockReset() + }) + + it("concatenates active wallet history before the legacy archive wallet history", async () => { + const activeSettlementWallet = wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Usdt, + }) + const legacyUsdWallet = wallet({ + id: "22222222-2222-4222-8222-222222222222", + currency: WalletCurrency.Usd, + }) + + jest + .mocked(Ibex.getAccountTransactions) + .mockImplementation(async ({ account_id }) => { + if (account_id === activeSettlementWallet.id) { + return [ + { + id: "active-newer", + accountId: activeSettlementWallet.id, + amount: 100, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-06-01T00:00:00.000Z", + }, + ] as GResponse200 + } + + return [ + { + id: "legacy-archive-older", + accountId: legacyUsdWallet.id, + amount: 200, + currencyId: 3, + transactionTypeId: 1, + createdAt: "2026-05-01T00:00:00.000Z", + }, + ] as GResponse200 + }) + + const result = await getTransactionsForWallets({ + wallets: [activeSettlementWallet, legacyUsdWallet], + paginationArgs: { first: 20 }, + }) + + expect(Ibex.getAccountTransactions).toHaveBeenNthCalledWith(1, { + account_id: activeSettlementWallet.id, + limit: 20, + page: 0, + sort: "settledAt", + }) + expect(Ibex.getAccountTransactions).toHaveBeenNthCalledWith(2, { + account_id: legacyUsdWallet.id, + limit: 20, + page: 0, + sort: "settledAt", + }) + expect(result.result?.slice.map((transaction) => transaction.id)).toEqual([ + "active-newer", + "legacy-archive-older", + ]) + }) +}) diff --git a/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts b/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts index 2ec777ebb..c30522d70 100644 --- a/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts +++ b/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts @@ -1,246 +1,249 @@ -const mockGetTransactionsForAccountByWalletIds = jest.fn() -const mockGetTransactionsForWallets = jest.fn() -const mockGetTransactionsForWalletsByAddresses = jest.fn() -const mockResolveCashWalletPresentationForAccount = jest.fn() - -jest.mock("@app", () => ({ - Accounts: { - getTransactionsForAccountByWalletIds: ( - ...args: Parameters - ) => mockGetTransactionsForAccountByWalletIds(...args), - }, - Prices: {}, - Wallets: { - getTransactionsForWallets: ( - ...args: Parameters - ) => mockGetTransactionsForWallets(...args), - getTransactionsForWalletsByAddresses: ( - ...args: Parameters - ) => mockGetTransactionsForWalletsByAddresses(...args), - }, -})) - -jest.mock("@app/cash-wallet-cutover", () => { - const presentation = jest.requireActual("@app/cash-wallet-cutover/presentation") - - return { - cashWalletHistoryWalletIdsForPresentation: - presentation.cashWalletHistoryWalletIdsForPresentation, - cashWalletHistoryWalletsForPresentation: - presentation.cashWalletHistoryWalletsForPresentation, - resolveCashWalletPresentationForAccount: ( - ...args: Parameters - ) => mockResolveCashWalletPresentationForAccount(...args), - } -}) - -import ConsumerAccount from "@graphql/public/types/object/consumer-account" -import BusinessAccount from "@graphql/public/types/object/business-account" -import UsdWallet from "@graphql/shared/types/object/usd-wallet" -import UsdtWallet from "@graphql/shared/types/object/usdt-wallet" - -import { WalletCurrency } from "@domain/shared" -import { WalletType } from "@domain/wallets" - -const accountId = "cash-account-id" as AccountId -const account = { - id: accountId, - uuid: "cash-account-uuid" as AccountUuid, - displayCurrency: "USD" as DisplayCurrency, -} as Account - -const client = { - cashWalletPresentation: "usdt", - hasUsdtCashWalletSupport: true, -} as const - -const context = { - domainAccount: account, - cashWalletClientCapabilities: client, -} as GraphQLPublicContextAuth - -const wallet = ({ - id, - currency, - type = WalletType.Checking, -}: { - id: string - currency: WalletCurrency - type?: WalletType -}): Wallet => - ({ - id: id as WalletId, - accountId, - currency, - type, - onChainAddressIdentifiers: [], - onChainAddresses: () => [], - lnurlp: `lnurlp-${id}` as Lnurl, - }) as Wallet - -const btcWallet = wallet({ - id: "11111111-1111-4111-8111-111111111111", - currency: WalletCurrency.Btc, -}) -const legacyUsdWallet = wallet({ - id: "22222222-2222-4222-8222-222222222222", - currency: WalletCurrency.Usd, -}) -const usdtWallet = wallet({ - id: "33333333-3333-4333-8333-333333333333", - currency: WalletCurrency.Usdt, -}) - -const presentation = { - wallets: [btcWallet, usdtWallet], - defaultWalletId: usdtWallet.id, - legacyUsdWallet, - activeSettlementWallet: usdtWallet, -} - -const emptyConnectionResult = { - result: { slice: [], total: 0 }, - partialResult: true, -} - -beforeEach(() => { - jest.clearAllMocks() - mockResolveCashWalletPresentationForAccount.mockResolvedValue(presentation) - mockGetTransactionsForAccountByWalletIds.mockResolvedValue(emptyConnectionResult) - mockGetTransactionsForWallets.mockResolvedValue(emptyConnectionResult) - mockGetTransactionsForWalletsByAddresses.mockResolvedValue(emptyConnectionResult) -}) - -const resolveField = async ({ - objectType, - field, - source, - args, -}: { - objectType: - | typeof ConsumerAccount - | typeof BusinessAccount - | typeof UsdWallet - | typeof UsdtWallet - field: string - source: unknown - args: Record -}) => { - const resolver = objectType.getFields()[field].resolve as - | (( - source: unknown, - args: Record, - context: GraphQLPublicContextAuth, - info: never, - ) => unknown) - | undefined - if (!resolver) throw new Error(`Missing resolver for ${field}`) - - return resolver(source, args, context, {} as never) -} - -describe("account Cash Wallet transaction resolvers", () => { - it("expands ConsumerAccount legacy USD filters to active USDT history then legacy USD archive", async () => { - await resolveField({ - objectType: ConsumerAccount, - field: "transactions", - source: account, - args: { first: 20, walletIds: [legacyUsdWallet.id] }, - }) - - expect(mockGetTransactionsForAccountByWalletIds).toHaveBeenCalledWith({ - account, - walletIds: [usdtWallet.id, legacyUsdWallet.id], - paginationArgs: { first: 20, walletIds: [legacyUsdWallet.id] }, - }) - }) - - it("expands BusinessAccount default Cash Wallet history to active USDT then legacy USD archive", async () => { - await resolveField({ - objectType: BusinessAccount, - field: "transactions", - source: account, - args: { first: 20 }, - }) - - expect(mockGetTransactionsForAccountByWalletIds).toHaveBeenCalledWith({ - account, - walletIds: [btcWallet.id, usdtWallet.id, legacyUsdWallet.id], - paginationArgs: { first: 20 }, - }) - }) -}) - -describe("wallet object Cash Wallet transaction resolvers", () => { - it("expands legacy USD wallet object transactions to active USDT history then legacy USD archive", async () => { - await resolveField({ - objectType: UsdWallet, - field: "transactions", - source: legacyUsdWallet, - args: { first: 20 }, - }) - - expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ - wallets: [usdtWallet, legacyUsdWallet], - paginationArgs: { first: 20 }, - }) - }) - - it("expands USDT wallet object transactions to active USDT history then legacy USD archive", async () => { - await resolveField({ - objectType: UsdtWallet, - field: "transactions", - source: usdtWallet, - args: { first: 20 }, - }) - - expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ - wallets: [usdtWallet, legacyUsdWallet], - paginationArgs: { first: 20 }, - }) - }) - - it("expands wallet object transactionsByAddress across active USDT and legacy USD backing wallets", async () => { - const address = "bc1-cash-wallet-address" as OnChainAddress - - await resolveField({ - objectType: UsdWallet, - field: "transactionsByAddress", - source: legacyUsdWallet, - args: { first: 20, address }, - }) - - expect(mockGetTransactionsForWalletsByAddresses).toHaveBeenCalledWith({ - wallets: [usdtWallet, legacyUsdWallet], - addresses: [address], - paginationArgs: { first: 20, address }, - }) - }) - - it("keeps wrong-account wallet object history scoped to the source wallet only", async () => { - const otherAccountUsdWallet = wallet({ - id: "44444444-4444-4444-8444-444444444444", - currency: WalletCurrency.Usd, - }) - - await resolveField({ - objectType: UsdWallet, - field: "transactions", - source: { - ...otherAccountUsdWallet, - accountId: "other-account-id" as AccountId, - }, - args: { first: 20 }, - }) - - expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ - wallets: [ - { - ...otherAccountUsdWallet, - accountId: "other-account-id" as AccountId, - }, - ], - paginationArgs: { first: 20 }, - }) - }) -}) +const mockGetTransactionsForAccountByWalletIds = jest.fn() +const mockGetTransactionsForWallets = jest.fn() +const mockGetTransactionsForWalletsByAddresses = jest.fn() +const mockResolveCashWalletPresentationForAccount = jest.fn() + +jest.mock("@app", () => ({ + Accounts: { + getTransactionsForAccountByWalletIds: ( + ...args: Parameters + ) => mockGetTransactionsForAccountByWalletIds(...args), + }, + Prices: {}, + Wallets: { + getTransactionsForWallets: ( + ...args: Parameters + ) => mockGetTransactionsForWallets(...args), + getTransactionsForWalletsByAddresses: ( + ...args: Parameters + ) => mockGetTransactionsForWalletsByAddresses(...args), + }, +})) + +jest.mock("@app/cash-wallet-cutover", () => { + const presentation = jest.requireActual("@app/cash-wallet-cutover/presentation") + + return { + cashWalletHistoryWalletIdsForPresentation: + presentation.cashWalletHistoryWalletIdsForPresentation, + cashWalletHistoryWalletsForPresentation: + presentation.cashWalletHistoryWalletsForPresentation, + resolveCashWalletPresentationForAccount: ( + ...args: Parameters + ) => mockResolveCashWalletPresentationForAccount(...args), + } +}) + +import ConsumerAccount from "@graphql/public/types/object/consumer-account" +import BusinessAccount from "@graphql/public/types/object/business-account" +import UsdWallet from "@graphql/shared/types/object/usd-wallet" +import UsdtWallet from "@graphql/shared/types/object/usdt-wallet" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const accountId = "cash-account-id" as AccountId +const account = { + id: accountId, + uuid: "cash-account-uuid" as AccountUuid, + displayCurrency: "USD" as DisplayCurrency, +} as Account + +const client = { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, +} as const + +const context = { + domainAccount: account, + cashWalletClientCapabilities: client, +} as GraphQLPublicContextAuth + +const wallet = ({ + id, + currency, + type = WalletType.Checking, +}: { + id: string + currency: WalletCurrency + type?: WalletType +}): Wallet => + ({ + id: id as WalletId, + accountId, + currency, + type, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: `lnurlp-${id}` as Lnurl, + }) as Wallet + +const btcWallet = wallet({ + id: "11111111-1111-4111-8111-111111111111", + currency: WalletCurrency.Btc, +}) +const legacyUsdWallet = wallet({ + id: "22222222-2222-4222-8222-222222222222", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Usdt, +}) + +const presentation = { + wallets: [btcWallet, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, +} + +const emptyConnectionResult = { + result: { slice: [], total: 0 }, + partialResult: true, +} + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCashWalletPresentationForAccount.mockResolvedValue(presentation) + mockGetTransactionsForAccountByWalletIds.mockResolvedValue(emptyConnectionResult) + mockGetTransactionsForWallets.mockResolvedValue(emptyConnectionResult) + mockGetTransactionsForWalletsByAddresses.mockResolvedValue(emptyConnectionResult) +}) + +const resolveField = async ({ + objectType, + field, + source, + args, +}: { + objectType: + | typeof ConsumerAccount + | typeof BusinessAccount + | typeof UsdWallet + | typeof UsdtWallet + field: string + source: unknown + args: Record +}) => { + const resolver = objectType.getFields()[field].resolve as + | (( + source: unknown, + args: Record, + context: GraphQLPublicContextAuth, + info: never, + ) => unknown) + | undefined + if (!resolver) throw new Error(`Missing resolver for ${field}`) + + return resolver(source, args, context, {} as never) +} + +describe("account Cash Wallet transaction resolvers", () => { + it("expands ConsumerAccount legacy USD filters to active USDT history then legacy USD archive", async () => { + await resolveField({ + objectType: ConsumerAccount, + field: "transactions", + source: account, + args: { first: 20, walletIds: [legacyUsdWallet.id] }, + }) + + expect(mockGetTransactionsForAccountByWalletIds).toHaveBeenCalledWith({ + account, + walletIds: [usdtWallet.id, legacyUsdWallet.id], + paginationArgs: { first: 20, walletIds: [legacyUsdWallet.id] }, + }) + }) + + it("expands BusinessAccount default Cash Wallet history to active USDT then legacy USD archive", async () => { + await resolveField({ + objectType: BusinessAccount, + field: "transactions", + source: account, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForAccountByWalletIds).toHaveBeenCalledWith({ + account, + walletIds: [btcWallet.id, usdtWallet.id, legacyUsdWallet.id], + paginationArgs: { first: 20 }, + }) + }) +}) + +describe("wallet object Cash Wallet transaction resolvers", () => { + it("expands legacy USD wallet object transactions to active USDT history then legacy USD archive", async () => { + await resolveField({ + objectType: UsdWallet, + field: "transactions", + source: legacyUsdWallet, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ + wallets: [usdtWallet, legacyUsdWallet], + paginationArgs: { first: 20 }, + displayCurrency: account.displayCurrency, + }) + }) + + it("expands USDT wallet object transactions to active USDT history then legacy USD archive", async () => { + await resolveField({ + objectType: UsdtWallet, + field: "transactions", + source: usdtWallet, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ + wallets: [usdtWallet, legacyUsdWallet], + paginationArgs: { first: 20 }, + displayCurrency: account.displayCurrency, + }) + }) + + it("expands wallet object transactionsByAddress across active USDT and legacy USD backing wallets", async () => { + const address = "bc1-cash-wallet-address" as OnChainAddress + + await resolveField({ + objectType: UsdWallet, + field: "transactionsByAddress", + source: legacyUsdWallet, + args: { first: 20, address }, + }) + + expect(mockGetTransactionsForWalletsByAddresses).toHaveBeenCalledWith({ + wallets: [usdtWallet, legacyUsdWallet], + addresses: [address], + paginationArgs: { first: 20, address }, + }) + }) + + it("keeps wrong-account wallet object history scoped to the source wallet only", async () => { + const otherAccountUsdWallet = wallet({ + id: "44444444-4444-4444-8444-444444444444", + currency: WalletCurrency.Usd, + }) + + await resolveField({ + objectType: UsdWallet, + field: "transactions", + source: { + ...otherAccountUsdWallet, + accountId: "other-account-id" as AccountId, + }, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ + wallets: [ + { + ...otherAccountUsdWallet, + accountId: "other-account-id" as AccountId, + }, + ], + paginationArgs: { first: 20 }, + displayCurrency: account.displayCurrency, + }) + }) +})