# Country pricing + franchise upgrade — API & frontend guide

Base URL: `http://localhost:8000` (production: `https://your-host`)
Content-Type: `application/json` on every request.

Envelope — success `{"ok":true,"data":{...}}`, failure
`{"ok":false,"error":"..."}` with an optional `errors` map.
**Read `error`, not `message`.**

---

## 0. The whole flow in one picture

```
SIGN-UP                    UPGRADE TO FRANCHISE                 PAY
───────                    ────────────────────                 ───
GET  /public/countries  →  GET  /upgrade/state                 ───
     country dropdown         shows categories + packages
POST /auth/register          GET  /my/franchise/territory/options
     + country_code          state dropdown (taken/mine)
                              POST /upgrade/apply
                                  category + package + territory
                                  + KYC documents
                                  → territory RESERVED
                                  → free tier? granted instantly
                                                                             POST /my/packages/{mapId}/pay
                                                                             → UPI QR  or  Razorpay
                                                                               ($59.99 USD)
                                                                         POST /pay/{ref}/capture
                                                                             └─ payment confirmed
                                                                                      ↓
                                                                        AUTOMATIC — no admin step
                                                                        package   → active / paid
                                                                        request   → approved
                                                                        KYC docs  → verified
                                                                        membership→ FPR active
                                                                        territory → assigned
                                                                        role      → synced
```

**Three calls after the user picks a package. No waiting on anyone.**

---

## 1. The pricing model

| Where the user lives | Catalogue holds | Charged | Recorded as |
|---|---|---|---|
| India (`country_code = IN`) | ₹ INR | **₹ INR** — UPI or card | `currency=INR` |
| Everywhere else | ₹ INR | **$ USD** — Razorpay card | `currency=USD` + `amount_inr` |

The catalogue only ever stores INR. Conversion happens once, at display and at
checkout. The ledger keeps **both** figures, so revenue splits, commission and
reports stay in INR no matter where the payer lives.

FX rate: `site_settings.pricing_usd_per_inr`, falling back to
`PRICING_USD_PER_INR` in `.env`, default `0.012` (₹1 ≈ $0.012).
Razorpay converts back to INR for settlement using its own rate on the payment
date — the app's `fx_rate` is the internal record, not Razorpay's.

---

## 2. Country list for the form

```
GET /api/public/countries
```
No auth — call before rendering the sign-up form.

```json
{
  "ok": true,
  "data": {
    "countries": [
      {"code": "IN", "name": "India",          "dial": "+91", "currency": "INR"},
      {"code": "US", "name": "United States",  "dial": "+1",  "currency": "USD"},
      {"code": "CA", "name": "Canada",         "dial": "+1",  "currency": "USD"},
      {"code": "GB", "name": "United Kingdom", "dial": "+44", "currency": "USD"}
    ],
    "supported": ["INR", "USD"],
    "default": "IN",
    "fx_note": "Prices are held in INR and converted for non-India countries."
  }
}
```

56 countries. `currency` is precomputed — the client never hard-codes it.

---

## 3. Registration

```
POST /api/auth/register
```

| Field | Type | Notes |
|---|---|---|
| `country_code` | string(2) | ISO-3166 alpha-2. **Preferred.** |
| `country` | string(80) | Full name — used when no code |
| `state` | string(120) | Optional |

```json
{
  "name": "John Doe",
  "email": "john@example.com",
  "phone": "+14165551234",
  "password": "secret123",
  "country_code": "CA",
  "city": "Toronto",
  "state": "Ontario",
  "dob": "1990-05-02",
  "gender": "male"
}
```

```json
{
  "ok": true,
  "data": {
    "token": "12|vp_XXXX...",
    "user": {
      "id": 137, "name": "John Doe", "email": "john@example.com",
      "role": "client", "phone": "+14165551234",
      "dob": "1990-05-02", "gender": "male",
      "tob": null, "pob": null,
      "country_code": "CA",
      "currency": "USD",
      "profile_complete": true
    },
    "country_code": "CA",
    "currency": "USD"
  }
}
```

Normalisation is forgiving — `country_code` accepts `"in"`, `"IN"`, `"India"`,
`"united states"`, `"United States"`. An unknown value is **ignored**
(registration still succeeds) so a typo can never block a sign-up.

> **`city` decides the currency.** In the example above `city: "Toronto"` is what
> makes this user pay in **USD** — `country_code` is only a fallback, and is
> auto-corrected to match the city. `phone` has **no** effect on currency. See §4a.

`country_code` and `currency` appear both at `data` top level and inside
`data.user`.

| Case | Status |
|---|---|
| Registered | `201` |
| Email already registered | `409` `"An account with this email already exists. Please log in instead."` |
| Bad email / password < 6 | `422` |

