# Journey API &mdash; contract for the React application

Base URL: `{APP_URL}/api/v1`

Every response has the same envelope:

```jsonc
// success
{ "success": true, "message": "…optional…", "data": { } }

// failure
{ "success": false, "message": "Human readable, safe to show the customer",
  "error": { "code": "machine_readable", "details": { } } }
```

Branch on `error.code`, never on the message text.

---

## 1. Resolve the hand-off link

`POST /entry/resolve` &nbsp;&middot;&nbsp; no auth &nbsp;&middot;&nbsp; 30 requests/minute

BankIslami open the SPA in a WebView with the customer's details encrypted in the query string.
**Forward the whole parameter bag verbatim** &mdash; do not try to pick out fields, because the bank
may send either one blob or one encrypted value per field:

```jsonc
// shape A - single blob
{ "data": "<the ?data= value>", "sig": "optional" }

// shape B - per field
{ "msisdn": "<enc>", "cnic": "<enc>", "name": "<enc>", "email": "<enc>" }
```

In practice: `Object.fromEntries(new URLSearchParams(window.location.search))`.

**200**

```jsonc
{
  "success": true,
  "message": "Welcome, Ayesha Khan.",
  "data": {
    "token": "…64 chars…",               // use as: Authorization: Bearer <token>
    "expires_at": "2026-07-30T18:30:00+05:00",
    "registered": false,                  // false = we have no record of them yet
    "next": "consent",                    // consent | product_selection | dashboard
    "customer": {
      "uuid": null,                       // null until they register
      "full_name": "Ayesha Khan",
      "cnic_masked": "***********67-1", "mobile_masked": "*******4567",
      "email": null, "city": "Karachi", "date_of_birth": "1990-05-14",
      "account_masked": "********7890", "account_title": "Ayesha Khan",
      "branch_name": "Clifton Branch",
      "is_eligible": true, "registered_at": null
    },
    "summary": { "total": 0, "active": 0, "in_progress": 0 },
    "subscriptions": []
  }
}
```

**Routing on `next`**

| Value | Meaning | Screen |
|---|---|---|
| `consent` | `registered: false` &mdash; no record exists yet | Registration consent |
| `product_selection` | Registered, nothing in flight | Product catalogue |
| `dashboard` | Registered, has a policy or an application in progress | Their plans |

A customer is recognised by **mobile number**. Identifiers are always returned masked, even before
registration &mdash; a decrypted CNIC never travels back over the wire in full.

**422** &mdash; the link cannot be trusted. Show `message` and a "return to BankIslami" action.

| `error.code` | Cause |
|---|---|
| `decryption_failed` | Ciphertext did not decrypt under the shared key |
| `malformed_payload` | Decrypted, but not JSON or form-encoded |
| `signature_mismatch` | HMAC did not match (when signatures are enabled) |
| `payload_expired` | The payload's `ts` is older than the configured TTL |
| `payload_replayed` | The same link was already used (when replay is disabled) |
| `missing_identity` | No CNIC, bank customer id or account number in the payload |

**503** &mdash; `journey_closed`; the programme has been switched off in Settings. Show `message`.

---

## 2. Authentication for everything else

```
Authorization: Bearer <token>
```

`X-Journey-Token: <token>` is accepted as an alternative. The token's expiry is pushed out on
every authenticated call, so a long form does not time out mid-way.

**401 `session_expired`** &mdash; send the customer back to the BankIslami link.

**403 `registration_required`** &mdash; the token is valid but the customer has not registered yet.
Send them to the consent screen. Every endpoint below except `/session` and `/registration`
answers this until registration completes.

---

## 2a. Registration

Only for a visitor whose `registered` came back `false`. Until this succeeds **nothing about them
is stored** beyond the session, so the consent captured here is what creates the customer record.

`GET /registration` &mdash; what the consent screen needs, including the exact wording to display:

```jsonc
{ "success": true, "data": {
    "registered": false,
    "profile": {
      "full_name": "Ayesha Khan",
      "cnic_masked": "***********67-1",
      "mobile_masked": "*******4567",
      "email": null, "city": "Karachi"
    },
    "consent_text": "I confirm the details shared by BankIslami are mine, and I agree to …",
    "consent_version": "v1.0"
}}
```

Render `consent_text` as given &mdash; whatever you display is what gets stored as evidence, so do
not paraphrase it in the UI.

`POST /registration` &nbsp;&middot;&nbsp; 10/minute

```jsonc
{
  "consent": true,              // required, must be truthy
  "consent_channel": "web",     // optional
  "consent_text": "…",          // optional; echo back what you displayed
  "marketing": false,           // optional
  "email": "ayesha@example.com",// optional; the customer may correct these two
  "city": "Karachi"
}
```

**201** returns `{ registered: true, next: "product_selection", customer: {…} }` &mdash; the customer
now has a `uuid` and `registered_at`. Store nothing client-side; just re-route on `next`.

**422** if consent was not given. **401 `session_expired`** if the session lost its parked profile.

> Registering does not buy cover and deducts nothing. Purchasing a plan is a **separate** consent,
> captured per subscription at `POST /subscriptions`.

---

## 3. Catalogue

`GET /products` &nbsp;&middot;&nbsp; token optional

Returns all nine live products. With a token, each carries an `availability` block.

```jsonc
{
  "success": true,
  "data": [{
    "code": "P1",
    "slug": "efu-afiyah-takaful-plus-plan",
    "name": "EFU Afiyah Takaful Plus Plan",
    "short_name": "Afiyah Takaful Plus",
    "tagline": "Income continuation, disability cover and motorbike protection in one plan.",
    "highlights": ["Monthly income for the family for 12 months on death due to any cause", "…"],
    "underwriters": ["efu_life", "efu_general"],
    "payment_frequencies": ["annual"],
    "coverage_term": "Annual",
    "has_plans": true,
    "eligibility": { "enrollment_age_min": 18, "enrollment_age_max": 64, "coverage_age_max": 65,
                     "who_is_covered": "Bank Islami Accountholders", "cover_type": "Takaful Voluntary Cover" },
    "from_contribution": { "amount": 10150, "frequency": "annual", "currency": "PKR" },
    "availability": { "can_subscribe": true, "reasons": [] }
  }]
}
```

`GET /products/{slug-or-code}` &nbsp;&middot;&nbsp; token optional

Adds `scope_of_cover`, `free_look_days`, `waiting_period_days`, `other_key_details`,
`special_conditions`, `exclusions`, `policy_aggregate_note`, `levies`, and the full `plans` array:

```jsonc
"plans": [{
  "code": "A",
  "name": "Category A",
  "contributions": { "annual": 10150 },        // only the frequencies this plan offers
  "currency": "PKR",
  "policy_aggregate_limit": null,
  "benefits": [{
    "code": "monthly_income_continuation",
    "name": "Monthly Income Continuation (DAC)",
    "short_name": "Monthly Income",
    "description": "Monthly income paid to the family for 12 months…",
    "underwriter": "efu_life",
    "value_type": "monthly_amount",            // amount | monthly_amount | flag
    "sum_covered": 50000,
    "display_value": "50,000",                 // use this for rendering; "Built-in" for mHealth
    "note": null
  }]
}]
```

`display_value` is always safe to print. `sum_covered` is `null` for flag benefits such as mHealth.

---

## 4. OTP

Required before consent whenever `consent.require_otp` is on (it is by default).

`POST /otp/send` &nbsp;&middot;&nbsp; 5/minute

```jsonc
{ "success": true, "data": {
    "reference": "uuid", "expires_at": "…", "mobile_masked": "*******4567",
    "test_code": "123456"        // present ONLY while OTP_TEST_MODE=true
}}
```

