logicspike/docs

Billing

Billing & Subscription — Architecture

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:

  1. JWT Entitlement Check — Reads services: { blog: { enabled: true } } from the JWT. If the tenant's subscription doesn't include a requested service, return 403 Forbidden immediately.
  2. 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 entitlements

Data 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_seats platform seats Team Seats count 2
platform_api_keys platform api_keys API Keys count 1
platform_custom_roles platform custom_roles Custom Roles boolean 0
blog_posts blog posts Blog Posts count 0
blog_storage blog storage_mb Blog Storage mb 0
blog_custom_domain blog custom_domain Custom Domain boolean 0
media_storage media storage_mb Media Storage mb 0
comms_emails comms email_sends Email Sends / month per_month 0
chatbot_convos chatbot conversations Monthly Conversations per_month 0
chatbot_agents chatbot agents AI Agents count 0
voice_minutes voice call_minutes Call 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
pro platform seats 10
pro platform api_keys 10
pro platform custom_roles 1 (enabled)
pro blog posts -1 (unlimited)
pro blog storage_mb 25600 (25 GB)
pro blog custom_domain 1 (enabled)
pro media storage_mb 25600 (25 GB)
pro comms email_sends 5000
pro chatbot conversations 1000
pro chatbot agents 3
pro voice call_minutes 0 (not included)

Example: Free plan (minimal)

plan_id service_code limit_key value
free platform seats 2
free platform api_keys 1
free platform custom_roles 0 (disabled)
free blog posts 10
free blog storage_mb 512
free blog custom_domain 0 (disabled)
free media storage_mb 512

Example: Starter plan

plan_id service_code limit_key value
starter platform seats 5
starter platform api_keys 3
starter platform custom_roles 0 (disabled)
starter blog posts 50
starter blog storage_mb 5120 (5 GB)
starter blog custom_domain 0 (disabled)
starter media storage_mb 5120 (5 GB)
starter comms email_sends 1000
starter chatbot conversations 100
starter chatbot agents 1
starter voice call_minutes 0 (not included)

Example: Business plan

plan_id service_code limit_key value
business platform seats 25
business platform api_keys -1 (unlimited)
business platform custom_roles 1 (enabled)
business blog posts -1 (unlimited)
business blog storage_mb 102400 (100 GB)
business blog custom_domain 1 (enabled)
business media storage_mb 102400 (100 GB)
business comms email_sends 25000
business chatbot conversations -1 (unlimited)
business chatbot agents 10
business voice call_minutes 500

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 = false

Why 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/buy reads addon_catalog to 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" badge

Flow 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 += 5GB

Flow 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 blocked

Flow 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 boost

Limit 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 remaining

Preparation 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, or settings.service directly. 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.ts or coins.service.ts, not scattered across other services.
  • Config isolation — Stripe keys, webhook secrets, and billing-specific env vars should be prefixed with STRIPE_ or BILLING_ 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_id from the plans table.
  • 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_events table.
  • 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_services before allowing resource creation.
  • Never cache tenant_services on 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/DELETE billing endpoints MUST check membership.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_events table 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.
Billing