# Tabby API Simulator

A fake Tabby server inside this Phoenix app. It copies Tabby's API paths and response shapes so a
real Tabby integration switches to it by changing only its base URL, and it provides a fake hosted
payment page (HPP). It makes no calls to the real Tabby.

> Working notes and progress live in [CONTEXT.md](CONTEXT.md). This file is the permanent reference:
> setup, env vars, test credentials and behaviour rules.

## Setup

1. Create the tables (four `tabby_sim_*` tables, nothing else is touched):
   ```bash
   mix ecto.migrations -r DaProductApp.Repo          # the tabby_sim migrations show as "down"
   mix ecto.migrate    -r DaProductApp.Repo --step 4  # all four (a fresh install)
   mix ecto.migrate    -r DaProductApp.Repo --step 1  # only the newest, 20261029100000 tabby_sim_faults
                                                      # (when the first three are already applied)
   mix ecto.rollback   -r DaProductApp.Repo --step 1  # undo the newest: drops only tabby_sim_faults
   ```
   Check `mix ecto.migrations` first and make sure the `--step` count matches only `tabby_sim` migrations: `--step`
   runs the next pending migrations in order, whoever wrote them.
   Always pass `-r DaProductApp.Repo`; the project also has `ShukriaMmsRepo`. `** FILE NOT FOUND **`
   rows in the listing are old `schema_migrations` entries and are harmless.
2. Start the app with the simulator enabled and a public URL the customer's browser can reach:
   ```bash
   TABBY_SIM_ENABLED=true TABBY_SIM_PUBLIC_URL=http://localhost:4030 mix phx.server
   ```
   `TABBY_SIM_PUBLIC_URL` is copied into the hosted-page link, so it must be an address **the
   customer's browser** can open. If the browser runs on a different machine than the server,
   `localhost` points at the browser's own machine and the page will not load: use the server's
   address (`http://<server-ip>:4030`) and open that port, or keep `localhost` and forward the port
   with `ssh -L 4030:localhost:4030 <user>@<server>`. The port serves the whole app, not only the
   simulator, so restrict it to your own IP rather than opening it to everyone.
   `config/runtime.exs` is read when the server boots: **restart after changing any `TABBY_SIM_*` variable.** With the
   simulator on, a `tabby_sim_webhooks` Oban queue is started for its jobs (see Webhooks and Expiry). For the admin
   page also set `TABBY_SIM_ADMIN_TOKEN=<a long random password>` (see "Admin page" for why, and for restricting the port).
3. Point the integration at the simulator instead of Tabby:
   `TABBY_BASE_URL=http://localhost:4030/tabby-sim` (real Tabby: `https://api.tabby.ai`).

With `TABBY_SIM_ENABLED` unset or `false` the app behaves exactly as without the simulator: every
`/tabby-sim/*` URL returns the app's normal 404.

## Environment variables

| Variable | Default | Meaning |
|---|---|---|
| `TABBY_SIM_ENABLED` | `false` | Turns the simulator on (`true` or `1`) |
| `TABBY_SIM_PUBLIC_URL` | `http://localhost:4000` | Base URL used in the HPP link (`<url>/tabby-sim/hpp/<session_id>`) |
| `TABBY_SIM_SESSION_TTL_MIN` | `20` | Minutes the HPP link stays usable (the page then says "Session expired"; the payment is still `CREATED`) |
| `TABBY_SIM_EXPIRE_AFTER_MIN` | `30` | Minutes after creation when a still-`CREATED` payment becomes `EXPIRED` (stored per payment as `expires_at` at checkout, so a changed value applies to new payments only) |
| `TABBY_SIM_DEFAULT_DECISION` | `created` | Decision for buyers matching no test rule (`created` or `rejected`) |
| `TABBY_SIM_PRESCORE_REJECT_REASON` | `not_available` | Rejection reason for the reject test phone |
| `TABBY_SIM_MAX_AMOUNT` | unset | Amounts above this are rejected (`order_amount_too_high`) |
| `TABBY_SIM_SEND_EXPIRED_WEBHOOK` | `false` | Send a webhook when a payment expires (Tabby: optional, enabled on request) |
| `TABBY_SIM_ADMIN_TOKEN` | unset | Password of the admin page (`/tabby-sim/admin`). **Unset: the page does not exist (404).** |
| `TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK` | `false` | Also send it for payments whose checkout was rejected up front (`CREATED` + session `rejected`); they expire either way |
| `TABBY_SIM_MERCHANT_NAME` | `Mercury Payments (POS)` | `merchant.name` in responses and the HPP |

