logicspike/docs

Billing

Billing & Subscription — Implementation Plan

Phased build order for the LogicSpike billing system. Each phase is self-contained and deployable.

Docs: user_journeys.md · architecture.md · razorpay_setup_guide.md · schema_compatibility.md · stripe_setup_guide.md (Phase 8)


Phase Overview

Phase Name Depends On Outcome
0 Razorpay Setup Razorpay account configured, test keys ready
1 Foundation (DB + Plans API) Phase 0 Billing tables exist, plans queryable via API
2 Subscription Checkout Phase 1 Workspace owners can subscribe via Razorpay modal
3 Webhooks + Entitlements Phase 2 Razorpay events sync to DB, limits enforced
4 Billing Portal + Invoices Phase 3 Users can cancel, view invoices, change plans
5 Coins & Add-ons Phase 3 Coin top-up, add-on marketplace, recurring renewal
6 Dashboard UI (Billing Settings) Phase 4+5 Full billing settings page with all 5 tabs
7 Limit Enforcement (All Services) Phase 3 Soft walls across blog, media, team, API keys
8 Stripe Integration (Future) Phase 1-4 International billing via StripeProvider
Phase 0 → Phase 1 → Phase 2 → Phase 3 ─┬→ Phase 4 ─┬→ Phase 6
                                         ├→ Phase 5 ─┘
                                         └→ Phase 7

Phases 4, 5, and 7 can be built in parallel after Phase 3 is complete.


Phase 0 — Razorpay Setup

Goal: Configure Razorpay so the backend has everything it needs. See razorpay_setup_guide.md for step-by-step instructions.

Tasks

  • Create / log into Razorpay account (Test Mode)
  • Collect API Keys (Key ID + Key Secret)
  • Configure Business Profile (name, logo, support email)
  • Create Subscription Plans (Starter/Pro/Business × Monthly/Yearly) — 6 plans total
  • Configure Checkout settings (UPI, Card, Net Banking, logo, brand color)
  • Register Webhook endpoint URL: https://api.logicspike.com/webhooks/razorpay
    • Subscribe to: subscription.activated, subscription.charged, subscription.cancelled, payment.captured, payment.failed
  • Configure GST settings
  • Copy to .env.local:
    • RAZORPAY_KEY_ID
    • RAZORPAY_KEY_SECRET
    • RAZORPAY_WEBHOOK_SECRET

Done When

  • All 6 Razorpay Plan IDs are recorded and ready to insert into plans table
  • Webhook endpoint is registered with 5 events
  • Test keys are in .env.local

Phase 1 — Foundation (Database + Plans API)

Goal: Billing tables exist. Plans are queryable. Free plan auto-provisioned on workspace creation.

Backend Tasks

1a. Database Schema (Drizzle Migrations)

Create all billing tables in packages/core-database:

  • plans — with yearly_discount_pct, Razorpay Plan IDs, Stripe Price IDs (nullable), seat config
  • services — global catalog (blog, media, comms, platform)
  • service_limits — limit definitions per service (blog.posts, blog.storage_mb, etc.)
  • plan_service_limits — plan × service × limit_key → value
  • subscriptions — with has_used_trial, billing_cycle, pending_plan_id
  • tenant_services — source of truth for tenant entitlements
  • tenant_coins — coin wallet
  • coin_transactions — immutable ledger
  • tenant_addons — active coin-purchased extras
  • processed_payment_events — idempotency guard (provider-agnostic)
  • coin_packs — purchasable coin pack definitions (small/medium/large)
  • addon_catalog — add-on definitions with coin pricing and limit mappings

1b. Seed Data

  • Seed plans with Free / Starter / Pro / Business (include Razorpay Plan IDs from Phase 0, Stripe IDs nullable)
  • Seed services with platform, blog, media, comms
  • Seed service_limits with all limit keys per service
  • Seed plan_service_limits with all plan × limit values (matching pricing table in user_journeys.md) — all 4 plans (Free, Starter, Pro, Business) × all limit keys
  • Seed coin_packs with Small (₹415/500 coins), Medium (₹1,660/2,200 coins), Large (₹4,150/6,000 coins)
  • Seed addon_catalog with storage, seat, email_sends, blog_posts, custom_domain

1c. Shared Packages

