@@ -506,7 +506,7 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsProratingGathering() {
506506 }
507507 })
508508
509- s .Run ("canceling the subscription DOES NOT cause the existing item to be pro-rated" , func () {
509+ s .Run ("canceling the subscription inside the service period causes the existing item to be pro-rated" , func () {
510510 // this test needs items longer than subscription.BillingCadence
511511 clock .SetTime (s .mustParseTime ("2024-01-01T10:00:00Z" ))
512512
@@ -551,7 +551,7 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsProratingGathering() {
551551 })
552552 price , err := flatFeeLine .Price .AsFlat ()
553553 s .NoError (err )
554- s .Equal (price .Amount .InexactFloat64 (), 9.0 , "failed for line %v" , flatFeeLine .ID )
554+ s .Equal (price .Amount .InexactFloat64 (), 3.07 , "failed for line %v" , flatFeeLine .ID )
555555 s .Equal (price .PaymentTerm , productcatalog .InArrearsPaymentTerm , "failed for line %v" , flatFeeLine .ID )
556556 })
557557}
@@ -649,6 +649,171 @@ func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncNonBillableAmou
649649 })
650650}
651651
652+ func (s * SubscriptionHandlerTestSuite ) TestInAdvanceFlatFeeCancelAtFirstBillingBoundaryKeepsInitialProration () {
653+ ctx := s .T ().Context ()
654+ startAt := s .mustParseTime ("2024-01-21T21:55:18Z" )
655+ billingAnchor := s .mustParseTime ("2024-01-01T00:00:00Z" )
656+ firstBillingBoundary := s .mustParseTime ("2024-02-01T00:00:00Z" )
657+ expectedProratedPrice := productcatalog .NewPriceFrom (productcatalog.FlatPrice {
658+ Amount : alpacadecimal .NewFromFloat (6.51 ),
659+ PaymentTerm : productcatalog .InAdvancePaymentTerm ,
660+ })
661+ expectedFullPrice := productcatalog .NewPriceFrom (productcatalog.FlatPrice {
662+ Amount : alpacadecimal .NewFromFloat (20 ),
663+ PaymentTerm : productcatalog .InAdvancePaymentTerm ,
664+ })
665+
666+ clock .FreezeTime (startAt )
667+ defer clock .UnFreeze ()
668+ s .enableProrating ()
669+
670+ // given:
671+ // - a subscription starts mid-period but bills on the first of the month
672+ // - the first in-advance flat fee line is generated with alignment proration
673+ // when:
674+ // - renewal is canceled at the first billing boundary before the pending line is invoiced
675+ // then:
676+ // - the cancel resync keeps the initial prorated price instead of replacing it with the full period price
677+ planEntity , err := s .PlanService .CreatePlan (ctx , plan.CreatePlanInput {
678+ NamespacedModel : models.NamespacedModel {
679+ Namespace : s .Namespace ,
680+ },
681+ Plan : productcatalog.Plan {
682+ PlanMeta : productcatalog.PlanMeta {
683+ Name : "Test Plan" ,
684+ Key : "test-plan" ,
685+ Version : 1 ,
686+ Currency : currency .USD ,
687+ BillingCadence : datetime .MustParseDuration (s .T (), "P1M" ),
688+ ProRatingConfig : productcatalog.ProRatingConfig {
689+ Enabled : true ,
690+ Mode : productcatalog .ProRatingModeProratePrices ,
691+ },
692+ },
693+ Phases : []productcatalog.Phase {
694+ {
695+ PhaseMeta : s .phaseMeta ("first-phase" , "" ),
696+ RateCards : productcatalog.RateCards {
697+ & productcatalog.UsageBasedRateCard {
698+ RateCardMeta : productcatalog.RateCardMeta {
699+ Key : "in-advance" ,
700+ Name : "in-advance" ,
701+ Price : productcatalog .NewPriceFrom (productcatalog.FlatPrice {
702+ Amount : alpacadecimal .NewFromFloat (20 ),
703+ PaymentTerm : productcatalog .InAdvancePaymentTerm ,
704+ }),
705+ },
706+ BillingCadence : datetime .MustParseDuration (s .T (), "P1M" ),
707+ },
708+ },
709+ },
710+ },
711+ },
712+ })
713+ s .NoError (err )
714+
715+ subscriptionPlan , err := s .SubscriptionPlanAdapter .GetVersion (ctx , s .Namespace , productcatalogsubscription.PlanRefInput {
716+ Key : planEntity .Key ,
717+ Version : lo .ToPtr (1 ),
718+ })
719+ s .NoError (err )
720+
721+ subsView , err := s .SubscriptionWorkflowService .CreateFromPlan (ctx , subscriptionworkflow.CreateSubscriptionWorkflowInput {
722+ ChangeSubscriptionWorkflowInput : subscriptionworkflow.ChangeSubscriptionWorkflowInput {
723+ Timing : subscription.Timing {
724+ Custom : lo .ToPtr (startAt ),
725+ },
726+ Name : "subs-1" ,
727+ },
728+ Namespace : s .Namespace ,
729+ CustomerID : s .Customer .ID ,
730+ BillingAnchor : lo .ToPtr (billingAnchor ),
731+ }, subscriptionPlan )
732+ s .NoError (err )
733+
734+ s .NoError (s .Service .SyncByView (ctx , subsView , firstBillingBoundary ))
735+
736+ gatheringInvoice := s .gatheringInvoice (ctx , s .Namespace , s .Customer .ID )
737+ s .expectLines (gatheringInvoice , subsView .Subscription .ID , []expectedLine {
738+ {
739+ Matcher : recurringLineMatcher {
740+ PhaseKey : "first-phase" ,
741+ ItemKey : "in-advance" ,
742+ Version : 0 ,
743+ PeriodMin : 0 ,
744+ PeriodMax : 0 ,
745+ },
746+ Price : mo .Some (expectedProratedPrice ),
747+ Periods : []timeutil.ClosedPeriod {
748+ {
749+ From : startAt ,
750+ To : firstBillingBoundary ,
751+ },
752+ },
753+ InvoiceAt : mo .Some ([]time.Time {startAt }),
754+ AdditionalChecks : func (line billing.GenericInvoiceLine ) {
755+ flatPrice , err := line .GetPrice ().AsFlat ()
756+ s .NoError (err )
757+ s .False (flatPrice .Amount .Equal (alpacadecimal .NewFromFloat (20 )))
758+ },
759+ },
760+ {
761+ Matcher : recurringLineMatcher {
762+ PhaseKey : "first-phase" ,
763+ ItemKey : "in-advance" ,
764+ Version : 0 ,
765+ PeriodMin : 1 ,
766+ PeriodMax : 1 ,
767+ },
768+ Price : mo .Some (expectedFullPrice ),
769+ Periods : []timeutil.ClosedPeriod {
770+ {
771+ From : firstBillingBoundary ,
772+ To : s .mustParseTime ("2024-03-01T00:00:00Z" ),
773+ },
774+ },
775+ InvoiceAt : mo .Some ([]time.Time {firstBillingBoundary }),
776+ },
777+ })
778+
779+ clock .FreezeTime (startAt .Add (13 * time .Second ))
780+ subscriptionModel , err := s .SubscriptionService .Cancel (ctx , subsView .Subscription .NamespacedID , subscription.Timing {
781+ Custom : lo .ToPtr (firstBillingBoundary ),
782+ })
783+ s .NoError (err )
784+
785+ subsView , err = s .SubscriptionService .GetView (ctx , subscriptionModel .NamespacedID )
786+ s .NoError (err )
787+
788+ s .NoError (s .Service .SyncByView (ctx , subsView , firstBillingBoundary ))
789+
790+ gatheringInvoice = s .gatheringInvoice (ctx , s .Namespace , s .Customer .ID )
791+ s .expectLines (gatheringInvoice , subsView .Subscription .ID , []expectedLine {
792+ {
793+ Matcher : recurringLineMatcher {
794+ PhaseKey : "first-phase" ,
795+ ItemKey : "in-advance" ,
796+ Version : 0 ,
797+ PeriodMin : 0 ,
798+ PeriodMax : 0 ,
799+ },
800+ Price : mo .Some (expectedProratedPrice ),
801+ Periods : []timeutil.ClosedPeriod {
802+ {
803+ From : startAt ,
804+ To : firstBillingBoundary ,
805+ },
806+ },
807+ InvoiceAt : mo .Some ([]time.Time {startAt }),
808+ AdditionalChecks : func (line billing.GenericInvoiceLine ) {
809+ flatPrice , err := line .GetPrice ().AsFlat ()
810+ s .NoError (err )
811+ s .False (flatPrice .Amount .Equal (alpacadecimal .NewFromFloat (20 )))
812+ },
813+ },
814+ })
815+ }
816+
652817func (s * SubscriptionHandlerTestSuite ) TestInAdvanceGatheringSyncNonBillableAmount () {
653818 ctx := s .T ().Context ()
654819 clock .FreezeTime (s .mustParseTime ("2024-01-01T00:00:00Z" ))
0 commit comments