Technical blueprint for the billing system that powers LogicSpike's monetization layer.
Finalized Decisions (Recap)
| # | Decision | Detail |
|---|---|
| 1 | Payment provider | Razorpay (current, India) — via IPaymentProvider abstraction. Stripe ready as StripeProvider for international (Phase 8). |
| 2 | Pricing model | Predefined plans (Free / Starter / Pro / Business) + LogicSpike Coins top-up system |
| 3 | Seat model | Free & Starter = flat with fixed seats · Pro = flat base + ₹400/extra seat/mo · Business = per-seat (₹1,000/seat/mo) |
| 4 | Billing cycles | Monthly & Yearly. Discount % stored per plan in plans.yearly_discount_pct (customizable) |
| 5 | Tax | Razorpay GST auto-configuration. Stripe Tax (automatic_tax: true) when Stripe is added. |
| 6 | Currency | INR (Razorpay). USD for international (Stripe, Phase 8). |
| 7 | Trial | 1-month free trial of Pro/Business; card captured upfront, charge deferred. |
| 8 | Downgrade policy | Soft enforcement — never delete data, only block new creation |
Multi-Workspace Note: Each workspace (tenant) maps to exactly one payment provider Customer. Billing is 100% workspace-scoped — a user who owns 3 workspaces has 3 separate provider Customer records. No user-level billing aggregation.
High-Level Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ SELLER DASHBOARD (Next.js) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Plans Tab │ │ Invoices │ │ Coins & Add-ons Tab │ │
│ │ (Pricing │ │ Tab │ │ (Balance, Buy Coins, │ │
│ │ Cards + │ │ (Razorpay │ │ Purchase Add-ons, │ │
│ │ Toggle) │ │ Invoices) │ │ Transaction History) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────┬─────────────┘ │
│ │ │ │ │
│ Razorpay modal opens inline (no redirect) — JS SDK │ │
│ ┌──────┴─────────────────┴────────────────────────┴─────────────┐ │
│ │ Billing Settings API Client │ │
│ └───────────────────────────┬───────────────────────────────────┘ │
└──────────────────────────────┼──────────────────────────────────────┘
│ HTTPS
┌──────────────────────────────┼──────────────────────────────────────┐
│ GATEWAY (Hono / Cloudflare Workers) │
│ auth.middleware → access.middleware → rate-limit → proxy │
└──────────────────────────────┼──────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
│ │
┌────────────▼───────────┐ ┌────────────▼───────────┐
│ MANAGER SERVICE │ │ PAYMENT PROVIDER │
│ (Billing Domain) │ │ IPaymentProvider │
│ ┌───────────────────┐ │ │ ┌──────────────────┐ │
│ │ Billing Router │ │ ←────── │ │ RazorpayProvider │ │
│ │ PaymentProvider │ │ ──────→ │ │ (current) │ │
│ │ interface calls │ │ │ ├──────────────────┤ │
│ └────────┬──────────┘ │ │ │ StripeProvider │ │
│ │ │ │ │ (Phase 8, future)│ │
│ ┌────────▼──────────┐ │ │ └──────────────────┘ │
│ │ Webhook Handler │ │ └────────────────────────┘
│ │ Normalizes & │ │
│ │ processes events │ │
│ └────────┬──────────┘ │
│ │ │
│ ┌────────▼──────────┐ │
│ │ Entitlement Svc │ │
│ │ Rebuilds limits │ │
│ │ in tenant_svc │ │
│ └───────────────────┘ │
└────────────┬───────────┘
│
┌────────────▼─────────────────────────────────┐
│ POSTGRES (Neon / Supabase) │
│ ┌────────────┐ ┌────────────────────────┐ │
│ │ Building │ │ Floor (Tenant) │ │
│ │ plans │ │ subscriptions │ │
│ │ services │ │ tenant_services │ │
│ │ svc_limits │ │ tenant_coins │ │
│ │ plan_svc_ │ │ coin_transactions │ │
│ │ limits │ │ tenant_addons │ │
│ │ coin_packs │ │ processed_rzp_events │ │
│ │ addon_cat │ └────────────────────────┘ │
│ └────────────┘ │
└──────────────────────────────────────────────┘Component Responsibilities
1. Seller Dashboard (Frontend)
Scope Note: All billing actions are workspace-scoped. Only the workspace owner can perform mutations (upgrade, buy coins, etc.).
The billing settings page has 5 tabs:
| Tab | Responsibility |
|---|---|
| Overview | Current plan badge, billing cycle, next billing date + amount, usage meters (posts/storage/seats/API keys), "Change Plan" and "Manage Subscription" buttons, trial/past-due banners. |
| Plans | Render pricing cards with Monthly/Yearly toggle. Show "Current Plan" badge. Show "Start Free Trial" or "Upgrade" CTA. Calls POST /billing/checkout → opens Razorpay modal inline (no redirect). After payment calls POST /billing/payment/verify. |
| Invoices | Fetch invoice list from Manager /billing/invoices (which proxies to Razorpay). Display date, amount, status, tax, PDF download link. |
| Coins & Add-ons | Display coin balance; coin pack purchase flow (→ Razorpay Order one-time payment); add-on marketplace; transaction history table. |
| Billing Info | Company Name + Tax ID (GST) editor. Saved to Razorpay Customer via Manager API. |
Limit Enforcement UI: Catch PLAN_LIMIT_REACHED errors → show upgrade modal or coin-purchase prompt. Show warning banners for storage > 95%, payment failures, trial ending.
2. Gateway (Edge)
The Gateway does not contain billing logic. Its billing role is purely enforcement:
- JWT Entitlement Check — Reads
services: { blog: { enabled: true } }from the JWT. If the tenant's subscription doesn't include a requested service, return403 Forbiddenimmediately. - Proxy — Forwards
/billing/*requests to the Manager service after auth validation.
3. Manager Service (Backend — Billing Domain)
The Manager is the single owner of all billing logic. It is the only service that talks to Stripe.
3a. Billing Router (API Layer)
| Endpoint | Method | Description |
|---|---|---|
/billing/plans |
GET | Return all public plans with features & pricing |
/billing/current |
GET | Return workspace's subscription state, coin balance, usage summary, and alerts |
/billing/checkout |
POST | Create a Stripe Checkout Session (subscription or coin purchase) |
/billing/portal |
POST | Create a Stripe Billing Portal session (card update, cancel) |
/billing/change-plan |
POST | Handle upgrades (immediate proration) and downgrades (end-of-period switch) |
/billing/switch-cycle |
POST | Switch billing between monthly ↔ yearly |
/billing/invoices |
GET | Fetch invoices from Stripe for the current tenant |
/billing/coins/balance |
GET | Return current coin balance from tenant_coins |
/billing/coins/transactions |
GET | Return paginated coin transaction history |
/billing/coins/buy |
POST | Create a Stripe Checkout Session for a one-time coin pack purchase |
/billing/addons |
GET | List active add-ons for the tenant |
/billing/addons/buy |
POST | Deduct coins and activate an add-on |
/billing/addons/cancel |
POST | Cancel a coin-based add-on |
/billing/info |
GET/PUT | Read/update company name & tax ID (synced to Stripe Customer) |
3b. Webhook Handler
Receives Stripe webhook events at a public, unauthed endpoint (validated via Stripe signature).
| Stripe Event | Action |
|---|---|
checkout.session.completed |
Create/update subscriptions row; rebuild tenant_services; credit coins (if coin purchase) |
invoice.payment_succeeded |
Update subscriptions.current_period_end; set status active |
invoice.payment_failed |
Set subscriptions.status = 'past_due'; trigger warning banner flag |
customer.subscription.updated |
Sync plan changes, trial status, billing cycle |
customer.subscription.deleted |
Set status canceled; downgrade tenant to Free; rebuild tenant_services |
3c. Entitlement Service (Internal Module)
The core reconciliation engine. Called after every subscription state change.
rebuildEntitlements(tenantId):
1. Read tenant's current plan from `subscriptions`
2. Load limits from `plan_service_limits` for that plan
3. Load active `tenant_addons` (coin-purchased extras)
4. Merge limits: plan_limits + addon_boosts = effective_limits
5. Upsert `tenant_services` rows with merged limits
6. Optionally: invalidate cached JWT so next token refresh picks up new entitlementsData Model
Building Level (Global)
plans
The pricing menu. Rows created by admin; read-only for tenants.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | free, starter, pro, business |
name |
text | Display name |
price_monthly |
integer | Cents (e.g., 2900 = $29) |
price_yearly |
integer | Cents (e.g., 28800 = $288/yr) |
yearly_discount_pct |
integer | Yearly discount shown on UI (e.g., 17 = "Save 17%"). Computed from prices, stored for easy display. |
stripe_price_id_monthly |
text | Stripe Price ID for monthly billing |
stripe_price_id_yearly |
text | Stripe Price ID for yearly billing |
max_seats_included |
integer | Seats bundled in the plan |
extra_seat_cost |
integer | Cents/seat/mo (0 if not allowed) |
is_public |
boolean | Shown on pricing page |
trial_days |
integer | 0 or 30 |
sort_order |
integer | Display order on pricing page |
services
Global catalog of platform services. Add a row whenever a new product launches.
| Column | Type | Notes |
|---|---|---|
code |
text (PK) | platform, blog, media, comms, chatbot, voice, newsletter, ads |
name |
text | Human-readable name (e.g., "Blog Engine") |
description |
text | Short description for admin panel |
is_active |
boolean | Feature-flag: if false, service is hidden from all plans |
created_at |
timestamp |
service_limits (NEW — defines limit keys per service)
Each service declares what limits it supports. This is the schema for customization — add rows here whenever a service introduces a new measurable resource.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | blog_posts, blog_storage_mb, comms_email_sends, chatbot_conversations, etc. |
service_code |
text (FK → services) | Which service this limit belongs to |
limit_key |
text | Machine-readable key: posts, storage_mb, email_sends, conversations |
display_name |
text | Human-readable: "Blog Posts", "Email Sends / month" |
unit |
text | count, mb, gb, per_month, boolean |
default_value |
integer | Fallback if not specified in plan_service_limits (typically 0) |
Example rows:
id service_code limit_key display_name unit default_value platform_seatsplatformseatsTeam Seats count 2 platform_api_keysplatformapi_keysAPI Keys count 1 platform_custom_rolesplatformcustom_rolesCustom Roles boolean 0 blog_postsblogpostsBlog Posts count 0 blog_storageblogstorage_mbBlog Storage mb 0 blog_custom_domainblogcustom_domainCustom Domain boolean 0 media_storagemediastorage_mbMedia Storage mb 0 comms_emailscommsemail_sendsEmail Sends / month per_month 0 chatbot_convoschatbotconversationsMonthly Conversations per_month 0 chatbot_agentschatbotagentsAI Agents count 0 voice_minutesvoicecall_minutesCall Minutes / month per_month 0
plan_service_limits (NEW — replaces old plan_features)
Maps plan × service × limit_key → value. This is the pricing configuration. Adding a new service to a plan = inserting rows here. No code changes.
| Column | Type | Notes |
|---|---|---|
plan_id |
text (PK, FK → plans) | |
service_code |
text (PK, FK → services) | |
limit_key |
text (PK, FK → service_limits.limit_key) | |
value |
integer | The limit value. -1 = unlimited. 1 = enabled (for booleans). 0 = disabled. |
Example: What Pro plan includes across all services
plan_id service_code limit_key value proplatformseats10 proplatformapi_keys10 proplatformcustom_roles1 (enabled) problogposts-1 (unlimited) problogstorage_mb25600 (25 GB) problogcustom_domain1 (enabled) promediastorage_mb25600 (25 GB) procommsemail_sends5000 prochatbotconversations1000 prochatbotagents3 provoicecall_minutes0 (not included) Example: Free plan (minimal)
plan_id service_code limit_key value freeplatformseats2 freeplatformapi_keys1 freeplatformcustom_roles0 (disabled) freeblogposts10 freeblogstorage_mb512 freeblogcustom_domain0 (disabled) freemediastorage_mb512 Example: Starter plan
plan_id service_code limit_key value starterplatformseats5 starterplatformapi_keys3 starterplatformcustom_roles0 (disabled) starterblogposts50 starterblogstorage_mb5120 (5 GB) starterblogcustom_domain0 (disabled) startermediastorage_mb5120 (5 GB) startercommsemail_sends1000 starterchatbotconversations100 starterchatbotagents1 startervoicecall_minutes0 (not included) Example: Business plan
plan_id service_code limit_key value businessplatformseats25 businessplatformapi_keys-1 (unlimited) businessplatformcustom_roles1 (enabled) businessblogposts-1 (unlimited) businessblogstorage_mb102400 (100 GB) businessblogcustom_domain1 (enabled) businessmediastorage_mb102400 (100 GB) businesscommsemail_sends25000 businesschatbotconversations-1 (unlimited) businesschatbotagents10 businessvoicecall_minutes500
How rebuildEntitlements Uses This
rebuildEntitlements(tenantId):
1. Read tenant's plan_id from subscriptions
2. SELECT * FROM plan_service_limits WHERE plan_id = :planId
→ gives all services + all limits for the plan
3. SELECT * FROM tenant_addons WHERE tenant_id = :tenantId AND status = 'active'
→ gives coin-purchased boosts
4. For each service_code:
effective_limits = plan_limits + addon_boosts
UPSERT tenant_services SET limits = effective_limits, enabled = true
5. For services NOT in the plan:
UPSERT tenant_services SET enabled = falseWhy This Design Scales
| Scenario | Action Required |
|---|---|
| Add a new service (e.g., Newsletter) | 1. INSERT into services. 2. INSERT limit definitions into service_limits. 3. INSERT values into plan_service_limits for each plan. Zero code changes. |
| Add a new limit to existing service (e.g., blog gets "scheduled_posts") | 1. INSERT into service_limits. 2. INSERT into plan_service_limits for each plan. Zero code changes. |
| Create a new plan (e.g., Enterprise) | 1. INSERT into plans. 2. INSERT rows into plan_service_limits for every service+limit. Zero code changes. |
| Custom/negotiated enterprise deal | INSERT custom plan_service_limits rows for that plan. Admin UI only. |
| Coin add-on boosts a specific limit | tenant_addons.addon_type maps to service_limits.limit_key. rebuildEntitlements merges them. |
Floor Level (Tenant-Scoped — every query MUST include WHERE tenant_id = ?)
subscriptions
The active contract between a tenant and Stripe.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | Stripe Subscription ID (sub_...) |
tenant_id |
text (FK → tenants) | CRITICAL |
plan_id |
text (FK → plans) | Current active plan |
status |
text | active, trialing, past_due, canceled |
billing_cycle |
text | monthly, yearly |
has_used_trial |
boolean | true after first trial. Prevents repeat trials. |
trial_end |
timestamp | NULL if no trial |
current_period_end |
timestamp | When the current billing cycle ends |
cancel_at_period_end |
boolean | True if a downgrade is scheduled |
pending_plan_id |
text | Plan to switch to at period end (downgrade) |
provider |
text | razorpay or stripe (for multi-provider future) |
razorpay_customer_id |
text | Razorpay Customer ID (current) |
razorpay_subscription_id |
text | Razorpay Subscription ID |
stripe_customer_id |
text | Stripe Customer ID (Phase 8, nullable for now) |
stripe_subscription_id |
text | Stripe Subscription ID (Phase 8, nullable for now) |
created_at |
timestamp | |
updated_at |
timestamp |
tenant_services
Source of truth for what a tenant can actually do.
| Column | Type | Notes |
|---|---|---|
tenant_id |
text (PK, FK → tenants) | |
service_code |
text (PK, FK → services) | |
enabled |
boolean | |
limits |
jsonb | Merged: plan base + coin boosts |
source |
text | plan, addon, manual_override |
tenant_coins
Coin wallet per tenant.
| Column | Type | Notes |
|---|---|---|
tenant_id |
text (PK, FK → tenants) | |
balance |
integer | Current coin count |
updated_at |
timestamp |
coin_transactions
Immutable ledger of all coin movements.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | |
tenant_id |
text (FK → tenants) | |
amount |
integer | Positive = credit, negative = debit |
balance_after |
integer | Wallet balance after this transaction |
reason |
text | purchase, addon_storage, addon_seat, refund |
description |
text | Human-readable label, e.g. "Purchased Medium Coin Pack" |
reference_id |
text | Stripe Payment ID or addon ID |
created_at |
timestamp |
tenant_addons
Active coin-purchased extras.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | |
tenant_id |
text (FK → tenants) | |
addon_type |
text | storage, seat, email_sends, custom_domain |
quantity |
integer | |
coin_cost |
integer | Monthly auto-deduction |
status |
text | active, paused |
next_renewal |
timestamp | When coins are next deducted |
processed_stripe_events → renamed to processed_payment_events
Idempotency guard — prevents duplicate webhook processing (provider-agnostic).
| Column | Type | Notes |
|---|---|---|
provider_event_id |
text (PK) | Provider Event ID (rzp_event_... or evt_...) |
provider |
text | razorpay or stripe |
event_type |
text | e.g., payment.captured, subscription.charged |
processed_at |
timestamp | When we processed this event |
Config Tables (No Tenant Scope)
coin_packs
Defines purchasable coin packs. Updated via admin/seed — not user-facing writes.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | small, medium, large |
name |
text | "Small Pack", "Medium Pack", etc. |
price_cents |
integer | USD cents: 500, 2000, 5000 |
coins |
integer | 500, 2200, 6000 |
bonus_pct |
integer | 0, 10, 20 |
stripe_price_id |
text | One-time Stripe Price ID |
is_active |
boolean | Feature-flag |
sort_order |
integer | Display order |
Why a table? Coin packs are business constants. Storing them in a table (vs hardcoding) lets admins add seasonal packs, adjust pricing, or deactivate packs without code deploys.
addon_catalog
Defines what add-ons can be purchased with coins.
| Column | Type | Notes |
|---|---|---|
id |
text (PK) | storage, seat, email_sends, blog_posts, custom_domain |
display_name |
text | "+1 GB Storage", "+1 Team Seat", etc. |
service_code |
text (FK → services) | Which service this boosts |
limit_key |
text (FK → service_limits) | Which limit key to boost |
coin_cost_per_unit |
integer | 100, 250, 50, 75, 500 |
unit_label |
text | "GB", "seat", "100 sends", "10 posts", "domain" |
is_recurring |
boolean | true = monthly coin deduction, false = one-time |
is_active |
boolean | Feature-flag |
Why a table? Same rationale as coin packs — new add-ons can be introduced by inserting rows, not writing code.
POST /billing/addons/buyreadsaddon_catalogto look up pricing and limit mappings.
Key Flows (Sequence)
Flow 1: Subscription Checkout
User clicks "Upgrade to Pro"
→ Dashboard POST /billing/checkout { planId: "pro", cycle: "yearly" }
→ Manager creates Stripe Checkout Session
price: plans.stripe_price_id_yearly
trial_period_days: 30 (if eligible)
automatic_tax: true
allow_promotion_codes: true
→ Returns session.url
→ Dashboard redirects to Stripe
Stripe collects payment / starts trial
→ Stripe POST /webhooks/stripe (checkout.session.completed)
→ Manager Webhook Handler:
1. Upsert `subscriptions` row (status: 'trialing' or 'active')
2. Update `tenants.plan_id`
3. Call rebuildEntitlements(tenantId)
→ User returns to Dashboard → sees "Pro Plan" badgeFlow 2: Coin Purchase & Add-on
User clicks "Buy Medium Pack (2,200 coins, $20)"
→ Dashboard POST /billing/coins/buy { pack: "medium" }
→ Manager creates Stripe Checkout Session (one-time, $20)
→ Redirect to Stripe → payment
Stripe POST /webhooks/stripe (checkout.session.completed, mode: 'payment')
→ Manager:
1. INSERT coin_transaction (+2200, reason: 'purchase')
2. UPDATE tenant_coins.balance += 2200
User clicks "Buy +5GB Storage (500 coins)"
→ Dashboard POST /billing/addons/buy { type: "storage", qty: 5 }
→ Manager:
1. Check tenant_coins.balance >= 500
2. INSERT coin_transaction (-500, reason: 'addon_storage')
3. UPDATE tenant_coins.balance -= 500
4. INSERT tenant_addons (type: 'storage', qty: 5, status: 'active')
5. Call rebuildEntitlements(tenantId) → storage limit += 5GBFlow 3: Payment Failure → Grace → Downgrade
Stripe auto-charge fails
→ Stripe POST /webhooks/stripe (invoice.payment_failed)
→ Manager:
1. SET subscriptions.status = 'past_due'
2. SET flag for sitewide warning banner
→ Dashboard shows ⚠️ banner with "Update Payment" link
Stripe retries over 7-day grace period...
If all retries fail:
→ Stripe POST /webhooks/stripe (customer.subscription.deleted)
→ Manager:
1. SET subscriptions.status = 'canceled'
2. SET tenants.plan_id = 'free'
3. Call rebuildEntitlements(tenantId) → Free limits applied
4. Coin-based addons set to 'paused'
→ Dashboard shows 🔴 downgrade banner
→ Soft enforcement: existing data preserved, new creation blockedFlow 4: Recurring Coin Add-on Renewal
CRON job or scheduled worker runs daily
→ For each tenant_addon WHERE status = 'active' AND next_renewal <= NOW():
1. Check tenant_coins.balance >= addon.coin_cost
2. If sufficient:
Deduct coins → INSERT coin_transaction → advance next_renewal by 30 days
3. If insufficient:
SET addon.status = 'paused'
Notify tenant: "Your +5GB storage add-on has been paused. Top up coins to reactivate."
Call rebuildEntitlements(tenantId) → remove addon boostLimit Enforcement Strategy
Backend (Services)
Every service endpoint that creates a resource checks entitlements:
async function checkLimit(tenantId: string, resource: string): Promise<void> {
const entitlement = await db.query.tenantServices.findFirst({
where: { tenantId, serviceCode: resourceToService(resource) }
});
const limit = entitlement?.limits?.[resource];
if (limit === undefined || limit === -1) return; // unlimited
const current = await countResource(tenantId, resource);
if (current >= limit) {
throw new PlanLimitError({
error: 'PLAN_LIMIT_REACHED',
resource,
limit,
current,
upgrade_url: `/dashboard/settings/billing`
});
}
}Frontend (Dashboard)
| Trigger | UI Response |
|---|---|
PLAN_LIMIT_REACHED error |
Modal with plan comparison + "Upgrade" / "Buy with Coins" CTAs |
| Storage > 95% | Persistent warning banner |
past_due subscription status |
Sitewide ⚠️ banner with "Update Payment" link |
| Trial ending in ≤ 3 days | Info banner: "Trial ends in X days" |
Security Considerations
| Concern | Mitigation |
|---|---|
| Webhook spoofing | Validate Stripe-Signature header using webhook secret. Reject unsigned requests. |
| Race conditions on coin deduction | Use SELECT ... FOR UPDATE or Postgres advisory locks on tenant_coins row. |
| Billing access control | Only owner role can mutate billing state. Other roles get read-only or no access (see Journey 12 in user_journeys.md). |
| Price tampering | Never accept price from the frontend. Always look up stripe_price_id from the plans table server-side. |
| Idempotency | Store Stripe event IDs; skip duplicate webhook deliveries. |
| PII in invoices | Company Name + Tax ID stored on Stripe Customer object; LogicSpike DB stores only the Stripe Customer ID reference. |
Payment Provider Object Mapping
Razorpay (Current)
| LogicSpike Concept | Razorpay Object |
|---|---|
| Tenant | Customer |
| Plan (monthly/yearly) | Plan (recurring interval) |
| Subscription | Subscription |
| Trial | trial_duration on Subscription |
| Coin Pack | Order (one-time, mode: payment) |
| Invoice | Invoice (auto-generated by Razorpay) |
| Payment verification | razorpay_signature HMAC verification |
| Failed payment | payment.failed event |
| Tax | GST configured in Razorpay Dashboard |
Stripe (Phase 8 — International)
| LogicSpike Concept | Stripe Object |
|---|---|
| Tenant | Customer |
| Plan (monthly/yearly) | Price (recurring) |
| Subscription | Subscription |
| Coin Pack | Price (one-time) + Checkout Session (mode: payment) |
| Invoice | Invoice (auto-generated by Stripe) |
| Promo Code | Promotion Code (managed in Stripe Dashboard) |
| Tax | Stripe Tax (automatic) |
| Update Payment | Billing Portal Session |
Webhook Event Normalization
The webhook handler translates provider-specific events into a normalized BillingEvent before processing:
| Normalized Event | Razorpay Event | Stripe Event |
|---|---|---|
SUBSCRIPTION_ACTIVATED |
subscription.activated |
checkout.session.completed (mode: subscription) |
SUBSCRIPTION_CHARGED |
subscription.charged |
invoice.payment_succeeded |
SUBSCRIPTION_PAYMENT_FAILED |
payment.failed |
invoice.payment_failed |
SUBSCRIPTION_CANCELLED |
subscription.cancelled |
customer.subscription.deleted |
SUBSCRIPTION_UPDATED |
subscription.pending |
customer.subscription.updated |
COIN_PAYMENT_CAPTURED |
payment.captured (order metadata: coinPack) |
checkout.session.completed (mode: payment) |
Service Placement Decision
Current: Billing lives inside Manager
Billing is implemented as a well-organized module inside the Manager service, not as a separate microservice.
Rationale
| Reason | Detail |
|---|---|
| Tenant coupling | Billing is deeply coupled to tenants, memberships, and entitlements — all owned by Manager. A separate service would create circular dependencies. |
| Shared database | Both would hit the same Postgres tables. Splitting adds a network hop without real isolation. |
| Infra overhead | Separate Worker, deployment pipeline, wrangler.toml, shared types package, inter-service auth — weeks of work before a single Stripe call. |
| Domain alignment | Existing services split by external domain (blog, media, comms). Billing is "platform" — it belongs with Manager. |
Module Structure (Inside Manager)
apps/manager/src/
routes/
billing.router.ts ← API endpoints (provider-agnostic)
services/
billing.service.ts ← Calls IPaymentProvider interface
entitlement.service.ts ← rebuildEntitlements()
coins.service.ts ← Coin balance + transactions
providers/
payment-provider.interface.ts ← IPaymentProvider interface
razorpay.provider.ts ← RazorpayProvider (current)
stripe.provider.ts ← StripeProvider (Phase 8, stub now)
webhooks/
razorpay.webhook.ts ← Razorpay webhook handler
stripe.webhook.ts ← Stripe webhook handler (Phase 8)
webhook.normalizer.ts ← Normalizes provider events → BillingEvent
utils/
razorpay-client.ts ← Razorpay SDK init + helpers
stripe-client.ts ← Stripe SDK init (Phase 8)Rule: All billing code MUST live inside these directories. No billing logic in auth, team, or settings routers. Billing routes call
IPaymentProvider— never call Razorpay or Stripe SDKs directly from a route. Limit-checking logic lives in@repo/core-billing(shared across all services).
Shared Packages (Monorepo)
Billing relies on 3 shared packages — one new, two updated:
@repo/core-billing (NEW)
Shared billing enforcement logic used by all services (Manager, Blog, Media, Communication).
packages/core-billing/src/
index.ts ← public exports
limit-guard.ts ← checkLimit(tenantId, serviceCode, limitKey)
billing.errors.ts ← PlanLimitReachedError, InsufficientCoinsError
billing.constants.ts ← LIMIT_KEYS, BILLING_EVENTS enum, PLAN_IDS| Export | Used By | Purpose |
|---|---|---|
checkLimit(tenantId, serviceCode, limitKey) |
Blog, Media, Manager, Comms | Reads tenant_services → returns { allowed, current, max } or throws PlanLimitReachedError |
PlanLimitReachedError |
All services | Consistent PLAN_LIMIT_REACHED error with { resource, currentUsage, limit, upgradeOptions } |
InsufficientCoinsError |
Manager | Error for coin debit failures |
LIMIT_KEYS |
All services + seed scripts | Single source of truth: blog.posts, blog.storage_mb, media.storage_mb, platform.seats, platform.api_keys, platform.custom_roles |
BILLING_EVENTS |
Manager webhooks + analytics | SUBSCRIPTION_ACTIVATED, SUBSCRIPTION_CHARGED, etc. |
What does NOT go here: Razorpay/Stripe SDK calls, webhook handlers, billing UI components. Those stay in Manager and seller-dashboard respectively.
@repo/core-types (UPDATE)
Add billing.ts with shared type definitions:
// packages/core-types/src/billing.ts
export type PaymentProvider = 'razorpay' | 'stripe'
export type BillingCycle = 'monthly' | 'yearly'
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
export interface Plan { id: string; name: string; priceMonthlyInr: number; ... }
export interface CoinTransaction { id: string; tenantId: string; amount: number; ... }
export interface NormalizedBillingEvent {
type: 'SUBSCRIPTION_ACTIVATED' | 'SUBSCRIPTION_CHARGED' | ...
provider: PaymentProvider
tenantId: string
payload: Record<string, any>
}Also update ServiceCode to add 'platform' | 'comms'.
@repo/core-access (UPDATE)
Add granular billing permissions to SYSTEM_ROLES:
// Add to role_admin permissions:
"billing:invoices.read", "billing:coins.read", "billing:addons.read",
"billing:info.read", "billing:plans.read"IPaymentProvider Interface
interface IPaymentProvider {
// Subscriptions
createSubscription(params: {
tenantId: string;
planId: string;
cycle: 'monthly' | 'yearly';
trialDays?: number;
}): Promise<{ providerId: string; customerId: string; status: string }>;
cancelSubscription(providerId: string): Promise<void>;
changePlan(params: {
subscriptionId: string;
newPlanId: string;
cycle: 'monthly' | 'yearly';
}): Promise<{ action: 'upgraded' | 'downgraded'; effective: 'immediate' | 'end_of_period' }>;
// One-time payments (coin packs)
createOrder(params: {
tenantId: string;
amount: number; // in smallest currency unit (paise for INR, cents for USD)
currency: string; // 'INR' or 'USD'
metadata: Record<string, string>;
}): Promise<{ orderId: string; amount: number; currency: string; keyId: string }>;
// Invoices
getInvoices(customerId: string, cursor?: string): Promise<ProviderInvoice[]>;
// Customer management
createCustomer(params: { name: string; email: string }): Promise<{ customerId: string }>;
updateCustomer(customerId: string, params: { name?: string; taxId?: string }): Promise<void>;
// Webhook
verifyWebhookSignature(payload: string, signature: string): boolean;
parseWebhookEvent(payload: string): NormalizedBillingEvent;
}Adding Stripe (Phase 8): Implement StripeProvider implements IPaymentProvider. Switch active provider via config flag. Zero changes to routes or business logic.
Future Extraction Plan (Manager → Billing Service)
When to Extract (Trigger Signals)
Do NOT extract proactively. Extract only when one or more of these signals appear:
| # | Signal | Why it matters |
|---|---|---|
| 1 | Manager bundle exceeds Cloudflare Worker size limits | Too much code in one deployment unit |
| 2 | Webhook processing causes latency in auth/team flows | Billing load degrades core platform responsiveness |
| 3 | Coin renewal CRON needs a long-running runtime | Workers have execution time limits; renewal jobs may need a different runtime (e.g., Node server, Durable Objects) |
| 4 | A dedicated billing team needs independent deploy cycles | Conway's Law — org structure should drive service boundaries |
| 5 | Stripe API calls hit rate limits | Isolating billing traffic prevents auth/team endpoints from being throttled |
Migration Steps
Phase 1 — Prepare (while still in Manager)
✅ All billing code already isolated in billing/ directories
✅ Billing service/router has zero imports from auth/team modules
✅ Shared types live in @repo/core-types (not Manager internals)
✅ Database queries use a dedicated billing query layer (not raw db calls scattered everywhere)
Phase 2 — Extract
1. Create apps/billing-service/ (Hono + Cloudflare Worker)
2. Move billing.router, billing.service, entitlement.service, coins.service, stripe.webhook
3. Update Gateway routing: /billing/* → billing-service (instead of Manager)
4. Stripe webhook endpoint URL updated in Stripe Dashboard
Phase 3 — Inter-Service Communication
1. Billing service reads tenant/membership data via internal API calls to Manager
OR shared DB access with billing-specific read models
2. Entitlement updates publish events to the Event Bus
→ Manager listens and invalidates JWT cache
3. Coin renewal CRON runs as a Cloudflare Durable Object or separate scheduled worker
Phase 4 — Verify
1. All billing endpoints respond identically (API contract tests)
2. Webhook processing works end-to-end
3. Manager has zero billing code remainingPreparation Checklist (Do These NOW)
Even while billing lives in Manager, follow these rules to make future extraction painless:
- No cross-imports — billing modules must NOT import from
auth.service,team.service, orsettings.servicedirectly. Pass tenant/user context via function parameters. - Shared types only — billing types (
Plan,Subscription,CoinTransaction) must be defined in@repo/core-types, not in Manager's local types. - Dedicated query layer — billing DB queries go through
billing.service.tsorcoins.service.ts, not scattered across other services. - Config isolation — Stripe keys, webhook secrets, and billing-specific env vars should be prefixed with
STRIPE_orBILLING_for easy extraction.
Coding Rules
Rule 1: Stripe is the Source of Truth for Money
- Never store price amounts in your DB and trust them for charging. Always reference
stripe_price_idfrom theplanstable. - Never accept price, discount, or coupon values from the frontend.
- Subscription status, period dates, and invoice data should be synced FROM Stripe via webhooks — not computed locally.
Rule 2: Idempotent Webhook Processing
// ✅ CORRECT — check before processing
const existing = await db.query.processedEvents.findFirst({
where: { stripeEventId: event.id }
});
if (existing) return; // Already processed, skip
// Process the event...
await db.insert(processedEvents).values({ stripeEventId: event.id });- Every webhook handler MUST be idempotent. Stripe retries failed deliveries.
- Store processed event IDs in a
processed_stripe_eventstable. - Use database transactions to ensure atomicity (event marked as processed + state change happen together).
Rule 3: Coin Operations Must Be Atomic
// ✅ CORRECT — use SELECT FOR UPDATE to prevent race conditions
await db.transaction(async (tx) => {
const wallet = await tx.query.tenantCoins.findFirst({
where: { tenantId },
forUpdate: true // Lock the row
});
if (wallet.balance < cost) throw new InsufficientCoinsError();
await tx.update(tenantCoins).set({ balance: wallet.balance - cost });
await tx.insert(coinTransactions).values({ amount: -cost, reason, tenantId });
});- Never deduct coins without a transaction + row lock.
- Never credit coins without a matching Stripe payment confirmation (webhook).
Concurrency Scenarios & Lock Ordering
| Scenario | Risk | Mitigation |
|---|---|---|
| Two browser tabs buy coin packs | Two webhooks fire near-simultaneously, both credit coins | Each webhook is idempotent via processed_stripe_events. Even if both run, each creates a unique checkout.session.completed event — no overcredit. |
| CRON renewal + manual coin purchase at same time | CRON locks tenant_coins for deduction while webhook tries to credit |
Both acquire SELECT FOR UPDATE on tenant_coins. One waits. No deadlock (single row, single lock target). |
| Addon cancellation during CRON renewal window | CRON renews and deducts coins for an addon the user just canceled | CRON must re-check tenant_addons.status inside the transaction after acquiring the lock. If status = 'paused', skip. |
| Two addon purchases at same time | Both check balance, both see sufficient, both deduct | SELECT FOR UPDATE serializes both — second one sees reduced balance and may fail with INSUFFICIENT_COINS. |
Lock ordering rule: Always lock tenant_coins first, then tenant_addons, then coin_transactions. Never reverse.
Rule 4: Entitlements Are Eventually Consistent
- After any plan/addon change, ALWAYS call
rebuildEntitlements(tenantId). - The JWT may carry stale entitlements until the next token refresh. This is acceptable — Gateway checks are "best effort", but the backend ALWAYS re-checks
tenant_servicesbefore allowing resource creation. - Never cache
tenant_serviceson the frontend for enforcement decisions.
Rule 5: Soft Enforcement Only
- NEVER delete user data on downgrade. Only block new creation.
- NEVER remove team members on seat limit reduction. Only prevent new invites.
- NEVER delete uploaded files when storage limit decreases. Only block new uploads.
- Existing data is sacred. Downgrades affect future actions, not past data.
Rule 6: Owner-Only Billing Mutations
- All
POST/PUT/DELETEbilling endpoints MUST checkmembership.is_owner === true. - Read-only endpoints (
GET /plans,GET /invoices) can be permission-gated via RBAC (billing:plans.read,billing:invoices.read). - Non-owners should see an "Access Denied" page, not a broken/empty UI.
Best Practices
Stripe Integration
| Practice | Detail |
|---|---|
| Use Stripe Checkout, not custom forms | Never handle raw card numbers. PCI compliance is Stripe's problem, not ours. |
| Use Billing Portal for card updates | Don't build a custom "update payment method" UI. Stripe Portal handles this securely. |
| Manage promos in Stripe Dashboard | Create Promotion Codes in Stripe, enable allow_promotion_codes: true at checkout. Zero backend code needed. |
| Use Stripe Tax from Day 1 | automatic_tax: { enabled: true } on every checkout session. Don't try to calculate tax yourself. |
| Keep Stripe objects lean | Store only stripe_customer_id and stripe_subscription_id in your DB. Fetch full details from Stripe API when needed. |
| Test with Stripe CLI | Use stripe listen --forward-to localhost:PORT/webhooks/stripe during local development to test webhooks. |
Error Handling
| Scenario | Response |
|---|---|
| Stripe API timeout | Retry with exponential backoff (max 3 attempts). Log the failure. Don't block the user — show "Processing, please wait". |
| Webhook signature invalid | Return 400 immediately. Log as potential attack. |
| Webhook for unknown tenant | Log warning, return 200 (so Stripe doesn't retry). Investigate manually. |
| Coin deduction fails mid-transaction | Rollback entire transaction. User sees "Something went wrong, please try again." |
| Plan downgrade with over-limit usage | Allow the downgrade. Show soft-wall warnings on next resource creation attempt. |
Webhook Disaster Recovery
If the Manager service is down for an extended period and Stripe exhausts webhook retries:
| Step | Action |
|---|---|
| 1. Detection | Monitor Stripe Dashboard → Developers → Webhooks → Failed events. Alert if failed count > 0. |
| 2. Replay | Use Stripe Dashboard to manually resend failed events, OR use Stripe CLI: stripe events resend evt_... |
| 3. Bulk reconciliation | Build a POST /billing/reconcile admin-only endpoint that: (a) lists all Stripe subscriptions for our account, (b) for each, syncs subscriptions table + calls rebuildEntitlements(). |
| 4. Prevention | Deploy webhook handler to a separate, always-on endpoint (e.g., Cloudflare Worker on its own subdomain) with minimal dependencies. |
Key principle: Because webhooks are idempotent, replaying them is always safe. The
processed_stripe_eventstable prevents double processing.
Testing Strategy
| Layer | Approach |
|---|---|
| Unit tests | Test rebuildEntitlements(), checkLimit(), coin math in isolation with mocked DB. |
| Integration tests | Test webhook handlers with Stripe's test event payloads. Verify DB state after processing. |
| Stripe test mode | All development and staging uses Stripe test API keys. Use test card numbers (4242 4242 4242 4242). |
| Edge cases to cover | Double-webhook delivery, expired trial transition, coin balance exactly zero, downgrade with pending addons, concurrent coin purchases. |
Monitoring & Alerts
| Metric | Alert Threshold |
|---|---|
| Webhook processing latency | > 5 seconds |
| Failed webhook deliveries | > 3 consecutive failures for same event |
past_due subscriptions |
Daily digest to admin |
| Coin balance goes negative (should never happen) | Immediate alert — indicates a race condition bug |
rebuildEntitlements failures |
Immediate alert — tenant may have wrong limits |
Future Considerations (v2+)
| Item | Notes |
|---|---|
| Usage-based billing | Meter API calls, email sends per month. Report to Stripe via Usage Records on metered prices. |
| Billing address + PO numbers | Extend Billing Info tab for enterprise invoicing. |
| Multi-currency pricing | Create locale-specific Stripe Prices instead of relying purely on conversion. |
| Dunning emails | Configure Stripe's Smart Retries + custom email templates via Communication service. |
| Revenue analytics | Stripe Revenue Recognition + internal dashboard for MRR, churn, LTV. |
| Enterprise / Custom plans | Admin-only is_public: false plans with custom pricing negotiated per-deal. |
| Billing service extraction | See "Future Extraction Plan" section above. Only when trigger signals appear. |