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— plan has a trial period and the trial has not ended. No charges yet.active— last charge succeeded;next_billing_dateis in the future.past_due— last charge failed; the service is running the retry schedule.cancelled— user cancelled. The status is set immediately ifimmediate=True, or atcurrent_period_endifimmediate=False.expired— retry schedule ran out, orcancel_at_period_endreached 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:
- Framework-managed subscriptions — Craft Easy owns the schedule. Each period, it creates a one-shot
Paymentand 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. - Provider-managed subscriptions — Stripe Subscriptions, Klarna Recurring. The provider owns the schedule; Craft Easy listens to webhooks and mirrors state into its own
Subscriptionmodel viaexternal_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.