# Registration → Franchise → Payment — Integration Guide

**For:** the mobile app / website frontend teams
**Backend:** this repo (`vpandit-api`)
**Status:** every request/response below was captured from the live API

---

## 1. The flow in one picture

```
  ┌─ 1. REGISTER ──────────────────────────────────────────┐
  │  POST /api/auth/register                               │
  │  user picks a CITY → server derives country + currency  │
  │  Delhi → IN → ₹        Toronto → CA → $                │
  └───────────────────────────┬─────────────────────────────┘
                              │
  ┌─ 2. PROFILE (optional, anytime) ───────────────────────┐
  │  POST /api/user-profile  {"city":"Toronto"}            │
  │  country + currency re-price instantly                  │
  └───────────────────────────┬─────────────────────────────┘
                              │
  ┌─ 3. CHOOSE A FRANCHISE PLAN ───────────────────────────┐
  │  GET  /api/franchise/plans      (no auth, public)      │
  │  GET  /api/upgrade/state        (auth)                 │
  └───────────────────────────┬─────────────────────────────┘
                              │
  ┌─ 4. APPLY (upload KYC) ───────────────────────────────┐
  │  POST /api/upgrade/apply   ← multipart/form-data       │
  │  → package_map_id + payment_due                        │
  └───────────────────────────┬─────────────────────────────┘
                              │
  ┌─ 5. PAY ───────────────────────────────────────────────┐
  │  POST /api/my/packages/{package_map_id}/pay             │
  │  → checkout { ref, amount, currency, key_id, gw_order_id }
  │  open Razorpay with those values                        │
  │  POST /api/pay/{ref}/capture    (on success)            │
  └───────────────────────────┬─────────────────────────────┘
                              │
  ┌─ 6. AUTO-GRANT (server-side, no admin) ────────────────┐
  │  payment captured → FPR role + membership + territory   │
  │  + KYC verified, all in one shot                       │
  │  → GET /api/auth/me to refresh, show the success screen│
  └─────────────────────────────────────────────────────────┘
```

**There is no admin approval step anywhere in this flow.** A captured payment is
enough — the backend grants the franchise itself.

---

## 2. The one rule you must implement correctly

### The selected **city** decides the currency. Nothing else does.

