Skip to content

Commit 23d70c9

Browse files
committed
✨ server: add account statement
1 parent 6bdc56c commit 23d70c9

11 files changed

Lines changed: 813 additions & 47 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: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { anvil } from "viem/chains";
4040
import fixedRate from "@exactly/common/fixedRate";
4141
import chain, {
4242
exaPluginAbi,
43+
exaPluginAddress,
4344
exaPreviewerAbi,
4445
exaPreviewerAddress,
4546
marketAbi,
@@ -50,11 +51,12 @@ import chain, {
5051
upgradeableModularAccountAbi,
5152
} from "@exactly/common/generated/chain";
5253
import { decodeWithdraw } from "@exactly/common/ProposalType";
53-
import { Address, Hash, type Hex } from "@exactly/common/validation";
54-
import { effectiveRate, WAD } from "@exactly/lib";
54+
import { Address, Hash, Hex } from "@exactly/common/validation";
55+
import { effectiveRate, MATURITY_INTERVAL, WAD } from "@exactly/lib";
5556

5657
import database, { cards, credentials, transactions } from "../database";
5758
import auth from "../middleware/auth";
59+
import AccountStatement from "../utils/AccountStatement";
5860
import { collectors as cryptomateCollectors } from "../utils/cryptomate";
5961
import { collectors as pandaCollectors } from "../utils/panda";
6062
import publicClient from "../utils/publicClient";
@@ -82,6 +84,15 @@ export default new Hono().get(
8284
async (c) => {
8385
const { include, maturity } = c.req.valid("query");
8486
if (maturity !== undefined && maturity > 864e10) return c.json({ code: "invalid maturity" }, 400);
87+
const pdf =
88+
accepts(c, {
89+
header: "Accept",
90+
supports: ["application/json", "application/pdf"],
91+
default: "application/json",
92+
}) === "application/pdf";
93+
const accountPdf = pdf && include === undefined;
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") || (maturity !== undefined && !accountPdf) ? 0 : undefined,
98109
},
99110
},
100111
});
@@ -119,6 +130,14 @@ 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
]);
133+
const debtManager =
134+
(!ignore("repay") || !ignore("received")) && plugins.has(parse(Hex, exaPluginAddress.toLowerCase()))
135+
? await publicClient.readContract({
136+
address: exaPluginAddress,
137+
functionName: "DEBT_MANAGER",
138+
abi: exaPluginAbi,
139+
})
140+
: undefined;
122141

