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,
|
"when": 1780000000000,
|
||||||
"tag": "0011_user_phone",
|
"tag": "0011_user_phone",
|
||||||
"breakpoints": true
|
"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)`,
|
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
|
||||||
// 0011 — optional user phone number
|
// 0011 — optional user phone number
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`,
|
`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[] = [];
|
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 "./ai";
|
||||||
export * from "./affiliate";
|
export * from "./affiliate";
|
||||||
export * from "./wardrobe";
|
export * from "./wardrobe";
|
||||||
|
export * from "./billing";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue