Skip to content

Commit 45e1532

Browse files
authored
feat: add extend support for usage-based charges [credit_then_invoice settlement mode] (#4333)
1 parent b2e7c9d commit 45e1532

41 files changed

Lines changed: 3144 additions & 1896 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/charges/SKILL.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ Do not emulate every billing rating mutator in the line mapper. Usage discounts
113113
Detailed-line expansion rules:
114114

115115
- `meta.ExpandDetailedLines` is not standalone for charge reads; it requires `meta.ExpandRealizations`
116+
- usage-based deleted realization runs are hidden from `meta.ExpandRealizations` by default because their invoice/ledger effect has been cleaned up; request `meta.ExpandDeletedRealizations` together with `meta.ExpandRealizations` only when a caller must inspect cleanup state, such as frontend audit views or deletion tests
117+
- production code that sums or interprets realization history must explicitly skip runs with `DeletedAt != nil`, documenting the business reason at the guard, because deleted realizations are no longer effective billable history
116118
- usage-based detailed lines live under `RealizationRun.DetailedLines`
117119
- flat-fee detailed lines also live under `Charge.Realizations.DetailedLines`, not on the root charge
118120
- `mo.None()` means detailed lines were not expanded; present options mean expanded data, even when the underlying slice is nil/empty
@@ -149,6 +151,18 @@ Important rules:
149151
- usage-based invoice branches should read in this order: `started -> waiting_for_collection -> processing -> issuing -> completed`, then auto-advance out of the branch. Keep `invoice_issued` as the boundary between `processing` and `issuing`, run `FinalizeInvoiceRun(...)` from the `issuing` state, and let `completed` be the last branch-local status before `next` returns a partial invoice to `active` or moves a final invoice to `active.awaiting_payment_settlement`
150152
- when adding or renaming usage-based detailed statuses, remember that `status_detailed` is an Ent enum for `ChargeUsageBased`; run `make generate` so the generated enum validators and migrate schema include the new values before trusting state-machine changes
151153

154+
Usage-based credit-then-invoice extension rules:
155+
156+
- `PatchExtend` must represent a real extension: the new service period end must be after the persisted intent end; full service period and billing period ends may stay unchanged but must not move backwards.
157+
- Do not stretch an in-progress final realization run to cover the extension. There can only be one active run, and stretching the old final run would keep the run open across the extension and delay the standard invoice lifecycle.
158+
- If the current final realization run is still backed by a mutable invoice line, the extend flow should update the charge intent, emit an invoice-line delete patch for that mutable standard line, and let the billing invoice updater/line engine delete the invoice line and mark the run deleted. The charge should move back to `active` with `AdvanceAfter` set to the new service-period end so the extended tail can realize later.
159+
- While a mutable final invoice run is before invoice issuing (`active.final_realization.started`, `active.final_realization.waiting_for_collection`, or `active.final_realization.processing`), billing remains the owner of the ongoing invoice lifecycle. Extension may delete the mutable line, mark the current run deleted, and recreate a gathering line; a direct `AdvanceCharges(...)` before the new service-period end must be a no-op, and the replacement final run should be created by billing when the replacement gathering line is invoiced at the extended end.
160+
- Once a final invoice run reaches `active.final_realization.issuing` or `active.final_realization.completed`, extend is explicitly rejected by `UnsupportedExtendOperation` because invoice lifecycle callbacks or state-machine advancement still own those states. Subscription sync is expected to retry instead of moving the charge out of those states manually.
161+
- Extending from `active.awaiting_payment_settlement` is allowed: preserve the invoice line and ledger bookings, reclassify the old final run as partial, move the charge back to `active`, and create only a tail gathering line.
162+
- If the old final realization has already passed into immutable invoice territory, keep the invoice and ledger untouched. Reclassify the old final run as a partial invoice run and move the charge back to `active`; the extended tail will produce a new final run later. Immutable invoice cleanup should be surfaced as validation warnings by billing rather than reversing ledger bookings.
163+
- Pending gathering lines for the charge should be extended in place by charge ID. Existing standard invoice lines should only be deleted in the mutable-current-final case above.
164+
- These rules are intentionally about not blocking the invoice train: extension should not hold a standard invoice lifecycle open while waiting for newly extended usage windows to finish.
165+
152166
Operational consequence:
153167

154168
- adding a new charge engine enum without a registered implementation causes invoice collection to fail when billing resolves the line engine

.agents/skills/subscriptionsync/SKILL.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,11 @@ Important routing rules:
142142

143143
The `filterInScopeLines` function gates which target items enter reconciliation. It filters out non-billable items for every backend, and only invoicing-backed targets are additionally gated on `GetExpectedLine()`. This runs before any diffing so absent targets naturally produce delete/no-op outcomes.
144144

145-
Current charge limitations:
146-
- charge collections only support create
147-
- delete/shrink/extend/prorate return unsupported errors
148-
149-
This is intentional. If a test expects credit-only cancellation to fail on delete, that is current behavior, not a bug in the test.
145+
Charge-backed mutation notes:
146+
- charge collections support create/delete and period-shape changes
147+
- shrink and most extend operations are emitted as an emulated replacement: delete the existing charge and create a replacement charge from the target state
148+
- usage-based `credit_then_invoice` extend is emitted as a native charge patch so the usage-based state machine can preserve existing immutable invoice/ledger state and keep the invoice train unblocked
149+
- charge-backed explicit prorate still returns unsupported; charge domains own their own proration/materialization behavior
150150

151151
## Invoice vs Charge Semantics
152152

@@ -160,8 +160,10 @@ Keep these separate:
160160
- Charge sync:
161161
- provisions charge intents directly
162162
- does not go through invoice-line rendering
163-
- currently only create is supported
164-
- invoice-style semantic proration is skipped in `diffItem(...)`; charges own their own proration logic
163+
- supports create/delete and period-shape changes
164+
- emits shrink and most extend operations as delete+create replacements
165+
- emits usage-based `credit_then_invoice` extend as a native charge patch
166+
- keeps explicit prorate unsupported because charges own their own proration logic
165167

166168
Do not force charge behavior through invoice abstractions.
167169

openmeter/billing/charges/flatfee/adapter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,5 +150,9 @@ func validateExpands(expands meta.Expands) error {
150150
return fmt.Errorf("%q requires %q", meta.ExpandDetailedLines, meta.ExpandRealizations)
151151
}
152152

153+
if expands.Has(meta.ExpandDeletedRealizations) && !expands.Has(meta.ExpandRealizations) {
154+
return fmt.Errorf("%q requires %q", meta.ExpandDeletedRealizations, meta.ExpandRealizations)
155+
}
156+
153157
return nil
154158
}

