@@ -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+
118229func (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 ())
0 commit comments