Skip to content

Commit 5b32f03

Browse files
authored
fix: sync deleted subscriptions at least once (#4382)
1 parent 6ce29e7 commit 5b32f03

27 files changed

Lines changed: 787 additions & 346 deletions

File tree

.agents/skills/subscriptionsync/SKILL.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Guidance for working with `openmeter/billing/worker/subscriptionsync/`.
1111

1212
`subscriptionsync` is the bridge between the subscription domain and billing.
1313

14-
- Input: `subscription.SubscriptionView`
14+
- Input: either a subscription ID (`models.NamespacedID`) or an already-expanded `subscription.SubscriptionView`
1515
- Output: reconciled billing state
1616
- invoice lines / split-line groups
1717
- charges
@@ -25,7 +25,8 @@ It does not own subscription editing rules and it does not own billing primitive
2525
openmeter/billing/worker/subscriptionsync/
2626
├── service/ # orchestration entrypoint used by the worker
2727
│ ├── service.go # Service struct, Config, FeatureFlags, constructor
28-
│ ├── sync.go # SynchronizeSubscription + SynchronizeSubscriptionAndInvoiceCustomer
28+
│ ├── sync.go # internal sync orchestration for SyncByID/SyncByView entrypoints
29+
│ ├── ref.go # normalizes subscription ID vs view references
2930
│ ├── reconcile.go # build persisted snapshot + target state + plan
3031
│ ├── handlers.go # event handlers (HandleCancelledEvent, HandleInvoiceCreation)
3132
│ ├── base_test.go # shared SuiteBase + SyncSuiteBase test harness
@@ -54,20 +55,29 @@ openmeter/billing/worker/subscriptionsync/
5455

5556
## Core Flow
5657

57-
`Service.SynchronizeSubscription(...)` in `service/sync.go` is the main entrypoint.
58+
The public sync entrypoints are defined by `subscriptionsync.SyncService`:
59+
60+
- `SyncByID(...)` and `SyncByIDAndInvoiceCustomer(...)` fetch subscription state internally.
61+
- `SyncByView(...)` and `SyncByViewAndInvoiceCustomer(...)` use an already-expanded subscription view.
62+
- The `AndInvoiceCustomer` variants run the regular sync and then invoice pending lines for the subscription customer.
63+
64+
The internal orchestration lives in `service/sync.go` (`synchronizeSubscription`, `synchronizeSubscriptionAndInvoiceCustomer`) and normalizes the public input through `subscriptionReferenceOrView` from `service/ref.go`.
5865

5966
High-level flow:
60-
1. Load persisted sync state and persisted billing artifacts.
61-
2. Build target state from the subscription view.
62-
3. Reconcile target vs persisted.
63-
4. Apply billing patches.
64-
5. Persist sync state.
67+
1. Load the subscription entity, including deleted subscriptions when the ID path is used.
68+
2. Load persisted sync state and persisted billing artifacts.
69+
3. Build target state from the subscription view, or an empty target state when the subscription is deleted.
70+
4. Reconcile target vs persisted.
71+
5. Apply billing patches.
72+
6. Persist sync state.
6573

6674
The important bridge boundaries are:
6775
- `persistedstate`: current billing-side reality relevant to this subscription
6876
- `targetstate`: expected billing-side reality derived from the subscription view
6977
- `reconciler`: diff between the two, expressed as backend-specific patches
7078

79+
Deleted-subscription cleanup uses the ID path. `subscription.Service.GetView` follows the normal non-deleted read path, so code that needs to reconcile deleted subscriptions must use `List(... IncludeDeleted: true)` or the sync service's ID entrypoints rather than calling `GetView` first.
80+
7181
## Persisted State
7282

7383
`service/persistedstate` owns the billing-side read model used by sync.
@@ -100,6 +110,7 @@ Invoice loading notes:
100110
`service/targetstate` converts a subscription view into expected billing/charge items.
101111

102112
Useful points:
113+
- `BuildInput.SubscriptionView` is a pointer. A nil view represents a deleted subscription and builds an empty target state so persisted billing artifacts are removed.
103114
- `StateItem.IsBillable()` is the first gate
104115
- `StateItem.GetServicePeriod()` is the diff-level period source
105116
- `StateItem.GetExpectedLine()` is invoice-specific rendering; keep direct billing assumptions isolated to places that really need invoice lines
@@ -191,6 +202,8 @@ Test suite hierarchy:
191202

192203
Use `setupChargesService(config)` on `SuiteBase` to rebuild the sync service with a charge-capable stack (replaces the default no-charges service).
193204

205+
When a billing sync test needs to exercise deleted-subscription cleanup, prefer the public ID entrypoint (`SyncByID` / `SyncByIDAndInvoiceCustomer`) so the service owns the `IncludeDeleted` lookup. Subscription-domain coverage for deleted scheduled replacements belongs under `openmeter/subscription/service/sync_test.go`; keep billing-package tests focused on billing artifacts.
206+
194207
For charge-backed sync tests:
195208
- prefer `openmeter/billing/charges/testutils.NewMockHandlers()`
196209
- these mocks are intentionally minimal but valid enough for charge creation/advancement
@@ -216,7 +229,7 @@ Pattern for credit-only tests:
216229

217230
Key methods:
218231
- `ListSubscriptions(...)` — pages through active subscriptions with their sync states
219-
- `ReconcileSubscription(...)`fetches subscription view and calls `SynchronizeSubscription`
232+
- `ReconcileSubscription(...)`calls `SyncByID` for the subscription under reconciliation
220233
- `All(...)` — reconciles all eligible subscriptions, skipping those with no billables or whose `NextSyncAfter` is in the future (unless `Force` is set)
221234

222235
## Common Refactor Rules

migrations/atlas.sum

Lines changed: 0 additions & 2 deletions
This file was deleted.

openmeter/billing/validators/customer/customer.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ func (v *Validator) ValidateDeleteCustomer(ctx context.Context, input customer.D
4747

4848
// Let's sync any subscriptions pending for this customer
4949
subs, err := v.subscriptionService.List(ctx, subscription.ListSubscriptionsInput{
50-
Namespaces: []string{input.Namespace},
51-
CustomerID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &input.ID}},
50+
Namespaces: []string{input.Namespace},
51+
CustomerID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &input.ID}},
52+
IncludeDeleted: true,
5253
})
5354
if err != nil {
5455
return err
@@ -57,13 +58,8 @@ func (v *Validator) ValidateDeleteCustomer(ctx context.Context, input customer.D
5758
watermark := time.Now().Add(-24 * time.Hour)
5859

5960
for _, sub := range subs.Items {
60-
if sub.ActiveTo == nil || watermark.Before(*sub.ActiveTo) {
61-
view, err := v.subscriptionService.GetView(ctx, sub.NamespacedID)
62-
if err != nil {
63-
return err
64-
}
65-
66-
if err := v.syncService.SynchronizeSubscription(ctx, view, time.Now()); err != nil {
61+
if sub.ActiveTo == nil || sub.DeletedAt != nil || watermark.Before(*sub.ActiveTo) {
62+
if err := v.syncService.SyncByID(ctx, sub.NamespacedID, time.Now()); err != nil {
6763
return err
6864
}
6965
}

openmeter/billing/worker/subscriptionsync/reconciler/reconciler.go

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -127,67 +127,89 @@ func (r *Reconciler) ListSubscriptions(ctx context.Context, in ReconcilerListSub
127127
break
128128
}
129129

130-
syncStates, err := r.subscriptionSync.GetSyncStates(ctx, lo.Map(subscriptions.Items, func(item subscription.Subscription, _ int) models.NamespacedID {
131-
return models.NamespacedID{
132-
ID: item.ID,
133-
Namespace: item.Namespace,
134-
}
135-
}))
130+
mapped, err := r.mapToSubscriptionWithSyncState(ctx, subscriptions.Items)
136131
if err != nil {
137-
return nil, fmt.Errorf("failed to get sync states: %w", err)
132+
return nil, fmt.Errorf("failed to map subscriptions to subscription with sync state: %w", err)
138133
}
139134

140-
syncStatesBySubscriptionID := lo.SliceToMap(syncStates, func(syncState subscriptionsync.SyncState) (models.NamespacedID, subscriptionsync.SyncState) {
141-
return models.NamespacedID{
142-
ID: syncState.SubscriptionID.ID,
143-
Namespace: syncState.SubscriptionID.Namespace,
144-
}, syncState
135+
out = append(out, mapped...)
136+
137+
pageIndex++
138+
}
139+
140+
pageIndex = 1
141+
142+
for {
143+
subscriptions, err := r.subscriptionService.List(ctx, subscription.ListSubscriptionsInput{
144+
Namespaces: in.Namespaces,
145+
CustomerID: customerID,
146+
// TODO: Later we might want to have a lookback for deleted subscriptions as well, but as of 2026-05-19
147+
// we had a delete bug, so we need to reconcile all deleted subscriptions.
148+
DeletedAt: &filter.FilterTime{
149+
Lte: lo.ToPtr(clock.Now()),
150+
},
151+
IncludeDeleted: true,
152+
Page: pagination.Page{
153+
PageNumber: pageIndex,
154+
PageSize: defaultWindowSize,
155+
},
145156
})
157+
if err != nil {
158+
return nil, fmt.Errorf("failed to list subscriptions: %w", err)
159+
}
146160

147-
out = append(out, lo.Map(subscriptions.Items, func(item subscription.Subscription, _ int) SubscriptionWithSyncState {
148-
existingSyncState, ok := syncStatesBySubscriptionID[item.NamespacedID]
161+
if len(subscriptions.Items) == 0 {
162+
break
163+
}
149164

150-
var syncState *subscriptionsync.SyncState
151-
if ok {
152-
syncState = lo.ToPtr(existingSyncState)
153-
}
165+
mapped, err := r.mapToSubscriptionWithSyncState(ctx, subscriptions.Items)
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to map subscriptions to subscription with sync state: %w", err)
168+
}
154169

155-
return SubscriptionWithSyncState{
156-
Subscription: item,
157-
SyncState: syncState,
158-
}
159-
})...)
170+
out = append(out, mapped...)
160171

161172
pageIndex++
162173
}
163174

164175
return out, nil
165176
}
166177

167-
func (r *Reconciler) ReconcileSubscription(ctx context.Context, subsID models.NamespacedID) error {
168-
subsView, err := r.subscriptionService.GetView(ctx, subsID)
178+
func (r *Reconciler) mapToSubscriptionWithSyncState(ctx context.Context, subs []subscription.Subscription) ([]SubscriptionWithSyncState, error) {
179+
syncStates, err := r.subscriptionSync.GetSyncStates(ctx, lo.Map(subs, func(item subscription.Subscription, _ int) models.NamespacedID {
180+
return models.NamespacedID{
181+
ID: item.ID,
182+
Namespace: item.Namespace,
183+
}
184+
}))
169185
if err != nil {
170-
return fmt.Errorf("failed to get subscription: %w", err)
186+
return nil, fmt.Errorf("failed to get sync states: %w", err)
171187
}
172188

173-
cus, err := r.customerService.GetCustomer(ctx, customer.GetCustomerInput{
174-
CustomerID: &customer.CustomerID{
175-
ID: subsView.Customer.ID,
176-
Namespace: subsID.Namespace,
177-
},
189+
syncStatesBySubscriptionID := lo.SliceToMap(syncStates, func(syncState subscriptionsync.SyncState) (models.NamespacedID, subscriptionsync.SyncState) {
190+
return models.NamespacedID{
191+
ID: syncState.SubscriptionID.ID,
192+
Namespace: syncState.SubscriptionID.Namespace,
193+
}, syncState
178194
})
179-
if err != nil {
180-
return fmt.Errorf("failed to get customer: %w", err)
181-
}
182195

183-
// TODO: remove this check to make sure that deleted customers are fully invoiced
184-
if cus != nil && cus.IsDeleted() {
185-
r.logger.WarnContext(ctx, "customer is deleted, skipping reconciliation", "customer_id", subsView.Customer.ID)
196+
return lo.Map(subs, func(item subscription.Subscription, _ int) SubscriptionWithSyncState {
197+
existingSyncState, ok := syncStatesBySubscriptionID[item.NamespacedID]
186198

187-
return nil
188-
}
199+
var syncState *subscriptionsync.SyncState
200+
if ok {
201+
syncState = lo.ToPtr(existingSyncState)
202+
}
189203

190-
return r.subscriptionSync.SynchronizeSubscription(ctx, subsView, time.Now())
204+
return SubscriptionWithSyncState{
205+
Subscription: item,
206+
SyncState: syncState,
207+
}
208+
}), nil
209+
}
210+
211+
func (r *Reconciler) ReconcileSubscription(ctx context.Context, subsID models.NamespacedID) error {
212+
return r.subscriptionSync.SyncByID(ctx, subsID, time.Now())
191213
}
192214

193215
type ReconcilerAllInput struct {

openmeter/billing/worker/subscriptionsync/service.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/openmeterio/openmeter/openmeter/billing"
88
"github.com/openmeterio/openmeter/openmeter/subscription"
9+
"github.com/openmeterio/openmeter/pkg/models"
910
)
1011

1112
type Service interface {
@@ -15,12 +16,15 @@ type Service interface {
1516
}
1617

1718
type SyncService interface {
18-
SynchronizeSubscriptionAndInvoiceCustomer(ctx context.Context, subs subscription.SubscriptionView, asOf time.Time) error
19-
SynchronizeSubscription(ctx context.Context, subs subscription.SubscriptionView, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
19+
SyncByViewAndInvoiceCustomer(ctx context.Context, view subscription.SubscriptionView, asOf time.Time) error
20+
SyncByIDAndInvoiceCustomer(ctx context.Context, subscriptionID models.NamespacedID, asOf time.Time) error
21+
SyncByView(ctx context.Context, view subscription.SubscriptionView, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
22+
SyncByID(ctx context.Context, subscriptionID models.NamespacedID, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
2023
}
2124

2225
type EventHandler interface {
2326
HandleCancelledEvent(ctx context.Context, event *subscription.CancelledEvent) error
27+
HandleDeletedEvent(ctx context.Context, event *subscription.DeletedEvent) error
2428
HandleSubscriptionSyncEvent(ctx context.Context, event *subscription.SubscriptionSyncEvent) error
2529
HandleInvoiceCreation(ctx context.Context, event *billing.StandardInvoiceCreatedEvent) error
2630
}

0 commit comments

Comments
 (0)