Skip to content

core-billing

core-billing is the subscription + money-lifecycle service: a Python HTTP service that sells the subscription tiers, drives every payment rail, and keeps a user’s token grant in step with their plan. It owns the money and the lifecycle — it does not gate generations. The generation chokepoints (core-gateway-consumer and core-mcp) read a cached entitlement snapshot; this service produces it.

All plan / subscription / entitlement data lives in the billing schema behind BillingDataService in core-database — core-billing holds no DB credentials of its own and reaches data over gRPC like every other service.

Rails are registered as a {name: provider} map at startup (app/main.py) and selected platform-first, then by currency in app/deps.py (provider_for_request(platform, currency)): iOS must buy through Apple, Android through Google (store policy), and web falls back to the currency rail — INR → PayU (India), everything else → Stripe.

Rail Region / platform Provider file Activation model Recurring
Stripe Web, USD / international providers/stripe.py Redirect → hosted Checkout → webhook-first Stripe Subscriptions
PayU Web, INR (India) providers/payu.py Form-post SI mandate → verify-then-activate on return PayU standing instruction
Apple App Store iOS in-app purchase providers/apple.py On-device StoreKit → verify-then-activate at /v1/billing/iap/verify App Store auto-renew
Google Play Android in-app purchase providers/google.py On-device Play Billing → verify-then-activate at /v1/billing/iap/verify Play auto-renew

A row is created incomplete at checkout, activated on the first confirmed payment, extended per cycle, and only downgraded on a terminal signal. The lifecycle is provider-agnostic — every rail funnels into the same transitions in app/services/subscriptions.py.

stateDiagram-v2
    [*] --> incomplete: CreateSubscription (checkout)
    incomplete --> active: first payment verified → activate + grant tokens
    active --> active: RENEWED webhook → extend period + grant tokens
    active --> past_due: PAYMENT_FAILED → grace window
    past_due --> active: charge recovers (RENEWED)
    past_due --> free: dunning exhausted → downgrade
    active --> free: cancel-at-period-end / CANCELED / MANDATE_REVOKED
    free --> [*]: resolver falls back to the free plan

Notes that matter when reading the dispatch in app/services/webhooks.py:

  • A cancel intent is not a downgrade. Apple AUTO_RENEW_DISABLED / Google SUBSCRIPTION_CANCELED (and Stripe cancel-at-period-end) normalize to an INFO event — the user keeps their paid time. Only a terminal EXPIRED / REVOKE drives the downgrade to free.
  • Downgrade is a fallback, not a delete. Cancelling sets cancel_at_period_end and stops future PSP renewals; entitlements persist until current_period_end, after which the resolver simply returns the free row.
  • Annual subs honour store expiry. IAP activation/renewal passes the store-authoritative store_period_end, so an annual purchase stays entitled for the full year instead of a fixed +1 month.

Each paid tier grants a renewable monthly token allotment into the base bucket of the token ledger via TokenService.RenewBaseTokens. RenewBaseTokens resets the base bucket (it does not add), so it is guarded by an idempotency key sub-renew:{subscription_id}:{period_end} — a redelivered renewal webhook must not refill spent tokens for free. The never-expiring extra bucket (the 1000-token signup starter, legacy balances) is untouched.

Eight plans, seeded declaratively on boot (idempotent UpsertPlan, free first so the entitlement resolver always has its fallback row). Each tier gates licenses, scales rate limits, sets queue priority, and sets a monthly token grant and a concurrency cap. License ids: 1=ROYALTY_FREE, 2=STANDARD, 3=DISTRIBUTION, 4=SYNC, 5=MASTER.

Plan Allowed licenses Rate-limit × Queue priority Concurrency
free 1 0 1
creator 1,2 10 2
pro 1,2,3,4 20 4
max_x1max_x5 1,2,3,4,5 10× → 50× 30 → 46 6 → 14

Prices are per-currency, in billing.plan_prices: USD from each plan row, INR seeded from the old-platform PayU price list (region-specific, not an FX conversion). The store SKUs are currency-independent, so the same apple_product_id / google_product_id is written onto both the USD and INR rows.

