# API Changes — What To Implement

**Only new + changed APIs and fields.** Nothing that already worked is repeated.

**Base URL:** `…/api` · **Auth:** `Authorization: Bearer <token>` · Always `Accept: application/json`

---

## 1. `POST /auth/register` — 3 changes

### Request — `phone` is now required, and 3 location fields are new

```json
{
  "name": "Jaydip Dabhi",
  "email": "jaydip@example.com",
  "phone": "+919876543210",
  "password": "secret123",
  "city": "Delhi",
  "country_code": "IN",
  "state": "DL"
}
```

| Field | Change |
|---|---|
| `phone` | **now required** · regex `^\+?[0-9]{7,15}$` · no spaces |
| `city` | **new** · free text or a code from §3 · decides the currency |
| `state` | **new** · state code, e.g. `DL` |
| `country_code` | **new** · ISO-2 · fallback only, city wins |

### Response — `data.user` gained 3 fields

```
city, state, country_name
```

Full `user` key order: `id, name, email, role, phone, dob, gender, tob, pob,
country_code, currency, city, state, country_name, profile_complete`

`data.currency` is already resolved: `"INR"` or `"USD"`.

### New errors

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

{ "ok": false, "error": "Validation failed",
  "errors": { "phone": ["The phone field format is invalid."] } }   // "abc", "12", spaces
```

---

## 2. `POST /auth/login` — 1 change

`email` now also accepts the **mobile number**, with or without `+91`:

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

Keep the field name `email`. Update the input label/placeholder to "Email or
mobile". Wrong credentials → `{ "ok": false, "error": "Invalid email/mobile or password" }`.

---

## 3. NEW — `GET /api/public/locations`

Country → State → City dropdowns from the `geographic_nodes` master.

```
GET /api/public/locations?level=country
GET /api/public/locations?level=state&parent=IN
GET /api/public/locations?level=city&parent=GJ
```

Response: `{ "ok": true, "data": { "level", "parent", "source", "note", "options": [...] } }`

| Option field | Notes |
|---|---|
| `code` | send this back as `state` / `territory_code` |
| `name`, `label` | display |
| `level` | `country` / `state` / `district` |
| `parent_code` | filter states by country |
| `dial` | country rows only — seed the phone |
| `currency` | country rows only |

**Check `source`** on a city response — it tells you what you actually got:

| `source` | Show |
|---|---|
| `city` | the returned cities (only Andhra Pradesh has these today) |
| `district` | the returned districts + the `note` text |
| `empty` | fall back to a free-text city input (all US/CA states) |

**Also new:** `GET /api/public/locations/one?country=IN&state=GJ` returns country
+ states + cities for that state in one call.

Currently loaded: **3 countries** (IN/US/CA) · **36 Indian states** (28 states +
8 UTs incl. Delhi) · 51 US · 13 CA.

---

## 4. `GET /api/public/currency` — 2 new fields

```
currency, symbol, supported, country, rate, locked, locked_currency
                                          ^^^^^^  ^^^^^^^^^^^^^^^^  NEW
