feat(billing): Task 1 — Razorpay subscription schema + migration

Three tables + lifecycle enum for Razorpay subscriptions:
- subscription_plans: maps razorpay_plan_id -> grants (price/storage/member/child)
- family_subscriptions: per-family sub state mirrored from Razorpay
- razorpay_webhook_events: append-only log, razorpay_event_id = idempotency key
- subscription_status_enum: mirrors Razorpay's lifecycle states exactly
- partial unique index family_live_sub_idx: at most one non-terminal sub/family

Notes:
- Raw-SQL + Drizzle schema both added (repo uses raw sql`` at runtime;
  schema file keeps drizzle-kit + type inference working)
- child_limit added to plan (not in original handoff) since premium lifts the
  free 1-baby cap to 3 per product decision
- Migration 0012 idempotent; also added to debug-migration hot-apply steps
- when=1780100000000 (> last entry, per journal drift rule)

Entitlement will sync onto families.tier (Task 3/5) so existing quota.ts
guards stay unchanged — these tables are audit + Razorpay state mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 12:10:53 +05:30
parent 87e795c837
commit 714909d7ee
5 changed files with 161 additions and 0 deletions

56
drizzle/0012_billing.sql Normal file
View file

@ -0,0 +1,56 @@
-- Billing / Razorpay subscriptions.
-- Idempotent: safe to re-run (used by debug-migration hot-apply too).
-- Subscription lifecycle enum (mirrors Razorpay states).
DO $$ BEGIN
CREATE TYPE subscription_status_enum AS ENUM (
'created','authenticated','active','pending',
'halted','cancelled','completed','expired','paused'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Plans: maps a Razorpay plan_id -> what it grants.
CREATE TABLE IF NOT EXISTS subscription_plans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
razorpay_plan_id text NOT NULL UNIQUE,
name text NOT NULL,
price_paise integer NOT NULL,
storage_bytes bigint NOT NULL,
member_limit integer NOT NULL,
child_limit integer NOT NULL DEFAULT 3,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- One subscription row per family (append on upgrade).
CREATE TABLE IF NOT EXISTS family_subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
family_id uuid NOT NULL REFERENCES families(id) ON DELETE CASCADE,
plan_id uuid NOT NULL REFERENCES subscription_plans(id),
razorpay_subscription_id text NOT NULL UNIQUE,
razorpay_customer_id text,
status subscription_status_enum NOT NULL DEFAULT 'created',
current_start timestamptz,
current_end timestamptz,
cancelled_at timestamptz,
ended_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS family_subscriptions_family_idx
ON family_subscriptions (family_id);
-- At most one LIVE (non-terminal) subscription per family.
CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx
ON family_subscriptions (family_id)
WHERE status IN ('created','authenticated','active','pending','halted');
-- Append-only webhook log. razorpay_event_id = idempotency key.
CREATE TABLE IF NOT EXISTS razorpay_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
razorpay_event_id text NOT NULL UNIQUE,
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);

View file

@ -85,6 +85,13 @@
"when": 1780000000000,
"tag": "0011_user_phone",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780100000000,
"tag": "0012_billing",
"breakpoints": true
}
]
}

View file

