# Register & Payment API — Integration Reference

**For:** mobile app / website teams updating the frontend
**Backend:** `vpandit-api` (Laravel)
**Every payload below was captured from the live API** — nothing here is guessed.

---

## 0. Conventions

### Base URL

| Env | Value |
|---|---|
| Dev | `http://<host>:8002/api` |
| Prod | `https://vpanditnew.hostingaipl.xyz/public/api` |

### Response envelope — every endpoint returns this shape

```json
{ "ok": true,  "data": { … } }
{ "ok": false, "error": "human message", "errors": { "field": ["why"] } }
```

Always branch on `ok`. A failing request returns `ok: false` with a **2xx-free**
status: validation is `422`, conflict `409`, auth `401`, not-found `404`,
throttle `429`.

### Auth header

```
Authorization: Bearer <token>
Accept: application/json
```

The token comes from register or login. It is a Sanctum token.

---

## 1. The one rule: the CITY decides the currency

A user picks a **city**. That city decides the country, and the country decides
the currency they are charged in.

| City the user selected | Country stored | Charged in |
|---|---|---|
| Delhi, Mumbai, Bharuch, Ahmedabad, Surat, Pune… | `IN` | **₹ INR** |
| Toronto, Vancouver | `CA` | **$ USD** |
| London | `GB` | **$ USD** |
| New York | `US` | **$ USD** |
| Dubai | `AE` | **$ USD** |
| Kathmandu / Colombo / Dhaka / Lahore | `NP` / `LK` / `BD` / `PK` | **$ USD** |

**Do not compute the currency in the frontend.** Read `locked_currency` from
`GET /api/public/currency`.

Two things that look like they should matter but **do not**:

- **The `country` field** — a fallback only. `country_code: "IN"` + `city: "Toronto"`
  still pays **USD**, and the stored country is silently corrected to `CA`.
- **The phone number** — zero influence. A `+91` number with city `Delhi` pays INR.

---

## 2. `POST /auth/register`

Creates the account and returns a token. **Mobile number and city are required.**

```json
{
  "name": "Jaydip Dabhi",
  "email": "jaydip@example.com",
  "phone": "+919876543210",
  "password": "secret123",
  "country_code": "IN",
  "state": "GJ",
  "city": "Bharuch",
  "dob": "1999-10-20",
  "gender": "male"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | **yes** | |
| `email` | string | **yes** | must be a valid email; must be unique |
| `password` | string | **yes** | min 6 characters |
| `phone` | string | **yes** | `+` then 7–15 digits. This is also a login id |
| `city` | string | **yes** | free text or a code from the location cascade. Decides the currency |
| `country_code` | string | no | ISO-2. Fallback when the city is unknown |
| `state` | string | no | State code, e.g. `GJ` |
| `dob` | string | no | `YYYY-MM-DD` |
| `gender` | string | no | `male` \| `female` |

### Success — `201`

```json
{
  "ok": true,
  "data": {
    "token": "1089|vp_YdEh3wpPico75hFhRbc95EUII447WIWFS52H7jbo77cbb3ed",
    "user": {
      "id": 261,
      "name": "Jaydip Dabhi",
      "email": "jaydip@example.com",
      "role": "client",
      "phone": "+919876543210",
      "dob": null,
      "gender": null,
      "tob": null,
      "pob": "Bharuch",
      "country_code": "IN",
      "currency": "INR",
      "city": "Bharuch",
      "state": "GJ",
      "country_name": "India",
      "profile_complete": false
    },
    "country_code": "IN",
    "currency": "INR"
  }
}
```

| Field | Notes |
|---|---|
| `data.token` | store it — every later call needs it |
| `data.user.currency` | already resolved from the city: `"INR"` or `"USD"` |
| `data.user.city` / `.state` / `.country_name` | the location you sent, stored |
| `data.user.role` | `"client"` for a plain signup |
| `data.user.profile_complete` | `true` once a birth date exists |

### Failure modes

```json
// 422 — missing/invalid fields
{ "ok": false, "error": "Validation failed",
  "errors": { "phone": ["The phone field is required."],
              "password": ["The password field must be at least 6 characters."] } }

// 422 — bad email
{ "ok": false, "error": "Validation failed",
  "errors": { "email": ["The email field must be a valid email address."] } }