openmeter/billing/charges/invoiceupdater/invoiceupdate.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ func (u *Updater) ApplyPatches(ctx context.Context, customerID customer.Customer
4444
return fmt.Errorf("resolving gathering line deletes by charge ID: %w", err)
4545
}
4646

47+
if err := u.resolveGatheringLineUpdatesByChargeID(ctx, customerID, &patchesParsed); err != nil {
48+
return fmt.Errorf("resolving gathering line updates by charge ID: %w", err)
49+
}
50+
4751
invoicesByID, err := u.listInvoicesByID(ctx, customerID.Namespace, lo.Keys(patchesParsed.updatedLinesByInvoiceID))
4852
if err != nil {
4953
return fmt.Errorf("listing invoices: %w", err)
@@ -203,6 +207,7 @@ type patchesParsed struct {
203207
updatedLinesByInvoiceID map[string]invoicePatches
204208

205209
gatheringLineDeletesByChargeID []string
210+
gatheringLineUpdatesByChargeID map[string]PatchUpdateGatheringLineByChargeID
206211
}
207212

208213
type invoicePatches struct {
@@ -212,7 +217,8 @@ type invoicePatches struct {
212217

213218
func (u *Updater) parsePatches(patches []Patch) (patchesParsed, error) {
214219
parsed := patchesParsed{
215-
updatedLinesByInvoiceID: make(map[string]invoicePatches),
220+
updatedLinesByInvoiceID: make(map[string]invoicePatches),
221+
gatheringLineUpdatesByChargeID: make(map[string]PatchUpdateGatheringLineByChargeID),
216222
}
217223

218224
for _, patch := range patches {
@@ -249,6 +255,13 @@ func (u *Updater) parsePatches(patches []Patch) (patchesParsed, error) {
249255
}
250256

251257
parsed.gatheringLineDeletesByChargeID = append(parsed.gatheringLineDeletesByChargeID, deletePatch.ChargeID)
258+
case PatchOpUpdateGatheringLineByChargeID:
259+
updatePatch, err := patch.AsUpdateGatheringLineByChargeIDPatch()
260+
if err != nil {
261+
return patchesParsed{}, fmt.Errorf("getting gathering line update: %w", err)
262+
}
263+
264+
parsed.gatheringLineUpdatesByChargeID[updatePatch.ChargeID] = updatePatch
252265
default:
253266
return patchesParsed{}, fmt.Errorf("unexpected patch operation: %s", patch.Op())
254267
}
@@ -320,6 +333,53 @@ func (u *Updater) resolveGatheringLineDeletesByChargeID(ctx context.Context, cus
320333
return nil
321334
}
322335

336+
func (u *Updater) resolveGatheringLineUpdatesByChargeID(ctx context.Context, customerID customer.CustomerID, parsed *patchesParsed) error {
337+
if len(parsed.gatheringLineUpdatesByChargeID) == 0 {
338+
return nil
339+
}
340+
341+
invoices, err := u.billingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{
342+
Namespaces: []string{customerID.Namespace},
343+
Customers: []string{customerID.ID},
344+
Expand: billing.GatheringInvoiceExpands{
345+
billing.GatheringInvoiceExpandLines,
346+
},
347+
})
348+
if err != nil {
349+
return fmt.Errorf("listing gathering invoices: %w", err)
350+
}
351+
352+
for _, invoice := range invoices.Items {
353+
for _, line := range invoice.Lines.OrEmpty() {
354+
if line.DeletedAt != nil || line.ChargeID == nil {
355+
continue
356+
}
357+
358+
updatePatch, ok := parsed.gatheringLineUpdatesByChargeID[*line.ChargeID]
359+
if !ok {
360+
continue
361+
}
362+
363+
line.ServicePeriod.To = updatePatch.ServicePeriodTo
364+
line.InvoiceAt = updatePatch.ServicePeriodTo
365+
if line.Subscription != nil {
366+
line.Subscription.BillingPeriod.To = updatePatch.ServicePeriodTo
367+
}
368+
369+
genericLine, err := line.AsInvoiceLine().AsGenericLine()
370+
if err != nil {
371+
return fmt.Errorf("converting gathering line[%s] to generic line: %w", line.ID, err)
372+
}
373+
374+
lineUpdates := parsed.updatedLinesByInvoiceID[invoice.ID]
375+
lineUpdates.updatedLines = append(lineUpdates.updatedLines, genericLine)
376+
parsed.updatedLinesByInvoiceID[invoice.ID] = lineUpdates
377+
}
378+
}
379+
380+
return nil
381+
}
382+
323383
func (u *Updater) updateMutableStandardInvoice(ctx context.Context, invoice billing.StandardInvoice, linePatches invoicePatches) error {
324384
updatedInvoice, err := u.billingService.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
325385
Invoice: invoice.GetInvoiceID(),

openmeter/billing/charges/invoiceupdater/patch.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package invoiceupdater
33
import (
44
"fmt"
55
"log/slog"
6+
"time"
67

78
"github.com/openmeterio/openmeter/openmeter/billing"
89
)
@@ -14,6 +15,7 @@ const (
1415
PatchOpLineDelete PatchOperation = "line_delete"
1516
PatchOpLineUpdate PatchOperation = "line_update"
1617
PatchOpDeleteGatheringLineByChargeID PatchOperation = "delete_gathering_line_by_charge_id"
18+
PatchOpUpdateGatheringLineByChargeID PatchOperation = "update_gathering_line_by_charge_id"
1719
)
1820

1921
type PatchLineCreate struct {
@@ -33,13 +35,19 @@ type PatchDeleteGatheringLineByChargeID struct {
3335
ChargeID string
3436
}
3537

38+
type PatchUpdateGatheringLineByChargeID struct {
39+
ChargeID string
40+
ServicePeriodTo time.Time
41+
}
42+
3643
type Patch struct {
3744
op PatchOperation
3845

3946
createLinePatch PatchLineCreate
4047
deleteLinePatch PatchLineDelete
4148
updateLinePatch PatchLineUpdate
4249
deleteGatheringLineByChargeIDPatch PatchDeleteGatheringLineByChargeID
50+
updateGatheringLineByChargeIDPatch PatchUpdateGatheringLineByChargeID
4351
}
4452

4553
func (p Patch) Op() PatchOperation {
@@ -78,6 +86,14 @@ func (p Patch) AsDeleteGatheringLineByChargeIDPatch() (PatchDeleteGatheringLineB
7886
return p.deleteGatheringLineByChargeIDPatch, nil
7987
}
8088

89+
func (p Patch) AsUpdateGatheringLineByChargeIDPatch() (PatchUpdateGatheringLineByChargeID, error) {
90+
if p.op != PatchOpUpdateGatheringLineByChargeID {
91+
return PatchUpdateGatheringLineByChargeID{}, fmt.Errorf("expected update gathering line by charge ID patch, got %s", p.op)
92+
}
93+
94+
return p.updateGatheringLineByChargeIDPatch, nil
95+
}
96+
8197
func NewDeleteLinePatch(lineID billing.LineID, invoiceID string) Patch {
8298
return Patch{
8399
op: PatchOpLineDelete,
@@ -97,6 +113,16 @@ func NewDeleteGatheringLineByChargeIDPatch(chargeID string) Patch {
97113
}
98114
}
99115

116+
func NewUpdateGatheringLineByChargeIDPatch(chargeID string, servicePeriodTo time.Time) Patch {
117+
return Patch{
118+
op: PatchOpUpdateGatheringLineByChargeID,
119+
updateGatheringLineByChargeIDPatch: PatchUpdateGatheringLineByChargeID{
120+
ChargeID: chargeID,
121+
ServicePeriodTo: servicePeriodTo,
122+
},
123+
}
124+
}
125+
100126
func NewUpdateLinePatch(line billing.GenericInvoiceLine) Patch {
101127
return Patch{
102128
op: PatchOpLineUpdate,
@@ -125,6 +151,8 @@ func (p Patch) Log(logger *slog.Logger) {
125151
logger.Info("update line patch", "line_id", p.updateLinePatch.TargetState.GetLineID().ID, "invoice_id", p.updateLinePatch.TargetState.GetInvoiceID(), "new_service_period_from", p.updateLinePatch.TargetState.GetServicePeriod().From, "new_service_period_to", p.updateLinePatch.TargetState.GetServicePeriod().To, "unique_reference_id", p.updateLinePatch.TargetState.GetChildUniqueReferenceID())
126152
case PatchOpDeleteGatheringLineByChargeID:
127153
logger.Info("delete gathering line by charge id patch", "charge_id", p.deleteGatheringLineByChargeIDPatch.ChargeID)
154+
case PatchOpUpdateGatheringLineByChargeID:
155+
logger.Info("update gathering line by charge id patch", "charge_id", p.updateGatheringLineByChargeIDPatch.ChargeID, "new_service_period_to", p.updateGatheringLineByChargeIDPatch.ServicePeriodTo)
128156
default:
129157
logger.Info("unknown patch operation", "operation", p.op)
130158
}

openmeter/billing/charges/meta/charge.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,18 @@ func (t ChargeType) Validate() error {
6969
type Expand string
7070

7171
const (
72-
ExpandRealizations Expand = "realizations"
73-
ExpandRealtimeUsage Expand = "realtime_usage"
74-
ExpandDetailedLines Expand = "detailed_lines"
72+
ExpandRealizations Expand = "realizations"
73+
ExpandRealtimeUsage Expand = "realtime_usage"
74+
ExpandDetailedLines Expand = "detailed_lines"
75+
ExpandDeletedRealizations Expand = "deleted_realizations"
7576
)
7677

7778
func (e Expand) Values() []Expand {
7879
return []Expand{
7980
ExpandRealizations,
8081
ExpandRealtimeUsage,
8182
ExpandDetailedLines,
83+
ExpandDeletedRealizations,
8284
}
8385
}
8486

openmeter/billing/charges/meta/patchextend.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ func (p PatchExtend) ValidateWith(intent Intent) error {
110110
errs = append(errs, err)
111111
}
112112

113-
if p.GetNewServicePeriodTo().Before(intent.ServicePeriod.To) {
114-
errs = append(errs, fmt.Errorf("new service period to must be greater than or equal to existing service period to"))
113+
if !p.GetNewServicePeriodTo().After(intent.ServicePeriod.To) {
114+
errs = append(errs, fmt.Errorf("new service period to must be greater than existing service period to"))
115115
}
116116

117117
if p.GetNewFullServicePeriodTo().Before(intent.FullServicePeriod.To) {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package meta
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/openmeterio/openmeter/pkg/timeutil"
10+
)
11+
12+
func TestPatchExtendValidateWith(t *testing.T) {
13+
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14+
15+
intent := Intent{
16+
ServicePeriod: timeutil.ClosedPeriod{
17+
From: base,
18+
To: base.AddDate(0, 1, 0),
19+
},
20+
FullServicePeriod: timeutil.ClosedPeriod{
21+
From: base,
22+
To: base.AddDate(0, 2, 0),
23+
},
24+
BillingPeriod: timeutil.ClosedPeriod{
25+
From: base,
26+
To: base.AddDate(0, 3, 0),
27+
},
28+
}
29+
30+
tests := []struct {
31+
name string
32+
patch PatchExtend
33+
wantErr bool
34+
}{
35+
{
36+
name: "allows service period extension with unchanged full service and billing periods",
37+
patch: mustNewPatchExtend(t, NewPatchExtendInput{
38+
NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour),
39+
NewFullServicePeriodTo: intent.FullServicePeriod.To,
40+
NewBillingPeriodTo: intent.BillingPeriod.To,
41+
}),
42+
},
43+
{
44+
name: "rejects unchanged service period end",
45+
patch: mustNewPatchExtend(t, NewPatchExtendInput{
46+
NewServicePeriodTo: intent.ServicePeriod.To,
47+
NewFullServicePeriodTo: intent.FullServicePeriod.To,
48+
NewBillingPeriodTo: intent.BillingPeriod.To,
49+
}),
50+
wantErr: true,
51+
},
52+
{
53+
name: "rejects earlier service period end",
54+
patch: mustNewPatchExtend(t, NewPatchExtendInput{
55+
NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour),
56+
NewFullServicePeriodTo: intent.FullServicePeriod.To,
57+
NewBillingPeriodTo: intent.BillingPeriod.To,
58+
}),
59+
wantErr: true,
60+
},
61+
{
62+
name: "rejects earlier full service period end",
63+
patch: mustNewPatchExtend(t, NewPatchExtendInput{
64+
NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour),
65+
NewFullServicePeriodTo: intent.FullServicePeriod.To.Add(-time.Hour),
66+
NewBillingPeriodTo: intent.BillingPeriod.To,
67+
}),
68+
wantErr: true,
69+
},
70+
{
71+
name: "rejects earlier billing period end",
72+
patch: mustNewPatchExtend(t, NewPatchExtendInput{
73+
NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour),
74+
NewFullServicePeriodTo: intent.FullServicePeriod.To,
75+
NewBillingPeriodTo: intent.BillingPeriod.To.Add(-time.Hour),
76+
}),
77+
wantErr: true,
78+
},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
err := tt.patch.ValidateWith(intent)
84+
if tt.wantErr {
85+
require.Error(t, err)
86+
return
87+
}
88+
89+
require.NoError(t, err)
90+
})
91+
}
92+
}
93+
94+
func mustNewPatchExtend(t *testing.T, input NewPatchExtendInput) PatchExtend {
95+
t.Helper()
96+
97+
patch, err := NewPatchExtend(input)
98+
require.NoError(t, err)
99+
return patch
100+
}

openmeter/billing/charges/service/patch.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,6 @@ func (s *service) ApplyPatches(ctx context.Context, input charges.ApplyPatchesIn
1818
}
1919

2020
return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error {
21-
// TODO[later]: Remove this once we have proper shrink/extend patching in place.
22-
input, err := s.tmpMapShrinkExtendToCreateDelete(ctx, input)
23-
if err != nil {
24-
return err
25-
}
26-
2721
if err := s.applyPatches(ctx, input.CustomerID, input.PatchesByChargeID); err != nil {
2822
return err
2923
}

0 commit comments

Comments
 (0)