← All posts

Subscriptions Without Per-Service Billing Logic

Billing systems have a way of spreading their tentacles into product code. A feature check here, a subscription tier lookup there — before long, billing logic is scattered across every endpoint, every service, every codebase. Changing what a tier includes means finding and updating code in four places.

We designed smplkit’s subscription system to avoid this. All subscription logic lives in one place: the smplcore.catalog module in the shared Python package. Services don’t implement billing logic — they ask the catalog whether an operation is permitted given the current subscription, and the catalog answers.

This post describes the architecture, the governance manifest pattern, and the Stripe integration.

The Product Catalog Module

smplcore.catalog defines two things:

The product catalog. Which products exist, what tiers they offer, and what each tier includes. This is the source of truth for the features matrix on the marketing site and the enforcement logic in the product services.

Governance manifests. Per-product, per-tier declarations of what’s allowed. A governance manifest for the flags product at the Standard tier might say: “targeting rules: yes, percentage rollouts: no, max flags: unlimited, max concurrent SDK connections: 50.”

When the flags service needs to know if a given account can create a flag with targeting rules, it calls catalog.flags.can_use_targeting_rules(account.subscription_tier). The catalog returns True or False. The service enforces the answer. The logic lives in the catalog, not the service.

This means: when we change what targeting rules are available at which tier, we change the catalog governance manifest. One change, one location. Services pick up the change automatically on the next deploy (or immediately, since the catalog is in-process code in each service, not a network call).

The Per-Product Subscription Row

Every smplkit account has subscription rows — one per product the account has subscribed to. The subscription table in the app service database has:

subscription
├── id (UUID)
├── account_id (UUID)
├── product_id (e.g., "flags")
├── tier (e.g., "standard", "pro")
├── bundle (e.g., "developer-suite", null)
├── status (e.g., "active", "cancelled")
├── stripe_subscription_id
└── current_period_end

One row per product subscription. An account on the Developer Suite bundle (which includes Config, Flags, and Logging) has three subscription rows — one for each product — all with bundle = "developer-suite".

The bundle column is what makes bundle billing work without bundle-specific billing logic. When a bundle is purchased, the app service fan-outs to create individual subscription rows for each product in the bundle. Each product service sees a normal per-product subscription; it doesn’t know or care whether it was purchased individually or as part of a bundle.

The Bundle Fan-Out

Bundle upgrades and downgrades trigger fan-outs. When an account upgrades from the Developer Standard bundle (Config Standard + Flags Standard + Logging Standard) to the Developer Pro bundle (Config Pro + Flags Pro + Logging Pro), the subscription service:

  1. Ends the three current Standard subscription rows
  2. Creates three new Pro subscription rows
  3. Sets bundle = "developer-pro" on all three

Bundle cancellation similarly soft-deletes all rows in the bundle simultaneously. The Stripe subscription is a single item at the bundle level; the fan-out is a server-side concern.

This fan-out approach means the flags service never needs to know “this account is on the Developer Pro bundle.” It only knows “this account has a Flags Pro subscription.” Bundle semantics are an application-layer concern; service-layer enforcement is against individual product subscriptions.

Stripe Integration

We use Stripe for payment processing and subscription management. The Stripe model maps to ours:

Stripe Customer ↔ smplkit Account. One Stripe customer per account.

Stripe Subscription ↔ smplkit Bundle or individual product subscription. For bundle subscribers, one Stripe Subscription covers all products in the bundle. For individual product subscribers, one Stripe Subscription per product.

Stripe Subscription Item ↔ Line item within a Stripe Subscription. Bundles have one item; multi-product individual subscriptions would have multiple items.

Stripe webhook events drive subscription status updates:

  • customer.subscription.created → create subscription row(s)
  • customer.subscription.updated → update tier or status
  • customer.subscription.deleted → mark subscription cancelled
  • invoice.payment_failed → mark subscription past_due

The app service processes Stripe webhooks and updates the subscription table. Product services read the subscription state from the app service’s internal API on each request (or cache it with a short TTL). They don’t query Stripe directly.

Product Services Read Subscription State at Request Time

Every product service reads the caller’s account context — including current subscription state for each product — at the start of each request, via the shared get_current_account dependency. The dependency returns the account, a product_subscriptions map keyed by product ID, and the resolved tier for each.

The product service uses this to enforce governance rules. The flags service, receiving a request to create a flag with targeting rules, reads product_subscriptions["flags"]["tier"], asks the catalog can_use_targeting_rules("standard"), and either proceeds or returns a 403 with a billing upgrade prompt.

What We’d Revisit

Self-serve plan management. Customers can’t currently upgrade or downgrade their subscription from the developer console. They email support. This needs to be self-serve before general availability.

smplkit subscriptions are modeled as per-product rows in the app service database. Bundle purchases fan-out to individual product subscription rows. Governance logic lives in smplcore.catalog and is enforced by each product service against the current subscription state.