CtrlK
BlogDocsLog inGet started
Tessl Logo

subscription

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.

68

Quality

82%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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.goNewSubscriptionServices 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   // Get, GetView, List, ExpandViews
    CommandService // Create, Update, Delete, Cancel, Continue, UpdateAnnotations, RegisterHook
}

Every mutating operation:

  1. Acquires a per-customer distributed lock: customer/{id}/subscription via lockr.Locker.
  2. Runs inside a transaction via transaction.Run().
  3. Invokes Before*/After* hooks.
  4. 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:

  1. 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.

  2. Re-create dirty resources that still exist in new spec — dirty phases/items that appear in the new spec are re-created.

  3. 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  // ApplyTo(spec *SubscriptionSpec, actx ApplyContext) error
    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/):

PatchWhat it does
PatchAddItemAdds item to a phase; auto-closes previous version of same key in current phase
PatchRemoveItemRemoves item from a phase
PatchAddPhaseAdds a new future phase
PatchRemovePhaseRemoves a future phase; `Shift: next
PatchStretchPhaseExtends/shrinks a phase by shifting all subsequent StartAfter values
PatchUnscheduleEditRemoves 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):

  1. 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.
  2. 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):

KeyMeaning
subscription.idWhich subscription created an entitlement
subscription.ownerOwner subsystem ("subscription" or "addon")
subscription.previous.idThe subscription this one replaced (set by ChangeToPlan)
subscription.superseding.idThe subscription that replaced this one
subscription.entitlement.boolean.countCount 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:

  • TimingImmediateclock.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.goNewService() 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.goSubscriptionSpecBuilder, 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):

ConstantEntitlementTemplateNotes
ExampleAddonRateCard1noneHas feature key; itemView.Entitlement will be nil
ExampleAddonRateCard2noneNo feature key
ExampleAddonRateCard3BooleanEntitlementTemplateUse this when testing entitlement cadences
ExampleAddonRateCard4noneFlat 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:

// Plan-sourced items have OwnerSubscriptionSubSystem; addon items do not.
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)
// or use the withDeps helper already defined in create_test.go

The createPlanWithAddon helper (available in addon service tests) creates features, plan, addon, links via PlanAddon, and publishes everything in one call.

Running Tests

# All subscription tests
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/subscription/...

# Service integration tests only
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/subscription/service/...

# Addon service tests
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/subscription/addon/service/...

# Patch unit tests (no Postgres needed)
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
Repository
openmeterio/openmeter
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.