```

- **`locked`** — `true` = this account's city fixes the currency. Show a static
  badge, **do not offer the INR/USD toggle**, and expect
  `POST /public/currency/switch` to be refused.
- **`locked_currency`** — **render this.** Non-null when locked.

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

> The **city** decides this. Not the country field, not the phone number.
> `rate` is for an estimate badge only — never use it for the real charge.

**Selecting the United States or Canada really does bill in dollars.** Verified
for all three categories:

| City / country | Plan | `checkout.amount` | `amount_inr` | `fx_rate` |
|---|---|---|---|---|
| New York, US | Prabandh Premium | **$119.99 USD** | 9999 | 0.012000 |
| Toronto, CA | Prabandh Premium | **$119.99 USD** | 9999 | 0.012000 |
| Toronto, CA | Nidhi Basic | **$17.99 USD** | 1499 | 0.012001 |
| New York, US | Āchārya Premium | **$35.99 USD** | 2999 | 0.012001 |

The INR base price is retained for the ledger, and `amount_inr × fx_rate` always
reconciles back to the charged amount. Note `txn.fx_rate` is the **realised** rate
for that specific amount, so its 6th decimal can differ from the configured
`rate: 0.012` on `/public/currency`.

### ⚠️ Dollar card payments need Razorpay International Payments

A USD charge is an **international** transaction. Unless the merchant account has
International Payments activated, Razorpay refuses the card at checkout with
*"International cards are not supported"* — and the payer never reaches our
`/capture` call, so the failure shows up inside the Razorpay window.

`PAY_INTERNATIONAL_ENABLED` in `.env` is the honest switch for this:

| Value | What happens for a USD package payment |
|---|---|
| `false` *(set now)* | `POST /api/my/packages/{id}/pay` → `422` **before** any card screen: *"Card payments in dollars are not available right now. Please contact support or ask us to invoice this amount in Indian rupees."* |
| `true` | The Razorpay checkout opens in dollars |

To actually enable it: **Razorpay Dashboard → Account & Settings → Payment
methods → International Payments**, and get their approval for the purpose code.
Once done, set `PAY_INTERNATIONAL_ENABLED=true`.

Indian payments (INR card + UPI) are unaffected either way.

The payment modal also maps Razorpay's merchant-facing wording into something a
customer can act on, and says so when the window is dismissed, so nobody is left
thinking they were charged.

---

## 5. `GET/POST /api/user-profile` — location save

`POST` body (partial is fine, it merges):

```json
{ "country": "IN", "state": "DL", "city": "Delhi" }
```

The city wins: `country: "IN"` + `city: "Toronto"` → response returns
`"country": "CA"` and the account flips to **USD**. After saving, re-fetch
`/api/public/currency` and `/api/upgrade/state` (the document list differs by region).

---

## 6. `GET /api/upgrade/state` — no new endpoint, use it as-is

```
option:      code, name, mandatory, optional, packages
mandatory:   code, name, file_types        ← file_types may be null
```

**Build the upload form from `mandatory[]` — never hard-code it.** It changes by
region, and it is the admin's Category Document Requirements table:

| Category | India | Everywhere else |
|---|---|---|
| `FPR` | `PAN, ADH, BUS, TAX` | `BUS, TAX, intl_id_proof, intl_income_tax` |
| `PND` | `ADH` | `intl_id_proof` |
| `VND` | `PAN, ADH` | `intl_id_proof, intl_income_tax` |

Packages now carry pricing fields — **render `price_display`** (already formatted):

```
price_amount, price_currency, price_symbol, price_display, price_base_inr, price_source
```

---

## 7. `POST /api/upgrade/apply` — field names + new response

**Must be `multipart/form-data`.** JSON silently loses the files.

| Field | Notes |
|---|---|
| `category` | `FPR` / `PND` / `VND` |
| `package_id` | omit for the free tier |
| `territory_code`, `territory_name` | franchise only — **required, free tier too** |
| `doc_<CODE>` | file, one per `mandatory[].code` → `doc_PAN`, `doc_ADH`, `doc_BUS`, `doc_TAX`, `doc_intl_id_proof` |
| `docnum_<CODE>` | optional text |

> ❌ Not `docs[PAN][file]`, not `files[]`, not a JSON `documents[]`.
> Let the client set `Content-Type` — the boundary is required.

### New response fields

```
request_id, category, documents, status, package_map_id, auto_granted, payment_due
```

```json
{ "ok": true, "data": {
  "request_id": 50, "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 `/pay`** |
| `auto_granted` | `true` on the free tier → **skip the payment modal entirely** |
| `payment_due` | `null` when auto-granted, else the INR figure |
| `status` | `kyc_pending` while paying, `approved` when granted |

### New / clarified errors

```json
// franchise sent no territory (happens even on the free tier)
{ "ok": false, "error": "Please select a franchise territory to apply." }

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

// body was sent as JSON → the files never arrived
{ "ok": false, "error": "Mandatory document missing: PAN" }

// body wasn't valid JSON at all
{ "ok": false, "error": "Validation failed",
  "errors": { "category": ["The category field is required."] } }
```

---

## 8. `GET /api/my/packages` — new fields

```
categories, packages, renewals, unpaid
```

Package row now has 8 new fields:

| Field | Notes |
|---|---|
| `needs_payment` | **drive the Pay button off this** |
| `paid_amount` | what they actually paid |
| `currency` | `INR` / `USD` |
| `paid_amount_inr` | ledger only, never show |
| `fx_rate` | ledger only |
| `payment_ref` | the `PMT-…` reference |
| `paid_at` | timestamp |

> **Before payment, `package_price` and `currency` still show the base INR price**
> even for a USD user. Expected. The real charge is `checkout.amount` from §9.

---

## 9. NEW — the payment trio

### `POST /api/my/packages/{package_map_id}/pay`

```
POST /api/my/packages/346/pay     ← mapId from apply, NOT package_id
```

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

A USD user gets `"amount": 119.99, "currency": "USD", "amount_inr": 9999`.

### Open Razorpay, then capture

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

```js
new window.Razorpay({
  key: checkout.key_id,
  order_id: checkout.gw_order_id,
  amount: Math.round(Number(checkout.amount) * 100),
  currency: checkout.currency,
  prefill: { name: user.name, email: user.email,
             contact: String(user.phone || "").replace(/\D/g, "") },
  handler: async (r) => {
    await capturePayment(checkout.ref, {
      gw_payment_id: r.razorpay_payment_id,
      signature: r.razorpay_signature,
    });
    // success — the backend already granted everything
  },
}).open();
```

### NEW — `POST /api/pay/{ref}/capture`

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

**Capture auto-grants everything** — package, role, territory. **There is no
admin approval step.** A bad signature returns:

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

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

`data.txn` gained `amount_inr`, `fx_rate`, `base_currency` (ledger view).
`status`: `created` → `pending` → `paid` / `failed` / `refunded`.

---

## 10. `GET /api/franchise/plans` — new public endpoint

Plans **and** claimable territories in one call, no auth:

```json
{ "ok": true, "data": {
  "category": "FPR", "currency": "INR", "symbol": "₹",
  "plans":      [ { "id", "code", "name", "price_display", "price_currency",
                    "price_base_inr", "is_current", … } ],
  "territories": [ { "code", "name", "level", "parent_code", "label",
                     "taken", "mine" } ] } }
```

| Territory field | Notes |
|---|---|
| `level` | only `state` is claimable |
| `taken` | held/reserved by someone else → disable |
| `mine` | already yours |
| `parent_code` | filter to the user's own country |

**Do not re-ask for country and state here** — they are already on the profile.
Show the country read-only and pre-select the state; only ask if their profile
state is already `taken`.

---

## 11. `GET /api/my/franchise/territory` — new flags

```
territory, required, has_territory, needs_territory
```

| 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 switch

**Only for existing franchises.** A non-franchise gets:

```json
{ "ok": false, "error": "Only an active franchise can be assigned a territory." }   // 403
```

A brand-new user gets their territory automatically from the **apply** call
(§7) — no separate claim needed. This endpoint is for *switching* afterwards.

**Field names are `code` / `name`** — not `territory_code` / `territory_name`:

```json
{ "code": "ML", "name": "Meghalaya" }
```

`code` must be a **STATE** node. A district code is rejected:

```json
{ "ok": false, "error": "Please select a state as your franchise territory." }   // 422
```

---

## 12. Field reference — added / changed, all in one list

**Request fields added**

| Endpoint | Field | Type |
|---|---|---|
| `POST /auth/register` | `phone` | **now required**, `^\+?[0-9]{7,15}$` |
| `POST /auth/register` | `city` | string |
| `POST /auth/register` | `state` | string |
| `POST /auth/register` | `country_code` | ISO-2 |
| `POST /auth/login` | `email` | now accepts a mobile number |
| `POST /api/user-profile` | `country` | ISO-2 |
| `POST /api/upgrade/apply` | `territory_code` | **required for franchise** |
| `POST /api/upgrade/apply` | `territory_name` | |
| `POST /api/upgrade/apply` | `doc_<CODE>` | file |
| `POST /api/upgrade/apply` | `docnum_<CODE>` | string |
| `POST /api/pay/{ref}/capture` | `gw_payment_id` | string |
| `POST /api/pay/{ref}/capture` | `signature` | string |

**Response fields added**

| Endpoint | Field |
|---|---|
| `POST /auth/register`, `POST /auth/login`, `GET /auth/me` | `user.city`, `user.state`, `user.country_name` |
| `GET /api/public/currency` | `locked`, `locked_currency` |
| `GET /api/upgrade/state` | `mandatory[].file_types`, package `price_display`, `price_amount`, `price_currency`, `price_symbol`, `price_base_inr`, `price_source` |
| `POST /api/upgrade/apply` | `request_id`, `documents`, `status`, `package_map_id`, `auto_granted`, `payment_due` |
| `GET /api/my/packages` | `needs_payment`, `paid_amount`, `currency`, `paid_amount_inr`, `fx_rate`, `payment_ref`, `paid_at` |
| `POST /api/my/packages/{id}/pay` | whole `data.checkout` — `ref`, `gateway`, `amount`, `currency`, `amount_inr`, `key_id`, `gw_order_id`, `mode` |
| `GET /api/pay/{ref}` | `txn.amount_inr`, `txn.fx_rate`, `txn.base_currency` |
| `GET /api/franchise/plans` | whole endpoint |
| `GET /api/public/locations` | whole endpoint |
| `GET /api/my/franchise/territory` | `has_territory`, `needs_territory` |

**Field names that are easy to get wrong**

| Endpoint | Send | ❌ Not this |
|---|---|---|
| `POST /api/upgrade/apply` | `territory_code`, `territory_name` | — |
| `POST /api/my/franchise/territory` | `code`, `name` | `territory_code`, `territory_name` |
| `POST /api/upgrade/apply` (files) | `doc_PAN`, `doc_ADH`, `docnum_PAN` | `docs[PAN][file]`, `files[]` |

**New endpoints**

```
GET  /api/public/locations
GET  /api/public/locations/one
GET  /api/franchise/plans
POST /api/pay/{ref}/capture
POST /api/my/franchise/territory      (self-service, was admin-only)
```

---

## 13. Implement in this order

1. **Register** — `phone` required, add the country/state/city dropdowns
2. **Login** — accept a mobile number, relabel the field
3. **Currency** — store `locked` + `locked_currency`, hide the toggle when locked
4. **Profile save** — send `country`/`state`/`city`, re-fetch currency after
5. **Upgrade page** — build docs from `mandatory[]`, send `doc_<CODE>` as
   `FormData`, send the territory, open payment only when `auto_granted` is false
6. **Payment** — `/pay` → Razorpay `×100` → `/capture`
7. **Packages screen** — drive the button off `needs_payment`
8. **Franchise territory** — read-only country, pre-selected state

---

## 14. Admin cannot undo a completed payment

Once a package is paid, admin `reject` / `cancel` **kaam nahi karenge**. Refund ke
liye alag route hai.

| Admin call on a paid package | Response |
|---|---|
| `POST /api/admin/user-packages/{userId}/reject/{mapId}` | `422` — *"This package has been paid for and cannot be rejected. Refund it through POST /api/pay/{ref}/refund instead."* |
| `POST /api/admin/user-packages/{userId}/cancel/{mapId}` | `422` — same wording, "cancelled" |
| `POST /api/upgrade/{id}/review` | `404` — *"Request not found or already reviewed."* |
| `POST /api/admin/franchise-territories/assign` | `422` if the code is not a real COUNTRY/STATE node |

A **pending / unpaid** package can still be rejected — the lock only applies once
the fee is settled. Refunds go through `POST /api/pay/{ref}/refund`, which keeps
the money trail intact.

---

## 15. Settlement reconciliation — what the bank actually received

A USD charge is booked against the catalogue's INR base, so `pay_txns` stores
`amount` (USD) **and** `amount_inr` plus an `fx_rate` **derived** from those two
numbers. That derived rate is not a market rate and not the rate the gateway used:
Razorpay converts to INR at its own rate, keeps a conversion fee, and settles INR to
the bank account. Before this, nobody could answer *"did we actually receive ₹799 for
that $9.99?"* — the FX spread was invisible.

| Endpoint | Purpose |
|---|---|
| `POST /api/admin/payments/settlements/sync` | Pull settlements + their transactions from the gateway (`count` 1–100, default 20) |
| `GET /api/admin/payments/settlements` | Money report (`limit` 1–200, default 50) |

Auth: `role:super_admin,trust_manager` — same guard as the other finance routes.

Sync response:
```json
{ "settlements_seen": 12, "settlements_stored": 12,
  "items_seen": 40, "items_matched": 38, "fx_loss_inr": 412.60,
  "errors": [], "message": "Settlements synced." }
```

Report shape:
```json
{ "totals": {
    "items": 40, "matched": 38, "unmatched": 2,
    "gateway_fees": 118.40, "gateway_tax": 17.76,
    "settled_net": 24380.10, "expected_inr": 24800.00,
    "fx_variance_inr": 419.90, "fx_variance_pct": 1.69, "flagged": 1 },
  "settlements": [ … ], "items": [ … ] }
```

**Reading the numbers**

- `fx_variance_inr` = `expected_inr − settled_net`. **Positive = the FX spread cost
  you that many rupees.** Negative means the settlement beat the booked figure.
- `flagged` marks any payment at or beyond `FX_VARIANCE_ALERT_PCT` (3%).
- `unmatched` is a settlement transaction that could not be linked to a `pay_txns`
  row. It is **kept and counted, never dropped** — that is exactly the case worth
  investigating.

Storage (self-healing, no migration):

| Table | Holds |
|---|---|
| `pay_settlements` | one row per gateway settlement — gross, status, `payout_id`, period, settled-on |
| `pay_settlement_items` | one row per settlement transaction — fees, tax, `settled_net`, `expected_inr`, `fx_variance` |
| `pay_txns.settlement_item_id` / `.settled_net` / `.fx_variance` | pointers on the ledger row so one payment traces end-to-end without joining |

Matching is by gateway `payment_id`, falling back to `order_id`. Re-running a sync is
**idempotent** — items are matched on the gateway's own id and updated in place.

> Test mode returns an empty settlement list, which is correct: settlements only
> accrue once real money moves. Verify the wiring with `sync` (expect a clean run and
> zero settlements), then re-check after going live.

## 16. USD rounding no longer overcharges small packages

Prices are displayed as `x.99`, but the old rule rounded unconditionally to the nearest
whole dollar minus a cent. On a small package that silently became an **overcharge**:
₹299 converts to `$3.59` yet was charged `$3.99` (+11% over the exact value, and +33%
over the ₹299 base).

`PriceBook::roundUsd()` now keeps the `.99` ending **only when it inflates the true
converted amount by no more than `USD_PSYCHOLOGICAL_MAX_PREMIUM` (2%)**. Above that
the exact figure stands.

| INR base | Exact USD | Was charged | Now charged |
|---|---|---|---|
| ₹299 | $3.59 | $3.99 ❌ | **$3.59** ✅ |
| ₹799 | $9.59 | $9.99 ❌ | **$9.59** ✅ |
| ₹4,999 | $59.99 | $59.99 | $59.99 |
| ₹9,999 | $119.99 | $119.99 | $119.99 |
| ₹19,999 | $239.99 | $239.99 | $239.99 |

The `.99` look survives wherever it was already effectively free. **This lowers prices
for small USD packages** — any pending USD order re-quotes at the lower amount.

## 17. Profile coordinates removed

`GET`/`POST /api/user-profile` no longer accept or return `lat`, `lon`, `cur_lat`,
`cur_lon`, `latitude` or `longitude` — the place name is the source of truth and the
server resolves coordinates itself. Sent values are ignored rather than rejected, so
older clients keep working but cannot overwrite the derived position.

`tob` (birth time) and `pob` (city of birth) are now part of this API, so one call
returns the whole birth block:

- `tob` accepts `HH:MM`, `HH:MM:SS` or `hh:mm AM/PM` and is always stored/returned as
  `H:i:s`. An unparseable value returns **422** rather than silently blanking a real
  birth time.
- `dob`, `tob` and `pob` are mirrored into the numerology profile so the two stores
  cannot disagree.

## 18. Before production

- Razorpay **International Payments** must be enabled for `USD` checkout (+ purpose code).
  Until then a USD-locked customer uses the **"Pay in ₹ … instead"** button — a domestic
  transaction that works on any card. No customer is blocked either way.
- `.env` still has **test** keys → `/pay` reports `mode: "test"`. Swap to live and confirm `mode: "live"`.
- Razorpay **International Settlement** is a separate toggle from acceptance. With only
  acceptance enabled, a USD charge still settles as **INR** to the Indian bank account —
  which is exactly what §15 measures. Confirm the arrangement with Razorpay.
- After going live, run `POST /api/admin/payments/settlements/sync` once the first payout
  lands, then read `GET /api/admin/payments/settlements` to see the real FX cost. Test
  mode reports zero settlements, which is correct.
- If `fx_variance_inr` runs persistently high, raise the price-book rate in
  `site_settings` (`USD_PSYCHOLOGICAL_MAX_PREMIUM` is a pricing knob, not a settlement one).
- Only **Andhra Pradesh** has real city rows; other Indian states fall back to districts.
- Only **3 countries** (IN, US, CA) exist in the territory master.

Full details, error catalogue and verified payloads:
`docs/api-register-and-payment.md` · `docs/api-country-pricing.md`