@@ -6,6 +6,7 @@ import "../mocks/sentry";
66
77import { captureException } from "@sentry/node" ;
88import { testClient } from "hono/testing" ;
9+ import assert from "node:assert" ;
910import { safeParse , type InferOutput } from "valibot" ;
1011import { padHex , zeroHash , type Hash } from "viem" ;
1112import { privateKeyToAddress } from "viem/accounts" ;
@@ -18,6 +19,24 @@ import app, { CreditActivity, DebitActivity, InstallmentsActivity, PandaActivity
1819import database , { cards , transactions } from "../../database" ;
1920import anvilClient from "../anvilClient" ;
2021
22+ function httpSerialize < T > ( object : T ) : T {
23+ const cloned = structuredClone ( object ) ;
24+ return removeUndefined ( cloned ) as T ;
25+ }
26+
27+ function removeUndefined ( object : unknown ) : unknown {
28+ if ( object === null || typeof object !== "object" ) return object ;
29+ if ( Array . isArray ( object ) ) return object . map ( ( value ) => removeUndefined ( value ) ) ;
30+
31+ const result : Record < string , unknown > = { } ;
32+ for ( const [ key , value ] of Object . entries ( object ) ) {
33+ if ( value !== undefined ) {
34+ result [ key ] = removeUndefined ( value ) ;
35+ }
36+ }
37+ return result ;
38+ }
39+
2140const appClient = testClient ( app ) ;
2241const account = deriveAddress ( inject ( "ExaAccountFactory" ) , {
2342 x : padHex ( privateKeyToAddress ( padHex ( "0xb0b" ) ) ) ,
@@ -66,26 +85,25 @@ describe.concurrent("authenticated", () => {
6685
6786 beforeAll ( async ( ) => {
6887 await database . insert ( cards ) . values ( [ { id : "activity" , credentialId : "bob" , lastFour : "1234" } ] ) ;
69- const logs = [
70- ...( await anvilClient . getContractEvents ( {
71- abi : marketAbi ,
72- eventName : "BorrowAtMaturity" ,
73- address : [ inject ( "MarketEXA" ) , inject ( "MarketUSDC" ) , inject ( "MarketWETH" ) ] ,
74- args : { borrower : account } ,
75- toBlock : "latest" ,
76- fromBlock : 0n ,
77- strict : true ,
78- } ) ) ,
79- ...( await anvilClient . getContractEvents ( {
80- abi : marketAbi ,
81- eventName : "Withdraw" ,
82- address : [ inject ( "MarketEXA" ) , inject ( "MarketUSDC" ) , inject ( "MarketWETH" ) ] ,
83- args : { owner : account } ,
84- toBlock : "latest" ,
85- fromBlock : 0n ,
86- strict : true ,
87- } ) ) ,
88- ] ;
88+ const cardLogs = await anvilClient . getContractEvents ( {
89+ abi : marketAbi ,
90+ eventName : "BorrowAtMaturity" ,
91+ address : inject ( "MarketUSDC" ) ,
92+ args : { borrower : account } ,
93+ toBlock : "latest" ,
94+ fromBlock : 0n ,
95+ strict : true ,
96+ } ) ;
97+ const withdrawLogs = await anvilClient . getContractEvents ( {
98+ abi : marketAbi ,
99+ eventName : "Withdraw" ,
100+ address : inject ( "MarketUSDC" ) ,
101+ args : { owner : account } ,
102+ toBlock : "latest" ,
103+ fromBlock : 0n ,
104+ strict : true ,
105+ } ) ;
106+ const logs = [ ...cardLogs , ...withdrawLogs ] ;
89107 const timestamps = await Promise . all (
90108 [ ...new Set ( logs . map ( ( { blockNumber } ) => blockNumber ) ) ] . map ( ( blockNumber ) =>
91109 anvilClient . getBlock ( { blockNumber } ) ,
@@ -126,7 +144,6 @@ describe.concurrent("authenticated", () => {
126144 createdAt,
127145 body : {
128146 id : String ( index ) ,
129- type : "spend" ,
130147 spend : {
131148 ...spendTemplate ,
132149 amount : Number ( total ) / 1e4 ,
@@ -161,7 +178,10 @@ describe.concurrent("authenticated", () => {
161178 const panda = safeParse ( PandaActivity , {
162179 ...( payload as object ) ,
163180 hashes,
164- borrows : eventName === "Withdraw" ? [ null ] : [ { blockNumber, events } ] ,
181+ borrows :
182+ eventName === "Withdraw"
183+ ? hashes . map ( ( ) => null )
184+ : hashes . map ( ( currentHash ) => ( currentHash === hash ? { timestamp : blockTimestamp , events } : null ) ) ,
165185 } ) ;
166186 if ( panda . success ) return panda . output ;
167187 const eventCount = eventName === "Withdraw" ? 0 : events . length ;
@@ -184,7 +204,7 @@ describe.concurrent("authenticated", () => {
184204 ) ;
185205
186206 expect ( response . status ) . toBe ( 200 ) ;
187- await expect ( response . json ( ) ) . resolves . toStrictEqual ( activity ) ;
207+ await expect ( response . json ( ) ) . resolves . toMatchObject ( httpSerialize ( activity ) ) ;
188208 } ) ;
189209
190210 it ( "reports bad transaction" , async ( ) => {
@@ -207,7 +227,7 @@ describe.concurrent("authenticated", () => {
207227 } ) ,
208228 ) ;
209229 expect ( response . status ) . toBe ( 200 ) ;
210- await expect ( response . json ( ) ) . resolves . toStrictEqual ( activity ) ;
230+ await expect ( response . json ( ) ) . resolves . toMatchObject ( httpSerialize ( activity ) ) ;
211231 } ) ;
212232 } ) ;
213233
@@ -289,6 +309,77 @@ describe.concurrent("authenticated", () => {
289309 ] ) ,
290310 ) ;
291311 } ) ;
312+
313+ describe ( "declined transactions" , ( ) => {
314+ it ( "parses declined transaction with created action" , ( ) => {
315+ const result = safeParse ( PandaActivity , {
316+ type : "panda" ,
317+ hashes : [ zeroHash ] ,
318+ borrows : [ null ] ,
319+ bodies : [
320+ {
321+ action : "created" ,
322+ createdAt : "2024-01-15T10:30:00.000Z" ,
323+ status : "declined" ,
324+ reason : "insufficient funds" ,
325+ body : {
326+ id : "declined-tx-1" ,
327+ spend : {
328+ amount : 1000 ,
329+ currency : "usd" ,
330+ localAmount : 1000 ,
331+ localCurrency : "usd" ,
332+ merchantCity : "Buenos Aires" ,
333+ merchantCountry : "AR" ,
334+ merchantName : "Test Merchant" ,
335+ } ,
336+ } ,
337+ } ,
338+ ] ,
339+ } ) ;
340+
341+ expect ( result . success ) . toBe ( true ) ;
342+ assert . ok ( result . success ) ;
343+ expect ( result . output . status ) . toBe ( "declined" ) ;
344+ expect ( result . output . reason ) . toBe ( "insufficient funds" ) ;
345+ expect ( result . output . timestamp ) . toBe ( "2024-01-15T10:30:00.000Z" ) ;
346+ expect ( result . output . id ) . toBe ( "declined-tx-1" ) ;
347+ } ) ;
348+
349+ it ( "parses declined transaction with requested action normalized to created" , ( ) => {
350+ const result = safeParse ( PandaActivity , {
351+ type : "panda" ,
352+ hashes : [ zeroHash ] ,
353+ borrows : [ null ] ,
354+ bodies : [
355+ {
356+ action : "requested" ,
357+ createdAt : "2024-01-15T11:00:00.000Z" ,
358+ status : "declined" ,
359+ reason : "merchant blocked" ,
360+ body : {
361+ id : "declined-tx-2" ,
362+ spend : {
363+ amount : 500 ,
364+ currency : "usd" ,
365+ localAmount : 500 ,
366+ localCurrency : "usd" ,
367+ merchantCity : "New York" ,
368+ merchantCountry : "US" ,
369+ merchantName : "Blocked Merchant" ,
370+ } ,
371+ } ,
372+ } ,
373+ ] ,
374+ } ) ;
375+
376+ expect ( result . success ) . toBe ( true ) ;
377+ assert . ok ( result . success ) ;
378+ expect ( result . output . status ) . toBe ( "declined" ) ;
379+ expect ( result . output . reason ) . toBe ( "merchant blocked" ) ;
380+ expect ( result . output . timestamp ) . toBe ( "2024-01-15T11:00:00.000Z" ) ;
381+ } ) ;
382+ } ) ;
292383} ) ;
293384
294385vi . mock ( "@sentry/node" , { spy : true } ) ;
0 commit comments