diff --git a/src/CommerceAdmin.php b/src/CommerceAdmin.php
index 49e3c9c..69b3364 100644
--- a/src/CommerceAdmin.php
+++ b/src/CommerceAdmin.php
@@ -7,29 +7,42 @@
use Nimbus\Plugin\PluginStorage;
/**
- * The Commerce admin page — a read-only list of orders with their status, total
- * and lines. Registered as a GET-only plugin admin page; orders are placed and
- * moved through the MCP tools (or an agent), so this is a window, not an editor.
+ * The Commerce admin page — the orders list with their status, total and lines,
+ * a quick place-order form, and per-row lifecycle buttons (pay, fulfil, cancel).
+ * The same lifecycle an agent drives over MCP; this is the human hand on it.
*
* Customer emails and SKU codes can originate from callers, so every value is
- * escaped before it reaches the page.
+ * escaped before it reaches the page. Status "pills" use the admin theme's
+ * semantic tokens (which redefine per theme) rather than hard-coded colours, so
+ * they stay legible in dark and every selectable theme.
*/
final class CommerceAdmin
{
+ /** status => [background token, text token] — all theme-defined, so dark-safe. */
private const STATUS_TONE = [
- 'pending' => '#9a6a12',
- 'paid' => '#0f766e',
- 'fulfilled' => '#5751d6',
- 'cancelled' => '#a5386b',
+ 'pending' => ['--nb-warn-bg', '--nb-warn-text'],
+ 'paid' => ['--nb-brand-tint', '--nb-link-color'],
+ 'fulfilled' => ['--nb-ok-bg', '--nb-ok-text'],
+ 'cancelled' => ['--nb-surface-2', '--nb-muted'],
+ ];
+
+ /** A tiny symbol map — no ext-intl dependency; unknown codes render as "12.50 XYZ". */
+ private const SYMBOL = [
+ 'USD' => '$', 'EUR' => '€', 'GBP' => '£', 'JPY' => '¥',
+ 'AUD' => 'A$', 'CAD' => 'C$', 'NZD' => 'NZ$', 'INR' => '₹',
];
private const NOTICES = [
- 'placed' => ['ok', 'Order placed and stock reserved.'],
- 'paid' => ['ok', 'Order marked paid.'],
- 'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'],
- 'cancelled' => ['ok', 'Order cancelled — stock released.'],
- 'short' => ['err', 'Not enough stock available to place that order.'],
- 'invalid' => ['err', 'Check the SKU and quantity and try again.'],
+ 'placed' => ['ok', 'Order placed and stock reserved.'],
+ 'paid' => ['ok', 'Order marked paid.'],
+ 'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'],
+ 'cancelled' => ['ok', 'Order cancelled — stock released.'],
+ 'short' => ['err', 'Not enough stock available to place that order.'],
+ 'badqty' => ['err', 'Enter a valid quantity and unit price.'],
+ 'noinventory' => ['err', 'Install the Inventory plugin — an order reserves stock against it.'],
+ 'notfound' => ['err', 'No order with that reference.'],
+ 'badstate' => ['err', 'That order can’t move to that state.'],
+ 'invalid' => ['err', 'Check the SKU and quantity and try again.'],
];
/** @param \Closure():PluginStorage $storage */
@@ -40,11 +53,23 @@ public function __construct(private \Closure $storage)
/**
* @param string $csrf CSRF token for the forms (passed by core to the page handler)
* @param ?string $notice a fixed notice code (from the ?ok=/?err= redirect)
+ * @param ?string $status a status filter (from ?status=), allow-listed to the known statuses
*/
- public function render(string $csrf = '', ?string $notice = null): string
+ public function render(string $csrf = '', ?string $notice = null, ?string $status = null): string
{
- $s = ($this->storage)();
- $orders = $s->select('SELECT id, reference, status, customer_email, total, placed_at FROM ' . Schema::ORDER . ' ORDER BY id DESC LIMIT 50');
+ $s = ($this->storage)();
+
+ // Allow-list the filter: an unknown value is ignored (never reaches SQL).
+ $status = ($status !== null && isset(self::STATUS_TONE[$status])) ? $status : null;
+ $where = $status === null ? '' : ' WHERE status = :status';
+ $params = $status === null ? [] : ['status' => $status];
+ $orders = $s->select('SELECT id, reference, status, customer_email, currency, total, placed_at FROM ' . Schema::ORDER . $where . ' ORDER BY id DESC LIMIT 50', $params);
+
+ /** @var list $skus SKUs sold before — the place-form suggestions (own table; boundary-safe) */
+ $skus = array_map(
+ static fn (array $r): string => (string) $r['sku_code'],
+ $s->select('SELECT DISTINCT sku_code FROM ' . Schema::LINE . ' ORDER BY sku_code'),
+ );
$banner = '';
if ($notice !== null && isset(self::NOTICES[$notice])) {
@@ -59,49 +84,130 @@ public function render(string $csrf = '', ?string $notice = null): string
$html = '
Commerce
' . $banner
. 'Orders reserve stock against Inventory. '
- . 'Place a quick order below, or drive the full lifecycle (pay, fulfil, cancel) over MCP.
'
+ . 'Place an order below and advance it with the buttons, or drive the lifecycle over MCP.'
+ . $this->datalist($skus)
. $this->placeForm($csrf);
+ $html .= $this->statusFilter($status);
+
if ($orders === []) {
- $html .= 'No orders yet. Place one with the shop_place_order tool.
';
+ $html .= $status === null
+ ? 'No orders yet. Place one above, or with the shop_place_order tool.
'
+ : 'No ' . $this->e($status) . ' orders.
';
return $html;
}
$html .= ''
. 'Order Status Customer Items '
- . 'Total Placed ';
+ . 'Total Placed Actions ';
foreach ($orders as $o) {
- $status = (string) $o['status'];
- $tone = self::STATUS_TONE[$status] ?? '#565d6d';
- $items = [];
+ $items = [];
foreach ($lines[(int) $o['id']] ?? [] as $ln) {
$items[] = $this->e((string) $ln['qty']) . ' × ' . $this->e((string) $ln['sku_code']) . '';
}
$html .= '' . $this->e((string) $o['reference']) . ' '
- . '' . $this->e($status) . ' '
+ . '' . $this->pill((string) $o['status']) . ' '
. '' . $this->e((string) ($o['customer_email'] ?? '—')) . ' '
. '' . implode(', ', $items) . ' '
- . '$' . $this->e((string) $o['total']) . ' '
- . '' . $this->e((string) $o['placed_at']) . ' ';
+ . '' . $this->money((string) $o['total'], (string) $o['currency']) . ' '
+ . '' . $this->e((string) $o['placed_at']) . ' '
+ . '' . $this->actions((string) $o['reference'], (string) $o['status'], $csrf) . ' ';
}
$html .= '
';
return $html;
}
+ /** A coloured status pill using theme tokens (dark-safe). */
+ private function pill(string $status): string
+ {
+ [$bg, $fg] = self::STATUS_TONE[$status] ?? ['--nb-surface-2', '--nb-muted'];
+
+ return '' . $this->e($status) . ' ';
+ }
+
+ /** The lifecycle buttons valid for this order's status; each is a CSRF-protected POST. */
+ private function actions(string $reference, string $status, string $csrf): string
+ {
+ $verbs = match ($status) {
+ 'pending' => ['pay' => 'Pay', 'cancel' => 'Cancel'],
+ 'paid' => ['fulfil' => 'Fulfil', 'cancel' => 'Cancel'],
+ default => [],
+ };
+ if ($verbs === []) {
+ return '— ';
+ }
+
+ $out = '';
+ foreach ($verbs as $action => $label) {
+ $primary = $action === 'cancel' ? '' : ' nb-btn-primary';
+ $out .= '';
+ }
+ return $out . '
';
+ }
+
+ /** Status filter chips (GET links, allow-listed). */
+ private function statusFilter(?string $active): string
+ {
+ $chip = function (string $label, ?string $status) use ($active): string {
+ $on = $status === $active;
+ $href = $status === null ? '/admin/commerce' : '/admin/commerce?status=' . rawurlencode($status);
+ $cls = 'nb-btn' . ($on ? ' nb-btn-primary' : '');
+ return '' . $this->e($label) . ' ';
+ };
+
+ $out = ''
+ . $chip('All', null);
+ foreach (array_keys(self::STATUS_TONE) as $status) {
+ $out .= $chip(ucfirst($status), $status);
+ }
+ return $out . '
';
+ }
+
+ /**
+ * Known-SKU suggestions for the place form, from this plugin's own order lines.
+ *
+ * @param list $skus
+ */
+ private function datalist(array $skus): string
+ {
+ $out = '';
+ foreach ($skus as $sku) {
+ $out .= ' ';
+ }
+ return $out . ' ';
+ }
+
+ private function money(string $amount, string $currency): string
+ {
+ $currency = strtoupper(trim($currency)) ?: 'USD';
+ $symbol = self::SYMBOL[$currency] ?? '';
+
+ return $symbol !== ''
+ ? $this->e($symbol . $amount)
+ : $this->e($amount . ' ' . $currency);
+ }
+
/** A quick single-line place-order form (posts to the plugin admin action with CSRF). */
private function placeForm(string $csrf): string
{
- $f = static fn (string $label, string $name, string $ph): string =>
- '' . htmlspecialchars($label, ENT_QUOTES) . ' '
- . '
';
+ $f = function (string $label, string $name, string $ph, bool $suggest = false): string {
+ $list = $suggest ? ' list="ord-skus"' : '';
+ return '' . $this->e($label) . ' '
+ . '
';
+ };
return '';
}
- private function e(string $v): string
+ public function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
diff --git a/src/CommercePlugin.php b/src/CommercePlugin.php
index 86d95a1..c991ea6 100644
--- a/src/CommercePlugin.php
+++ b/src/CommercePlugin.php
@@ -42,24 +42,34 @@ public function register(PluginContext $context): void
$context->mcp()->register(new CommerceToolset($orders));
- // Admin page: an orders overview + a quick place-order form (H3).
+ // Admin page: an orders overview + place form + per-row lifecycle buttons
+ // (H3). Gated on this plugin's own wildcard-immune capability (ADR 0020) —
+ // advancing an order in the UI needs `nimbuscms.commerce:write`, exactly
+ // like the MCP tools, so a content-only editor can't.
$context->adminPages()->register(
'commerce',
'Commerce',
'🧾',
- static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err')),
+ static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('status')),
+ self::ID . ':write',
);
$context->adminPages()->action('commerce', 'place', static function (Request $r) use ($orders): Response {
- $sku = trim((string) ($r->input('sku') ?? ''));
- $qty = trim((string) ($r->input('qty') ?? ''));
+ $sku = trim((string) ($r->input('sku') ?? ''));
+ $qty = trim((string) ($r->input('qty') ?? ''));
+ $price = trim((string) ($r->input('unit_price') ?? '')) ?: '0';
if ($sku === '' || $qty === '') {
return Response::redirect('/admin/commerce?err=invalid');
}
+ // Validate the numbers at the boundary so a non-numeric qty/price is an
+ // honest "badqty" notice, not a database error surfacing as something else.
+ if (preg_match('/^\d+(\.\d{1,4})?$/', $qty) !== 1 || preg_match('/^\d+(\.\d{1,2})?$/', $price) !== 1) {
+ return Response::redirect('/admin/commerce?err=badqty');
+ }
$line = [
'sku' => $sku,
'location' => trim((string) ($r->input('location') ?? '')) ?: 'main',
'qty' => $qty,
- 'unit_price' => trim((string) ($r->input('unit_price') ?? '')) ?: '0',
+ 'unit_price' => $price,
];
$email = trim((string) ($r->input('customer_email') ?? '')) ?: null;
try {
@@ -67,11 +77,41 @@ public function register(PluginContext $context): void
return Response::redirect('/admin/commerce?ok=placed');
} catch (\NimbusCMS\Inventory\InsufficientStock) {
return Response::redirect('/admin/commerce?err=short');
+ } catch (NoInventory) {
+ return Response::redirect('/admin/commerce?err=noinventory');
+ } catch (\InvalidArgumentException) {
+ return Response::redirect('/admin/commerce?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/commerce?err=invalid');
}
});
+ // The lifecycle actions — the UI catching up to the MCP tools. Each reads
+ // the order reference, advances it, and maps a typed failure to an honest
+ // notice (unknown order vs illegal transition).
+ foreach ([
+ 'pay' => static fn (OrderBook $o, string $ref): array => $o->pay($ref, date('Y-m-d H:i:s')),
+ 'fulfil' => static fn (OrderBook $o, string $ref): array => $o->fulfil($ref, 'admin-ui', date('Y-m-d H:i:s')),
+ 'cancel' => static fn (OrderBook $o, string $ref): array => $o->cancel($ref, date('Y-m-d H:i:s')),
+ ] as $action => $run) {
+ $context->adminPages()->action('commerce', $action, static function (Request $r) use ($orders, $run, $action): Response {
+ $ref = trim((string) ($r->input('reference') ?? ''));
+ if ($ref === '') {
+ return Response::redirect('/admin/commerce?err=invalid');
+ }
+ try {
+ $run($orders, $ref);
+ return Response::redirect('/admin/commerce?ok=' . ($action === 'pay' ? 'paid' : ($action === 'fulfil' ? 'fulfilled' : 'cancelled')));
+ } catch (OrderNotFound) {
+ return Response::redirect('/admin/commerce?err=notfound');
+ } catch (IllegalTransition) {
+ return Response::redirect('/admin/commerce?err=badstate');
+ } catch (\Throwable) {
+ return Response::redirect('/admin/commerce?err=invalid');
+ }
+ });
+ }
+
$context->skills()->register('Commerce', Guide::text());
}
}
diff --git a/src/IllegalTransition.php b/src/IllegalTransition.php
new file mode 100644
index 0000000..bbaa478
--- /dev/null
+++ b/src/IllegalTransition.php
@@ -0,0 +1,19 @@
+stock();
if ($port === null) {
- throw new \RuntimeException('No inventory plugin is installed, so stock cannot be reserved — an order cannot be placed.');
+ throw new NoInventory('No inventory plugin is installed, so stock cannot be reserved — an order cannot be placed.');
}
if ($lines === []) {
throw new \InvalidArgumentException('An order needs at least one line.');
@@ -124,7 +124,7 @@ public function fulfil(string $ref, string $actor, string $now): array
{
$order = $this->requireOrder($ref);
if ($order['status'] !== self::PAID) {
- throw new \RuntimeException("Only a paid order can be fulfilled (this one is \"{$order['status']}\").");
+ throw new IllegalTransition((string) $order['status'], self::FULFILLED);
}
$this->storage()->transaction(function () use ($order, $ref, $actor, $now): void {
@@ -147,7 +147,7 @@ public function cancel(string $ref, string $now): array
{
$order = $this->requireOrder($ref);
if ($order['status'] === self::FULFILLED) {
- throw new \RuntimeException('A fulfilled order cannot be cancelled.');
+ throw new IllegalTransition(self::FULFILLED, self::CANCELLED);
}
if ($order['status'] === self::CANCELLED) {
return $this->get($ref) ?? throw new \RuntimeException('Unknown order.');
@@ -203,7 +203,7 @@ private function requireOrder(string $ref): array
{
$order = $this->storage()->selectOne('SELECT * FROM ' . Schema::ORDER . ' WHERE reference = :ref', ['ref' => $ref]);
if ($order === null) {
- throw new \RuntimeException("No order with reference \"{$ref}\".");
+ throw new OrderNotFound($ref);
}
return $order;
}
@@ -212,7 +212,7 @@ private function transition(string $ref, string $from, string $to, string $now):
{
$order = $this->requireOrder($ref);
if ($order['status'] !== $from) {
- throw new \RuntimeException("An order can only move to \"{$to}\" from \"{$from}\" (this one is \"{$order['status']}\").");
+ throw new IllegalTransition((string) $order['status'], $to);
}
$this->setStatus((int) $order['id'], $to, $now);
}
diff --git a/src/OrderNotFound.php b/src/OrderNotFound.php
new file mode 100644
index 0000000..f0803ee
--- /dev/null
+++ b/src/OrderNotFound.php
@@ -0,0 +1,19 @@
+ */
+ private array $actions;
+
+ protected function setUp(): void
+ {
+ $this->db = new Connection([
+ 'host' => getenv('TEST_DB_HOST') ?: 'db',
+ 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306),
+ 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test',
+ 'user' => getenv('TEST_DB_USER') ?: 'root',
+ 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root',
+ ]);
+ foreach ([...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all()] as $sql) {
+ $this->db->execute($sql);
+ }
+ foreach ([InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE] as $t) {
+ $this->db->execute('TRUNCATE ' . $t);
+ }
+
+ // Stock a SKU so orders can be placed.
+ $ledger = new Ledger(fn (): PluginStorage => new PluginStorage($this->db));
+ $ledger->receive('LATTE', $ledger->ensureLocation('main', 'Main', '2026-01-01 09:00:00'), '10', 'each', 'setup', '2026-01-01 09:00:00');
+
+ // Install BOTH packages so Commerce resolves Inventory's port from the shared
+ // service registry — the real cross-plugin wiring.
+ $commerce = json_decode((string) file_get_contents(__DIR__ . '/../composer.json'), true);
+ $inventory = json_decode((string) file_get_contents(__DIR__ . '/../vendor/nimbuscms/inventory/composer.json'), true);
+ $pkg = static fn (array $m): array => ['name' => $m['name'], 'type' => $m['type'], 'extra' => $m['extra']];
+ $this->installedJson = (string) tempnam(sys_get_temp_dir(), 'nb-installed-');
+ file_put_contents($this->installedJson, json_encode(['packages' => [$pkg($inventory), $pkg($commerce)]], JSON_THROW_ON_ERROR));
+
+ $adminPages = new AdminPageRegistry();
+ $diagnostics = (new PluginLoader($this->installedJson))->load(new PluginCapabilities(
+ adminPages: $adminPages,
+ services: new ServiceRegistry(),
+ db: $this->db,
+ ));
+ self::assertSame([], $diagnostics, 'both plugins load cleanly (Commerce page gated on nimbuscms.commerce:write)');
+
+ $this->actions = [];
+ foreach ($adminPages->actions() as $a) {
+ if ($a['provider'] === 'nimbuscms.commerce') {
+ $this->actions[$a['action']] = $a['handler'];
+ }
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ @unlink($this->installedJson);
+ }
+
+ /** @param array $input */
+ private function post(string $action, array $input): Response
+ {
+ return ($this->actions[$action])(new Request('POST', '/admin/commerce/' . $action, [], $input, [], []));
+ }
+
+ private function place(): string
+ {
+ $r = $this->post('place', ['sku' => 'LATTE', 'location' => 'main', 'qty' => '2', 'unit_price' => '4.50']);
+ self::assertSame('/admin/commerce?ok=placed', $r->header('Location'), 'placing succeeds when stock is available');
+ $ref = $this->db->selectOne('SELECT reference FROM ' . CommerceSchema::ORDER . ' ORDER BY id DESC LIMIT 1');
+ return (string) $ref['reference'];
+ }
+
+ public function test_place_reserves_and_redirects_ok(): void
+ {
+ $this->place();
+ }
+
+ public function test_place_more_than_available_is_short(): void
+ {
+ $r = $this->post('place', ['sku' => 'LATTE', 'location' => 'main', 'qty' => '999', 'unit_price' => '4.50']);
+ self::assertSame('/admin/commerce?err=short', $r->header('Location'));
+ }
+
+ public function test_place_a_bad_quantity_is_badqty(): void
+ {
+ $r = $this->post('place', ['sku' => 'LATTE', 'location' => 'main', 'qty' => 'lots', 'unit_price' => '4.50']);
+ self::assertSame('/admin/commerce?err=badqty', $r->header('Location'));
+ }
+
+ public function test_pay_then_fulfil_advances_the_order(): void
+ {
+ $ref = $this->place();
+
+ self::assertSame('/admin/commerce?ok=paid', $this->post('pay', ['reference' => $ref])->header('Location'));
+ self::assertSame('/admin/commerce?ok=fulfilled', $this->post('fulfil', ['reference' => $ref])->header('Location'));
+ }
+
+ public function test_cancel_releases_the_hold(): void
+ {
+ $ref = $this->place();
+ self::assertSame('/admin/commerce?ok=cancelled', $this->post('cancel', ['reference' => $ref])->header('Location'));
+ }
+
+ public function test_paying_an_unknown_order_is_notfound(): void
+ {
+ $r = $this->post('pay', ['reference' => 'ORD-NOPE']);
+ self::assertSame('/admin/commerce?err=notfound', $r->header('Location'));
+ }
+
+ public function test_fulfilling_an_unpaid_order_is_badstate(): void
+ {
+ $ref = $this->place(); // still pending
+ $r = $this->post('fulfil', ['reference' => $ref]);
+ self::assertSame('/admin/commerce?err=badstate', $r->header('Location'));
+ }
+
+ public function test_a_missing_reference_is_invalid(): void
+ {
+ self::assertSame('/admin/commerce?err=invalid', $this->post('pay', [])->header('Location'));
+ }
+}
diff --git a/tests/CommerceAdminTest.php b/tests/CommerceAdminTest.php
new file mode 100644
index 0000000..f130ac4
--- /dev/null
+++ b/tests/CommerceAdminTest.php
@@ -0,0 +1,117 @@
+db = new Connection([
+ 'host' => getenv('TEST_DB_HOST') ?: 'db',
+ 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306),
+ 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test',
+ 'user' => getenv('TEST_DB_USER') ?: 'root',
+ 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root',
+ ]);
+ foreach (Schema::all() as $sql) {
+ $this->db->execute($sql);
+ }
+ $this->db->execute('TRUNCATE ' . Schema::ORDER);
+ $this->db->execute('TRUNCATE ' . Schema::LINE);
+
+ $this->storage = new PluginStorage($this->db);
+ $this->admin = new CommerceAdmin(fn (): PluginStorage => $this->storage);
+ }
+
+ private function order(string $ref, string $status, string $currency, string $total, string $sku): void
+ {
+ $oid = $this->storage->insert(
+ 'INSERT INTO ' . Schema::ORDER . ' (reference, status, customer_email, currency, total, placed_at, updated_at)
+ VALUES (:r, :s, :e, :c, :t, :n, :n2)',
+ ['r' => $ref, 's' => $status, 'e' => 'buyer@test.local', 'c' => $currency, 't' => $total, 'n' => '2026-01-01 09:00:00', 'n2' => '2026-01-01 09:00:00'],
+ );
+ $this->storage->insert(
+ 'INSERT INTO ' . Schema::LINE . ' (order_id, sku_code, location, qty, unit_price) VALUES (:o, :sku, :loc, :q, :p)',
+ ['o' => $oid, 'sku' => $sku, 'loc' => 'main', 'q' => '2', 'p' => '6.25'],
+ );
+ }
+
+ public function test_status_pills_use_theme_tokens_not_hard_coded_colours(): void
+ {
+ $this->order('ORD-1', 'pending', 'USD', '12.50', 'house-blend');
+ $html = $this->admin->render('tok');
+
+ self::assertStringContainsString('background:var(--nb-warn-bg);color:var(--nb-warn-text)', $html, 'pending pill uses theme tokens');
+ self::assertStringNotContainsString('color:#fff', $html, 'no hard-coded pill colours');
+ self::assertStringNotContainsString('#9a6a12', $html, 'no legacy hex tones');
+ }
+
+ public function test_totals_render_in_the_orders_currency(): void
+ {
+ $this->order('ORD-USD', 'pending', 'USD', '12.50', 'house-blend');
+ $this->order('ORD-EUR', 'paid', 'EUR', '9.00', 'oat-milk');
+ $html = $this->admin->render('tok');
+
+ self::assertStringContainsString('$12.50', $html);
+ self::assertStringContainsString('€9.00', $html);
+ }
+
+ public function test_an_unknown_currency_falls_back_to_the_code(): void
+ {
+ $this->order('ORD-X', 'pending', 'ZZZ', '5.00', 'thing');
+ self::assertStringContainsString('5.00 ZZZ', $this->admin->render('tok'));
+ }
+
+ public function test_lifecycle_buttons_match_the_status(): void
+ {
+ $this->order('ORD-P', 'pending', 'USD', '1.00', 'a');
+ $this->order('ORD-F', 'fulfilled', 'USD', '1.00', 'b');
+ $html = $this->admin->render('tok');
+
+ self::assertStringContainsString('action="/admin/commerce/pay"', $html, 'a pending order can be paid');
+ self::assertStringContainsString('action="/admin/commerce/cancel"', $html, 'and cancelled');
+ // A fulfilled order is terminal — no lifecycle buttons.
+ self::assertStringNotContainsString('action="/admin/commerce/fulfil"', $html);
+ }
+
+ public function test_the_status_filter_is_allow_listed_and_narrows(): void
+ {
+ $this->order('ORD-P', 'pending', 'USD', '1.00', 'a');
+ $this->order('ORD-D', 'paid', 'USD', '1.00', 'b');
+
+ $paid = $this->admin->render('tok', null, 'paid');
+ self::assertStringContainsString('ORD-D', $paid);
+ self::assertStringNotContainsString('ORD-P', $paid);
+
+ // A junk status is ignored (treated as no filter) and never reflected raw.
+ $junk = $this->admin->render('tok', null, '">');
+ self::assertStringContainsString('ORD-P', $junk, 'junk filter falls back to all orders');
+ self::assertStringNotContainsString('', $junk);
+ }
+
+ public function test_the_place_form_suggests_previously_ordered_skus(): void
+ {
+ $this->order('ORD-1', 'pending', 'USD', '1.00', 'house-blend');
+ $html = $this->admin->render('tok');
+
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString('', $html);
+ }
+}