// 409 — email already taken
{ "ok": false, "error": "An account with this email already exists. Please log in instead." }
```

> Phone format is validated as `/^\+?[0-9]{7,15}$/`. `"abc"`, `"12"` and `"+91 98765 43210"`
> (spaces) are all rejected — send digits only, `+` optional at the front.

---

## 3. Login and session

### `POST /auth/login`

The field is named `email` but **accepts the mobile number too**.

```json
{ "email": "jaydip@example.com", "password": "secret123" }
```

```json
{ "email": "+919876543210", "password": "secret123" }
```

Both work. Phone variants are matched loosely (`+91 98765 43210`, `09876543210`,
`919876543210` all resolve to the same account).

Success is the same shape as register: `{ "ok": true, "data": { "token", "user" } }`.

Wrong password:

```json
{ "ok": false, "error": "Invalid email/mobile or password" }
```

### `GET /auth/me`

```json
{
  "ok": true,
  "data": {
    "user": {
      "id": 261,
      "name": "Jaydip Dabhi",
      "email": "jaydip@example.com",
      "role": "client",
      "phone": "+919876543210",
      "dob": null,
      "gender": null,
      "tob": null,
      "pob": "Bharuch",
      "country_code": "IN",
      "currency": "INR",
      "city": "Bharuch",
      "state": "GJ",
      "country_name": "India",
      "profile_complete": false
    }
  }
}
```

Call this on **app start** to restore the session and refresh `currency`,
`city`, `state` and roles.

---

## 3b. `GET` / `POST /api/user-profile`

The unified profile the location dropdowns write to. Saving a **city** here is
what re-prices the account.

### `GET /api/user-profile`

```json
{
  "ok": true,
  "data": {
    "profile": {
      "user_id": 261, "display_name": "Jaydip Dabhi", "phone": "+919876543210",
      "email": "jaydip@example.com", "city": "Bharuch", "state": "GJ",
      "country": "IN", "pincode": null, "dob": null, "gender": null,
      "profile_complete": 1
    },
    "panels": ["common"]
  }
}
```

### `POST /api/user-profile`

```json
{ "country": "IN", "state": "GJ", "city": "Bharuch" }
```

Only `country` is optional; everything else you send is merged. The city wins over
the country — sending `country: "IN"` with `city: "Toronto"` returns
`"country": "CA"` and flips the account to **USD**.

```json
// request
{ "country": "IN", "state": "ON", "city": "Toronto" }
// response data.profile
{ "state": "ON", "country": "CA", … }
// and immediately after
GET /api/public/currency → { "currency": "USD", "locked": true, "locked_currency": "USD" }
```

| Response field | Notes |
|---|---|
| `data.profile` | the saved profile, including the corrected `country` |
| `data.panels` | which profile sections to show for this user |

After saving, re-fetch `/api/public/currency` — the currency may have flipped —
and `/api/upgrade/state`, because the document list changes between India and
everywhere-else.

---

## 4. Location cascade (for the dropdowns)

Country → State → City, straight from the `geographic_nodes` master. Whatever is
in the database is what the dropdowns show — no hard-coded lists.

### `GET /api/public/locations?level=country`

```json
{ "ok": true, "data": { "level": "country", "options": [
  { "code": "IN", "name": "India", "name_dev": "", "dial": "+91", "currency": "INR", "has_states": true },
  { "code": "CA", "name": "Canada", "name_dev": "", "dial": "+1", "currency": "USD", "has_states": true }
] } }
```

### `GET /api/public/locations?level=state&parent=IN` → 36 states

```json
{ "ok": true, "data": { "level": "state", "parent": "IN", "options": [
  { "code": "AP", "name": "Andhra Pradesh", "level": "state", "parent_code": "IN",
    "country": "IN", "latitude": null, "longitude": null, "pincode": "", "label": "Andhra Pradesh" },
  { "code": "GJ", "name": "Gujarat", "level": "state", "parent_code": "IN", … }
] } }
```

### `GET /api/public/locations?level=city&parent=GJ` → 32 options

```json
{ "ok": true, "data": {
  "level": "city",
  "parent": "GJ",
  "source": "district",
  "note": "No city master for this state yet — showing its districts.",
  "options": [
    { "code": "GJ-AHM", "name": "Ahmedabad", "level": "district", "parent_code": "GJ",
      "state": "GJ", "label": "Ahmedabad", … }
  ] } }
```

| `source` | Meaning | What to show the user |
|---|---|---|
| `city` | real CITY rows exist | the city dropdown |
| `district` | no city master, districts returned | a district dropdown + the note |
| `empty` | nothing at all (e.g. US states) | fall back to a free-text city input |

**Current data:** only **Andhra Pradesh** has real CITY rows (28). Every other
Indian state returns its districts. India has 36 states/UTs; the US has 51; Canada 13.

### `GET /api/public/locations/one?country=IN&state=GJ`

Everything in one call — country + its states + the cities for that state. Use
this when the user picks a state, to save a round trip.

### `GET /api/public/countries`

56 countries with dial code and currency, if you need a flat country list:

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

---

## 5. Currency

### `GET /api/public/currency`

Works signed-in or anonymous; **signed-in wins**.

```json
// signed-in, city in India
{ "ok": true, "data": {
  "currency": "INR", "symbol": "₹", "supported": ["INR", "USD"],
  "country": null, "rate": 0.012, "locked": true, "locked_currency": "INR" } }

// guest, CF-IPCountry: CA
{ "ok": true, "data": {
  "currency": "USD", "symbol": "$", "supported": ["INR", "USD"],
  "country": "CA", "rate": 0.012, "locked": false, "locked_currency": null } }
```

| Field | Notes |
|---|---|
| `locked_currency` | **render this.** Non-null when the account's city fixes the price |
| `locked` | `true` = currency is fixed, the manual switch is refused |
| `symbol` | `₹` or `$` |
| `rate` | USD per 1 INR. For a rough estimate badge only — never for the charge |
| `country` | only filled for anonymous visitors (from the edge header) |

### `POST /api/public/currency/switch`

```json
{ "cur": "USD" }
```

Only works while `locked` is `false`. Sets a 1-year `vp_currency` cookie. When
locked it returns the locked currency instead of switching:

```json
{ "ok": true, "data": { "currency": "INR", "symbol": "₹", "locked": true } }
```

---

## 6. What can this user become? `GET /api/upgrade/state`

```json
{
  "ok": true,
  "data": {
    "base_role": "client",
    "memberships": [ { "code": "USR", "name": "User / Clients" } ],
    "pending": null,
    "can_apply": true,
    "options": [
      { "code": "FPR", "name": "Franchise Partner",
        "mandatory": [ … ], "optional": [ … ], "packages": [ … ] },
      { "code": "PND", "name": "Pandit / Experts",   … },
      { "code": "VND", "name": "Vendor",            … }
    ]
  }
}
```

### Build the upload form from `mandatory[]` — never hard-code it

It changes with the user's country:

| Category | India (`IN`) | Everywhere else (`INTL`) |
|---|---|---|
| **FPR** Franchise | `PAN`, `ADH`, `BUS`, `TAX` | `BUS`, `TAX`, `intl_id_proof`, `intl_income_tax` |
| **PND** Pandit | `ADH` | `intl_id_proof` |
| **VND** Vendor | `PAN`, `ADH` | `intl_id_proof`, `intl_income_tax` |

This is the admin's **Category Document Requirements** table. An admin edit under
Admin → Document Requirements appears here with no code change.

Each entry:

```json
{ "code": "PAN", "name": "PAN Card", "file_types": null }
{ "code": "intl_id_proof", "name": "Valid ID Proof (Passport / National ID)",
  "file_types": "jpg,jpeg,png,pdf,webp" }
```

Use `file_types` for the file-picker `accept`. When it is `null`, fall back to
`jpg, jpeg, png, pdf`.

### Packages (franchise, in the caller's currency)

```json
{ "id": 6, "code": "PRB_PREMIUM", "name": "Prabandh Premium",
  "price_amount": 9999, "price_currency": "INR",
  "price_display": "₹9,999", "price_base_inr": 9999 }
