Cross-reference of packages/core-database/src/schema.ts vs billing architecture docs.
Docs: architecture.md · implementation_plan.md · razorpay_setup_guide.md
Existing Schema — What's Already There
| Table | Status | Notes |
|---|---|---|
plans |
⚠️ Needs columns added | Missing: priceYearly, yearlyDiscountPct, trialDays, Razorpay plan IDs, seat config |
planFeatures |
🔴 Replace entirely | Uses jsonb limits blob — billing requires a normalized plan_service_limits table with one row per limit key |
subscriptions |
⚠️ Needs columns added | Missing: billingCycle, hasUsedTrial, trialEnd, cancelAtPeriodEnd, pendingPlanId, provider, razorpayCustomerId, razorpaySubscriptionId, stripeCustomerId (nullable) |
services |
✅ Compatible | Already has code PK + name. No changes needed. |
tenantServices |
⚠️ Needs 1 column | Already has limits jsonb + enabled + source. Missing: updatedAt timestamp. |
tenants |
✅ Compatible | Already has planId FK. No changes to existing columns. |
memberships |
✅ Compatible | isOwner boolean already present — billing RBAC can use it directly. |
Tables to ADD (New — Zero Breaking Risk)
| Table | Risk | Notes |
|---|---|---|
service_limits |
🟢 None | New table. Normalizes limit definitions per service. |
plan_service_limits |
🟢 None | New table. Replaces planFeatures.limits jsonb blob logic. |
tenant_coins |
🟢 None | New table. One row per tenant. |
coin_transactions |
🟢 None | New table. Append-only ledger. |
tenant_addons |
🟢 None | New table. Active coin-purchased extras. |
processed_payment_events |
🟢 None | New table. Idempotency guard for webhooks. |
coin_packs |
🟢 None | New config table. Purchasable coin pack definitions. |
addon_catalog |
🟢 None | New config table. Add-on definitions. |
Detailed Change Analysis
1. plans — ADD columns
Current:
export const plans = pgTable("plans", {
id: text("id").primaryKey(),
name: text("name").notNull(),
priceMonthly: integer("price_monthly").notNull(), // USD cents
isPublic: boolean("is_public").default(false).notNull(),
})Needs added:
priceMonthlyInr: integer("price_monthly_inr"), // INR paise (Razorpay)
priceYearlyInr: integer("price_yearly_inr"), // INR paise, nullable (free = 0)
yearlyDiscountPct: integer("yearly_discount_pct").default(0).notNull(),
trialDays: integer("trial_days").default(0).notNull(),
// Razorpay Plan IDs (subscription plans)
razorpayPlanIdMonthly: text("razorpay_plan_id_monthly"),
razorpayPlanIdYearly: text("razorpay_plan_id_yearly"),
// Stripe Price IDs (Phase 8, nullable now)
stripePriceIdMonthly: text("stripe_price_id_monthly"),
stripePriceIdYearly: text("stripe_price_id_yearly"),
// Seat config for hybrid model
includedSeats: integer("included_seats").default(0).notNull(),
extraSeatCostInr: integer("extra_seat_cost_inr").default(0), // paise/seat/month
isPerSeat: boolean("is_per_seat").default(false).notNull(),Risk: 🟡 Low
- All new columns are
nullableor havedefault→ no migration data loss - Existing code reading
plansstill works (additive) priceMonthly(USD cents) kept intact for Stripe Phase 8 compatibility
2. planFeatures — DEPRECATE / REPLACE
Current:
export const planFeatures = pgTable("plan_features", {
planId: ...,
serviceCode: ...,
limits: jsonb("limits").$type<Record<string, number>>(), // ← blob
})Problem: Architecture requires normalized plan_service_limits with one row per (plan_id, service_code, limit_key, value). The existing planFeatures.limits is a JSON blob — you can't query "all plans where blog.posts > 10" efficiently.
Required action: Create plan_service_limits as a new parallel table. Old planFeatures table:
- Gets deprecated (stop writing to it, keep for backwards compat)
- Gets data migrated → then dropped after all reads move over
Risk: 🔴 High
planFeaturesmay already be queried in the codebase- Before touching: run
grep -r "planFeatures" apps/ packages/ - Recommendation: Keep
planFeaturesalive, createplan_service_limitsas parallel. Migrate service reads one by one. Drop only when all reads are moved.
3. subscriptions — ADD columns
Current:
export const subscriptions = pgTable("subscriptions", {
id: text("id").primaryKey(),
tenantId: ...,
planId: ...,
status: text("status").notNull(), // active, past_due, canceled
currentPeriodEnd: timestamp(...),
paymentMethodId: text("payment_method_id"), // ← Stripe-specific
createdAt: ...,
})Needs added:
billingCycle: text("billing_cycle").default("monthly").notNull(), // monthly | yearly
hasUsedTrial: boolean("has_used_trial").default(false).notNull(),
trialEnd: timestamp("trial_end"),
cancelAtPeriodEnd: boolean("cancel_at_period_end").default(false).notNull(),
pendingPlanId: text("pending_plan_id").references(() => plans.id), // scheduled downgrade
provider: text("provider").default("razorpay").notNull(), // razorpay | stripe
razorpayCustomerId: text("razorpay_customer_id"),
razorpaySubscriptionId: text("razorpay_subscription_id"),
stripeCustomerId: text("stripe_customer_id"), // Phase 8, nullable
stripeSubscriptionId: text("stripe_subscription_id"), // Phase 8, nullable
updatedAt: timestamp("updated_at").defaultNow().notNull(),About paymentMethodId: Stripe-specific — Razorpay doesn't use it. Keep column, leave nullable. Will be used when Stripe is added (Phase 8).
Status values: Add trialing to the allowed set — enforce in code (not DB constraint).
Risk: 🟡 Low — all new columns are defaulted/nullable. Existing reads unaffected.
4. tenantServices — ADD column
Current has: tenantId, serviceCode, enabled, limits jsonb, source
Needs: updatedAt: timestamp("updated_at").defaultNow().notNull()
Risk: 🟢 None — additive, with default.
Note on
limits jsonb: Architecture'stenant_servicesstores limits as an effective blob (plan limits + addon boosts merged). This matchestenantServices.limitsexactly — ✅ no design conflict.
New Tables — Full Drizzle Schema
service_limits
export const serviceLimits = pgTable("service_limits",
{
serviceCode: text("service_code").references(() => services.code).notNull(),
limitKey: text("limit_key").notNull(), // "posts", "storage_mb", "seats"
displayName: text("display_name").notNull(),
defaultValue: integer("default_value").default(0).notNull(),
unit: text("unit"), // "MB", "count", "boolean"
},
(t) => ({ pk: primaryKey(t.serviceCode, t.limitKey) })
)plan_service_limits
export const planServiceLimits = pgTable("plan_service_limits",
{
planId: text("plan_id").references(() => plans.id).notNull(),
serviceCode: text("service_code").references(() => services.code).notNull(),
limitKey: text("limit_key").notNull(),
value: integer("value").notNull(), // -1 = unlimited, 0 = disabled
},
(t) => ({ pk: primaryKey(t.planId, t.serviceCode, t.limitKey) })
)tenant_coins
export const tenantCoins = pgTable("tenant_coins", {
tenantId: text("tenant_id").primaryKey().references(() => tenants.id),
balance: integer("balance").default(0).notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})coin_transactions
export const coinTransactions = pgTable("coin_transactions", {
id: text("id").primaryKey(),
tenantId: text("tenant_id").references(() => tenants.id).notNull(),
amount: integer("amount").notNull(), // positive = credit, negative = debit
balanceAfter: integer("balance_after").notNull(),
reason: text("reason").notNull(), // plan_purchase | addon_renewal | manual_top_up
description: text("description"),
referenceId: text("reference_id"), // Razorpay payment_id or order_id
createdAt: timestamp("created_at").defaultNow().notNull(),
})tenant_addons
export const tenantAddons = pgTable("tenant_addons", {
id: text("id").primaryKey(),
tenantId: text("tenant_id").references(() => tenants.id).notNull(),
addonType: text("addon_type").references(() => addonCatalog.id).notNull(),
quantity: integer("quantity").default(1).notNull(),
status: text("status").default("active").notNull(), // active | paused
nextRenewal: timestamp("next_renewal"),
createdAt: timestamp("created_at").defaultNow().notNull(),
})processed_payment_events
export const processedPaymentEvents = pgTable("processed_payment_events", {
providerEventId: text("provider_event_id").primaryKey(),
provider: text("provider").notNull(), // razorpay | stripe
eventType: text("event_type").notNull(),
processedAt: timestamp("processed_at").defaultNow().notNull(),
})coin_packs
export const coinPacks = pgTable("coin_packs", {
id: text("id").primaryKey(), // small, medium, large
name: text("name").notNull(),
pricePaise: integer("price_paise").notNull(), // INR paise (41500 = ₹415)
coins: integer("coins").notNull(),
bonusPct: integer("bonus_pct").default(0).notNull(),
razorpayPriceId: text("razorpay_price_id"),
isActive: boolean("is_active").default(true).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
})addon_catalog
export const addonCatalog = pgTable("addon_catalog", {
id: text("id").primaryKey(),
displayName: text("display_name").notNull(),
serviceCode: text("service_code").references(() => services.code).notNull(),
limitKey: text("limit_key").notNull(),
coinCostPerUnit: integer("coin_cost_per_unit").notNull(),
unitLabel: text("unit_label").notNull(), // "GB", "seat"
isRecurring: boolean("is_recurring").default(false).notNull(),
isActive: boolean("is_active").default(true).notNull(),
})Risk Summary
| # | Risk | Severity | Mitigation |
|---|---|---|---|
| R1 | planFeatures queried in existing app code |
🔴 High | grep -r "planFeatures" apps/ packages/ before touching. Deprecate, don't drop immediately. |
| R2 | subscriptions.paymentMethodId — Stripe-specific, null for Razorpay |
🟡 Medium | Keep column, nullable. Phase 8 Stripe will use it. |
| R3 | plans.priceMonthly is USD cents — Razorpay needs INR paise |
🟡 Medium | Add price_monthly_inr separately. Keep priceMonthly for Stripe compatibility. |
| R4 | tenantServices.limits jsonb — effective limits blob |
🟢 None | Already compatible. No change needed. |
| R5 | ~10 new Drizzle migrations in one go | 🟡 Medium | Run on staging first. Back up DB before migrating prod. |
| R6 | subscriptions PK stores Razorpay sub_... ID |
🟢 None | Compatible — id field can store any string ID. |
| R7 | tenant_coins.balance could go negative on concurrency bug |
🔴 High | Use SELECT FOR UPDATE on every coin debit. Never update balance without row lock. |
| R8 | memberships.isOwner for billing RBAC |
🟢 None | Already a boolean in schema — works out of the box. |
Compatibility With Existing Code
| Area | Compatible? | Notes |
|---|---|---|
| Auth / JWT | ✅ | memberships.isOwner already exists → billing RBAC works out of the box |
| Invitations (seat limit enforcement) | ✅ | invitations → memberships flow unchanged. Billing adds a pre-check only. |
| Team RBAC | ✅ | roles + memberships + permissions not touched |
Media service (mediaFiles) |
✅ | Storage limit check reads tenantServices.limits.storage_mb — structure unchanged |
Communication service (messageLogs) |
✅ | Email send limits use tenantServices.limits.email_sends — structure unchanged |
| API Keys limit | ✅ | apiKeys table unchanged — limit check is additive |
tenants.planId FK |
✅ | Already references plans.id — works seamlessly with billing |
verifications / userAuthProviders |
✅ | Not touched by billing at all |
Recommended Migration Order
Migration 1: Add columns to `plans` (all nullable/defaulted — zero risk)
Migration 2: Add columns to `subscriptions` (all nullable/defaulted — zero risk)
Migration 3: Add `updatedAt` to `tenant_services`
Migration 4: Create `service_limits` + `plan_service_limits`
Migration 5: Seed `service_limits` + `plan_service_limits` data
Migration 6: Create `tenant_coins` + `coin_transactions`
Migration 7: Create `tenant_addons` + `processed_payment_events`
Migration 8: Create `coin_packs` + `addon_catalog`
Migration 9: Seed `coin_packs` + `addon_catalog`
Migration 10: (After billing verified in prod) — migrate reads off `planFeatures`, then dropRule: Never run all migrations at once in production. Migrations 1–3 are zero-risk and can deploy anytime. Migrations 4+ require the billing service code to be ready and tested on staging.