Skip to content

Commit c16cbe1

Browse files
authored
feat: subscription sync patch targeting (#4576)
1 parent 3d4c7f0 commit c16cbe1

26 files changed

Lines changed: 511 additions & 123 deletions

.agents/skills/billing/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ The same registry also owns the single `CreateLineRouter`. Billing's default rou
202202
- `ChangeSourceAPIRequest`: user/API-originated line edits. Billing diffs the original invoice against the edited invoice with `diffMutableInvoiceLines`, applies API edits through line engines with `applyAPIInvoiceLineEdits`, and rebuilds the invoice from unchanged lines plus line-engine outputs.
203203
- `ChangeSourceSystem`: system-originated edits from billing/charges/subscription sync. Billing still computes the line diff, but does not invoke API create/update callbacks. For standard invoices only, deleted lines are dispatched through `OnMutableStandardLinesDeletedBySystem` because charge line updaters currently rely on that cleanup notification. Gathering invoices do not emit system delete callbacks.
204204

205+
Subscription-sync charge patches can update or delete invoice lines as a side effect of reconciling charges. Those invoice updates must use `ChangeSourceSystem`: API edit callbacks are for user-initiated invoice-line edits, while system deletes use `OnMutableStandardLinesDeletedBySystem` so charge line engines can clean up detached line-backed runs.
206+
205207
The API edit path treats the diff as the owner of invoice lines while engines run:
206208

207209
- `applyAPIInvoiceLineEdits` clones the edited invoice and calls `UnsetLines()` before line-engine dispatch so the edited invoice is only the header/context, not a competing source of line truth.

.agents/skills/charges/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ Intent layer access rules:
116116
- Subscription sync and repair code targets subscription-owned source state. It should use `GetBaseIntent()` or base-specific helpers, not effective getters, so user/API overrides do not feed back into the subscription target state.
117117
- Adapter writes should persist the base intent with `GetBaseIntent()` and persist override fields only through the override-specific adapter paths. Do not derive base persistence values from effective intent accessors.
118118

119+
Patch target rules:
120+
121+
- Charge patch payloads (`PatchShrink`, `PatchExtend`, `PatchDelete`) must carry an explicit `meta.ChangeTarget`. Do not rely on a zero/default target.
122+
- Subscription sync emits patches against `meta.ChangeTargetBase` because it reconciles subscription-owned source state, not user/API overrides.
123+
- State machines should mutate the requested 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.
124+
- 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.
125+
- 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.
126+
119127
## Usage-Based Invoice Line Mapping
120128

121129
Usage-based charge realization runs are not billing standard lines. When mapping a run back to `billing.StandardLine` in `openmeter/billing/charges/usagebased/service/linemapper.go`, preserve the billing-facing semantics:

.coderabbit.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ reviews:
3939
4040
Only flag `AdvanceAfter` if the specific code path demonstrably persists an invalid value or violates an explicit flat-fee domain invariant in the current diff.
4141
42+
- path: "openmeter/billing/charges/**/service/*.go"
43+
instructions: |
44+
For charge state-machine handlers in this path, do not claim that an in-memory mutation is not persisted until you trace the full patch execution and persistence flow in the current code.
45+
46+
When a handler is invoked from `service.TriggerPatch`, follow this call chain before raising a persistence finding: `service.TriggerPatch -> stateMachine.FireAndActivate -> chargestatemachine.Machine.FireAndActivate`.
47+
48+
In this flow, mutations made by the handler are persisted after the handler returns via `Persistence.UpdateBase(ctx, m.Charge.GetBase())`.
49+
50+
Before reporting that a mutation is "only in memory" or "not saved", verify whether the handler mutates the base/intent state that `UpdateBase(...)` persists. If that persistence path exists in reachable current code, omit the finding. If you cannot verify a missing persistence path from the diff and reachable current code, omit the finding.
51+
4252
- path: "**/*.tsp"
4353
instructions: |
4454
Review the TypeSpec code for conformity with TypeSpec best practices. When recommending changes also consider the fact that multiple codegeneration toolchains depend on the TypeSpec code, each of which have their idiosyncrasies and bugs.

openmeter/billing/charges/adapter/search.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ func (a *adapter) ListCharges(ctx context.Context, input charges.ListChargesInpu
5353
Where(dbchargessearchv1.Namespace(input.Namespace))
5454

5555
if !input.IncludeDeleted {
56-
query = query.Where(dbchargessearchv1.DeletedAtIsNil())
56+
if input.DeletedAtFilter == charges.ListChargesDeletedAtFilterBaseIntent {
57+
query = query.Where(dbchargessearchv1.BaseIntentDeletedAtIsNil())
58+
} else {
59+
query = query.Where(dbchargessearchv1.DeletedAtIsNil())
60+
}
5761
}
5862

5963
if len(input.CustomerIDs) > 0 {

openmeter/billing/charges/adapter/search_test.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func (s *ListCustomersToAdvanceSuite) createCustomer(namespace string) string {
8585
}
8686

8787
// insertFlatFeeCharge inserts a minimal flat fee charge row for testing the search view.
88-
func (s *ListCustomersToAdvanceSuite) insertFlatFeeCharge(namespace, customerID string, status meta.ChargeStatus, advanceAfter *time.Time) {
88+
func (s *ListCustomersToAdvanceSuite) insertFlatFeeCharge(namespace, customerID string, status meta.ChargeStatus, advanceAfter *time.Time) string {
8989
s.T().Helper()
9090

9191
now := time.Now().UTC().Truncate(time.Microsecond)
@@ -121,8 +121,62 @@ func (s *ListCustomersToAdvanceSuite) insertFlatFeeCharge(namespace, customerID
121121
create = create.SetAdvanceAfter(*advanceAfter)
122122
}
123123

124-
_, err := create.Save(context.Background())
124+
charge, err := create.Save(context.Background())
125125
s.Require().NoError(err)
126+
127+
return charge.ID
128+
}
129+
130+
func (s *ListCustomersToAdvanceSuite) TestListChargesDeletedAtFilter() {
131+
ctx := s.T().Context()
132+
ns := "test-list-charges-deleted-at-filter"
133+
now := time.Now().UTC().Truncate(time.Microsecond)
134+
deletedAt := now.Add(time.Minute)
135+
136+
customerID := s.createCustomer(ns)
137+
138+
liveChargeID := s.insertFlatFeeCharge(ns, customerID, meta.ChargeStatusActive, nil)
139+
overrideDeletedChargeID := s.insertFlatFeeCharge(ns, customerID, meta.ChargeStatusActive, nil)
140+
baseDeletedChargeID := s.insertFlatFeeCharge(ns, customerID, meta.ChargeStatusActive, nil)
141+
142+
_, err := s.dbClient.ChargeFlatFee.UpdateOneID(overrideDeletedChargeID).
143+
SetDeletedAt(deletedAt).
144+
Save(ctx)
145+
s.Require().NoError(err)
146+
147+
_, err = s.dbClient.ChargeFlatFee.UpdateOneID(baseDeletedChargeID).
148+
SetDeletedAt(deletedAt).
149+
SetIntentDeletedAt(deletedAt).
150+
Save(ctx)
151+
s.Require().NoError(err)
152+
153+
listIDs := func(input charges.ListChargesInput) []string {
154+
s.T().Helper()
155+
156+
input.Namespace = ns
157+
input.ChargeTypes = []meta.ChargeType{meta.ChargeTypeFlatFee}
158+
159+
result, err := s.adapter.ListCharges(ctx, input)
160+
s.Require().NoError(err)
161+
162+
out := make([]string, 0, len(result.Items))
163+
for _, item := range result.Items {
164+
out = append(out, item.ID.ID)
165+
}
166+
167+
return out
168+
}
169+
170+
s.ElementsMatch([]string{liveChargeID}, listIDs(charges.ListChargesInput{}))
171+
s.ElementsMatch([]string{liveChargeID}, listIDs(charges.ListChargesInput{
172+
DeletedAtFilter: charges.ListChargesDeletedAtFilterEffective,
173+
}))
174+
s.ElementsMatch([]string{liveChargeID, overrideDeletedChargeID}, listIDs(charges.ListChargesInput{
175+
DeletedAtFilter: charges.ListChargesDeletedAtFilterBaseIntent,
176+
}))
177+
s.ElementsMatch([]string{liveChargeID, overrideDeletedChargeID, baseDeletedChargeID}, listIDs(charges.ListChargesInput{
178+
IncludeDeleted: true,
179+
}))
126180
}
127181

128182
func (s *ListCustomersToAdvanceSuite) TestReturnsOnlyEligibleCustomers() {

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

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1616
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
1717
"github.com/openmeterio/openmeter/openmeter/productcatalog"
18+
"github.com/openmeterio/openmeter/pkg/clock"
1819
"github.com/openmeterio/openmeter/pkg/models"
1920
"github.com/openmeterio/openmeter/pkg/statelessx"
2021
"github.com/openmeterio/openmeter/pkg/timeutil"
@@ -26,10 +27,12 @@ type CreditThenInvoiceStateMachine struct {
2627

2728
type periodPatch interface {
2829
Op() meta.PatchType
30+
GetTarget() meta.ChangeTarget
2931
GetNewServicePeriodTo() time.Time
3032
GetNewFullServicePeriodTo() time.Time
3133
GetNewBillingPeriodTo() time.Time
3234
GetNewInvoiceAt() time.Time
35+
ValidateWith(meta.IntentMutableFields) error
3336
}
3437

3538
var (
@@ -77,7 +80,7 @@ func (s *CreditThenInvoiceStateMachine) configureStates() {
7780
flatfee.StatusActive,
7881
statelessx.BoolFn(s.IsInsideServicePeriodAndNonZeroAmount),
7982
).
80-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
83+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
8184
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
8285
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
8386
OnActive(s.AdvanceAfterServicePeriodFrom)
@@ -88,63 +91,77 @@ func (s *CreditThenInvoiceStateMachine) configureStates() {
8891
// operational state.
8992
Permit(meta.TriggerNext, flatfee.StatusFinal, statelessx.BoolFn(s.IsZeroAmount)).
9093
Permit(meta.TriggerFinalInvoiceCreated, flatfee.StatusActiveRealizationStarted).
91-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
94+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
9295
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
9396
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
9497
OnActive(s.AdvanceAfterServicePeriodTo)
9598

9699
s.Configure(flatfee.StatusActiveRealizationStarted).
97100
Permit(meta.TriggerNext, flatfee.StatusActiveRealizationWaitingForCollection).
98-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
101+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
99102
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
100103
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
101104
OnEntryFrom(meta.TriggerFinalInvoiceCreated, statelessx.WithParameters(s.StartRealization))
102105

103106
s.Configure(flatfee.StatusActiveRealizationWaitingForCollection).
104107
Permit(meta.TriggerCollectionCompleted, flatfee.StatusActiveRealizationProcessing).
105-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
108+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
106109
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
107110
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge))
108111

109112
s.Configure(flatfee.StatusActiveRealizationProcessing).
110113
Permit(meta.TriggerInvoiceIssued, flatfee.StatusActiveRealizationIssuing).
111-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
114+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
112115
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
113116
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge))
114117

115118
s.Configure(flatfee.StatusActiveRealizationIssuing).
116119
Permit(meta.TriggerNext, flatfee.StatusActiveRealizationCompleted).
117-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
120+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
118121
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.UnsupportedExtendOperation)).
119122
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation)).
120123
OnEntryFrom(meta.TriggerInvoiceIssued, statelessx.WithParameters(s.AccrueInvoiceUsage))
121124

