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:
parent
87e795c837
commit
714909d7ee
5 changed files with 161 additions and 0 deletions
56
drizzle/0012_billing.sql
Normal file
56
drizzle/0012_billing.sql
Normal 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()
|
||||
);
|
||||
|
|
@ -85,6 +85,13 @@
|
|||
"when": 1780000000000,
|
||||
"tag": "0011_user_phone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1780100000000,
|
||||
"tag": "0012_billing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
90
src/db/schema/billing.ts
Normal 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;
|
||||
|
|
@ -16,3 +16,4 @@ export * from "./support";
|
|||
export * from "./ai";
|
||||
export * from "./affiliate";
|
||||
export * from "./wardrobe";
|
||||
export * from "./billing";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue