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.
The four payment rails
Section titled “The four payment rails”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 |
Subscription lifecycle
Section titled “Subscription lifecycle”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/ GoogleSUBSCRIPTION_CANCELED(and Stripe cancel-at-period-end) normalize to anINFOevent — the user keeps their paid time. Only a terminalEXPIRED/REVOKEdrives the downgrade to free. - Downgrade is a fallback, not a delete. Cancelling sets
cancel_at_period_endand stops future PSP renewals; entitlements persist untilcurrent_period_end, after which the resolver simply returns thefreerow. - 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.
Money: the monthly token grant
Section titled “Money: the monthly token grant”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.
Subscription tiers
Section titled “Subscription tiers”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 | 1× | 0 | 1 |
creator |
1,2 | 2× | 10 | 2 |
pro |
1,2,3,4 | 5× | 20 | 4 |
max_x1 … max_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.
Store product-id columns
Section titled “Store product-id columns”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.
Webhooks
Section titled “Webhooks”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 |
-
saas-facing routes authenticate the end-user Logto bearer by delegating to core-identity
/v1/auth/validate(JWKS +sub→identity.users.id+ JIT provisioning).user_idcomes from that response, never from the request body. See Configure Logto. -
core-billing → core-database gRPC carries the internal
INTERNAL_RPC_SECRETbearer (aliased toINTERNAL_AUTH_SECRET; a name drift once caused a staging 401 loop — same lesson as core-identity). -
Webhooks authenticate on their provider signature / shared token, not Logto.
Field guide
Section titled “Field guide”- Shape — Python long-lived HTTP service (FastAPI). Listens on
APP_PORT(default80); 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 files —
app/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.
Legacy-subscription migration
Section titled “Legacy-subscription migration”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.
Deploy status
Section titled “Deploy status”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:
- Publish
soundverse-proto(thebilling/subscriptions/v1package + the store product-id columns). - Apply the
plan_pricesstore-id and billing-schema SQL to the DB before shipping the new core-database image (theUpsertPlanPricewrite binds$7/$8). - Deploy core-database first (mounts
BillingDataService). - 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.
- Deploy the gateway + core-mcp entitlement enforcement together, then saas last.
Related
Section titled “Related”- The token ledger & money path — the base/extra buckets this service grants into
- Tool pricing model — per-generation cost, distinct from subscriptions
- core-database — where
BillingDataServiceand thebillingschema live - core-migration — the legacy-subscription port this unblocks
- Billing reconciliation — the settlement backstop
- Data schema catalog — the
billing.plans/subscriptions/plan_pricestables