@ -103,6 +103,13 @@ export async function POST(req: Request) {
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
// 0011 — optional user phone number
`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`,
// 0012 — billing / Razorpay subscriptions
`DO $$ BEGIN CREATE TYPE subscription_status_enum AS ENUM ('created','authenticated','active','pending','halted','cancelled','completed','expired','paused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
`CREATE TABLE IF NOT EXISTS subscription_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), razorpay_plan_id text NOT NULL UNIQUE, name text NOT NULL, price_paise integer NOT NULL, storage_bytes bigint NOT NULL, member_limit integer NOT NULL, child_limit integer NOT NULL DEFAULT 3, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS family_subscriptions (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), family_id uuid NOT NULL REFERENCES families(id) ON DELETE CASCADE, plan_id uuid NOT NULL REFERENCES subscription_plans(id), razorpay_subscription_id text NOT NULL UNIQUE, razorpay_customer_id text, status subscription_status_enum NOT NULL DEFAULT 'created', current_start timestamptz, current_end timestamptz, cancelled_at timestamptz, ended_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS family_subscriptions_family_idx ON family_subscriptions (family_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx ON family_subscriptions (family_id) WHERE status IN ('created','authenticated','active','pending','halted')`,
`CREATE TABLE IF NOT EXISTS razorpay_webhook_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), razorpay_event_id text NOT NULL UNIQUE, event_type text NOT NULL, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now())`,
];
const results: string[] = [];

90
src/db/schema/billing.ts Normal file
View file

@ -0,0 +1,90 @@
import {
pgTable,
text,
timestamp,
uuid,
pgEnum,
integer,
bigint,
boolean,
jsonb,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { families } from "./family";
// ---------------------------------------------------------------------------
// Billing schema — Razorpay subscriptions.
//
// Design:
// - Mirror Razorpay's lifecycle states exactly (don't invent a vocabulary).
// - razorpay_webhook_events is APPEND-ONLY; razorpay_event_id is the
// idempotency key (webhooks WILL be redelivered).
// - Entitlement is synced onto families.tier by the webhook — the existing
// quota.ts guards (isPaidFamily) keep working unchanged. These tables are
// the audit trail + Razorpay state mirror, not a second enforcement path.
// ---------------------------------------------------------------------------
// Mirror Razorpay's subscription lifecycle states exactly.
export const subscriptionStatusEnum = pgEnum("subscription_status_enum", [
"created", // sub created, not yet authenticated
"authenticated", // mandate set up (future-dated starts sit here)
"active", // charged & live — THE entitled state
"pending", // a charge failed; Razorpay is retrying (still entitled = grace)
"halted", // retries exhausted — downgrade
"cancelled", // user/we cancelled
"completed", // all total_count cycles done
"expired",
"paused",
]);
// Maps a Razorpay plan_id -> what it grants. Source of grant values.
export const subscriptionPlans = pgTable("subscription_plans", {
id: uuid("id").primaryKey().defaultRandom(),
razorpayPlanId: text("razorpay_plan_id").notNull().unique(), // plan_xxx from RZP
name: text("name").notNull(), // "Tia Premium"
pricePaise: integer("price_paise").notNull(), // 19900 = ₹199
storageBytes: bigint("storage_bytes", { mode: "number" }).notNull(), // the grant
memberLimit: integer("member_limit").notNull(), // the grant
childLimit: integer("child_limit").notNull().default(3), // the grant
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// One non-terminal subscription per family (enforced by partial unique index).
export const familySubscriptions = pgTable(
"family_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
familyId: uuid("family_id")
.notNull()
.references(() => families.id, { onDelete: "cascade" }),
planId: uuid("plan_id")
.notNull()
.references(() => subscriptionPlans.id),
razorpaySubscriptionId: text("razorpay_subscription_id").notNull().unique(),
razorpayCustomerId: text("razorpay_customer_id"), // captured from webhook
status: subscriptionStatusEnum("status").notNull().default("created"),
currentStart: timestamp("current_start", { withTimezone: true }),
currentEnd: timestamp("current_end", { withTimezone: true }), // entitlement valid until
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("family_subscriptions_family_idx").on(table.familyId)]
);
// Append-only. razorpay_event_id is the idempotency key.
export const razorpayWebhookEvents = pgTable("razorpay_webhook_events", {
id: uuid("id").primaryKey().defaultRandom(),
razorpayEventId: text("razorpay_event_id").notNull().unique(), // x-razorpay-event-id header
eventType: text("event_type").notNull(),
payload: jsonb("payload").notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
});
// Type exports
export type SubscriptionPlan = typeof subscriptionPlans.$inferSelect;
export type FamilySubscription = typeof familySubscriptions.$inferSelect;
export type RazorpayWebhookEvent = typeof razorpayWebhookEvents.$inferSelect;

View file

@ -16,3 +16,4 @@ export * from "./support";
export * from "./ai";
export * from "./affiliate";
export * from "./wardrobe";
export * from "./billing";