## Test credentials

| What | Value | Effect |
|---|---|---|
| Secret key | `Bearer sk_test_<anything>` | Required on API calls; any other key gets 401 |
| Reject phone | `+971500000002`, `971500000002`, `500000002`, `0500000002`, `+966500000002`, `966500000002` | Checkout is **rejected** |
| Success phone | the same formats ending `…0001` | Checkout is **created** |
| `otp.success@tabby.ai` | buyer email | Checkout created; HPP approves |
| `otp.rejected@tabby.ai` | buyer email | Checkout created; HPP **Approve behaves as Reject** |
| HPP OTP | `8888` | The only code the HPP accepts |

## Endpoints (all under `/tabby-sim`)

| Method and path | Purpose |
|---|---|
| `POST /api/v2/checkout` | Create a session and payment; returns Tabby's checkout response |
| `GET /hpp/:session_id` | Hosted payment page (HTML) |
| `GET /merchant/success`, `/merchant/cancel`, `/merchant/failure` | Stand-in merchant landing pages (HTML, not a Tabby endpoint): a green, amber or red result card showing the `payment_id` query parameter and the payment's current status. For `merchant_urls` when testing in a browser; read-only |
| `POST /hpp/:session_id` | Submit an HPP button (HTML form) |
| `GET /api/v2/payments/:id` | Retrieve a payment |
| `PUT /api/v2/payments/:id` | Update `order.reference_id` |
| `POST /api/v2/payments/:id/captures` | Capture (full or partial) |
| `POST /api/v2/payments/:id/refunds` | Refund (full or partial) |
| `POST /api/v2/payments/:id/close` | Close (cancels what was not captured) |
| `GET /api/v2/payments` | List payments |
| `POST /api/v1/webhooks` | Register a webhook (header `X-Merchant-Code`, max 4 per merchant code) |
| `GET /api/v1/webhooks` | List the merchant code's webhooks (`null` if none) |
| `GET /admin` | Admin page (HTML): payments, deliveries, webhooks, faults. 404 unless `TABBY_SIM_ADMIN_TOKEN` is set |
| `GET /admin/payments/:id` | One payment: force status, send its webhooks out of order, its deliveries, its JSON |
| `POST /admin/webhooks/resend` | Resend a stored delivery (optional delay) |
| `POST /admin/payments/:id/out-of-order` | Queue a payment's recorded webhooks newest first |
| `POST /admin/payments/:id/force-status` | Move a payment along the documented lifecycle |
| `POST /admin/faults`, `POST /admin/faults/:id/delete` | Arm / remove a fault (the next call fails) |

## Auth and errors

API routes need `Authorization: Bearer sk_test_…`, otherwise **401** `not_authorized`. The text is
Tabby's and differs per endpoint: checkout returns `"merchant is null"`, other endpoints
`"missing Bearer scheme"`, and the webhook registration routes `"authorization"`. The HPP is opened by a customer's
browser and needs no key. The admin page uses HTTP Basic auth instead: any user name, the password is
`TABBY_SIM_ADMIN_TOKEN` (the page is a 404 when it is unset).

Errors use Tabby's envelope: `{"status":"error","errorType":"…","error":"…"}` with `bad_data` 400,
`not_authorized` 401, `no_permission` 403 (checkout only, see below), `not_found` 404, `conflict` 409, `internal` 500. Error texts for validation
failures (`"payment.amount is required"`, `"payment.currency must be one of AED, SAR"`, …) are the
simulator's own wording.

## Checkout behaviour

**Validation (400 `bad_data`):** `payment.amount` (a number), `payment.currency` (`AED` or `SAR`),
`payment.buyer.email`, `payment.buyer.phone` and `merchant_code` are required. Any unparseable
amount in the order or its items is rejected with the field path.

**Decision, first match wins:**
1. Phone is the reject number (any format above) -> rejected, reason `TABBY_SIM_PRESCORE_REJECT_REASON`
2. Amount > `TABBY_SIM_MAX_AMOUNT` -> rejected, reason `order_amount_too_high`
3. Phone is the success number, or email is `otp.success@tabby.ai` / `otp.rejected@tabby.ai` -> created
4. Otherwise `TABBY_SIM_DEFAULT_DECISION`

Phones are compared by their 9-digit national number after removing whitespace and an optional
`+971`/`971`/`+966`/`966`/leading `0`. UAE and KSA share the numbers, so `0500000002` and
`500000002` are the same for both. Anything unparseable matches nothing.

There is no check that the payment amount equals the item total.

