diff --git a/.changeset/fine-houses-invite.md b/.changeset/fine-houses-invite.md new file mode 100644 index 0000000000..03006b9b52 --- /dev/null +++ b/.changeset/fine-houses-invite.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add account statement diff --git a/server/api/activity.ts b/server/api/activity.ts index ff2235cd49..b309d16bcb 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -1,7 +1,7 @@ import { renderToBuffer } from "@react-pdf/renderer"; import { captureException, setUser } from "@sentry/node"; -import { arrayOverlaps, eq } from "drizzle-orm"; +import { arrayOverlaps, eq, sql } from "drizzle-orm"; import { Hono } from "hono"; import { accepts } from "hono/accepts"; import { validator as vValidator } from "hono-openapi/valibot"; @@ -51,9 +51,10 @@ import chain, { } from "@exactly/common/generated/chain"; import { decodeWithdraw } from "@exactly/common/ProposalType"; import { Address, Hash, type Hex } from "@exactly/common/validation"; -import { effectiveRate, WAD } from "@exactly/lib"; +import { effectiveRate, MATURITY_INTERVAL, WAD } from "@exactly/lib"; import { cards, credentials, transactions } from "../database/schema"; +import AccountStatement from "../utils/AccountStatement"; import { collectors as cryptomateCollectors } from "../utils/cryptomate"; import { declineMessage, collectors as pandaCollectors } from "../utils/panda"; import publicClient from "../utils/publicClient"; @@ -86,6 +87,40 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg async (c) => { const { include, maturity } = c.req.valid("query"); if (maturity !== undefined && maturity > 864e10) return c.json({ code: "invalid maturity" }, 400); + const pdf = + accepts(c, { + header: "Accept", + supports: ["application/json", "application/pdf"], + default: "application/json", + }) === "application/pdf"; + const accountPdf = pdf && include === undefined; + const eventMaturity = accountPdf ? undefined : maturity; + if (pdf && include !== undefined && maturity === undefined) + return c.json({ code: "maturity required for filtered pdf" }, 400); + if (accountPdf && maturity === undefined) + return c.json({ code: "maturity required for account statement pdf" }, 400); + const period = + maturity === undefined + ? undefined + : { end: new Date(maturity * 1000), start: new Date((maturity - MATURITY_INTERVAL) * 1000) }; + const fromBlock = accountPdf && period ? await findBlock(period.start) : 0n; + const toBlock = accountPdf && period ? await findBlock(period.end, true) : "latest"; + const transactionPeriod = + accountPdf && period + ? sql`( + ( + ${transactions.payload}->>'type' = 'cryptomate' + AND (${transactions.payload}->'data'->>'created_at')::timestamptz > ${period.start} /* cspell:ignore timestamptz */ + AND (${transactions.payload}->'data'->>'created_at')::timestamptz <= ${period.end} /* cspell:ignore timestamptz */ + ) + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(${transactions.payload}->'bodies', '[]'::jsonb)) AS body + WHERE (body->>'createdAt')::timestamptz > ${period.start} /* cspell:ignore timestamptz */ + AND (body->>'createdAt')::timestamptz <= ${period.end} /* cspell:ignore timestamptz */ + ) + )` + : undefined; function ignore(type: InferInput) { return include && (Array.isArray(include) ? !include.includes(type) : include !== type); } @@ -97,8 +132,8 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg with: { cards: { columns: { id: true, lastFour: true }, - with: { transactions: { columns: { hashes: true, payload: true } } }, - limit: ignore("card") || maturity !== undefined ? 0 : undefined, + with: { transactions: { columns: { hashes: true, payload: true }, where: transactionPeriod } }, + limit: ignore("card") || eventMaturity !== undefined ? 0 : undefined, }, }, }); @@ -123,7 +158,6 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg .then((logs) => new Set(logs.map(({ args }) => args.plugin.toLowerCase() as Hex))) : Promise.resolve(forbid(new Set())), ]); - const market = (address: Hex) => { const found = markets.get(address.toLowerCase() as Hex); if (!found) throw new Error("market not found"); @@ -137,8 +171,8 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg eventName: "RepayAtMaturity", address: [...markets.keys()], args: { caller: [...plugins, debtManagerAddress], borrower: account }, - toBlock: "latest", - fromBlock: 0n, + toBlock, + fromBlock, strict: true, }) : Promise.resolve(forbid([])); @@ -153,8 +187,8 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg eventName: "Deposit", address: [...markets.keys()], args: { caller: account, owner: account }, - toBlock: "latest", - fromBlock: 0n, + toBlock, + fromBlock, strict: true, }) .then((logs) => @@ -174,7 +208,7 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg ? [] : repayPromise.then((logs) => logs - .filter(({ args }) => maturity === undefined || Number(args.maturity) === maturity) + .filter(({ args }) => eventMaturity === undefined || Number(args.maturity) === eventMaturity) .map((log) => parse(RepayActivity, { ...log, @@ -190,8 +224,8 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg eventName: "Withdraw", address: [...markets.keys()], args: { caller: account, owner: account }, - toBlock: "latest", - fromBlock: 0n, + toBlock, + fromBlock, strict: true, }), publicClient.getContractEvents({ @@ -247,8 +281,8 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg eventName: "BorrowAtMaturity", address: marketUSDCAddress, args: { borrower: account }, - toBlock: "latest", - fromBlock: 0n, + toBlock, + fromBlock, strict: true, }) .then((logs) => @@ -269,7 +303,7 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg ); const timestamps = new Map(blocks.map(({ number: block, timestamp }) => [block, timestamp])); const purchases = - !ignore("card") && borrows && maturity !== undefined + !ignore("card") && borrows && maturity !== undefined && !accountPdf ? await (() => { const hashes = borrows .entries() @@ -290,13 +324,6 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg })() : credential.cards; - const accept = accepts(c, { - header: "Accept", - supports: maturity === undefined ? ["application/json"] : ["application/json", "application/pdf"], - default: "application/json", - }); - const pdf = accept === "application/pdf"; - const response = [ ...purchases.flatMap(({ id: cardId, lastFour, transactions: txs }) => txs.map(({ hashes, payload }) => { @@ -307,16 +334,22 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg const b = borrows?.get(h as Hash); if (!b) return null; const filtered = - maturity === undefined ? b.events : b.events.filter(({ maturity: m }) => Number(m) === maturity); + eventMaturity === undefined + ? b.events + : b.events.filter(({ maturity: m }) => Number(m) === eventMaturity); if (filtered.length === 0) return null; return { - events: maturity !== undefined && b.events.length > 1 ? b.events : filtered, + events: eventMaturity !== undefined && b.events.length > 1 ? b.events : filtered, timestamp: b.blockNumber && timestamps.get(b.blockNumber), }; }), }); if (panda.success) { - if (maturity === undefined || pdf) return { ...panda.output, cardId, lastFour }; + if (eventMaturity === undefined || pdf) { + const output = { ...panda.output, cardId, lastFour }; + Object.defineProperty(output, "completion", { value: stringAt(panda.output, "completion") }); + return output; + } const operations: typeof panda.output.operations = []; for (const operation of panda.output.operations) { if (!("borrow" in operation)) continue; @@ -367,11 +400,12 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg const hash = hashes[0]; const borrow = borrows?.get(hash as Hash); const filtered = - maturity === undefined || !borrow + eventMaturity === undefined || !borrow ? borrow?.events - : borrow.events.filter(({ maturity: m }) => Number(m) === maturity); - if (maturity !== undefined && borrow && filtered?.length === 0) return; - const events = !borrow || maturity === undefined || borrow.events.length <= 1 ? filtered : borrow.events; + : borrow.events.filter(({ maturity: m }) => Number(m) === eventMaturity); + if (eventMaturity !== undefined && borrow && filtered?.length === 0) return; + const events = + !borrow || eventMaturity === undefined || borrow.events.length <= 1 ? filtered : borrow.events; const cryptomate = safeParse( { 0: DebitActivity, 1: CreditActivity }[events?.length ?? 0] ?? InstallmentsActivity, { @@ -382,7 +416,7 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg }, ); if (cryptomate.success) { - if (maturity === undefined || pdf) return { ...cryptomate.output, cardId, lastFour }; + if (eventMaturity === undefined || pdf) return { ...cryptomate.output, cardId, lastFour }; if (!borrow) return; if (borrow.events.length <= 1) return { ...cryptomate.output, cardId, lastFour }; if (!("borrow" in cryptomate.output) || !("installments" in cryptomate.output.borrow)) @@ -436,6 +470,174 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg .filter((value: T | undefined): value is T => value !== undefined) .toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id)); + if (accountPdf) { + const items = response + .flatMap((item): (typeof response)[number][] => { + if (item.type !== "panda") return [item]; + const pending = + item.status === "pending" + ? item.operations.filter( + (operation) => + operation.action === "updated" && + stringAt(operation, "spendStatus") === "pending" && + operation.usdAmount < 0, + ) + : []; + const current = + pending.length === 0 + ? item + : { + ...item, + amount: item.amount - pending.reduce((sum, operation) => sum + operation.amount, 0), + operations: item.operations.filter((operation) => !pending.includes(operation)), + usdAmount: item.usdAmount - pending.reduce((sum, operation) => sum + operation.usdAmount, 0), + }; + const refunds = current.operations.filter( + (operation) => + operation.usdAmount < 0 && + stringAt(operation, "spendStatus") !== "pending" && + stringAt(operation, "spendStatus") !== "reversed" && + (operation.action !== "completed" || + isRefund(operation) || + !current.operations.some(({ action }) => action === "created")), + ); + const settledAt = + current.operations.findLast(({ action, usdAmount: amount }) => action === "completed" && amount > 0) + ?.timestamp ?? stringAt(item, "completion"); + const charges = current.operations.filter( + (operation) => operation.usdAmount >= 0 || (operation.action === "completed" && !isRefund(operation)), + ); + const chargeItems = Map.groupBy(charges, ({ mode }) => mode > 0) + .values() + .toArray() + .flatMap((operations) => { + const usdAmount = operations.reduce((sum, { usdAmount: amount }) => sum + amount, 0); + if (usdAmount <= 0) return []; + const completed = operations.findLast(({ action }) => action === "completed"); + return [ + { + ...current, + amount: operations.reduce((sum, { amount }) => sum + amount, 0), + operations, + timestamp: + completed?.timestamp ?? + stringAt(item, "completion") ?? + operations.findLast(({ action }) => action === "created")?.timestamp ?? + current.timestamp, + usdAmount, + }, + ]; + }); + const charged = chargeItems.map((charge, index) => ({ + ...charge, + ...(index > 0 && { id: `${item.id}:${index}` }), + })); + if (refunds.length === 0) + return charged.length > 0 ? charged : [settledAt ? { ...current, timestamp: settledAt } : current]; + return [ + ...charged, + ...refunds.map((operation, index) => { + const n = index + chargeItems.length; + return { + ...current, + ...(n > 0 && { id: `${item.id}:${n}` }), + amount: operation.amount, + operations: [operation], + timestamp: operation.timestamp, + usdAmount: operation.usdAmount, + }; + }), + ]; + }) + .filter( + (item) => + (item.type !== "panda" || item.status === "settled" || item.usdAmount < 0) && + (maturity === undefined || + (Date.parse(item.timestamp) / 1000 > maturity - MATURITY_INTERVAL && + Date.parse(item.timestamp) / 1000 <= maturity)), + ); + return c.body( + new Uint8Array( + await renderToBuffer( + AccountStatement({ + account: mask(account), + activities: items.map((item) => { + if ("merchant" in item) { + const movement = { + id: item.id, + timestamp: item.timestamp, + amount: -item.usdAmount, + title: item.merchant.name, + }; + if (-item.usdAmount > 0) return { ...movement, detail: `Refund – Card **** ${item.lastFour}` }; + if (item.type === "panda" ? item.operations.some(({ mode }) => mode > 0) : item.mode > 0) + return { ...movement, detail: `Credit purchase – Card **** ${item.lastFour}` }; + return { ...movement, detail: `Debit purchase – Card **** ${item.lastFour}` }; + } + switch (item.type) { + case "received": + return { + id: item.id, + timestamp: item.timestamp, + amount: item.usdAmount, + title: "Funds added", + detail: `${item.amount} ${item.currency}`, + }; + case "repay": + return { + id: item.id, + timestamp: item.timestamp, + amount: -item.usdAmount, + title: "Debt payment", + detail: `${item.amount} ${item.currency}`, + }; + case "sent": + return { + id: item.id, + timestamp: item.timestamp, + amount: -item.usdAmount, + title: `Sent to ${mask(item.receiver)}`, + detail: `${item.amount} ${item.currency}`, + }; + default: + throw new Error("unsupported activity type", { cause: item }); + } + }), + cards: [ + ...Map.groupBy( + items.filter((item) => "merchant" in item), + ({ cardId }) => cardId, + ), + ].map(([cardId, cardItems]) => ({ + amount: cardItems.reduce( + (sum, item) => + sum + + (item.usdAmount > 0 && + (item.type === "panda" ? item.operations.every(({ mode }) => mode <= 0) : item.mode <= 0) + ? item.usdAmount + : 0), + 0, + ), + lastFour: cardItems.at(0)?.lastFour ?? "", + cardId, + })), + period: + maturity === undefined + ? undefined + : new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", + timeZone: "UTC", + year: "numeric", + }).formatRange(new Date((maturity - MATURITY_INTERVAL) * 1000), new Date(maturity * 1000)), + }), + ), + ), + 200, + { "content-type": "application/pdf" }, + ); + } + if (maturity !== undefined && pdf) { const purchasesByCard = Map.groupBy( response.flatMap((item) => { @@ -469,30 +671,37 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg }), ({ cardId }) => cardId, ); - const statement = { - account: `${account.slice(0, 6)}...${account.slice(-6)}`, - maturity, - cards: purchases - .filter(({ id }) => purchasesByCard.has(id)) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - .map(({ id, lastFour }) => ({ - id, - lastFour, - purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest), - })), - payments: response - .filter((item) => item.type === "repay") - .filter((repay) => repay.currency === market(marketUSDCAddress).symbol) - .map(({ id, timestamp, amount, positionAmount }) => ({ - id, - timestamp, - amount, - positionAmount, - })), - }; - return c.body(new Uint8Array(await renderToBuffer(Statement(statement))), 200, { - "content-type": "application/pdf", - }); + return c.body( + new Uint8Array( + await renderToBuffer( + Statement({ + account: mask(account), + maturity, + cards: purchases + .filter(({ id }) => purchasesByCard.has(id)) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map(({ id, lastFour }) => ({ + id, + lastFour, + purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest), + })), + payments: response + .filter((item) => item.type === "repay") + .filter((repay) => repay.currency === market(marketUSDCAddress).symbol) + .map(({ id, timestamp, amount, positionAmount }) => ({ + id, + timestamp, + amount, + positionAmount, + })), + }), + ), + ), + 200, + { + "content-type": "application/pdf", + }, + ); } return c.json(response, 200); }, @@ -501,6 +710,17 @@ export default function route({ auth, database }: { auth: Auth; database: NodePg const Borrow = object({ maturity: bigint(), assets: bigint(), fee: bigint() }); +type PandaMetadata = { completion?: string; refund?: boolean; spendStatus?: string; type: string }; + +function isRefund(operation: PandaMetadata) { + return operation.refund === true; +} + +function stringAt(activity: PandaMetadata, property: "completion" | "spendStatus") { + const value = activity[property]; + return typeof value === "string" ? value : undefined; +} + export const PandaActivity = pipe( object({ bodies: array( @@ -542,7 +762,14 @@ export const PandaActivity = pipe( blockTimestamp: borrow?.timestamp, }, ); - if (validation.success) return validation.output; + if (validation.success) { + const amount = body?.body.spend.amount; + Object.defineProperties(validation.output, { + refund: { value: body?.action === "completed" && typeof amount === "number" && amount < 0 }, + spendStatus: { value: body?.body.spend.status }, + }); + return validation.output; + } throw new Error("bad panda activity"); }) .filter((p) => p.provider === "panda"); @@ -601,7 +828,7 @@ export const PandaActivity = pipe( .reduce((sum, { usdAmount: amount }) => sum + amount, 0); const exchangeRate = flow.completed?.exchangeRate ?? [flow.created, ...flow.updates].at(-1)?.exchangeRate; if (exchangeRate === undefined) throw new Error("no exchange rate"); - return { + const result = { id, currency, amount: usdAmount * exchangeRate, @@ -619,6 +846,12 @@ export const PandaActivity = pipe( status: declined ? ("declined" as const) : flow.completed ? ("settled" as const) : ("pending" as const), ...(declined && { reason: declined.reason ?? "transaction declined" }), }; + Object.defineProperty(result, "completion", { + value: operations.findLast( + ({ action, transactionHash }) => action === "completed" && transactionHash === zeroHash, + )?.timestamp, + }); + return result; }), ); @@ -638,6 +871,7 @@ const PandaBase = { merchantName: string(), authorizationUpdateAmount: optional(number()), enrichedMerchantIcon: optional(string()), + status: optional(picklist(["completed", "declined", "pending", "reversed"])), }), }), forceCapture: boolean(), @@ -695,7 +929,7 @@ function transformCard(activity: InferOutput) { const usdAmount = (function () { if (activity.action === "completed") { - if (activity.forceCapture) return activity.body.spend.amount; + if (activity.forceCapture || activity.body.spend.amount < 0) return activity.body.spend.amount; return activity.body.spend.amount - (activity.body.spend.authorizedAmount ?? 0); } return activity.body.spend.authorizationUpdateAmount ?? activity.body.spend.amount; @@ -839,6 +1073,25 @@ export const WithdrawActivity = pipe( })), ); +async function findBlock(timestamp: Date, upper = false) { + const target = BigInt(Math.floor(timestamp.getTime() / 1000)); + const latest = await publicClient.getBlock({ blockTag: "latest" }); + if (upper && latest.timestamp <= target) return latest.number; + let low = 0n; + let high = latest.number; + while (low < high) { + const blockNumber = (low + high) / 2n; + const block = await publicClient.getBlock({ blockNumber }); + if (block.timestamp < target || (upper && block.timestamp === target)) low = blockNumber + 1n; + else high = blockNumber; + } + return upper && low > 0n ? low - 1n : low; +} + +function mask(address: Address) { + return `${address.slice(0, 6)}...${address.slice(-6)}`; +} + function forbid(value: T) { return new Proxy(value, { /* v8 ignore start */ diff --git a/server/test/api/activity.test.ts b/server/test/api/activity.test.ts index 0040f18178..bd243450b4 100644 --- a/server/test/api/activity.test.ts +++ b/server/test/api/activity.test.ts @@ -8,21 +8,50 @@ import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; import assert from "node:assert"; -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; import { safeParse, type InferOutput } from "valibot"; -import { padHex, zeroHash, type Hash } from "viem"; +import { padHex, parseEventLogs, zeroHash, type Hash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; -import { marketAbi } from "@exactly/common/generated/chain"; +import chain, { marketAbi } from "@exactly/common/generated/chain"; +import { MATURITY_INTERVAL } from "@exactly/lib"; import route, { CreditActivity, DebitActivity, InstallmentsActivity, PandaActivity } from "../../api/activity"; import database, { cards, credentials, transactions } from "../../database"; import authenticate from "../../middleware/auth"; import anvilClient from "../anvilClient"; +import type * as accountStatement from "../../utils/AccountStatement"; +import type * as statement from "../../utils/Statement"; + +const mocks = vi.hoisted(() => ({ + accountStatement: vi.fn<(properties: Parameters[0]) => void>(), + statement: vi.fn<(properties: Parameters[0]) => void>(), +})); + +vi.mock("../../utils/AccountStatement", async (importOriginal) => { + const module = await importOriginal(); + return { + ...module, + default: (properties: Parameters[0]) => { + mocks.accountStatement(properties); + return module.default(properties); + }, + }; +}); + +vi.mock("../../utils/Statement", async (importOriginal) => { + const module = await importOriginal(); + return { + ...module, + default: (properties: Parameters[0]) => { + mocks.statement(properties); + return module.default(properties); + }, + }; +}); + function httpSerialize(object: T): T { const cloned = structuredClone(object); return removeUndefined(cloned) as T; @@ -89,35 +118,72 @@ describe.concurrent("authenticated", () => { let activity: CardActivity[]; let installment: { hash: Hash; maturity: string }; let maturity: string; + let amount: number; + let purchaseId: string; + let creditPurchaseId: string; + let repayment: { hash: Hash; logIndex: number }; beforeAll(async () => { await database.insert(cards).values([ { id: "first-activity-card", credentialId: "bob", lastFour: "1234" }, { id: "second-activity-card", credentialId: "bob", lastFour: "6789" }, ]); - const borrows = await anvilClient.getContractEvents({ + const borrows = await anvilClient + .getContractEvents({ + abi: marketAbi, + eventName: "BorrowAtMaturity", + address: [inject("MarketEXA"), inject("MarketUSDC"), inject("MarketWETH")], + args: { borrower: account }, + toBlock: "latest", + fromBlock: 0n, + strict: true, + }) + .then((events) => events.toSorted(order)); + assert.ok(borrows[0], "expected at least one BorrowAtMaturity event"); + maturity = String(borrows[0].args.maturity); + amount = Number(borrows[0].args.assets) / 1e6; + const repayments = await anvilClient.getContractEvents({ abi: marketAbi, - eventName: "BorrowAtMaturity", - address: [inject("MarketEXA"), inject("MarketUSDC"), inject("MarketWETH")], + eventName: "RepayAtMaturity", + address: inject("MarketUSDC"), args: { borrower: account }, toBlock: "latest", fromBlock: 0n, strict: true, }); - assert.ok(borrows[0], "expected at least one BorrowAtMaturity event"); - maturity = String(borrows[0].args.maturity); + const selected = repayments.find(({ args }) => args.maturity === BigInt(maturity)); + assert.ok(selected, "expected repayment for the selected maturity"); + repayment = { hash: selected.transactionHash, logIndex: selected.logIndex }; const logs = [ ...borrows, - ...(await anvilClient.getContractEvents({ - abi: marketAbi, - eventName: "Withdraw", - address: [inject("MarketEXA"), inject("MarketUSDC"), inject("MarketWETH")], - args: { owner: account }, - toBlock: "latest", - fromBlock: 0n, - strict: true, - })), + ...(await anvilClient + .getContractEvents({ + abi: marketAbi, + eventName: "Withdraw", + address: [inject("MarketEXA"), inject("MarketUSDC"), inject("MarketWETH")], + args: { owner: account }, + toBlock: "latest", + fromBlock: 0n, + strict: true, + }) + .then((events) => events.toSorted(order))), ]; + const receipts = await Promise.all( + [...new Set(logs.map(({ transactionHash }) => transactionHash))].map(async (transactionHash) => ({ + transactionHash, + receipt: await anvilClient.getTransactionReceipt({ hash: transactionHash }), + })), + ); + const receiptHash = receipts.find( + ({ receipt }) => + parseEventLogs({ + abi: marketAbi, + eventName: "BorrowAtMaturity", + logs: receipt.logs.filter(({ address }) => address.toLowerCase() === inject("MarketUSDC").toLowerCase()), + strict: true, + }).length === 0, + )?.transactionHash; + assert.ok(receiptHash, "expected a non-borrow receipt hash"); const timestamps = await Promise.all( [...new Set(logs.map(({ blockNumber }) => blockNumber))].map((blockNumber) => anvilClient.getBlock({ blockNumber }), @@ -184,6 +250,31 @@ describe.concurrent("authenticated", () => { blockTimestamp, }; }), + { + id: "transaction-pending", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [zeroHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "pending", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(3).toISOString(), + body: { + id: "transaction-pending", + spend: { ...spendTemplate, amount: 1500, localAmount: 1500 }, + }, + }, + ], + }, + }, { id: "transaction-declined", cardId: "first-activity-card", @@ -226,6 +317,407 @@ describe.concurrent("authenticated", () => { ], }, }, + { + id: "transaction-refund", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "refund", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-refund", + spend: { ...spendTemplate, amount: 0, localAmount: 0 }, + }, + }, + { + action: "updated", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-refund", + spend: { ...spendTemplate, authorizationUpdateAmount: -500, status: "completed" }, + }, + }, + ], + }, + }, + { + id: "transaction-completed-refund", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "refund", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-completed-refund", + spend: { ...spendTemplate, amount: -500, localAmount: -500, status: "completed" }, + }, + }, + ], + }, + }, + { + id: "transaction-completed-refund-after-created", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "refund", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date((Number(maturity) - 1000) * 1000).toISOString(), + body: { + id: "transaction-completed-refund-after-created", + spend: { ...spendTemplate, amount: 2000, localAmount: 2000 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-completed-refund-after-created", + spend: { + ...spendTemplate, + amount: -2000, + authorizedAmount: -2000, + localAmount: -2000, + status: "completed", + }, + }, + }, + ], + }, + }, + { + id: "transaction-partial-refund", + cardId: "second-activity-card", + lastFour: "6789", + hashes: [receiptHash, receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "partial refund", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-partial-refund", + spend: { ...spendTemplate, amount: 1000, localAmount: 1000 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-partial-refund", + spend: { + ...spendTemplate, + amount: 1000, + localAmount: 1000, + authorizedAmount: 1000, + status: "completed", + }, + }, + }, + { + action: "updated", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-partial-refund", + spend: { ...spendTemplate, authorizationUpdateAmount: -500, status: "completed" }, + }, + }, + ], + }, + }, + { + id: "transaction-settled-after-authorization", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, zeroHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "settlement", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date((Number(maturity) - MATURITY_INTERVAL - 1) * 1000).toISOString(), + body: { + id: "transaction-settled-after-authorization", + spend: { ...spendTemplate, amount: 1000, localAmount: 1000 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-settled-after-authorization", + spend: { ...spendTemplate, amount: 1000, localAmount: 1000, authorizedAmount: 1000 }, + }, + }, + ], + }, + }, + { + id: "transaction-mixed-mode", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, borrows[0].transactionHash], + hash: borrows[0].transactionHash, + blockNumber: borrows[0].blockNumber, + eventName: "mixed mode", + events: [borrows[0].args], + blockTimestamp: timestamps.get(borrows[0].blockNumber)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-mixed-mode", + spend: { ...spendTemplate, amount: 1000, localAmount: 1000 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-mixed-mode", + spend: { + ...spendTemplate, + amount: 1000, + authorizedAmount: 1000, + localAmount: 1000, + status: "completed", + }, + }, + }, + ], + }, + }, + { + id: "transaction-reversed", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "reversal", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date((Number(maturity) - MATURITY_INTERVAL - 1) * 1000).toISOString(), + body: { + id: "transaction-reversed", + spend: { ...spendTemplate, amount: 500, localAmount: 500 }, + }, + }, + { + action: "updated", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-reversed", + spend: { ...spendTemplate, authorizationUpdateAmount: -500, status: "reversed" }, + }, + }, + ], + }, + }, + { + id: "transaction-pending-adjustment", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "pending adjustment", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-pending-adjustment", + spend: { ...spendTemplate, amount: 2000, localAmount: 2000 }, + }, + }, + { + action: "updated", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-pending-adjustment", + spend: { + ...spendTemplate, + amount: 1500, + localAmount: 1500, + authorizationUpdateAmount: -500, + status: "pending", + }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-pending-adjustment", + spend: { ...spendTemplate, amount: 1500, localAmount: 1500, authorizedAmount: 1500 }, + }, + }, + ], + }, + }, + { + id: "transaction-settled-below-authorization", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "settlement adjustment", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-settled-below-authorization", + spend: { ...spendTemplate, amount: 2000, localAmount: 2000 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-settled-below-authorization", + spend: { ...spendTemplate, amount: 1500, localAmount: 1500, authorizedAmount: 2000 }, + }, + }, + ], + }, + }, + { + id: "transaction-old-charge-period-refund", + cardId: "first-activity-card", + lastFour: "1234", + hashes: [receiptHash, receiptHash, receiptHash], + hash: zeroHash, + blockNumber: 0n, + eventName: "period refund", + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date((Number(maturity) - MATURITY_INTERVAL - 1) * 1000).toISOString(), + body: { + id: "transaction-old-charge-period-refund", + spend: { ...spendTemplate, amount: 2000, localAmount: 2000 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date((Number(maturity) - MATURITY_INTERVAL - 1) * 1000).toISOString(), + body: { + id: "transaction-old-charge-period-refund", + spend: { ...spendTemplate, amount: 2000, localAmount: 2000 }, + }, + }, + { + action: "updated", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id: "transaction-old-charge-period-refund", + spend: { ...spendTemplate, authorizationUpdateAmount: -500, status: "completed" }, + }, + }, + ], + }, + }, + periodTransaction( + "transaction-period-start", + "period start", + new Date((Number(maturity) - MATURITY_INTERVAL) * 1000).toISOString(), + { + amount: 100, + localAmount: 100, + }, + ), + periodTransaction("transaction-period-end", "period end", new Date(Number(maturity) * 1000).toISOString(), { + amount: 0, + localAmount: 0, + }), + periodTransaction( + "transaction-period-outside", + "period outside", + new Date((Number(maturity) - MATURITY_INTERVAL - 1) * 1000).toISOString(), + { amount: 0, localAmount: 0 }, + ), ]; await database @@ -263,6 +755,20 @@ describe.concurrent("authenticated", () => { const second = operation.borrow.installments[1]; assert.ok(second, "expected second installment"); installment = { hash: operation.transactionHash, maturity: String(second.maturity) }; + const purchase = activity.find((item) => { + if (item.id === "transaction-mixed-mode") return false; + if ("operations" in item) + return item.operations.some(({ transactionHash }) => transactionHash === borrows[0]?.transactionHash); + return item.transactionHash === borrows[0]?.transactionHash; + }); + assert.ok(purchase, "expected maturity purchase"); + purchaseId = purchase.id; + const pandaCreditPurchase = activity.find( + (item) => + item.id !== "transaction-mixed-mode" && item.type === "panda" && item.operations.some(({ mode }) => mode > 0), + ); + assert.ok(pandaCreditPurchase, "expected panda credit purchase"); + creditPurchaseId = pandaCreditPurchase.id; }, 66_666); it("returns the card transaction", async () => { @@ -437,8 +943,10 @@ describe.concurrent("authenticated", () => { it("returns statement pdf", async () => { expect.hasAssertions(); + mocks.statement.mockClear(); + mocks.accountStatement.mockClear(); const response = await appClient.index.$get( - { query: { maturity } }, + { query: { maturity, include: ["card", "repay"] } }, { headers: { "test-credential-id": "bob", accept: "application/pdf" } }, ); @@ -446,9 +954,8 @@ describe.concurrent("authenticated", () => { expect(response.headers.get("content-type")).toBe("application/pdf"); const body = await response.arrayBuffer(); expect(body.byteLength).toBeGreaterThan(0); - const directory = path.join("node_modules/@exactly/.runtime"); - await mkdir(directory, { recursive: true }); - await writeFile(path.join(directory, `statement-${Date.now()}.pdf`), new Uint8Array(body)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact path includes timestamp + expect(mocks.statement).toHaveBeenCalledOnce(); + expect(mocks.accountStatement).not.toHaveBeenCalled(); }); it("returns statement pdf with mixed borrow operations", async () => { @@ -461,7 +968,7 @@ describe.concurrent("authenticated", () => { { id: "panda-mixed-operations", cardId: "first-activity-card", - hashes: [hash, padHex("0xdeb17", { size: 32 })], + hashes: [hash, zeroHash], payload: { type: "panda", bodies: ["created", "completed"].map((action) => ({ @@ -477,7 +984,7 @@ describe.concurrent("authenticated", () => { }, ]); const response = await appClient.index.$get( - { query: { maturity } }, + { query: { maturity, include: ["card", "repay"] } }, { headers: { "test-credential-id": "bob", accept: "application/pdf" } }, ); @@ -493,7 +1000,7 @@ describe.concurrent("authenticated", () => { it("returns statement pdf for combined accept header", async () => { expect.hasAssertions(); const response = await appClient.index.$get( - { query: { maturity } }, + { query: { maturity, include: ["card", "repay"] } }, { headers: { "test-credential-id": "bob", accept: "application/pdf, */*" } }, ); @@ -515,6 +1022,260 @@ describe.concurrent("authenticated", () => { expect(Array.isArray(await response.json())).toBe(true); }); + it.sequential("rejects account statement pdf without maturity", async () => { + expect.hasAssertions(); + mocks.statement.mockClear(); + mocks.accountStatement.mockClear(); + const response = await appClient.index.$get( + {}, + { headers: { "test-credential-id": "bob", accept: "application/pdf" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "maturity required for account statement pdf" }); + expect(mocks.accountStatement).not.toHaveBeenCalled(); + expect(mocks.statement).not.toHaveBeenCalled(); + }); + + it.sequential("returns account statement pdf with maturity", async () => { + expect.hasAssertions(); + mocks.statement.mockClear(); + mocks.accountStatement.mockClear(); + const hash = activity + .flatMap((item) => ("operations" in item ? item.operations : [])) + .find((operation) => "borrow" in operation)?.transactionHash; + assert.ok(hash, "expected maturity purchase hash"); + const response = await appClient.index.$get( + { query: { maturity } }, + { headers: { "test-credential-id": "bob", accept: "application/pdf" } }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/pdf"); + expect(await response.arrayBuffer().then(({ byteLength }) => byteLength)).toBeGreaterThan(0); + expect(mocks.accountStatement).toHaveBeenCalledOnce(); + expect(mocks.statement).not.toHaveBeenCalled(); + const statement = mocks.accountStatement.mock.calls[0]?.[0]; + assert.ok(statement, "expected account statement properties"); + expect(statement.cards.find(({ cardId }) => cardId === "first-activity-card")).toStrictEqual({ + amount: 75, + cardId: "first-activity-card", + lastFour: "1234", + }); + const second = statement.cards.find(({ cardId }) => cardId === "second-activity-card"); + assert.ok(second, "expected second card summary"); + expect(second.lastFour).toBe("6789"); + expect(typeof second.amount).toBe("number"); + const refundTimestamp = new Date(Number(maturity) * 1000).toISOString(); + expect( + statement.activities + .filter( + ({ id }) => id.startsWith("transaction-completed-refund") || id.startsWith("transaction-partial-refund"), + ) + .toSorted((a, b) => a.id.localeCompare(b.id)), + ).toStrictEqual([ + { + amount: 5, + detail: "Refund – Card **** 1234", + id: "transaction-completed-refund", + timestamp: refundTimestamp, + title: "once", + }, + { + amount: -20, + detail: "Debit purchase – Card **** 1234", + id: "transaction-completed-refund-after-created", + timestamp: new Date((Number(maturity) - 1000) * 1000).toISOString(), + title: "once", + }, + { + amount: 20, + detail: "Refund – Card **** 1234", + id: "transaction-completed-refund-after-created:1", + timestamp: refundTimestamp, + title: "once", + }, + { + amount: -10, + detail: "Debit purchase – Card **** 6789", + id: "transaction-partial-refund", + timestamp: refundTimestamp, + title: "once", + }, + { + amount: 5, + detail: "Refund – Card **** 6789", + id: "transaction-partial-refund:1", + timestamp: refundTimestamp, + title: "once", + }, + ]); + expect(statement.period).toBe( + new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", + timeZone: "UTC", + year: "numeric", + }).formatRange(new Date((Number(maturity) - MATURITY_INTERVAL) * 1000), new Date(Number(maturity) * 1000)), + ); + expect(statement.activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ amount: 2500, detail: "1 WETH", title: "Funds added" }), + expect.objectContaining({ + amount: -25, + detail: "0.01 WETH", + title: "Sent to 0x0000...000069", + }), + ]), + ); + const repays = await anvilClient.getContractEvents({ + abi: marketAbi, + eventName: "RepayAtMaturity", + address: inject("MarketUSDC"), + args: { borrower: account }, + toBlock: "latest", + fromBlock: 0n, + strict: true, + }); + const repay = repays.find( + ({ logIndex, transactionHash }) => transactionHash === repayment.hash && logIndex === repayment.logIndex, + ); + assert.ok(repay, "expected USDC debt payment"); + const repayBlock = await anvilClient.getBlock({ blockNumber: repay.blockNumber }); + const repayAmount = Number(repay.args.assets) / 1e6; + const repayId = `${chain.id}:${repay.blockNumber}:${repay.transactionIndex}:${repay.logIndex}`; + expect(statement.activities.find(({ id }) => id === repayId)).toStrictEqual({ + amount: -repayAmount, + detail: `${repayAmount} USDC`, + id: repayId, + timestamp: new Date(Number(repayBlock.timestamp) * 1000).toISOString(), + title: "Debt payment", + }); + expect( + statement.activities.every( + ({ timestamp }) => + Date.parse(timestamp) / 1000 > Number(maturity) - MATURITY_INTERVAL && + Date.parse(timestamp) / 1000 <= Number(maturity), + ), + ).toBe(true); + expect(statement.activities.find(({ id }) => id === purchaseId)).toMatchObject({ + amount: -amount, + detail: "Credit purchase – Card **** 1234", + }); + expect(statement.activities.find(({ id }) => id === creditPurchaseId)).toMatchObject({ + detail: "Credit purchase – Card **** 6789", + }); + expect(statement.activities.find(({ id }) => id === "transaction-mixed-mode")).toMatchObject({ + amount: -10, + detail: "Debit purchase – Card **** 1234", + }); + expect(statement.activities.find(({ id }) => id === "transaction-period-start")).toBeUndefined(); + expect(statement.activities.find(({ id }) => id === "transaction-period-end")).toMatchObject({ + timestamp: new Date(Number(maturity) * 1000).toISOString(), + }); + expect(statement.activities.find(({ id }) => id === "transaction-period-outside")).toBeUndefined(); + expect(statement.activities.find(({ id }) => id === "transaction-settled-after-authorization")).toMatchObject({ + timestamp: new Date(Number(maturity) * 1000).toISOString(), + }); + expect(statement.activities.find(({ id }) => id === "transaction-reversed")).toBeUndefined(); + expect(statement.activities.find(({ id }) => id === "transaction-settled-below-authorization")).toMatchObject({ + amount: -15, + detail: "Debit purchase – Card **** 1234", + timestamp: new Date(Number(maturity) * 1000).toISOString(), + }); + expect(statement.activities.find(({ id }) => id === "transaction-old-charge-period-refund")).toBeUndefined(); + expect(statement.activities.find(({ id }) => id === "transaction-old-charge-period-refund:1")).toMatchObject({ + amount: 5, + detail: "Refund – Card **** 1234", + timestamp: refundTimestamp, + }); + expect(statement.activities.some(({ id }) => id.startsWith("transaction-settled-below-authorization:"))).toBe( + false, + ); + expect(statement.activities.find(({ id }) => id === "transaction-pending-adjustment")).toMatchObject({ + amount: -20, + detail: "Debit purchase – Card **** 1234", + }); + expect(statement.activities.find(({ id }) => id === "transaction-pending-adjustment:1")).toBeUndefined(); + expect(statement.activities.map(({ detail }) => detail)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^Credit purchase – Card /), + expect.stringMatching(/^Debit purchase – Card /), + ]), + ); + }); + + it.sequential("preserves settled authorization increases", async () => { + const id = "transaction-settled-after-increase"; + const source = activity.find(({ id: activityId }) => activityId === creditPurchaseId); + assert.ok(source && "operations" in source, "expected credit purchase"); + const hash = source.operations.find((operation) => "borrow" in operation)?.transactionHash; + assert.ok(hash, "expected credit purchase hash"); + mocks.accountStatement.mockClear(); + try { + await database.insert(transactions).values([ + { + id, + cardId: "first-activity-card", + hashes: [hash, hash, hash], + payload: { + type: "panda", + bodies: [ + { + action: "created", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { id, spend: { ...spendTemplate, amount: 1000, localAmount: 1000 } }, + }, + { + action: "updated", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id, + spend: { ...spendTemplate, amount: 1500, localAmount: 1500, authorizationUpdateAmount: 500 }, + }, + }, + { + action: "completed", + resource: "transaction", + createdAt: new Date(Number(maturity) * 1000).toISOString(), + body: { + id, + spend: { ...spendTemplate, amount: 1500, localAmount: 1500, authorizedAmount: 1500 }, + }, + }, + ], + }, + }, + ]); + const response = await appClient.index.$get( + { query: { maturity } }, + { headers: { "test-credential-id": "bob", accept: "application/pdf" } }, + ); + expect(response.status).toBe(200); + const statement = mocks.accountStatement.mock.calls[0]?.[0]; + assert.ok(statement, "expected account statement properties"); + expect(statement.activities.find(({ id: activityId }) => activityId === id)).toMatchObject({ + amount: -15, + detail: "Credit purchase – Card **** 1234", + }); + } finally { + await database.delete(transactions).where(eq(transactions.id, id)); + } + }); + + it("rejects filtered pdf without maturity", async () => { + expect.hasAssertions(); + const response = await appClient.index.$get( + { query: { include: "received" } }, + { headers: { "test-credential-id": "bob", accept: "application/pdf" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "maturity required for filtered pdf" }); + }); + it("scopes maturity transaction lookup to user cards", async () => { expect.hasAssertions(); const before = await appClient.index.$get( @@ -562,7 +1323,7 @@ describe.concurrent("authenticated", () => { { query: { include: "card", maturity } }, { headers: { "test-credential-id": "bob" } }, ); - expect((await after.json()) as unknown[]).toHaveLength(baseline.length); + expect(await after.json()).toStrictEqual(baseline); } finally { await database.delete(transactions).where(eq(transactions.id, leak.transactionId)); await database.delete(cards).where(eq(cards.id, leak.cardId)); @@ -937,7 +1698,7 @@ describe.concurrent("authenticated", () => { vi.mock("@sentry/node", { spy: true }); afterEach(() => { - vi.clearAllMocks(); + vi.mocked(captureException).mockClear(); vi.restoreAllMocks(); }); @@ -962,3 +1723,37 @@ const spendTemplate = { userId: "f5eb6ea9-e9ba-4e2f-b16a-94a99f32385c", userLastName: "SAMPLEapproved", }; + +function periodTransaction(id: string, eventName: string, createdAt: string, spend: Partial) { + return { + id, + cardId: "first-activity-card", + lastFour: "1234", + hashes: [zeroHash], + hash: zeroHash, + blockNumber: 0n, + eventName, + events: [], + blockTimestamp: 0n, + payload: { + type: "panda", + bodies: [ + { + action: "completed", + resource: "transaction", + createdAt, + body: { id, spend: { ...spendTemplate, ...spend } }, + }, + ], + }, + }; +} + +function order( + a: { blockNumber: bigint; logIndex: number; transactionIndex: number }, + b: { blockNumber: bigint; logIndex: number; transactionIndex: number }, +) { + if (a.blockNumber !== b.blockNumber) return a.blockNumber < b.blockNumber ? -1 : 1; + if (a.transactionIndex !== b.transactionIndex) return a.transactionIndex - b.transactionIndex; + return a.logIndex - b.logIndex; +} diff --git a/server/test/utils/account-statement.test.ts b/server/test/utils/account-statement.test.ts new file mode 100644 index 0000000000..57712d0635 --- /dev/null +++ b/server/test/utils/account-statement.test.ts @@ -0,0 +1,226 @@ +import { renderToBuffer } from "@react-pdf/renderer"; +import { isValidElement } from "react"; + +import { inflateSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; + +import AccountStatement from "../../utils/AccountStatement"; + +describe("account statement rendering", () => { + it("renders all activity types", async () => { + const statement = { + account: "0x92bD...e82AB8", + period: "December, 2025", + cards: [{ amount: 1407, cardId: "card-6789", lastFour: "6789" }], + activities: [ + { + id: "purchase-1", + timestamp: "2025-12-19T11:35:11.030Z", + amount: -50.25, + title: "grocery store", + detail: "Debit purchase – Card **** 1234", + }, + { + id: "deposit-1", + timestamp: "2025-12-19T11:35:11.030Z", + amount: 100, + title: "Funds added", + detail: "1.45 ETH", + }, + { + id: "repay-1", + timestamp: "2025-12-19T11:35:11.030Z", + amount: -30, + title: "Debt payment", + detail: "942.63 USDC", + }, + { + id: "withdraw-1", + timestamp: "2025-12-19T11:35:11.030Z", + amount: -20, + title: "Sent to 0x92bD...e82AB8", + detail: "200 USD", + }, + ], + }; + const pdf = await renderToBuffer(AccountStatement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + expect(extractPages(pdf)).toHaveLength(1); + const text = collectText(AccountStatement(statement)); + expect(text).not.toContain("Account balance"); + expect(text).not.toContain("As of 19/12/2025"); + expect(text).toContain("Card **** 6789"); + expect(text).toContain("Debit purchases in the period"); + expect(text).toContain("$1,407.00"); + expect(text).toContain("Account movements"); + expect(text).toContain("Dec 19, 2025"); + expect(text).toContain("grocery store"); + expect(text).toContain("Debit purchase – Card **** 1234"); + expect(text).toContain("-$50.25"); + expect(text).toContain("Funds added"); + expect(text).toContain("1.45 ETH"); + expect(text).toContain("$100.00"); + expect(text).toContain("Debt payment"); + expect(text).toContain("942.63 USDC"); + expect(text).toContain("-$30.00"); + expect(text).toContain("Sent to 0x92bD...e82AB8"); + expect(text).toContain("200 USD"); + expect(text).toContain("-$20.00"); + expect(text).not.toContain("ACCOUNT BALANCE"); + expect(text).not.toContain("TOTAL BALANCE"); + expect(text).not.toContain("—"); + expect(collectText(AccountStatement({ ...statement, period: undefined }))).not.toContain("Debit purchases"); + }); + + it.each([ + [14, 1], + [15, 1], + [23, 2], + [37, 2], + ])("flows rows across the %d-row page boundary", async (count, total) => { + const statement = { + account: "0x92bD...e82AB8", + period: "December, 2025", + cards: [], + activities: Array.from({ length: count }, (_, index) => ({ + id: `movement-${index}-row`, + timestamp: new Date(Date.UTC(2025, 0, index + 1)).toISOString(), + amount: 10, + title: `movement-${index}-row`, + detail: "1 USDC", + })), + }; + const pdf = await renderToBuffer(AccountStatement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + const pages = extractPages(pdf); + expect(pages).toHaveLength(total); + expect(pages.every(({ text }) => ["DATE", "MOVEMENT", "AMOUNT"].every((label) => text.includes(label)))).toBe(true); + expect(pages.map(({ raw }) => /\d+ • \d+$/.exec(extractText(raw))?.[0]?.replace(/^0+(?=\d)/, ""))).toEqual( + Array.from({ length: total }, (_, index) => `${index + 1} • ${total}`), + ); + for (let index = 0; index < count; index++) { + expect(pages.filter(({ text }) => text.includes(`movement-${index}-row`))).toHaveLength(1); + } + expect(pages[0]?.text).toContain("movement-0"); + expect(pages.at(-1)?.text).toContain(`movement-${count - 1}`); + expect(pages.every(({ text }) => !text.includes("ACCOUNT BALANCE"))).toBe(true); + expect(pages.every(({ text }) => !text.includes("TOTAL BALANCE"))).toBe(true); + }); + + it("renders cards and movements together", async () => { + const statement = { + account: "0x92bD...e82AB8", + period: "December, 2025", + cards: Array.from({ length: 6 }, (_, index) => ({ + amount: index + 1, + cardId: `card-${index}`, + lastFour: `${index}${index}${index}${index}`, + })), + activities: Array.from({ length: 37 }, (_, index) => ({ + id: `movement-${index}-row`, + timestamp: new Date(Date.UTC(2025, 0, index + 1)).toISOString(), + amount: 10, + title: `movement-${index}-row`, + detail: "1 USDC", + })), + }; + const pdf = await renderToBuffer(AccountStatement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + const pages = extractPages(pdf); + expect(pages).toHaveLength(3); + expect(pages[0]?.text).toContain("Card **** 0000"); + expect(pages[0]?.text).toContain("Card **** 5555"); + expect(pages[0]?.text).toContain("movement-0"); + expect(pages.every(({ text }) => ["DATE", "MOVEMENT", "AMOUNT"].every((label) => text.includes(label)))).toBe(true); + expect(pages.at(-1)?.text).toContain("movement-36"); + expect(pages.every(({ text }) => !text.includes("ACCOUNT BALANCE"))).toBe(true); + expect(pages.every(({ text }) => !text.includes("TOTAL BALANCE"))).toBe(true); + }); + + it("keeps the movement heading with the table start after summary cards", async () => { + const statement = { + account: "0x92bD...e82AB8", + period: "December, 2025", + cards: Array.from({ length: 6 }, (_, index) => ({ + amount: index + 1, + cardId: `card-${index}`, + lastFour: `${index}${index}${index}${index}`, + })), + activities: Array.from({ length: 14 }, (_, index) => ({ + id: `movement-${index}-row`, + timestamp: new Date(Date.UTC(2025, 0, index + 1)).toISOString(), + amount: 10, + title: `movement-${index}-row`, + detail: "1 USDC", + })), + }; + const pdf = await renderToBuffer(AccountStatement(statement)); + const pages = extractPages(pdf); + const tableStartPage = pages.find(({ text }) => text.includes("Account movements")); + expect(tableStartPage?.text).toContain("DATE"); + expect(tableStartPage?.text).toContain("movement-0-row"); + }); + + it("renders many cards with movements on one page", async () => { + const statement = { + account: "0x92bD...e82AB8", + period: "December, 2025", + cards: Array.from({ length: 9 }, (_, index) => ({ + amount: index + 1, + cardId: `card-${index}`, + lastFour: `${index}${index}${index}${index}`, + })), + activities: [ + { + id: "movement-0-row", + timestamp: "2025-01-01T00:00:00.000Z", + amount: 10, + title: "movement-0-row", + detail: "1 USDC", + }, + ], + }; + const pdf = await renderToBuffer(AccountStatement(statement)); + const pages = extractPages(pdf); + expect(pages).toHaveLength(1); + expect(pages[0]?.text).toContain("Card **** 0000"); + expect(pages[0]?.text).toContain("Card **** 8888"); + expect(pages[0]?.text).toContain("movement-0-row"); + }); +}); + +function collectText(node: unknown): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map((element) => collectText(element)).join(""); + if (isValidElement<{ children?: unknown }>(node)) return collectText(node.props.children); + return ""; +} + +function extractPages(pdf: Uint8Array) { + const source = new TextDecoder("latin1").decode(pdf); + return [...source.matchAll(/\/Type \/Page\b[\s\S]{0,400}?\/Contents (\d+) 0 R/g)].map(([, reference]) => { + const object = [...source.matchAll(/(?:^|\n)(\d+) 0 obj[\s\S]*?endobj/g)] + .map((match) => ({ index: match.index, reference: match[1], value: match[0] })) // cspell:ignore endobj FlateDecode + .find(({ reference: id, value }) => id === reference && value.includes("/Filter /FlateDecode")); + if (object === undefined) throw new Error("expected compressed page content"); + const marker = source.indexOf("stream", object.index); + const stream = source[marker + 6] === "\r" ? marker + 8 : marker + 7; + const end = source.indexOf("endstream", stream); + const raw = new TextDecoder("latin1").decode(inflateSync(pdf.slice(stream, end))); + return { raw, text: extractText(raw) }; + }); +} + +function extractText(value: string) { + return [...value.matchAll(/<([0-9a-f]+)>/gi)] + .map(([, hex]) => hex) + .filter((hex): hex is string => hex !== undefined) + .map((hex) => + new TextDecoder("latin1").decode( + Uint8Array.from(hex.match(/../g)?.map((byte) => Number.parseInt(byte, 16)) ?? []), + ), + ) + .join("") + .replaceAll("\u0095", "•"); +} diff --git a/server/utils/AccountStatement.tsx b/server/utils/AccountStatement.tsx new file mode 100644 index 0000000000..9a4583cb9c --- /dev/null +++ b/server/utils/AccountStatement.tsx @@ -0,0 +1,210 @@ +import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer"; + +import { Logo } from "./Statement"; + +const date = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC", year: "numeric" }); + +const styles = StyleSheet.create({ + page: { backgroundColor: "#FFFFFF", paddingBottom: 48 }, + header: { backgroundColor: "#EEF1F0", paddingHorizontal: 56.625 }, + headerNext: { flexDirection: "row", justifyContent: "center", alignItems: "center", height: 58 }, + headerFirst: { + backgroundColor: "#EEF1F0", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + height: 139, + marginTop: -58, + paddingHorizontal: 56.625, + }, + headerLogoFirst: { marginTop: 14 }, + headerText: { flex: 1, alignItems: "flex-end", marginTop: 22 }, + title: { fontSize: 18, fontWeight: "600", color: "#000000" }, + headerDetail: { fontSize: 12, color: "#5F6462", marginTop: 2, textAlign: "right" }, + headerLabel: { fontWeight: "bold" }, + body: { paddingHorizontal: 56.625 }, + cards: { flexDirection: "row", flexWrap: "wrap", gap: 11.325, marginTop: 39.5 }, + card: { + backgroundColor: "#EEF1F0", + borderRadius: 4, + height: 80.625, + paddingHorizontal: 11.375, + paddingTop: 8.6, + width: 235.35, + }, + cardLabel: { fontSize: 14, fontWeight: "bold", color: "#1A201E" }, + cardDetail: { fontSize: 10, color: "#5F6462", marginTop: 1 }, + cardAmount: { fontFamily: "Courier", fontSize: 16, fontWeight: "bold", color: "#1A201E", marginTop: 8.5 }, + sectionTitle: { + fontSize: 18, + fontWeight: "bold", + color: "#000000", + marginHorizontal: 11.375, + marginTop: 37, + }, + tableHeader: { + flexDirection: "row", + borderBottom: "0.566 solid #EEF1F0", + paddingHorizontal: 11.375, + paddingBottom: 10.3, + marginTop: 16, + }, + tableLabel: { fontSize: 7.333, fontWeight: "bold", color: "#5F6462" }, + headerDate: { width: 65.333 }, + headerDesc: { flex: 1 }, + headerTotal: { width: 90, textAlign: "right" }, + tableRow: { flexDirection: "row", paddingHorizontal: 11.375 }, + tableRowSingle: { paddingVertical: 6.05 }, + tableRowDouble: { paddingVertical: 5.9 }, + movement: { flex: 1 }, + colDate: { width: 65.333, fontSize: 9.333, color: "#5F6462" }, + descText: { fontSize: 10, color: "#1A201E", maxLines: 1, textOverflow: "ellipsis" }, + movementDetail: { fontSize: 8, color: "#828282", marginTop: 2, maxLines: 1, textOverflow: "ellipsis" }, + colTotal: { + width: 90, + fontFamily: "Courier", + fontSize: 10, + fontWeight: "bold", + color: "#1A201E", + textAlign: "right", + }, + footer: { + position: "absolute", + bottom: 30, + right: 68, + fontSize: 7.333, + fontWeight: "bold", + color: "#5F6462", + }, +}); + +export default function AccountStatement({ + account, + activities, + cards, + period, +}: { + account: string; + activities: { + amount: number; + detail?: string; + id: string; + timestamp: string; + title: string; + }[]; + cards: { amount: number; cardId: string; lastFour: string }[]; + period?: string; +}) { + const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + const rows = [...activities].toSorted((a, b) => a.timestamp.localeCompare(b.timestamp)); + return ( + + + {Header({ account, first: true, period })} + + {Summary({ cards, currency, period })} + + Account movements + {TableHeader()} + {rows[0] !== undefined && MovementRow({ currency, item: rows[0] })} + + (pageNumber === 1 ? null : TableHeader())} /> + {rows.slice(1).map((item) => MovementRow({ currency, item }))} + + {Footer()} + + + ); +} + +function Summary({ + cards, + currency, + period, +}: { + cards: { amount: number; cardId: string; lastFour: string }[]; + currency: Intl.NumberFormat; + period?: string; +}) { + return ( + + {cards.map(({ amount, cardId, lastFour }) => ( + + Card **** {lastFour} + {period !== undefined && Debit purchases in the period} + {currency.format(amount)} + + ))} + + ); +} + +function TableHeader() { + return ( + + DATE + MOVEMENT + AMOUNT + + ); +} + +function Header({ account, first = false, period }: { account: string; first?: boolean; period?: string }) { + return ( + <> + + + + + + {first && ( + + + + + + Account activity + + Account + {account} + {period !== undefined && ( + <> + {"\n"} + Period + {period} + + )} + + + + )} + + ); +} + +function Footer() { + return `${pageNumber} • ${totalPages}`} />; +} + +function MovementRow({ + currency, + item, +}: { + currency: Intl.NumberFormat; + item: { amount: number; detail?: string; id: string; timestamp: string; title: string }; +}) { + return ( + + {date.format(Date.parse(item.timestamp))} + + {item.title} + {item.detail !== undefined && {item.detail}} + + {currency.format(item.amount)} + + ); +} diff --git a/server/utils/Statement.tsx b/server/utils/Statement.tsx index aaa3b0105d..1a0f8611b7 100644 --- a/server/utils/Statement.tsx +++ b/server/utils/Statement.tsx @@ -39,34 +39,7 @@ const Statement = ({ - - - - - - - - - - - - - + Statement @@ -220,6 +193,39 @@ export function format(value: number | string) { }); } +export function Logo({ width = 133, height = 54 }: { height?: number; width?: number }) { + return ( + + + + + + + + + + + + + + ); +} + const styles = StyleSheet.create({ page: { flexDirection: "column", backgroundColor: "#FBFDFC", padding: 24 }, header: {