Skip to content

Commit e16545b

Browse files
authored
fix: reconcile hidden charge base intents (#4677)
1 parent 7f5f753 commit e16545b

12 files changed

Lines changed: 652 additions & 277 deletions

File tree

.agents/skills/charges/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ Patch target rules:
126126
- Charge patch payloads carry `billing.ChangeSource` source intent instead of an explicit target whenever the target can be derived. Do not rely on a zero/default source.
127127
- Subscription sync emits system-sourced patches because it reconciles subscription-owned source state, not user/API overrides.
128128
- State machines resolve the effective target layer from base intent ownership, override presence, and patch source. System period patches target base only and must reject API-sourced period changes. API manual edits/deletes target an existing override when present, target base for manually managed base intents without an override, and create/target an override for subscription/system-owned base intents.
129-
- State machines should mutate the resolved patch target, then reconcile customer-facing invoice artifacts from the effective intent. If a base-target patch hits a charge with an active override, updating the base layer should not rewrite effective invoice state.
130-
- Delete patches follow the same target rule: deleting the base layer while an override is active should mark the base intent deleted but leave the effective override-backed charge behavior intact.
129+
- Type-specific state machines own only active customer-facing charge lifecycle: status transitions, realization runs, credit corrections, and invoice patches. If a patch targets the base layer while an override exists, that base layer is hidden source state and the state machine must reject it instead of treating it as a lifecycle no-op.
130+
- Subscription-sync and repair paths may still need to reconcile hidden base/source intent fields, such as base period shrink/extend/delete under an active override. Apply those updates before state-machine dispatch in service-level reconciliation, persist only the source intent, and do not emit invoice patches or mutate realization/customer-facing state from that path.
131+
- Delete patches follow the same target rule: deleting the hidden base layer while an override is active should mark only the base intent deleted through the service-level source reconciliation path and leave the effective override-backed charge behavior intact.
131132
- Charge listing for subscription-sync persisted-state loading must filter on base-intent deletion state, not effective `DeletedAt`; otherwise an active override can hide a subscription-owned base charge that sync still needs to reconcile.
132133
- Gathering-line upsert patches are target-state patches. `PatchOpUpsertGatheringLineByChargeID` should carry the full rebuilt `billing.GatheringLine` target state, not just period deltas. The invoice updater queries the existing pending gathering line by charge ID and merges the patch target with `WithTargetState(...)` so DB identity and invoice membership stay intact; if no active pending line exists, it creates one through the regular pending-line provisioning path.
133134
- `PatchShrink` is system-only and always targets the base layer. API/customer-originated period shortening must use a distinct API patch such as `PatchShrinkToRealizedPeriod`, which resolves its target layer through API patch rules and creates or updates the override layer when the base intent is subscription-managed.

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

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,16 @@ func (s *CreditThenInvoiceStateMachine) DeleteCharge(ctx context.Context, patch
165165
if err != nil {
166166
return fmt.Errorf("getting patch target layer: %w", err)
167167
}
168+
if err := s.rejectHiddenIntentTarget(target); err != nil {
169+
return err
170+
}
168171

169172
if err := s.mutateIntentLayer(ctx, target, func(fields *flatfee.IntentMutableFields) {
170173
fields.IntentDeletedAt = deletedAt
171174
}); err != nil {
172175
return fmt.Errorf("deleting intent: %w", err)
173176
}
174177

175-
if target == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
176-
// Subscription sync targets the base intent. When an override is active,
177-
// the customer-facing charge and invoice history remain owned by the override.
178-
return nil
179-
}
180-
181178
s.Charge.Status = flatfee.StatusDeleted
182179

183180
patches := invoiceupdater.Patches{
@@ -220,10 +217,6 @@ func (s *CreditThenInvoiceStateMachine) ExtendCharge(ctx context.Context, patch
220217
return err
221218
}
222219

223-
if !invoicingStateInput.ShouldReconcile {
224-
return nil
225-
}
226-
227220
return s.reconcileInvoicingState(ctx, invoicingStateInput)
228221
}
229222

@@ -233,10 +226,6 @@ func (s *CreditThenInvoiceStateMachine) ShrinkCharge(ctx context.Context, patch
233226
return err
234227
}
235228

236-
if !invoicingStateInput.ShouldReconcile {
237-
return nil
238-
}
239-
240229
return s.reconcileInvoicingState(ctx, invoicingStateInput)
241230
}
242231