```

Same plan, per currency:

| Plan | INR user | USD user |
|---|---|---|
| `PRB_AARAMBH` (free) | `₹0` | `$0.00` |
| `PRB_BASIC` | `₹4,999` | `$59.99` |
| `PRB_PREMIUM` | `₹9,999` | `$119.99` |
| `PRB_SUPREME` | `₹19,999` | `$239.99` |

Render `price_display` — the server has already formatted it.

---

## 7. Applying — `POST /api/upgrade/apply`

> ### ⚠️ This must be `multipart/form-data`
> Sent as JSON, the file fields cannot attach and the request fails.

Postman: **Body → form-data**. React Native / fetch: append to `FormData` and
never set `Content-Type` yourself — the client adds the boundary.

| Field | Type | Required |
|---|---|---|
| `category` | text | **yes** — `FPR` / `PND` / `VND` |
| `package_id` | text | no — omit for the free tier |
| `territory_code` | text | franchise only |
| `territory_name` | text | franchise only |
| `doc_<CODE>` | **file** | one per entry in `mandatory[]` |
| `docnum_<CODE>` | text | optional — that document's ID number |

The file field name is built as **`"doc_" + code`**:

```jsx
const fd = new FormData();
fd.append("category", "FPR");
fd.append("package_id", "6");
fd.append("territory_code", "GA");
fd.append("territory_name", "Goa");
mandatory.forEach(d => fd.append(`doc_${d.code}`, fileFor(d.code)));
// optional: fd.append(`docnum_${d.code}`, "ABC1234567");

await fetch(`${API}/api/upgrade/apply`, {
  method: "POST",
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  body: fd,                       // do NOT set Content-Type
});
```

> ❌ **Never** `docs[PAN][file]`, `files[]`, or an `documents[]` JSON blob. The
> controller reads `doc_{CODE}` and silently ignores anything else.

### Success — `201`

```json
{
  "ok": true,
  "data": {
    "request_id": 80,
    "category": "FPR",
    "documents": 4,
    "status": "kyc_pending",
    "package_map_id": 346,
    "auto_granted": false,
    "payment_due": 9999
  }
}
```

| Field | Notes |
|---|---|
| `package_map_id` | **this is the `{mapId}` for the pay call** |
| `payment_due` | INR figure while payment is outstanding. **`null`** when `auto_granted` |
| `auto_granted` | `true` only for the free tier — nothing to pay |
| `status` | `kyc_pending` while payment is outstanding, `approved` when granted |
| `documents` | how many files were attached |

### Failure modes

```json
// 422 — body sent as JSON, so the files never arrived
{ "ok": false, "error": "Mandatory document missing: PAN" }

// 422 — that state is already held by another franchise
{ "ok": false, "error": "This territory is already assigned to (or requested by) another franchise. Please choose a different one." }

// 422 — franchise with no territory (required even on the free tier)
{ "ok": false, "error": "Please select a franchise territory to apply." }

// 422 — the bare "<file>" tokens made the JSON unparseable, so nothing arrived
{ "ok": false, "error": "Validation failed",
  "errors": { "category": ["The category field is required."] } }
