Skip to content

Commit 1549ce4

Browse files
committed
✨ server: add account statement
1 parent f1f8aea commit 1549ce4

6 files changed

Lines changed: 1110 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: 175 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,125 @@ 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
444+
.flatMap((item): (typeof response)[number][] => {
445+
if (item.type !== "panda") return [item];
446+
const refunds = item.operations.filter(({ usdAmount }) => usdAmount < 0);
447+
if (refunds.length === 0) return [item];
448+
const charges = item.operations.filter(({ usdAmount }) => usdAmount >= 0);
449+
const chargeUsdAmount = charges.reduce((sum, { usdAmount }) => sum + usdAmount, 0);
450+
const chargeAmount = charges.reduce((sum, { amount }) => sum + amount, 0);
451+
return [
452+
...(chargeUsdAmount > 0
453+
? [{ ...item, amount: chargeAmount, operations: charges, usdAmount: chargeUsdAmount }]
454+
: []),
455+
...refunds.map((operation, index) => {
456+
const n = index + Number(chargeUsdAmount > 0);
457+
return {
458+
...item,
459+
...(n > 0 && { id: `${item.id}:${n}` }),
460+
amount: operation.amount,
461+
operations: [operation],
462+
timestamp: operation.timestamp,
463+
usdAmount: operation.usdAmount,
464+
};
465+
}),
466+
];
467+
})
468+
.filter(
469+
(item) =>
470+
(item.type !== "panda" || item.status === "settled" || item.usdAmount < 0) &&
471+
(maturity === undefined ||
472+
(Date.parse(item.timestamp) / 1000 > maturity - MATURITY_INTERVAL &&
473+
Date.parse(item.timestamp) / 1000 <= maturity)),
474+
);
475+
return c.body(
476+
new Uint8Array(
477+
await renderToBuffer(
478+
AccountStatement({
479+
account: mask(account),
480+
activities: items.map((item) => {
481+
if ("merchant" in item) {
482+
const movement = {
483+
id: item.id,
484+
timestamp: item.timestamp,
485+
amount: -item.usdAmount,
486+
title: item.merchant.name,
487+
};
488+
if (-item.usdAmount > 0) return { ...movement, detail: `Refund – Card **** ${item.lastFour}` };
489+
if (item.type === "panda" ? item.operations.some(({ mode }) => mode > 0) : item.mode > 0)
490+
return { ...movement, detail: `Credit purchase – Card **** ${item.lastFour}` };
491+
return { ...movement, detail: `Debit purchase – Card **** ${item.lastFour}` };
492+
}
493+
switch (item.type) {
494+
case "received":
495+
return {
496+
id: item.id,
497+
timestamp: item.timestamp,
498+
amount: item.usdAmount,
499+
title: "Funds added",
500+
detail: `${item.amount} ${item.currency}`,
501+
};
502+
case "repay":
503+
return {
504+
id: item.id,
505+
timestamp: item.timestamp,
506+
amount: -item.usdAmount,
507+
title: "Debt payment",
508+
detail: `${item.amount} ${item.currency}`,
509+
};
510+
case "sent":
511+
return {
512+
id: item.id,
513+
timestamp: item.timestamp,
514+
amount: -item.usdAmount,
515+
title: `Sent to ${mask(item.receiver)}`,
516+
detail: `${item.amount} ${item.currency}`,
517+
};
518+
default:
519+
throw new Error("unsupported activity type", { cause: item });
520+
}
521+
}),
522+
cards: [
523+
...Map.groupBy(
524+
items.filter((item) => "merchant" in item),
525+
({ cardId }) => cardId,
526+
),
527+
].map(([cardId, cardItems]) => ({
528+
...cardItems.reduce(
529+
(summary, item) => ({
530+
amount:
531+
summary.amount +
532+
(item.usdAmount > 0 &&
533+
(item.type === "panda" ? item.operations.every(({ mode }) => mode <= 0) : item.mode <= 0)
534+
? item.usdAmount
535+
: 0),
536+
lastFour: item.lastFour,
537+
}),
538+
{ amount: 0, lastFour: "" },
539+
),
540+
cardId,
541+
})),
542+
period:
543+
maturityDate === undefined
544+
? undefined
545+
: new Intl.DateTimeFormat("en-US", {
546+
day: "numeric",
547+
month: "short",
548+
timeZone: "UTC",
549+
year: "numeric",
550+
}).formatRange(new Date(maturityDate.getTime() - MATURITY_INTERVAL * 1000), maturityDate),
551+
}),
552+
),
553+
),
554+
200,
555+
{ "content-type": "application/pdf" },
556+
);
557+
}
558+
435559
if (maturity !== undefined && pdf) {
436560
const purchasesByCard = Map.groupBy(
437561
response.flatMap((item) => {
@@ -465,30 +589,37 @@ export default new Hono().get(
465589
}),
466590
({ cardId }) => cardId,
467591
);
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-
});
592+
return c.body(
593+
new Uint8Array(
594+
await renderToBuffer(
595+
Statement({
596+
account: `${account.slice(0, 6)}...${account.slice(-6)}`,
597+
maturity,
598+
cards: purchases
599+
.filter(({ id }) => purchasesByCard.has(id))
600+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
601+
.map(({ id, lastFour }) => ({
602+
id,
603+
lastFour,
604+
purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest),
605+
})),
606+
payments: response
607+
.filter((item) => item.type === "repay")
608+
.filter((repay) => repay.currency === market(marketUSDCAddress).symbol)
609+
.map(({ id, timestamp, amount, positionAmount }) => ({
610+
id,
611+
timestamp,
612+
amount,
613+
positionAmount,
614+
})),
615+
}),
616+
),
617+
),
618+
200,
619+
{
620+
"content-type": "application/pdf",
621+
},
622+
);
492623
}
493624
return c.json(response, 200);
494625
},

0 commit comments

Comments
 (0)