@@ -245,6 +234,9 @@ func (s *CreditThenInvoiceStateMachine) LineManualEdit(ctx context.Context, patc
245234
if err != nil {
246235
return fmt.Errorf("getting patch target layer: %w", err)
247236
}
237+
if err := s.rejectHiddenIntentTarget(target); err != nil {
238+
return err
239+
}
248240

249241
override := patch.GetOverride()
250242
if err := meta.ValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFields(override); err != nil {
@@ -332,7 +324,6 @@ func (s *CreditThenInvoiceStateMachine) LineManualEdit(ctx context.Context, patc
332324
}
333325

334326
return s.reconcileInvoicingState(ctx, reconcileInvoicingStateInput{
335-
ShouldReconcile: true,
336327
Op: meta.PatchTypeLineManualEdit,
337328
Period: s.Charge.Intent.GetEffectiveServicePeriod(),
338329
Intent: s.Charge.Intent,
@@ -346,6 +337,9 @@ func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (rec
346337
if err != nil {
347338
return reconcileInvoicingStateInput{}, fmt.Errorf("getting patch target layer: %w", err)
348339
}
340+
if err := s.rejectHiddenIntentTarget(target); err != nil {
341+
return reconcileInvoicingStateInput{}, err
342+
}
349343

350344
targetIntent, err := s.Charge.Intent.GetIntentForTarget(target)
351345
if err != nil {
@@ -367,19 +361,12 @@ func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (rec
367361

368362
s.Charge.Intent = intent
369363

370-
if target == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
371-
// Subscription sync targets the base intent. When an override is active,
372-
// the customer-facing invoice remains owned by the override layer.
373-
return reconcileInvoicingStateInput{}, nil
374-
}
375-
376364
amountAfterProration, err := intent.CalculateAmountAfterProration()
377365
if err != nil {
378366
return reconcileInvoicingStateInput{}, fmt.Errorf("calculating amount after proration: %w", err)
379367
}
380368

381369
return reconcileInvoicingStateInput{
382-
ShouldReconcile: true,
383370
Op: patch.Op(),
384371
Period: intent.GetEffectiveServicePeriod(),
385372
Intent: intent,
@@ -520,7 +507,6 @@ func (s *CreditThenInvoiceStateMachine) AreAllPaymentsSettled() bool {
520507
}
521508

522509
type reconcileInvoicingStateInput struct {
523-
ShouldReconcile bool
524510
Op meta.PatchType
525511
Period timeutil.ClosedPeriod
526512
Intent flatfee.OverridableIntent

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ func (s *CreditsOnlyStateMachine) applyPeriodPatch(ctx context.Context, patch pe
148148
return fmt.Errorf("getting patch target layer: %w", err)
149149
}
150150

151+
if err := s.rejectHiddenIntentTarget(target); err != nil {
152+
return err
153+
}
154+
151155
targetIntent, err := s.Charge.Intent.GetIntentForTarget(target)
152156
if err != nil {
153157
return fmt.Errorf("getting %s intent: %w", target, err)
@@ -169,12 +173,6 @@ func (s *CreditsOnlyStateMachine) applyPeriodPatch(ctx context.Context, patch pe
169173

170174
s.Charge.Intent = intent
171175

172-
if target == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
173-
// Subscription sync targets the base intent. When an override is active,
174-
// customer-facing credit allocations remain owned by the override.
175-
return nil
176-
}
177-
178176
amountAfterProration, err := intent.CalculateAmountAfterProration()
179177
if err != nil {
180178
return fmt.Errorf("calculating amount after proration: %w", err)
@@ -244,18 +242,16 @@ func (s *CreditsOnlyStateMachine) DeleteCharge(ctx context.Context, patch meta.P
244242
return fmt.Errorf("getting patch target layer: %w", err)
245243
}
246244

245+
if err := s.rejectHiddenIntentTarget(target); err != nil {
246+
return err
247+
}
248+
247249
if err := s.mutateIntentLayer(ctx, target, func(fields *flatfee.IntentMutableFields) {
248250
fields.IntentDeletedAt = deletedAt
249251
}); err != nil {
250252
return fmt.Errorf("deleting intent: %w", err)
251253
}
252254

253-
if target == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
254-
// Subscription sync targets the base intent. When an override is active,
255-
// customer-facing credit allocations remain owned by the override.
256-
return nil
257-
}
258-
259255
s.Charge.Status = flatfee.StatusDeleted
260256

261257
if patch.GetPolicy().CreditRefundPolicy == meta.CreditRefundPolicyCorrect && s.Charge.Realizations.CurrentRun != nil {

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1313
chargestatemachine "github.com/openmeterio/openmeter/openmeter/billing/charges/statemachine"
1414
"github.com/openmeterio/openmeter/pkg/clock"
15+
"github.com/openmeterio/openmeter/pkg/models"
1516
)
1617

1718
type stateMachine struct {
@@ -131,6 +132,22 @@ func (s *stateMachine) mutateIntentLayer(ctx context.Context, target meta.Change
131132
return nil
132133
}
133134

135+
// rejectHiddenIntentTarget prevents lifecycle state machines from processing a
136+
// hidden source intent. When an override layer exists, the override is the
137+
// active customer-facing charge: it owns status transitions, realization runs,
138+
// credit corrections, and invoice patches. Subscription-owned base/source
139+
// changes must be applied before state-machine dispatch by service-level
140+
// reconciliation, not interpreted as lifecycle events.
141+
func (s *stateMachine) rejectHiddenIntentTarget(target meta.ChangeTarget) error {
142+
if target == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
143+
return models.NewGenericPreConditionFailedError(
144+
fmt.Errorf("cannot mutate hidden base intent while override intent is active"),
145+
)
146+
}
147+
148+
return nil
149+
}
150+
134151
func (s *stateMachine) IsInsideServicePeriod() bool {
135152
return !clock.Now().Before(s.Charge.Intent.GetEffectiveServicePeriod().From)
136153
}

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
99
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1010
"github.com/openmeterio/openmeter/openmeter/productcatalog"
11+
"github.com/openmeterio/openmeter/pkg/clock"
1112
"github.com/openmeterio/openmeter/pkg/framework/transaction"
1213
"github.com/openmeterio/openmeter/pkg/models"
1314
)
@@ -45,6 +46,26 @@ func (s *service) TriggerPatch(ctx context.Context, chargeID meta.ChargeID, patc
4546
var result meta.TriggerPatchResult[flatfee.Charge]
4647

4748
charge, err := s.withLockedCharge(ctx, chargeID, func(ctx context.Context, charge flatfee.Charge) (*flatfee.Charge, error) {
49+
chargeWithUpdatedBase, err := applyBaseIntentPatchForOverriddenCharge(charge, patch)
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
if chargeWithUpdatedBase != nil {
55+
// Hidden base/source intent changes are subscription reconciliation,
56+
// not customer-facing lifecycle events. Persist the source intent and
57+
// skip the state machine because the active override owns lifecycle
58+
// state and hidden targets are rejected there.
59+
updatedChargeBase, err := s.adapter.UpdateCharge(ctx, chargeWithUpdatedBase.ChargeBase)
60+
if err != nil {
61+
return nil, fmt.Errorf("updating flat fee charge[%s] base intent: %w", chargeWithUpdatedBase.ID, err)
62+
}
63+
64+
chargeWithUpdatedBase.ChargeBase = updatedChargeBase
65+
66+
return chargeWithUpdatedBase, nil
67+
}
68+
4869
stateMachine, err := s.newStateMachine(StateMachineConfig{
4970
Charge: charge,
5071
Adapter: s.adapter,
@@ -75,6 +96,65 @@ func (s *service) TriggerPatch(ctx context.Context, chargeID meta.ChargeID, patc
7596
return result, nil
7697
}
7798

99+
func applyBaseIntentPatchForOverriddenCharge(charge flatfee.Charge, patch meta.Patch) (*flatfee.Charge, error) {
100+
target, err := patch.GetTargetLayer(charge.Intent)
101+
if err != nil {
102+
return nil, fmt.Errorf("getting patch target layer: %w", err)
103+
}
104+
105+
if target != meta.ChangeTargetBase || !charge.Intent.HasOverrideLayer() {
106+
return nil, nil
107+
}
108+
109+
switch patch := patch.(type) {
110+
case meta.PatchDelete:
111+
if err := charge.Intent.Mutate(meta.ChangeTargetBase, func(fields *flatfee.IntentMutableFields) {
112+
deletedAt := clock.Now()
113+
fields.IntentDeletedAt = &deletedAt
114+
}); err != nil {
115+
return nil, fmt.Errorf("mutating base intent for %s patch: %w", patch.Op(), err)
116+
}
117+
118+
return &charge, nil
119+
case meta.PatchShrink:
120+
if err := mutateBaseIntentPeriodForOverriddenCharge(&charge, patch); err != nil {
121+
return nil, err
122+
}
123+
124+
return &charge, nil
125+
case meta.PatchExtend:
126+
if err := mutateBaseIntentPeriodForOverriddenCharge(&charge, patch); err != nil {
127+
return nil, err
128+
}
129+
130+
return &charge, nil
131+
}
132+
133+
return nil, nil
134+
}
135+
136+
func mutateBaseIntentPeriodForOverriddenCharge(charge *flatfee.Charge, patch periodPatch) error {
137+
targetIntent, err := charge.Intent.GetIntentForTarget(meta.ChangeTargetBase)
138+
if err != nil {
139+
return fmt.Errorf("getting base intent: %w", err)
140+
}
141+
142+
if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil {
143+
return fmt.Errorf("validate %s patch: %w", patch.Op(), err)
144+
}
145+
146+
if err := charge.Intent.Mutate(meta.ChangeTargetBase, func(fields *flatfee.IntentMutableFields) {
147+
fields.ServicePeriod.To = patch.GetNewServicePeriodTo()
148+
fields.FullServicePeriod.To = patch.GetNewFullServicePeriodTo()
149+
fields.BillingPeriod.To = patch.GetNewBillingPeriodTo()
150+
fields.InvoiceAt = patch.GetNewInvoiceAt()
151+
}); err != nil {
152+
return fmt.Errorf("mutating base intent for %s patch: %w", patch.Op(), err)
153+
}
154+
155+
return nil
156+
}
157+
78158
func (s *service) newStateMachine(config StateMachineConfig) (StateMachine, error) {
79159
switch config.Charge.Intent.GetSettlementMode() {
80160
case productcatalog.CreditOnlySettlementMode:

openmeter/billing/charges/meta/patch.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type Patch interface {
6767

6868
Op() PatchType
6969
Trigger() stateless.Trigger
70+
GetTargetLayer(LayeredIntentReader) (ChangeTarget, error)
7071
}
7172

7273
type TriggerPatchResult[T any] struct {

0 commit comments

Comments
 (0)