**Response:** the payment is always stored with status `CREATED`, even when the session is rejected.
- Created: `available_products.installments = [{"web_url": "<PUBLIC_URL>/tabby-sim/hpp/<session_id>", "qr_code": null}]`,
  `is_available: true`, `rejection_reason: null`, `rejection_reason_code: null`.
- Rejected: `available_products: {}`, `is_available: false`, `rejection_reason` set. There is no `web_url`.
- `rejection_reason_code` is `"not_enough_limit"` only when the reason is `order_amount_too_high`
  and `null` for every other reason (`not_available`, `order_amount_too_low`, …). The only documented
  pairing is `order_amount_too_high` -> `not_enough_limit`, so the rest is an assumption.

**Normalised like real Tabby, never an echo of the request:**
- Decimal strings lose trailing zeros: `"100.00"` -> `"100"`, `"0.00"` -> `"0"`, `"12.50"` -> `"12.5"`.
- A missing `order.updated_at` is `"0001-01-01T00:00:00Z"`; missing tax/shipping/discount are `"0"`.
- Every order item has the full key set. Missing text fields are `""`, `quantity` defaults to `1`,
  `is_refundable` is `null`.
- `merchant_urls` always has `success`, `cancel` and `failure`; a URL that was not sent is `null`, and
  a missing `merchant_urls` is `{"success":null,"cancel":null,"failure":null}`. **This follows Tabby's
  API spec (each field is nullable); it is spec-based and has not been observed against the real API.**
- The raw request body is kept in the `payload` column for debugging only.

### Full sessions and the `token` parameter

- **Minimal vs full payload.** Tabby's Checkout Flow: pre-scoring and session creation use the same endpoint, and the minimal payload
  "is accepted **for the background pre-scoring call only**". So the simulator never answers 400 for a field outside its minimum
  (`amount`, `currency`, `buyer.email`, `buyer.phone`, `merchant_code`). When a checkout carries `merchant_urls` (a full-session attempt)
  but lacks something from Tabby's Create a session required lists, it still behaves exactly the same; the simulator only logs a
  warning (`[TabbySim] checkout payment=… has merchant_urls but is incomplete for a full session; missing: …`) and the admin page marks
  the payment **"incomplete for a full session"** with the missing paths. The lists: request `payment`, `lang`, `merchant_code`;
  payment `amount`, `currency`, `buyer`, `order`, `buyer_history`, `shipping_address`; buyer `phone`, `email`, `name`; shipping address
  `city`, `address`, `zip`; buyer history `registered_since`; order `items`, `reference_id`; each item `title`, `quantity`,
  `unit_price`, `category`. A parent that is missing is reported alone.
- **`token`.** Error codes page, checkout 403: "You are passing the `token` parameter, but it is not enabled for your integration".
  A request with a non-null `token` gets `403 {"status":"error","errorType":"no_permission"}` (a null or absent `token` is fine, an
  empty string counts as non-null). The check runs right after authentication, before validation. The other documented cause (the wrong
  regional base URL) does not apply: the simulator has one base URL.

## Hosted payment page

`GET /tabby-sim/hpp/<session_id>` is a website-style checkout (not a phone mock-up; the look is the simulator's own, drawn in code, no Tabby
logo or artwork): one centered white sheet on a mint background, like Tabby's own page, so that going to it from the merchant's shop feels
like going to another site. The sheet has a header (close, merchant name, "Pay with tabby"), a collapsed order summary (merchant and total,
open it for the items) and the steps below. The simulator's own test tools are outside the sheet, closed until opened. On a phone the sheet
fills the screen.

1. **Verify your phone**: the buyer's phone, four code boxes (the code is `8888`), a resend countdown (only for show: the simulator sends no
   code), the data-sharing checkbox (checked, must stay checked to continue) and Continue.
2. **Choose how to pay**: the amount and the number of payments, **12, 8, 6 or 4** (6 is selected), with the monthly amount and fee of the
   chosen plan, and Continue.
3. **Secure checkout**: the chosen **Pay in N** plan with its breakdown (order amount, service fee, order
   total, due today). The plan row opens the repayment schedule (a dialog on a wide screen, a bottom sheet on a phone) with the legal chips,
   **Got it** (closes it) and **Change plan** (back to step 2). The black **Pay** button is Approve.
