Skip to content

Billing & Subscriptions

Craft Easy's billing subsystem runs recurring charges: a customer signs up for a plan, and the framework charges them on a schedule until they cancel. It manages the plan catalog, the subscription lifecycle, the trial period, the next-billing-date math, and the retry schedule when a payment fails.

Billing sits on top of Payments — a subscription charge ultimately becomes a Payment routed through a provider. What billing adds on top is the schedule: when to charge, how to retry, when to expire the subscription if every retry fails.

The plan catalog

A RecurringBillingPlan is the template. It declares the price, currency, interval, and trial period, and it is shared across every subscription that uses it.

Field Purpose
name, description Display metadata
amount, currency Price per period (Decimal + ISO 4217)
interval daily, weekly, monthly, yearly
interval_count 1 for every period, 3 for quarterly, 6 for semi-annual, 12 for annual-via-monthly, etc.
trial_days Number of free days at the start of a subscription
is_active Whether the plan can accept new subscriptions (existing subscriptions are unaffected when a plan is retired)
plan = RecurringBillingPlan(
    name="Pro Monthly",
    description="Unlimited users, priority support",
    amount=Decimal("499.00"),
    currency="SEK",
    interval="monthly",
    interval_count=1,
    trial_days=14,
    is_active=True,
)

A tenant typically has a handful of plans — Starter, Pro, Enterprise — each with different prices or intervals. Plans are not tenant-scoped; they are system-level templates shared across deployments, with every subscription carrying its own tenant_id.

The Subscription model

A Subscription links a user and tenant to a plan, tracks the current billing period, and carries the retry state.

Field Purpose
plan_id The plan this subscription was created from (frozen at creation — renaming the plan does not change the subscription)
tenant_id, user_id Who owns the subscription
status trial, active, past_due, cancelled, expired
current_period_start, current_period_end The window covered by the most recent successful charge
next_billing_date When the next charge is due
cancel_at_period_end If true, expire at current_period_end rather than charge again
cancelled_at When the cancel request was received
trial_start, trial_end Trial window (only set when plan.trial_days > 0)
retry_count, last_retry_at, last_payment_error State for the retry loop
external_subscription_id Provider-side subscription id, if the provider manages its own subscription objects (Stripe does; Swish does not)

Statuses

         trial ──▶ active ──▶ past_due ──▶ cancelled / expired
                      │          │
                      └──────────┘
                      retry succeeds → active
  • trial — plan has a trial period and the trial has not ended. No charges yet.
  • active — last charge succeeded; next_billing_date is in the future.
  • past_due — last charge failed; the service is running the retry schedule.
  • cancelled — user cancelled. The status is set immediately if immediate=True, or at current_period_end if immediate=False.
  • expired — retry schedule ran out, or cancel_at_period_end reached its date. Terminal.

BillingService

craft_easy.core.billing.service.BillingService owns every transition. It has a short public surface:

from craft_easy.core.billing.service import BillingService

svc = BillingService()

# Create
sub = await svc.create_subscription(plan_id=plan.id, tenant_id=tenant.id, user_id=user.id)

# Cancel (two modes)
await svc.cancel(sub.id, immediate=False)   # expire at period end
await svc.cancel(sub.id, immediate=True)    # expire now

# Reactivate before the period ends (if immediate=False)
await svc.reactivate(sub.id)

# Move the clock forward after a successful charge
await svc.advance_period(sub.id)

# Retry a failed charge
await svc.retry_failed_payment(sub.id)

# Force a state if something happened externally
await svc.mark_past_due(sub.id)
await svc.expire(sub.id)

Trial handling

If the plan carries trial_days > 0, create_subscription sets trial_start=now, trial_end=now + trial_days, and next_billing_date=trial_end. The subscription's status is trial until the first charge, which the billing runner triggers on trial_end. If the customer cancels during the trial, they are never charged.

Period math

_advance_date(dt, interval, count) walks the clock forward by the plan's interval. Monthly/yearly intervals use month-end clamping — if the subscription started on January 31, the February period ends on February 28/29 (not on March 3 or something weird). _add_months(dt, months) is the workhorse; it handles every edge case around month-end dates.

