Ledger

Subscriptions

Track customer commitments, lifecycle states, and plan associations.

A Subscription represents a customer's commitment to a plan. It tracks the lifecycle from trial through active billing to cancellation, managing state transitions, billing periods, and usage entitlements.

Structure

type Subscription struct {
    ledger.Entity
    ID               id.SubscriptionID
    CustomerID       id.CustomerID
    PlanID           id.PlanID
    TenantID         string
    AppID            string
    Status           SubscriptionStatus
    CurrentPeriodStart time.Time
    CurrentPeriodEnd   time.Time
    TrialStart       *time.Time
    TrialEnd         *time.Time
    CanceledAt       *time.Time
    CancellationReason string
    Metadata         map[string]any
}

Lifecycle states

Subscriptions transition through well-defined states:

type SubscriptionStatus string

const (
    StatusTrialing     SubscriptionStatus = "trialing"
    StatusActive       SubscriptionStatus = "active"
    StatusPastDue      SubscriptionStatus = "past_due"
    StatusCanceled     SubscriptionStatus = "canceled"
    StatusUnpaid       SubscriptionStatus = "unpaid"
    StatusPaused       SubscriptionStatus = "paused"
)

State diagram

trialing → active → past_due → unpaid
   ↓         ↓         ↓          ↓
          canceled ← ← ← ← ← ← ← ←

               paused
StatusDescription
trialingIn trial period, no billing
activeActively billed, entitlements enabled
past_duePayment failed, grace period active
unpaidPayment collection failed, limited access
pausedTemporarily suspended by customer
canceledTerminated, no further billing

Billing periods

Each subscription tracks its current billing cycle:

CurrentPeriodStart: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
CurrentPeriodEnd:   time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC)

When an invoice is generated, the billing period advances:

  • Monthly: CurrentPeriodEnd moves forward 1 month
  • Yearly: CurrentPeriodEnd moves forward 1 year
  • Usage-based: Period defined by billing schedule (e.g., monthly)

Trial periods

Trials are tracked separately from billing periods:

TrialStart: &time.Time{...} // Trial started
TrialEnd:   &time.Time{...} // Trial ends (becomes active or canceled)

When time.Now() is before TrialEnd:

  • Status is trialing
  • No invoices are generated
  • Full plan entitlements are granted

When trial expires:

  • Status transitions to active (if payment method on file) or unpaid
  • First invoice is generated

Cancellation

Subscriptions can be canceled immediately or at period end:

Immediate cancellation

subscription.Status = subscription.StatusCanceled
subscription.CanceledAt = &now
subscription.CancellationReason = "customer_request"

Entitlements are revoked immediately.

Cancel at period end

subscription.CancelAtPeriodEnd = true

The subscription remains active until CurrentPeriodEnd, then transitions to canceled.

Plan changes (upgrades/downgrades)

When a customer changes plans:

Immediate change

subscription.PlanID = newPlan.ID
subscription.CurrentPeriodStart = time.Now()
subscription.CurrentPeriodEnd = time.Now().AddDate(0, 1, 0) // restart billing cycle

A prorated invoice is generated for the unused portion of the old plan.

At period end

subscription.ScheduledPlanChange = &ScheduledChange{
    NewPlanID:     newPlan.ID,
    EffectiveDate: subscription.CurrentPeriodEnd,
}

The plan change applies at the end of the current billing period.

Usage tracking

Subscriptions accumulate usage events for metered features:

// Usage is stored separately and aggregated during invoice generation
meter.Record(ctx, &meter.Event{
    SubscriptionID: subscription.ID,
    Meter:          "api-calls",
    Value:          1,
    Timestamp:      time.Now(),
})

See Usage Metering for details.

Complete example: Creating a subscription

subscription := &subscription.Subscription{
    ID:                 id.NewSubscriptionID(),
    CustomerID:         customer.ID,
    PlanID:             starterPlan.ID,
    TenantID:           "acme-corp",
    AppID:              "saas-platform",
    Status:             subscription.StatusTrialing,
    CurrentPeriodStart: time.Now(),
    CurrentPeriodEnd:   time.Now().AddDate(0, 1, 0), // 1 month
    TrialStart:         ptrTime(time.Now()),
    TrialEnd:           ptrTime(time.Now().AddDate(0, 0, 14)), // 14 days
}

if err := store.CreateSubscription(ctx, subscription); err != nil {
    log.Fatal(err)
}

Scheduled jobs

Ledger runs background jobs to manage subscription lifecycle:

JobFrequencyAction
Trial expirationHourlyTransition trialingactive or unpaid
Period rolloverDailyAdvance billing periods, generate invoices
Dunning managementDailyRetry failed payments, transition past_dueunpaid
Scheduled changesDailyApply plan changes at EffectiveDate

Store interface

type Store interface {
    CreateSubscription(ctx context.Context, sub *Subscription) error
    GetSubscription(ctx context.Context, subID id.SubscriptionID) (*Subscription, error)
    GetSubscriptionsByCustomer(ctx context.Context, customerID id.CustomerID) ([]*Subscription, error)
    UpdateSubscription(ctx context.Context, sub *Subscription) error
    CancelSubscription(ctx context.Context, subID id.SubscriptionID, reason string) error
    ListSubscriptions(ctx context.Context, filter *ListFilter) ([]*Subscription, error)
}

API routes

MethodPathDescription
POST/ledger/subscriptionsCreate a subscription
GET/ledger/subscriptionsList subscriptions
GET/ledger/subscriptions/{id}Get subscription by ID
PUT/ledger/subscriptions/{id}Update subscription
POST/ledger/subscriptions/{id}/cancelCancel subscription
POST/ledger/subscriptions/{id}/pausePause subscription
POST/ledger/subscriptions/{id}/resumeResume subscription
POST/ledger/subscriptions/{id}/change-planUpgrade/downgrade plan

On this page