---

## 4. Changing city / country later

```
POST /api/user-profile            (PATCH/PUT also work)
Authorization: Bearer <token>
```
```json
{ "city": "Toronto", "state": "Ontario" }
```

The **city** is what decides. On save the backend resolves it to a country, writes
that country to **both** `user_profiles.country` and `users.country_code`, and every
price, package and checkout for that user re-prices immediately — no logout, no
re-registration.

```
GET /api/auth/me
```
Returns `country_code` + `currency` in `data.user` — call on app resume.

---

## 4a. The one rule: the CITY picks the currency

A user picks a **city** at registration (and can change it on the profile). That
city decides the country, and the country decides the currency.

| City the user selected | Resolved country | Currency charged |
|---|---|---|
| Delhi, Mumbai, Ahmedabad, Surat, Pune… | `IN` | **₹ INR** |
| Toronto, Vancouver | `CA` | **$ USD** |
| London | `GB` | **$ USD** |
| New York | `US` | **$ USD** |
| Dubai | `AE` | **$ USD** |
| Singapore | `SG` | **$ USD** |
| Kathmandu / Colombo / Dhaka / Lahore | `NP` / `LK` / `BD` / `PK` | **$ USD** |

**City wins over the country dropdown.** If a user somehow registers
`country_code=IN` with `city=Toronto`, they pay **USD**, and the stored country is
automatically corrected to `CA` so the two can never disagree. The reverse
(`country_code=CA` + `city=Mumbai`) pays **INR** and stores `IN`.

```
POST /api/user-profile   {"city":"Toronto"}   →  currency flips INR → USD
POST /api/user-profile   {"city":"Mumbai"}    →  currency flips USD → INR
```

### What is deliberately NOT consulted

- **The phone number.** A `+1` or `+44` dial code says nothing about where someone
  settles. It has no effect on currency at all — a `+91` number with city `Delhi`
  pays INR, and a `+91` number with city `Toronto` pays USD.
- **IP address**, for any signed-in user with a city.
- **Latitude/longitude** as a pricing signal — coordinates are used only to decide
  whether a city is inside India.

### How the city is resolved (no external service)

`app/Support/CityCountry.php`, using the offline data already in
`app/Support/city_geo.php`:

1. **IANA timezone → ISO country.** Foreign cities carry a timezone, and PHP's own
   timezone database converts it exactly: `"America/Toronto"` → `CA`,
   `"Europe/London"` → `GB`, `"Asia/Dubai"` → `AE`. This is what correctly catches
   the neighbouring countries that sit *inside* India's bounding box —
   `Kathmandu` → `NP`, `Dhaka` → `BD`, `Colombo` → `LK`, `Lahore` → `PK`.
2. **Coordinates.** Indian cities carry no timezone entry (they are all IST), so
   they are identified by falling inside India's bounding box.
3. **The state name**, if the city itself is unknown.
4. **The declared country**, as a last resort.

Free text works too: `"Toronto, Ontario"` → `CA`, `"new york"` → `US`.

> ⚠️ The frontend must **not** derive the currency from the city field itself, and
> must not let the user hand-pick a currency once a city is selected. Always render
> `locked_currency` from `GET /api/public/currency` (see §5).

---

## 5. Which currency is this user in?

```
GET /api/public/currency
```
No auth, but authenticated wins.

```json
{
  "ok": true,
  "data": {
    "currency": "USD",
    "symbol": "$",
    "supported": ["INR", "USD"],
    "country": null,
    "rate": 0.012,
    "locked": true,
    "locked_currency": "USD"
  }
}
```

| Field | Meaning |
|---|---|
| `currency` | what this visitor should be quoted in |
| `symbol` | `₹` or `$` |
| `rate` | USD per 1 INR — **client-side estimates only** |
| `locked` | `true` when a declared country fixes the currency |
| `locked_currency` | the currency they are locked to — **use this, not `currency`**, when deciding what to render |
| `country` | ⚠️ **always `null`.** This field is derived from the request/IP only, never from the signed-in user's account. It is not a reliable signal — read `country_code` from `/api/auth/me` instead. |

`locked: true` → the declared country fixes the currency and the manual switch
is refused. This is what stops a Canadian being quoted ₹.

```
POST /api/public/currency/switch   {"cur": "USD"}
```
Only works while `locked` is false. Sets a 1-year `vp_currency` cookie. When
locked, the endpoint returns the locked currency instead of switching and says
nothing was changed.

---

## 6. Price response shape

Every price-carrying endpoint returns these fields (via `PriceBook::resolve()`
or `PriceBook::decorate()`):

```json
{
  "amount":    59.99,
  "base_inr":  4999.00,
  "currency":  "USD",
  "symbol":    "$",
  "display":   "$59.99",
  "source":    "fx"
}
```

| Field | Meaning |
|---|---|
| `amount` | what this user pays, in `currency` |
| **`base_inr`** | the INR catalogue price (the key is `base_inr`, **not** `base`) |
| `currency` | `INR` or `USD` |
| `symbol` | `₹` or `$` — use for rendering |
| `display` | pre-formatted, e.g. `"$59.99"` |
| `source` | `base` (INR, unchanged) · `fx` (converted) · `manual` (admin pinned it) |

Package lists (`/upgrade/state`, `/my/packages/options?category=…`) carry the
same values prefixed, so a row looks like:
```json
{
  "id": 5, "code": "PRB_BASIC", "name": "Prabandh Basic",
  "price": 4999,
  "price_base_inr": 4999.00,
  "price_amount": 59.99,
  "price_currency": "USD",
  "price_symbol": "$",
  "price_display": "$59.99",
  "price_source": "fx"
}
```

Franchise packages:

| Package | `base_inr` | India pays | Canada / USA pays |
|---|---|---|---|
| `PRB_AARAMBH` | ₹0 | ₹0 | $0 |
| `PRB_BASIC` | ₹4,999 | ₹4,999 | **$59.99** |
| `PRB_PREMIUM` | ₹9,999 | ₹9,999 | **$119.99** |
| `PRB_SUPREME` | ₹19,999 | ₹19,999 | **$239.99** |

USD prices land on a `.99` ending. To pin an exact price instead of converting,
set `packages.price_usd` in the admin — `source` then reads `manual` and the
override wins.

---

## 7. The franchise upgrade flow, API by API

### Step 1 — what can this user become?

```
GET /api/upgrade/state
Authorization: Bearer <token>
```

```json
{
  "ok": true,
  "data": {
    "base_role": "client",
    "memberships": [{"code": "USR", "name": "User / Clients"}],
    "pending": null,
    "region": "INTL",
    "options": [
      {
        "code": "FPR",
        "name": "Franchise Partner",
        "mandatory": [
          {"code": "BUS",           "name": "Business Registration",           "file_types": null},
          {"code": "TAX",           "name": "Tax Registration",                "file_types": null},
          {"code": "intl_id_proof", "name": "Valid ID Proof (Passport / National ID)", "file_types": "jpg,jpeg,png,pdf,webp"}
        ],
        "optional": [],
        "packages": [
          {
            "id": 5, "code": "PRB_BASIC", "name": "Prabandh Basic",
            "description": "Franchise management essentials — team onboarding, lead flow and core reporting for a growing territory.",
            "price": "4999.00",
            "period_value": 1, "period_unit": "months",
            "ai_hours": 5, "max_services": 5, "max_leads": 25,
            "price_base_inr": 4999,
            "price_amount": 59.99,
            "price_currency": "USD",
            "price_symbol": "$",
            "price_display": "$59.99",
            "price_source": "fx"
          }
        ]
      }
    ]
  }
}
```

| Field | Meaning |
|---|---|
| `memberships[]` | categories the user already holds |
| `pending` | the open upgrade request, or `null` |
| `region` | `IN` or `INTL` — decides which documents are mandatory |
| `options[]` | categories they can still apply to (**the key is `options`, not `categories`**) |

An Indian franchise sees `BUS` + `TAX` as mandatory; a foreign one sees
`intl_id_proof` + `intl_income_tax`. Both arrive in the same `mandatory[]` array,
so the form renders one screen.

**This is also the source of your plan cards** — every package here is already
priced for this user. Do not call `/api/my/packages/options` to browse plans: it
requires `?category=` *and* the user must already hold that category (`403`), so
it is only for changing a plan you own.

### Step 2 — pick a territory

```
GET /api/my/franchise/territory/options
Authorization: Bearer <token>
```

```json
{
  "ok": true,
  "data": {
    "level": "state",
    "options": [
      {"code": "GJ", "name": "Gujarat", "level": "state", "parent_code": "IN", "label": "Gujarat", "taken": false, "mine": false},
      {"code": "US-CA", "name": "California", "level": "state", "parent_code": "US", "label": "California", "taken": true, "mine": false}
    ]
  }
}
```

| Flag | Meaning |
|---|---|
| `taken: true` | another franchise holds it, **or** someone has an open application reserving it |
| `mine: true` | the caller already holds it |

### Step 3 — apply

```
POST /api/upgrade/apply
Authorization: Bearer <token>
Content-Type: multipart/form-data
```

