# Tabby API Simulator - working notes and progress

**Never delete or move this file.** It is the resume point if the chat reaches its limit, so keep it
current after every step. Permanent docs (setup, env vars, test credentials, behaviour rules, response
shapes) live in [README.md](README.md) and are deliberately not repeated here; this file is progress,
decisions and what is verified.

## 1. History

1. The user first asked for help building a real Tabby "Pay in 4" custom integration (Postman
   collection `tabby-custom-api (1).json` + https://docs.tabby.ai/pay-in-4-custom-integration/quick-start).
2. Claude wrote a Tabby API client (`lib/da_product_app/tabby/client.ex`) plus a `:tabby` block in
   `config/runtime.exs`. **The user undid both.** They are not in the repo; do not recreate them
   unless asked.
3. The user then supplied a brief to build the **simulator** so the real integration can switch with
   only `TABBY_BASE_URL`. That is what is being built.

## 2. Working rules the user set (follow them)

- Work **ONE STEP at a time**. After each step: list changed files, show `git diff --stat`, give
  curl/Postman tests, then **STOP until the user confirms** ("Step N approved").
- Do not invent Tabby behaviour that is not in the brief or https://docs.tabby.ai. Ask if unclear.
  (Fetch the API reference pages: `https://docs.tabby.ai/llms.txt` lists them as `.md` URLs.)
- **Strict isolation.** New code only in `lib/da_product_app/tabby_sim/`,
  `lib/da_product_app_web/controllers/tabby_sim/`, `lib/da_product_app_web/plugs/tabby_sim_*.ex`,
  `test/tabby_sim/`, new migrations. Namespaces `DaProductApp.TabbySim` / `DaProductAppWeb.TabbySim`.
- Existing files may only be changed by ADDING lines: `router.ex` (`/tabby-sim` scopes at the end;
  new pipelines only, never change `:browser`, `:api`, `:api_auth` or the CSP), `config/runtime.exs`
  (TABBY_SIM_* vars), `config/config.exs` (one Oban cron entry only, Step 6).
- DB: only NEW tables prefixed `tabby_sim_`, each with a working rollback. Never touch existing tables.
- No changes to `mix.exs` or shared modules (ResponseHandler, EventLogger, ApiKeyAuth, Transactions,
  Alipay/Aani/QR/POS code).
- Logs prefixed `[TabbySim]`. With `TABBY_SIM_ENABLED=false` the app must behave exactly as before.
- The user runs migrations and the server. Claude must not run migrations or `mix deps.get`, and must
  not compile the whole project in the sandbox (it dirties tracked `deps/` files). Verify with syntax
  parses, standalone runs of the pure modules, and the in-memory Ecto harness (section 6).
- The user once interrupted Claude running a DB query and a curl against their server unasked: **ask
  before running anything against their server or database**; they usually say "yes lets test".
- Use `Req` for HTTP (not HTTPoison). Prefer separate small file operations over one huge command.
- Keep both docs: CONTEXT.md = working notes, README.md = permanent docs; no duplicated content.


**Rules added by the user on 2026-10-28 (Oban, shared `oban_jobs` table):**
- Step 6 (expiry cron) goes **inside the same `if TABBY_SIM_ENABLED` block in `config/runtime.exs`, not in
  `config/config.exs`**, on a sim-only queue, and the worker must do nothing when the simulator is disabled.
  Caution found while checking: `config` deep-merges *keyword* lists (so `queues:` merged), but `plugins:` is
  a plain list of tuples and is **replaced**, not merged. A runtime.exs cron entry must therefore restate the
  whole `plugins` list (Pruner and the existing `Oban.Plugins.Cron` crontab incl. `ReportRetentionWorker`)
  with the new entry added, only inside the enabled block. Check with `Config.Reader` before and after.
- The real Tabby integration's workers must use their own queue (e.g. `:tabby`), never `:webhooks`.
- Existing issue, not ours to fix, to be reported to the team: all apps share one `oban_jobs` table, and
  queue names overlap between apps and servers. A server that does not have a job's worker module fails it
  with "unknown worker", which uses up an attempt and, after `max_attempts`, discards the job. Facts from the
  table (2026-10-28): 88 of 220 `DisputeCore.Workers.ChargebackWebhookWorker` jobs are `discarded`, all with
  "unknown worker" - but they sit in the `disputes` queue, which CloudLayer does not run, so they are not
  evidence about CloudLayer's `webhooks` queue (only our own jobs are in `webhooks`). For queues CloudLayer
  does run, "unknown worker" errors exist on other apps' jobs in `mailers` (TmsCore `DeliveryWorker`: 1
  discarded, 3 completed after errors), `default` (2 completed) and `low` (`AgentCore.Learning.NightlyJob`,
  discarded); the table cannot say which server failed them, so whether CloudLayer fetches other apps' jobs
  in those queues is unproven.


**Rules added by the user on 2026-10-28 (credentials, processes):**
- Never put the DB password on a command line (`mysql -p...`). `~/.my.cnf` (mode 600, `[client]` user and
  password read from `config/dev.exs`) now exists; run `mysql shukria_transactions -e "..."` with no `-u`/`-p`.
  The password was visible in `ps` on this shared server while earlier commands ran.
- Stop what you started: background test scripts and receivers must be killed when the test is done and
  confirmed with `ps`/`pgrep`. The user's own receiver on port 9099 is theirs; do not touch it.

## 3. Step status

| # | Step | Status |
|---|------|--------|
| 1 | Migrations + schemas, config, routes, auth plug | Done, approved. Migrations applied by the user. |
| 2 | `POST /api/v2/checkout` | Done. The user ran the 200/400/401, reject-phone, bad-currency and bad-price cases; they moved on to Step 3 without a separate approval. `TABBY_SIM_MAX_AMOUNT=50` has only been tested in unit tests, never over HTTP. |
| 3 | HPP page (`GET, POST /hpp/:session_id`) | **Done, approved by the user (2026-10-28).** Results in section 6. |
| 4 | Retrieve / update / capture / refund / close / list, every error case, idempotency | **Done.** User tested tests 1-3 by hand; Claude ran the HTTP script at the user's request: 91/91 passed, MySQL checked. The user moved on to Step 5 ("ok go ahead with step 5"). |
| 5 | Webhook registration + sender worker + delivery log | **Done, approved by the user (2026-10-28).** 44/44 quick HTTP checks and the retry test passed (results in section 6); jobs use the sim-only queue `tabby_sim_webhooks`. Expired webhook with `expired_at` is unit-tested only; its end-to-end check belongs to Step 6 testing. |
| 6 | Session expiry (on-read + self-rescheduling job) | **Done and fully tested (E1-E6, 2026-10-29); awaiting the user's "Step 6 approved".** Results in section 6. |
| 7 | `/admin` page (resend, out-of-order, delay, force status, fault injection) | **Done, approved by the user (2026-10-29).** 111 HTTP checks (110 passed, the 1 miss was a mistake in the test), T12 passed, and the refund lock 409 rerun passed after the body change. Results in section 6. |
| 8 | Remaining ExUnit tests (HTTP level, DB level) | **Closed by the user (2026-10-29), no DB-backed `mix test`.** Reason: the failed `mix test` run showed the project's migration history cannot build an empty database, and the user will not copy schemas out of `shukria_transactions` on this shared server. Delivered: 203 unit tests (no DB) + the HTTP scripts in `test/tabby_sim/scripts/`. The planned DB-backed tests (A1-A5, B1, B2, B4-B7) are not written. |

## 4. Review log

**Step 1 review (implemented in Step 2):** responses never echo the raw payload (normalised like real
Tabby, raw `payload` column is debug only); 401 text `"merchant is null"` on checkout and
`"missing Bearer scheme"` elsewhere; ping route removed.

**Step 2 review fixes:** `rejection_reason_code` only for `order_amount_too_high`; `merchant_urls`
always three keys with `null`; all UAE/KSA phone formats for the reject (`…0002`) and success (`…0001`)
numbers; no amount-vs-items check; tests for `Decision` and `Normalizer`; docs split.

**Step 3:** approved with no changes requested. Setup lesson added to the README (browser on another
machine cannot use `localhost`; the user opened port 4030 with `ufw`, which exposes the whole app -
told to restrict it to their IP or use an SSH tunnel).

**Step 4 (retrieve/update/capture/refund/close/list):** the user corrected the list endpoint after review: `status`
`new`/`captured`/`refunded`/`cancelled` are Tabby dashboard names defined in the docs (my first version wrongly gave 400);
mapped as in Tabby's "Payment statuses" page, `partially refunded` not accepted. The user tested by hand (retrieve, update,
capture, replay, over-capture, reused reference_id, full capture, capture on CREATED/REJECTED/EXPIRED); Claude then ran the
HTTP script (91/91). Two behaviours stay on the Questions for Tabby list (failed-key reuse, `order.updated_at`). The user
moved on to Step 5 ("ok go ahead with step 5").

**Step 5 (webhooks):** "Step 5 approved" (2026-10-28), with: never put the DB password on a command line (`~/.my.cnf`), stop the
processes Claude starts, and the Step 6 options below. During testing the retry test failed because `oban_jobs` is shared with
other apps; the user approved the dedicated queue `tabby_sim_webhooks` (added only inside an `if TABBY_SIM_ENABLED` block in
`config/runtime.exs`), asked for the real Tabby integration's workers to use their own queue, and for the shared-table issue to
be reported to the team (checked against the table: the facts are in section 2). Retry pass criteria were set from the docs:
5 attempts, every gap about 1-4 minutes and non-decreasing, no "unknown worker". `expired_at` was added to the expired webhook.

**Step 6 (expiry):** the user chose option C (expire on read + a self-rescheduling job that stops when no CREATED payment is left,
restarted by a new checkout), two separate timers (hosted-page session, then payment status), `expired_at` = stored
`expires_at`, and no expired webhook for payments rejected up front unless a setting says so. Testing found an early-stop
bug in the job (fixed), then the user added two guards (rows with NULL `expires_at` never keep it alive; payments still
CREATED and due after an expiry attempt reschedule in 60 s with an ERROR log, never 1 s). "Step 6 approved" (2026-10-29).

**Step 7 (admin page):** design approved with: faults in a new table `tabby_sim_faults`; 500 "before" and "after applying" (the
latter labelled as inferred); the refund 409 "payment is disputed" with the error codes page body (no `errorType`); a webhook
on update; force status along the documented lifecycle only; per-action delay; `TABBY_SIM_ADMIN_TOKEN` (404 when unset).
Then: "after applying" also on close, forced EXPIRED must follow the expiry webhook rules, faults fire before anything else
(including expire-on-read), 500 faults on `POST`/`GET /api/v1/webhooks`, and the errorType discrepancy noted as Q5. Built; awaiting
the user's review.

**Step 8 (remaining tests):** the user approved the proposal (A1-A6, B1-B7) with these decisions, checked against Tabby's docs:
1. No strict 400 mode for required fields. Checkout Flow: pre-scoring and session creation use the same endpoint and the minimal payload "is
   accepted for the background pre-scoring call only". Instead, a checkout that has `merchant_urls` but misses a field from the Create a session
   required lists is logged as a warning and marked "incomplete for a full session" on the admin page. Behaviour unchanged.
