@@ -51,10 +51,11 @@ import chain, {
5151} from "@exactly/common/generated/chain" ;
5252import { decodeWithdraw } from "@exactly/common/ProposalType" ;
5353import { 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
5656import database , { cards , credentials , transactions } from "../database" ;
5757import auth from "../middleware/auth" ;
58+ import AccountStatement from "../utils/AccountStatement" ;
5859import { collectors as cryptomateCollectors } from "../utils/cryptomate" ;
5960import { declineMessage , collectors as pandaCollectors } from "../utils/panda" ;
6061import 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