123142
const market = (address: Hex) => {
124143
const found = markets.get(address.toLowerCase() as Hex);
@@ -132,7 +151,7 @@ export default new Hono().get(
132151
abi: marketAbi,
133152
eventName: "RepayAtMaturity",
134153
address: [...markets.keys()],
135-
args: { caller: [...plugins], borrower: account },
154+
args: { caller: [...plugins, ...(debtManager === undefined ? [] : [debtManager])], borrower: account },
136155
toBlock: "latest",
137156
fromBlock: 0n,
138157
strict: true,
@@ -170,7 +189,7 @@ export default new Hono().get(
170189
? []
171190
: repayPromise.then((logs) =>
172191
logs
173-
.filter(({ args }) => maturity === undefined || Number(args.maturity) === maturity)
192+
.filter(({ args }) => accountPdf || maturity === undefined || Number(args.maturity) === maturity)
174193
.map((log) =>
175194
parse(RepayActivity, {
176195
...log,
@@ -265,7 +284,7 @@ export default new Hono().get(
265284
);
266285
const timestamps = new Map(blocks.map(({ number: block, timestamp }) => [block, timestamp]));
267286
const purchases =
268-
!ignore("card") && borrows && maturity !== undefined
287+
!ignore("card") && borrows && maturity !== undefined && !accountPdf
269288
? await (() => {
270289
const hashes = borrows
271290
.entries()
@@ -286,13 +305,6 @@ export default new Hono().get(
286305
})()
287306
: credential.cards;
288307

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-
296308
const response = [
297309
...purchases.flatMap(({ id: cardId, lastFour, transactions: txs }) =>
298310
txs.map(({ hashes, payload }) => {
@@ -303,7 +315,9 @@ export default new Hono().get(
303315
const b = borrows?.get(h as Hash);
304316
if (!b) return null;
305317
const filtered =
306-
maturity === undefined ? b.events : b.events.filter(({ maturity: m }) => Number(m) === maturity);
318+
maturity === undefined || accountPdf
319+
? b.events
320+
: b.events.filter(({ maturity: m }) => Number(m) === maturity);
307321
if (filtered.length === 0) return null;
308322
return {
309323
events: maturity !== undefined && b.events.length > 1 ? b.events : filtered,
@@ -363,11 +377,12 @@ export default new Hono().get(
363377
const hash = hashes[0];
364378
const borrow = borrows?.get(hash as Hash);
365379
const filtered =
366-
maturity === undefined || !borrow
380+
maturity === undefined || accountPdf || !borrow
367381
? borrow?.events
368382
: borrow.events.filter(({ maturity: m }) => Number(m) === maturity);
369383
if (maturity !== undefined && borrow && filtered?.length === 0) return;
370-
const events = !borrow || maturity === undefined || borrow.events.length <= 1 ? filtered : borrow.events;
384+
const events =
385+
!borrow || maturity === undefined || accountPdf || borrow.events.length <= 1 ? filtered : borrow.events;
371386
const cryptomate = safeParse(
372387
{ 0: DebitActivity, 1: CreditActivity }[events?.length ?? 0] ?? InstallmentsActivity,
373388
{
@@ -433,6 +448,76 @@ export default new Hono().get(
433448
.filter((item) => chain.id === anvil.id || !("status" in item && item.status === "declined"))
434449
.toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id));
435450

451+
if (accountPdf) {
452+
const maturityDate = maturity === undefined ? undefined : new Date(maturity * 1000);
453+
const mask = (address: Address) => `${address.slice(0, 6)}...${address.slice(-6)}`;
454+
const items = response.filter(
455+
({ timestamp }) =>
456+
maturity === undefined ||
457+
(Date.parse(timestamp) / 1000 >= maturity - MATURITY_INTERVAL && Date.parse(timestamp) / 1000 <= maturity),
458+
);
459+
return c.body(
460+
new Uint8Array(
461+
await renderToBuffer(
462+
AccountStatement({
463+
account: mask(account),
464+
activities: items.map((item) =>
465+
"merchant" in item
466+
? {
467+
id: item.id,
468+
timestamp: item.timestamp,
469+
amount: -item.usdAmount,
470+
title: item.merchant.name,
471+
detail: `${-item.usdAmount > 0 ? "Refund" : item.type === "panda" ? (item.operations.some(({ mode }) => mode > 0) ? "Credit purchase" : "Debit purchase") : item.mode > 0 ? "Credit purchase" : "Debit purchase"} – Card **** ${item.lastFour}`,
472+
}
473+
: item.type === "received"
474+
? {
475+
id: item.id,
476+
timestamp: item.timestamp,
477+
amount: item.usdAmount,
478+
title: "Funds added",
479+
detail: `${item.amount} ${item.currency}`,
480+
}
481+
: item.type === "repay"
482+
? {
483+
id: item.id,
484+
timestamp: item.timestamp,
485+
amount: -item.usdAmount,
486+
title: "Debt payment",
487+
detail: `${item.amount} ${item.currency}`,
488+
}
489+
: {
490+
id: item.id,
491+
timestamp: item.timestamp,
492+
amount: -item.usdAmount,
493+
title: `Sent to ${mask(item.receiver)}`,
494+
detail: `${item.amount} ${item.currency}`,
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, { lastFour, usdAmount }) => ({ amount: summary.amount + usdAmount, lastFour }),
505+
{ amount: 0, lastFour: "" },
506+
),
507+
cardId,
508+
})),
509+
period:
510+
maturityDate === undefined
511+
? undefined
512+
: `${maturityDate.toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })}, ${maturityDate.getUTCFullYear()}`,
513+
}),
514+
),
515+
),
516+
200,
517+
{ "content-type": "application/pdf" },
518+
);
519+
}
520+
436521
if (maturity !== undefined && pdf) {
437522
const purchasesByCard = Map.groupBy(
438523
response.flatMap((item) => {

server/assets/fonts/OFL.txt

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
Copyright 2021 The Spline Sans Project Authors (https://github.com/SorkinType/SplineSans)
2+
Copyright 2022 The Spline Sans Mono Project Authors (https://github.com/SorkinType/SplineSansMono)
3+
4+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
5+
This license is copied below, and is also available with a FAQ at:
6+
https://scripts.sil.org/OFL
7+
8+
9+
-----------------------------------------------------------
10+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
11+
-----------------------------------------------------------
12+
13+
PREAMBLE
14+
The goals of the Open Font License (OFL) are to stimulate worldwide
15+
development of collaborative font projects, to support the font creation
16+
efforts of academic and linguistic communities, and to provide a free and
17+
open framework in which fonts may be shared and improved in partnership
18+
with others.
19+
20+
The OFL allows the licensed fonts to be used, studied, modified and
21+
redistributed freely as long as they are not sold by themselves. The
22+
fonts, including any derivative works, can be bundled, embedded,
23+
redistributed and/or sold with any software provided that any reserved
24+
names are not used by derivative works. The fonts and derivatives,
25+
however, cannot be released under any other type of license. The
26+
requirement for fonts to remain under this license does not apply
27+
to any document created using the fonts or their derivatives.
28+
29+
DEFINITIONS
30+
"Font Software" refers to the set of files released by the Copyright
31+
Holder(s) under this license and clearly marked as such. This may
32+
include source files, build scripts and documentation.
33+
34+
"Reserved Font Name" refers to any names specified as such after the
35+
copyright statement(s).
36+
37+
"Original Version" refers to the collection of Font Software components as
38+
distributed by the Copyright Holder(s).
39+
40+
"Modified Version" refers to any derivative made by adding to, deleting,
41+
or substituting -- in part or in whole -- any of the components of the
42+
Original Version, by changing formats or by porting the Font Software to a
43+
new environment.
44+
45+
"Author" refers to any designer, engineer, programmer, technical
46+
writer or other person who contributed to the Font Software.
47+
48+
PERMISSION & CONDITIONS
49+
Permission is hereby granted, free of charge, to any person obtaining
50+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
51+
redistribute, and sell modified and unmodified copies of the Font
52+
Software, subject to the following conditions:
53+
54+
1) Neither the Font Software nor any of its individual components,
55+
in Original or Modified Versions, may be sold by itself.
56+
57+
2) Original or Modified Versions of the Font Software may be bundled,
58+
redistributed and/or sold with any software, provided that each copy
59+
contains the above copyright notice and this license. These can be
60+
included either as stand-alone text files, human-readable headers or
61+
in the appropriate machine-readable metadata fields within text or
62+
binary files as long as those fields can be easily viewed by the user.
63+
64+
3) No Modified Version of the Font Software may use the Reserved Font
65+
Name(s) unless explicit written permission is granted by the corresponding
66+
Copyright Holder. This restriction only applies to the primary font name as
67+
presented to the users.
68+
69+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70+
Software shall not be used to promote, endorse or advertise any
71+
Modified Version, except to acknowledge the contribution(s) of the
72+
Copyright Holder(s) and the Author(s) or with their explicit written
73+
permission.
74+
75+
5) The Font Software, modified or unmodified, in part or in whole,
76+
must be distributed entirely under this license, and must not be
77+
distributed under any other license. The requirement for fonts to
78+
remain under this license does not apply to any document created
79+
using the Font Software.
80+
81+
TERMINATION
82+
This license becomes null and void if any of the above conditions are
83+
not met.
84+
85+
DISCLAIMER
86+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94+
OTHER DEALINGS IN THE FONT SOFTWARE.
47.4 KB
Binary file not shown.
52 KB
Binary file not shown.
42.7 KB
Binary file not shown.

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"bin": "./dist/index.cjs",
2323
"files": [
2424
"app",
25+
"assets",
2526
"dist",
2627
"generated/release.js",
2728
"instrument.cjs"

0 commit comments

Comments
 (0)