After a successful charge, advance_period copies current_period_end to current_period_start, computes a new current_period_end, and sets next_billing_date to the new current_period_end. Repeat until cancelled.

The retry schedule

A failed charge is not the end of the subscription. BillingService.MAX_RETRIES = 3 and the retry delays are [1, 3, 7] days — after a failure, the service schedules a retry 1 day later, then 3 days after that, then 7 days after that. If all three retries fail, the subscription expires.

await svc.retry_failed_payment(sub.id)
# - Reads retry_count, picks delay from [1, 3, 7]
# - Calls the payment provider
# - On success: flips status to active, resets retry_count, calls advance_period
# - On failure: increments retry_count, updates last_payment_error, schedules next retry
# - After 3 failures: flips status to expired

The billing runner (in craft-easy-jobs or a cron job) queries for subscriptions with status="past_due" and next_retry_at <= now and calls retry_failed_payment on each.

Routes

Plan endpoints

POST /billing-plans              # create plan
GET  /billing-plans              # list (filter: is_active)
GET  /billing-plans/{plan_id}    # fetch
PUT  /billing-plans/{plan_id}    # update (name, description, is_active — amount is frozen after first subscription)

Subscription endpoints

POST /subscriptions                                   # create
GET  /subscriptions                                   # list (filter: tenant_id, user_id, status)
GET  /subscriptions/{subscription_id}                 # fetch
POST /subscriptions/{subscription_id}/cancel          # cancel (body: {"immediate": bool})
POST /subscriptions/{subscription_id}/reactivate      # undo a pending cancel
GET  /subscriptions/{subscription_id}/renew           # trigger billing right now (usually called by the scheduler)

GET /subscriptions/{id}/renew is deliberately a GET because it is idempotent — calling it twice while a charge is in flight does not start two charges. The service locks the subscription during the charge and returns the same Subscription document the second time.

End-to-end example

# 1. Create a plan (usually done once during deployment)
plan = await create_plan(
    name="Pro Monthly",
    amount=Decimal("499"),
    currency="SEK",
    interval="monthly",
    trial_days=14,
)

# 2. A user subscribes (from the signup flow)
sub = await svc.create_subscription(
    plan_id=plan.id,
    tenant_id=tenant.id,
    user_id=user.id,
)
# sub.status == "trial"
# sub.trial_end == now + 14 days

# 3. The billing runner triggers at trial_end
# Internally it calls:
await svc.advance_period(sub.id)
# And uses PaymentService to charge the user.
# sub.status == "active"
# sub.current_period_end == trial_end + 1 month

# 4. A month later a charge fails (insufficient funds)
# The billing runner calls:
await svc.mark_past_due(sub.id)
# sub.status == "past_due", retry scheduled in 1 day

# 5. Day 1: retry
await svc.retry_failed_payment(sub.id)
# Still failing -> retry_count=1, next retry in 3 days

# 6. Day 4: retry
await svc.retry_failed_payment(sub.id)
# Succeeds -> status=active, retry_count=0

# 7. Customer decides to cancel at end of current period
await svc.cancel(sub.id, immediate=False)
# sub.cancel_at_period_end == true, status still "active"
# At current_period_end, the runner calls svc.expire() instead of retry.

Notes on providers

Two provider styles exist:

  1. Framework-managed subscriptions — Craft Easy owns the schedule. Each period, it creates a one-shot Payment and asks the provider to charge the saved payment method. Works with every provider. Suitable when you want full control over retries, dunning, trial edge cases.
  2. Provider-managed subscriptions — Stripe Subscriptions, Klarna Recurring. The provider owns the schedule; Craft Easy listens to webhooks and mirrors state into its own Subscription model via external_subscription_id. Lighter integration, less flexibility.

The Subscription model supports both. Which path to pick depends on your tenant's tolerance for provider vendor lock-in versus operational burden. Most deployments start with framework-managed and move specific high-volume tenants to provider-managed later.

Read Payments for the lower-level charge loop, and Claims for what happens when a subscription exhausts its retries and needs formal debt recovery.