```

The first two mean the same thing to the user: *the file inputs did not reach the
server*. Check the body type and the `doc_<CODE>` field names. The third one
means the body was not valid JSON at all.

---

## 8. Payment

### 8.1 `GET /api/my/packages` — what is still owed

```json
{
  "ok": true,
  "data": {
    "categories": [
      { "code": "USR", "name": "User / Clients",
        "package": { "id": 343, "status": "active", "payment_status": "paid",
                     "payment_method": "free", "needs_payment": false,
                     "paid_at": "2026-10-08 12:57:29", "currency": "INR" } }
    ],
    "packages": [
      { "id": 346, "category_code": "FPR", "package_name": "Prabandh Premium",
        "package_price": "9999.00", "status": "applied",
        "payment_status": "pending", "payment_method": null,
        "start_date": "2026-10-08", "expiry_date": "2026-11-08",
        "days_left": 31, "expiring_soon": false, "expired": false,
        "needs_payment": true,
        "paid_amount": null, "currency": "INR",
        "paid_amount_inr": null, "fx_rate": null,
        "payment_ref": null, "paid_at": null }
    ]
  }
}
```

Drive the **Pay** button off **`needs_payment`**.

> Before payment, `package_price` and `currency` show the **base INR** price for
> everyone, even a USD user. That is expected. The amount they will actually be
> charged is `checkout.amount` from the next call.

### 8.2 `POST /api/my/packages/{mapId}/pay`

`{mapId}` is `package_map_id` from apply — **not** `package_id`.

```json
{}
```

```json
{
  "ok": true,
  "data": {
    "checkout": {
      "ref": "PMT-2026-000118",
      "gateway": "razorpay",
      "amount": 9999,
      "currency": "INR",
      "amount_inr": 9999,
      "key_id": "rzp_test_Tky4Z3HmOEZI3d",
      "gw_order_id": "order_TlKdlWC76M8Ite",
      "mode": "test"
    }
  }
}
```

A Toronto user gets the same plan in dollars:

```json
"checkout": {
  "ref": "PMT-2026-000119", "gateway": "razorpay",
  "amount": 119.99, "currency": "USD", "amount_inr": 9999,
  "key_id": "rzp_test_…", "gw_order_id": "order_TlKdsOJtTIccIS", "mode": "test"
}
```

| Field | Notes |
|---|---|
| `amount` | **charge this**, in `currency` |
| `currency` | `"INR"` or `"USD"` — matches `locked_currency` |
| `amount_inr` | bookkeeping only. Never show it to the user |
| `gw_order_id` | Razorpay `order_id` for checkout |
| `key_id` | Razorpay public key |
| `ref` | keep it — the capture call needs it |
| `mode` | `test` or `live`. Currently `test` |

### 8.3 Open Razorpay Checkout

> ⚠️ **Razorpay takes the smallest currency unit — send `amount × 100`.**
> `9999 → 999900` · `119.99 → 11999`

```js
const opts = {
  key: checkout.key_id,
  order_id: checkout.gw_order_id,
  amount: Math.round(Number(checkout.amount) * 100),
  currency: checkout.currency,          // "INR" | "USD"
  name: "VPandit",
  description: pkg.package_name,
  prefill: {
    name: user.name,
    email: user.email,
    contact: String(user.phone || "").replace(/\D/g, ""),
  },
  handler: async (resp) => {
    await capturePayment(checkout.ref, {
      gw_payment_id: resp.razorpay_payment_id,
      signature: resp.razorpay_signature,
    });
    // success — refresh the user
  },
  theme: { color: "#375944" },
};
new window.Razorpay(opts).open();
```

Load the script once, lazily:

```html
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
```

### 8.4 `POST /api/pay/{ref}/capture`

```json
{ "gw_payment_id": "pay_XXXXXXXXXXXX", "signature": "hex…" }
```

Both values come straight from Razorpay's `handler`. The server verifies the
signature against the gateway — a fabricated one is rejected:

```json
{ "ok": false, "error": "Signature verification failed." }
```

On success the backend marks the transaction paid **and auto-grants** the package,
the franchise/pandit/vendor role and the territory. There is **no admin approval
step anywhere in this flow**.

### 8.5 `GET /api/pay/{ref}` — poll status

```json
{
  "ok": true,
  "data": {
    "txn": {
      "id": 137,
      "ref": "PMT-2026-000118",
      "context": "user_package",
      "context_id": "346",
      "gateway": "razorpay",
      "amount": "119.99",
      "currency": "USD",
      "status": "pending",
      "gw_order_id": "order_TlKdsOJtTIccIS",
      "gw_payment_id": null,
      "utr": null,
      "refunded_amount": "0.00",
      "payer_name": "Jaydip Dabhi",
      "payer_contact": null,
      "created_at": "2026-10-08 12:59:51",
      "paid_at": null,
      "amount_inr": "9999.00",
      "fx_rate": "0.012000",
      "base_currency": "INR"
    }
  }
}
```

`status`: `created` → `pending` → `paid` | `failed` | `partly_refunded` | `refunded`.

`amount_inr` + `fx_rate` + `base_currency` are the ledger's INR view of the
charge — useful for reports, never for the UI.

### 8.6 UPI alternative

If `gateway` is `upi`, show `upi_id` / `upi_link`, take the UTR, then:

```
POST /api/pay/{ref}/upi     { "utr": "12-digit-reference" }
```

The package activates once the payment is confirmed.

### 8.7 Webhook

```
POST /api/pay/webhook
```

Razorpay calls this. Nothing for the client to do — it is the safety net when a
user closes the browser before `capture` runs.

---

## 9. Free tier — no payment at all

Applying with the free plan grants immediately. `auto_granted` is `true`,
`status` is `approved`, and **`payment_due` is `null`** (not `0`):

```json
{ "ok": true, "data": {
  "request_id": 84,
  "category": "FPR",
  "documents": 4,
  "status": "approved",
  "package_map_id": 354,
  "auto_granted": true,
  "payment_due": null } }
