← All posts

Keeping Stripe out of your product services

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 in every service, and changing what a tier includes means finding and updating code in four places.

We designed smplkit’s subscription system to prevent exactly that. All subscription logic lives in one place — the smplcore.catalog module in our shared Python package — and services don’t implement billing logic at all. They ask the catalog whether an operation is permitted under the current subscription, and the catalog answers.

The catalog module

smplcore.catalog defines two things.

The product catalog: which products exist, what tiers they offer, what each tier includes. It’s the source of truth for both 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. The manifest for flags 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 whether an account can create a flag with targeting rules, it calls catalog.flags.can_use_targeting_rules(account.subscription_tier) and gets back True or False. The service enforces the answer; the logic lives in the catalog. Changing which tier gets targeting rules is one edit to one manifest, and every service picks it up on its next deploy — the catalog is in-process code, not a network call, so there’s no availability story to worry about.

One row per product subscription

Every account has subscription rows — one per product it’s subscribed to — in the app service’s database:

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

An account on the Developer Suite bundle — Config, Flags, and Logging together — has three subscription rows, one per product, each with bundle = "developer-suite". That column is the entire trick that makes bundle billing work without bundle-specific billing logic: when a bundle is purchased, the app service fans out individual subscription rows for each product in it. Each product service sees an ordinary per-product subscription and neither knows nor cares whether it arrived alone or in a box of three.

The fan-out

Bundle changes are fan-outs too. Upgrading from Developer Standard (Config, Flags, and Logging at Standard) to Developer Pro ends the three Standard rows, creates three Pro rows, and stamps bundle = "developer-pro" on all of them. Cancellation soft-deletes all rows in the bundle at once. On Stripe’s side there’s a single subscription at the bundle level; the fan-out is purely a server-side concern.

The flags service never needs to learn what “Developer Pro” means. It only ever sees “this account has a Flags Pro subscription.” Bundle semantics are an application-layer concern; service-layer enforcement is against individual product subscriptions.

The Stripe mapping

Stripe handles payment processing and subscription management, and its model maps onto ours cleanly: a Stripe Customer per smplkit account; a Stripe Subscription per bundle (or per individually purchased product); Stripe Subscription Items as the line items within one.

Webhooks drive state:

  • 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 the webhooks and maintains the subscription table. Product services read subscription state from the app service’s internal API on each request, or cache it with a short TTL. No product service queries Stripe directly. The tentacles stop at the app service’s door.

Enforcement at request time

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

So 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 yet upgrade or downgrade from the developer console — they email support, which is a subscription management UI with terrible latency. This needs to be self-serve before general availability.