Skip to content

Commit 72235b7

Browse files
committed
fix subscription cancel proration
1 parent 277f8d3 commit 72235b7

2 files changed

Lines changed: 167 additions & 6 deletions

File tree

openmeter/billing/worker/subscriptionsync/service/sync_test.go

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
652817
func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncNonBillableAmount() {
653818
ctx := s.T().Context()
654819
clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z"))

openmeter/billing/worker/subscriptionsync/service/targetstate/targetstateitem.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,6 @@ func (r StateItem) shouldProrate() bool {
142142
return false
143143
}
144144

145-
if r.Subscription.ActiveTo != nil && !r.Subscription.ActiveTo.After(r.ServicePeriod.To) {
146-
return false
147-
}
148-
149145
switch r.Subscription.ProRatingConfig.Mode {
150146
case productcatalog.ProRatingModeProratePrices:
151147
return true

0 commit comments

Comments
 (0)