Skip to content

Commit 1d4286f

Browse files
authored
fix(billing): support credit-only period patches (#4597)
1 parent 5b9c743 commit 1d4286f

10 files changed

Lines changed: 804 additions & 142 deletions

File tree

openmeter/billing/charges/flatfee/service/creditsonly.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import (
44
"context"
55
"fmt"
66

7+
"github.com/alpacahq/alpacadecimal"
78
"github.com/samber/lo"
9+
"github.com/samber/mo"
810

911
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
1012
flatfeerealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service/realizations"
1113
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
14+
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
1215
"github.com/openmeterio/openmeter/openmeter/productcatalog"
1316
"github.com/openmeterio/openmeter/pkg/clock"
1417
"github.com/openmeterio/openmeter/pkg/statelessx"
@@ -44,19 +47,25 @@ func (s *CreditsOnlyStateMachine) configureStates() {
4447
s.Configure(flatfee.StatusCreated).
4548
Permit(meta.TriggerNext, flatfee.StatusActive, statelessx.BoolFn(s.IsInsideServicePeriod)).
4649
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
50+
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
51+
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
4752
OnActive(
4853
s.AdvanceAfterServicePeriodFrom,
4954
)
5055

5156
s.Configure(flatfee.StatusActive).
5257
Permit(meta.TriggerNext, flatfee.StatusFinal, statelessx.BoolFn(s.IsAfterInvoiceAt)).
5358
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
59+
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
60+
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
5461
OnActive(
5562
s.AdvanceAfterInvoiceAt,
5663
)
5764

5865
s.Configure(flatfee.StatusFinal).
5966
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
67+
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
68+
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
6069
OnActive(
6170
statelessx.AllOf(
6271
s.AllocateCredits,
@@ -102,6 +111,10 @@ func (s *CreditsOnlyStateMachine) AllocateCredits(ctx context.Context) error {
102111
}
103112
}
104113

114+
if s.Charge.Realizations.CurrentRun != nil && len(s.Charge.Realizations.CurrentRun.CreditRealizations) > 0 {
115+
return s.reconcileCurrentRunCredits(ctx, amount)
116+
}
117+
105118
result, err := s.Realizations.AllocateCreditsOnly(ctx, flatfeerealizations.AllocateCreditsOnlyInput{
106119
Charge: s.Charge,
107120
Amount: amount,
@@ -115,6 +128,104 @@ func (s *CreditsOnlyStateMachine) AllocateCredits(ctx context.Context) error {
115128
return nil
116129
}
117130

131+
func (s *CreditsOnlyStateMachine) ExtendCharge(ctx context.Context, patch meta.PatchExtend) error {
132+
return s.applyPeriodPatch(ctx, patch)
133+
}
134+
135+
func (s *CreditsOnlyStateMachine) ShrinkCharge(ctx context.Context, patch meta.PatchShrink) error {
136+
return s.applyPeriodPatch(ctx, patch)
137+
}
138+
139+
func (s *CreditsOnlyStateMachine) applyPeriodPatch(ctx context.Context, patch periodPatch) error {
140+
targetIntent, err := s.Charge.Intent.GetIntentForTarget(patch.GetTarget())
141+
if err != nil {
142+
return fmt.Errorf("getting %s intent: %w", patch.GetTarget(), err)
143+
}
144+
145+
if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil {
146+
return fmt.Errorf("validate %s patch: %w", patch.Op(), err)
147+
}
148+
149+
intent := s.Charge.Intent
150+
if err := intent.Mutate(patch.GetTarget(), func(fields *flatfee.IntentMutableFields) {
151+
fields.ServicePeriod.To = patch.GetNewServicePeriodTo()
152+
fields.FullServicePeriod.To = patch.GetNewFullServicePeriodTo()
153+
fields.BillingPeriod.To = patch.GetNewBillingPeriodTo()
154+
fields.InvoiceAt = patch.GetNewInvoiceAt()
155+
}); err != nil {
156+
return fmt.Errorf("mutating %s intent: %w", patch.GetTarget(), err)
157+
}
158+
159+
s.Charge.Intent = intent
160+
161+
if patch.GetTarget() == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
162+
// Subscription sync targets the base intent. When an override is active,
163+
// customer-facing credit allocations remain owned by the override.
164+
return nil
165+
}
166+
167+
amountAfterProration, err := intent.CalculateAmountAfterProration()
168+
if err != nil {
169+
return fmt.Errorf("calculating amount after proration: %w", err)
170+
}
171+
s.Charge.State.AmountAfterProration = amountAfterProration
172+
173+
if s.Charge.Realizations.CurrentRun == nil {
174+
return nil
175+
}
176+
177+
return s.reconcileCurrentRunCredits(ctx, amountAfterProration)
178+
}
179+
180+
func (s *CreditsOnlyStateMachine) reconcileCurrentRunCredits(ctx context.Context, amount alpacadecimal.Decimal) error {
181+
currentRun := s.Charge.Realizations.CurrentRun
182+
if currentRun == nil {
183+
return nil
184+
}
185+
186+
currencyCalculator, err := s.Charge.Intent.GetCurrency().Calculator()
187+
if err != nil {
188+
return fmt.Errorf("get currency calculator: %w", err)
189+
}
190+
191+
amount = currencyCalculator.RoundToPrecision(amount)
192+
servicePeriod := s.Charge.Intent.GetEffectiveServicePeriod()
193+
run := *currentRun
194+
run.ServicePeriod = servicePeriod
195+
196+
reconcileResult, err := s.Realizations.ReconcileCredits(ctx, flatfeerealizations.ReconcileCreditRealizationsInput{
197+
Charge: s.Charge,
198+
Run: run,
199+
AllocateAt: flatfee.UsageBookedAt(s.Charge.Intent.GetEffectivePaymentTerm(), servicePeriod),
200+
TargetAmount: amount,
201+
CurrencyCalculator: currencyCalculator,
202+
})
203+
if err != nil {
204+
return fmt.Errorf("reconcile credits for run %s: %w", run.ID.ID, err)
205+
}
206+
207+
run.CreditRealizations = append(run.CreditRealizations, reconcileResult.Realizations...)
208+
209+
runBase, err := s.Adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{
210+
ID: run.ID,
211+
ServicePeriod: mo.Some(servicePeriod),
212+
AmountAfterProration: mo.Some(amount),
213+
Totals: mo.Some(totals.Totals{
214+
Amount: amount,
215+
CreditsTotal: amount,
216+
Total: alpacadecimal.Zero,
217+
}),
218+
NoFiatTransactionRequired: mo.Some(true),
219+
})
220+
if err != nil {
221+
return fmt.Errorf("update credit-only run: %w", err)
222+
}
223+
224+
run.RealizationRunBase = runBase
225+
s.Charge.Realizations.CurrentRun = &run
226+
return nil
227+
}
228+
118229
func (s *CreditsOnlyStateMachine) DeleteCharge(ctx context.Context, patch meta.PatchDelete) error {
119230
if err := s.Charge.Intent.Mutate(patch.GetTarget(), func(fields *flatfee.IntentMutableFields) {
120231
fields.IntentDeletedAt = lo.ToPtr(clock.Now())

openmeter/billing/charges/meta/patch.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package meta
22

33
import (
4+
"context"
45
"fmt"
56
"slices"
67

@@ -53,3 +54,11 @@ type TriggerPatchResult[T any] struct {
5354
Charge *T
5455
InvoicePatches []invoiceupdater.Patch
5556
}
57+
58+
// PatchAction adapts a generic Patch action to a concrete patch action when
59+
// statelessx.AllOfWithParameters requires strict typing for composed actions.
60+
func PatchAction[T Patch](fn func(context.Context, Patch) error) func(context.Context, T) error {
61+
return func(ctx context.Context, patch T) error {
62+
return fn(ctx, patch)
63+
}
64+
}

openmeter/billing/charges/usagebased/service/creditheninvoice.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,6 @@ type periodPatch interface {
3636
ValidateWith(meta.IntentMutableFields) error
3737
}
3838

39-
type applyPeriodPatchResult struct {
40-
ShouldReconcile bool
41-
OldServicePeriod timeutil.ClosedPeriod
42-
}
43-
4439
var (
4540
_ periodPatch = meta.PatchExtend{}
4641
_ periodPatch = meta.PatchShrink{}
@@ -398,14 +393,19 @@ func (s *CreditThenInvoiceStateMachine) ShrinkCharge(_ context.Context, patch me
398393
return nil
399394
}
400395

401-
func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (applyPeriodPatchResult, error) {
396+
type creditThenInvoiceApplyPeriodPatchResult struct {
397+
ShouldReconcile bool
398+
OldServicePeriod timeutil.ClosedPeriod
399+
}
400+
401+
func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (creditThenInvoiceApplyPeriodPatchResult, error) {
402402
targetIntent, err := s.Charge.Intent.GetIntentForTarget(patch.GetTarget())
403403
if err != nil {
404-
return applyPeriodPatchResult{}, fmt.Errorf("getting %s intent: %w", patch.GetTarget(), err)
404+
return creditThenInvoiceApplyPeriodPatchResult{}, fmt.Errorf("getting %s intent: %w", patch.GetTarget(), err)
405405
}
406406

407407
if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil {
408-
return applyPeriodPatchResult{}, fmt.Errorf("validate %s patch: %w", patch.Op(), err)
408+
return creditThenInvoiceApplyPeriodPatchResult{}, fmt.Errorf("validate %s patch: %w", patch.Op(), err)
409409
}
410410

411411
oldServicePeriod := meta.NormalizeClosedPeriod(targetIntent.IntentMutableFields.ServicePeriod)
@@ -416,16 +416,16 @@ func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (app
416416
fields.BillingPeriod.To = patch.GetNewBillingPeriodTo()
417417
fields.InvoiceAt = patch.GetNewInvoiceAt()
418418
}); err != nil {
419-
return applyPeriodPatchResult{}, fmt.Errorf("mutating %s intent: %w", patch.GetTarget(), err)
419+
return creditThenInvoiceApplyPeriodPatchResult{}, fmt.Errorf("mutating %s intent: %w", patch.GetTarget(), err)
420420
}
421421

422422
if patch.GetTarget() == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
423423
// Subscription sync targets the base intent. When an override is active,
424424
// customer-facing invoice/run state remains owned by the override layer.
425-
return applyPeriodPatchResult{}, nil
425+
return creditThenInvoiceApplyPeriodPatchResult{}, nil
426426
}
427427

428-
return applyPeriodPatchResult{
428+
return creditThenInvoiceApplyPeriodPatchResult{
429429
ShouldReconcile: true,
430430
OldServicePeriod: oldServicePeriod,
431431
}, nil

0 commit comments

Comments
 (0)