@@ -458,6 +458,133 @@ describe("POST /api/payments/coinpayportal/webhook", () => {
458458 expect ( notifChain . insert ) . not . toHaveBeenCalled ( ) ;
459459 } ) ;
460460
461+ it ( "escrow.funded transitions a pending escrow and sends funded notifications" , async ( ) => {
462+ const payload = makeWebhookPayload ( "escrow.funded" , {
463+ payment_id : "cp-escrow-pending" ,
464+ status : "funded" ,
465+ } ) ;
466+
467+ const escrowFetchChain = chainResult ( {
468+ data : {
469+ id : "escrow-pending" ,
470+ coinpay_escrow_id : "cp-escrow-pending" ,
471+ status : "pending_payment" ,
472+ application_id : "app-escrow" ,
473+ gig_id : "gig-escrow" ,
474+ worker_id : "worker-escrow" ,
475+ poster_id : "poster-escrow" ,
476+ amount_usd : 20 ,
477+ } ,
478+ error : null ,
479+ } ) ;
480+ const escrowUpdateChain = chainResult ( { data : { id : "escrow-pending" } , error : null } ) ;
481+ const appChain = chainResult ( { data : null , error : null } ) ;
482+ const gigChain = chainResult ( { data : { title : "Escrow bugfix" } , error : null } ) ;
483+ const notifChain = chainResult ( { data : null , error : null } ) ;
484+ let escrowCalls = 0 ;
485+
486+ mockFrom . mockImplementation ( ( table : string ) => {
487+ if ( table === "gig_escrows" ) {
488+ escrowCalls += 1 ;
489+ return escrowCalls === 1 ? escrowFetchChain : escrowUpdateChain ;
490+ }
491+ if ( table === "applications" ) return appChain ;
492+ if ( table === "gigs" ) return gigChain ;
493+ if ( table === "notifications" ) return notifChain ;
494+ return chainResult ( { data : null , error : null } ) ;
495+ } ) ;
496+
497+ const res = await POST ( makeRequest ( payload , WEBHOOK_SECRET ) ) ;
498+ expect ( res . status ) . toBe ( 200 ) ;
499+ expect ( escrowUpdateChain . update ) . toHaveBeenCalledWith (
500+ expect . objectContaining ( { status : "funded" } )
501+ ) ;
502+ expect ( escrowUpdateChain . eq ) . toHaveBeenCalledWith ( "status" , "pending_payment" ) ;
503+ expect ( appChain . update ) . toHaveBeenCalledWith (
504+ expect . objectContaining ( { status : "in_progress" } )
505+ ) ;
506+ expect ( notifChain . insert ) . toHaveBeenCalledTimes ( 2 ) ;
507+ } ) ;
508+
509+ it ( "escrow.funded does not downgrade an already released escrow" , async ( ) => {
510+ const payload = makeWebhookPayload ( "escrow.funded" , {
511+ payment_id : "cp-escrow-released" ,
512+ status : "funded" ,
513+ } ) ;
514+
515+ const escrowFetchChain = chainResult ( {
516+ data : {
517+ id : "escrow-released" ,
518+ coinpay_escrow_id : "cp-escrow-released" ,
519+ status : "released" ,
520+ application_id : "app-escrow" ,
521+ gig_id : "gig-escrow" ,
522+ worker_id : "worker-escrow" ,
523+ poster_id : "poster-escrow" ,
524+ amount_usd : 20 ,
525+ } ,
526+ error : null ,
527+ } ) ;
528+ const escrowUpdateChain = chainResult ( { data : null , error : null } ) ;
529+ const appChain = chainResult ( { data : null , error : null } ) ;
530+ const notifChain = chainResult ( { data : null , error : null } ) ;
531+ let escrowCalls = 0 ;
532+
533+ mockFrom . mockImplementation ( ( table : string ) => {
534+ if ( table === "gig_escrows" ) {
535+ escrowCalls += 1 ;
536+ return escrowCalls === 1 ? escrowFetchChain : escrowUpdateChain ;
537+ }
538+ if ( table === "applications" ) return appChain ;
539+ if ( table === "notifications" ) return notifChain ;
540+ return chainResult ( { data : null , error : null } ) ;
541+ } ) ;
542+
543+ const res = await POST ( makeRequest ( payload , WEBHOOK_SECRET ) ) ;
544+ expect ( res . status ) . toBe ( 200 ) ;
545+ expect ( escrowUpdateChain . update ) . not . toHaveBeenCalled ( ) ;
546+ expect ( appChain . update ) . not . toHaveBeenCalled ( ) ;
547+ expect ( notifChain . insert ) . not . toHaveBeenCalled ( ) ;
548+ } ) ;
549+
550+ it ( "escrow.refunded does not override an already released escrow" , async ( ) => {
551+ const payload = makeWebhookPayload ( "escrow.refunded" , {
552+ payment_id : "cp-escrow-released" ,
553+ status : "refunded" ,
554+ } ) ;
555+
556+ const escrowFetchChain = chainResult ( {
557+ data : {
558+ id : "escrow-released" ,
559+ coinpay_escrow_id : "cp-escrow-released" ,
560+ status : "released" ,
561+ application_id : "app-escrow" ,
562+ gig_id : "gig-escrow" ,
563+ worker_id : "worker-escrow" ,
564+ poster_id : "poster-escrow" ,
565+ amount_usd : 20 ,
566+ } ,
567+ error : null ,
568+ } ) ;
569+ const escrowUpdateChain = chainResult ( { data : null , error : null } ) ;
570+ const notifChain = chainResult ( { data : null , error : null } ) ;
571+ let escrowCalls = 0 ;
572+
573+ mockFrom . mockImplementation ( ( table : string ) => {
574+ if ( table === "gig_escrows" ) {
575+ escrowCalls += 1 ;
576+ return escrowCalls === 1 ? escrowFetchChain : escrowUpdateChain ;
577+ }
578+ if ( table === "notifications" ) return notifChain ;
579+ return chainResult ( { data : null , error : null } ) ;
580+ } ) ;
581+
582+ const res = await POST ( makeRequest ( payload , WEBHOOK_SECRET ) ) ;
583+ expect ( res . status ) . toBe ( 200 ) ;
584+ expect ( escrowUpdateChain . update ) . not . toHaveBeenCalled ( ) ;
585+ expect ( notifChain . insert ) . not . toHaveBeenCalled ( ) ;
586+ } ) ;
587+
461588 it ( "uses service client (not cookie-based auth)" , async ( ) => {
462589 // This test verifies the fix: service client is used for webhooks
463590 // The mock is set up for createServiceClient, not createClient
0 commit comments