| User picks city | Country stored | Currency they pay in |
|---|---|---|
| Delhi, Mumbai, Ahmedabad, Surat, Pune, Jaipur… | `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** |

### Rules to code against

1. **City beats the country dropdown.** If the app sends `country_code: "IN"`
   together with `city: "Toronto"`, the user pays **USD** and the backend
   silently corrects the stored country to `CA`. Do not fight this — either hide
   the country field or let it be overridden.

2. **The phone number has zero influence.** A `+1` number with `city: "Delhi"` pays
   INR. A `+91` number with `city: "Toronto"` pays USD. Never infer currency from
   a dial code.

3. **Never compute the currency client-side.** Call
   `GET /api/public/currency` and render `locked_currency`. Do not derive from
   city, country, IP or your own FX table.

4. **Changing the city re-prices instantly** — no logout, no re-registration. After
   saving the profile, call `GET /api/public/currency` again and update every
   price on screen.

---

## 3. Endpoints, with real payloads

### 3.1 `POST /api/auth/register`

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

`201`:

```json
{
  "ok": true,
  "data": {
    "token": "1019|vp_PJX1OFhNMC5J4hUfJx9wCqCVMQGAqJjzYrs3egkNd21f9e4e",
    "user": {
      "id": 207,
      "name": "John Doe",
      "email": "john@example.com",
      "role": "client",
      "phone": "+919812345678",
      "country_code": "CA",
      "currency": "USD",
      "profile_complete": false
    },
    "country_code": "CA",
    "currency": "USD"
  }
}
```

| Field | Notes |
|---|---|
| `data.token` | store it; every later call needs `Authorization: Bearer <token>` |
| `data.user.currency` | `"INR"` or `"USD"` — already resolved from the city |
| `data.user.country_code` | ISO-2 derived from the city |

`city` is the only field that drives money. `country_code` is optional and is
overwritten by the city when the city is recognised.

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

No auth required, but **authenticated wins**. Call this on app start and after any
profile save.

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

| Field | Meaning |
|---|---|
| `locked_currency` | **render this** — the currency they must be charged in |
| `locked` | `true` when a city/country fixes the currency |
| `symbol` | `₹` or `$` |
| `rate` | USD per 1 INR — for estimates only, never for the real charge |
| `country` | always `null` for this endpoint; read `country_code` from `/api/auth/me` |

When `locked: true`, `POST /api/public/currency/switch` returns the locked currency
and **does not switch** — that is correct, not a bug.

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

56 countries, each already carrying its currency — no client-side mapping table.

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

### 3.4 `GET /api/franchise/plans` — public plan + territory list

**No auth.** This is the one call that gives you plans *and* claimable territories
together, so the pricing screen needs a single request.

```json
{
  "ok": true,
  "data": {
    "category": "FPR",
    "category_name": "Franchise Partner",
    "currency": "INR",
    "symbol": "₹",
    "plans": [
      {
        "id": 4, "code": "PRB_AARAMBH", "name": "Prabandh Aarambh (Free)",
        "price_amount": 0, "price_currency": "INR", "price_display": "₹0",
        "price_base_inr": 0, "is_current": false
      },
      {
        "id": 6, "code": "PRB_PREMIUM", "name": "Prabandh Premium",
        "price_amount": 9999, "price_currency": "INR", "price_display": "₹9,999",
        "price_base_inr": 9999, "is_current": false
      }
    ],
    "territories": [
      { "code": "GJ", "name": "Gujarat", "level": "state",
        "parent_code": "IN", "label": "Gujarat", "taken": false, "mine": false }
    ]
  }
}
```

For a Canadian visitor the same plans come back in dollars:

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

Territory fields:

| Field | Meaning |
|---|---|
| `level` | `"country"` or `"state"` — **only claim `state`** |
| `taken` | another franchise holds it or has an open application → disable it |
| `mine` | already yours → show as selected |

Filter the dropdown to the visitor's own country using `parent_code` against
`country_code` from `/api/auth/me`.

### 3.5 `GET /api/upgrade/state` — auth

```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": [
          { "code": "PAN", "name": "PAN Card", "file_types": null },
          { "code": "ADH", "name": "Aadhar Card", "file_types": null },
          { "code": "BUS", "name": "Business Registration", "file_types": null },
          { "code": "TAX", "name": "Tax Registration", "file_types": null }
        ],
        "optional": [],
        "packages": [ /* same shape as plans */ ]
      }
    ]
  }
}
```

**Build the upload form from `mandatory[]` — never hard-code the document list.**
It changes with the user's country:

| Country | `mandatory[].code` |
|---|---|
| India | `PAN`, `ADH`, `BUS`, `TAX` |
| Everyone else | `BUS`, `TAX`, `intl_id_proof`, `intl_income_tax` |

`intl_*` rows carry `file_types: "jpg,jpeg,png,pdf,webp"` — honour it for the
file-picker `accept` attribute. Indian rows send `file_types: null`, so fall back
to `jpg, jpeg, png, pdf`.

### 3.6 `POST /api/upgrade/apply` — ⚠️ multipart/form-data

**This must be `multipart/form-data`. Sending it as JSON silently loses every
field** and returns:

```json
{ "ok": false, "error": "Validation failed",
  "errors": { "category": ["The category field is required."] } }
