Skip to content

Commit 17aaadc

Browse files
committed
feat(subs): normalize anchor time to closest iteration before activation
1 parent b7a6d48 commit 17aaadc

File tree

6 files changed

+72
-6
lines changed

6 files changed

+72
-6
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) {
@@ -48,6 +49,29 @@ func (s *service) CreateFromPlan(ctx context.Context, inp subscriptionworkflow.C
4849
return def, fmt.Errorf("failed to resolve active from: %w", err)
4950
}
5051

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

6286
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
@@ -28,6 +28,7 @@ import (
2828
"github.com/openmeterio/openmeter/pkg/framework/lockr"
2929
"github.com/openmeterio/openmeter/pkg/isodate"
3030
"github.com/openmeterio/openmeter/pkg/models"
31+
"github.com/openmeterio/openmeter/pkg/timeutil"
3132
)
3233

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

95131
for _, tc := range testCases {
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
-- modify "subscriptions" table
2-
ALTER TABLE "subscriptions" ADD COLUMN "billing_anchor" timestamptz NOT NULL;
2+
ALTER TABLE "subscriptions" ADD COLUMN "billing_anchor" timestamptz;
3+
4+
-- update all entries to set the billing anchor to the active_from time
5+
UPDATE "subscriptions" SET "billing_anchor" = "active_from" WHERE "billing_anchor" IS NULL;
6+
7+
-- make the billing anchor not nullable
8+
ALTER TABLE "subscriptions" ALTER COLUMN "billing_anchor" SET NOT NULL;

tools/migrate/migrations/atlas.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
h1:O4KJp2wgNnSPMGyVIFTLms5dJy1jh5VwwD3vodSIgxc=
1+
h1:rNbbVrC/sli4lZdHSKmh2/iV0pMvJ4Xu42ZzoQOAOGA=
22
20240826120919_init.up.sql h1:tc1V91/smlmaeJGQ8h+MzTEeFjjnrrFDbDAjOYJK91o=
33
20240903155435_entitlement-expired-index.up.sql h1:Hp8u5uckmLXc1cRvWU0AtVnnK8ShlpzZNp8pbiJLhac=
44
20240917172257_billing-entities.up.sql h1:Q1dAMo0Vjiit76OybClNfYPGC5nmvov2/M2W1ioi4Kw=
@@ -106,4 +106,4 @@ h1:O4KJp2wgNnSPMGyVIFTLms5dJy1jh5VwwD3vodSIgxc=
106106
20250609172811_billing-split-line-group.up.sql h1:lop1t+ERtU58bCMK9cxgrc51OstEkIVjM2VIQj5x2ko=
107107
20250609204117_billing-migrate-split-line-groups.up.sql h1:jtPl2Fpkhwut/5WcUz8h2lSxtbjvfRH2/QhbRcJDQzU=
108108
20250610101736_plan-subscription-billing-cadence.up.sql h1:01Cu9dQMHIEFZuT66Zxo9uWq3fIj57mlqnYkMu7Zobo=
109-
20250610125104_subs-billing-anchor.up.sql h1:8YpAKGOLEi4tEeQztsEzPTNWmyyqJ77NaKy2JcY6iuE=
109+
20250610125104_subs-billing-anchor.up.sql h1:oCxmAFVtwo7h+INdbpp2XseR/srBwgCWKUQTjguXjl4=

0 commit comments

Comments
 (0)