`POST /otp/verify` &nbsp;&middot;&nbsp; 10/minute &mdash; `{ "reference": "…", "code": "123456" }`

Returns `{ "otp_verified": true }`, or **422 `otp_invalid`** with a message covering wrong code,
expiry and attempt exhaustion.

`422 mobile_missing` from `/otp/send` means the bank sent no mobile number &mdash; the customer
must contact BankIslami.

---

## 5. Subscribe

`POST /subscriptions` &nbsp;&middot;&nbsp; auth &nbsp;&middot;&nbsp; 10/minute

```jsonc
{
  "product": "efu-afiyah-takaful-plus-plan",   // slug or code
  "plan": "B",                                  // omit only for single-plan products (P7, P8)
  "payment_frequency": "annual",                // must be offered by the product
  "consent": true,                              // must be truthy
  "consent_channel": "web",                     // optional
  "consent_text": "…",                          // optional; the exact wording shown, stored verbatim
  "nominee_name": "Bilal Khan",                 // all nominee fields optional
  "nominee_relation": "Spouse",
  "nominee_cnic": "42101-7654321-3",
  "nominee_mobile": "03211234567"
}
```

**201**

```jsonc
{ "success": true,
  "message": "Your application has been recorded. Reference SUB-2607-000001.",
  "data": {
    "subscription_no": "SUB-2607-000001", "policy_no": null,
    "status": "lead", "status_label": "Lead Captured",
    "product": { "code": "P1", "name": "…", "slug": "…", "short_name": "…" },
    "plan": { "code": "B", "name": "Category B" },
    "payment_frequency": "annual", "payment_frequency_label": "Annual",
    "contribution_amount": 11150, "currency": "PKR",
    "consent": { "given": true, "at": "…", "channel": "web", "version": "v1.0", "otp_verified": true },
    "nominee": { "name": "Bilal Khan", "relation": "Spouse" },
    "timeline": { "created_at": "…" }, "cover": {}, "total_collected": 0,
    "next_steps": ["Your application has been received.",
                   "BankIslami will call you to confirm the details.",
                   "Your cover starts once the contribution is deducted from your account."]
  }}
```

**428 `otp_required`** &mdash; verify the mobile first.

**422** &mdash; standard Laravel validation shape. Business rules that surface here: the product does
not offer that frequency; the customer's age is outside the enrollment band; they already hold the
product; they have hit the maximum number of plans; the account is marked ineligible.

---

## 6. Their plans

| Endpoint | Notes |
|---|---|
| `GET /session` | Re-hydrate after a refresh: customer block + all subscriptions |
| `GET /subscriptions` | List |
| `GET /subscriptions/{subscription_no}` | Detail, plus the `charges` (instalment) array |
| `POST /subscriptions/{subscription_no}/cancel` | Body `{ "reason": "…" }`; free-look aware |
| `POST /session/logout` | Revokes the token |

### Status values

| `status` | `status_label` | What the customer should be told |
|---|---|---|
| `lead` | Lead Captured | Application received |
| `exported` | Sent to BIPL | Awaiting the bank's confirmation call |
| `cbc_confirmed` | CBC Confirmed | Confirmed, awaiting deduction |
| `deduction_pending` | Awaiting Deduction | Contribution will be deducted shortly |
| `active` | Active Policy | Cover is in force |
| `cbc_rejected` | CBC Rejected | Application closed |
| `deduction_failed` | Deduction Failed | Contribution could not be taken |
| `lapsed` | Lapsed | A renewal contribution was missed |
| `cancelled` / `free_look_cancelled` / `expired` | &mdash; | Closed |

Cancellation inside `cover.free_look_expires_at` is recorded as `free_look_cancelled`, which is
what drives the refund entitlement.

---

## CORS

Allowed origins come from `FRONTEND_ORIGINS` (comma separated) with credentials enabled. Add the
React app's deployed origin there.