122125
s.Configure(flatfee.StatusActiveRealizationCompleted).
123126
Permit(meta.TriggerNext, flatfee.StatusActiveAwaitingPaymentSettlement).
124-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
127+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
125128
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.UnsupportedExtendOperation)).
126129
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation))
127130

128131
s.Configure(flatfee.StatusActiveAwaitingPaymentSettlement).
129132
Permit(meta.TriggerNext, flatfee.StatusFinal, statelessx.BoolFn(s.AreAllPaymentsSettled)).
130133
Permit(meta.TriggerAllPaymentsSettled, flatfee.StatusFinal, statelessx.BoolFn(s.AreAllPaymentsSettled)).
131-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
134+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
132135
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
133136
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge))
134137

135138
s.Configure(flatfee.StatusFinal).
136-
Permit(meta.TriggerDelete, flatfee.StatusDeleted).
139+
InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)).
137140
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)).
138141
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)).
139142
OnActive(s.ClearAdvanceAfter)
140143

141144
s.Configure(flatfee.StatusDeleted).
142145
InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.UnsupportedExtendOperation)).
143-
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation)).
144-
OnEntry(statelessx.WithParameters(s.DeleteCharge))
146+
InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation))
145147
}
146148

147-
func (s *CreditThenInvoiceStateMachine) DeleteCharge(ctx context.Context, _ meta.PatchDeletePolicy) error {
149+
func (s *CreditThenInvoiceStateMachine) DeleteCharge(ctx context.Context, patch meta.PatchDelete) error {
150+
if err := s.Charge.Intent.Mutate(patch.GetTarget(), func(fields flatfee.IntentMutableFields) (flatfee.IntentMutableFields, error) {
151+
fields.IntentDeletedAt = lo.ToPtr(clock.Now())
152+
return fields, nil
153+
}); err != nil {
154+
return fmt.Errorf("mutating %s intent deleted at: %w", patch.GetTarget(), err)
155+
}
156+
157+
if patch.GetTarget() == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
158+
// Subscription sync targets the base intent. When an override is active,
159+
// the customer-facing charge and invoice history remain owned by the override.
160+
return nil
161+
}
162+
163+
s.Charge.Status = flatfee.StatusDeleted
164+
148165
patches := []invoiceupdater.Patch{
149166
invoiceupdater.NewDeleteGatheringLineByChargeIDPatch(s.Charge.ID),
150167
}
@@ -180,43 +197,57 @@ func (s *CreditThenInvoiceStateMachine) DeleteCharge(ctx context.Context, _ meta
180197
}
181198

182199
func (s *CreditThenInvoiceStateMachine) ExtendCharge(ctx context.Context, patch meta.PatchExtend) error {
183-
if err := patch.ValidateWith(s.Charge.Intent.GetEffectiveMetaIntentMutableFields()); err != nil {
184-
return fmt.Errorf("validate extend patch: %w", err)
185-
}
186-
187200
invoicingStateInput, err := s.applyPeriodPatch(patch)
188201
if err != nil {
189202
return err
190203
}
191204

205+
if !invoicingStateInput.ShouldReconcile {
206+
return nil
207+
}
208+
192209
return s.reconcileInvoicingState(ctx, invoicingStateInput)
193210
}
194211

195212
func (s *CreditThenInvoiceStateMachine) ShrinkCharge(ctx context.Context, patch meta.PatchShrink) error {
196-
if err := patch.ValidateWith(s.Charge.Intent.GetEffectiveMetaIntentMutableFields()); err != nil {
197-
return fmt.Errorf("validate shrink patch: %w", err)
198-
}
199-
200213
invoicingStateInput, err := s.applyPeriodPatch(patch)
201214
if err != nil {
202215
return err
203216
}
204217

218+
if !invoicingStateInput.ShouldReconcile {
219+
return nil
220+
}
221+
205222
return s.reconcileInvoicingState(ctx, invoicingStateInput)
206223
}
207224

208225
func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (reconcileInvoicingStateInput, error) {
209-
oldAmountAfterProration := s.Charge.State.AmountAfterProration
226+
targetIntent, err := s.Charge.Intent.GetIntentForTarget(patch.GetTarget())
227+
if err != nil {
228+
return reconcileInvoicingStateInput{}, fmt.Errorf("getting %s intent: %w", patch.GetTarget(), err)
229+
}
210230

231+
if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil {
232+
return reconcileInvoicingStateInput{}, fmt.Errorf("validate %s patch: %w", patch.Op(), err)
233+
}
211234
intent := s.Charge.Intent
212-
if err := intent.MutateEffective(func(fields flatfee.IntentMutableFields) (flatfee.IntentMutableFields, error) {
235+
if err := intent.Mutate(patch.GetTarget(), func(fields flatfee.IntentMutableFields) (flatfee.IntentMutableFields, error) {
213236
fields.ServicePeriod.To = patch.GetNewServicePeriodTo()
214237
fields.FullServicePeriod.To = patch.GetNewFullServicePeriodTo()
215238
fields.BillingPeriod.To = patch.GetNewBillingPeriodTo()
216239
fields.InvoiceAt = patch.GetNewInvoiceAt()
217240
return fields, nil
218241
}); err != nil {
219-
return reconcileInvoicingStateInput{}, fmt.Errorf("mutating effective intent: %w", err)
242+
return reconcileInvoicingStateInput{}, fmt.Errorf("mutating %s intent: %w", patch.GetTarget(), err)
243+
}
244+
245+
s.Charge.Intent = intent
246+
247+
if patch.GetTarget() == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() {
248+
// Subscription sync targets the base intent. When an override is active,
249+
// the customer-facing invoice remains owned by the override layer.
250+
return reconcileInvoicingStateInput{}, nil
220251
}
221252

222253
amountAfterProration, err := intent.CalculateAmountAfterProration()
@@ -225,10 +256,11 @@ func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (rec
225256
}
226257

227258
return reconcileInvoicingStateInput{
259+
ShouldReconcile: true,
228260
Op: patch.Op(),
229261
Period: intent.GetEffectiveServicePeriod(),
230262
Intent: intent,
231-
OldAmountAfterProration: oldAmountAfterProration,
263+
OldAmountAfterProration: s.Charge.State.AmountAfterProration,
232264
NewAmountAfterProration: amountAfterProration,
233265
}, nil
234266
}
@@ -304,6 +336,7 @@ func (s *CreditThenInvoiceStateMachine) AreAllPaymentsSettled() bool {
304336
}
305337

306338
type reconcileInvoicingStateInput struct {
339+
ShouldReconcile bool
307340
Op meta.PatchType
308341
Period timeutil.ClosedPeriod
309342
Intent flatfee.OverridableIntent

0 commit comments

Comments
 (0)