# Secure Merchant Checkout — Zyel Lab 015

Arthur Organic and every order are fictional. The runnable default uses a fake
PaymentGateway, local Laravel routes and SQLite. It sends **zero Stripe requests**
and accepts no PAN or CVC. A real Stripe sandbox adapter is present but hard
disabled, unconfigured and unexercised. This adds a fifteenth Lab and replaces
none of the existing fourteen. It is a tutorial, not a production merchant system.

## Tested stack

PHP 8.4.24; Composer 2.10.3; Laravel 12.70.1; Node 24.19.0; npm 12.2.1;
Vue 3.5.44; TypeScript 5.9.3; Vite 7.3.7; vue-tsc 3.3.13; PHPUnit 11.5.57;
Playwright 1.64.1; Chromium headless build 1248 (156.0.8078.4).
PHP needs PDO SQLite, SQLite3, mbstring, OpenSSL, cURL, fileinfo, sodium, zip
and Argon2id support.

Installed SDKs: **stripe/stripe-php 22.0.0** and **@stripe/stripe-js 10.2.0**.
The PHP API constant is **2026-09-30.endive**. Stripe.js imports are type-only,
erased from runtime code. These are recorded versions, not a latest-version claim.
TypeScript 7.0.2 failed with vue-tsc 3.3.13; the tested 5.9.3 pin resolves it.
Preserve both lockfiles.

## Install and run

From the extracted project, with PHP extensions enabled:

```sh
composer install --no-interaction
npm ci --ignore-scripts
php scripts/prepare.php
php artisan migrate --seed --force
npm run build
php scripts/package.php
php -S 127.0.0.1:8425 -t public scripts/router.php
```

Open **http://127.0.0.1:8425/**. The last command is a local development server;
do not expose it publicly. Preparation generates a random local Laravel app key
in `.env`, prints no values, and refuses to overwrite an existing environment.
`.env.example` has empty placeholders and is not a configured file to copy blindly.

The fictional login requires local/testing environment, offline mode, explicit
offline-demo opt-in and loopback request address. Production disables it and all
fixture controls. `LAB_PAYMENT_MODE=stripe-sandbox` fails closed with
`stripe_adapter_disabled`; it does not activate the real adapter.

## Reproduce the failures

1. Open the fictional merchant workspace. Two oat products total **1500 USD minor
   units** from the server catalog. Tamper with the total to see rejection.
2. Queue **3DS requires action**, then process the inbox. It stays unfulfilled.
   Queue **Async payment pending** and process it; settlement remains incomplete.
3. Queue **Async payment succeeds**, then use **Crash worker once**. The actual
   transaction rolls back. Retry **Process inbox**; one fulfillment row commits.
4. Replay the last event and break its signature. Duplicate delivery cannot create
   another business effect, and the malformed signature is rejected.
5. For disorder, use a fresh order, queue async failure followed by success and
   process both. Current gateway state wins. Paid cannot regress on stale delivery.
6. Choose **Timeout after gateway commit**, create a checkout, then retry the
   unchanged cart. One durable operation UUID retrieves one canonical session.
   The UUID survives reload. Editing the cart starts a different operation.
7. Refund 400 minor units after payment. Reload and retry an uncertain response;
   the same intent UUID is retained. A second refund of that amount needs an
   explicit **Start another refund** action. The server reserves the balance first.

The fake signing key is intentionally public fixture material, not a credential.
It is accepted only within the local offline fixture boundary. Never reuse it for
a real or public endpoint.

## Run verification

```sh
php vendor/phpunit/phpunit/phpunit --colors=never
php scripts/concurrency.php
php scripts/verify-package.php
```

With the local server running:

```sh
npx playwright install chromium --only-shell
npm run test:ui
```

Browser tests use the actual local backend. A regression drops a committed local
refund response, reloads and checks one UUID and amount. The outbound test denies
both Laravel HTTP and the official Stripe transport. The 45-request/minute limit
stays active; allow its window to expire between repeated flows. Offline proofs
do not establish real Stripe integration success.

The static companion can be inspected at `/tutorial-preview`; it calls no API,
demo or webhook routes. `php scripts/verify-package.php --extract` verifies the
archive and extracts it into a new sibling directory named from its checksum,
without overwriting an existing extraction.

## Read the boundaries accurately

The inbox stores minimal references and a body digest, not raw customer payloads.
The SDK verifies exact raw bytes; an extra ±300-second bound rejects future
timestamps too. The worker retrieves the current session, checks mode, amount,
currency and stored order/operation binding, then commits the order state,
unique fulfillment row and inbox acknowledgement together in SQLite
`BEGIN IMMEDIATE`. Trusted operation metadata closes the webhook-before-response
race after a timeout.

Once-only scope is the **local business record**. External shipping, email or
inventory effects need a transactional outbox and idempotent downstream consumers.
Only SQLite was exercised; another engine needs equivalent locking and tests.

Unresolved operations stop retrying at a conservative **23-hour horizon** and
require explicit reconciliation without another create call. Stripe API v1 may
prune an idempotency key after at least 24 hours. A retained local key is not an
unlimited provider replay guarantee. Unknown refund reservations remain held.

## Optional clinic and future choices

The fake Connect clinic binds state to actor, initiating browser session, exact
redirect and test mode, then claims state/code digests once before its simulated
exchange. Its local state TTL is five minutes. Separately, Stripe's actual
authorization codes expire after five minutes. Real token exchange is
non-idempotent; re-consuming a code can revoke the connection. Reconcile an
uncertain exchange instead of retrying it. No Stripe PKCE support is assumed.
OAuth is optional for this single merchant; Stripe discourages it for new
Connect platforms. No real OAuth adapter or connection exists.

Hosted Checkout Sessions are the main future design. The Payment Element with
Checkout Sessions trades more layout control for more client lifecycle work.
Direct PaymentIntent handling and manual capture are explainers, not implemented
flows. Subscriptions, taxes, inventory, disputes and Connect transfers are outside
this bounded Lab.

Laravel Argon2id generates unique random per-password nonsecret salts. This Lab
uses 64 MiB, three iterations and one thread. Optional pepper, encryption keys
and nonces, webhook HMAC keys and payment tokenization have different jobs.
There is no custom three-salt standard, password encryption or card hashing.

A production-hosted demo can use sandbox credentials after separately approved
setup. It must visibly say **Sandbox — no real purchases**, authenticate and
authorize users, rate-limit, reject live keys/objects and keep secrets server-side.
Use a separate general sandbox and compatible restricted test keys. The current
adapter is hard disabled and has made no request. Any account, key, webhook,
OAuth or persistent-access setup requires exact action approval.
