Skip to content

Commit 3b4cfe9

Browse files
committed
feat(sync): fix prorating
1 parent 505a652 commit 3b4cfe9

File tree

2 files changed

+115
-51
lines changed

2 files changed

+115
-51
lines changed

openmeter/billing/worker/subscription/sync.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView
492492
// TODO[OM-1040]: We should support rounding errors in prorating calculations (such as 1/3 of a dollar is $0.33, 3*$0.33 is $0.99, if we bill
493493
// $1.00 in three equal pieces we should charge the customer $0.01 as the last split)
494494
perUnitAmount := currency.RoundToPrecision(price.Amount)
495-
if !item.ServicePeriod.IsEmpty() && h.shouldProrateFlatFee(price) {
495+
if !item.ServicePeriod.IsEmpty() && h.shouldProrate(item, subs) {
496496
perUnitAmount = currency.RoundToPrecision(price.Amount.Mul(item.PeriodPercentage()))
497497
}
498498

@@ -551,12 +551,25 @@ func (h *Handler) discountsToBillingDiscounts(discounts productcatalog.Discounts
551551
return out
552552
}
553553

554-
func (h *Handler) shouldProrateFlatFee(price productcatalog.FlatPrice) bool {
555-
switch price.PaymentTerm {
556-
case productcatalog.InAdvancePaymentTerm:
557-
return h.featureFlags.EnableFlatFeeInAdvanceProrating
558-
case productcatalog.InArrearsPaymentTerm:
559-
return h.featureFlags.EnableFlatFeeInArrearsProrating
554+
func (h *Handler) shouldProrate(item subscriptionItemWithPeriods, subView subscription.SubscriptionView) bool {
555+
if !subView.Subscription.ProRatingConfig.Enabled {
556+
return false
557+
}
558+
559+
// We only prorate flat prices
560+
if item.Spec.RateCard.AsMeta().Price.Type() != productcatalog.FlatPriceType {
561+
return false
562+
}
563+
564+
// We do not prorate due to the subscription ending
565+
if subView.Subscription.ActiveTo != nil && !subView.Subscription.ActiveTo.After(item.ServicePeriod.End) {
566+
return false
567+
}
568+
569+
// We're just gonna prorate all flat prices based on subscription settings
570+
switch subView.Subscription.ProRatingConfig.Mode {
571+
case productcatalog.ProRatingModeProratePrices:
572+
return true
560573
default:
561574
return false
562575
}

openmeter/billing/worker/subscription/sync_test.go

Lines changed: 95 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsProrating() {
663663
s.Equal(flatFeeLine.FlatFee.Quantity.InexactFloat64(), 1.0)
664664
})
665665

666-
s.Run("canceling the subscription causes the existing item to be pro-rated", func() {
666+
s.Run("canceling the subscription DOES NOT cause the existing item to be pro-rated", func() {
667667
clock.SetTime(s.mustParseTime("2024-01-01T10:00:00Z"))
668668

669669
cancelAt := s.mustParseTime("2024-01-01T12:00:00Z")
@@ -706,8 +706,8 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsProrating() {
706706
Start: s.mustParseTime("2024-01-01T00:00:00Z"),
707707
End: cancelAt,
708708
})
709-
s.Equal(flatFeeLine.FlatFee.PerUnitAmount.InexactFloat64(), 2.5)
710-
s.Equal(flatFeeLine.FlatFee.Quantity.InexactFloat64(), 1.0)
709+
s.Equal(5.0, flatFeeLine.FlatFee.PerUnitAmount.InexactFloat64())
710+
s.Equal(1.0, flatFeeLine.FlatFee.Quantity.InexactFloat64())
711711
})
712712
}
713713

@@ -801,24 +801,44 @@ func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncNonBillableAmou
801801
// the gathering invoice will only contain both versions of the fee as we are not
802802
// doing any pro-rating logic
803803

804-
subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{
805-
{
806-
PhaseMeta: s.phaseMeta("first-phase", ""),
807-
RateCards: productcatalog.RateCards{
808-
&productcatalog.UsageBasedRateCard{
809-
RateCardMeta: productcatalog.RateCardMeta{
810-
Key: "in-advance",
811-
Name: "in-advance",
812-
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
813-
Amount: alpacadecimal.NewFromFloat(5),
814-
PaymentTerm: productcatalog.InAdvancePaymentTerm,
815-
}),
804+
planInput := plan.CreatePlanInput{
805+
NamespacedModel: models.NamespacedModel{
806+
Namespace: s.Namespace,
807+
},
808+
Plan: productcatalog.Plan{
809+
PlanMeta: productcatalog.PlanMeta{
810+
Name: "Test Plan",
811+
Key: "test-plan",
812+
Version: 1,
813+
Currency: currency.USD,
814+
BillingCadence: isodate.MustParse(s.T(), "P1M"),
815+
ProRatingConfig: productcatalog.ProRatingConfig{
816+
Enabled: false,
817+
Mode: productcatalog.ProRatingModeProratePrices,
818+
},
819+
},
820+
Phases: []productcatalog.Phase{
821+
{
822+
PhaseMeta: s.phaseMeta("first-phase", ""),
823+
RateCards: productcatalog.RateCards{
824+
&productcatalog.UsageBasedRateCard{
825+
RateCardMeta: productcatalog.RateCardMeta{
826+
Key: "in-advance",
827+
Name: "in-advance",
828+
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
829+
Amount: alpacadecimal.NewFromFloat(5),
830+
PaymentTerm: productcatalog.InAdvancePaymentTerm,
831+
}),
832+
},
833+
BillingCadence: isodate.MustParse(s.T(), "P1D"),
834+
},
816835
},
817-
BillingCadence: isodate.MustParse(s.T(), "P1D"),
818836
},
819837
},
820838
},
821-
})
839+
}
840+
841+
subsView := s.createSubscriptionFromPlan(planInput)
822842

823843
s.NoError(s.Handler.SyncronizeSubscription(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z")))
824844
s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID))
@@ -897,24 +917,44 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsGatheringSyncNonBillableAmou
897917
// the gathering invoice will only contain both versions of the fee as we are not
898918
// doing any pro-rating logic
899919

900-
subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{
901-
{
902-
PhaseMeta: s.phaseMeta("first-phase", ""),
903-
RateCards: productcatalog.RateCards{
904-
&productcatalog.UsageBasedRateCard{
905-
RateCardMeta: productcatalog.RateCardMeta{
906-
Key: "in-arrears",
907-
Name: "in-arrears",
908-
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
909-
Amount: alpacadecimal.NewFromFloat(5),
910-
PaymentTerm: productcatalog.InArrearsPaymentTerm,
911-
}),
920+
planInput := plan.CreatePlanInput{
921+
NamespacedModel: models.NamespacedModel{
922+
Namespace: s.Namespace,
923+
},
924+
Plan: productcatalog.Plan{
925+
PlanMeta: productcatalog.PlanMeta{
926+
Name: "Test Plan",
927+
Key: "test-plan",
928+
Version: 1,
929+
Currency: currency.USD,
930+
BillingCadence: isodate.MustParse(s.T(), "P1M"),
931+
ProRatingConfig: productcatalog.ProRatingConfig{
932+
Enabled: false,
933+
Mode: productcatalog.ProRatingModeProratePrices,
934+
},
935+
},
936+
Phases: []productcatalog.Phase{
937+
{
938+
PhaseMeta: s.phaseMeta("first-phase", ""),
939+
RateCards: productcatalog.RateCards{
940+
&productcatalog.UsageBasedRateCard{
941+
RateCardMeta: productcatalog.RateCardMeta{
942+
Key: "in-arrears",
943+
Name: "in-arrears",
944+
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
945+
Amount: alpacadecimal.NewFromFloat(5),
946+
PaymentTerm: productcatalog.InArrearsPaymentTerm,
947+
}),
948+
},
949+
BillingCadence: isodate.MustParse(s.T(), "P1D"),
950+
},
912951
},
913-
BillingCadence: isodate.MustParse(s.T(), "P1D"),
914952
},
915953
},
916954
},
917-
})
955+
}
956+
957+
subsView := s.createSubscriptionFromPlan(planInput)
918958

919959
s.NoError(s.Handler.SyncronizeSubscription(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z")))
920960
s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID))
@@ -1467,7 +1507,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionInvoicing() {
14671507
},
14681508
BillingCadence: isodate.MustParse(s.T(), "P4W"),
14691509
ProRatingConfig: productcatalog.ProRatingConfig{
1470-
Enabled: true,
1510+
Enabled: false,
14711511
Mode: productcatalog.ProRatingModeProratePrices,
14721512
},
14731513
},
@@ -3675,6 +3715,9 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
36753715
gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)
36763716
s.DebugDumpInvoice("gathering invoice", gatheringInvoice)
36773717