To route an IAP purchase back to a plan, billing.plan_prices gained two columns — apple_product_id and google_product_id — added as PlanPrice proto fields 7 and 8 (binding to SQL $7/$8), with the twin schema.sql, in soundverse-proto. They are seeded from APPLE_PRODUCT_IDS / GOOGLE_PRODUCT_IDS (a plan_code → SKU JSON map), surfaced on GET /v1/billing/plans so native clients can map plan → store product, and used by plan_for_product() to resolve a verified purchase back to a plan on the way in.

One receiver per provider, all signature/auth-guarded (never Logto-authenticated). The raw body is read before any parse so the provider can verify over the exact bytes.

Endpoint Auth model
POST /v1/billing/webhook/stripe Stripe signature header over the raw body
POST /v1/billing/webhook/payu PayU HMAC (merchant salt / configured secret)
POST /v1/billing/webhook/apple ASSN V2 — self-verifying signed JWS; the Apple Root CA-G3 cert chain (from APPLE_ROOT_CERTS_DIR) is the auth
POST /v1/billing/webhook/google RTDN via Pub/Sub push — no HMAC; guarded by a shared secret in the push URL (?token=, constant-time) plus optional OIDC identity
  1. saas-facing routes authenticate the end-user Logto bearer by delegating to core-identity /v1/auth/validate (JWKS + subidentity.users.id + JIT provisioning). user_id comes from that response, never from the request body. See Configure Logto.

  2. core-billing → core-database gRPC carries the internal INTERNAL_RPC_SECRET bearer (aliased to INTERNAL_AUTH_SECRET; a name drift once caused a staging 401 loop — same lesson as core-identity).

  3. Webhooks authenticate on their provider signature / shared token, not Logto.

  • Shape — Python long-lived HTTP service (FastAPI). Listens on APP_PORT (default 80); deployed with external HTTP ingress (service_name: core-billing-staging / core-billing-prod).
  • Public surface/v1/billing/: checkout, change-plan, iap/verify, payu/return, webhook/{stripe,payu,apple,google}, subscription, subscription/cancel, plans (public, no auth), entitlements.
  • Key filesapp/routes/checkout.py (polymorphic {mode: redirect | form_post | iap} response), app/routes/iap.py, app/routes/webhook.py, app/services/subscriptions.py (the state machine), app/services/plans.py (the catalog seed).
  • Env (names only)CORE_DATABASE_GRPC, CORE_IDENTITY_GRPC, INTERNAL_RPC_SECRET, REDIS_ADDR, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_IDS, PAYU_KEY, PAYU_SALT, PAYU_WEBHOOK_SECRET, PAYU_RETURN_URL, APPLE_ISSUER_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_BUNDLE_ID, APPLE_APP_APPLE_ID, APPLE_ROOT_CERTS_DIR, APPLE_PRODUCT_IDS, GOOGLE_SERVICE_ACCOUNT, GOOGLE_PACKAGE_NAME, GOOGLE_PRODUCT_IDS, GOOGLE_RTDN_SHARED_SECRET. See the env-var catalog.
  • Tests — the pytest suite (37 tests) covers webhook signature/parse, the migration external-id mapping, and the store-SKU → plan resolution; store SDKs are lazily imported so pure parse/mapping logic runs without them.

Building all four rails was the connectors-first prerequisite for the 1.0 → 2.0 migration: porting the ~3,300 live consumer subscriptions (Apple + Android + Stripe + PayU) into billing.subscriptions. Migrated rows carry no 2.0 metadata, so renewal webhooks map them by external_subscription_id via the GetSubscriptionByExternalId fallback — the import must write the stable per-rail id: Apple originalTransactionId, Google purchaseToken, Stripe sub_…, PayU mihpayid.

In code, not deployed — the service is complete (registered as the core-billing submodule on the staging branch, tests green, full proto codegen validated) but not yet deployed. The rollout is hard-gated and ordered:

  1. Publish soundverse-proto (the billing/subscriptions/v1 package + the store product-id columns).
  2. Apply the plan_prices store-id and billing-schema SQL to the DB before shipping the new core-database image (the UpsertPlanPrice write binds $7/$8).
  3. Deploy core-database first (mounts BillingDataService).
  4. Deploy core-billing with the Stripe / PayU / Apple / Google secrets, and point the provider dashboards — Stripe/PayU webhook URLs, Apple ASSN V2, and the Play RTDN Pub/Sub push endpoint — at core-billing’s own FQDN.
  5. Deploy the gateway + core-mcp entitlement enforcement together, then saas last.