@repo/core-types — add billing.ts:

  • Create packages/core-types/src/billing.ts:
    • PaymentProvider = 'razorpay' | 'stripe'
    • BillingCycle = 'monthly' | 'yearly'
    • SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
    • Plan, CoinTransaction, TenantAddon, ServiceLimit, PlanServiceLimit, CoinPack, AddonCatalog
    • NormalizedBillingEvent (type union for webhook events)
  • Update existing Subscription interface with new fields (billingCycle, hasUsedTrial, trialEnd, provider, etc.)
  • Add 'platform' | 'comms' to ServiceCode type
  • Export from index.ts: export * from "./billing"

@repo/core-access — add billing permissions:

  • Add granular billing permissions to SYSTEM_ROLES.role_admin: billing:invoices.read, billing:coins.read, billing:addons.read, billing:info.read, billing:plans.read

@repo/core-billing — new package (scaffold now, implement in Phase 7):

  • Create packages/core-billing/ with package.json, tsconfig.json
  • Create src/billing.constants.tsLIMIT_KEYS, BILLING_EVENTS enum, PLAN_IDS
  • Create src/billing.errors.tsPlanLimitReachedError, InsufficientCoinsError
  • Create src/limit-guard.ts — stub checkLimit() (implemented in Phase 7)
  • Create src/index.ts — export all

1d. Plans API

  • GET /billing/plans — returns all public plans with their service limits, formatted for the pricing UI
  • GET /billing/current — returns workspace's subscription state, coin balance, usage summary, and alerts
  • Response: { plans: [{ id, name, price_monthly, price_yearly, yearly_discount_pct, services: { blog: { posts: 50, storage_mb: 5120 }, ... } }] }

1e. Free Plan Auto-Provisioning

  • Update workspace creation flow (in tenants.ts or onboarding.ts):
    • On new tenant → INSERT subscriptions (plan: free, status: active)
    • Call rebuildEntitlements(tenantId) → populate tenant_services with Free limits
    • INSERT tenant_coins (balance: 0)

Done When

  • pnpm drizzle-kit push runs without errors
  • GET /billing/plans returns all 4 plans with correct limits
  • Creating a new workspace auto-provisions Free plan + entitlements
  • Types importable from @repo/core-types

Phase 2 — Subscription Checkout

Goal: Workspace owners can upgrade to a paid plan via Razorpay modal checkout.