| Field | Type | Required |
|---|---|---|
| `category` | string | yes — `FPR` / `PND` / `VND` |
| `package_id` | integer | no — defaults to the free tier |
| `territory_code` | string | franchise only |
| `doc_<CODE>` | file | one file input per mandatory document (e.g. `doc_PAN`, `doc_ADH`, `doc_BUS`) |
| `docnum_<CODE>` | string | optional — the ID number of that document |

⚠️ **This request must be `multipart/form-data`** (Postman: Body → form-data),
NOT `application/json`. If you send it as JSON, the `doc_<CODE>` file fields
cannot be attached and Laravel also fails to decode the bare `<file>` tokens —
every nothing is found and the first failing rule is `category is required`
(`422 {"error":"Validation failed","errors":{"category":["The category field is required."]}}`).
That exact message means you used the wrong body type.

In Postman specifically: **Headers** → set `Authorization` and `Accept`;
**Body** → select **form-data**. Add each text field, and for every document
change the field type from *Text* to **File** and name it `doc_PAN` / `doc_ADH`
/ `doc_BUS` etc. Do NOT name them `docs[PAN][file]` — this controller reads
`doc_{CODE}`.

```json
{
  "ok": true,
  "data": {
    "request_id": 29,
    "package_map_id": 163,
    "status": "kyc_pending",
    "auto_granted": false,
    "payment_due": 4999,
    "category": "FPR",
    "documents": 3
  }
}
```

| Field | Meaning |
|---|---|
| **`package_map_id`** | goes into the `{mapId}` URL segment of step 4 — it is the `user_packages.id`, **not** the package id. Easy to get wrong. |
| `status` | `kyc_pending` for a paid tier, `approved` for a free one |
| **`auto_granted`** | `true` → the category is **already granted**, skip payment entirely |
| `payment_due` | INR amount still to collect (only on a paid tier) |

**Free tier (`PRB_AARAMBH`, ₹0) skips payment entirely.** There is no payment
event to trigger on, so the category is granted at apply time:

```json
{
  "ok": true,
  "data": {
    "request_id": 45, "package_map_id": 186,
    "status": "approved", "auto_granted": true, "payment_due": null
  }
}
```

Branch on `auto_granted`: `true` → go straight to the success screen (§8.9);
`false` → show the plan total and continue to step 4.

The territory is **reserved at apply time** in both cases, which is what stops
two applicants claiming the same state.

### Step 4 — start the payment (paid tiers only)

```
POST /api/my/packages/{mapId}/pay
Authorization: Bearer <token>
```

**Indian user → UPI**
```json
{
  "ok": true,
  "data": {
    "checkout": {
      "ref": "PMT-2026-000123",
      "gateway": "upi",
      "amount": 4999, "amount_inr": 4999, "currency": "INR",
      "upi_id": "your@upi", "upi_name": "Vedic Platform",
      "upi_link": "upi://pay?pa=your@upi&pn=Vedic%20Platform&am=4999.00&cu=INR&tn=PMT-2026-000123"
    }
  }
}
```

Show the QR from `upi_link`, collect the UTR, then:
```
POST /api/pay/{ref}/upi      {"utr": "412345678901"}
```

**Foreign user → Razorpay card (USD)**
```json
{
  "ok": true,
  "data": {
    "checkout": {
      "ref": "PMT-2026-000124",
      "gateway": "razorpay",
      "amount": 59.99, "amount_inr": 4999.00, "currency": "USD",
      "key_id": "rzp_test_xxxxxxxx",
      "gw_order_id": "order_XXXXXXXXXXXX",
      "mode": "test"
    }
  }
}
```

```js
const co = checkout;                       // data.checkout
const rz = new window.Razorpay({
  key: co.key_id,
  amount: Math.round(co.amount * 100),     // SUBUNITS — $59.99 → 5999
  currency: co.currency,                   // "USD"
  order_id: co.gw_order_id,
  name: "VPandit",
  description: label,
  prefill: { name, email, contact: phone },
  notes: { ref: co.ref },
  handler: async (r) => {
    await api.post(`/api/pay/${co.ref}/capture`, {
      gw_payment_id: r.razorpay_payment_id,
      signature: r.razorpay_signature,
    });
    // → user is now a franchise. Navigate away.
  },
  modal: { ondismiss: () => showToast("Payment cancelled. Your application is saved.") },
});
rz.open();
```

⚠️ **`amount × 100` is mandatory.** Razorpay takes the smallest currency unit.
Sending `59.99` instead of `5999` charges ₹0.60.

### Step 5 — confirm

```
POST /api/pay/{ref}/capture
Authorization: Bearer <token>
```
```json
{ "gw_payment_id": "pay_XXXXXXXX", "signature": "a1b2c3..." }
```
```json
{ "ok": true, "data": { "already": false } }
```

