@@ -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,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