```

> **A franchise still needs a territory, even on the free tier.** Omitting
> `territory_code` returns:
> ```json
> { "ok": false, "error": "Please select a franchise territory to apply." }
> ```
> Pandit and Vendor apply with no territory at all.

So: **open the payment modal only when `auto_granted` is false.**

After the grant, everything below reflects it — no admin step, no waiting:

```json
// GET /api/upgrade/state
"memberships": [ { "code": "FPR", "name": "Franchise Partner" },
                 { "code": "USR", "name": "User / Clients" } ],
"pending": null

// GET /api/my/packages
FPR: Prabandh Aarambh (Free)  status=active  pay=paid  needs_payment=false
USR: Yajmaan Aarambh (Free)   status=active  pay=paid  needs_payment=false

// GET /api/my/franchise/territory
{ "territory": { "territory_key": "jh", "territory_name": "Jharkhand",
                 "franchise_user_id": 268, "is_active": 1 },
  "required": true, "has_territory": true, "needs_territory": false }
```

After **any** apply (free or paid), refresh:

```
GET /api/auth/me
GET /api/my/packages
GET /api/my/franchise/territory
```

---

## 10. Franchise territory

Only **Franchise** needs a territory. Pandit and Vendor apply without one.

### `GET /api/franchise/plans` — plans **and** territories in one public call

```json
{
  "ok": true, "data": {
    "category": "FPR", "category_name": "Franchise Partner",
    "currency": "INR", "symbol": "₹",
    "plans": [ { "id": 6, "code": "PRB_PREMIUM", "price_display": "₹9,999",
                 "price_currency": "INR", "price_base_inr": 9999, "is_current": false } ],
    "territories": [
      { "code": "GA", "name": "Goa", "level": "state", "parent_code": "IN",
        "label": "Goa", "taken": false, "mine": false }
    ] } }
```

| Territory field | Notes |
|---|---|
| `level` | `"country"` or `"state"` — **only claim `state`** |
| `taken` | another franchise holds it or has an open application → disable |
| `mine` | already yours → show as selected |
| `parent_code` | filter the list to the user's own country |

103 territories are loaded (3 countries, 100 states/provinces).

### `GET /api/my/franchise/territory/options`

Same territory list, but requires auth and is scoped to the caller. Use it when
you want `taken`/`mine` computed against the signed-in user:

```json
{
  "ok": true,
  "data": {
    "options": [
      { "code": "GA", "name": "Goa", "level": "state", "parent_code": "IN",
        "label": "Goa", "taken": false, "mine": false }
    ],
    "level": "state"
  }
}
```

`/franchise/plans` already embeds `territories`, so one call is usually enough —
use this endpoint if you need the list on its own.

### Do not re-ask for the country and state

They are already on the user's profile. Show the country read-only and pre-select
the state; only ask if their profile state is already taken by someone else.

### `GET /api/my/franchise/territory`

```json
{ "ok": true, "data": {
  "territory": null, "required": false,
  "has_territory": false, "needs_territory": false } }