Idempotent — safe to call twice. Also keep the server-side webhook
(`POST /api/pay/webhook`, verify `RAZORPAY_WEBHOOK_SECRET`) so a closed browser
never loses the payment.

### What the server did, the instant payment landed

| Record | Change |
|---|---|
| `user_packages` | `status=active`, `payment_status=paid`, `paid_amount`, `paid_amount_inr`, `currency`, `fx_rate`, `approved_at` |
| `category_upgrade_requests` | `status=approved`, note suffixed `[auto-approved on payment <ref>]` |
| `user_documents` | `status=verified` |
| `user_category_members` | FPR row created, `status=active`, `valid_till` = package expiry |
| `franchise_territories` | the reserved territory assigned |
| `users.role` | re-synced from the new category |

There is **no admin approval step.** Do not show a "waiting for approval"
screen.

### Step 6 — verify (optional, good on refresh)

```
GET /api/my/packages
```
```json
{
  "ok": true,
  "data": {
    "categories": [
      {
        "code": "FPR",
        "name": "Franchise Partner",
        "package": {
          "id": 194,
          "category_code": "FPR",
          "category_name": "Franchise Partner",
          "package_name": "Prabandh Basic",
          "package_price": "4999.00",
          "status": "active",
          "payment_status": "paid",
          "payment_method": "gateway",
          "start_date": "2026-10-07",
          "expiry_date": "2026-11-07",
          "days_left": 31,
          "expiring_soon": false,
          "expired": false,
          "paid_amount": 59.99,
          "currency": "USD",
          "paid_amount_inr": 4999.00,
          "fx_rate": 0.012002,
          "payment_ref": "PMT-2026-000124",
          "paid_at": "2026-10-07 15:03:32"
        }
      }
    ],
    "packages": [ /* same rows, flat */ ],
    "renewals": [],
    "unpaid": []
  }
}
```

> The key is **`packages[]`** (and `categories[].package` for a grouped view) —
> **not `rows[]`**, which some other endpoints in this API use.
>
> `unpaid[]` holds every row where `needs_payment` is true — use it to drive a
> "Finish your payment" banner.

| Field | Use |
|---|---|
| `paid_amount` + `currency` | what they actually paid → the success screen's "Paid" line |
| `paid_amount_inr` | the INR equivalent → the greyed "(₹4,999)" |
| `package_price` | catalogue INR price (a string, not a number) |
| `days_left` / `expiring_soon` / `expired` | renewal prompts |

```
GET /api/pay/{ref}
```
```json
{
  "ok": true,
  "data": {
    "txn": {
      "id": 98, "ref": "PMT-2026-000124",
      "context": "user_package", "context_id": "194",
      "gateway": "razorpay",
      "amount": "59.99", "currency": "USD", "status": "pending",
      "amount_inr": "4999.00", "fx_rate": "0.012000", "base_currency": "INR",
      "gw_order_id": "order_TkyDOiSFHcbftH",
      "gw_payment_id": null, "utr": null, "refunded_amount": "0.00",
      "payer_name": "John Doe", "payer_contact": null,
      "created_at": "2026-10-07 15:03:32", "paid_at": null
    }
  }
}
```

> Status lives at **`data.txn.status`**, not `data.status`. Amounts come back as
> **strings** (raw DECIMAL columns) — cast before doing arithmetic in JS.

```
GET /api/upgrade/my-roles
```
```json
{ "ok": true, "data": { "base_role": "client", "categories": [{"code": "USR", "name": "User / Clients"}] } }
```

```
GET /api/my/franchise/territory
```
```json
{
  "ok": true,
  "data": {
    "territory": {"territory_key": "gj", "territory_name": "Gujarat", "franchise_user_id": 137, "is_active": 1},
    "required": false
  }
}
```

`territory` is `null` before a franchise is granted. `required: true` afterwards
means the requested state was taken between applying and paying — the membership
is live but no land is claimed. Keep showing the "claim your territory" prompt.

---

## 8. FRONTEND PROMPTS

Every string the user should see, screen by screen. Hindi and English both.

### 8.1 Sign-up — country field

```
Label (Hindi) : देश
Label (Eng)   : Country
Helper        : आपके देश के अनुसार भुगतान की मुद्रा तय होगी।
                We will show prices in your local currency.
Placeholder   : Select your country / अपना देश चुनें
Required?     : Recommended — default India
Error (empty) : कृपया अपना देश चुनें / Please select your country
Error (bad)   : देश सही नहीं है / That country could not be recognised
```

India is preselected. When the user picks a USD country, show once:

```
IN:  कीमतें भारतीय रुपये में दिखेंगी।
     Prices will be shown in Indian Rupees (₹).
US:  Prices will be shown in US Dollars ($). Payment will be by card.
CA:  कीमतें डॉलर ($) में दिखेंगी और भुगतान कार्ड से होगा।
```

### 8.2 Login — the one identifier field

The field is labelled "email" in the API but accepts a phone number too.

```
Label (Hindi) : ईमेल या मोबाइल नंबर
Label (Eng)   : Email or Mobile Number
Placeholder   : you@email.com  or  9876543210
Helper        : अपना ईमेल या मोबाइल नंबर डालें
                Enter the email or mobile number you registered with
Error (empty) : ईमेल या मोबाइल नंबर ज़रूरी है
                Email or mobile number is required
```

> **Frontend note:** the input must be `type="text"`, **not** `type="email"`.
> With `type="email"` the browser blocks a phone number before the request is
> ever sent ("Please include an '@' in the email address"). Same for any
> client-side validator — relax it to accept digits:
> ```js
> z.string().refine(v => v.includes('@') || /^[+]?[\d\s-]{10,15}$/.test(v))
> ```

### 8.3 Upgrade entry — "become a partner"

```
Screen title (Hindi): पार्टनर बनें
Screen title (Eng)   : Become a Partner
Subtitle  (Eng)      : Upgrade your account and start earning.
CTA       (Eng)      : View plans
```

### 8.4 Territory picker (franchise only)

```
Label (Hindi) : अपना क्षेत्र चुनें
Label (Eng)   : Choose your Territory
Helper (Eng)  : You will be the only partner for this state. It cannot be
                changed later.
CTA           : Continue
```

Per option state:

| Flag | Show |
|---|---|
| `taken: false, mine: false` | `Gujarat` + subtext `Available` |
| `taken: true` | `California` — **disabled**, greyed, subtext `Already assigned` |
| `mine: true` | `Gujarat` — preselected, subtext `Your territory` |

If no state is free at all:
```
Title (Hindi) : अभी कोई क्षेत्र उपलब्ध नहीं है
Title (Eng)   : No territory available right now
Body  (Eng)   : Every state is currently assigned. Please try again later.
CTA           : Refresh list
```

### 8.5 KYC document upload

```
Section title (Eng): Upload your documents
Helper      (Eng)   : Required documents for verification.
                      आवश्यक दस्तावेज़
Per doc (mandatory) : PAN Card  —  required
Per doc (optional) : Tax Registration  —  optional
Upload button       : Choose file / Upload
Replace button      : Replace
Success             : ✓ Uploaded
Error (type)        : Only JPG, PNG or PDF up to 5 MB
Error (required)    : This document is required to continue
CTA (incomplete)    : Disabled — "Upload required documents first"
CTA (complete)      : Continue
```

### 8.6 Plan selection

```
Screen title (Eng) : Choose your plan
Price line (IN)    : ₹4,999   · 1 month
Price line (US)    : $59.99   · 1 month   ·  "incl. taxes at checkout"
Base line (US)     : ₹4,999 (charged as $59.99)     ← use price_base_inr
Per-plan CTA       : Select plan
Selected marker    : ✓ Selected
```

Show `price_display` directly if you want the server to format it. `base_inr`
should be shown small and greyed for USD payers so the INR provenance is
visible without confusing the total.

```
Footnote (US only) : Prices for international customers are shown in US Dollars.
                    The final amount in ₹ is confirmed at settlement.
```

A ₹0 tier needs no payment card at all:

```
Free badge   : FREE / निःशुल्क
Price line   : Free   · 1 month
CTA          : Start free
```

### 8.7 Payment screen — UPI

```
Title (Hindi) : भुगतान करें
Title (Eng)   : Complete Payment
Amount line   : ₹4,999
Below QR      : Scan with any UPI app
               या किसी भी UPI ऐप से स्कैन करें
Status idle   : Waiting for payment…
Status success: ✓ Payment received
Status failed : ✕ Payment failed. Please try again.
UTR label     : UTR / Transaction ID
UTR hint      : 12-digit number from your UPI app
Button        : I have paid / मैंने भुगतान कर दिया
Cancel        : Cancel payment
```

### 8.8 Payment screen — Razorpay (card)

```
Title   (Eng) : Pay $59.99
Subtitle (Eng) : Prabandh Basic · 1 month
Note    (Eng) : You will be charged in US Dollars by card.
Button  (Eng) : Pay $59.99
On dismiss    : Payment cancelled. Your application is saved — you can pay later.
On success    : → straight to the success screen (§8.9)
```

### 8.9 Success — this is the important one

**No approval pending.** The franchise is already live.

Reached either by `auto_granted: true` on apply (free tier) or after a payment
capture — show the same screen either way.

