Skip to content

Commit c4b8ce8

Browse files
committed
feat(subs): normalize anchor time to closest iteration before activation
1 parent 0dd5ecc commit c4b8ce8

File tree

4 files changed

+63
-3
lines changed

4 files changed

+63
-3
lines changed

openmeter/subscription/repo/mapping.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func MapDBSubscription(sub *db.Subscription) (subscription.Subscription, error)
6060
Currency: sub.Currency,
6161
BillingCadence: billingCadence,
6262
ProRatingConfig: sub.ProRatingConfig,
63-
BillingAnchor: sub.BillingAnchor,
63+
BillingAnchor: sub.BillingAnchor.UTC(),
6464
}, nil
6565
}
6666

openmeter/subscription/repo/subscriptionrepo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func (r *subscriptionRepo) Create(ctx context.Context, sub subscription.CreateSu
110110
SetNillableDescription(sub.Description).
111111
SetBillablesMustAlign(sub.BillablesMustAlign).
112112
SetMetadata(sub.Metadata).
113-
SetBillingAnchor(sub.BillingAnchor)
113+
SetBillingAnchor(sub.BillingAnchor.UTC())
114114

115115
if sub.ActiveTo != nil {
116116
command = command.SetActiveTo(*sub.ActiveTo)

openmeter/subscription/workflow/service/subscription.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/openmeterio/openmeter/pkg/clock"
1515
"github.com/openmeterio/openmeter/pkg/framework/transaction"
1616
"github.com/openmeterio/openmeter/pkg/models"
17+
"github.com/openmeterio/openmeter/pkg/timeutil"
1718
)
1819

1920
func (s *service) CreateFromPlan(ctx context.Context, inp subscriptionworkflow.CreateSubscriptionWorkflowInput, plan subscription.Plan) (subscription.SubscriptionView, error) {
@@ -43,6 +44,29 @@ func (s *service) CreateFromPlan(ctx context.Context, inp subscriptionworkflow.C
4344
return def, fmt.Errorf("failed to resolve active from: %w", err)
4445
}
4546

47+
// Let's normalize the billing anchor to the closest iteration based on the cadence
48+
billingAnchor := lo.FromPtrOr(inp.BillingAnchor, activeFrom).UTC()
49+
50+
billingRecurrence, err := timeutil.RecurrenceFromISODuration(lo.ToPtr(plan.ToCreateSubscriptionPlanInput().BillingCadence), billingAnchor)
51+
if err != nil {
52+
return def, fmt.Errorf("failed to get billing recurrence: %w", err)
53+
}
54+
55+
billingAnchor, err = billingRecurrence.PrevBefore(activeFrom)
56+
if err != nil {
57+
return def, fmt.Errorf("failed to get billing anchor: %w", err)
58+
}
59+
60+
// When anchor = beforeTime (or falls on iteration boundary), PrevBefore will return an iteration early.
61+
oneBefore, err := billingRecurrence.Prev(activeFrom)
62+
if err != nil {
63+
return def, fmt.Errorf("failed to get one iteration before billing anchor: %w", err)
64+
}
65+
66+
if oneBefore.Equal(billingAnchor) {
67+
billingAnchor = activeFrom
68+
}
69+
4670
// Let's create the new Spec
4771
spec, err := subscription.NewSpecFromPlan(plan, subscription.CreateSubscriptionCustomerInput{
4872
CustomerId: cust.ID,
@@ -51,7 +75,7 @@ func (s *service) CreateFromPlan(ctx context.Context, inp subscriptionworkflow.C
5175
MetadataModel: inp.MetadataModel,
5276
Name: lo.CoalesceOrEmpty(inp.Name, plan.GetName()),
5377
Description: inp.Description,
54-
BillingAnchor: lo.FromPtrOr(inp.BillingAnchor, activeFrom).UTC(),
78+
BillingAnchor: billingAnchor,
5579
})
5680

5781
if err := subscriptionworkflow.MapSubscriptionErrors(err); err != nil {

openmeter/subscription/workflow/service/subscription_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/openmeterio/openmeter/pkg/clock"
2828
"github.com/openmeterio/openmeter/pkg/isodate"
2929
"github.com/openmeterio/openmeter/pkg/models"
30+
"github.com/openmeterio/openmeter/pkg/timeutil"
3031
)
3132

3233
func TestCreateFromPlan(t *testing.T) {
@@ -89,6 +90,41 @@ func TestCreateFromPlan(t *testing.T) {
8990
}
9091
},
9192
},
93+
{
94+
Name: "Should normalize billing anchor to the closest iteration",
95+
Handler: func(t *testing.T, deps testCaseDeps) {
96+
ctx, cancel := context.WithCancel(context.Background())
97+
defer cancel()
98+
99+
cad := deps.Plan.ToCreateSubscriptionPlanInput().BillingCadence
100+
101+
activeFrom := deps.CurrentTime
102+
103+
billingAnchor := activeFrom.Add(time.Hour)
104+
105+
cadRec, err := timeutil.RecurrenceFromISODuration(lo.ToPtr(cad), billingAnchor)
106+
require.Nil(t, err)
107+
108+
// Let's set an anchor one hour after the activeFrom, which will then be set to one iteration before
109+
110+
expectedBillingAnchor, err := cadRec.Prev(billingAnchor)
111+
require.Nil(t, err)
112+
113+
subView, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
114+
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
115+
Timing: subscription.Timing{
116+
Custom: &deps.CurrentTime,
117+
},
118+
},
119+
CustomerID: deps.Customer.ID,
120+
Namespace: subscriptiontestutils.ExampleNamespace,
121+
BillingAnchor: &billingAnchor,
122+
}, deps.Plan)
123+
require.Nil(t, err)
124+
125+
require.Equal(t, expectedBillingAnchor, subView.Subscription.BillingAnchor)
126+
},
127+
},
92128
}
93129

94130
for _, tc := range testCases {

0 commit comments

Comments
 (0)