3718+
// January is 31 days, wechange phase after 2 weeks (14 days)
3719+
// 5 * 14/31 = 2.258... which we round to 2.26
3720+
36783721
s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{
36793722
// First phase lines
36803723
{
@@ -3683,7 +3726,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
36833726
ItemKey: "in-advance",
36843727
},
36853728
Qty: mo.Some(1.0),
3686-
UnitPrice: mo.Some(2.5),
3729+
UnitPrice: mo.Some(2.26),
36873730
Periods: []billing.Period{
36883731
{
36893732
Start: s.mustParseTime("2024-01-01T00:00:00Z"),
@@ -3698,7 +3741,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
36983741
ItemKey: "in-arrears",
36993742
},
37003743
Qty: mo.Some(1.0),
3701-
UnitPrice: mo.Some(2.5),
3744+
UnitPrice: mo.Some(2.26),
37023745
Periods: []billing.Period{
37033746
{
37043747
Start: s.mustParseTime("2024-01-01T00:00:00Z"),
@@ -3712,8 +3755,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
37123755
PhaseKey: "first-phase",
37133756
ItemKey: "api-requests-total",
37143757
},
3715-
Qty: mo.Some(1.0),
3716-
UnitPrice: mo.Some[float64](10),
3758+
Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromFloat(10)})),
37173759
Periods: []billing.Period{
37183760
{
37193761
Start: s.mustParseTime("2024-01-01T00:00:00Z"),
@@ -3731,7 +3773,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
37313773
PeriodMax: 1,
37323774
},
37333775
Qty: mo.Some(1.0),
3734-
UnitPrice: mo.Some(2.5),
3776+
UnitPrice: mo.Some(5.0),
37353777
Periods: []billing.Period{
37363778
{
37373779
Start: s.mustParseTime("2024-01-15T00:00:00Z"),
@@ -3752,7 +3794,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
37523794
PeriodMax: 1,
37533795
},
37543796
Qty: mo.Some(1.0),
3755-
UnitPrice: mo.Some(2.5),
3797+
UnitPrice: mo.Some(5.0),
37563798
Periods: []billing.Period{
37573799
{
37583800
Start: s.mustParseTime("2024-01-15T00:00:00Z"),
@@ -3772,8 +3814,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior(
37723814
PeriodMin: 0,
37733815
PeriodMax: 1,
37743816
},
3775-
Qty: mo.Some(1.0),
3776-
UnitPrice: mo.Some[float64](10),
3817+
Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromFloat(10)})),
37773818
Periods: []billing.Period{
37783819
{
37793820
Start: s.mustParseTime("2024-01-15T00:00:00Z"),
@@ -3824,19 +3865,29 @@ func (s *SubscriptionHandlerTestSuite) expectLines(invoice billing.Invoice, subs
38243865

38253866
if expectedLine.Qty.IsPresent() {
38263867
if line.Type == billing.InvoiceLineTypeFee {
3827-
s.Equal(expectedLine.Qty.OrEmpty(), line.FlatFee.Quantity.InexactFloat64(), "%s: quantity", childID)
3868+
if line.FlatFee == nil {
3869+
s.Failf("flat fee line not found", "line not found with child id %s", childID)
3870+
} else {
3871+
s.Equal(expectedLine.Qty.OrEmpty(), line.FlatFee.Quantity.InexactFloat64(), "%s: quantity", childID)
3872+
}
38283873
} else {
3829-
s.Equal(expectedLine.Qty.OrEmpty(), line.UsageBased.Quantity.InexactFloat64(), "%s: quantity", childID)
3874+
if line.UsageBased == nil {
3875+
s.Failf("usage based line not found", "line not found with child id %s", childID)
3876+
} else if line.UsageBased.Quantity == nil {
3877+
s.Failf("usage based line quantity not found", "line not found with child id %s", childID)
3878+
} else {
3879+
s.Equal(expectedLine.Qty.OrEmpty(), line.UsageBased.Quantity.InexactFloat64(), "%s: quantity", childID)
3880+
}
38303881
}
38313882
}
38323883

38333884
if expectedLine.UnitPrice.IsPresent() {
3834-
s.Equal(line.Type, billing.InvoiceLineTypeFee, "%s: line type", childID)
3885+
s.Equal(billing.InvoiceLineTypeFee, line.Type, "%s: line type", childID)
38353886
s.Equal(expectedLine.UnitPrice.OrEmpty(), line.FlatFee.PerUnitAmount.InexactFloat64(), "%s: unit price", childID)
38363887
}
38373888

38383889
if expectedLine.Price.IsPresent() {
3839-
s.Equal(line.Type, billing.InvoiceLineTypeUsageBased, "%s: line type", childID)
3890+
s.Equal(billing.InvoiceLineTypeUsageBased, line.Type, "%s: line type", childID)
38403891
s.Equal(*expectedLine.Price.OrEmpty(), *line.UsageBased.Price, "%s: price", childID)
38413892
}
38423893

0 commit comments

Comments
 (0)