# Premium plans, entitlements, and mandates

**Date:** 2026-09-19  
**Decision:** Approach A — three tables.

## Tables

### `plans` (catalog)

Sellable SKUs. Seeded once; prices are editable later.

| Column | Meaning |
|---|---|
| `code` | `monthly`, `quarterly`, `yearly` (unique) |
| `duration_months` | 1 / 3 / 12 |
| `price_inr` | What we charge |
| `discount_percent` | Display badge (0–100) |
| `active` | Hide from paywall without deleting |

Seed prices: monthly ₹99 (0%), quarterly ₹249 (16%), yearly ₹799 (33%).

### `user_plans` (entitlements)

Every paid period. History is expired/cancelled rows — no separate log table.

| Column | Meaning |
|---|---|
| `user_id`, `plan_id` | Who bought which SKU |
| `starts_at`, `ends_at` | Access window (`ends_at` is required) |
| `status` | `active` / `expired` / `cancelled` |
| `amount_inr` | Price snapshot at purchase |

Current plan = `status = active` AND `ends_at > now`. At most one current entitlement per user (enforced in the model).

`User#premium?` uses this query only.

### `subscriptions` (mandates)

Every Razorpay/mock mandate. History is cancelled/expired rows.

| Column | Meaning |
|---|---|
| `user_id`, `plan_id` | Who authorized which SKU |
| `razorpay_subscription_id` | Provider id (null for mock) |
| `status` | `created` / `authorized` / `active` / `paused` / `cancelled` / `expired` |
| `next_charge_at` | Next auto-debit (nullable) |
| `source` | `mock` / `razorpay` / `admin` |

Current mandate = `authorized` or `active`. At most one current mandate per user (enforced in the model).

## Rules

- Cancel mandate → user stays premium until `user_plans.ends_at`.
- Mock purchase creates one `user_plan` and one `subscription`.
- Existing `mall_premium` lifetime rows migrate onto `yearly` with `ends_at = COALESCE(old.ends_at, starts_at + 12 months)`.
