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 7Phases 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
- Subscribe to:
- 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
planstable - 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— withyearly_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— withhas_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
planswith Free / Starter / Pro / Business (include Razorpay Plan IDs from Phase 0, Stripe IDs nullable) - Seed
serviceswithplatform,blog,media,comms - Seed
service_limitswith all limit keys per service - Seed
plan_service_limitswith all plan × limit values (matching pricing table in user_journeys.md) — all 4 plans (Free, Starter, Pro, Business) × all limit keys - Seed
coin_packswith Small (₹415/500 coins), Medium (₹1,660/2,200 coins), Large (₹4,150/6,000 coins) - Seed
addon_catalogwith 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
Subscriptioninterface with new fields (billingCycle,hasUsedTrial,trialEnd,provider, etc.) - Add
'platform' | 'comms'toServiceCodetype - 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/withpackage.json,tsconfig.json - Create
src/billing.constants.ts—LIMIT_KEYS,BILLING_EVENTSenum,PLAN_IDS - Create
src/billing.errors.ts—PlanLimitReachedError,InsufficientCoinsError - Create
src/limit-guard.ts— stubcheckLimit()(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.tsoronboarding.ts):- On new tenant → INSERT
subscriptions(plan:free, status:active) - Call
rebuildEntitlements(tenantId)→ populatetenant_serviceswith Free limits - INSERT
tenant_coins(balance: 0)
- On new tenant → INSERT
Done When
pnpm drizzle-kit pushruns without errorsGET /billing/plansreturns 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
razorpaynpm SDK in Manager service - Create
utils/razorpay-client.ts— Razorpay SDK init - Create
providers/payment-provider.interface.ts—IPaymentProviderinterface - Create
providers/razorpay.provider.ts— ImplementsIPaymentProvider:-
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)→ returnsNormalizedBillingEvent
-
- Create
providers/stripe.provider.ts— Stub only (Phase 8) - Create
webhooks/webhook.normalizer.ts— maps Razorpay events →BillingEvent - Create
services/billing.service.ts— callsIPaymentProvider, not Razorpay SDK directly - Create
routes/billing.router.ts:-
POST /billing/checkout→ callscreateSubscription, 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 modalPOST /billing/payment/verifywith 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-Signatureheader - Check
processed_payment_eventsfor idempotency - Normalize event via
webhook.normalizer.ts - Route by normalized
BillingEventtype:
- Verify
| 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):- Read plan from
subscriptions - Query
plan_service_limitsfor that plan - Query active
tenant_addons - Merge limits (plan + addons)
- Upsert all
tenant_servicesrows - Disable services not in plan
- Read plan from
-
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.activatedwebhook fires - Trigger subscription charge → verify
subscription.chargedwebhook fires - Verify
subscriptions+tenant_servicesrows created correctly - Verify idempotency: send same event twice → no duplicate processing
Done When
- Complete checkout → webhook fires →
subscriptionscreated →tenant_servicesrebuilt → 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 viaIPaymentProvider.cancelSubscription(). Setscancel_at_period_end = true. -
GET /billing/invoices— fetch invoices from Razorpay viaIPaymentProvider.getInvoices(razorpay_customer_id)- Return:
[{ id, amount, status, date, tax, pdf_url }]
- Return:
-
POST /billing/change-plan— handle upgrades and downgrades viaIPaymentProvider.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_idviaIPaymentProvider
- Update subscription's plan to the alternate
-
GET /billing/info— return tenant's company name + tax ID (GST) -
PUT /billing/info— update company name + GST on Razorpay Customer viaIPaymentProvider.updateCustomer()
Note:
POST /billing/portalis 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)— callsIPaymentProvider.createOrder()to create Razorpay Order (one-time payment) -
creditCoins(tenantId, amount, razorpayPaymentId)— called by webhook afterCOIN_PAYMENT_CAPTUREDevent -
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), inserttenant_addons, callrebuildEntitlements() -
POST /billing/addons/cancel— set addon status topaused, callrebuildEntitlements() -
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_renewalby 30 days - If insufficient coins: set
status = 'paused', notify tenant, callrebuildEntitlements()
- Query
Done When
- Buy coins → Razorpay payment →
COIN_PAYMENT_CAPTUREDwebhook → coins credited - Buy add-on → coins deducted atomically →
tenant_serviceslimits 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/plansresponse - Monthly / Yearly toggle — shows
yearly_discount_pctas "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-revalidatepattern (SWR/React Query) for billing data - After Razorpay modal closes +
payment/verifysucceeds, 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_viewedwhen Plans tab opened - Fire
billing.checkout_startedwhen Razorpay modal opens - Fire
billing.plan_upgraded/billing.plan_downgradedon plan change confirmation - Fire
billing.trial_startedon trial activation - Fire
billing.coins_purchasedwith pack info on coin purchase - Fire
billing.addon_purchasedwith 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.limitsfor the given tenant + service - Queries current usage count (e.g., count of posts, sum of storage)
- Returns
{ allowed: boolean, current: number, max: number }or throwsPlanLimitReachedError
- Reads
- Add
@repo/core-billingas 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_REACHEDresponses - 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_limitsvalues 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 neededThis 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 toBillingEvent
-
- Implement
webhooks/stripe.webhook.ts(currently stubbed) - Add routing logic: INR customers → Razorpay, USD customers → Stripe
- Update
planstable withstripe_price_id_monthly+stripe_price_id_yearly - Enable
POST /billing/portalfor 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
IPaymentProviderinterface 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 |