```

| Field | Notes |
|---|---|
| `has_territory` | do they actually hold one — **branch on this** |
| `needs_territory` | franchise with none → show the claim screen |
| `required` | permanently `true` for any franchise. **Do not branch on it** |

### `POST /api/my/franchise/territory` — self-service, no admin

```json
{ "territory_code": "GA", "territory_name": "Goa" }
```

---

## 11. Error catalogue

| Status | Body | What it means | What to do |
|---|---|---|---|
| `422` | `errors.category = ["The category field is required."]` | body was sent as JSON, the whole payload failed to parse | switch to `FormData` / form-data |
| `422` | `"Mandatory document missing: PAN"` | the `doc_<CODE>` file inputs never arrived | same — check body type and field names |
| `422` | `"This territory is already assigned…"` | someone claimed it first | refetch `territories`, let them re-pick |
| `422` | `"Please select a franchise territory to apply."` | franchise sent with no `territory_code` | send the territory (free tier needs it too) |
| `422` | `errors.phone = [...]` | missing or malformed mobile | show the message, re-ask |
| `422` | `errors.password = [...]` | under 6 characters | |
| `409` | `"An account with this email already exists…"` | | route to login |
| `401` | | token missing/expired | clear the token, redirect to login |
| `404` | `"Not found"` | bad `ref` or `mapId` | |
| `429` | `"Too Many Attempts."` | over 120 requests/min | back off ~8s and retry |
| `422` | `"Signature verification failed."` | gateway mismatch or a tampered signature | retry the payment from `/pay` |

---

## 12. Frontend integration checklist

### After register / login

```
store token
store user.currency        ("INR" | "USD")
store user.city, user.state, user.country_code
```

### After saving the profile with a city

```
GET /api/public/currency    → the currency flipped; re-render every price
GET /api/upgrade/state      → the document list may have changed (IN vs INTL)
```

### Where prices come from — only two sources

1. `GET /api/franchise/plans` → `plans[].price_display`
2. `POST /api/my/packages/{mapId}/pay` → `checkout.amount` (the real charge)

Never multiply `price_base_inr` yourself. `rate: 0.012` is for an estimate badge
only.

### Common mistakes

| ❌ Wrong | ✅ Right |
|---|---|
| Deriving currency from the city on the client | render `locked_currency` from the API |
| Deriving currency from the phone dial code | the phone has no effect at all |
| `apply` with a JSON body | `FormData` / form-data |
| `docs[PAN][file]`, `files[]` | `doc_PAN`, `doc_ADH`, `doc_BUS`, `doc_TAX` |
| Hard-coding `["PAN","ADH","BUS","TAX"]` | build from `mandatory[].code` |
| `POST /api/my/packages/6/pay` | `POST /api/my/packages/270/pay` (`package_map_id`) |
| `amount: 9999` to Razorpay | `amount: 999900` (subunits) |
| Showing `amount_inr` to the user | show `amount` + `currency` |
| Setting `Content-Type: multipart/form-data` yourself | let the client set it with the boundary |
| Branching on `territory.required` | branch on `has_territory` |
| Expecting an admin approval screen | capture auto-grants everything |
| Asking for country/state again on the upgrade page | read it from the profile |

### Rate limiting

All routes are throttled to **120 requests/minute**. Fire the bootstrap calls
once (auth/me + currency + state), not on every render. On `429`, wait ~8s.

---

## 13. Verified vs not

**Live-verified end to end** (both currencies):

- register → `201`, currency resolved from the city, phone required, validation errors
- login by email **and** by mobile, wrong-password error, `/auth/me`
- location cascade: countries, 36 Indian states, 103 territories, district fallback, IP for guests
- upgrade state, per-category/per-region document requirements, plan pricing in ₹ and $
- apply with correct multipart → `201` + `package_map_id` + `payment_due`
- apply with a JSON body → the exact two error messages above
- territory-already-taken error
- `/my/packages` with `needs_payment`
- `/pay` → Razorpay `key_id` + `gw_order_id` + correct amount per currency
- `/pay/{ref}` status polling, capture rejecting a fake signature
- free tier auto-granting without payment

**Not exercisable without a live gateway:** a *successful* `/pay/{ref}/capture`
needs a genuine Razorpay `razorpay_payment_id` + `razorpay_signature` pair. It
cannot be faked — that is the point of the signature check. Test it once from a
browser in Razorpay test mode, then the auto-grant runs for real.

---

## 14. Before production

- **Razorpay International Payments** must be enabled on the account for `USD`
  checkout, plus purpose-code approval.
- `.env` still holds **test** keys — the pay response reports `mode: "test"`.
  Swap to live keys and confirm `mode: "live"`.
- Only **Andhra Pradesh** has real city rows. Other Indian states show districts;
  add `level='CITY'` rows to `geographic_nodes` for true city dropdowns.
- Only 3 countries (IN, US, CA) exist in the territory master. Add more before
  opening other markets.

---

## 15. Related documents

| Document | Purpose |
|---|---|
| `docs/api-country-pricing.md` | full currency, pricing and ledger reference |
| `docs/api-auth.md` | auth flows, forgot password, OTP |
| `docs/postman-country-payment.md` | step-by-step Postman walkthrough |
| `docs/postman-country-payment.postman_collection.json` | importable collection |