Skip to content

Commit 8e25b35

Browse files
committed
✨ server: add account statement
1 parent bf51a8e commit 8e25b35

6 files changed

Lines changed: 1036 additions & 99 deletions

File tree

.changeset/fine-houses-invite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✨ add account statement

server/api/activity.ts

Lines changed: 150 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ import chain, {
5151
} from "@exactly/common/generated/chain";
5252
import { decodeWithdraw } from "@exactly/common/ProposalType";
5353
import { Address, Hash, type Hex } from "@exactly/common/validation";
54-
import { effectiveRate, WAD } from "@exactly/lib";
54+
import { effectiveRate, MATURITY_INTERVAL, WAD } from "@exactly/lib";
5555

5656
import database, { cards, credentials, transactions } from "../database";
5757
import auth from "../middleware/auth";
58+
import AccountStatement from "../utils/AccountStatement";
5859
import { collectors as cryptomateCollectors } from "../utils/cryptomate";
5960
import { declineMessage, collectors as pandaCollectors } from "../utils/panda";
6061
import publicClient from "../utils/publicClient";
@@ -82,6 +83,16 @@ export default new Hono().get(
8283
async (c) => {
8384
const { include, maturity } = c.req.valid("query");
8485
if (maturity !== undefined && maturity > 864e10) return c.json({ code: "invalid maturity" }, 400);
86+
const pdf =
87+
accepts(c, {
88+
header: "Accept",
89+
supports: ["application/json", "application/pdf"],
90+
default: "application/json",
91+
}) === "application/pdf";
92+
const accountPdf = pdf && include === undefined;
93+
const eventMaturity = accountPdf ? undefined : maturity;
94+
if (pdf && include !== undefined && maturity === undefined)
95+
return c.json({ code: "maturity required for filtered pdf" }, 400);
8596
function ignore(type: InferInput<typeof ActivityTypes>) {
8697
return include && (Array.isArray(include) ? !include.includes(type) : include !== type);
8798
}
@@ -94,7 +105,7 @@ export default new Hono().get(
94105
cards: {
95106
columns: { id: true, lastFour: true },
96107
with: { transactions: { columns: { hashes: true, payload: true } } },
97-
limit: ignore("card") || maturity !== undefined ? 0 : undefined,
108+
limit: ignore("card") || eventMaturity !== undefined ? 0 : undefined,
98109
},
99110
},
100111
});
@@ -119,7 +130,6 @@ export default new Hono().get(
119130
.then((logs) => new Set(logs.map(({ args }) => args.plugin.toLowerCase() as Hex)))
120131
: Promise.resolve(forbid(new Set<Hex>())),
121132
]);
122-
123133
const market = (address: Hex) => {
124134
const found = markets.get(address.toLowerCase() as Hex);
125135
if (!found) throw new Error("market not found");
@@ -170,7 +180,7 @@ export default new Hono().get(
170180
? []
171181
: repayPromise.then((logs) =>
172182
logs
173-
.filter(({ args }) => maturity === undefined || Number(args.maturity) === maturity)
183+
.filter(({ args }) => eventMaturity === undefined || Number(args.maturity) === eventMaturity)
174184
.map((log) =>
175185
parse(RepayActivity, {
176186
...log,
@@ -265,7 +275,7 @@ export default new Hono().get(
265275
);
266276
const timestamps = new Map(blocks.map(({ number: block, timestamp }) => [block, timestamp]));
267277
const purchases =
268-
!ignore("card") && borrows && maturity !== undefined
278+
!ignore("card") && borrows && maturity !== undefined && !accountPdf
269279
? await (() => {
270280
const hashes = borrows
271281
.entries()
@@ -286,13 +296,6 @@ export default new Hono().get(
286296
})()
287297
: credential.cards;
288298

289-
const accept = accepts(c, {
290-
header: "Accept",
291-
supports: maturity === undefined ? ["application/json"] : ["application/json", "application/pdf"],
292-
default: "application/json",
293-
});
294-
const pdf = accept === "application/pdf";
295-
296299
const response = [
297300
...purchases.flatMap(({ id: cardId, lastFour, transactions: txs }) =>
298301
txs.map(({ hashes, payload }) => {
@@ -303,16 +306,18 @@ export default new Hono().get(
303306
const b = borrows?.get(h as Hash);
304307
if (!b) return null;
305308
const filtered =
306-
maturity === undefined ? b.events : b.events.filter(({ maturity: m }) => Number(m) === maturity);
309+
eventMaturity === undefined
310+
? b.events
311+
: b.events.filter(({ maturity: m }) => Number(m) === eventMaturity);
307312
if (filtered.length === 0) return null;
308313
return {
309-
events: maturity !== undefined && b.events.length > 1 ? b.events : filtered,
314+
events: eventMaturity !== undefined && b.events.length > 1 ? b.events : filtered,
310315
timestamp: b.blockNumber && timestamps.get(b.blockNumber),
311316
};
312317
}),
313318
});
314319
if (panda.success) {
315-
if (maturity === undefined || pdf) return { ...panda.output, cardId, lastFour };
320+
if (eventMaturity === undefined || pdf) return { ...panda.output, cardId, lastFour };
316321
const operations: typeof panda.output.operations = [];
317322
for (const operation of panda.output.operations) {
318323
if (!("borrow" in operation)) continue;
@@ -363,11 +368,11 @@ export default new Hono().get(
363368
const hash = hashes[0];
364369
const borrow = borrows?.get(hash as Hash);
365370
const filtered =
366-
maturity === undefined || !borrow
371+
eventMaturity === undefined || !borrow
367372
? borrow?.events
368-
: borrow.events.filter(({ maturity: m }) => Number(m) === maturity);
369-
if (maturity !== undefined && borrow && filtered?.length === 0) return;
370-
const events = !borrow || maturity === undefined || borrow.events.length <= 1 ? filtered : borrow.events;
373+
: borrow.events.filter(({ maturity: m }) => Number(m) === eventMaturity);
374+
if (eventMaturity !== undefined && borrow && filtered?.length === 0) return;
375+
const events = !borrow || eventMaturity === undefined || borrow.events.length <= 1 ? filtered : borrow.events;
371376
const cryptomate = safeParse(
372377
{ 0: DebitActivity, 1: CreditActivity }[events?.length ?? 0] ?? InstallmentsActivity,
373378
{
@@ -378,7 +383,7 @@ export default new Hono().get(
378383
},
379384
);
380385
if (cryptomate.success) {
381-
if (maturity === undefined || pdf) return { ...cryptomate.output, cardId, lastFour };
386+
if (eventMaturity === undefined || pdf) return { ...cryptomate.output, cardId, lastFour };
382387
if (!borrow) return;
383388
if (borrow.events.length <= 1) return { ...cryptomate.output, cardId, lastFour };
384389
if (!("borrow" in cryptomate.output) || !("installments" in cryptomate.output.borrow))
@@ -432,6 +437,100 @@ export default new Hono().get(
432437
.filter(<T>(value: T | undefined): value is T => value !== undefined)
433438
.toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id));
434439

440+
if (accountPdf) {
441+
const maturityDate = maturity === undefined ? undefined : new Date(maturity * 1000);
442+
const mask = (address: Address) => `${address.slice(0, 6)}...${address.slice(-6)}`;
443+
const items = response.filter(
444+
(item) =>
445+
(!("status" in item) || item.status !== "declined") &&
446+
(maturity === undefined ||
447+
(Date.parse(item.timestamp) / 1000 >= maturity - MATURITY_INTERVAL &&
448+
Date.parse(item.timestamp) / 1000 <= maturity)),
449+
);
450+
return c.body(
451+
new Uint8Array(
452+
await renderToBuffer(
453+
AccountStatement({
454+
account: mask(account),
455+
activities: items.map((item) => {
456+
if ("merchant" in item) {
457+
const movement = {
458+
id: item.id,
459+
timestamp: item.timestamp,
460+
amount: -item.usdAmount,
461+
title: item.merchant.name,
462+
};
463+
if (-item.usdAmount > 0) return { ...movement, detail: `Refund – Card **** ${item.lastFour}` };
464+
if (item.type === "panda" ? item.operations.some(({ mode }) => mode > 0) : item.mode > 0)
465+
return { ...movement, detail: `Credit purchase – Card **** ${item.lastFour}` };
466+
return { ...movement, detail: `Debit purchase – Card **** ${item.lastFour}` };
467+
}
468+
switch (item.type) {
469+
case "received":
470+
return {
471+
id: item.id,
472+
timestamp: item.timestamp,
473+
amount: item.usdAmount,
474+
title: "Funds added",
475+
detail: `${item.amount} ${item.currency}`,
476+
};
477+
case "repay":
478+
return {
479+
id: item.id,
480+
timestamp: item.timestamp,
481+
amount: -item.usdAmount,
482+
title: "Debt payment",
483+
detail: `${item.amount} ${item.currency}`,
484+
};
485+
case "sent":
486+
return {
487+
id: item.id,
488+
timestamp: item.timestamp,
489+
amount: -item.usdAmount,
490+
title: `Sent to ${mask(item.receiver)}`,
491+
detail: `${item.amount} ${item.currency}`,
492+
};
493+
default:
494+
throw new Error("unsupported activity type", { cause: item });
495+
}
496+
}),
497+
cards: [
498+
...Map.groupBy(
499+
items.filter((item) => "merchant" in item),
500+
({ cardId }) => cardId,
501+
),
502+
].map(([cardId, cardItems]) => ({
503+
...cardItems.reduce(
504+
(summary, item) => ({
505+
amount:
506+
summary.amount +
507+
(item.usdAmount > 0 &&
508+
(item.type === "panda" ? item.operations.every(({ mode }) => mode <= 0) : item.mode <= 0)
509+
? item.usdAmount
510+
: 0),
511+
lastFour: item.lastFour,
512+
}),
513+
{ amount: 0, lastFour: "" },
514+
),
515+
cardId,
516+
})),
517+
period:
518+
maturityDate === undefined
519+
? undefined
520+
: new Intl.DateTimeFormat("en-US", {
521+
day: "numeric",
522+
month: "short",
523+
timeZone: "UTC",
524+
year: "numeric",
525+
}).formatRange(new Date(maturityDate.getTime() - MATURITY_INTERVAL * 1000), maturityDate),
526+
}),
527+
),
528+
),
529+
200,
530+
{ "content-type": "application/pdf" },
531+
);
532+
}
533+
435534
if (maturity !== undefined && pdf) {
436535
const purchasesByCard = Map.groupBy(
437536
response.flatMap((item) => {
@@ -465,30 +564,37 @@ export default new Hono().get(
465564
}),
466565
({ cardId }) => cardId,
467566
);
468-
const statement = {
469-
account: `${account.slice(0, 6)}...${account.slice(-6)}`,
470-
maturity,
471-
cards: purchases
472-
.filter(({ id }) => purchasesByCard.has(id))
473-
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
474-
.map(({ id, lastFour }) => ({
475-
id,
476-
lastFour,
477-
purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest),
478-
})),
479-
payments: response
480-
.filter((item) => item.type === "repay")
481-
.filter((repay) => repay.currency === market(marketUSDCAddress).symbol)
482-
.map(({ id, timestamp, amount, positionAmount }) => ({
483-
id,
484-
timestamp,
485-
amount,
486-
positionAmount,
487-
})),
488-
};
489-
return c.body(new Uint8Array(await renderToBuffer(Statement(statement))), 200, {
490-
"content-type": "application/pdf",
491-
});
567+
return c.body(
568+
new Uint8Array(
569+
await renderToBuffer(
570+
Statement({
571+
account: `${account.slice(0, 6)}...${account.slice(-6)}`,
572+
maturity,
573+
cards: purchases
574+
.filter(({ id }) => purchasesByCard.has(id))
575+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
576+
.map(({ id, lastFour }) => ({
577+
id,
578+
lastFour,
579+
purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest),
580+
})),
581+
payments: response
582+
.filter((item) => item.type === "repay")
583+
.filter((repay) => repay.currency === market(marketUSDCAddress).symbol)
584+
.map(({ id, timestamp, amount, positionAmount }) => ({
585+
id,
586+
timestamp,
587+
amount,
588+
positionAmount,
589+
})),
590+
}),
591+
),
592+
),
593+
200,
594+
{
595+
"content-type": "application/pdf",
596+
},
597+
);
492598
}
493599
return c.json(response, 200);
494600
},

0 commit comments

Comments
 (0)