diff --git a/drizzle/0012_billing.sql b/drizzle/0012_billing.sql new file mode 100644 index 0000000..5f94ad4 --- /dev/null +++ b/drizzle/0012_billing.sql @@ -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() +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e679df7..48f2a53 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1780000000000, "tag": "0011_user_phone", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1780100000000, + "tag": "0012_billing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/debug-migration/route.ts b/src/app/api/debug-migration/route.ts index 8c85908..611810b 100644 --- a/src/app/api/debug-migration/route.ts +++ b/src/app/api/debug-migration/route.ts @@ -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[] = []; diff --git a/src/db/schema/billing.ts b/src/db/schema/billing.ts new file mode 100644 index 0000000..7e2b713 --- /dev/null +++ b/src/db/schema/billing.ts @@ -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; diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 0c746e5..9de0d36 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -16,3 +16,4 @@ export * from "./support"; export * from "./ai"; export * from "./affiliate"; export * from "./wardrobe"; +export * from "./billing";