4. **Redirecting**: after a button that sends the customer back to the merchant, a "Mission complete!" screen (or "We can’t approve this
   purchase" / "Payment cancelled") that moves on after about two seconds, with a "Continue" link.

The steps are switched in the browser by a small inline script. The server renders all four plans and choosing one only changes which is
shown, so there is no new server state. Without JavaScript every step shows and plan 6 is used (the code is then one plain field).

The plan numbers are the simulator's own, taken from Tabby's demo video and **not** from the API documentation. The video shows one plan:
Pay in 6 on 1,600.00 is 80.00 of fee (5%), 280.00 a month, 13.33 of it a monthly service fee. The other plans are not shown, so the fee is
scaled by the number of payments, `order * 5% * n / 6` rounded half up, which gives the same monthly fee in every plan (for 1,600.00: fee
53.33 / 80.00 / 106.67 / 160.00 for 4 / 6 / 8 / 12 payments). The total is split into `n` payments (the remainder of the division goes into
the first one, so they add up exactly), the first due today and the others on the same day of each following month (a day that does not
exist, such as the 31st, becomes the month's last day). `HppPlan` holds this, as integer cents.

The page is one HTML form, so the names the scripts post are unchanged: `otp` and `action` = `approve`, `approve_no_redirect`, `cancel`,
`reject`. The plan radios post a `plan` value that the server ignores. The extra hidden field `ui=1` is sent only by the page itself: with it the answer to a redirecting button is the 200
"redirecting" screen, **without it (curl, Postman, the scripts) it is the same 302 as before**. The status change, the webhook and the
redirect target do not depend on `ui`. The Enter key in the form presses Approve (a hidden first button), as the old page's first button did.

| Button | Needs OTP 8888 | Payment status becomes | Customer is sent to |
|---|---|---|---|
| Approve | yes | `AUTHORIZED` (`REJECTED` if the buyer email is `otp.rejected@tabby.ai`) | `merchant_urls.success` (`failure` when rejected) |
| Approve, then close the tab | yes | same as Approve | nowhere: a "you can close this tab" page |
| Back to Store (cancel) | no | `EXPIRED` (`is_expired` set) | `merchant_urls.cancel` |
| Reject | no | `REJECTED` | `merchant_urls.failure` |

- Wording follows Tabby's "Testing Credentials" page: the cancel button is **"Back to Store"** (scenario 3) and the page shown when a
  rejection is not followed by a redirect is titled **"We can’t approve this purchase"** (scenario 4; the typographic apostrophe is
  Tabby's). Unlike Tabby, the simulator redirects to `merchant_urls.failure` straight away on Reject, without that screen in between.
- A wrong OTP shows the form again with an error and changes nothing.
- The redirect URL gets `payment_id=<payment id>` appended with `?`, or `&` if the URL already has a
  query, and any `#fragment` is kept. Only `http`/`https` URLs are followed; a missing or other URL
  shows the result page instead.
- Status moves only forward (`CREATED` -> `AUTHORIZED`/`REJECTED`/`EXPIRED`) and each change is a
  single conditional update, so a double click or two tabs cannot both win.
- Pages: unknown session or a rejected session -> 404 "Checkout not found"; link older than
  `TABBY_SIM_SESSION_TTL_MIN`, or a payment already `EXPIRED` -> 410 "Session expired"; payment already
  `AUTHORIZED`/`REJECTED`/`CLOSED` -> "Already processed".

## Payments API

`:id` is the `payment.id` from the checkout response (not the session id). A value that is not a
UUID is 400 `invalid payment id`; an unknown UUID is 404 `no such payment`. All calls need the
bearer key (401 `missing Bearer scheme` otherwise).

**Status** moves only forward: `CREATED` -> `AUTHORIZED` | `REJECTED` | `EXPIRED` (the hosted page,
or the expiry cron), and `AUTHORIZED` -> `CLOSED` (a full capture, or close). Nothing goes backwards.

| Call | Rules | 400 `bad_data` texts |
|---|---|---|
| `GET /payments/:id` | Returns the payment object | `invalid payment id` |
| `PUT /payments/:id` | Only `order.reference_id` changes, and only on an `AUTHORIZED` or `CLOSED` payment. Every other field in the body is ignored; a body without it changes nothing | `could not update not authorized payments` |
| `POST /payments/:id/captures` | `amount` and `reference_id` required; optional `tax_amount`, `shipping_amount`, `discount_amount` (default `"0"`) and `items` (`null` when not sent). Only `AUTHORIZED`. A capture may not exceed what is left. Capturing the full amount closes the payment (`closed_at` is set); a partial capture leaves it `AUTHORIZED` | `could not capture not authorized payments`, `capture amount exceeds the remaining amount`, `reference_id is required`, `amount is required`, `amount is invalid`, `amount must be greater than 0`, `<field> is invalid` |
| `POST /payments/:id/refunds` | `amount` and `reference_id` required; optional `reason` and `items`. Only a `CLOSED` payment that has captures. All refunds together may not exceed all captures. Status stays `CLOSED` | `can not refund payment that has no captures and not closed`, `refund amount exceeds the captured amount`, plus the field texts above and `reason is invalid` |
| `POST /payments/:id/close` | `AUTHORIZED` -> `CLOSED`; no body | `already closed`, `could not close not authorized payments` |
| `GET /payments` | See below | `failed to decode request` |

**Idempotency.** A capture or refund whose `reference_id` was already used on this payment (captures
and refunds are tracked separately) changes nothing and answers 200 with the payment as it is now,
whatever amount the retry carries. This is checked after the body is validated but **before** the
status and amount rules, so retrying the capture that closed the payment still succeeds instead of
failing with "not authorized".

A capture or refund that fails (400) records nothing, so its `reference_id` is not used up and may be
sent again with a valid amount. Tabby's docs say to check the payment with `GET /payments/:id` before
retrying after an error. Whether real Tabby also frees the key is not documented (see "Not confirmed by
Tabby" below).

**Concurrency.** Capture, refund, close and update lock the payment row, so parallel calls take
turns and each sees the previous result: ten parallel captures of 20 on a payment of 100 succeed
exactly five times.

**Payment object** (retrieve, update, capture, refund, close, and each list element):
`id`, `created_at`, `expires_at`, `status` (uppercase), `is_test`, `amount`, `currency`, `description`,
`buyer`, `shipping_address`, `order`, `captures`, `refunds`, `buyer_history`, `order_history`, `meta`,
`attachment`. Objects the checkout did not receive are `null`; `captures` and `refunds` are always lists.
Amounts are strings without trailing zeros.
- capture entry: `id`, `created_at`, `amount`, `tax_amount`, `shipping_amount`, `discount_amount`, `items`, `reference_id`
- refund entry: `id`, `created_at`, `amount`, `reason`, `items`, `reference_id`

**List** `GET /payments` returns `{"payments": [...], "pagination": {"limit", "offset", "total_count"}}`,
newest first, `total_count` ignoring limit and offset.
- `status`: `authorized`, `closed`, `rejected`, `new`, `captured`, `refunded` or `cancelled`. Tabby: "`new`,
  `captured`, `refunded` and `cancelled` refer to the statuses on Tabby Merchant Dashboard". **Without it
  only `AUTHORIZED` and `CLOSED` payments are returned**, as Tabby documents. The dashboard names map as in
  Tabby's "Payment statuses" page:

  | `status` | Payments returned |
  |---|---|
  | `new` | `AUTHORIZED` (nothing captured, or only partially) |
  | `captured` | `CLOSED` with captures (in full, or partially captured then closed), not fully refunded |
  | `cancelled` | `CLOSED` with no captures |
  | `refunded` | `CLOSED`, captured, then refunded in full (total refunds = total captures) |

  `partially refunded` is on the dashboard but not in the list API's values, so it is not accepted (400); a
  partly refunded payment is listed under `captured`.
- `created_at__gte`, `created_at__lte`: a date (`2025-03-01`, which means midnight UTC, as Tabby documents,
  so `gte` and `lte` on the same date match one instant) or a full ISO 8601 date-time.
- `limit`: 1 to 20, default 20. `offset`: 0 or more, default 0. Any invalid value is 400.
- The list is not limited to one merchant: the test key does not identify one.

**Where the texts come from.** Tabby's documentation gives these error texts, and the simulator uses
them as written: `missing Bearer scheme`, `merchant is null` (checkout 401), `invalid payment id`,
`no such payment`, `could not capture not authorized payments`,
`can not refund payment that has no captures and not closed`, `already closed`,
`failed to decode request` and `Internal Server Error`. The other texts (`capture amount exceeds the
remaining amount`, `refund amount exceeds the captured amount`, `could not close not authorized payments`,
`could not update not authorized payments` and the field validation texts) are the simulator's own wording.

## Webhooks

**Register** `POST /api/v1/webhooks` with `Authorization: Bearer sk_test_…`, header `X-Merchant-Code: <code>`
(the `merchant_code` you send at checkout) and `{"url": "https://…", "header": {"title": "X-Sign", "value": "secret"}}`
(`header` optional; it is added to every delivery so you can check it). Response `200`:
`{"id": "1", "url": "…", "is_test": true, "header": {"title": "X-Sign", "value": "secret"}}`.
At most 4 per merchant code. Any bad request, including a 5th webhook or a missing `X-Merchant-Code`,
is `400 {"status":"error","errorType":"bad_data","error":"bad_request"}`; the 401 text is `authorization`.
`GET /api/v1/webhooks` returns `null` when none are registered, otherwise a list. `http://` and local
urls are accepted so an app on the same server can receive.

**When one is sent** (to every webhook of the payment's merchant code):

| What happened | `status` in the webhook | Added |
|---|---|---|
| Hosted page: Approve | `authorized` | |
| Capture, partial | `authorized` | entry in `captures` |
| Capture, full | `authorized`, then (about 1 s later) a separate `closed` | the first has the entry in `captures` and no `closed_at`; the second has `closed_at` (Webhooks page, typical payment; Full Testing Checklist) |
| Close | `closed` | `closed_at` |
| Refund | `closed` | entry in `refunds` |
| Hosted page: Reject (or `otp.rejected@tabby.ai`) | `rejected` | |
| Hosted page: Cancel, expiry | `expired` | `expired_at`; only with `TABBY_SIM_SEND_EXPIRED_WEBHOOK=true` |
| `PUT /payments/:id` (a real update) | as before (`authorized` or `closed`) | the new `order.reference_id` (Tabby's table: "Update \| the same as before the Update Request \| order.reference_id updated") |

Nothing is sent for a `PUT` with nothing to update, for a replayed capture/refund, or for a failed call.

**Body** (a snapshot of the payment when the change happened): `id, created_at, expires_at, closed_at,
status, is_test, is_expired, amount, currency, order{reference_id}, captures, refunds, meta, token`; the
expired event also has `expired_at`.

**Delivery** (the policy is in one module, `DeliveryRules`): a job per webhook in the simulator's own Oban queue `tabby_sim_webhooks` (started only when
`TABBY_SIM_ENABLED=true`; restart the server after turning it on). It is not `webhooks` because `oban_jobs`
is shared with other apps whose servers would pick up the job and fail it. Only **HTTP 200** is a success
(201, 204, redirects and everything else are failures); the request times out after 60 s; after a failure
it is tried up to 4 more times. Tabby documents "an exponential interval between attempts (1-4 minutes)"
without exact values; the simulator waits 1, 2, 4 and 4 minutes. Every attempt is a row in
`tabby_sim_webhook_deliveries`. Delivery order is not guaranteed and a webhook can arrive twice, as with
Tabby. A webhook registered after an event does not receive it.

## Expiry

Two separate timers, as Tabby documents ("the session expires 20 minutes after creation", the payment
status about 10 minutes later):

| Timer | Default | What happens |
|---|---|---|
| `TABBY_SIM_SESSION_TTL_MIN` | 20 min | The hosted page shows "Session expired" (410). The payment is still `CREATED`: retrieve says `CREATED` |
| `TABBY_SIM_EXPIRE_AFTER_MIN` | 30 min | A `CREATED` payment becomes `EXPIRED` (`is_expired` true). `EXPIRED` is terminal: capture, refund, close and update answer 400 |

Only `CREATED` payments expire; `AUTHORIZED`, `CLOSED` and `REJECTED` never do. A payment expires at most
once, by two cooperating mechanisms:

1. **On read.** Retrieve, list, update, capture, refund, close and the hosted page first move a due
   `CREATED` payment to `EXPIRED`, so polling always sees the right status, even when no job is running.
2. **A background job** (`ExpiryWorker`, in the simulator's own Oban queue `tabby_sim_webhooks`) expires due
   payments on time so the `expired` webhook is not late. It runs again at the earliest due time (at
   most 60 s away) **only while unexpired `CREATED` payments exist**, and stops when none are left. A new
   checkout (re)starts it; there is never more than one pending job. With the simulator disabled it does
   nothing. It does not use the Oban plugins list.

The `expired` webhook is sent only with `TABBY_SIM_SEND_EXPIRED_WEBHOOK=true`: `status` "expired",
`is_expired` true and `expired_at`, which is the payment's stored `expires_at` (the hosted page's Cancel
button uses the time of the click). A payment whose checkout was rejected up front also expires but sends
no webhook unless `TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK=true`.

## Admin page

`GET /tabby-sim/admin` (HTML). It **does not exist (404) unless `TABBY_SIM_ENABLED=true` and `TABBY_SIM_ADMIN_TOKEN` is set**.
With the token set, the browser asks for a password (any user name): the token. Every form carries a signed,
one-hour token, so a form cannot be posted from another site.

**Security.** The page uses HTTP Basic auth, and the simulator is served over plain HTTP, so the password travels
**unencrypted** with every request, and the page can change data (force a status, fire webhooks, make calls fail). Use a long random
`TABBY_SIM_ADMIN_TOKEN` (for example `openssl rand -hex 24`), never a password you use elsewhere, and keep port 4030
restricted to your own IP (`ufw allow from <your-ip> to any port 4030`) or reach it through an SSH tunnel
(`ssh -L 4030:localhost:4030 <user>@<server>`, then open `http://localhost:4030/tabby-sim/admin`). The token is also visible to
anyone who can list the server's processes or environment.

| Feature | Tabby basis |
|---|---|
| **Resend** a delivery (the same payload again), optionally after N seconds | Webhooks page: "the delivery order is not guaranteed and the same event may occasionally arrive twice". The delay is a test convenience. |
| **Send a payment's webhooks out of order**: all recorded events newest first, 2 s apart | Webhooks page: "A `closed` event can arrive before the `authorized` one" |
| **Make the next call fail** (see below) | Error codes page, per endpoint |
| **Force status** along the documented lifecycle (CREATED to AUTHORIZED / REJECTED / EXPIRED, AUTHORIZED to CLOSED), with a "send the webhook" checkbox | The moves are Tabby's lifecycle; the button is a **test convenience**. A forced EXPIRED follows the expiry webhook rules (no webhook for a checkout rejected up front unless `TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK=true`; still needs `TABBY_SIM_SEND_EXPIRED_WEBHOOK=true`). |
| Lists of payments, deliveries, registered webhooks, armed faults. A payment whose checkout had `merchant_urls` but lacked required fields shows **"incomplete for a full session"** with the missing paths (see "Full sessions" above) | Test convenience |

**Faults.** Pick an endpoint, an error, an optional payment id (blank: the next matching call for any payment) and
how many times. The error is sent instead of the normal answer, **before anything else happens** (so a 500 on
retrieve of a due payment does not expire it). Only combinations Tabby documents are accepted (the admin form has one "409 lock" choice; the body follows the endpoint you pick):

| Error | Endpoints | Body |
|---|---|---|
| 409 lock, capture | capture | `{"status":"error","errorType":"conflict","error":"could not acquire lock: payment is locked"}` (error codes page, captures; the capture API schema agrees) |
| 409 lock, refund | refund | `{"status":"error","error":"could not acquire lock: payment is locked"}`: **no `errorType`**. The error codes page shows one 409 body for refunds, with no `errorType`, and says the body "carries" the lock text for concurrent refunds and `payment is disputed` for a dispute |
| 409 disputed | refund | `{"status":"error","error":"payment is disputed"}` (**no `errorType`**, the same shape as the refund lock above). The refund API schema (`Error_409`) has `errorType: "conflict"`, so a client must detect both refund 409s by HTTP 409 plus the `error` text, never by `errorType` |
| 500, change NOT applied | checkout, retrieve, list, update, capture, refund, close, `POST` and `GET /api/v1/webhooks` | `{"status":"error","errorType":"internal","error":"Internal Server Error"}` |
| 500, change APPLIED first | capture, refund, close | the same body; the change happens, then the 500. **Inferred**, not stated: Tabby says to check "already-applied captures" (refunds) or the "current status" (close) before retrying. |

Faults live in `tabby_sim_faults`; without that table (migration not run) the faults silently do nothing and an
error is logged on each API call.

## Data

| Table | Holds |
|---|---|
| `tabby_sim_payments` | One row per checkout: ids (UUID strings), status, amounts, buyer, normalised order, captures, refunds, expiry times, raw `payload` |
| `tabby_sim_webhooks` | Webhooks registered through the API |
| `tabby_sim_faults` | Armed faults (endpoint, error kind, optional payment id, uses left) |
| `tabby_sim_webhook_deliveries` | One row per webhook delivery attempt: event, attempt number, `success`/`failed`, url, HTTP status, request body, response body (first 4000 characters), error, duration |

## Tests

**Unit tests** (no database, no server): 241 expected (213 before the hosted page restyle, plus 28 for `HppPlan` and the new page cases; the two files `hpp_plan_test.exs` and `hpp_page_test.exs`, 38 tests, were run on their own and pass).

```bash
mix run --no-start -e 'Application.ensure_all_started(:plug_crypto); ExUnit.start(autorun: false); Enum.each(Path.wildcard("test/tabby_sim/*_test.exs"), &Code.require_file/1); ExUnit.run()'
```
`mix run --no-start` starts no application, and `AdminToken` needs `plug_crypto`'s key cache (an ETS table owned by that
application), so the command starts it first; without that the three `admin_token` tests fail with "the table identifier does not
refer to an existing ETS table".

**Postman collection** for demos and manual testing, in `test/tabby_sim/postman/` (one collection file with its variables built in, and a README): 13 folders, 99
requests with assertions, covering every endpoint, the hosted page, the error cases, the admin fault injection and the webhook tools.

**Integration scripts** against a running simulator (Python, standard library only) are in `test/tabby_sim/scripts/`, with their own
README: hosted page, payments, webhooks (including the 14-minute retry test), expiry, and the admin page and faults. They are not part
of `mix test`, they create test payments, and they read the database through the `mysql` client, so use a development instance.

**Why not plain `mix test`.** `mix.exs` aliases `test` to `ecto.create --quiet`, `ecto.migrate --quiet`, `test`, and
`test/test_helper.exs` puts the Repo in sandbox mode, so it needs a test database that the project's migration history can build. It
cannot: on an empty database the history fails (for example `rename_terminal_id` renames a column of `pos_terminals`, a table no
remaining migration creates). The simulator therefore has no database-backed ExUnit tests; its database behaviour is covered by the
scripts above.

## Examples

```bash
H='Authorization: Bearer sk_test_000000000'; J='content-type: application/json'
U=localhost:4030/tabby-sim/api/v2/checkout

# create a session (copy web_url from the response and open it in a browser)
curl -s -X POST $U -H "$H" -H "$J" -d '{"merchant_code":"ae","payment":{"amount":"100.00","currency":"AED","buyer":{"phone":"500000001","email":"otp.success@tabby.ai"},"order":{"reference_id":"T1","items":[{"title":"Phone","quantity":1,"unit_price":"100.00"}]}},"merchant_urls":{"success":"https://example.com/ok","cancel":"https://example.com/cancel","failure":"https://example.com/fail"}}'

# approve from the command line (shows the redirect without following it)
curl -i -X POST localhost:4030/tabby-sim/hpp/<session_id> -d 'action=approve&otp=8888'

# then work with the payment (use the payment id, not the session id)
P=localhost:4030/tabby-sim/api/v2/payments/<payment_id>
curl -s $P -H "$H"                                                                   # retrieve
curl -s -X POST $P/captures -H "$H" -H "$J" -d '{"amount":"60.00","reference_id":"cap-1"}'   # partial capture
curl -s -X POST $P/captures -H "$H" -H "$J" -d '{"amount":"40.00","reference_id":"cap-2"}'   # rest: payment becomes CLOSED
curl -s -X POST $P/refunds  -H "$H" -H "$J" -d '{"amount":"30.00","reference_id":"ref-1","reason":"damaged"}'
curl -s -X PUT  $P -H "$H" -H "$J" -d '{"order":{"reference_id":"ORDER-1"}}'
curl -s "localhost:4030/tabby-sim/api/v2/payments?status=closed&limit=5" -H "$H"     # list
curl -s -X POST localhost:4030/tabby-sim/api/v1/webhooks -H "$H" -H 'X-Merchant-Code: ae' -H "$J" -d '{"url":"http://localhost:9099/ok","header":{"title":"X-Sign","value":"secret-1"}}'   # register a webhook
```

## Not confirmed by Tabby

The docs do not settle these; the simulator's rule is stated so integrations do not rely on it:
- A failed (400) capture or refund does not reserve its `reference_id`.
- `order.updated_at` defaults to `0001-01-01T00:00:00Z` when not sent, and `PUT /payments/:id` never changes it.
- A payment whose checkout was rejected up front (session `rejected`, payment `CREATED`) expires like any other but
  sends no `expired` webhook unless `TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK=true`.
- The `expired` webhook carries `expired_at` (the payment's `expires_at`); Tabby's sample payload does not show the
  field, so do not depend on it.
- The refund 409s ("payment is disputed" and "could not acquire lock: payment is locked") have no `errorType` here (the error
  codes page's single refund 409 body) but `errorType: "conflict"` in Tabby's refund API schema. Detect them by HTTP 409 plus the
  `error` text, not by `errorType`. The capture lock 409 does have `errorType: "conflict"`.
- A 500 "after" the change was applied is inferred from the docs (check the state before retrying); Tabby does not say
  a 500 can follow a change.
- A `token` that is an empty string is refused (403) like any non-null value, and the 403 is checked before the 400 validation. Tabby
  does not say how an empty `token` or the order of the two checks behaves.
- Tabby marks more fields as required for a full session than the simulator enforces (see "Full sessions"). Whether Tabby's server
  really rejects a full session that lacks them is not stated; the simulator only warns.
