| name | subscription |
| description | Work with the OpenMeter subscription package. Use when modifying subscription creation, editing, cancellation, plan changes, addons, the sync algorithm, patch system, spec model, workflow layer, or subscription-related tests. Trigger this skill whenever the task touches `openmeter/subscription/...`, subscription views, billing cadences, or the relationship between subscriptions, plans, entitlements, and addons. |
| user-invocable | true |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob, Agent |
Subscription
Guidance for working with the OpenMeter subscription package (openmeter/subscription/).
Package Layout
openmeter/subscription/
├── (root) — Domain types, interfaces, patch system, spec model
├── service/ — Service implementation + sync algorithm
├── repo/ — Ent ORM-backed repositories
├── entitlement/ — EntitlementAdapter impl (bridges to entitlement service)
├── patch/ — Concrete Patch implementations (add/remove item/phase, stretch, unschedule)
├── workflow/ — High-level orchestration (CreateFromPlan, EditRunning, ChangeToPlan, Restore)
├── workflow/service/ — WorkflowService impl split into subscription.go + addon.go
├── addon/ — Subscription addon sub-system
├── addon/diff/ — Addon apply/restore to spec
├── addon/repo/ — Ent-backed addon repos
├── addon/service/ — Addon service impl
├── validators/ — Customer and subscription uniqueness validators (registered as Hooks)
├── hooks/annotations/ — AnnotationCleanupHook (maintains previous/superseding chain on delete)
└── testutils/ — Full integration test wiring, builders, mocks, comparison helpers
The public HTTP entry points live in openmeter/productcatalog/subscription/http/. The PlanSubscriptionService in openmeter/productcatalog/subscription/service/ wraps the workflow service to add plan resolution, StartingPhase, Migrate, and Change.
DI wiring: app/common/subscription.go → NewSubscriptionServices assembles all repos, adapters, services, hooks, and returns SubscriptionServiceWithWorkflow.
Core Domain Model
Entity hierarchy:
Subscription
└── SubscriptionPhase (ordered by ActiveFrom; ActiveTo = next phase's ActiveFrom)
└── SubscriptionItem[] (grouped by Key; each key holds a version history as a slice)
└── Entitlement (optional, 1:1, when item has entitlement-bearing RateCard)
SubscriptionSpec is the "desired state" object. It is the primary thing patches and the sync engine operate on. Key fields:
Phases map[string]*SubscriptionPhaseSpec — pointer values so patches mutate in-place
CreateSubscriptionPlanInput — plan ref, billing cadence, pro-rating config
CreateSubscriptionCustomerInput — customer, currency, timing, billing anchor
SubscriptionPhaseSpec — contains StartAfter (ISO duration from subscription start), and ItemsByKey map[string][]*SubscriptionItemSpec. Each key maps to an ordered slice of item versions.
SubscriptionItemSpec — contains RateCard and optional relative timing overrides (ActiveFromOverrideRelativeToPhaseStart, ActiveToOverrideRelativeToPhaseStart — stored as ISO durations, not timestamps).
SubscriptionView — the fully-hydrated read model. Built by NewSubscriptionView(). Contains Subscription, Customer, Spec, and []SubscriptionPhaseView (each with items, entitlements, and feature data loaded in).
Phase timing is implicit: SubscriptionPhase stores only ActiveFrom. ActiveTo is derived as the next phase's ActiveFrom (or the subscription's ActiveTo). Calling GetPhaseCadence() must iterate all sorted phases to find the successor.
Item versioning within a phase: For a given ItemKey, SubscriptionPhaseSpec.ItemsByKey[key] is a slice. Index 0 is the first version, index 1 is a replacement, etc. The sync algorithm matches items by slice index. When PatchAddItem targets the current phase and a matching key already exists, it automatically sets the existing item's ActiveTo to the new item's ActiveFrom, creating a version history.
Service Interface
type Service interface {
QueryService
CommandService
}
Every mutating operation:
- Acquires a per-customer distributed lock:
customer/{id}/subscription via lockr.Locker.
- Runs inside a transaction via
transaction.Run().
- Invokes Before*/After* hooks.
- Publishes a domain event via
eventbus.Publisher.
ExpandViews bulk-loads phases, items, entitlements, and features for multiple subscriptions — but only supports a single customer ID per call.
Sync Algorithm (Core Engine)
sync() in service/sync.go reconciles the current SubscriptionView against a new SubscriptionSpec.
Three passes:
-
Mark dirty — for each existing phase/item, look up the corresponding entry in the new spec by key (and index for items). If missing or entity inputs differ → mark as dirty (delete it). Dirtiness propagates downward via touched map[SpecPath]bool with parent-path inheritance: if /phases/trial is dirty, all its items are also dirty.
-
Re-create dirty resources that still exist in new spec — dirty phases/items that appear in the new spec are re-created.
-
Create net-new resources — phases/items in the new spec not present in the current view are created.
Comparison is by value: CreateSubscriptionItemEntityInput.Equal() uses reflect.DeepEqual but intentionally ignores Metadata and Annotations. Any difference in entity inputs — including a change to ActiveToOverrideRelativeToPhaseStart — causes a delete+recreate cycle for that item row and its linked entitlement.
What happens when PatchAddItem replaces an existing item in the current phase: The patch closes item[0] (sets its ActiveTo) and appends item[1]. In sync, Pass 1 detects that item[0]'s ActiveTo changed → soft-deletes item[0] and its entitlement. Pass 2 recreates item[0] with the closed window (and a new entitlement bounded to that window). Pass 3 creates item[1] as net-new (with its own entitlement starting from editTime). The phase entity is untouched.
Entitlement comparison caveat (FIXME in code): During comparison, the current entitlement is used for both old and new sides. This works because entitlement-relevant properties are captured in the item's RateCard, but it means the entitlement ID itself is never compared.
createItem / deleteItem (in service/synchelpers.go): item creation calls EntitlementAdapter.ScheduleEntitlement when the RateCard requires an entitlement. Item deletion calls EntitlementAdapter.DeleteByItemID. Phase deletion cascades to all items in the phase.
Patch System
Patch interface:
type Patch interface {
AppliesToSpec
Validate() error
Op() PatchOperation
Path() SpecPath
}
SpecPath — a string path like /phases/trial/items/seat/idx/0. Valid depths:
- 2: phase —
/phases/{key} — use for patches that target a whole phase (PatchAddPhase, PatchRemovePhase, PatchStretchPhase)
- 4: item —
/phases/{key}/items/{key} — use for patches that target an item regardless of version (PatchAddItem, PatchRemoveItem, and any in-place mutation patch)
- 6: item version —
/phases/{key}/items/{key}/idx/{N} — use only when a patch must address a specific version by index
Do not use depth-6 for a patch that mutates in-place (e.g., replacing a rate card without creating a new version). Depth-4 is correct for that — the patch logic selects the relevant version internally.
ApplyContext — carries CurrentTime. Patches use it to reject operations on past phases/items.
Concrete patches (subscription/patch/):
| Patch | What it does |
|---|
PatchAddItem | Adds item to a phase; auto-closes previous version of same key in current phase |
PatchRemoveItem | Removes item from a phase |
PatchAddPhase | Adds a new future phase |
PatchRemovePhase | Removes a future phase; `Shift: next |
PatchStretchPhase | Extends/shrinks a phase by shifting all subsequent StartAfter values |
PatchUnscheduleEdit | Removes a scheduled future edit |
Patches are applied via SubscriptionSpec.ApplyMany(patches, actx). Addons also implement AppliesToSpec — they go through the same ApplyTo mechanism.
Workflow Layer
workflow.Service (openmeter/subscription/workflow/) provides higher-level operations:
CreateFromPlan — resolves timing, builds SubscriptionSpec from a Plan, calls Service.Create.
EditRunning — validates no addons are on the subscription (edit is blocked if any exist), applies patches to current spec, calls Service.Update. Sets OwnerSubscriptionSubSystem and UniquePatchID annotations on added items.
ChangeToPlan — cancels the current subscription, creates a new one with the new plan. Passes billing anchor through. Stores previous/superseding subscription IDs in annotations.
Restore — (single-subscription mode only) deletes all future-scheduled subscriptions, calls Continue on the current one. Forbidden when multi-subscription-enabled feature flag is on.
AddAddon / ChangeAddonQuantity — delegates to AddonService.
Important: EditRunning is blocked if the subscription has any active addons. Addon-bearing subscriptions must be modified through the addon workflow.
Addon Sub-system
SubscriptionAddon — an addon applied to a subscription, with a timeutil.Timeline[SubscriptionAddonQuantity] for quantity changes over time.
addon/diff — applies addon rate cards onto SubscriptionSpec:
- For each addon instance, the diff algorithm overlays rate cards on existing items or inserts new items.
GetRestores() produces patches to undo addon effects.
Quirk: When an addon creates a new item (not a split of an existing one), feature key resolution happens at sync time. Multiple items with the same feature key may point to different feature instances.
Hook System
SubscriptionCommandHook interface — Before* and After* callbacks:
BeforeCreate/BeforeContinue/BeforeUpdate/BeforeDelete
AfterCreate/AfterUpdate/AfterCancel/AfterContinue
Embed NoOpSubscriptionCommandHook to get default no-ops.
Registered hooks (automatically wired in app/common/subscription.go):
SubscriptionUniqueConstraintValidator — prevents overlapping subscriptions for the same customer; also prevents two items with the same feature key (entitlement or billable) from overlapping in time. Full overlap check is gated by multi-subscription-enabled feature flag in BeforeUpdate.
AnnotationCleanupHook — on BeforeDelete, updates previous/superseding annotation pointers in linked subscriptions to keep the chain intact when a middle subscription is deleted.
Register additional hooks via Service.RegisterHook(hook).
Annotations
Key annotation keys (subscription/annotations.go):
| Key | Meaning |
|---|
subscription.id | Which subscription created an entitlement |
subscription.owner | Owner subsystem ("subscription" or "addon") |
subscription.previous.id | The subscription this one replaced (set by ChangeToPlan) |
subscription.superseding.id | The subscription that replaced this one |
subscription.entitlement.boolean.count | Count of boolean entitlements |
Use subscription.AnnotationParser for typed access. workflow.UniquePatchID (in workflow/annotations.go) generates deterministic patch IDs to prevent duplicate item creation.
Timing System
Timing struct — either Custom *time.Time or Enum *TimingEnum:
TimingImmediate → clock.Now()
TimingNextBillingCycle → end of current aligned billing period (requires BillingCadence)
Validation rules (timing.go):
- Update: must be in the future; cannot time-travel to a different phase
- Cancel: custom time must align with billing cadence if the subscription is aligned
- Create: custom time must not be more than 2 minutes in the past
Aligned billing periods: SubscriptionSpec.GetAlignedBillingPeriodAt(at) computes the billing period for a given time, accounting for phase boundaries and the billing anchor.
State Machine
Uses the stateless library. States: Inactive, Active, Canceled, Scheduled.
Inactive ──create──> Active
Active ──cancel──> Canceled
Active ──update──> Active (reentry)
Canceled ──continue──> Active
Scheduled ──delete──> (removed)
Each service method calls CanTransitionOrErr before proceeding.
Billing Connection
billing/charges/meta.SubscriptionReference carries {SubscriptionID, PhaseID, ItemID}. The billing worker's subscription sync service (billing/worker/subscriptionsync/) reads subscription views and reconciles invoice lines via billing.Service.GetLinesForSubscription.
Plan Bridge
openmeter/productcatalog/subscription/ wraps the workflow service:
PlanSubscriptionService.Create — plan resolution + StartingPhase support (zeroes out earlier phases)
Migrate — switches to a different version of the same plan
Change — switches to a different plan entirely
- HTTP handlers:
openmeter/productcatalog/subscription/http/
The subscription package does NOT import the plan package directly — it uses the Plan, PlanPhase, PlanRateCard interfaces defined in subscription/plan.go.
Multi-Subscription Feature Flag
MultiSubscriptionEnabledFF = ffx.Feature("multi-subscription-enabled") gates:
- Full overlap validation in
BeforeUpdate (uniqueness check is relaxed when disabled)
Restore is forbidden when enabled
- Default in tests:
false
Set it in tests via ffx.NewTestContextService(ffx.AccessConfig{...}).
Context Marker
subscription.NewSubscriptionOperationContext(ctx) marks the context as a subscription operation. subscription.IsSubscriptionOperation(ctx) checks it. Used to prevent recursive hook invocations when the customer validator internally queries the subscription service.
Customer Currency Auto-Set
If a new subscription has billable items and the customer has no currency set, Service.Create automatically updates the customer's currency to the subscription's currency.
Non-Obvious Pitfalls
- Spec phases are pointer-valued:
Phases map[string]*SubscriptionPhaseSpec — patches mutate the pointer contents directly. Never replace the pointer with a new value in a patch; modify the pointed-to struct.
ExpandViews is single-customer only: current implementation does not support multiple customers per call.
- Phase timing is not stored on the phase entity:
ActiveTo must be derived by sorting all phases and finding the successor. Never assume you can read ActiveTo from a single phase row.
- Item index = version history:
ItemsByKey[key][0] is the original version, [1] is a replacement, etc. This is not a multi-quantity concept — it is a time-ordered edit history.
- Sync deletes and recreates changed items: any diff in entity inputs causes a full delete+recreate cycle for that item, including re-scheduling the linked entitlement.
- EditRunning is blocked by addons: any attempt to call
EditRunning on a subscription that has addons returns an error. Use the addon workflow instead.
BillingAnchor defaults to activeFrom: normalized to UTC. Used for all aligned billing period calculations.
Testing Guidance
testutils/service.go → NewService() provides a full integration test wiring with a real Postgres DB (schema via migrations). Prefer this over mocks for any service-layer tests.
testutils/builder.go — SubscriptionSpecBuilder, PhaseBuilder, ItemBuilder for constructing specs declaratively in tests.
testutils/compare.go — deep comparison helpers that account for non-deterministic ordering in views.
testutils/mock.go — mock implementations for adapters (useful for unit tests of patches and spec logic).
- Feature flag tests: use
ffx.NewTestContextService.
- Unit tests for patches live in
subscription/patch/*_test.go.
- Integration tests for the service live in
subscription/service/service_test.go and subscription/service/sync_test.go.
- Addon service tests:
subscription/addon/service/*_test.go.
Addon test rate cards and entitlements (testutils/addon.go):
| Constant | EntitlementTemplate | Notes |
|---|
ExampleAddonRateCard1 | none | Has feature key; itemView.Entitlement will be nil |
ExampleAddonRateCard2 | none | No feature key |
ExampleAddonRateCard3 | BooleanEntitlementTemplate | Use this when testing entitlement cadences |
ExampleAddonRateCard4 | none | Flat price on ExampleFeatureKey2 |
Use ExampleAddonRateCard3 (or build a custom rate card with an EntitlementTemplate) when the test needs to assert itemView.Entitlement != nil.
Identifying addon items vs plan items in a view:
ownerSystems := subscription.AnnotationParser.ListOwnerSubSystems(item.Annotations)
isAddonItem := !lo.Contains(ownerSystems, subscription.OwnerSubscriptionSubSystem)
Addon integration test wiring (in addon/service/ tests):
dbDeps := subscriptiontestutils.SetupDBDeps(t)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
The createPlanWithAddon helper (available in addon service tests) creates features, plan, addon, links via PlanAddon, and publishes everything in one call.
Running Tests
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/subscription/...
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/subscription/service/...
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/subscription/addon/service/...
go test -v ./openmeter/subscription/patch/...
Editing Checklist
When modifying subscription service behavior:
- Check whether the change belongs in: domain type, service, workflow, or plan bridge layer
- If touching mutating operations: ensure the per-customer lock is held and the operation runs in a transaction
- If adding new Before*/After* hook callbacks: add a no-op default to
NoOpSubscriptionCommandHook
- If changing spec comparison: remember
Equal() ignores Metadata and Annotations by design
When modifying the sync algorithm:
- Preserve the three-pass structure (mark dirty → recreate dirty → create new)
- Keep
touched parent-path inheritance working correctly when adding new SpecPath depths
- The entitlement comparison FIXME is intentional — the entitlement ID is not compared
When modifying the patch system:
- New patches must implement
Patch (which embeds AppliesToSpec) and validate against ApplyContext.CurrentTime
- Register new patches in any patch-routing code that switches on
PatchOperation
- Test both valid and invalid (past-phase, missing-key) scenarios
When modifying addons:
- Keep
EditRunning blocked when addons are present
- The diff/restore roundtrip must be symmetric:
Apply followed by GetRestores must produce a spec equivalent to the original
- Feature key resolution happens at sync time for addon-created items — do not assume it happens at diff time