```
✓ (green tick)

Title (Hindi) : आपका फ्रेंचाइज़ एक्टिव हो गया है!
Title (Eng)   : Your franchise is now active!
Subtitle(Eng) : Welcome aboard. Your territory is assigned and all
                franchise tools are unlocked.

Line 1: Plan          Prabandh Basic
Line 2: Paid          $59.99  (₹4,999)
Line 3: Territory     Gujarat
Line 4: Valid until   7 Nov 2026

CTA primary : Go to franchise dashboard
CTA second  : Download receipt
```

Free tier variant (no money line):

```
Line 1: Plan          Prabandh Aarambh (Free)
Line 2: Paid          Free
Line 3: Territory     Gujarat
Line 4: Valid until   7 Nov 2026
```

The receipt button points at an existing endpoint — no extra backend work:

```
GET /api/pay/{ref}/receipt.pdf
Authorization: Bearer <token>
```

Use the `ref` saved during checkout. It prints in the currency actually charged
(`$59.99`) with the INR equivalent in the breakdown.

### 8.10 Territory missing after payment

```
⚠ (amber icon)

Title (Hindi) : क्षेत्र अभी उपलब्ध नहीं है
Title (Eng)   : One last step — claim your territory
Body  (Eng)   : The state you chose was assigned while your payment was
                processing. Pick any available state to finish setting up.
CTA           : Choose territory
```

### 8.11 Payment failed / cancelled

```
Title (Hindi) : भुगतान पूरा नहीं हुआ
Title (Eng)   : Payment not completed
Body  (Eng)   : Your application is saved. Pay any time before it expires —
                your documents will not need resubmitting.
Button        : Try payment again
Button (text) : Cancel application
```

### 8.12 Already a partner

```
Title (Eng) : You already have this category
Body  (Eng) : You are already registered as a Franchise Partner.
              Need a different plan? Choose another tier below.
Button      : View my plan
```

### 8.13 Error messages the API can return

| HTTP | `error` | Show |
|---|---|---|
| `422` | `Invalid or expired OTP. Please try again.` | `OTP is incorrect or has expired. Please try again.` |
| `422` | `This package fee is already paid.` | `This plan is already paid for.` |
| `422` | `This package has no fee to pay.` | `This plan is free — nothing to pay.` |
| `422` | `A valid date of birth is required (YYYY-MM-DD).` | `Please enter a valid date of birth.` |
| `422` | `Time of birth ka format galat hai (HH:MM).` | `Please enter the time as HH:MM.` |
| `404` | `Package not found.` | `This plan is no longer available. Please choose again.` |
| `404` | `No active account found for that email. Please register first.` | `No account found with this email.` |
| `409` | `An account with this email already exists. Please log in instead.` | `This email is already registered. Please log in.` |
| `409` | `This territory is already assigned to another franchise.` | `This state has just been assigned. Please choose another.` |
| `401` | `Invalid email/mobile or password` | `Incorrect email/mobile number or password.` |
| `403` | `Account is disabled` | `Your account has been disabled. Contact support.` |
| `429` | `Too Many Attempts.` | `Too many attempts. Please wait a minute and try again.` |
| `422` | `International (USD) payments are not available yet. Configure a Razorpay gateway…` | **Server misconfiguration** — show `Card payment is temporarily unavailable. Please contact support.` and log it |
| `422` | `Package not found.` / rate errors on `submitUpi` | `We could not verify the payment. Please contact support with your UTR.` |

Never render a raw `error` string to the user. Map it through this table.

### 8.14 Loading / empty states

```
Loading plans      : Loading plans…
Loading options    : Loading available states…
No plans           : No plans available for this category right now.
No packages        : No plans available. Please check back soon.
Empty documents    : No documents required for your country.
```

---

## 9. Frontend checklist

1. `GET /api/public/countries` on the sign-up page; country select → `country_code`.
2. Login field `type="text"` + relaxed client validation (§8.2). **This one is
   easy to miss and it silently blocks phone login.**
3. Cache `data.user.currency` after login; render prices from the server's
   `amount` + `currency`, never a client-side FX calculation.
4. Country change on the profile screen (`POST /api/user-profile`).
5. `gateway: "upi"` → render the QR from `upi_link`; `gateway: "razorpay"` → open
   Checkout with `amount × 100`.
6. Always call `/api/pay/{ref}/capture` from the handler **and** rely on the
   webhook.
7. **After capture → success screen directly.** No "pending approval" screen, no
   polling for an admin.
8. `GET /api/my/franchise/territory` → if `required: true`, keep the "claim your
   territory" prompt.
9. Map every API `error` through §8.13 — never show a raw string.
10. App resume → `GET /api/auth/me` to refresh `currency` and roles.