Backend Tasks

  • Install razorpay npm SDK in Manager service
  • Create utils/razorpay-client.ts — Razorpay SDK init
  • Create providers/payment-provider.interface.tsIPaymentProvider interface
  • Create providers/razorpay.provider.ts — Implements IPaymentProvider:
    • createSubscription(tenantId, planId, cycle, trialDays) — creates Razorpay subscription
    • createOrder(tenantId, amount, currency, metadata) — creates Razorpay order for coin packs
    • changePlan(subscriptionId, newPlanId, cycle)
    • cancelSubscription(subscriptionId)
    • getInvoices(customerId, cursor)
    • verifyWebhookSignature(payload, signature)
    • parseWebhookEvent(payload) → returns NormalizedBillingEvent
  • Create providers/stripe.provider.tsStub only (Phase 8)
  • Create webhooks/webhook.normalizer.ts — maps Razorpay events → BillingEvent
  • Create services/billing.service.ts — calls IPaymentProvider, not Razorpay SDK directly
  • Create routes/billing.router.ts:
    • POST /billing/checkout → calls createSubscription, returns { provider, subscription_id, key_id, ... }
    • POST /billing/payment/verify → verifies Razorpay HMAC signature, activates subscription
  • Add Gateway proxy route: /billing/* → Manager

Done When

  • POST /billing/checkout { planId: "pro", cycle: "monthly" } returns { provider: "razorpay", subscription_id, key_id } — frontend can open Razorpay modal
  • POST /billing/payment/verify with valid HMAC returns { verified: true }
  • Gateway correctly proxies /billing/* to Manager

Phase 3 — Webhooks + Entitlements

Goal: Razorpay webhook events sync to our DB. tenant_services automatically rebuilt on plan changes.

Backend Tasks

3a. Webhook Handler

  • Create webhooks/razorpay.webhook.ts:
    • Verify X-Razorpay-Signature header
    • Check processed_payment_events for idempotency
    • Normalize event via webhook.normalizer.ts
    • Route by normalized BillingEvent type:
Normalized Event Handler Logic
SUBSCRIPTION_ACTIVATED Read metadata.tenantId + metadata.planId. Upsert subscriptions. Set has_used_trial = true if trial. Update tenants.plan_id. Call rebuildEntitlements().
SUBSCRIPTION_CHARGED Update current_period_end. Set status active.
SUBSCRIPTION_PAYMENT_FAILED Set status past_due.
SUBSCRIPTION_UPDATED Sync plan_id, status, billing_cycle, trial_end, cancel_at_period_end. Call rebuildEntitlements().
SUBSCRIPTION_CANCELLED Set status canceled. Set tenants.plan_id = 'free'. Call rebuildEntitlements().
COIN_PAYMENT_CAPTURED Credit coins to tenant_coins. Insert coin_transactions.
  • Register webhook route: POST /webhooks/razorpay (public, no auth middleware)

3b. Entitlement Service

  • Create services/entitlement.service.ts:
    • rebuildEntitlements(tenantId):
      1. Read plan from subscriptions
      2. Query plan_service_limits for that plan
      3. Query active tenant_addons
      4. Merge limits (plan + addons)
      5. Upsert all tenant_services rows
      6. Disable services not in plan

Testing

  • Use Razorpay Test Mode with test card 4111 1111 1111 1111 (see razorpay_setup_guide.md for test cards)
  • Create test subscription → verify subscription.activated webhook fires
  • Trigger subscription charge → verify subscription.charged webhook fires
  • Verify subscriptions + tenant_services rows created correctly
  • Verify idempotency: send same event twice → no duplicate processing

Done When

  • Complete checkout → webhook fires → subscriptions created → tenant_services rebuilt → correct limits in DB
  • Payment failure → status changes to past_due
  • Subscription canceled → downgraded to Free → entitlements rebuilt
  • Double webhook delivery → no duplicate rows

Phase 4 — Subscription Management + Invoices + Plan Changes

Goal: Users can cancel subscription, view invoices, and change plans. Custom cancel/management UI (Razorpay lacks a portal equivalent).

Backend Tasks

  • POST /billing/cancel — cancel subscription via IPaymentProvider.cancelSubscription(). Sets cancel_at_period_end = true.
  • GET /billing/invoices — fetch invoices from Razorpay via IPaymentProvider.getInvoices(razorpay_customer_id)
    • Return: [{ id, amount, status, date, tax, pdf_url }]
  • POST /billing/change-plan — handle upgrades and downgrades via IPaymentProvider.changePlan():
    • Upgrade: Razorpay creates new subscription at higher plan. Old one cancelled at period end. Immediate effect on entitlements.
    • Downgrade: Set cancel_at_period_end = true, pending_plan_id. Webhook handles actual switch at period end.
  • POST /billing/switch-cycle — switch between monthly ↔ yearly:
    • Update subscription's plan to the alternate razorpay_plan_id via IPaymentProvider
  • GET /billing/info — return tenant's company name + tax ID (GST)
  • PUT /billing/info — update company name + GST on Razorpay Customer via IPaymentProvider.updateCustomer()

Note: POST /billing/portal is Stripe Phase 8 only (Stripe Customer Portal). For Razorpay, cancel/management is handled via custom UI.

Done When

  • Cancel subscription works — sets cancel_at_period_end = true, confirmed at period end via webhook
  • Invoices listed with download PDF link
  • Upgrade triggers new subscription at higher plan
  • Downgrade schedules change at period end
  • Billing info saved to Razorpay Customer

Phase 5 — Coins & Add-ons

Goal: Workspace owners can buy coins and purchase add-ons with them.

Backend Tasks

5a. Coin Purchase

  • Create services/coins.service.ts:
    • buyCoinPack(tenantId, pack) — calls IPaymentProvider.createOrder() to create Razorpay Order (one-time payment)
    • creditCoins(tenantId, amount, razorpayPaymentId) — called by webhook after COIN_PAYMENT_CAPTURED event
    • getBalance(tenantId) — returns current coin balance
    • getTransactions(tenantId, page) — returns paginated coin transaction history
  • Update webhook handler: COIN_PAYMENT_CAPTURED → credit coins + record transaction
  • Routes:
    • GET /billing/coins/balance
    • GET /billing/coins/transactions
    • POST /billing/coins/buy

5b. Add-on Marketplace

  • POST /billing/addons/buy — deduct coins atomically (SELECT FOR UPDATE), insert tenant_addons, call rebuildEntitlements()
  • POST /billing/addons/cancel — set addon status to paused, call rebuildEntitlements()
  • GET /billing/addons — list active add-ons for the tenant

5c. Recurring Add-on Renewal (CRON)

  • Create scheduled worker or Cloudflare Cron Trigger:
    • Query tenant_addons WHERE status = 'active' AND next_renewal <= NOW()
    • For each: attempt coin deduction → advance next_renewal by 30 days
    • If insufficient coins: set status = 'paused', notify tenant, call rebuildEntitlements()

Done When

  • Buy coins → Razorpay payment → COIN_PAYMENT_CAPTURED webhook → coins credited
  • Buy add-on → coins deducted atomically → tenant_services limits increased
  • CRON renews active add-ons monthly
  • Insufficient coins → add-on paused → limits reduced

Phase 6 — Dashboard UI (Billing Settings Page)

Goal: Full billing settings page at /dashboard/settings/billing with 5 tabs.

Frontend Tasks

Tab 1 — Overview

  • Current plan badge + billing cycle
  • Next billing date + amount (from subscriptions)
  • Usage meters (posts, storage, members, API keys — from tenant_services + current counts)
  • "Change Plan" button → plan comparison modal with Monthly/Yearly toggle
  • "Cancel Subscription" button → confirmation modal → POST /billing/cancel
  • Trial banner: "Trial ends in X days" (if status === 'trialing')
  • Past-due banner: ⚠️ "Payment failed" with "Update Payment" link

Tab 2 — Plans

  • Pricing cards rendered from GET /billing/plans response
  • Monthly / Yearly toggle — shows yearly_discount_pct as "Save X%" badge
  • Current plan highlighted
  • "Start Free Trial" vs "Upgrade" CTA logic (based on has_used_trial)
  • Upgrade → POST /billing/checkout → open Razorpay modal → POST /billing/payment/verify
  • Downgrade → warning modal → POST /billing/change-plan

Tab 3 — Invoices

  • Table: Date, Amount, Status, Tax, PDF Download
  • Data from GET /billing/invoices

Tab 4 — Coins & Add-ons

  • Coin balance display (workspace-level)
  • "Buy Coins" → coin pack selector → POST /billing/coins/buy → Razorpay Order modal
  • Active add-ons list
  • "Buy Extra" → add-on marketplace → POST /billing/addons/buy
  • Transaction history table (from GET /billing/coins/transactions)

Tab 5 — Billing Info

  • Company Name (editable)
  • Tax ID / VAT / GST (editable)
  • Save → PUT /billing/info

Access Control

  • membership.is_owner → full access
  • billing:invoices.read → Tab 3 only (read-only)
  • billing:plans.read → Tab 2 only (read-only)
  • No permissions → Access Denied page

Done When

  • All tabs render with real data
  • Upgrade / downgrade / trial flows work end-to-end
  • Non-owners see appropriate access restrictions

Frontend State Management

  • Use stale-while-revalidate pattern (SWR/React Query) for billing data
  • After Razorpay modal closes + payment/verify succeeds, invalidate billing query cache to force refetch
  • Sidebar plan badge reads from billing cache — auto-updates when cache refreshes
  • Do NOT use optimistic updates for billing mutations — wait for payment/verify + webhook confirmation
  • Show loading skeleton on billing tabs during initial fetch

Analytics Events

  • Fire billing.plan_viewed when Plans tab opened
  • Fire billing.checkout_started when Razorpay modal opens
  • Fire billing.plan_upgraded / billing.plan_downgraded on plan change confirmation
  • Fire billing.trial_started on trial activation
  • Fire billing.coins_purchased with pack info on coin purchase
  • Fire billing.addon_purchased with addon type on add-on purchase

Phase 7 — Limit Enforcement Across All Services

Goal: Every resource-creating endpoint checks tenant_services limits and returns PLAN_LIMIT_REACHED.

Backend Tasks

  • Implement checkLimit(tenantId, serviceCode, limitKey) in @repo/core-billing/src/limit-guard.ts:
    • Reads tenant_services.limits for the given tenant + service
    • Queries current usage count (e.g., count of posts, sum of storage)
    • Returns { allowed: boolean, current: number, max: number } or throws PlanLimitReachedError
  • Add @repo/core-billing as a dependency to Blog, Media, Manager, Communication services
  • Integrate checkLimit() into services:
Service Endpoints to Guard Limit Key
Blog Service POST /posts blog.posts
Blog Service All write endpoints blog.storage_mb (check total storage)
Media Service POST /upload media.storage_mb
Manager POST /invitations platform.seats
Manager POST /api-keys platform.api_keys
Manager POST /custom-roles platform.custom_roles

Frontend Tasks

  • Global error interceptor catches PLAN_LIMIT_REACHED responses
  • Upgrade prompt modal:
    • Shows which limit was hit
    • Plan comparison (current vs upgrade options)
    • "Upgrade Plan" CTA → billing page
    • "Buy with Coins" CTA → coins tab (if applicable)
  • Storage warning banner at > 95% usage

Done When

  • Creating 11th post on Free plan → blocked with upgrade prompt
  • Inviting 3rd member on Free → blocked
  • Storage at 100% → upload blocked
  • All limits match plan_service_limits values exactly

Suggested Build Order (Timeline)

Week 1:  Phase 0 (Razorpay Setup) + Phase 1 (DB + Plans API)
Week 2:  Phase 2 (Checkout) + Phase 3 (Webhooks + Entitlements)
Week 3:  Phase 4 (Portal + Invoices) + Phase 5 (Coins) — in parallel
Week 4:  Phase 6 (Dashboard UI)
Week 5:  Phase 7 (Limit Enforcement) + Testing + Polish
Future:  Phase 8 (Stripe / International) — when needed

This is a rough estimate. Phase 3 (webhooks) is the most complex and may take extra time to test thoroughly.


Phase 8 — Stripe Integration (Future / International)

Goal: Add Stripe as a second payment provider for international customers using the existing IPaymentProvider abstraction.

Trigger conditions (add Stripe when one of these is true):

  • Product expands internationally (non-INR customers)
  • Enterprise customers require USD/EUR billing
  • Need Stripe Customer Portal for self-serve subscription management

Tasks

  • Complete Stripe setup per stripe_setup_guide.md
  • Implement providers/stripe.provider.ts (currently stubbed):
    • createSubscription() → Stripe Checkout Session (mode: subscription)
    • createOrder() → Stripe Checkout Session (mode: payment)
    • createPortalSession() → Stripe Customer Portal
    • getInvoices()stripe.invoices.list()
    • verifyWebhookSignature()stripe.webhooks.constructEvent()
    • parseWebhookEvent() → normalize Stripe events to BillingEvent
  • Implement webhooks/stripe.webhook.ts (currently stubbed)
  • Add routing logic: INR customers → Razorpay, USD customers → Stripe
  • Update plans table with stripe_price_id_monthly + stripe_price_id_yearly
  • Enable POST /billing/portal for Stripe customers (no-op for Razorpay)

Done When

  • International customers can subscribe via Stripe Checkout
  • Stripe webhooks sync correctly to DB
  • Both providers work concurrently for different workspaces

Zero architectural changes required. The IPaymentProvider interface ensures Stripe slots in without touching billing routes, entitlement service, or DB schema.


Risk Register

Risk Impact Mitigation
Razorpay webhook delivery failures Subscription status out of sync Razorpay auto-retries + idempotent processing via processed_payment_events + manual reconciliation endpoint
Race conditions on coin deduction Negative balance, double spend SELECT FOR UPDATE + DB transactions on every coin debit
Worker bundle size limit Can't deploy Manager See Future Extraction Plan in architecture.md
Stale JWT entitlements User accesses features after downgrade Backend always re-checks tenant_services (JWT is optimization, not enforcement)
Tax / GST errors Legal/compliance issues Use Razorpay GST config exclusively — never compute locally. Stripe Tax when Stripe added (Phase 8).
planFeatures deprecation Breaking existing queries Grep codebase first (grep -r "planFeatures" apps/ packages/). Keep alive during transition → see schema_compatibility.md
plans.priceMonthly in USD cents vs INR paise Wrong amounts shown Add separate price_monthly_inr column. Keep priceMonthly for Stripe Phase 8 → see schema_compatibility.md
Payment signature forgery Fraudulent subscription activations Always verify X-Razorpay-Signature with HMAC-SHA256 before processing any webhook event
Billing