@@ -403,6 +403,81 @@ describe('Members API - Member Offers', function () {
403403 }
404404 } ) ;
405405
406+ it ( 'returns retention offers when a one-month repeating signup offer no longer applies to the next payment' , async function ( ) {
407+ const { subscription} = await getMemberSubscription ( 'paid@test.com' ) ;
408+ const stripePrice = subscription . related ( 'stripePrice' ) ;
409+ const stripeProduct = stripePrice . related ( 'stripeProduct' ) ;
410+ const product = stripeProduct . related ( 'product' ) ;
411+
412+ const tierId = product . id ;
413+ const cadence = stripePrice . get ( 'interval' ) ;
414+ const originalCurrentPeriodEnd = subscription . get ( 'current_period_end' ) ;
415+
416+ const retentionOffer = await models . Offer . add ( {
417+ name : 'Retention Offer After Repeating Signup' ,
418+ code : 'retention-after-repeating-signup' ,
419+ portal_title : '20% off' ,
420+ portal_description : 'Stay with us!' ,
421+ discount_type : 'percent' ,
422+ discount_amount : 20 ,
423+ duration : 'once' ,
424+ interval : cadence ,
425+ product_id : null ,
426+ currency : null ,
427+ active : true ,
428+ redemption_type : 'retention'
429+ } ) ;
430+
431+ const signupOffer = await models . Offer . add ( {
432+ name : 'One Month Signup Offer' ,
433+ code : 'one-month-signup-offer' ,
434+ portal_title : '10% off for 1 month' ,
435+ portal_description : 'Welcome!' ,
436+ discount_type : 'percent' ,
437+ discount_amount : 10 ,
438+ duration : 'repeating' ,
439+ duration_in_months : 1 ,
440+ interval : cadence ,
441+ product_id : tierId ,
442+ currency : null ,
443+ active : true ,
444+ redemption_type : 'signup'
445+ } ) ;
446+
447+ const now = new Date ( ) ;
448+ const currentPeriodEnd = new Date ( now . getTime ( ) + 30 * 24 * 60 * 60 * 1000 ) ;
449+ const discountStart = new Date ( now . getTime ( ) - 30 * 24 * 60 * 60 * 1000 ) ;
450+ const discountEnd = new Date ( currentPeriodEnd ) ;
451+
452+ await subscription . save ( {
453+ offer_id : signupOffer . id ,
454+ discount_start : discountStart ,
455+ discount_end : discountEnd ,
456+ current_period_end : currentPeriodEnd
457+ } , { patch : true } ) ;
458+
459+ try {
460+ const token = await getIdentityToken ( 'paid@test.com' ) ;
461+
462+ const { body} = await membersAgent
463+ . post ( '/api/member/offers' )
464+ . body ( { identity : token } )
465+ . expectStatus ( 200 ) ;
466+
467+ assert . equal ( body . offers . length , 1 ) ;
468+ assert . equal ( body . offers [ 0 ] . id , retentionOffer . id ) ;
469+ } finally {
470+ await subscription . save ( {
471+ offer_id : null ,
472+ discount_start : null ,
473+ discount_end : null ,
474+ current_period_end : originalCurrentPeriodEnd
475+ } , { patch : true } ) ;
476+ await models . Offer . destroy ( { id : retentionOffer . id } ) ;
477+ await models . Offer . destroy ( { id : signupOffer . id } ) ;
478+ }
479+ } ) ;
480+
406481 it ( 'returns empty offers if subscription is already set to cancel' , async function ( ) {
407482 const { subscription} = await getMemberSubscription ( 'paid@test.com' ) ;
408483 const stripePrice = subscription . related ( 'stripePrice' ) ;
@@ -559,6 +634,151 @@ describe('Members API - Member Offers', function () {
559634 }
560635 } ) ;
561636
637+ it ( 'allows applying a retention offer when a one-month repeating signup offer no longer applies to the next payment' , async function ( ) {
638+ const { subscription} = await getMemberSubscription ( 'paid@test.com' ) ;
639+ const stripePrice = subscription . related ( 'stripePrice' ) ;
640+ const stripeProduct = stripePrice . related ( 'stripeProduct' ) ;
641+ const product = stripeProduct . related ( 'product' ) ;
642+
643+ const stripeSubscriptionId = subscription . get ( 'subscription_id' ) ;
644+ const customerId = subscription . get ( 'customer_id' ) ;
645+ const tierId = product . id ;
646+ const cadence = stripePrice . get ( 'interval' ) ;
647+ const originalCurrentPeriodEnd = subscription . get ( 'current_period_end' ) ;
648+
649+ const existingSignupOffer = await models . Offer . add ( {
650+ name : 'Repeating Signup Offer' ,
651+ code : 'repeating-signup-offer' ,
652+ portal_title : '10% off for 1 month' ,
653+ portal_description : 'Welcome!' ,
654+ discount_type : 'percent' ,
655+ discount_amount : 10 ,
656+ duration : 'repeating' ,
657+ duration_in_months : 1 ,
658+ interval : cadence ,
659+ product_id : tierId ,
660+ currency : null ,
661+ active : true ,
662+ redemption_type : 'signup'
663+ } ) ;
664+
665+ const stripeCouponId = 'coupon_redeem_after_repeating_signup' ;
666+ mockManager . stripeMocker . coupons . push ( {
667+ id : stripeCouponId ,
668+ object : 'coupon' ,
669+ percent_off : 20 ,
670+ duration : 'once'
671+ } ) ;
672+
673+ const mockPrice = {
674+ id : stripePrice . get ( 'stripe_price_id' ) ,
675+ product : stripeProduct . get ( 'stripe_product_id' ) ,
676+ active : true ,
677+ nickname : cadence ,
678+ unit_amount : stripePrice . get ( 'amount' ) ,
679+ currency : stripePrice . get ( 'currency' ) ,
680+ type : 'recurring' ,
681+ recurring : {
682+ interval : cadence
683+ }
684+ } ;
685+ mockManager . stripeMocker . prices . push ( mockPrice ) ;
686+
687+ mockManager . stripeMocker . customers . push ( {
688+ id : customerId ,
689+ object : 'customer' ,
690+ email : 'paid@test.com' ,
691+ invoice_settings : {
692+ default_payment_method : null
693+ } ,
694+ subscriptions : {
695+ type : 'list' ,
696+ data : [ ]
697+ }
698+ } ) ;
699+
700+ const now = new Date ( ) ;
701+ const currentPeriodEnd = new Date ( now . getTime ( ) + 30 * 24 * 60 * 60 * 1000 ) ;
702+ const discountStart = new Date ( now . getTime ( ) - 30 * 24 * 60 * 60 * 1000 ) ;
703+ const discountEnd = new Date ( currentPeriodEnd ) ;
704+
705+ mockManager . stripeMocker . subscriptions . push ( {
706+ id : stripeSubscriptionId ,
707+ object : 'subscription' ,
708+ status : 'active' ,
709+ customer : customerId ,
710+ cancel_at_period_end : false ,
711+ current_period_end : Math . floor ( currentPeriodEnd . getTime ( ) / 1000 ) ,
712+ start_date : Math . floor ( discountStart . getTime ( ) / 1000 ) ,
713+ items : {
714+ data : [ {
715+ id : 'si_test_repeating_signup' ,
716+ price : mockPrice
717+ } ]
718+ }
719+ } ) ;
720+
721+ const retentionOffer = await models . Offer . add ( {
722+ name : 'Retention Offer After Repeating Signup' ,
723+ code : 'redeem-after-repeating-signup' ,
724+ portal_title : '20% off' ,
725+ portal_description : 'Stay with us!' ,
726+ discount_type : 'percent' ,
727+ discount_amount : 20 ,
728+ duration : 'once' ,
729+ interval : cadence ,
730+ product_id : null ,
731+ currency : null ,
732+ active : true ,
733+ redemption_type : 'retention' ,
734+ stripe_coupon_id : stripeCouponId
735+ } ) ;
736+
737+ await subscription . save ( {
738+ offer_id : existingSignupOffer . id ,
739+ discount_start : discountStart ,
740+ discount_end : discountEnd ,
741+ current_period_end : currentPeriodEnd
742+ } , { patch : true } ) ;
743+
744+ try {
745+ const token = await getIdentityToken ( 'paid@test.com' ) ;
746+
747+ await membersAgent
748+ . post ( `/api/subscriptions/${ stripeSubscriptionId } /apply-offer` )
749+ . body ( { identity : token , offer_id : retentionOffer . id } )
750+ . expectStatus ( 204 ) ;
751+
752+ await DomainEvents . allSettled ( ) ;
753+
754+ await subscription . refresh ( ) ;
755+ assert . equal ( subscription . get ( 'offer_id' ) , retentionOffer . id ) ;
756+
757+ const redemption = await models . OfferRedemption . findOne ( {
758+ offer_id : retentionOffer . id ,
759+ subscription_id : subscription . id
760+ } ) ;
761+ assert . ok ( redemption , 'Offer redemption should be recorded' ) ;
762+ } finally {
763+ const redemption = await models . OfferRedemption . findOne ( {
764+ offer_id : retentionOffer . id ,
765+ subscription_id : subscription . id
766+ } ) ;
767+ if ( redemption ) {
768+ await models . OfferRedemption . destroy ( { id : redemption . id } ) ;
769+ }
770+
771+ await subscription . save ( {
772+ offer_id : null ,
773+ discount_start : null ,
774+ discount_end : null ,
775+ current_period_end : originalCurrentPeriodEnd
776+ } , { patch : true } ) ;
777+ await models . Offer . destroy ( { id : retentionOffer . id } ) ;
778+ await models . Offer . destroy ( { id : existingSignupOffer . id } ) ;
779+ }
780+ } ) ;
781+
562782 it ( 'returns 400 when offer cadence does not match subscription' , async function ( ) {
563783 const { subscription} = await getMemberSubscription ( 'paid@test.com' ) ;
564784 const stripePrice = subscription . related ( 'stripePrice' ) ;
0 commit comments