---

## 10. Admin (unchanged, but for reference)

```
GET  /api/upgrade/queue                       → requests not yet paid for
POST /api/upgrade/{id}/review                 {"decision":"approved"|"rejected","note":"..."}
GET  /api/admin/franchise-territories
POST /api/admin/franchise-territories/assign  {"user_id":137,"code":"GJ","name":"Gujarat"}
```

The queue now only holds **unpaid** applications. Approving an already
auto-approved request is a harmless no-op. Review is for rejecting an
application or reversing a category after a refund.

---

## 11. Server configuration

`.env`:
```
RAZORPAY_KEY_ID=rzp_test_xxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxx
RAZORPAY_WEBHOOK_SECRET=xxxxxxxx
PAY_GATEWAY_MODE=test            # live in production
PRICING_USD_PER_INR=0.012
```
An active `pay_gateways` master row overrides these, so keys can be rotated from
the admin UI without a deploy.

`site_settings` (admin → payments tab):

| Key | Default | Meaning |
|---|---|---|
| `pay_upi_id` | — | **Currently empty** — the Indian UPI link is malformed until set |
| `pay_upi_name` | `Vedic Platform` | UPI payee name |
| `pay_international` | `1` | `0` refuses every USD charge |
| `pricing_usd_per_inr` | `0.012` | FX rate, editable from the admin |

### Does Razorpay actually settle USD?

Yes, with three things in place:

1. **KYC complete** on the Razorpay account.
2. **International Payments activated** — Dashboard → Account & Settings →
   Payment methods → International. Not on by default; banking partners approve
   it, and they require a live site with clear Terms, Privacy, Refund Policy and
   Contact pages.
3. **A purpose code** under International Payment Codes.

Then send `currency: "USD"` with the amount in subunits. Razorpay converts to
INR for settlement at its own rate on the payment date and supports 3-D Secure
2.0 for foreign cards. Until step 2 is done the API accepts USD but Razorpay
declines — test with `rzp_test_` keys first.

---

## 12. Field reference

**`users`** (added)

| Column | Type | Notes |
|---|---|---|
| `country_code` | `VARCHAR(2)` NULL | authoritative, indexed |
| `country_name` | `VARCHAR(80)` NULL | display only |

**`user_profiles`** (added) — `country VARCHAR(80) NULL`

**`pay_txns`** (added)

| Column | Type | Meaning |
|---|---|---|
| `amount_inr` | `DECIMAL(12,2)` | INR equivalent of `amount` |
| `fx_rate` | `DECIMAL(12,6)` | USD per 1 INR used |
| `base_currency` | `VARCHAR(8)` | always `INR` |

**`user_packages`** (added)

| Column | Type | Meaning |
|---|---|---|
| `currency` | `VARCHAR(8)` | currency actually charged |
| `paid_amount_inr` | `DECIMAL(12,2)` | INR catalogue price |
| `fx_rate` | `DECIMAL(12,6)` | rate used |

Installed idempotently on first use. To pre-install manually:
```
php artisan db:seed --class="Database\Seeders\CountryPricingSchemaSeeder"
-- or
mysql -u root vpandit < database/sql/country_pricing_schema.sql
```

---

## 13. Known gaps

Honest list of what is **not** converted yet:

| Area | Status |
|---|---|
| Packages, report modules (numerology / astrology / vastu / palmistry) | ✅ converted |
| Franchise / vendor / pandit upgrade + payment | ✅ converted, granted automatically on payment |
| Shop, group poojas, expert consultations, AI consults, pandit hour top-ups | ❌ hardcoded INR; those order tables have no `currency` column |
| `amount_total` on report orders is client-supplied | ⚠️ a client can post any number; the server should derive it from the catalogue |
| `users.role` enum has no `partner`/`franchise` value | ⚠️ franchise identity lives in `user_category_members`; `role` stays `client` |
| 64 orphaned `pay_txns` rows with `context='user_package'` | ⚠️ historical — `context_id` points at deleted `user_packages` rows |
| 26 `user_packages` rows `paid` with `paid_amount = NULL` | ⚠️ granted by admin review before the payment gate existed |

The order-table gaps do not affect franchise upgrades or report orders — both go
through the converted paths.

---

## 14. Local development gotcha: opcache

This XAMPP has `opcache.enable_cli = On` with `revalidate_freq = 60`. After
editing a PHP file the change may not take effect for up to **60 seconds**, which
makes a correct fix look broken.

```bash
# restart the web server between edits (best), or
php -d opcache.enable_cli=0 artisan test          # bypass the cache for one run
```

Do this before concluding a change "didn't work" — it bit us twice during this
build.