2. Hosted-page wording aligned with Testing Credentials: reject screen "We can’t approve this purchase" (#4), cancel button "Back to Store" (#3).
3. 403: implement the documented `token` cause (error codes, checkout 403): a non-null `token` gives `403 {"status":"error","errorType":"no_permission"}`;
   null stays fine; skip the wrong-region cause (one base URL).
4. B3 without wall-clock waits: unit-test the backoff values (60/120/240/240, within "1-4 minutes") and "only 200 succeeds".
5. Copy the scripts and the receiver to `test/tabby_sim/scripts/` with a README, after removing the IP, the scratchpad paths and any secrets.
6. No refund/capture/close buttons on the admin page (asked again by the user: refunds stay a curl/Postman call). 
Order: build what needs no DB first (1, 2, 3, 4, A6, the scripts), then wait for the user's `mix test` result before the DB-backed tests.
**Step 8 closed (2026-10-29):** the user ran `MIX_TEST_PARTITION=_greeshma ... mix test test/tabby_sim/decision_test.exs`. The project compiled, but the `test` alias's
`ecto.migrate` failed on the new empty test DB: `(1146) Table 'da_product_app_test_greeshma.pos_terminals' doesn't exist` while running `ALTER TABLE pos_terminals
RENAME COLUMN neo_terminal_id TO terminal_id` (migration `202507071233 rename_terminal_id`). The history has dozens of `** FILE NOT FOUND **` entries and cannot build an
empty DB. Claude offered a workaround (structure-only copy of `shukria_transactions` plus its `schema_migrations`); **the user declined** and closed Step 8 with the unit tests
and the scripts. Not a fault of the simulator.

## 5. Decisions chosen by Claude - not in the brief or the docs, verify with the user

Checkout (details in README):
- Validation message wording; item key set and `quantity` default of 1.
- `TABBY_SIM_DEFAULT_DECISION=rejected` reuses `TABBY_SIM_PRESCORE_REJECT_REASON`.
- A bad bearer key (wrong prefix, empty) gets the same 401 text as a missing one.
- The success phone (`…0001`) forces `created` (added at the user's request).

HPP (details in README):
- "Close tab without redirect" is a fourth button, "Approve, then close the tab (no redirect)".
- OTP required only for the two approve buttons; `otp.rejected@tabby.ai` also turns the close-tab
  button into a reject (no redirect). Wrong OTP -> 200 form with an error.
- "Session expired" (410) is shown for an `EXPIRED` payment, including after Cancel + Back, and when
  the link is older than `TABBY_SIM_SESSION_TTL_MIN`; it does not change the payment. The wording
  does not tell "cancelled" from "timed out" - offered to change it, no answer yet.
- Redirects follow only http(s); `payment_id` is the payment id; status changes use one conditional UPDATE.

Step 4 (what Tabby's docs do say is in README, "Where the texts come from"). Chosen by Claude:
1. A non-UUID `:id` is 400 `invalid payment id` on **every** `:id` endpoint (the docs show that text for
   retrieve and update only).
2. A replay (same `reference_id`) answers with the payment's **current** state, not a stored copy of the
   original response, and ignores the retry's amount. Captures and refunds are separate namespaces.
   Body validation runs before the replay check.
3. Own wording for: `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
   all field validation texts. Tabby's text for refunding a non-closed payment is used for both "not
   CLOSED" and "no captures".
4. Update with no (or a non-string) `order.reference_id` is a 200 no-op, because the docs do not mark it
   required; any other body field is ignored (documented).
5. Capture/refund `items` are normalised like checkout items but the OrderItem "required" fields
   (title, quantity, unit_price, category) are not enforced; `items` is `null` when not sent.
6. Close just sets CLOSED (+ `closed_at`); an uncaptured remainder is not tracked. Amounts with more than
   2 decimals (docs: max 2 for AED/SAR) are not rejected.
7. List: `new`/`captured`/`refunded`/`cancelled` are implemented per the Tabby docs (user-corrected: the first
   version wrongly returned 400); a partly refunded payment shows under `captured` (my reading); the dashboard
   filter runs in Elixir over the CLOSED rows (JSON columns), not SQL; status is
   case-insensitive; an empty result is `"payments": []` (docs say nullable); newest first; a date-only
   `created_at__lte` is midnight UTC exactly as documented, which surprises people who expect the whole day.
8. `closed_at` is stored but not part of the payment object (the documented object has no such key);
   Step 5 webhooks will use it.
9. Fixed in Step 4: checkout's 500 text is now Tabby's `Internal Server Error`.

Step 5 (webhooks), chosen by Claude:
1. `GET /api/v1/webhooks`: the docs schema is "one Webhook object, or `null` if none"; up to 4 can exist,
   so it returns `null` when none and otherwise a JSON **array** of the merchant code's webhooks
   (docs description says "retrieves all registered webhooks"). Say if you want one object instead.
2. Webhook `id` is the auto-increment number as a string (`"7"`); `is_test` is always `true` (only `sk_test_`
   keys are accepted); `header` is `null` when none was registered.
3. Registration accepts `http://` and local addresses (docs: HTTPS, "not local") so an app under test on
   the same server can receive. Header title must be a valid HTTP header name.
4. Every registration problem is Tabby's one documented 400 (`bad_data`, `bad_request`); the reason is in
   the log. That includes a missing `X-Merchant-Code` and the 5th webhook for a merchant code (the docs
   state the limit but not the response). The 401 text on these two routes is `authorization` (docs).
5. ~~No webhook on `PUT`~~ Changed in Step 7 at the user's request: a real update (something changed) now sends an
   `update` webhook, status as before, new `order.reference_id` (Tabby's events table: "Update | the same as before
   the Update Request | order.reference_id updated"). A `PUT` with nothing to update sends none.
6. The payload is a snapshot taken when the change happened, one Oban job per webhook per event; webhook
   status is the payment's status after the change, lowercased (partial capture -> `authorized`, full
   capture, close, refund -> `closed`). **Corrected (slice 0, 2026-10-30):** a *full* capture now sends `authorized`
   (capture in `captures[]`, no `closed_at`) and then a separate `close` webhook (`closed`, `closed_at` set), see
   section 10. Webhooks registered later do not receive earlier events; a payment
   is matched to webhooks by `merchant_code`.
7. Only HTTP 200 succeeds (201/204 do not); redirects are not followed; 5 attempts, waits 1, 2, 4, 4 min;
   60 s request timeout. The custom header is sent only if both title and value were given.
8. Queued after the DB change commits; a queueing failure is logged and swallowed, never an API error.
   Oban's Pruner removes finished jobs after 24 h; `tabby_sim_webhook_deliveries` rows stay.
9. `expire` events go out only with `TABBY_SIM_SEND_EXPIRED_WEBHOOK=true`; the HPP Cancel button is the
   only source of EXPIRED until the Step 6 cron. The expire payload also has `expired_at` (docs: the expire
   event updates `status`, `expired_at` and `is_expired`), taken from the payment's `updated_at` right after
   the move (there is no `expired_at` column); other events do not have the key.
10. Retry (user, from the docs): "up to 4 more times with an exponential interval between attempts (1-4
    minutes)", 1 minute timeout, only HTTP 200 succeeds; the exact intervals are not documented. The
    simulator waits 1, 2, 4, 4 minutes. Retry-test pass criteria: 5 attempts in total, every gap between
    about 1 and 4 minutes, gaps non-decreasing, no "unknown worker" error, final Oban state `discarded`.
11. The simulator's Oban jobs use the queue `:tabby_sim_webhooks`, not `:webhooks` (see section 6).

Step 6 (expiry), chosen by the user from the docs (payment-processing, payment-statuses, webhooks, checkout-flow):
1. Two timers: the hosted-page session dies at `TABBY_SIM_SESSION_TTL_MIN` (20), the payment status becomes
   EXPIRED at `TABBY_SIM_EXPIRE_AFTER_MIN` (30, stored per payment as `expires_at`). In between the page says
   "Session expired" and retrieve still says CREATED. EXPIRED is terminal; only CREATED payments expire.
2. Mechanism "C": expire on read (`Payments.refresh/1` in `get`, `get_by_session_id`, `list` via `expire_due/0`, and
   before every change in `mutate/3`) plus `ExpiryWorker` on `tabby_sim_webhooks`. The job runs next at the
   earliest still-unexpired CREATED payment's due time (1-60 s), stops when none are left, is restarted by a
   new checkout (`Expiry.ensure_scheduled/0`), is unique over available/scheduled/retryable (never two
   pending), does nothing when the simulator is disabled, and does not touch the Oban plugins list.
   `Payments.expire/1` uses the conditional update, so a payment expires and is announced once.
   If the job chain breaks (e.g. 3 failed attempts), the next checkout restarts it; payments are still
   expired on read. Nothing restarts it at boot: until the first new checkout after a restart, due payments
   are expired only when read.
3. The expired webhook (`TABBY_SIM_SEND_EXPIRED_WEBHOOK=true`) has `expired_at` = the stored `expires_at`;
   the hosted page's Cancel keeps the click time. Tabby's events table says expire updates `status`,
   `expired_at` and `is_expired`, but its sample payload has no `expired_at`: we send it, the real
   integration must not depend on it.
4. Pre-scoring-rejected payments (CREATED + `session_status=rejected`) expire but send no webhook unless
   `TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK=true` (default false). **Inference, the docs are silent** (also on
   the Questions for Tabby list).
5. Two guards added at the user's request (2026-10-29), after the early-stop bug:
   (a) The "still waiting" and "due" counts exclude `CREATED` rows with `expires_at IS NULL` (every checkout sets
   one, so such a row is invalid/legacy data and must not keep the job alive; it is never expired either).
   (b) The job repeats the expire step until a pass finds nothing (max 3 passes), then counts payments still
   `CREATED` and due; if any ("stuck", e.g. the conditional update keeps failing) it logs an ERROR and
   reschedules in 60 s, never 1 s, so a stuck row cannot make it insert a row into the shared `oban_jobs`
   table every second (`ExpiryRules.delay/3`, `Payments.expire_due_settled/1`, `Payments.due_count/0`).
   A payment that merely becomes due while the job runs is caught by the next pass or by a 1 s rerun and is
   not "stuck".
6. The first run after deploy expires every already-overdue CREATED row at once (28 in the dev DB when
   proposed), with webhooks for those whose merchant code has one registered.

Step 7 (admin page), design approved by the user 2026-10-29; chosen/implemented:
1. Faults in a new table `tabby_sim_faults` (migration `20261029100000`, with rollback; the user runs it). Documented
   combinations only (`FaultRules`): 409 lock on capture and refund; 409 "payment is disputed" on refund; 500 on every
   endpoint including `POST`/`GET /api/v1/webhooks`; 500 "after applying" on capture, refund and close (inferred from the
   docs' "check already-applied captures/refunds" and, for close, "check the current status", not stated directly).
2. The fault check is the FIRST thing in an action (`FaultGate`), before expire-on-read and before anything changes. A fault
   for exactly one payment wins over one for any payment; `Faults.take/2` is atomic (conditional update) and never raises.
3. Refund 409s: the error codes page shows ONE refund 409 body, `{ "status": "error", "error": "payment is disputed" }` (no `errorType`),
   for two causes: a dispute (`"error": "payment is disputed"`) and concurrent refunds (`"error": "could not acquire lock: payment is
   locked"`). Both are implemented in that shape (no `errorType`; changed after the first HTTP run at the user's request, 2026-10-29).
   The capture lock 409 keeps `errorType: "conflict"` (the captures section of the error codes page and the capture API schema). The
   admin form has one "409 lock" choice, `FaultRules.response/2` picks the body from the endpoint. See Questions for Tabby Q5.
4. Update webhook: see decision 5 under Step 5.
5. Force status: documented forward moves only; EXPIRED follows `ExpiryRules.webhook?/2` and the global
   `TABBY_SIM_SEND_EXPIRED_WEBHOOK`; the checkbox can suppress the webhook; the expired_at is the click time.
6. Delay: per action only (resend, out of order). Out of order = a payment's recorded first-attempt deliveries newest
   first, 2 s apart (+ delay); resend = the stored payload as a new job with the normal retry policy.
7. `TABBY_SIM_ADMIN_TOKEN`: unset/empty -> `/tabby-sim/admin` is a 404; set -> Basic auth (password = token) plus a signed
   one-hour hidden token on every form (`AdminToken`, derived from the token, no session). Results travel as
   `?kind=&msg=` after a redirect and are HTML-escaped.
8. Admin reads rows directly, never `Payments.get/1`, so looking at a payment does not expire it.

Step 8 (so far), implementation notes:
1. `SessionRules` (pure): `token_forbidden?/1` (any non-null `token`, an empty string included, is refused) and `incomplete/1` (the dotted paths missing from the
   create-session required lists when `merchant_urls` is a non-empty map; a missing parent is reported alone; `nil`, blank text and empty lists count as missing,
   0 and false do not). The 403 is checked in `CheckoutController` right after authentication and the fault gate, before validation (order not stated by Tabby).
   The mark on the admin page is computed from the stored request (`payload` column) so no migration was needed.
2. Hosted page: `HppPage.cancel_label/0` = "Back to Store", `HppPage.done_title("REJECTED")` = "We can’t approve this purchase" (Tabby's typographic apostrophe). The
   simulator still redirects to the failure URL immediately on Reject; Tabby shows the rejection screen first and the customer clicks "Back to store" (not implemented).
3. `DeliveryRules` (pure) is now the single source of the delivery policy: `success?/1` (200 only), `max_attempts/0` (5), `backoff/1` (60/120/240/240, capped at 240),
   `gaps/0`, `timeout_ms/0` (60 000), `job_timeout_ms/0`. `Webhooks.deliver/5` and `WebhookWorker` use it (behaviour unchanged; the Step 5 harness still passes).
4. Scripts (`test/tabby_sim/scripts/`): configured through `TABBY_SIM_*` environment variables, the database name is required (`TABBY_SIM_DB`), the admin password is
   required (`TABBY_SIM_ADMIN_TOKEN`); renamed `hpp_checks`, `hpp_race`, `payments_checks`, `webhooks_checks`, `expiry_checks`, `admin_checks`, `webhook_receiver`. The Step 5
   script was corrected: a `PUT` that changes the reference now sends an `update` webhook, so the expected deliveries are authorize, capture, update, capture, refund.

### Questions for Tabby (open - the docs do not answer them; the simulator's current rule is kept meanwhile)
Q3. What happens to a payment whose checkout Tabby rejected up front (session `rejected`, payment `CREATED`)?
    Does it ever become `EXPIRED`, and is an `expired` webhook sent for it? Simulator (inference): it expires
    like any other CREATED payment but sends no webhook (`TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK`, default off).
Q4. The webhooks page says the expire event updates `status`, `expired_at` and `is_expired`, but its sample
    payload has no `expired_at`. Is the field really sent? Simulator: yes, `expired_at` = the payment's
    `expires_at`; do not depend on it in the real integration.
Q5. The refund 409s. Tabby's error codes page shows ONE refund 409 body, `{ "status": "error", "error": "payment is disputed" }` (no
    `errorType`), covering both causes (a dispute, and concurrent refunds whose body "carries `"error": "could not acquire lock:
    payment is locked"`"); the refund API schema `Error_409` shows `errorType: "conflict"` (example text "payment is disputed"). For
    capture, both the error codes page and the API schema have `errorType: "conflict"`. Which is real for refunds? Simulator = the
    error codes page for both refund 409s (user's choice): no `errorType`. The real integration must detect a refund 409 by HTTP 409 +
    the `error` text (disputed vs lock), never by `errorType`.
Q6. Can a 500 follow a change that WAS applied? Not stated; inferred from "check already-applied captures/refunds"
    (and "check the current status" for close) before retrying. The admin "500, change APPLIED first" fault is
    labelled as inferred.
Q7. Checkout 403 for `token`: Tabby says only "You are passing the `token` parameter, but it is not enabled for your integration". Not stated: whether an empty
    string counts as passing it, and whether the 403 comes before or after the 400 field validation. Simulator: any non-null value is refused, and the 403 comes
    first.
Q8. Does Tabby's server really reject a full session that lacks fields its Create a session page marks as required (`buyer.name`, `buyer_history`, `shipping_address`,
    `lang`, item `category`...)? The docs mark them required but say the minimal payload is accepted for pre-scoring only. Simulator: never rejects, warns and marks
    the payment on the admin page (user's decision).
Q1. Does real Tabby reserve a capture/refund `reference_id` when the request fails with a 400? The docs say a
    retry with the same `reference_id` creates no second capture/refund, that a failed call should "release the
    claim so a later retry can proceed", and that the integration should `GET /payments/{id}` before retrying
    after an error - but never what happens to the key of a failed request. Simulator: a failed request stores
    nothing, so the key can be reused with a valid amount (user decision, 2026-10-28).
Q2. What does real Tabby return in `order.updated_at` when the merchant does not send it, and does
    `PUT /payments/{id}` ever change it? The docs only say the update endpoint changes `order.reference_id` and
    ignores other fields, and that `updated_at` is an optional, merchant-supplied `Order` field; the
    `0001-01-01T00:00:00Z` default is the user's statement, not on the create-session page. Simulator: the
    default is `0001-01-01T00:00:00Z` and update never touches `updated_at` (user decision, 2026-10-28).

## 6. Verification

**Step 3 test record (all passed):**
- Unit tests: OTP, button -> status, `otp.rejected` rule, redirect-URL building (`?`/`&`, fragment,
  non-http), HTML builder and escaping.
- User in Chrome (confirmed from the server log and database): page layout; Approve with 8888 ->
  `example.com/ok?payment_id=<payment id>` and `AUTHORIZED`; reload of a used link -> "Already
  processed"; wrong OTP -> error, no change; Cancel -> `EXPIRED` (Back -> "Session expired");
  Reject -> `REJECTED`; "Approve, then close the tab" -> `AUTHORIZED`, no redirect; `otp.rejected@tabby.ai`
  + Approve -> `REJECTED`; truncated session id -> "Checkout not found".
- Claude over HTTP (18 checks): cancel/reject redirect addresses, `&` and fragment handling,
  `javascript:` URL not followed, null `merchant_urls`, rejected session -> 404, unknown action, approve
  twice, reject after approve, wrong OTP on the close-tab button, unknown/non-uuid ids, HTML escaping.
- Concurrency: 12 simultaneous submits on one session -> exactly one wins (twice).
- Expired link with `TABBY_SIM_SESSION_TTL_MIN=0` (user): GET and POST -> 410, no redirect, DB row still
  `CREATED`, `session_expires_at = inserted_at`.

**Step 4 results:**
- Unit tests (standalone, no Mix/DB): `payment_rules`, `list_params` among them. In-memory Ecto harness
  checked the DB logic before any HTTP run.
- User by hand (outputs pasted): retrieve, update on AUTHORIZED/CLOSED/CREATED, partial capture + replay,
  over-capture, reuse of a failed capture's `reference_id`, full capture closing + retry, capture on
  CREATED/REJECTED (HPP Reject)/EXPIRED: all as expected.
- Claude, with the user's permission, against the dev server: `step4_http_checks.py` (scratchpad),
  **91/91 passed**: auth, ids, every error text, replays, refunds, close, all list filters incl. the
  dashboard names (`new`/`captured`/`cancelled`/`refunded` against payments in known states), 10-way
  parallel captures (exactly one winner), then a read-only MySQL query (no AUTHORIZED/CLOSED row with
  NULL `captures`).
- Findings: a checkout rejected at creation (phone `500000002`) is stored `CREATED` with session status
  `rejected`; only the HPP Reject button (or `otp.rejected@tabby.ai` + Approve) makes `REJECTED`. The
  payment object has no `is_expired` key (the column exists; EXPIRED shows in `status`).

**Step 5 so far (Claude only):**
- **127 ExUnit tests pass** standalone (19 new: `webhook_payload`, `webhook_params`).
- In-memory harness (scratchpad `webhook_harness.exs`, `webhook_compile.exs`; deps' compiled beams from
  `_build`, nothing written): compiles the real `Webhooks`, `WebhookWorker`, `Payments`, `Presenter`
  with a stand-in Repo. 31 checks against a tiny local HTTP server: 200 = success; 201, 204, 302, 404,
  409, 500 = failure; connection refused = failure; custom header and JSON body sent; one delivery row
  per attempt; 5th registration refused; `notify` never raises; worker backoff 60/120/240/240 s,
  5 attempts, queue `tabby_sim_webhooks`, unknown webhook -> cancelled; capture notifies once, a replay notifies nothing (an
  update notified nothing at the time; since Step 7 a real update sends an `update` webhook).
- Claude over HTTP at the user's request (scratchpad `step5_http_checks.py`, receiver on port 9199): **44/44
  quick checks passed** (registration + every 400/401, limit of 4 incl. 8 parallel registrations, the full
  flow authorize/capture/capture/refund with exact payloads, header `X-Sign`, nothing sent for replays,
  failed calls, PUT, GET, second close, Cancel (expire off) - NB since Step 7 a `PUT` that changes the reference sends an `update`
  webhook, so `step5_http_checks.py` (scratchpad) would now fail its "PUT sends nothing" check; it is outdated, not the simulator, checkout-rejected and never-approved payments,
  other merchant code, webhook registered later, two webhooks, HTTP 201 = failure, connection refused).
- **First retry test FAILED; root cause: the Oban table is shared.** `oban_jobs` in `shukria_transactions`
  is used by many other apps and developers' servers (RiskCore, TmsCore, DisputeCore, PgSettlement ...).
  Our jobs were in the `webhooks` queue, which other servers also run. One of them fetched our job, could
  not load `DaProductApp.TabbySim.WebhookWorker` ("unknown worker"), and the failure used up one of the 5
  attempts with Oban's default backoff. Evidence: jobs 374609/374623/374624 ended `discarded` at attempt 5
  but have only 1-3 delivery rows and 2-4 "unknown worker" errors; the attempts our server did run were
  60 s apart as designed (11:26:40 -> 11:27:41). The only jobs in the `webhooks` queue are ours (22; 3
  discarded this way).
  **Fix (approved by the user, 2026-10-28):** a dedicated queue `tabby_sim_webhooks`, started only inside a
  new `if TABBY_SIM_ENABLED` block in `config/runtime.exs` (`config :da_product_app, Oban, queues:
  [tabby_sim_webhooks: 3]`), and `queue: :tabby_sim_webhooks` in the worker. Checked offline with
  `Config.Reader` (same files the server loads): disabled -> queues unchanged `[default: 10, mailers: 20,
  high: 50, low: 5, reports: 3, webhooks: 5]`; enabled -> the same plus `tabby_sim_webhooks: 3`, engine,
  notifier and both plugins kept. **The running server must be restarted** (runtime.exs is read at boot).
  **Retry test rerun after the restart: PASSED (2026-10-28).** Payment `fad26d49-...`, webhook answering 500.
  Delivery log: attempt 1 12:17:16, 2 12:18:16 (+60 s), 3 12:20:17 (+121 s), 4 12:24:19 (+242 s), 5 12:28:20
  (+241 s), all `failed` HTTP 500, no 6th. Oban job 374763: queue `tabby_sim_webhooks`, `discarded`,
  attempt 5 of 5, 5 errors all "HTTP 500", 0 "unknown worker". The user showed `Application.get_env(:da_product_app,
  Oban)[:queues]` in iex: the six existing queues plus `tabby_sim_webhooks: 3`.


**Step 6 results (Claude over HTTP against the dev server, short timers `SESSION_TTL_MIN=1`, `EXPIRE_AFTER_MIN=2`,
`SEND_EXPIRED_WEBHOOK=true`; receiver on 9199, since stopped; no table was cleared):**
- Unit tests 142 pass; in-memory harness 48 checks pass (it models Repo, so it cannot see timing races; it does check the generated SQL of `pending_expiry`/`due_count`).
- First run: 48/49. **Bug found by the E5 check and fixed:** the job counted only CREATED payments with a future
  `expires_at` when deciding whether to reschedule. Three payments due one second after the others became due
  while the job was still expiring the first four, so the job saw none left, stopped, and they stayed CREATED
  (116 s, until a list call expired them on read). Fix: `Payments.pending_expiry/0` counts every CREATED payment;
  an overdue earliest time means "run again in 1 s" (`ExpiryRules.next_delay/2` already clamped to >= 1).
- Rerun of batch 1 after the fix: **34/34**. E1: page 200 at once, 410 "Session expired" and approve 410 after
  1 min while retrieve = CREATED; the job expired the payment 2-4 s after `expires_at` with nobody reading it;
  exactly one delivery row and one receiver POST (event expire, success 200, body status "expired", is_expired
  true, expired_at = the payment's `expires_at`, closed_at null); more retrieves/list/PUT sent no second one.
  E3: pre-scoring-rejected payment EXPIRED with 0 webhooks, the control payment of the same merchant 1.
  E4: capture/close/refund/update on EXPIRED -> 400 with the documented texts, EXPIRED terminal. E5: 7 quick
  checkouts -> exactly 1 pending job; the three same-second payments were expired by the job; the chain ran 3
  times (the last one 1 s after the previous, the fixed path) and stopped with 0 pending; all jobs completed.
- E2 (batch 2, 15/15): with the pending job deleted, both payments stayed CREATED in the DB; retrieve returned
  EXPIRED and sent the webhook then (late) with `expired_at` = `expires_at`; the list endpoint expired the other
  payment (own single webhook); more reads sent no second; a new checkout restarted the job (1 pending).
- The first job run expired the 28 overdue rows left from earlier days (no table was cleared).
- E6 (simulator disabled), with the user restarting the server: payment `6bfd3605-...` created while enabled
  (job 377502 scheduled); server restarted without any `TABBY_SIM_*`: the simulator route gives HTTP 404 with the
  same `Phoenix.Router.NoRouteError` and message format as a path that never existed (`/this/never/existed`),
  the normal app page still 200; 2+ minutes past `expires_at` the payment was still `CREATED` (`updated_at`
  unchanged), the job still `available` and never attempted (its queue is not started while disabled), no
  delivery rows, nothing logged by the simulator. Server restarted with the simulator on: the overdue job ran at
  once (07:19:34), payment `EXPIRED`, `is_expired` 1, job `completed`, 0 pending jobs, 0 CREATED payments.
- Caution when checking servers with `pgrep -f "beam.smp"` inside a `bash -c` command: the shell's own command
  line matches and shows up as a second "server" with no env vars.
- After the two guards: harness 48/48 (NULL `expires_at` not counted and never expired; a payment whose expiry
  keeps failing -> 3 passes, ERROR log, 60 s; three payments becoming due mid-run -> all expired, no error, chain
  stops) and batch 1 again live: 34/34 (the job expired all seven payments itself, 3 runs, the last 1 s after the
  previous because two payments were only just due, chain stopped, no "stuck" error in the server log).

**Step 7 results (unit tests and stand-in harnesses first, then HTTP against the dev server at the user's request, 2026-10-29):**
- Unit tests: **176** passed at the end of Step 7 (203 now, see the Step 8 results) (new: `fault_rules`, `admin_token`, `admin_rules`, `admin_page`; `webhook_payload` updated for
  the update event). Every new and edited `.ex`/`.exs` parses.
- Harnesses (stand-in Repo, real modules, scratchpad): Step 5 33/33, Step 6 48/48, Step 7 49/49. Step 7 covers: arming (refuses
  undocumented combinations), a fault for one payment beats one for any payment, atomic use (a lost race retries, always losing gives
  up after 3 tries), a missing faults table never breaks a call, **a 500 on retrieve of a due payment leaves it CREATED (faults fire
  first)**, 409 lock / 500 "before" change nothing, 500 "after" applies the capture (and the close) first, 500 on checkout and the
  webhook endpoints, force-status (every documented move, every refused one, the webhook rules for EXPIRED incl. a checkout rejected
  up front, the checkbox, the global setting), the update webhook (sent for a real change, not for an empty PUT or a 400), resend and
  out-of-order (newest first, 2 s apart, delay added; refused with fewer than 2 events or a missing webhook/payload).
- **Not covered:** the real MySQL (the faults table and the atomic update), the Phoenix controllers/router (a compile error would
  show on the first request after a restart), the pages in a browser, Basic auth and the form token end to end.

**Step 7 over HTTP** (scratchpad `step7_http_checks.py`, receiver on 9199 since stopped; server restarted by the user with
`SESSION_TTL_MIN=5 EXPIRE_AFTER_MIN=10 SEND_EXPIRED_WEBHOOK=true ADMIN_TOKEN=...`, migration `20261029100000` applied): **110/111**.
- T1 protection: no password 401 with a Basic challenge, wrong password 401, right password 200; the token never appears on the
  page; no-store header; POST without/forged/tampered form token 400; valid form token without the password 401; the API bearer key
  does not open the admin page.
- T2 409 lock on capture (documented body with `errorType: conflict`, nothing captured, the next capture works); T3 refund 409
  "payment is disputed" exactly `{"status":"error","error":"payment is disputed"}` with no `errorType`; a refund fault does not fire on
  a capture; T4 500 "before" (nothing applied) and "after" (capture applied, CLOSED), the idempotent retry gives 200 and one
  capture; 500 "after" on close and on refund; 500 "before" on retrieve, update, close and list.
- T5 a 500 on retrieve of a DUE payment leaves it CREATED (is_expired 0); the next retrieve expires it. T6 500 on `POST`/`GET
  /api/v1/webhooks` and on checkout (no payment created); a fault never answers before authentication.
- T7 resend: the same payload again, a second delivery row; with delay 8 not delivered after 3 s, delivered after ~8 s; refusals
  (unknown delivery, negative/oversized delay, bad id). T8 out of order: arrived closed(2 captures), authorized(1), authorized(0)
  at 10:58:47, :49, :52 (2-3 s apart); one-event payment and bad id refused.
- T9 force status: CREATED to CLOSED refused; CREATED to AUTHORIZED and AUTHORIZED to CLOSED with their webhooks (`closed_at` set);
  nothing moves out of CLOSED; a checkout rejected up front forced to EXPIRED sends NO webhook, a normal one sends `expired` with
  `is_expired` and `expired_at`; checkbox off sends none; forced REJECTED sends `rejected`; unknown payment and bad target refused.
- T10 update webhook: status as before, new `order.reference_id`, delivery events authorize, update; nothing for an empty PUT;
  on a CLOSED payment the webhook says `closed`; nothing for a 400. T11 fault list shows payment id and uses left; remove; "already
  gone"; ten refused combinations/inputs arm nothing.
- Extra: a fault with 3 uses and 8 parallel captures gives exactly 3 x 409 and 5 captures applied (atomic); markup in a merchant code
  and in the notice text is HTML-escaped; payment page for unknown/bad ids redirects with an error.
- **Changed after this HTTP run:** the refund lock 409 now has no `errorType` (see decision 3). The T3 check "409 lock on refund" in this run
  passed under the OLD body (with `errorType`); the script `step7_http_checks.py` now expects the new body and has not been rerun against the
  server. Unit tests (176) and the Step 7 harness (51/51) cover the new body.
- **Rerun by the user on the server after the change (2026-10-29):** the refund lock 409 now answers `409 {"error":"could not acquire lock:
  payment is locked","status":"error"}` with **no `errorType`**; the retry with the same `reference_id` gave 200 with exactly one refund (30).
  The payment was approved in the browser through the public IP (the hosted page works from outside, not only with curl).
- The "8 parallel captures" check: amounts were 10.00 each on a payment of 100.00 with a fault allowing 3 x 409. 3 got the 409 and 5 were
  applied (the check asserted 3 and 5; the DB read afterwards showed AUTHORIZED with 5 captures, i.e. 50 of 100). The total can never have
  exceeded the amount in this check (even all 8 would be 80); it never reached the over-capture limit. That rule under concurrency is
  covered by Step 4 (10 parallel captures of 20 on 100: exactly 5 succeed, 5 get 400, total 100). The script now also asserts the sum.
- The 1 miss: "a final-state payment page has no force form" was run against a payment with 50 of 100 captured (still AUTHORIZED, so
  correctly offering CLOSED). Checked afterwards: CLOSED, EXPIRED and REJECTED payment pages say "final state" and have no force form.
- The first requests after the restart took ~20 s (the server recompiled the new code); the controllers compiled without error.
- T12 (the user restarted with `TABBY_SIM_ENABLED=true TABBY_SIM_PUBLIC_URL=...` and NO `TABBY_SIM_ADMIN_TOKEN`): `/tabby-sim/admin` gives HTTP 404
  with `Phoenix.Router.NoRouteError` "no route found for GET /tabby-sim/admin" - identical in kind and message to `/this/never/existed`; the
  same with the old password; `GET /admin/payments/:id` and `POST /admin/faults` also 404 and armed nothing; the API still answers (simulator on).
- Left behind: no armed fault (the script deletes `tabby_sim_faults` rows at start and end), no process of Claude's running.

**Step 8, part 1 (no DB needed; Claude only, nothing run against the server or DB):**
- **203 unit tests pass** standalone, stable over repeated runs (new: `session_rules`, `delivery_rules`; `hpp_page`, `admin_page`, `admin_token`, `fault_rules` updated).
  One real defect found and fixed while writing them: a `payment` that is not a map was not reported as missing; and a flaky tamper test in `admin_token_test`.
- Harnesses after the refactors: Step 5 33/33, Step 6 48/48, Step 7 51/51, plus a new checkout harness 8/8 (a checkout with `merchant_urls` but missing fields is created
  exactly as before and only logs the warning; no warning for a complete one, for the minimal payload, or for a failed validation; a checkout rejected up front is unchanged).
- The 403 itself lives in the Phoenix controller, so it is covered by the `SessionRules` unit tests only; **not run over HTTP**. Same for the hosted page's new wording
  (`HppPage` is unit-tested; the live page was not opened).
- The scripts in `test/tabby_sim/scripts/` were byte-compiled and scanned for the IP, paths, passwords, the database name and the admin test password (none left);
  they were **not rerun** after the copy.

**Postman collection (2026-10-29, after Step 8; written on request, nothing run against the server or DB):** `test/tabby_sim/postman/` holds a Postman v2.1
collection (13 folders, 99 requests) and a README. **Update 2026-10-29: no environment file any more;** all variables (with the defaults) are collection variables, so importing the one file is enough, and the scripts save ids with `pm.collectionVariables.set`. (Reason: the user imported the collection alone and saw nothing resolved.) Every request has Tests (status code + key fields / error text); the checkout request saves
`session_id`, `payment_id` and `web_url`; hosted-page POSTs are form-encoded with `followRedirects` off; admin requests use Basic auth `{{admin_user}}` / `{{admin_token}}`
and the admin POSTs send `_token={{form_token}}`, read from the dashboard HTML by "Get admin form token". Checked offline only: both JSON files parse, all 99 requests have
`pm.test` assertions, every script passes `node --check`, no secrets or database names (`admin_token` is the test value `admin-test-123` and must match the server's `TABBY_SIM_ADMIN_TOKEN`; `secret_key` is the simulator's
test key; `base_url` holds the server's public address as the user specified). **Update 2026-10-30: the user has now run the collection in Postman against the server, folders 0-11 (see "Postman verification" below); folder 12 was skipped.** (Original note: the assertions had not been run in Postman, so a text or two might need adjusting.)
It was generated by a throwaway script kept outside the repo, so later edits are made in the JSON directly.

**Result pages (2026-10-29, after the Postman run; built on request, not yet run on the server):** the user could not tell approve / cancel / reject apart
because the `merchant_urls` pointed at `example.com`. Added stand-in merchant pages: `GET /tabby-sim/merchant/{success|cancel|failure}?payment_id=...`
(`ResultPage` pure HTML, `MerchantController`, three lines in `router.ex` inside the `:tabby_sim_hpp` scope, so the same gate). A colour card (green PAYMENT SUCCESSFUL,
amber PAYMENT CANCELLED, red PAYMENT FAILED) shows `payment_id` and the payment's current status (plain `Repo.get`, so a visit never expires or changes anything).
Not a Tabby feature: in a real integration these are the merchant's own pages. The Postman collection's `merchant_urls` now use `{{base_url}}/merchant/...`
and the `Location` assertions were updated. 5 new unit tests (`result_page_test.exs`, 208 total, run standalone: pass). The project compiled with the new
controller (via `mix run --no-start`, which also touched `_build`). Needs a server restart to take effect. **Verified 2026-10-30:** the user restarted, and approve / reject / Back to Store in a browser ended on the green / red / amber pages with the right status.

**Postman verification (2026-10-30, run by the user; each folder checked against the DB by Claude with read-only SELECTs, on the user's request):**
- Folders 1-9 pass. Folder 8: the `total_count` of every filter matched the DB (default 72, new 45, captured 18, cancelled 6, refunded 3, rejected 14).
- Folder 0: List webhooks and Get admin form token. "Register webhook" was deliberately not sent, because `demo` already had a webhook for the same receiver URL and a second one would double every delivery. The limit of 4 per merchant code was covered by the earlier Python scripts.
- Folders 10 and 11 were run and the DB agrees: no fault left armed, a 409 stores nothing, a 500 "after" leaves the change applied and the retry with the same `reference_id` adds no second capture, the resent duplicate and the out-of-order send appear in `tabby_sim_webhook_deliveries` (rows 2 s apart, captures before authorize), a forced status sends one webhook, a refused force sends none.
- Folder 12 (expiry) was skipped on purpose; the Python expiry scripts covered it earlier.
- Live webhooks: with `test/tabby_sim/scripts/webhook_receiver.py 9099` running, a new payment delivers `authorize` and `capture` on attempt 1 (HTTP 200, a few ms). While no receiver was running, events failed with `connection refused` and were retried at 1/2/4/4 minutes; once the receiver was up, the events still inside their 5 attempts were delivered (attempt 5) and the others were discarded for good.
- (Observed in this run, now FIXED by slice 0 - see section 10) a full capture sent **one `capture` webhook whose snapshot said `status: closed`**. That was a simulator bug, not an open question: the Webhooks page and the Full Testing Checklist document `authorized` (with the capture in `captures[]`) followed by `closed`. Folder 1's text said "two" and was then wrongly "corrected" to describe the buggy behaviour; both are now replaced by "three webhooks".
- Facts learned while testing: payment and session ids are random UUIDs (never order by `id`, use `inserted_at`); `merchant_urls` are stored at creation and never rewritten; `payload` keeps the original request while `order.reference_id` can change with PUT update; used faults disappear from `tabby_sim_faults`. The collection holds all its variables itself (no environment file).
- Read-only notes comparing MPGS with this simulator are in `Tabby_Mpgs_understanding.txt` (same folder).
- The user took this file out of the commit on purpose. It stays in the working copy; never delete or move it.

## 7. Step 4 test plan (to hand to the user)

Easiest: ask to run `step4_http_checks.py` (needs the user's OK, creates ~15 test payments). By hand:
1. Create a payment, open its link, Approve with 8888 (README "Examples").
2. `GET` it: `status AUTHORIZED`, all documented keys. `PUT` `order.reference_id`.
3. Capture 60 -> AUTHORIZED, 1 capture; repeat the same call -> still 1 capture (replay).
4. Capture 50 -> 400 `capture amount exceeds the remaining amount`; capture 40 -> `CLOSED`.
5. Repeat the capture of 40 with the same `reference_id` -> 200 `CLOSED` (not an error).
6. Refund 30, 70 -> OK; refund 0.01 more -> 400; close -> 400 `already closed`.
7. List: `?status=closed&limit=1`, `?limit=21` (400), no params (only AUTHORIZED and CLOSED).

## 8. Repo facts worth knowing

- Elixir / Phoenix 1.7 (`:da_product_app`), MySQL via MyXQL, Oban (Dolphin engine) with a `:webhooks`
  queue and a Cron plugin in `config/config.exs`. `Req` is a direct dep; HTTPoison is only transitive.
- **`oban_jobs` is shared by many apps and developers' servers on this database.** Never use the shared queue names for
  simulator work: it runs in its own `tabby_sim_webhooks` queue (started in `config/runtime.exs` only when the simulator is on;
  `queues:` is deep-merged there, `plugins:` would be replaced). `Oban.insert` unique jobs work on the Dolphin engine.
- `~/.my.cnf` (mode 600) holds the DB login for the `mysql` client; never pass `-p` on a command line.
- Dev DB (`config/dev.exs`) was changed by the user to `shukria_transactions`. `ecto_repos` lists
  `DaProductApp.Repo` and `ShukriaMmsRepo`; always pass `-r DaProductApp.Repo`.
- The dev server reloads code on the next request (a live-reload iframe is in the pages), so a compile
  error in a new file shows up the moment anyone calls the app.
- `deps/**/_build`, `_build/` and `priv/static/assets/app.css` show up in `git status` from the user's
  builds; not part of this work.
- `lib/da_product_app/qr_provider/alipay.ex` has a private key committed in source (not touched; worth
  moving to env separately). Pre-existing warning: unused alias `Transaction` in
  `merchant_transactions_controller.ex`.
- The app runs on a remote machine (`demo`, listening on port 4030); the user's browser is on a Windows PC.
  The simulator's pages are reached at the server's public address (`78.47.213.209:4030`).
- Server logs: `logs/app_<dates>.log`; simulator lines contain `[TabbySim]`.
- **Left behind by the failed `mix test` run (not touched, by the user's decision):** the database `da_product_app_test_greeshma` (created by `ecto.create`, partly or not
  migrated: the run stopped at `rename_terminal_id`), and `shukria_mms_test_greeshma` if the run got as far as creating it (not checked). Claude did not touch any database.
  Drop them only if the user asks.
- `mix test` cannot work here: the test DB must be built by the migration history, which fails on an empty DB (see Step 8, section 4). Use the unit-test command in the README.

## 9. Files created or changed by this work

New:
- `priv/repo/migrations/20261028120000_create_tabby_sim_payments.exs`, `…120100_create_tabby_sim_webhooks.exs`,
  `…120200_create_tabby_sim_webhook_deliveries.exs`
- `lib/da_product_app/tabby_sim/`: `payment.ex`, `webhook.ex`, `webhook_delivery.ex`, `config.ex`,
  `errors.ex`, `normalizer.ex`, `decision.ex`, `checkout.ex`, `presenter.ex`, `hpp_rules.ex`,
  `hpp_page.ex`, `hpp.ex`, `payments.ex`, `payment_rules.ex`, `list_params.ex`, `webhooks.ex`,
  `webhook_worker.ex`, `webhook_payload.ex`, `webhook_params.ex`, `expiry_rules.ex`, `expiry.ex`, `expiry_worker.ex`, `fault_rules.ex`, `fault.ex`, `faults.ex`, `fault_gate.ex`, `admin.ex`, `admin_page.ex`, `admin_rules.ex`, `admin_token.ex`, `session_rules.ex`, `delivery_rules.ex`, `README.md`, `CONTEXT.md`
- `lib/da_product_app_web/plugs/tabby_sim_gate.ex`, `tabby_sim_auth.ex`, `tabby_sim_admin_auth.ex`
- `priv/repo/migrations/20261029100000_create_tabby_sim_faults.exs`
- `lib/da_product_app_web/controllers/tabby_sim/`: `checkout_controller.ex`, `hpp_controller.ex`, `payments_controller.ex`, `webhooks_controller.ex`, `admin_controller.ex`
- `test/tabby_sim/`: `decision_test.exs`, `normalizer_test.exs`, `hpp_rules_test.exs`, `hpp_page_test.exs`,
  `payment_rules_test.exs`, `list_params_test.exs`, `webhook_payload_test.exs`, `webhook_params_test.exs`, `expiry_rules_test.exs`, `fault_rules_test.exs`, `admin_token_test.exs`, `admin_rules_test.exs`, `admin_page_test.exs`, `session_rules_test.exs`, `delivery_rules_test.exs`, and `test/tabby_sim/scripts/` (7 Python files + README), `test/tabby_sim/postman/` (collection, README)

Test-setup edits **reverted**: `config/test.exs` (env-var DB login, `:start_mqtt false`) and `lib/da_product_app/application.ex` (conditional MQTT children) were changed
at the user's request on 2026-10-29 and then restored with `git checkout --` when Step 8 was closed; `git diff --stat` for both is empty. They are committed versions again.

Edited, shared files (additions only): `config/runtime.exs` (the TABBY_SIM_* block incl. `TABBY_SIM_PRESCORE_REJECTED_EXPIRE_WEBHOOK`
and `TABBY_SIM_ADMIN_TOKEN`, and the `if TABBY_SIM_ENABLED` block with the `tabby_sim_webhooks` queue); `lib/da_product_app_web/router.ex`
(pipelines `:tabby_sim_api`, `:tabby_sim_checkout`, `:tabby_sim_hpp`, `:tabby_sim_webhooks`, `:tabby_sim_admin` and their `/tabby-sim`
scopes: checkout, payments, HPP, `/api/v1/webhooks`, `/admin`). Nothing in `config/config.exs`, `mix.exs`, the Oban plugins list or
any other shared module.

Edited later, simulator's own files: `payments.ex` (webhook hooks, expiry, force status, update webhook), `checkout.ex` (restarts the
expiry job), `webhooks.ex`, `webhook_payload.ex`, `presenter.ex`, `config.ex`, `errors.ex` (`send_raw/3`), and the three API controllers
(each runs through `FaultGate`).

## 10. Next action

Steps 1-8 of the simulator are closed, the Postman collection was verified by the user (folders 0-11, 2026-10-30) and the simulator was committed as 87298ef (branch now `feature/tabby-integration`; CONTEXT.md is not part of the commit). The real integration is built separately from MPGS and merged with it later.

### Slice 0 - full capture sends two webhooks (edited 2026-10-30; NOT compiled, NOT run - the user runs the tests)

Why: Tabby's Webhooks page (typical payment) and the Full Testing Checklist document `authorized` -> `authorized` (with the capture in `captures[]`) -> `closed` for a full capture; the simulator sent a single `capture` webhook with status `closed`.
What changed (simulator files only):
- `webhook_payload.ex`: new pure `plan/2` -> `[{event, snapshot, delay_s}]`. A capture that left the payment CLOSED gives `{"capture", snapshot with status AUTHORIZED and closed_at nil, 0}` then `{"close", the closed payment, 1}`; every other action gives one entry. The 1 s delay only keeps `closed` behind `authorized` in the queue (delivery order is not guaranteed anyway).
- `webhooks.ex`: new `notify_change/2` (runs the plan), `notify/3` accepts `delay:` (Oban `schedule_in`); the stale moduledoc queue name `:webhooks` was corrected to `:tabby_sim_webhooks`.
- `payments.ex`: `mutate/3` calls `Webhooks.notify_change/2` instead of `notify/2`. `payment_rules.ex`: moduledoc note only (no rule changed).
- Tests: 5 new unit tests in `webhook_payload_test.exs` (`plan/2`), so 213 unit tests expected. `scripts/webhooks_checks.py` (closing capture -> two deliveries `capture` + `close`; the payment has 6 deliveries: authorize, capture, update, capture, close, refund) and `scripts/admin_checks.py` T8 (4 webhooks: authorized x3 then closed; out of order sends 4, closed first) were updated. Postman: folder 1 text ("three webhooks") and the "Capture the remaining 40" description (JSON edited directly; the scratchpad generator got the same text), `postman/README.md` and `README.md` webhook table.
- **Bug found by the first manual test (2026-10-30) and fixed:** `Webhooks.notify_change/2` piped the payment into `plan(action)`, so the arguments were swapped (`plan/2` takes the action first) and NO capture webhook was sent (the DB showed only the `authorize` delivery). The 213 unit tests passed because they call `plan/2` directly. Fixed by calling `WebhookPayload.plan(action, payment)`; the server needs a recompile (`recompile` in iex) and a fresh payment to retest.
- **Verified by hand (2026-10-30, the user ran the curls, Claude only read the output):** 213 unit tests, 0 failures. After the fix, a fresh payment (SLICE0-3, merchant_code `slice0`) delivered `authorized` (no captures) -> `authorized` (capture 60) -> `authorized` (2 captures, `closed_at` null) -> `closed` (`closed_at` set, 2 s later); `tabby_sim_webhook_deliveries` shows 4 rows authorize / capture / capture / close, all success HTTP 200. Also verified by hand on the same payment: replaying the closing capture returned the same payment (2 captures) and added NO delivery row; a refund of 30 added exactly one `refund` delivery (success, 200), status stayed CLOSED. `webhooks_checks.py quick` was run by the user after the fix: **47/47 passed** (full capture = `capture` + `close`, 6 deliveries for the payment, close of an AUTHORIZED payment = `authorize` then `close`, closing again sends nothing). The out-of-order tool (T8) was checked by hand on the admin page of the same payment (5 recorded events): the receiver got, 2-3 s apart, `closed` (1 refund), `closed`, `authorized` (2 captures), `authorized` (1 capture), `authorized` (0 captures) - newest first, as expected. NOT run after the change: `admin_checks.py` itself (its T8 expectations say 4 webhooks for a payment without a refund; run it at the start of slice 1 testing if wanted) and the Postman folders 1, 6, 11 (description changes only). **Slice 0 closed.**
- Behaviour that did NOT change: the delivery rows/retry policy, a replayed capture sends nothing, a partial capture sends one `authorized`, close sends `closed` (`closed_at`), refund sends `closed` (`refunds[]`), "force status" CLOSED sends one `closed`.

### Real Tabby integration - revised design (approved by the user 2026-10-30; no code written yet)

Principle: switching simulator <-> real Tabby is only config (base URL per region; `TABBY_BASE_URL` is an override used for the simulator). Nothing in the real code may reference `tabby_sim`. Built in `lib/da_product_app/tabby/` (empty dir exists) and `lib/da_product_app_web/controllers/tabby/`; one additive `scope "/tabby"` in the router; Oban queue `:tabby`, never `:webhooks`.

Decisions (user): D1 new `tabby_*` tables only (`tabby_payments` = one row per attempt, `tabby_refunds`, `tabby_webhook_events`), several attempts per order reference. D2 v1: `Tabby.Credentials` from env vars in `runtime.exs` (one test merchant incl. region); swap to the MPGS Vault at the merge, no custom encryption now. D3 JSON API + a simple demo checkout page (TabbyCard with the public key only; its doc details are still to be fetched before that slice). D4 AED/SAR, web Pay in 4, full capture, refunds incl. partial, close. D5 validate the full create-session required fields + the amount rule; the caller sends its own success/cancel/failure URLs, we redirect to them after verifying (stored at create time, host allow-list so the return endpoint is not an open redirect).

| # | Design point | Source |
|---|---|---|
| 1 | Background pre-scoring: `POST /tabby/eligibility`, minimal payload (amount, currency, buyer.email, buyer.phone, merchant_code) to `POST /api/v2/checkout`; `created` -> show Tabby, `rejected` -> hide + `configuration.products.installments.rejection_reason`; on error/timeout show Tabby. Writes nothing. | doc: Checkout Flow |
| 2 | Sessions are disposable: several `tabby_payments` attempts per order reference, one click = one session, never reuse an HPP URL; our timeout shorter than the HPP lifetime (20 min, configurable). Only one attempt may end authorized/captured (nullable unique `winner_key` column, MySQL has no partial unique index); a second authorized attempt is closed without capture and flagged. Idempotency lives on capture/refund `reference_id`, not on session creation. | doc: Full Testing Checklist (Sessions and Attempts); winner_key is ours |
| 3 | Rejected session = business outcome: return the reason, no redirect, info log only, no alert; always check `web_url` before redirecting. | doc: Full Testing Checklist (Rejected Sessions), Checkout Flow |
| 4 | Return/verification always calls Retrieve (the redirect type is not trusted): AUTHORIZED -> capture; CLOSED -> already captured, no call; CREATED -> "confirming", keep checking; REJECTED/EXPIRED -> failed/expired (EXPIRED terminal). | doc: Payment Processing (Payment Verification) |
| 5 | Capture safety: atomic claim `UPDATE ... SET status='capturing', claimed_at=now WHERE id=? AND status='authorized'` (affected rows = 1 owns it; released on failure), AND `reference_id = "capture-{order_reference}"` (never a timestamp). Return page, webhook worker and poller all use this one function. A capture timeout is not a failure: Retrieve, then retry the same `reference_id`. Stale claims are released by the poller. Partial capture out of scope in v1. | doc: Payment Processing ("Avoiding duplicate captures", idempotency), Full Testing Checklist (Captures); claim columns are ours |
| 6 | Webhooks are notifications only: take `payment_id` from the body, call Retrieve, apply forward-only transitions. No dedupe by status (capture webhook stays `authorized`, refund stays `closed`). Store every event raw; respond 200 fast; non-empty `captures[]` = confirmation, not a capture request; unknown payment -> 404 so Tabby retries; check the registered static header (no signature) in constant time; optional IP allow-list (8 documented IPs); read `captures[]`/`refunds[]` by `created_at`, never array position. Webhook URL must be HTTPS and reachable ("not local"). Registered per merchant_code + key pair, max 4. | doc: Webhooks page; FAQ "How can I verify Payments via Webhooks?" and Full Testing Checklist (getPayment on the authorized webhook); Register a webhook |
| 7 | Retries: 5xx, timeouts, 409 "payment is locked" and 429 retried with backoff and the SAME `reference_id`; other 4xx recorded, not retried. Branch on HTTP status + `errorType` (400 bad_data, 401 not_authorized, 403 no_permission, 404 not_found, 409 conflict, 500 internal; 429 none); never parse the `error` text. A refund 409 may be "locked" (retry) or "disputed" (blocked): retry a few times with the same `reference_id`, then mark "needs attention". | doc: API Error Codes; Technical Requirements |
| 8 | Base URL per merchant region (AED -> `api.tabby.ai`, SAR -> `api.tabby.sa`); a wrong region gives 403 "merchant region does not match cluster region" = a configuration error. `TABBY_BASE_URL` overrides. | doc: API Overview, API Error Codes |
| 9 | Full capture webhook sequence is `authorized` (with capture) -> `closed`; the old "open question" is removed. The simulator is fixed by slice 0. | doc: Webhooks page + Full Testing Checklist |
| 10 | Close (`POST /api/v2/payments/{id}/close`, only for our `authorized`; a second close returns 400 "already closed" = treated as success after Retrieve) and refunds incl. partial (only CLOSED, within 180 days, total <= captured, a full refund once). Refund `reference_id` = `refund-{order_reference}-{n}`, `n` a DB sequence stored BEFORE the call. A poller flags `authorized` payments older than N days (Tabby may capture itself after 21 days). | doc: Close a payment, Payment Processing (Refunds); `n` and the poller are ours |
| 11 | Create-session validation: required `lang` (ar/en), `merchant_code`, `payment.amount` (max 2 decimals), `currency`, `buyer.name/email/phone`, `shipping_address.city/address/zip`, `order.reference_id`, `items[].title/quantity(>=1)/unit_price(>=0)/category`, `buyer_history.registered_since`; omit optional fields instead of sending empty strings. **Amount rule (documented, Technical Requirements):** `payment.amount = SUM(item.unit_price * quantity - item.discount_amount) + tax_amount + shipping_amount - order.discount_amount`; compute each line, round it ONCE to the currency's decimals, then sum the rounded values (item `discount_amount` is included). | doc: Session Payload Model, create-a-session (LanguageCode), Technical Requirements |
| 12 | Session lifetime 20 min and EXPIRED about 10 min later (configurable); webhook URL must be HTTPS and public. | doc: Payment Processing, Register a webhook |

Slices (stop for approval after each; Claude never runs migrations, `mix compile` or `mix test` - the user does): 0 simulator fix (done in code, awaiting the user's test) / 1 `Config` (region), `Credentials`, `Client` (create, retrieve, capture, refund, close, webhook registration), error classifier + retry policy, unit tests with a faked HTTP layer, no DB / 2 migrations + schemas / 3 eligibility / 4 create session (validation, rejected, URL allow-list) / 5 return page + verification / 6 capture claim, close, poller / 7 webhooks / 8 refunds / 9 demo checkout page / 10 end-to-end against the simulator, then one controlled run on real Tabby with test credentials.

### Slice 1 - the Tabby client (written 2026-10-30; the first version passed 97 tests, 0 failures; **the user did NOT approve it and asked for three changes, made the same day; the user ran them: 111 tests, 0 failures; then a second adjustment (close 429), see below; awaiting the user's approval and commit)
(The `[warning]`/`[info]` lines printed during a run come from tests that do not capture the log; they are expected: they show the retries and, as designed, no key.)

**Review round 1 - the user's three changes (from Tabby's docs):**
1. `POST /api/v2/checkout` (session AND pre-scoring) gets exactly ONE attempt, no automatic retry (Full Testing Checklist: "One click produces exactly one session. Check backend logs."; the call has no idempotency key, so a retry after a timeout could create a second session). `RetryPolicy.decide/2` stops at once for `action: :checkout`; the caller shows "unavailable" (pre-scoring shows Tabby, the docs' fail-safe). `retrieve_first` stays false (no payment id yet).
2. Close is NOT retried automatically (Response codes, close 500: "Call GET /api/v2/payments/{id} to check the current status before retrying"). A retryable failure (timeout, network, 5xx, and also 429/409 - Claude's choice, the user named timeout/5xx) is returned after ONE call with `retrieve_first: true`; 400/401/403/404 (e.g. "already closed") come back unchanged. Capture and refund keep their retries (they carry a `reference_id`).
3. The payment id check accepts any 8-4-4-4-12 hex string, case-insensitive, lowercased, `\z`-anchored (no version/variant check, no `Ecto.UUID`). Test with the real Tabby id `01a1810a-0540-8d1c-9a08-2bf2a93982f4`.
**Review round 2 (user, after 111/111 passed):** close SHOULD retry a 429 (Technical Requirements: "A request over the limit is rejected with HTTP status 429 ... Your integration should handle 429 at any volume"; a rejected request was not processed). `RetryPolicy`: the close clause now applies only when `kind != :rate_limited`, so a close 429 follows the generic retry (500/1000/2000/4000 ms, 5 attempts); if it never clears the error has `retrieve_first: false`; a timeout/network/5xx/409 on a later attempt stops and says `retrieve_first: true`. Close 409 stays "one call + retrieve_first" (409 is only documented for capture/refund). Checkout stays at exactly one attempt for everything, 429 included. 8 tests added (4 policy, 4 client; the old "close not retried" list lost its 429 entry), so 119 tests; **the user ran them: 119 tests, 0 failures**.
Tests added in round 1: retry policy (checkout one attempt for 500/502/429/409/400/timeout/network; close stops with `retrieve_first`; capture/refund/retrieve still retry), client (checkout timeout/500/502/429/409 = exactly one HTTP call although more answers are scripted; close timeout and close 500 = one call + `retrieve_first`; close 400 unchanged; capture/refund timeout still retried with the same body; the real Tabby id in retrieve/capture/close URLs; malformed ids incl. trailing newline refused). The old "close that keeps failing" test (five calls) was replaced.
Slice 2 answers given by the user for later: yes to `winner_key` (unique, set only when an attempt becomes authorized).
`git diff config/runtime.exs` shown to the user: 15 added lines, `TABBY_SECRET_KEY`, `TABBY_PUBLIC_KEY`, `TABBY_MERCHANT_CODE`, `TABBY_REGION` and `TABBY_BASE_URL` are read with `System.get_env/1` and NO default; only the two non-secret integers have defaults (`TABBY_HTTP_TIMEOUT_MS` 30000, `TABBY_MAX_ATTEMPTS` 5).

Branch `feature/tabby-integration`. No DB, no migrations, no routes, nothing run against the server. New files (all additive):
- `lib/da_product_app/tabby/`: `amount.ex`, `status.ex`, `config.ex`, `credentials.ex`, `error.ex`, `error_classifier.ex`, `retry_policy.ex`, `session.ex`, `http_adapter.ex`, `client.ex`, `README.md` (its own README: env vars, modules, each rule tied to its Tabby page, how to run the tests).
- `test/tabby/`: 9 files, 119 unit tests (97 in the first version, 111 after round 1) (the HTTP layer is a scripted fake: `Client` takes an `:http` function and a `:sleep` function). Run command is in the client README (needs `Application.ensure_all_started(:logger)` for the log-capturing tests).
- Shared file touched, additive only: `config/runtime.exs` got a `config :da_product_app, :tabby` block (TABBY_SECRET_KEY, TABBY_PUBLIC_KEY, TABBY_MERCHANT_CODE, TABBY_REGION, TABBY_BASE_URL, TABBY_HTTP_TIMEOUT_MS, TABBY_MAX_ATTEMPTS). Needs a server restart to take effect; without the variables the client only reports "not configured".

How the six points of the brief were built:
1. Auth: `authorization: Bearer <secret key>` on every call, `x-merchant-code` also on `register_webhook` / `list_webhooks`. The key is never logged: `Credentials` hides it from `inspect`, `Credentials.redact/2` masks the key and any `Bearer ...` in text, and `Client` masks `Error.detail` before it reaches the log or the caller. Tests feed the key back in Tabby/network error text and assert it is absent from the log, the error and its `inspect`.
2. Base URL from the credentials' region (uae -> https://api.tabby.ai, ksa -> https://api.tabby.sa); `TABBY_BASE_URL` only overrides. `test/tabby/isolation_test.exs` fails if any `.ex` file under `lib/da_product_app/tabby/` names the simulator, `Repo` or the web layer.
3. `ErrorClassifier`: status first (400/401/403/404/409/429/5xx), `errorType` only as a fallback for a status outside the table; the `error` text becomes `detail` for logs and is never branched on (tests vary the text and assert the same outcome). A rejected session (200, status `rejected`) is `{:ok, %Session{status: :rejected}}`, logged at info.
4. `RetryPolicy`: for capture, refund, Retrieve and the webhook calls: 5xx, timeouts, network, 429, 409 retried with backoff 500/1000/2000/4000 ms and the SAME request (identical body, same `reference_id`, asserted); 400/401/403/404 returned at once. After the attempts run out a capture/refund error has `retrieve_first: true` and `context: %{payment_id, reference_id}`. Any refund 409 is retried 3 times (4 calls), then `needs_attention: true`. **Create session is never retried automatically; close is not retried after a timeout/network/5xx/409 but IS retried on a 429** (see review rounds 1 and 2).
5. `Amount`: decimal strings with exactly 2 decimals out; floats, a third decimal, negatives, zero (for capture/refund) refused before anything is sent; the same for `payment.amount` of a session. No rounding inside the client.
6. Tests as above, none touching a database or the network.

Design decisions of Claude in this slice (not from the docs; tell the user): the backoff numbers (500 ms doubling to 8 s), 5 attempts, 3 refund-409 retries and the 30 s request timeout are ours; the client checks a payment id has the 8-4-4-4-12 hex shape before putting it in a path (no version/variant check); `list_webhooks` treats JSON `null` as `[]`; `GET/PUT/DELETE /api/v1/webhooks/{id}` are not wrapped (the simulator lacks them too); `update_payment` is not wrapped (not needed yet).

### Slice 1 status: committed as `1d13312` ("Add Tabby API client"), after review rounds 1 and 2 (119 tests, 0 failures). Slice 0 is `eed15ae`.

### Slice 2 - the three tables (written 2026-10-30; NOT compiled, NOT run, migrations NOT run - the user runs everything; awaiting the user's review)

Design shown first and answered by the user: (1) amounts `decimal(15,2)`; (2) `winner_key` is cleared when the authorized attempt is closed uncaptured (cancel), kept for `captured`; (3) keep all extras (`claimed_by`, `needs_attention`, `attention_note`, refund `error_kind`, event `processed_at`); (4) store only webhooks that passed the header check; (5) Tabby DOES document a limit (Technical Requirements: "String fields accept a maximum of 255 characters"): the order reference is limited to 200 characters in the app and `order_reference` is `varchar(200)`, so `capture-<ref>` and `refund-<ref>-<n>` fit in 255 (`capture_reference_id` and `tabby_refunds.reference_id` are `varchar(255)`); (6) `winner_key` is the SHA-256 hex of `"merchant_code:order_reference"` in a `char(64)` column (unique index always fits MySQL's key limit).

New files, all additive (no existing table touched, no foreign keys, each rollback = drop the table):
- `priv/repo/migrations/20261030140000_create_tabby_payments.exs`, `...140100_create_tabby_refunds.exs`, `...140200_create_tabby_webhook_events.exs` (the last uses `up`/`down` because of one `ALTER TABLE ... AUTO_INCREMENT`, the same step as the simulator's bigint-key table; the first two use `change`). Versions are after the newest existing one (`20261029100000`).
- `lib/da_product_app/tabby/payment.ex`, `refund.ex`, `webhook_event.ex`: schemas, changesets and pure rules only (no Repo calls): `Payment.winner_key/2`, `keeps_winner_key?/1`, `capture_reference_id/1`, `can_transition?/2` (forward-only moves; `capturing -> authorized` is the claim released after a failed capture), `Refund.reference_id/2`.
- Tests (no database): `payment_test.exs`, `refund_test.exs`, `webhook_event_test.exs`, `migrations_test.exs` (reads the migration files: exactly the three new ones, after every existing version, own table only, NO foreign keys, every migration reversible, money columns `decimal(15,2)`, the agreed lengths and unique indexes, `order_reference` NOT unique, no unique index on webhook events). Total now 168 unit tests; **the user ran them: 168 tests, 0 failures** (seed 436233). **Migrations applied by the user (2026-10-30, `mix ecto.migrate --step 3`): `mix ecto.migrations -r DaProductApp.Repo` shows all three `up`** (20261030140000 create_tabby_payments, 140100 create_tabby_refunds, 140200 create_tabby_webhook_events). Without `-r` the task also tries `DaProductApp.Repos.ShukriaMmsRepo` and stops with "Could not find migrations directory priv/shukria_mms_repo/migrations": harmless for our tables, do NOT create that folder; use `mix ecto.migrate -r DaProductApp.Repo` / `mix ecto.rollback -r DaProductApp.Repo --step 3` from now on. The `SHOW COLUMNS` / `SHOW INDEX` checks against the design were offered, not yet reported.
- `lib/da_product_app/tabby/README.md` has a "Data (slice 2)" section.

Claude's choices in this slice (tell the user): `winner_key` hashes exactly the string the user specified (`merchant_code <> ":" <> order_reference`); the status words (`new, created, rejected, failed, authorized, capturing, captured, closed, expired`) and the allowed moves are Claude's design; indexes `(merchant_code, order_reference)` and `(status, inserted_at)` on payments are for the pollers; `mediumtext` for `raw_body`; the user's MySQL must allow a 1000-byte index on `(merchant_code(50), order_reference(200))` under utf8mb4 (MySQL 5.7+/8 default; if "Specified key was too long" appears, tell Claude).

Commands given to the user (nothing run by Claude): the 168-test command; list pending = `mix ecto.migrations | grep " down "` (must show ONLY the three new tabby migrations); run = `mix ecto.migrate --step 3`; undo (drops the new tables) = `mix ecto.rollback --step 3`.

### Slice 2 status: committed as `952b2ee` ("Add Tabby payments, refunds and webhook event tables"); the three migrations are applied (`mix ecto.migrations -r DaProductApp.Repo` shows them `up`). The `SHOW COLUMNS`/`SHOW INDEX` check against the design was offered and never reported.

### Slice 3 - eligibility endpoint / background pre-scoring (written 2026-11-02; Claude RAN the unit tests at the user's explicit request: **213 tests, 0 failures**, no compiler warning in the new files; NOT committed; nothing run against the server or the DB; awaiting the user's review)

Design shown first and approved ("go with your picks for D1-D5"): D1 server-to-server caller with a shared-secret header from an env var; D2 no limiter in v1, but document "check on entering checkout and again only when the amount changes"; D3 minimal payload; D4 3 s timeout, configurable; D5 names as proposed. The user's corrections: Tabby's rejection text is never passed on (code + the docs' fixed en/ar message for `not_available`, `order_amount_too_high`, `order_amount_too_low`; unknown/null code = the `not_available` message); the endpoint is safe to call again whenever the cart amount changes; the answer is only `show` / `reason` / `message` (no session id, no web_url: slice 4 creates a fresh session with the full payload); currency other than AED/SAR = 400; no caching; Tabby not configured = `show: true` + an error in the log; exactly one Tabby call, short timeout, no DB write. The user also corrected Claude: the unused CREATED session expiring IS documented (session 20 min, then EXPIRED), not an assumption.

Doc lines used (Checkout Flow "Background Pre-scoring Check", FAQ, Technical Requirements, Testing Credentials): "Call `POST /api/v2/checkout` endpoint"; minimal payload "amount, currency, buyer.email, buyer.phone, and merchant_code"; `created` -> show, `rejected` -> hide or show the rejection message; reason at `configuration.products.installments.rejection_reason`; "always default to showing Tabby on API errors or timeouts"; FAQ "Perform the eligibility check once per checkout session before displaying payment methods" (conflicts mildly with Checkout Flow "when customer enters checkout or cart amount changes": the page decides, the endpoint is stateless); FAQ "The minimal payload is accepted for the background pre-scoring call only"; FAQ "Tabby is not permitted to disclose the specific rejection reasons to merchants"; Technical Requirements: create-session limits "counted per API key" (live 100/10 s, test 10/10 s) - an eligibility call uses the same budget as a real "Place order". Testing Credentials: `otp.success@tabby.ai` + `+971500000002` / `+966500000002` = rejected at pre-scoring; NO documented test for too_high / too_low (only testable against the simulator).

New files (additive): `lib/da_product_app/tabby/eligibility.ex` (validate, payload, messages, outcomes, `check/2`; no Repo/web), `lib/da_product_app_web/controllers/tabby/eligibility_controller.ex` (thin), `lib/da_product_app_web/plugs/tabby_eligibility_auth.ex` (header `X-Tabby-Eligibility-Key`, constant-time compare with `TABBY_ELIGIBILITY_KEY`, unset/empty = 401 for everyone + an error in the log, key never logged); tests `eligibility_test.exs` (33), `eligibility_auth_test.exs` (6).
Additive edits to existing files: `router.ex` (one pipeline `:tabby_eligibility` + `scope "/tabby"` with `post "/eligibility"` at the very end), `config/runtime.exs` (`eligibility_key` with no default, `eligibility_timeout_ms` 3000), `config.ex` (two defaults), `client.ex` (new per-call options `:timeout` and `:redact`; redact masks caller-listed strings such as the buyer's email/phone in any error text that is logged or returned, found while reviewing: Tabby's error text could echo them), README, tests in `client_test.exs` and `config_test.exs`. tabby_sim, the shared DB and other people's files were not touched.

Route/contract: `POST /tabby/eligibility`, body `{"amount": "150.00" (string or integer, >0, <=2 decimals, no JSON floats), "currency": "AED"|"SAR", "buyer": {"email", "phone" (8-15 digits, optional +)}}`. 200 `{"show": true, "reason": null, "message": null}` (created, or ANY failure: timeout, network, 5xx, 429, 409, Tabby 4xx, unreadable, not configured, no region), 200 `{"show": false, "reason": code, "message": {"en","ar"}}`, 400 `{"error": "invalid_request", "fields": {...}}` (no Tabby call, values not echoed), 401 `{"error": "unauthorized"}`. A failure caused by our configuration/payload (Tabby 400/401/403/404, bad answer, missing setting) is logged at error level, passing problems at warning; email, phone and keys are never logged.

Claude's choices (tell the user): the header/env/route names; phone 8-15 digits and email "contains @, <=255" (Tabby documents neither check); the timeout of 3 s; 401 (fail closed) when the shared key is unset; the error-vs-warning split in the log; the `:redact` option.
Risk to watch in the first REAL run: the docs say the minimal payload works, but the session payload page marks `lang`, shipping and order fields as required. If real Tabby answers 400, every customer would silently see Tabby (errors in the log).
**Manual curl test (2026-11-02, run by the user on its own server: app restarted with `TABBY_SIM_ENABLED=true`, `TABBY_BASE_URL=http://localhost:4030/tabby-sim`, `TABBY_SECRET_KEY=sk_test_000000000`, `TABBY_MERCHANT_CODE=eligtest`, `TABBY_REGION=uae`, `TABBY_ELIGIBILITY_KEY=elig-test-key-123`, `TABBY_SIM_MAX_AMOUNT=5000`): tests 1-7 ALL passed.** Eligible buyer (`otp.success@tabby.ai`, `500000001`) -> 200 `show: true`; phone `500000002` -> 200 `show: false`, `not_available`, the general en+ar text; amount 9999.00 -> 200 `show: false`, `order_amount_too_high`, the spending-limit en+ar text; wrong key -> 401 `{"error":"unauthorized"}`; no key header -> 401; amount `"150.555"` and the JSON float `150.555` -> 400 `fields.amount`; currency USD -> 400 `fields.currency`. (A missing space inside one Arabic word in the pasted output is a terminal/paste artifact: the source bytes have it.) Then also run and passed: the limit boundary (4999.00 and 5000.00 -> `show: true`; 5001.00 -> `show: false`, `order_amount_too_high`, so the simulator rejects only amounts above the max), and **test 8, Tabby unreachable**: app restarted with `TABBY_BASE_URL=http://localhost:9/tabby-sim` (nothing listens on port 9), the eligible-buyer curl -> 200 `{"show":true,"reason":null,"message":null}` (the fail-safe). The server log lines were not pasted, so the exact warning text for that case was not seen. **The whole curl plan is done.** Each eligible call left a `CREATED` row in the simulator's own `tabby_sim_payments` (merchant code `eligtest`).

### Slice 3 status: committed as `0002672` ("Add Tabby eligibility check endpoint"); 213 unit tests, the whole manual curl plan passed (tests 1-8 plus the 4999/5000/5001 boundary).

### Slice 4 - create the payment session, `POST /tabby/sessions` (written 2026-11-02; Claude RAN the unit tests at the user's explicit request: **320 tests, 0 failures**, no compiler warning in the new files; NOT committed; no migrations; nothing run against the server or the DB; awaiting the user's review)

Design shown first and approved ("go with your picks D1-D6"): D1 separate key `TABBY_API_KEY` / header `X-Tabby-Api-Key` for this and the later capture/refund/close endpoints (the eligibility key stays as it is); D2 caller URLs held to an allow-list `TABBY_RETURN_HOSTS` (exact hostnames, no default = every URL refused), https required unless `TABBY_ALLOW_HTTP_URLS=true`; D3 502 `tabby_error` / 503 `tabby_unavailable`; D4 `TABBY_PUBLIC_URL` (no default) is the base of the return URLs `<url>/tabby/return/<attempt id>/<success|cancel|failure>` (attempt id in the PATH so Tabby's appended `?payment_id=` cannot clash); D5 optional fields in v1: item `reference_id`, `description`, `image_url`, `product_url`, `brand`, `discount_amount`, and `buyer.dob` (not `order_history`, not the loyalty fields); D6 the store is a parameter (behaviour `Store`, real `PaymentStore`, in-memory fake in tests), and the isolation test now lets exactly one file, `payment_store.ex`, use the database layer.
The user's two fixes: (1) order `tax_amount`, `shipping_amount`, `discount_amount` are OPTIONAL (Session payload model): missing/null/blank = 0 in the amount rule, no 400, and they are omitted from the payload to Tabby when the caller did not give them; (2) Checkout Flow "Always validate that web_url is present ... If it is missing or status is rejected, show the rejection message": created-but-no-web_url (or an empty one) = 200 `rejected` with `not_available`, the attempt `failed` (`attention_note` = `created_without_web_url`, both Tabby ids kept), and an ERROR in the log. For slice 5 later: redirect immediately and show "Confirming payment..." as the docs say (Checkout Flow, Redirection to the Store: "Redirect the customer immediately, without blocking on status checks").

Doc lines used (re-read 2026-11-02): Checkout Flow "Checkout Session Initiation" (call the Checkout API "with the full payload"; "Save `payment.id` from the response"; validate `web_url`); "Redirection to the Store" (Tabby appends `payment_id` to each of the three URLs; success = AUTHORIZED or CLOSED, cancel = EXPIRED, failure = REJECTED; "Never rely on redirect URLs or query parameters alone as the source of truth"); Technical Requirements (the amount rule "payment.amount must equal the sum of your order lines"; "up to 2 decimals for AED and SAR"; **the 255 limit is worded "We are processing a maximum of 255 symbols in the "string" field."**, not "String fields accept a maximum of 255 characters" as the user quoted it - same limit; lang "English and Arabic"; dates "ISO 8601 ... in UTC", `dob` YYYY-MM-DD); Full Testing Checklist ("one click produces exactly one session", "Sessions are disposable", omit optional fields instead of sending empty strings). NOT in the docs: HTTPS for the redirect URLs (so https-required is Claude's choice, configurable), uniqueness of `order.reference_id` (duplicates are allowed here), the HPP session lifetime on the Checkout Flow page (20 min comes from Payment Processing).

New files (additive): `lib/da_product_app/tabby/` `fields.ex` (shared text/email/phone checks; `Eligibility` now delegates to it, behaviour unchanged), `session_request.ex` (pure: `validate/2`, the amount rule with exact `Decimal` maths, `tabby_payload/3`, `order_total/4`), `sessions.ex` (`create/2`: validate -> config -> SAVE the attempt as `new` -> ONE Tabby call (the client never repeats a session) -> record created/rejected/failed; one immediate retry of a failed update; the fixed-text rejection bodies come from `Eligibility`), `store.ex` (behaviour), `payment_store.ex` (the only database code; never raises); `lib/da_product_app_web/controllers/tabby/sessions_controller.ex`, `lib/da_product_app_web/plugs/tabby_api_auth.ex`; tests `fields_test.exs`, `session_request_test.exs`, `sessions_test.exs`, `api_auth_test.exs`.
Additive edits: `router.ex` (pipeline `:tabby_api` + `post "/sessions"` at the very end), `config/runtime.exs` (`api_key`, `public_url`, `return_hosts`, `allow_http_urls`; all without defaults except allow_http false), `config.ex` (defaults incl. `session_ttl_min` 20), `eligibility.ex` (delegates email/phone to `Fields`), `config_test.exs`, `isolation_test.exs`, README. tabby_sim, the shared DB and other people's files were not touched; NO migration was needed (slice 2's columns are enough).

Behaviour: validation errors are keyed by dotted path (`items[0].quantity`, `buyer.email`), all reported together (the amount mismatch only when the numbers it needs are themselves valid), never repeating the sent value; every string <= 255 chars, order reference <= 200; money = decimal strings <= 2 decimals, quantity a whole number >= 1; unit_price may be 0; a discount larger than its line is refused (Claude's choice); a currency that does not match the merchant region is a 400 unless `TABBY_BASE_URL` is set; the caller URLs are never sent to Tabby. Statuses written: `new` (before the call) -> `created` (ids + `expires_at` now+20 min) | `rejected` (known reason) | `failed` (`attention_note` = error kind). Answers: 200 created / 200 rejected / 400 / 401 / 502 / 503 (`tabby_unavailable` or `not_configured`) / 500 (`internal`: attempt not saved, or Tabby's answer could not be recorded; that log line names the attempt and both Tabby ids). Double click = two attempts and two sessions by design; only one can become authorized (`winner_key`, slice 5/6). The log never holds the buyer's name/email/phone/address or a key (the client masks name, email and phone in Tabby's error text via `:redact`).
Claude's choices (tell the user): the 502/503 split and bodies; the error kind in `attention_note`; the retry-once of a failed update; refusing a discount bigger than its line; the 30 s default timeout for session creation (the 3 s one is only for eligibility); https required for the redirect URLs; item/dob optional-field list.
**Manual curl test (2026-11-02, run by the user on its own server; app restarted with `TABBY_API_KEY=api-test-key-123`, `TABBY_PUBLIC_URL=http://78.47.213.209:4030`, `TABBY_RETURN_HOSTS=shop.example`, `TABBY_ALLOW_HTTP_URLS=true`, `TABBY_MERCHANT_CODE=slice4`, `TABBY_SIM_MAX_AMOUNT=5000`, Tabby pointed at the simulator): test 1 PASSED** - 200 `created` with `attempt_id`, `payment_id` and `web_url`. The server log showed the order: `INSERT tabby_payments ... "new"` BEFORE the call to `/tabby-sim/api/v2/checkout`, ONE call, the payload's `merchant_urls` were OUR return URLs `<public url>/tabby/return/<attempt id>/success|cancel|failure` (no caller URL), the `UPDATE` set `created`, both Tabby ids and `expires_at` = now + 20 min, and the `web_url` is not in any SQL. Read-only SELECT on `tabby_payments`: exactly one row (`SLICE4-001`, `created`, `tabby_payment_id` = the answer's, 157.50 AED, `winner_key` NULL, `expires_at` 07:46:38, `attention_note` NULL), so the real `PaymentStore` works against the database. **Test 2 PASSED** (same `order_reference` `SLICE4-001` again, run twice by the user): each run answered 200 `created` with a NEW `attempt_id`, `payment_id` and `web_url`; the log showed a new `INSERT` (`new`) before the single simulator call, return URLs carrying the new attempt id, and `UPDATE ... created`; the SELECT showed three `SLICE4-001` rows, each with its own id and Tabby payment id, all `created`, `winner_key` NULL, `expires_at` +20 min, no note (the pasted answer and log belonged to the two different runs). **Test 3 PASSED** (`SLICE4-002`, no tax/shipping/discount, amount 135.00): 200 `created`; the log showed the payload sent to the simulator had `order` = only `items` and `reference_id` (no `tax_amount`, `shipping_amount` or `discount_amount` keys, no `discount_amount` on the item, no empty strings), the attempt `INSERT` before the single call, our return URLs, and `UPDATE ... created` with `expires_at` +20 min. **Tests 6 and 7 PASSED** (run before 4 and 5): amount 150.00 for lines adding up to 157.50 -> 400 `fields.amount` "does not match the order lines (they add up to 157.50)"; `lang` "fr" -> 400 `fields.lang` "must be en or ar"; in the log neither request had an `INSERT INTO tabby_payments` nor a call to `/tabby-sim/api/v2/checkout` (answered in under 4 ms), so no row and no Tabby call. **Tests 4 and 5 PASSED:** phone `500000002` (`SLICE4-003`) -> 200 `rejected`, `not_available`, the general en+ar text; amount 5001.00 (`SLICE4-004`) -> 200 `rejected`, `order_amount_too_high`, the spending-limit en+ar text; no `web_url` in either; the log showed `INSERT ... "new"` BEFORE the single simulator call, `UPDATE ... "rejected"` with `rejection_reason` and both Tabby ids, and only two `[info]` lines from the Tabby code ("session rejected by pre-scoring / by Tabby (business outcome)"), no warning or error. **SELECT on `tabby_payments` (merchant `slice4`) after tests 1-7: six rows exactly as expected** (three `SLICE4-001` `created` each with its own ids and `expires_at` +20 min; `SLICE4-002` `created` 135.00; `SLICE4-003` and `SLICE4-004` `rejected` with a Tabby payment id and `expires_at` NULL; `winner_key` and `attention_note` NULL on all). The rejection-reason SELECT also matched (`SLICE4-003` `not_available`, `SLICE4-004` `order_amount_too_high`). **Test 8 PASSED** (`success_url` on `evil.example`): 400 `fields.success_url` "host is not an allowed return host", no `INSERT`, no simulator call, `Sent 400 in 1ms`. **Test 9 PASSED** (wrong key, run twice): 401 `{"error":"unauthorized"}`, `[warning] [Tabby] api 401 POST /tabby/sessions: missing or wrong key`, `Phoenix.Router halted in :tabby_api/2`, no key in the log, nothing saved. **Correction to what Claude predicted:** the log DID show `Processing with ...SessionsController.create/2` and the full `Parameters:` line (buyer details) BEFORE the 401, because Phoenix logs the matched route and its parameters at dispatch, before the pipeline (the key plug) runs; so at debug level even unauthenticated requests get their body printed (not at `:info`, the production level). Any Tabby-only parameter filter would have to run before the key check. Unrelated: `DaProductApp.FileLogger` (existing file) prints "the log level :warn is deprecated" whenever a warning is logged. The "should be zero" count for `SLICE4-005..008` returned 0 (the four refused requests saved nothing). **Test 10 PASSED** (restart with `TABBY_BASE_URL=http://localhost:9/tabby-sim`, `SLICE4-009`): 503 `{"attempt_id","error":"tabby_unavailable"}` in 474 ms; the log showed `INSERT ... "new"`, ONE warning `[Tabby] checkout ... failed: kind=network ... attempt=1 ... detail=connection refused` (no attempt 2, no retry, no `[error]` line), the sessions warning "Tabby call failed (network) ...; marked failed and was not repeated", and `UPDATE ... status "failed", attention_note "network"`; the row: `failed`, `tabby_payment_id` NULL, `expires_at` NULL, `attention_note` `network`, `id` = the answer's `attempt_id`. **The whole slice 4 curl plan (tests 1-10 + the checks) PASSED.** Open: the decision on Phoenix's debug-level `Parameters:` log line (buyer details; options 1/2/3 above).
**Logging finding (not in my code):** Phoenix itself prints the whole request, `Parameters: %{... "buyer" => %{"email", "name", "phone"}, "shipping_address" ...}`, at DEBUG level (Phoenix hides only parameters named `password` by default). Every log line written by Tabby code is free of the buyer's details, but this Phoenix line is not, so the claim "the log never holds the buyer's details" holds for production only (`config/prod.exs` sets the logger level to `:info`, so the line is not written there) and not for debug-level servers. `config :phoenix, :filter_parameters` is global (matches by name for every route), so changing it touches other people's logs: options offered to the user: (1) leave it and rely on the prod level, (2) add the global filter, (3) a small plug that blanks only the `/tabby/*` params before Phoenix logs them (Claude's pick if debug-level protection is wanted). DECISION PENDING.
The browser flow cannot be completed until slice 5 adds the return routes.

### Slice 4 status: committed as `b312669` ("Add Tabby payment session creation", 18 files). The whole slice 4 curl plan passed (see above).

### Slice 5 - the return route, verification and the status endpoint (written 2026-11-02; Claude RAN the unit tests at the user's explicit request: **415 tests, 0 failures** at first, later **420 tests, 0 failures** (user's run, 2026-11-02); curl test PASSED; no migrations; ready to commit)

Design shown first and approved ("go with your picks D1-D8"): D1 the verification is an Oban job on its own queue `tabby`; D2 302 to the merchant's stored URL with `attempt_id` appended (+ `referrer-policy: no-referrer`); D3 a read-only status endpoint for the "Confirming payment..." page; D4 a duplicate authorized attempt is only FLAGGED here (the close comes in slice 6; not a risk on a test server, Tabby could auto-capture it after 21 days on a live one); D5 amount/currency mismatch blocks authorization and flags it; D6 a still-`CREATED` payment is looked at again after 5/10/20/40/80 s, then left to the polling job (slice 6); D7 the stored URL is re-checked against the current allow-list at redirect time; D8 no capture in this slice.
The user's four additions: (1) the verify job is UNIQUE per `attempt_id` for ~60 s (Oban unique, so refreshes do not queue repeated Retrieve calls): `unique: [period: 60, fields: [:worker, :queue, :args], keys: [:attempt_id]]`, supported by `Oban.Engines.Dolphin` (Oban 2.19.1, checked in the deps); (2) the amount/currency check is Claude's/the user's own choice (Payment Processing only says Tabby checks the amount at capture): marked as ours in the README and in `Verification`'s moduledoc; (3) Oban: ONLY `tabby: 3` added to the queues, in its own `if System.get_env("TABBY_SECRET_KEY") not in [nil, ""]` block in `config/runtime.exs`; no plugin, `webhooks` and every other queue untouched (keyword lists are deep-merged), and the simulator's `tabby_sim_webhooks` block is unchanged; the runtime.exs diff of this slice is that one block (+8 lines); (4) `log: false` on every `/tabby/...` route in `router.ex` (eligibility, sessions create and show, the three return routes), so Phoenix does not print the request parameters at debug level - this CLOSES the open logging question for the Tabby routes; nothing global changed, and a test (`routes_test.exs`) asserts `log: false` on each Tabby route and that the simulator's routes still log as before.

Doc lines used (Checkout Flow, Redirection to the Store; Payment Processing, Payment Verification; re-read 2026-11-02): "Redirect the customer immediately to avoid delays - do not block with payment status checks"; "The redirect is a frontend navigation signal only"; "Never rely on redirect URLs or query parameters alone as the source of truth"; "Show "Confirming payment..." on Thank you page if status is pending, update once backend confirms final status"; success = AUTHORIZED or CLOSED, cancel = EXPIRED, failure = REJECTED; Payment Processing: AUTHORIZED -> "Process the order ..., capture the payment"; REJECTED / EXPIRED -> "Cancel or delete the order"; CREATED -> "keep checking until the status changes or the session expires"; polling "every couple of minutes"; session 20 min, EXPIRED about 10 min later; "After you verify that the payment is `AUTHORIZED`, send a Capture Request"; 21 days. NOTE on sources: these verification rules are on the Payment Processing page, which calls the call the "Retrieve Request"; it does NOT say to use a webhook's payment_id for it (that advice is in the FAQ, relevant for slice 7).

New files (additive): `lib/da_product_app/tabby/` `verification.ex` (pure: `plan/3` -> `{:steps, [{from, to, attrs}]}` | `:unchanged` | `:pending` | `{:attention, note}` | `{:error, reason}`; CLOSED seen from `created` walks through `authorized`; a cancel gives the winner key back, a capture keeps it; every chain is a legal forward chain, property-tested), `verifier.ex` (load -> Retrieve with the payment id WE saved -> plan -> compare-and-set; returns `{:ok, _}` / `{:pending, :created}` / `{:retry, _}` / `{:discard, _}`), `verify_worker.ex` (the Oban job; `result/2` maps to ok / snooze / error / cancel), `returns.ex` (`handle/4`, `redirect_url/2`, `id_shape?/1`), `attempt_status.ex`; `lib/da_product_app_web/controllers/tabby/return_controller.ex`; tests `verification_test.exs`, `verifier_test.exs`, `verify_worker_test.exs`, `returns_test.exs`, `attempt_status_test.exs`, `routes_test.exs`.
Additive edits: `store.ex` (new callbacks `get_attempt/1` and `transition/4`), `payment_store.ex` (`get_attempt/1`; `transition/4` = one `UPDATE ... WHERE id = ? AND status = <from>` via `Repo.update_all`, returning `{:error, :stale}` when 0 rows, `{:error, :winner_taken}` on MySQL's duplicate-entry error for the `winner_key` unique index, and refusing columns outside a whitelist), `session_request.ex` (public `check_url/2`, the same URL rules, used again by the return route), `sessions_controller.ex` (`show/2`), `router.ex`, `config/runtime.exs`, README, and `sessions_test.exs` / `verifier_test.exs` (two small test fixes). No migration (slice 2's columns are enough). tabby_sim, the shared DB and other people's files were not touched.

Behaviour: the return route 404s (never redirects) for a bad id/outcome, an unknown attempt, or a stored URL that no longer passes; 503 if storage fails; a queueing failure is logged and the customer is still redirected. The job: AUTHORIZED -> `authorized` + `authorized_at` + `winner_key` (a taken key -> `authorized` without the key + `needs_attention` + note "another attempt for this order is already authorized"); CLOSED + captures -> `captured` (+ `captured_amount`, `captured_at`, key kept); CLOSED without captures -> `closed` (+ `closed_at`, key given back); REJECTED / EXPIRED -> `rejected` / `expired`; CREATED -> pending/snooze. Transient problems retry (5 attempts), unfixable ones cancel with an error log.
Claude's choices (tell the user): the snooze times 5/10/20/40/80 s; the 404/503 plain-text pages; `referrer-policy: no-referrer`; refusing to set columns outside a whitelist in `transition/4`; the status endpoint's body (our status + `final`); treating Tabby's 404 on Retrieve and its 400/401/403 as "discard".
**Follow-up question from the user (2026-11-02), answered by reading the code: are the 5/10/20/40/80 s re-checks `{:snooze, n}` on the same job, or new jobs (which the 60 s unique rule with `completed` in its states would drop)?** They are `{:snooze, n}` on the SAME job: `perform/1` -> `run_job/2` -> `result/2` returns `{:snooze, 5|10|20|40|80}` for runs 1-5, `:ok` from run 6; Oban's `snooze_job` (Dolphin delegates to Basic) does `UPDATE oban_jobs SET state='scheduled', scheduled_at=now+n, max_attempts=max_attempts+1 WHERE id=<job>` - no insert, so the unique rule (checked only at insert) cannot drop it. No fix was needed, but the test only covered `result/2`, so it was strengthened (NOT run - the user said not to run anything): `verify_worker.ex` `perform/1` now calls the new public `run_job/2` (the verifier passed in, so it is testable without a database); new tests in `verify_worker_test.exs`: runs 1-5 snooze 5/10/20/40/80 through `run_job/2`, run 6 and 7 return `:ok`, each run asks the verifier about the attempt id in the job's own args exactly once, retry/discard/ok pass through, and a source test that `Oban.insert` appears only in `verify_worker.ex` (`enqueue/1`) and `VerifyWorker.enqueue` is called only from `returns.ex`. Also new: `Verifier` now logs `[info] [Tabby] verify: attempt=<id> payment=<id> is still CREATED at Tabby; it will be looked at again` on every pending look (so the re-checks are visible in the log), with the existing "created: pending" verifier test extended to assert it. Expected unit tests after this change: 420 (415 + 4 new `run_job` tests + 1 source test); NOT run by Claude, to be confirmed by the user's run.
**Manual curl test of slice 5 (2026-11-02, run by the user; restarted with `TABBY_SECRET_KEY` etc., merchant code `slice5`): ALL PASSED.** Queue: `Oban.check_queue(queue: :tabby)` showed limit 3, not paused. SLICE5-001 (approve, return): immediate 302 to the merchant URL + `attempt_id` with `cache-control: no-store` and `referrer-policy: no-referrer`; the job ran `created -> authorized` with a `winner_key`; status endpoint 200. SLICE5-002 (cancel) -> `expired` final; SLICE5-003 (failure) -> `rejected` final. SLICE5-004 (never approved): immediate 302, then looks about 0/7/19/41/~83/~163 s (each scheduled delay + 1-2 s) with the 'still CREATED' log line; ONE job row (392255) whose `attempt` grew and `max_attempts` grew by one per snooze (attempt 3 / max 8 mid-way, attempt 5 / max 10 before the last run), ending `completed`, attempt 6, max_attempts 10 (run 6 returns `:ok`: the polling job of slice 6 takes over). Refresh test: three back-to-back return calls for a second SLICE5-004 attempt gave three 302 and exactly ONE job row (the unique rule works with the real Dolphin engine). SLICE5-005 (two attempts for one order, both approved at the simulator, return A then B): A `authorized` with winner_key; B's update with the same key was refused by MySQL (`QUERY ERROR`), logged `[error] ... is authorized but another attempt for this order is already authorized`, then recorded `authorized` WITHOUT winner_key, `needs_attention` 1, note 'another attempt for this order is already authorized'; both customers still got a plain 302. 404/401: unknown attempt id, outcome `banana`, id `not-an-id` -> 404 'This payment link is not valid.' (no DB read for the last two); status endpoint unknown id -> 404 `not_found`, no key -> 401 `unauthorized`; none queued a job or called Tabby. `log: false` verified: no `Processing with`/`Parameters:` lines for /tabby routes. Final SELECTs matched (001 authorized+key; 002 expired; 003 rejected; both 004 created, no key; 005 A authorized+key, 005 B authorized, no key, flagged); `oban_jobs` (queue tabby): all completed, none stuck, none for the 404 requests. The 004 attempts stay `created` here; slice 6's polling job will deal with them (the simulator expired the second one itself after 20 min).

### Slice 6 - capture, close a duplicate, the polling job (written 2026-11-02 on branch `feature/tabby-capture`; Claude RAN `test/tabby/` only: **520 tests, 0 failures**, no warning in any Tabby file; NOT committed; no migrations; nothing run against the server or the DB; the manual curl test is NOT YET DONE)
Design approved with the user's changes: D1 capture AUTOMATICALLY right after verification ("the best-practice flow is a full capture right after verification"), only the attempt holding the winner_key, NO merchant capture endpoint (the proposed D3 was dropped); D2 full capture only; D4 close duplicates automatically and keep the flag (Close page: closing without capture refunds the customer); D5 self-rescheduling poller every 2 minutes (no Oban cron plugin, no shared-config change); the numbers (50 per run, 2 min young, 5 min stale claim, 18-day and 60-minute flags) are ours - the documented rate limit is for Create Session only. Two fixes from the user: (1) a capture refused with 4xx ("investigate before retrying"): release the claim, set `needs_attention`, the poller skips flagged rows, no auto-retry loop; (2) on a capture 400, Retrieve first: if CLOSED with captures record `captured`, not an error. The exact doc lines are in the README, section "Capture, close and the polling job (slice 6)" (quoted from the pages as fetched on 2026-11-02 through the fetch tool; spot-check them against the live pages if they matter).
New files (all under `lib/da_product_app/tabby/` and `test/tabby/`): `capture.ex`, `close.ex`, `settle.ex`, `settle_worker.ex`, `poller.ex`, `poll_worker.ex`, `capture_test.exs`, `close_test.exs`, `settle_test.exs`, `poller_test.exs`, `poll_worker_test.exs`. Changed, additively: `verifier.ex` (public `credentials/2`, `apply_plan/3`, `duplicate_note/0`), `verify_worker.ex` (`run_job/3`: queues `SettleWorker` after a verification that recorded `authorized` or an authorized duplicate; queueing failure only logged), `store.ex` + `payment_store.ex` (optional callbacks `poll_candidates/2`, `count_open/1` and their Ecto queries; `claimed_at`, `claimed_by`, `capture_reference_id` added to the settable columns), `sessions_controller.ex` (`PollWorker.kick/0` after a `created` session, can never fail the request), `verify_worker_test.exs` (source tests now allow exactly the three workers to insert jobs). `config/runtime.exs` NOT touched (jobs run in the existing `tabby` queue). `tabby_sim`, the shared DB and the shared Oban config were not touched.
A real bug found by the unit tests and fixed before the run was green: in `Capture.run/2` a lost claim (`{:ok, :stale}`) was treated like a successful claim; `claim/4` now returns `{:claimed, row}`.
Behaviour to check in the curl test: (a) the happy path: verify -> `SettleWorker` -> `authorized -> capturing -> captured`, the simulator's full capture also sends its `authorized` then `closed` webhooks (ignored until slice 7); (b) the duplicate of SLICE5-005 style gets closed and keeps the flag with the new note; (c) the polling chain: ONE `PollWorker` row `scheduled` while attempts are open, none once nothing is left; (d) a custom `states` list in `unique:` against the real Dolphin engine (only exercised by the manual test); (e) a refused capture (a simulator fault, if available) is flagged and never retried.

Merchant filter note (RECONSTRUCTED 2026-11-03: the original paragraph, written after the slice 6 curl test, was lost when this file was rolled back to an older version; this is rebuilt from the code and README, not the original wording): every `Poller` query only picks rows whose `merchant_code` equals the configured `TABBY_MERCHANT_CODE` (`poll_candidates/2` cutoffs carry `:merchant_code`; `count_open/2` takes `(merchant_code, duplicate_note)`), `Poller` drops any other row it is handed, and rows of another merchant never keep the chain alive. Tests in `poller_test.exs` and `payment_store_queries_test.exs`.

### Slice 7 - the webhook receiver (written 2026-11-03 on branch `feature/tabby-webhooks`; Claude RAN `test/tabby/` only: **598 tests, 0 failures** with the README command (`mix run --no-start ...`), no warning in any Tabby file; NOT committed; no migrations; curl test run by the user 2026-11-03: PASSED, every step reported; ready to commit)
Design approved with one fix: an unknown payment id is a **404** (Payment webhooks page: "respond with a non-200 status (e.g. 404)", "Acknowledge with 200 only when you either processed the event or deliberately chose to ignore it"), stored nothing; another merchant's attempt is 200, ignored, stored nothing; ours is saved, queued, 200. Answers: D4 store the parsed body re-encoded as JSON, `endpoint.ex` untouched; D2 `X-Auth-Key` default, settings `TABBY_WEBHOOK_HEADER` / `TABBY_WEBHOOK_SECRET`; D6 2-minute pickup, 1-day give-up; D8 registration by hand from iex; slice 6's commit message left as it is.
New: `webhooks.ex` (check header in constant time -> payment id -> look up our attempt -> save -> queue), `webhook_worker.ex` (queue `tabby`, unique per event 60 s; Verifier, queue settlement, mark processed), `webhook_registration.ex` (`register/1`: list first, register only if our URL is missing, never logs the secret), `lib/da_product_app_web/controllers/tabby/webhooks_controller.ex`, route `post "/tabby/webhooks"` (public, `log: false`), tests `webhooks_test`, `webhook_worker_test`, `webhook_registration_test`, `poller_webhooks_test`. Changed, additively: `store.ex` + `payment_store.ex` (optional callbacks `find_attempt_by_payment_id/1`, `insert_event/1`, `get_event/1`, `mark_event_processed/1`, `unprocessed_events/2`, `count_unprocessed_events/0`; only `payment_store.ex` uses Repo), `poller.ex` (unprocessed events older than 2 min are processed; older than 1 day marked processed + logged as error; they count as open; a store without the callbacks has none), `config.ex`, `config/runtime.exs`, `webhook_event.ex` (comments only), `router.ex`, and the tests `routes_test`, `config_test`, `payment_store_queries_test`, `verify_worker_test` (its "where jobs are inserted" guards now allow `webhook_worker.ex`). README: new "Webhooks (slice 7)" section with the exact doc lines, Tabby's 8 webhook IPs (for the firewall; the app does not check them) and the corrected `tabby_webhook_events` wording.
Decisions ours (verify with the user): no body for any answer; 400 for a body with no usable `id`; 500 when no merchant code is configured (so Tabby retries); a payment Tabby still reports as CREATED counts as processed (the poller watches `created` attempts); a refused Retrieve (`discard`) leaves the event unprocessed for the poller and its 1-day give-up; the registration module accepts the list answer as one object, `null` or a list and refuses to register on any other shape. The `GET /api/v1/webhooks` schema (API reference, "Retrieve all webhooks") says one `Webhook` object or `null` and has no response example, so the real shape was NOT seen live; the simulator answers an array.
Known limits: if the polling chain has stopped and a late event could not be queued, nothing restarts it until the next session; `PUT`/`DELETE /api/v1/webhooks/{id}` not wrapped; refunds (`tabby_refunds` from a Refund event) are slice 8.
Test-running note: `mix test` cannot run in the sandbox (its alias needs the MySQL root login); use the README command. Under `MIX_ENV=test mix run -e 'Mix.Tasks.Test.run(...)'` the app's `LogFormatter` makes ~29 older log-text tests fail ("[error]" format) - not a code problem.
**Manual curl test of slice 7 (2026-11-03, run by the user; simulator as Tabby; merchant `slice7`, `TABBY_WEBHOOK_SECRET=hook-secret-123`, header `X-Auth-Key`): PASSED.** Step 2: `GET /tabby-sim/api/v1/webhooks` lists exactly one webhook for `slice7` (an array, with `header` `{"title":"X-Auth-Key","value":...}` and our URL `<TABBY_PUBLIC_URL>/tabby/webhooks`). Step 3: SLICE7-001 (attempt `42918538...`) `captured` 157.50, simulator CLOSED with exactly ONE capture `capture-SLICE7-001`, all deliveries 200; but query C showed a `VerifyWorker` job, so the browser had followed the redirect to the return route and that run did not prove "webhook alone". Step 3b (SLICE7-002, "Approve, then close the tab (no redirect)"): `captured` 157.50 with `WebhookWorker` jobs for events 5-7, exactly ONE `SettleWorker` queued 0.5 s after the first, and NO `VerifyWorker` row: the webhook alone authorized and captured it. Step 4 (admin "Resend" of the first `authorize` delivery #242 -> #245, 200): event 4 stored and processed, attempt `updated_at` unchanged (07:54:23), still one capture, no new `SettleWorker`. Step 5 (admin out-of-order on the same payment): the simulator re-sent 4 deliveries newest first (`authorize`, `close`, `capture`, `authorize`; 4, not 3, because the resend counts), events 8-11 stored in the order authorized, closed, authorized, authorized, all processed (`processed (already_final)` in the log), status still `captured`, `updated_at` unchanged. The log has no `Parameters:` line for `POST /tabby/webhooks` (`log: false` works); the Ecto debug query log does print the `INSERT` with `raw_body` (production level `:info` hides it; the simulator's webhook body has no buyer data, a real Tabby payment object may). Step 6: wrong `X-Auth-Key` and missing header both 401; `tabby_webhook_events` count 11 before. Step 7 (payment created directly on the simulator for merchant `slice7`, approved with no redirect, so unknown to our app, payment id `7fb64d9c-c9f4-462a-b0f2-af2bfb360720`): our receiver answered **404**, the simulator retried 5 times in all (09:24:14, 09:25:14, 09:27:16, 09:31:18, 09:35:19; every one `failed` / 404, no attempt 6) and `tabby_webhook_events` holds 0 rows for that payment. State of our tables: 11 events, all `processed = 1`; `SLICE7-001` `captured`; an older `SLICE7-001` attempt from before the run is `expired`.
Both remaining items were reported afterwards and PASSED: a second `WebhookRegistration.register()` listed first (`GET /api/v1/webhooks` -> 200, one call) and answered `{:ok, :already_registered}` without registering again; the event count after the two 401 curls was still 11. Corrections to what Claude predicted before the test: the out-of-order send is 4 events (not 3); the "never returned" approval of step 3 needs the no-redirect button, otherwise the return route also verifies.

### Slice 7 status: committed as `da107c3` ("Add Tabby webhook receiver", 20 files, on `feature/tabby-webhooks`), 598 unit tests, curl test passed. The commit message shows the title only: the `Co-Authored-By` trailer is missing (left as it is unless the user asks to amend). Slice 8 (refunds) is written, see below.

### Slice 8 - refunds (written 2026-11-03 on branch `feature/tabby-refunds`; Claude RAN `test/tabby/` only: **732 tests, 0 failures** with the README command (`mix run --no-start ...`), no warning in any Slice 8 file; NOT committed; the migration was applied by the user (`SHOW CREATE TABLE` checked); curl test PASSED 2026-11-03, run by the user, see below)
Design approved ("go with your design") with these answers and changes: D1 path `POST /tabby/sessions/:attempt_id/refunds` is fine, and yes to `GET` of the same path (the list); D2 yes to the caller `request_id` with a migration (file written, not run); D3 yes, including the 180-day check ("Refunds can be initiated within 180 days from the payment creation"); D5/D6/D7 fine, D6 count refunds made outside our code into `refunded_amount` (Tabby is the source of truth); slice 7 commit left as it is. Changes: (1) D4 inline but ONE Tabby call per request, no client retry loop inside it (5 attempts x 30 s could hold the caller for minutes): on timeout/5xx Retrieve once, our `reference_id` in `refunds[]` -> `succeeded`, else 202 `pending` and the poller retries with the same `reference_id`; (2) a refund 409 is handled by HTTP status only, never by `errorType` (the API reference shows `errorType` "conflict", the Error codes page shows none), a disputed payment will not clear by retrying so it ends as `needs_attention`; tests for both bodies; (3) `refunds[]` entries are matched by `reference_id` and read by `created_at`, never by array position.
New: `refunds.ex` (create/list/reconcile/retry_pending), `refund_rules.ex` (pure: 180 days, captured total, reading `refunds[]`), `lib/da_product_app_web/controllers/tabby/refunds_controller.ex`, migration `20261103120000_tabby_refunds_add_request_id.exs` (adds ONE nullable `request_id` varchar(64) and ONE unique index `(payment_id, request_id)` on `tabby_refunds`; only ADDS, drops/changes/deletes nothing, touches no row), tests `refund_rules_test`, `refunds_test`, `poller_refunds_test`. Changed, additively: `refund.ex` (`request_id`), `client.ex` (option `once: true`: one call, no retries, no waiting), `retry_policy.ex` (`decide_once/1`), `store.ex` + `payment_store.ex` (optional callbacks `insert_refund/1`, `get_refund/1`, `get_refund_by_request/2`, `list_refunds/1`, `transition_refund/4`, `pending_refunds/3`, `count_pending_refunds/1`; only `payment_store.ex` uses Repo; the pending queries join the attempt so only the configured merchant's refunds are picked up), `poller.ex` (pending refunds older than 2 min; they count as open), `webhook_worker.ex` (a webhook whose body has `refunds` runs `Refunds.reconcile/2`), `config.ex` + `config/runtime.exs` (`refund_timeout_ms`, env `TABBY_REFUND_TIMEOUT_MS`, default 15000), `router.ex` (two routes under `:tabby_api`), README (new "Refunds (slice 8)" section with the exact doc lines), and test changes: cases added to `retry_policy_test`, `client_test`, `refund_test`, `payment_store_queries_test`, `config_test`, `routes_test`, `migrations_test`, `webhook_worker_test`; two EXISTING expectations changed because of the new column: in `refund_test` the unique indexes are now three, and in `migrations_test` the "sorts after every migration" rule skips the slice 8 migration. Not touched: `tabby_sim`, `endpoint.ex`, the shared DB, the shared Oban config.
Decisions ours (verify with the user): answers are 200 `succeeded`, 202 `pending`, 502 `failed` / `needs_attention` (with the refund in the body, never Tabby's text), 422 `refund_refused` with reason `status_not_captured` / `too_old` / `exceeds_captured` / `request_id_reused`, 400 with fields, 404 (unknown or another merchant's attempt), 503; a replay of the same `request_id` returns the stored refund without calling Tabby again (also for `pending` and `needs_attention`); `used` amount = max(`refunded_amount`, succeeded rows) + pending + needs_attention rows (a `needs_attention` refund keeps its amount reserved and nothing resets it, no endpoint yet); a 429 stays `pending` after one call, no Retrieve; a refund still unknown after 1 day is `needs_attention` (`unresolved`); a 200 without our `reference_id` or with another amount is `needs_attention`, never `succeeded`; the refund call and its Retrieve use `refund_timeout_ms` (15 s) instead of the 30 s default; `reason` is never logged.
Known limits: no way yet to clear a `needs_attention` refund (a person looks at the row; nothing automatic and no endpoint); the migration must be run before the refund code can save a refund; the refund webhook body is not documented, so the webhook trigger only checks that `refunds` is a non-empty list; items are not sent in a refund (v1).
Test-running note: the same as slice 7 (README command; `mix test` cannot run in the sandbox).
Curl plan to hand over (simulator as Tabby, merchant `slice8`, the migration run first): create a session and capture it (100.00); refund 40.00 (200, one refund at the simulator); the same call again (same refund, still one); refund 60.00, then 1.00 (422 locally, no call); a refund on a cancelled attempt (422); wrong API key (401); faults armed on the admin page (a 409 on refund, and a 500 after the change is applied); a refund made directly at the simulator (counted into `refunded_amount`); read-only SELECTs on `tabby_refunds`, `tabby_payments`, `oban_jobs`.
**Manual curl test of slice 8 (2026-11-03, run by the user; simulator as Tabby; merchant `slice8`, webhook registered as id 52): ALL PASSED.** Migration: `SHOW CREATE TABLE tabby_refunds` shows `request_id varchar(64) DEFAULT NULL` and `UNIQUE KEY tabby_refunds_payment_request_index (payment_id, request_id)`, every other column and key unchanged. Four sessions of 100.00 (A SLICE8-001, B SLICE8-002, C SLICE8-003 never approved, D SLICE8-004), A, B and D approved with `action=approve` without following the redirect, so the webhooks alone captured them (one `SettleWorker` each, no `VerifyWorker`). **A:** refund 40.00 (`a1`) 200 `succeeded` `refund-SLICE8-001-1`; the same call again 200 with the SAME `refund_id` and no second row; `a1` with 41.00 422 `request_id_reused`; 60.01 (`a2`) 422 `exceeds_captured`, no row; 60.00 (`a3`) 200 `refund-SLICE8-001-2`; 0.01 (`a4`) 422; wrong key and no key 401; bad body 400 with `fields`; unknown attempt 404; the list 200 `refunded_amount` 100.00 with 2 refunds; the simulator holds exactly 2 refunds (40 and 60); `tabby_payments.refunded_amount` 100.00. **C** (still `created`): refund refused 422 `status_not_captured`, nothing saved. **B:** the first refund `b1` (30.00) returned 200 `succeeded`, NOT the expected 502, because it ran BEFORE the 409 fault was armed (`b1` saved 11:01:19, fault 27 inserted 11:02:35; Claude's plan had not stressed arming first and checking "Armed faults"); then with the fault armed: `b2` 25.00 502 `needs_attention` `conflict` `refund-SLICE8-002-2` (the simulator's refund lock body has NO `errorType`, so the status-only rule was exercised), ONE call, the simulator still had only the 30.00 refund, the replay of `b2` returned the same `refund_id` with 502 and no new call; the simulator DELETES a fault row when it is used up, so the plan's "remaining 0" prediction was wrong (an empty `tabby_sim_faults` result means used); then fault 28 `internal_after`: `b3` 20.00 200 `succeeded` `refund-SLICE8-002-3` found by one Retrieve, the simulator holds exactly two refunds (30 and 20, no `-2`); `b4` 25.01 422 `exceeds_captured` (the 25.00 `needs_attention` stays reserved); `tabby_refunds` rows `succeeded`, `needs_attention`/`conflict`/no Tabby id, `succeeded`; `refunded_amount` 50.00 (Tabby's total, the unresolved refund is not in it). **D:** a refund made directly at the simulator (`dashboard-test-1`, 25.00): `refund` webhook event 25 `closed`, `processed`, `WebhookWorker` job completed at once; our list `refunded_amount` "25.00" with `refunds` []; `tabby_payments.refunded_amount` 25.00, status still `captured`. **Stage 6 (the 500-before-applied extra):** `DaProductApp.Tabby.PollWorker.kick()` restarted the chain (it had stopped by itself after 11:08: nothing open, and a `needs_attention` refund does not keep it alive); fault 29 `internal_before`; `d1` 10.00 202 `pending` `refund-SLICE8-004-1`, one failed refund call and ONE Retrieve in 935 ms (no waiting), the simulator still only had the outside refund; the 11:18:51 poll run skipped it (younger than 2 min: `nothing to do; 1 attempt(s) still watched`); the 11:20:52 run found it, Retrieved (not there), POSTed the SAME `reference_id` once (`refund post ... -> 200`), `refunded_amount` became 35, the row `succeeded` with `tabby_refund_id` `3d76115d-...`, then `poll: handled 1 (refund:ok=1); 0 attempt(s) still watched` and the chain stopped; the refund webhook (event 26) was processed without changes; the simulator holds exactly two refunds on D (25 outside, 10 ours, once); final `refunded_amount` 35.00; every `oban_jobs` row in the `tabby` queue `completed`, none `retryable` or `discarded`. Observations: the iex lines for the 409 call and the 500-after-applied call were not pasted, so "ONE call" for them rests on the simulator's state plus the unit tests (the 500-before call was seen live); a `Bandit ... Connection that looks like TLS received on a clear channel` warning at 11:19:20 is somebody probing port 4030 with HTTPS, not our code. Not tested by hand: a disputed 409 body (the lock body covered the status-only rule), an expired 180-day refund, items in a refund.

### Slice 8 status: committed as `b876536` ("Add Tabby refunds", 26 files, on `feature/tabby-refunds`), 732 unit tests, curl test passed. The commit shows the title only: the `Co-Authored-By` trailer is missing again (same as slice 7; left as it is unless the user asks to amend). Slice 9 (the demo checkout page) is written, see below.

### Slice 9 - the demo checkout page (written 2026-11-03 on branch `feature/tabby-demo`; Claude RAN `test/tabby/` only: **891 tests, 0 failures** with the README command (`mix run --no-start ...`), no warning in any Slice 9 file; NOT committed; no migration, no new table; curl test stages A and B PASSED, browser stages in progress; the page was REDESIGNED on 2026-11-03 after the user said the first look was dated, see "Redesign" below)
Design approved ("go with your design") with these answers and corrections: gate fine (flag + password, 404 otherwise); the static fallback is fine for the simulator (the user will get a `pk_test` key for slice 10); EN + AR with `dir="rtl"`; three amounts; no server-side double-click lock; slice 7 and 8 commit trailers left as they are; the `needs_attention` refund tool goes on the follow-up list after slice 10. Corrections (checked against Checkout Flow, On-Site Messaging, Full Testing Checklist): (1) monthly amount = "order total × 1.015 / 4" (not total / 4), rounded half up to 2 decimals (rounding ours), 150.00 -> 38.06; (2) fallback text is the doc's exact EN and AR lines; (3) cancel and failure pages use the doc's EN and AR texts, only "Thank you" / "Confirming payment..." Arabic is ours; (4) method name "Pay later with Tabby" / "ادفع لاحقًا مع تابي" plus a clearly marked logo PLACEHOLDER (no logo copied; the user will ask Tabby for the official one); (5) "Tabby Checkout is opened in the same browser window": same-window redirect, no new tab or pop-up; (6) a pre-scoring reject hides Tabby; (7) the page works at 320px.
New: `lib/da_product_app/tabby/demo.ex` (pure: texts EN/AR, `monthly/1`, cart, `place/2`, `eligibility/2`, `context/1`; no web code, no database), `lib/da_product_app_web/controllers/tabby/demo_controller.ex`, `lib/da_product_app_web/controllers/tabby/demo_page.ex` (the HTML), `lib/da_product_app_web/plugs/tabby_demo_gate.ex`, tests `demo_test`, `demo_page_test`, `demo_gate_test`. Changed, additively: `config.ex` + `config/runtime.exs` (`demo_enabled` env `TABBY_DEMO_ENABLED` default false, `demo_token` env `TABBY_DEMO_TOKEN`), `router.ex` (pipeline `:tabby_demo` = gate + secure headers; scope `/tabby/demo`: `GET /`, `GET /result/:outcome`, `POST /eligibility`, `POST /place`, `GET /status/:attempt_id`, all `log: false`), README (new "Demo checkout page (slice 9)" section with the exact doc lines), tests `routes_test` and `config_test`. Not touched: `tabby_sim`, `endpoint.ex`, the shared DB, the shared Oban config. Two things I fixed on the way, found by the tests: `html_safe` JSON escapes `/` (so the page tests read the config as JSON), and the isolation test forbids even the NAME of the web module in `lib/da_product_app/tabby/` (a doc comment had it).
Decisions ours (verify with the user): both POSTs need the header `X-Tabby-Demo: 1` (Basic auth would otherwise be sent by the browser to a form on another site); the page only holds the public key and the merchant code, and a test renders every page with a leaky context to prove no secret is ever printed; `place` creates a NEW `DEMO-xxxxxxxx` order reference on every call; only the number replaces `XX.XX` in the fallback (no currency); the "Learn more" pop-up is not built (it is Tabby's); the "cart kept / cleared" lines are messages only (the cart is fixed); the success page polls every 2 s for up to 30 tries then says it is still confirming; the buyer is prefilled with `otp.success@tabby.ai` and `500000001`; an error or timeout in pre-scoring shows Tabby (the doc's fail-safe, already in `Eligibility`); Tabby is hidden after a rejection at Place order too.
Known limits: TabbyCard loads from `checkout.tabby.ai`, so it cannot be tested offline, and whether it draws anything with the simulator's keys is not known (the fallback line is what the simulator run shows); the logo is a placeholder; no server-side lock on a double click (a second session just expires); the demo result pages need our own host in `TABBY_RETURN_HOSTS`; no refund, close or capture button.
**Browser step B1 (2026-11-03, first look, before the redesign):** the English page opened with the demo login: title, language links, three amounts, cart 130.00 + 20.00 = 150.00 AED, prefilled buyer, COD selected by default; selecting Tabby showed the placeholder logo and **Tabby's own TabbyCard drew with the fake key `pk_test_000000000`** (a generic card: "Split your purchase into monthly payments", "No processing fees", "Use any card", "Buyer protection", "View options"; no instalment amount), so the static fallback did not show (the page falls back only when the script fails or the box stays empty; to see the fallback block `checkout.tabby.ai` in DevTools). **Curl stages A and B PASSED:** demo off 404 (also with the right password and header); no credentials 401; right password 200; POST `place` and `eligibility` without `X-Tabby-Demo: 1` 403 `forbidden`; with the header: 150.00 `show: true`, 5001.00 in Arabic `show: false` with the SPENDING-LIMIT text (Claude had predicted the general rejection text: wrong, the text follows the reason `order_amount_too_high`), bad amount and email 400 with `fields`; secret scan 0 matches, `pk_test_000000000`, `slice9` and `38.06` present. Not yet reported: the wrong-password 401, the `www-authenticate` header, the baseline `COUNT(*)` of 0.
**Fixes after the first browser look (2026-11-04):** user saw v3 (dark and light) and paid once (DEMO-DAEB6343, captured 1050.00, one row, one verify and one settle job). Coupon Apply button put on one line (CSS). Real pictures: optional files in `priv/tabby_demo/` served by new `GET /tabby/demo/img/:name` (whitelist: product ids and `tabby-logo`), the page uses them when present, else the drawn SVG / logo placeholder. The official Tabby logo could NOT be fetched: Tabby publishes it only in a Figma deck (docs.tabby.ai/marketing/toolkit), no file link; the user must download it. Tests: 891.

**Redesign v3 (2026-11-03, from the user's Gemini mock-up; supersedes the look described below):** `demo_page.ex` rewritten, new `demo_art.ex` (own SVG product pictures), `demo.ex` rewritten (catalog of 6 products, cart, promo `DEMO10`, Standard/Express 15.00, server-side totals via `Demo.summary/2`, session built from the cart with `shipping_amount`/`discount_amount`), new route `POST /tabby/demo/summary` (needs `X-Tabby-Demo: 1`), `?amount=` removed. Layout: dark default + sun toggle, green accent, 3-step bar, customer details LEFT and Order cart RIGHT from 960px (Arabic mirrors), Home/Office cards, country code select, promo, delivery, totals, Tabby box (monthly = total x 1.015 / 4, 850.00 -> 215.69). Own "Demo Store" header/footer; no Tabby logo/copyright, no "Total Due Today", USD, "Instant Approval", "256-Bit", "No interest". Cart kept in `localStorage` (`tabbyDemoCart`): kept after cancel/failure, cleared only on `captured`; pre-scoring re-runs and TabbyCard redraws when the total changes. Checked without a browser: `node --check` on every rendered script, and the cart module run in node (kept after cancel, cleared after success, storage blocked does not throw). NOT yet seen in a browser. Tests: 886, 0 failures.

**Redesign (2026-11-03, on the user's request: "make it modern, with features"):** `demo_page.ex` rewritten, `demo.ex` got ~30 more texts in both languages (all ours, marked), `demo_controller.ex` passes `order` and `attempt_id` to the result pages. Look: header (brand, "Test mode" badge, EN/AR switch), step indicator, two-column layout with a sticky order summary, payment-method cards, light/dark themes (`prefers-color-scheme`), `prefers-reduced-motion`, inline SVG icons (no images, fonts or outside files). Features: cart-size chips with labels, a "Test tools" panel (approved or rejected buyer), inline validation, a shimmer while Tabby is checked, a spinner on the button, a progress timeline on the success page, a copyable order reference, a result icon, and a language switch on result pages that keeps `order` and `attempt_id`. Kept from the design: placeholder logo, same-window redirect, one request per click, only the public key on the page, 320px. **Second round (same day, after the user sent 7 frames of Tabby's "See It In Action" video: DemoStore product page with the Tabby promo, checkout with "Choose a payment method" and the Tabby card, billing and delivery cards, "Pay with Tabby" button, then Tabby's own hosted pages: OTP, plan choice 12/8/6/4, Secure checkout, repayment schedule, "Mission complete!"):** the frames were a STYLE reference only (the user: "it shd be done for everything, not just phone"; "order summary on left payment on right"). A first attempt that made the page a narrow phone-width column was WRONG and was reverted. Now: wide responsive layout (container 1040px, grid `360px | 1fr` from 900px: order summary LEFT, details + payment methods + delivery card + button RIGHT; one column below 900px with the summary on top; Arabic mirrors), soft grey page, white cards, accent blue selected method, pill button ("Place order", "Pay with Tabby" once Tabby is chosen), heading "Choose a payment method", a delivery card that shows `Demo.shipping_address/0` (the same value is sent to Tabby), the cart-size chips moved into "Test tools". NOT built (offered): a product page with the TabbyPromo snippet (frame 1; was out of scope, D9) and anything like Tabby's hosted pages (those are Tabby's, and in our setup the simulator's, which is not touched). Observed in the video: Tabby's real UI shows plans of 12/8/6/4 payments with a monthly service fee and "As low as D156.00/month" for 1,600, which is NOT the documented fallback formula (order total x 1.015 / 4): that formula is only the doc's static fallback line; the card and promo are computed by Tabby (candidate for the Questions for Tabby list). Tests: 861, 0 failures. Added from Tabby's Checkout Flow UX lines: "Show loading states during checks" and failure "offer alternative payment methods". The user also pointed at Tabby's Quick Start "See It In Action" video (an MP4): Claude downloaded it to the scratchpad (5.5 MB, since deleted) but could not extract frames (no ffmpeg; the snap Chromium cannot start in this sandbox), so the design is NOT copied from the video; the user offered screenshots. The redesigned page has NOT been seen in a browser by anyone yet. Tests: the old markup assertions still hold; the CSS-width test now ignores media-query breakpoints; 19 new tests (the look, the features, an outside-URL scan that allows only Tabby's script, XSS on `order` and `attempt_id`).
Test-running note: the same as slice 7 and 8 (README command; `mix test` cannot run in the sandbox).
Curl/browser plan to hand over (simulator as Tabby, merchant `slice9`, `TABBY_DEMO_ENABLED=true`, `TABBY_DEMO_TOKEN`, `TABBY_PUBLIC_KEY`, `TABBY_RETURN_HOSTS` containing our host): (1) the gate: 404 with the flag off, 401 without the password, 200 with it; (2) the page source in EN and AR: `dir`, the three amounts, `TabbyCardBox`, no secret key (grep); (3) `POST /tabby/demo/eligibility` with and without the header (403), a normal and an over-limit amount (hide Tabby), 400 on a bad email; (4) in the browser: select Tabby, Place order, approve at the simulator, return to "Thank you" then "Payment confirmed." (captured, exactly one capture); (5) cancel and failure pages in both languages; (6) a rejected buyer phone `500000002`: the message, no redirect, Tabby hidden; (7) a double click makes one session; (8) read-only SELECTs on `tabby_payments` and `oban_jobs`.

Remaining Questions for Tabby (the rest of the earlier answers turned out to be documented): which payment statuses can be closed (the Close page does not say), and the body of a 429 response. Plus the older list (Q1-Q8).

Next phase: the real Tabby integration, per the revised design above, tested against this simulator. Committed: slice 0 (eed15ae), slice 1 the client (1d13312), slice 2 the three tables (952b2ee, migrations applied). Slice 3 (eligibility endpoint) is committed too (0002672). Slice 4 (create session) is committed too (b312669), after its full curl test. Slice 5 (the return routes: redirect immediately, verify with Retrieve in a background job, a status endpoint, `log: false` on the Tabby routes) is committed (5d0055c, message body and trailer still missing unless the user amended it). Slice 6 (capture, close a duplicate, the polling job) is written on feature/tabby-capture, 520 unit tests pass, the curl test is not done, not committed; the old note: slice 6 (capture claim, closing a duplicate authorized attempt, close, the polling job) starts only after that.

Other follow-ups, only if the user asks: report the shared `oban_jobs` issue to the team (section 2); the Questions for Tabby (Q1-Q8); drop the leftover test databases (section 8). Do not run anything against the server or DB without asking.


### Hosted payment page restyle (written 2026-11-05 on branch `feature/tabby-demo-ui`, which starts at `6494e1c` = the tip of `feature/tabby-demo`, so that branch is untouched; NOT committed; no migration, no new table, no route; a simulator change the user asked for)
The user shared four frames of Tabby's demo video (Secure checkout with "Pay in 6", the repayment-schedule sheet, the Pay spinner, "Mission complete!") and asked for the fake hosted page to look like it, **as a website layout, not a phone mock-up**. Design was shown first and approved ("you can apply it").
- **Changed:** `hpp_page.ex` (new markup, CSS and one inline script; same public functions plus `redirecting/3`; `form` got an optional 4th argument `today` for tests), `hpp_controller.ex` (`submit` only: `ui=1` + a redirect result -> 200 "redirecting" screen, status read with `Payments.get_by_session_id/1`; everything else is the old 302), README (Hosted payment page section, unit-test count). **New:** `hpp_plan.ex` (pure: the Pay in 6 numbers as integer cents, `add_months/2`, `money/1`, `date_label/2`), `test/tabby_sim/hpp_plan_test.exs` (12 tests after the second round), 16 tests appended to `hpp_page_test.exs` (the 10 old ones are unchanged and pass). **Not touched:** `hpp.ex`, `hpp_rules.ex`, the database, `router.ex`, config, the integration, the demo page, `result_page.ex`.
- **Kept on purpose, so scripts and tests still work:** the form field names (`otp`, `action` = approve / approve_no_redirect / cancel / reject), the "Back to Store" button text, the `We can’t approve this purchase` title, `100 AED` style amounts and `Phone &times; 2` rows, the HTTP codes of the other pages. A request without `ui=1` (curl, Postman, scripts) still gets the 302.
- **Decisions (ours):** Pay in 6 with a 5% fee, from the video (1,600.00 -> fee 80.00 -> 1,680.00 -> 6 x 280.00, monthly fee 13.33); the remainder of total/6 goes into the first payment; same day each month, clamped to the month's last day; text `AED`/`SAR` (no dirham glyph); a fake "Debit card •••• 1234"; OTP is its own first screen, switched in the browser (no new server state; without JavaScript both screens show); "Change plan" and "Got it" both just close the dialog (one plan); Arabic toggle left out for now (a later step); logo, card mark, check illustration and font are our own (system font, a plain bold "tabby" wordmark, no Tabby artwork, because the official logo's licence is unclear); a hidden first button (Approve) so Enter does what the old first button did; the Pay button is never disabled (a disabled button would not send `action=approve`), it gets a spinner class and a second submit is ignored.
- **Tests:** Claude ran `hpp_rules`, `normalizer`, `hpp_plan`, `hpp_page` and the two test files on their own with `elixir -pa _build/dev/lib/{decimal,plug,plug_crypto,mime,telemetry}/ebin` (no project compile, no tracked file changed): **38 tests, 0 failures** (final, after the second round; 28 in the first). NOT run: the whole `test/tabby_sim/` suite (expected 241), the controller (no DB-free test for it), and nothing in a browser. The look, the dialog, the spinner and the redirect timing are unproven until the user opens the page.
- **Second round, same day (the user sent four more video frames: Verify your phone, Choose how to pay, then the frames already seen):** the page now follows the real order, three steps: Verify your phone (buyer's phone, four code boxes that fill the one real `otp` field, resend countdown for show, consent checkbox that must stay checked, Continue), Choose how to pay (amount, pills 12 / 8 / 6 / 4, 6 selected), Secure checkout (plan card, Pay). "Change plan" now goes back to step 2 (it no longer just closes). `HppPlan.build/3` takes the number of payments (`options/0` = [12, 8, 6, 4], `default/0` = 6); **the fee of the other plans is ours, not in the video:** `order * 5% * n / 6` (half up), so the monthly fee is the same in every plan (1,600.00: 53.33 / 80.00 / 106.67 / 160.00). The server renders all four plans (`pv pv-N` blocks, one dialog each) and the browser only switches `data-plan` on the body, so still no server state. The plan radios post a `plan` field that is ignored. Unit tests now **38 in the two files, 0 failures** (plan 12, page 26; 28 new, so 241 expected overall); one test of mine failed on the first run because the CSS itself contains `.phone-line` (a test bug, fixed).
- **Third round, same day (the user said the page did not look like Tabby and that the simulator page and our own demo shop should feel like two different sites):** layout changed from two columns to **one centered white sheet (max 480 px) on a mint gradient**, with an icon-only close button, the merchant name and "Pay with tabby" in the header, the step background white for the phone step and warm grey for the plan and checkout steps (like the frames), bolder headings. **Removed the fake "Debit card •••• 1234" / Payment method card** (it is on Tabby's Secure checkout frame but means nothing in a simulator without cards). The order summary is now a collapsed `<details>` row at the top of the sheet (merchant and total; the items, tax, shipping and discount rows are still in the HTML), and the test tools are a collapsed `<details>` strip **outside** the sheet; the "TEST MODE" badge moved under the sheet. On a phone the sheet fills the screen. The `ResultPage`, the form names, the 302 behaviour, the plans and the tests' expectations are unchanged; the 38 tests still pass (no test needed changing). Still our own drawn look-alikes: no Tabby logo or font (the user may supply the logo SVG later, to be inlined in the header), no Arabic toggle yet.
- **Demo shop layout refresh (same day, same branch `feature/tabby-demo-ui`; the user asked to improve the overall layout and look of "our real integration page", the demo shop at `/tabby/demo`, with NO change in behaviour):** in `demo_page.ex` only. CSS is **appended** after the old rules (so every CSS string the tests pin is untouched) and a few markup lines changed: `header/5` now prints a `.strip` (test-mode pill and the currency badge) above the header, which keeps the store name, the theme button and the language switch; a sticky `.mbar` link (`#hdrCount`, `#mTotal`) shows the cart count and total on phones (hidden from 960 px up); the progress is a segmented line (coloured top edge per step), the panels have a larger radius and softer shadow, the cart image size is 64 px, and `details.tools` is a fixed corner drawer. Two display-only script lines copy the count and the total into the mobile bar. Nothing else in the script, no id, field name, endpoint or text changed. **Tests:** Claude ran `test/tabby/demo_page_test.exs` on its own (the pure modules compiled in memory with `Kernel.ParallelCompiler`, no project compile): **61 tests, 0 failures**. NOT run: `demo_test.exs`, the controller test, and nothing in a browser. Unproven until opened: how the drawer, the sticky bar and the progress line look, RTL (Arabic) mirroring of the new rules (logical properties are used), and the phone layout.
- **Demo shop, cart rounds (same day):** two attempts at making the cart panel lighter were **reverted on the user's word** ("this doesn't look good"): a narrower, compact cart (360 px, smaller rows) and a flat, frameless cart summary. The user then said the problems were B (the cart is too tall, too many rows) and C (the Review & pay step looks empty on the left), and a third change was made, behaviour-preserving: step 3 now lists the order items in the form (`#reviewItems`, filled by `renderReview` from `cart` and the server `quote`), the total row is the strongest line, and `go(n)` folds the cart's item list from step 2 on (changed after the user compared the payment and review screens; it unfolds on going back to step 1) (`setItems`, `autoFolded`: only if it was open, and it unfolds again on leaving step 3). Heading reuses the existing "Order cart" text, so no new translation. `demo_page_test.exs`: **62 tests, 0 failures** (one new), and `node --check` on both scripts of the rendered page: OK. NOT seen in a browser.
- **To check by hand:** wrong OTP (error shows on the code step), Continue, Pay in 6 row opens the dialog (Esc, Got it, Change plan close it), Pay -> spinner -> "Mission complete!" -> redirect after ~2 s; Reject and Back to Store (header X and test tools); the narrow-screen layout; and `curl -i -X POST .../tabby-sim/hpp/<session> -d 'action=approve' -d 'otp=8888'` must still answer 302.

## 11. How to work with Claude in this project (answer style and test method)

Copy of the saved guide, so a new chat (or a person) can follow it. The same text is in Claude's memory file `feedback_how_to_respond_and_test.md`, which every new session loads.

  originSessionId: 56401918-36c4-47f3-b542-5ca973ba2698
  modified: 2026-11-03T09:45:58.704Z
---

### Overview

The user (Greeshma) runs every manual test herself and pastes outputs and logs back. Claude never runs server/curl/DB steps. The style below is what worked through slices 0-6; keep it exactly.

## Working rules (hard)
- ONE step at a time, then STOP for approval. Design first (no code), then build, then unit tests, then review, then manual curl test, then commit.
- Tabby facts come from the docs only: re-read the pages, quote the doc line VERBATIM for each rule, and mark every choice that is Claude's own as "(ours)". Never invent Tabby behaviour. If something is not documented, say so (list it under "Questions for Tabby").
- Isolation: new code only under `lib/da_product_app/tabby/` (+ web controller under `controllers/tabby/`, tests under `test/tabby/`). Additive edits only to shared files. Never touch `tabby_sim`, `endpoint.ex`, the shared DB, or the shared Oban config. Nothing in `lib/da_product_app/tabby/` may mention the simulator; only `payment_store.ex` may use `Repo`.
- Never run anything against the server/DB. Claude only runs `test/tabby/` unit tests, and only when asked: `mix run --no-start -e 'Application.ensure_all_started(:plug_crypto); Application.ensure_all_started(:logger); ExUnit.start(autorun: false); Enum.each(Path.wildcard("test/tabby/*_test.exs"), &Code.require_file/1); ExUnit.run()'`. Tests are DB-free (fake stores, fake HTTP, Plug.Test). No migrations by Claude. No DELETE/UPDATE on anything in manual tests. DB password never on a command line (the user's `mysql shukria_transactions -e "..."` uses ~/.my.cnf).
- Keep `lib/da_product_app/tabby_sim/CONTEXT.md` and `lib/da_product_app/tabby/README.md` current after every change. CONTEXT.md is deliberately NOT committed. Never delete/move it.
- Commits: ONE short title line (plus the one-line `Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>` trailer); no description body. Stage by explicit file names, never `git add .`. Keep out of every commit: `config/dev.exs`, `priv/static/assets/app.css`, `erl_crash.dump`, `lib/da_product_app/tabby_sim/CONTEXT.md`, `lib/tabby-custom-api.json`, `_build/*`. Then give the check commands (`git status --short | grep -v _build`, `git show --stat --format=%B HEAD`).
- Branches are a chain (each built on the previous, no merges): tabby-simulator -> feature/tabby-integration (slice 0-3) -> feature/tabby-checkout (4-5) -> feature/tabby-capture (6) -> feature/tabby-webhooks (7). Build each slice on its own branch.

## How a design answer looks
- Heading "Slice N design (no code yet)". Short "What Tabby says" with verbatim quotes and the page name. Numbered decisions D1..Dn, each with a recommendation and "(ours)" where it is our choice. What is reused / what changes / what is NOT touched. Unit-test list. Curl-plan outline. A numbered "Questions for you". End: "I'll build nothing until you answer."
- The user answers like "go with your picks D1-D6, plus these fixes ..."; apply exactly those, then build.

## How a build answer looks
- Files changed (new / changed additively / not touched), the unit-test result (count, failures, warnings in Tabby files), honest notes (a real bug the tests found, limits of DB-free tests), docs updated. Then STOP.

## How a curl-test answer looks (the important part)
- Open with the verdict as a heading ("# X passes" / "is not yet") then a table: Check | Evidence | OK (use the user's pasted log lines, ids, times; quote them). Explain anything surprising plainly, including when Claude's own prediction was wrong, and explain terminal artifacts (wrapped text like "anotherattempt" = column wrapping, not a bug; `nonode@nohost`; Oban timing).
- Commands: FULLY written-out, single line, in code spans, NO shell variables and NO `<placeholders>` (a literal `<attempt_id>` once broke bash). Real ids filled in from the user's earlier output. When ids are not known yet, give only the commands that do not need them, then say "paste these outputs, I will fill in the ids".
- Number the steps (Step 1, 2, ...). Say WHERE to run each (iex window vs second terminal vs browser). After each command write "Expect:" with the exact values/log lines. End with a "Paste back:" list. Keep time-sensitive things explicit (UTC times from the log; hosted pages last 10 min in the test setup).
- Read-only checks only: SELECTs on `tabby_payments`, `oban_jobs WHERE queue = 'tabby'` (also filter `worker`), and the simulator's `GET /tabby-sim/api/v2/payments/<id>` with `-H 'Authorization: Bearer sk_test_000000000'`.
- When the user is unsure ("which one?"), re-list the steps clearly in order with the exact commands. When they paste incomplete output, ask for the specific missing piece instead of assuming.
- Summaries on request ("give full summary of slice N tests"): unit tests per file + the manual stages with results + honest limits + what was NOT tested.

## The manual test setup (simulator as Tabby)
- Restart (new merchant code per run, e.g. slice7): `TABBY_SIM_ENABLED=true TABBY_SIM_PUBLIC_URL=http://78.47.213.209:4030 TABBY_SIM_ADMIN_TOKEN=admin-test-123 TABBY_SIM_MAX_AMOUNT=5000 TABBY_SIM_SESSION_TTL_MIN=10 TABBY_SIM_EXPIRE_AFTER_MIN=12 TABBY_BASE_URL=http://localhost:4030/tabby-sim TABBY_SECRET_KEY=sk_test_000000000 TABBY_MERCHANT_CODE=slice7 TABBY_REGION=uae TABBY_API_KEY=api-test-key-123 TABBY_PUBLIC_URL=http://78.47.213.209:4030 TABBY_ALLOW_HTTP_URLS=true TABBY_RETURN_HOSTS=shop.example iex -S mix phx.server` (TABBY_SECRET_KEY set = the `tabby` Oban queue starts; check with `Oban.check_queue(queue: :tabby)`). Everyday command: `TABBY_SIM_ENABLED=true TABBY_SIM_PUBLIC_URL=http://78.47.213.209:4030 TABBY_SIM_ADMIN_TOKEN=admin-test-123 iex -S mix phx.server`. Simulator timers are shortened with env vars, never by code changes.
- Create a session: `curl -s -w '\nHTTP status: %{http_code}\n' -X POST http://localhost:4030/tabby/sessions -H 'Content-Type: application/json' -H 'X-Tabby-Api-Key: api-test-key-123' -d '{"order_reference":"SLICE7-001","amount":"100.00","currency":"AED","lang":"en","buyer":{"name":"Sara Ali","email":"otp.success@tabby.ai","phone":"500000001"},"buyer_history":{"registered_since":"2023-02-01T10:00:00Z"},"shipping_address":{"city":"Dubai","address":"Marina 1","zip":"00000"},"items":[{"title":"Phone case","quantity":1,"unit_price":"100.00","category":"Accessories"}],"success_url":"https://shop.example/paid","cancel_url":"https://shop.example/cancelled","failure_url":"https://shop.example/failed"}'` (answer has attempt_id, payment_id, web_url `.../tabby-sim/hpp/<session>`).
- Approve at the simulator: `curl -s -o /dev/null -w 'HTTP %{http_code}\n%{redirect_url}\n' -X POST http://localhost:4030/tabby-sim/hpp/<session> -d 'action=approve' -d 'otp=8888'` (also `action=cancel`, `action=reject`). Customer returns: `curl -s -o /dev/null -w 'HTTP %{http_code}\n%{redirect_url}\n' 'http://localhost:4030/tabby/return/<attempt>/success?payment_id=<payment>'`.
- Faults (500/409 on capture etc.) are armed in the browser admin page `http://78.47.213.209:4030/tabby-sim/admin` (any user, password `admin-test-123`), BEFORE approving, scoped to the payment id. The simulator has no 4xx capture fault: use a wrong `TABBY_SECRET_KEY` (no `sk_test_` prefix) to get a 401. Admin page also has resend (duplicate webhook) and out-of-order. The poller can verify and capture an approved payment on its own after 2 minutes, so arm faults before approving.
- Always prove "exactly one capture" at the simulator, and prove chains/jobs with `oban_jobs` SELECTs. Poll runs are 2 minutes apart; the stale-claim time (5 min) cannot be shortened without code.

## How to give staging and commit commands (and what never goes in a commit)
Give them as a numbered, copy-paste block, after the user's unit tests (and curl test) passed. One commit per slice, on that slice's own branch.
1. Check the branch: `git branch --show-current` (Expect the slice's branch, e.g. `feature/tabby-webhooks`).
2. Stage by EXPLICIT file names in ONE command: `git add <full/path/one> <full/path/two> ...`. List every changed and new file of the slice (library files, the web controller, `router.ex`/`config/runtime.exs` if changed additively, tests, the README). Build the list from `git status --short | grep -v _build`, and COUNT it: say "N files" and recount before sending (a miscount happened once: 17 vs 18 vs 19). Never `git add .`, `git add -A`, `git commit -a`.
3. Check what is staged: `git status --short | grep -v _build`. Expect `M ` or `A ` (letter in the FIRST column) for exactly those N files. Everything in the never-commit list must show a leading space (` M`) or `??`, i.e. NOT staged. Explain this column rule when the user pastes something ambiguous (` M` = not staged, `M ` = staged).
4. Commit with ONE short imperative title and the one-line trailer: `git commit -m "Add Tabby webhook receiver" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"`. No description paragraph in the commit.
5. Check the commit: `git show --stat --format=%B HEAD`. Expect the title, the trailer, and exactly the N files, nothing else. (Also `git show --stat --format=%h HEAD | grep -c <file>` = 0 to prove a specific file is not in it.)
6. If something was staged by mistake: `git restore --staged <file>` BEFORE committing. If it is already committed, explain the options and let the user decide; never amend, reset, rebase or force-push without being asked (the commits are local and unpushed).

NEVER commit:
- `config/dev.exs` (local settings), `priv/static/assets/app.css` (regenerated by the asset build), `erl_crash.dump`, `lib/da_product_app/tabby_sim/CONTEXT.md` (deliberately not committed, kept current locally), `lib/tabby-custom-api.json`.
- Anything under `_build/` (tracked build artefacts that always show as ` M` or ` D`; never stage them; do not "clean" them).
- Stray files from a mis-pasted command (for example an empty file named `authorized` created by a pasted `->`); suggest `ls -la <name>` then `rm <name>` and only after the user agrees.
- Secrets, `.env`, logs, crash dumps, temporary files, real keys. (The test key `sk_test_000000000` lives only in commands, not in files.)
- `tabby_sim/` files, unless the slice is explicitly a simulator change the user approved (slice 0 was). Migrations only in a migration slice the user ran and approved.
- Docs rule: the README IS committed with the slice; CONTEXT.md is NOT.

## What to update in README.md and CONTEXT.md (after EVERY change, not only at the end of a slice)
Always finish a build answer with one line saying which of the two files were updated and what changed. Keep the numbers (test count, file lists) identical in both (a 419-vs-420 mismatch happened once).

README (`lib/da_product_app/tabby/README.md`; COMMITTED with the slice; written for a developer who has never seen this chat):
- **Configuration table**: every new env var / config key (name, default, meaning); mirror `config/runtime.exs`.
- **Modules table**: one row per new module or job.
- **"Behaviour, with the Tabby page each rule comes from"**: a row per Tabby rule with its doc page, and "ours" for our own choices.
- **A new section per slice** (`## <Feature> (slice N)`): "What Tabby says" (verbatim quotes with the page name and the date fetched), how it works (route/job, steps, status codes, error handling table), every decision marked "(ours)", "Known limits", "Tested by hand" (one short paragraph, added after the user's curl test passed), "Files and tests".
- **Data section** if tables or columns change; **Usage** if the iex usage changes; **Tests** section: the unit-test count and the run command.
- Remove or fix lines that became outdated ("not built yet" notes). Do NOT put chat history, one run's ids, log lines or timings in the README.

CONTEXT.md (`lib/da_product_app/tabby_sim/CONTEXT.md`; NOT committed; the working notebook a new chat reads first; never delete or move it):
- **Section 3 "Step status" and section 10 "Next action"**: the status of the current slice and what comes next.
- **A slice entry `### Slice N - ...`**: header with date, branch, who ran the unit tests and the count (say "NOT run by Claude" where true), committed or not, curl test done or not. Body: the design as approved (with the user's changes quoted), files new / changed additively / not touched, real bugs the tests found, the behaviour to check in the curl test, findings made while planning.
- **After the user's curl test**: a paragraph "Manual curl test of slice N (date, run by the user)" with the stages, the orders/cases, the result of each, observations, and limits (what was NOT proven).
- **After the commit**: a line "### Slice N status: committed as `<hash>` (\"<title>\"), N unit tests, curl test passed".
- **Section 5 "Decisions chosen by Claude"**: add every "(ours)" decision. **Section 2 "Working rules"**: any new rule the user gives. **Sections 8 and 9**: repo facts and the files created or changed. The open "Questions for Tabby" list and the "Next phase" paragraph at the end.
- Be honest and specific: say what was NOT run, what is only an inference from the docs, and what the tests cannot prove.