```

That error means *wrong body type*, not a missing field. Postman: **Body →
form-data**. React Native / fetch: append to `FormData`, never `JSON.stringify`.

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

The field name is **`doc_PAN`, `doc_ADH`, `doc_BUS`, `doc_TAX`**,
`doc_intl_id_proof`, `doc_intl_income_tax` — built as `"doc_" + code`.

> ❌ Do **not** use `docs[PAN][file]`, `files[]` or an `documents[]` JSON blob.
> The controller reads `doc_{CODE}` and ignores everything else.

```js
const fd = new FormData();
fd.append('category', 'FPR');
fd.append('package_id', '6');
fd.append('territory_code', 'AR');
fd.append('territory_name', 'Arunachal Pradesh');
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, the browser sets the boundary
});
```

`201`:

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

| Field | Notes |
|---|---|
| `package_map_id` | **this is the `{mapId}` for the pay call** |
| `payment_due` | INR figure; the actual charged amount comes from `/pay` |
| `auto_granted` | `true` only for the free tier (no payment needed) |
| `status` | `kyc_pending` while payment is outstanding |

### 3.7 `GET /api/my/packages` — auth

```json
{
  "ok": true,
  "data": {
    "packages": [
      {
        "id": 270,
        "category_code": "FPR",
        "package_name": "Prabandh Premium",
        "package_price": "9999.00",
        "status": "applied",
        "payment_status": "pending",
        "needs_payment": true,
        "paid_amount": null,
        "currency": "INR",
        "paid_amount_inr": null,
        "fx_rate": null,
        "payment_ref": null,
        "paid_at": null,
        "start_date": "2026-10-08",
        "expiry_date": "2026-11-08",
        "days_left": 31,
        "expiring_soon": false,
        "expired": false
      }
    ]
  }
}
```

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

> Before payment, `package_price` / `currency` show the **base INR** price for
> everyone. That is expected. The amount the user will actually be charged is
> `checkout.amount` from the pay call — always take it from there.

### 3.8 `POST /api/my/packages/{mapId}/pay` — auth

`{mapId}` is `package_map_id` from the apply response, **not** `package_id`.

```json
{}
```

`200`:

```json
{
  "ok": true,
  "data": {
    "checkout": {
      "ref": "PMT-2026-000084",
      "gateway": "razorpay",
      "amount": 119.99,
      "currency": "USD",
      "amount_inr": 9999,
      "key_id": "rzp_test_…",
      "gw_order_id": "order_TlHg7IEd0CTslf",
      "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, you need it for capture |

For an Indian user the same call returns `"amount": 9999, "currency": "INR"`.

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

### 3.9 Capture the payment

```
POST /api/pay/{ref}/capture
Authorization: Bearer <token>
```

`{ref}` is `checkout.ref`. On success the backend auto-grants the franchise, then
return the user to the app and call:

```
GET  /api/auth/me
GET  /api/upgrade/state
GET  /api/my/franchise/territory
```

### 3.10 `GET /api/my/franchise/territory` — auth

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

| Field | Meaning |
|---|---|
| `has_territory` | do they actually hold one — **branch on this** |
| `needs_territory` | franchise with no territory → show the claim screen |
| `required` | permanently `true` for any franchise; **do not use for branching** |

A franchise can claim or switch their own state with no admin:

```
POST /api/my/franchise/territory   { "territory_code": "GJ", "territory_name": "Gujarat" }
```

---

## 4. Frontend implementation notes

### 4.1 What to persist

| When | Store |
|---|---|
| After register / login | `token` |
| After register / login | `currency` (`"INR"` / `"USD"`) |
| After register / login | `country_code` |
| On app start | re-fetch `GET /api/public/currency`, overwrite `currency` |

### 4.2 Where prices come from

Always the server. There are exactly two price sources:

- `GET /api/franchise/plans` → `plans[].price_display` (already formatted, e.g. `$119.99`)
- `POST /api/my/packages/{mapId}/pay` → `checkout.amount` (the real charge)

Never multiply `price_base_inr` by your own rate. `rate: 0.012` in
`/api/public/currency` is for a rough estimate badge only.

### 4.3 Refresh points

| After | Re-fetch |
|---|---|
| register / login | `/api/public/currency`, `/api/auth/me` |
| saving profile **with a city** | `/api/public/currency`, `/api/upgrade/state` |
| successful apply | `/api/my/packages` |
| successful capture | `/api/auth/me`, `/api/upgrade/state`, `/api/my/franchise/territory` |

### 4.4 Error handling

| Status | Meaning | What to do |
|---|---|---|
| `422` + `errors.category` on apply | wrong body type | switch to `FormData` / `form-data` |
| `422` + `errors.<doc field>` | bad file type or > 8 MB | show the message, re-pick the file |
| `422` "This territory is already assigned…" | someone claimed it first | refresh `territories`, let them re-pick |
| `409` on register | email already exists | route to login |
| `429` | rate limit hit | back off and retry — see below |

### 4.5 Rate limiting

All API routes are throttled to **120 requests/minute**. A page that fires register
+ currency + plans + state + packages + territory in parallel is fine, but do not
put this in a `useEffect` that re-runs on every render. On `429`, wait ~8s and
retry.

---

## 5. Common mistakes

| ❌ Wrong | ✅ Right |
|---|---|
| Deriving currency from `city` on the client | Render `locked_currency` from the API |
| Deriving currency from the phone dial code | Phone has no effect at all |
| `POST /api/upgrade/apply` with a JSON body | `multipart/form-data` / `FormData` |
| `docs[PAN][file]`, `files[]`, `documents[]` | `doc_PAN`, `doc_ADH`, `doc_BUS`, `doc_TAX` |
| Hard-coded `["PAN","ADH","BUS","TAX"]` | Build from `mandatory[].code` in `/api/upgrade/state` |
| `POST /api/my/packages/6/pay` (package id) | `POST /api/my/packages/270/pay` (`package_map_id`) |
| Sending `amount: 9999` to Razorpay | `amount: 999900` (subunits) |
| Showing `amount_inr` to the user | Show `amount` + `currency` |
| Branching on `territory.required` | Branch on `territory.has_territory` |
| Manually setting `Content-Type: multipart/form-data` | Let the client set it — the boundary is required |
| Expecting an admin approval screen | Payment capture auto-grants everything |

---

## 6. Verification checklist

Run this end-to-end after each release. Two personas: **Delhi** (INR) and
**Toronto** (USD).

- [ ] Register with `city: "Delhi"` → `currency: "INR"`, `country_code: "IN"`
- [ ] Register with `city: "Toronto"` → `currency: "USD"`, `country_code: "CA"`
- [ ] Register `country_code:"IN"` + `city:"Toronto"` → still **USD**, country becomes `CA`
- [ ] Save profile `{"city":"Toronto"}` → currency flips with no re-login
- [ ] `/api/franchise/plans` returns 4 plans in the visitor's own currency
- [ ] `/api/upgrade/state` `mandatory[]` differs: `PAN,ADH,BUS,TAX` vs `BUS,TAX,intl_id_proof,intl_income_tax`
- [ ] Apply as `FormData` → `201`, `package_map_id` + `payment_due` present
- [ ] `/api/my/packages` shows `needs_payment: true`
- [ ] `/pay` returns `amount` 9999 (INR) / 119.99 (USD) with matching `currency`
- [ ] After capture, `/api/auth/me` shows the FPR membership and
      `/api/my/franchise/territory` shows `has_territory: true`
- [ ] Free plan (`package_id: 4`) → `auto_granted: true`, no pay step

---

## 7. Related documents

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

**Open items before production**

- Razorpay International Payments must be enabled on the account for `USD`
  checkout, plus purpose code approval.
- Test keys are currently in `.env` (`mode: "test"` in the pay response). Swap to
  live keys and confirm `mode: "live"`.
- Territory codes come from the `geographic_nodes` master — 3 countries
  (`IN`, `CA`, `US`) and 92 states are loaded. Add more before enabling other
  markets.