From d033ca75f101d1f0cd6cb544c359923724cf7034 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 30 Aug 2026 15:24:57 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20order=20detail?= =?UTF-8?q?=20+=20append-only=20status=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An order detail view at GET /admin/commerce?order=REF (a branch of the existing gated page — no new route, holdsPageGate still applies): the order, its lines, its lifecycle buttons, and a real timeline. The timeline is backed by a new append-only commerce_order_event table (002_order_events); OrderBook records one row inside each existing gated transition (place/pay/fulfil/cancel). The actor is server-set — 'admin-ui' from the admin actions, the token principal name over MCP — never from request input. The order's own status stays the authoritative current state. Order references in the list link to the detail. All reads bound; REF escaped. place/pay/cancel gained an optional actor param (default 'system', BC); the now-unused transition() helper was inlined into pay(). Tests: order detail renders lines + timeline (pending reads as "Placed"), currency in the order's currency, rows link to it, unknown/hostile ?order= are handled + escaped; migration list asserts 002_order_events; Choreography + actions tests create the event table. 26 tests green; PHPStan + php-cs-fixer clean. Co-Authored-By: Claude Opus 4.8 --- src/CommerceAdmin.php | 93 +++++++++++++++++++++++++++--- src/CommercePlugin.php | 9 +-- src/CommerceToolset.php | 8 +-- src/OrderBook.php | 59 ++++++++++++++----- src/Schema.php | 23 ++++++++ tests/ChoreographyTest.php | 4 +- tests/CommerceAdminActionsTest.php | 4 +- tests/CommerceAdminTest.php | 49 +++++++++++++++- tests/PackageIntegrationTest.php | 2 +- 9 files changed, 215 insertions(+), 36 deletions(-) diff --git a/src/CommerceAdmin.php b/src/CommerceAdmin.php index f7361aa..9d6c4f9 100644 --- a/src/CommerceAdmin.php +++ b/src/CommerceAdmin.php @@ -54,10 +54,14 @@ 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 + * @param ?string $order a specific order reference (from ?order=) — renders the detail view */ - public function render(string $csrf = '', ?string $notice = null, ?string $status = null): string + public function render(string $csrf = '', ?string $notice = null, ?string $status = null, ?string $order = null): string { $s = ($this->storage)(); + if ($order !== null && trim($order) !== '') { + return $this->renderDetail($s, trim($order), $notice, $csrf); + } // Allow-list the filter: an unknown value is ignored (never reaches SQL). $status = ($status !== null && isset(self::STATUS_TONE[$status])) ? $status : null; @@ -71,11 +75,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $statu $s->select('SELECT DISTINCT sku_code FROM ' . Schema::LINE . ' ORDER BY sku_code'), ); - $banner = ''; - if ($notice !== null && isset(self::NOTICES[$notice])) { - [$kind, $msg] = self::NOTICES[$notice]; - $banner = '
' . $this->e($msg) . '
'; - } + $banner = $this->notice($notice); $lines = []; foreach ($s->select('SELECT order_id, sku_code, qty, unit_price FROM ' . Schema::LINE . ' ORDER BY id') as $l) { @@ -107,7 +107,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $statu 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']) . '' + $html .= '' . $this->orderLink((string) $o['reference']) . '' . '' . $this->pill((string) $o['status']) . '' . '' . $this->e((string) ($o['customer_email'] ?? '—')) . '' . '' . implode(', ', $items) . '' @@ -120,6 +120,85 @@ public function render(string $csrf = '', ?string $notice = null, ?string $statu return $html . $this->placeForm($csrf); } + /** The order detail view (?order=REF): the order, its lines, and its timeline. */ + private function renderDetail(PluginStorage $s, string $ref, ?string $notice, string $csrf): string + { + $order = $s->selectOne('SELECT id, reference, status, customer_email, currency, total, placed_at FROM ' . Schema::ORDER . ' WHERE reference = :ref', ['ref' => $ref]); + + $html = '

Commerce

' . $this->notice($notice) + . '

← All orders

'; + + if ($order === null) { + return $html . '

Order ' . $this->e($ref) . '

' + . '

No order with that reference. Check the orders list.

'; + } + + $oid = (int) $order['id']; + $lines = $s->select('SELECT sku_code, qty, unit_price FROM ' . Schema::LINE . ' WHERE order_id = :oid ORDER BY id', ['oid' => $oid]); + $events = $s->select('SELECT status, actor, occurred_at FROM ' . Schema::EVENT . ' WHERE order_id = :oid ORDER BY id', ['oid' => $oid]); + + $html .= '

Order ' . $this->e((string) $order['reference']) . ' ' . $this->pill((string) $order['status']) . '

' + . '

' . $this->e((string) ($order['customer_email'] ?? '—')) . ' · ' + . $this->money((string) $order['total'], (string) $order['currency']) . ' · placed ' . $this->e((string) $order['placed_at']) . '

' + . '
' . $this->actions((string) $order['reference'], (string) $order['status'], $csrf) . '
'; + + // Lines + $html .= '

Lines

' + . ''; + foreach ($lines as $ln) { + $lineTotal = number_format((float) $ln['qty'] * (float) $ln['unit_price'], 2, '.', ''); + $html .= '' + . '' + . '' + . ''; + } + $html .= '
SKUQtyUnit priceLine total
' . $this->e((string) $ln['sku_code']) . '' . $this->e((string) $ln['qty']) . '' . $this->money((string) $ln['unit_price'], (string) $order['currency']) . '' . $this->money($lineTotal, (string) $order['currency']) . '
'; + + // Timeline + $html .= '

Timeline

'; + if ($events === []) { + $html .= '

No recorded events.

'; + } else { + $html .= '
' + . ''; + foreach ($events as $e) { + $html .= '' + . '' + . ''; + } + $html .= '
EventByWhen
' . $this->pill((string) $e['status']) . ' ' . $this->e($this->statusLabel((string) $e['status'])) . '' . $this->e((string) $e['actor']) . '' . $this->e((string) $e['occurred_at']) . '
'; + } + + return $html; + } + + private function notice(?string $notice): string + { + if ($notice === null || !isset(self::NOTICES[$notice])) { + return ''; + } + [$kind, $msg] = self::NOTICES[$notice]; + return '
' . $this->e($msg) . '
'; + } + + /** An order reference as a link to its detail view. */ + private function orderLink(string $ref): string + { + return '' . $this->e($ref) . ''; + } + + /** The human label for a lifecycle status in the timeline (the first event is the placement). */ + private function statusLabel(string $status): string + { + return match ($status) { + 'pending' => 'Placed', + 'paid' => 'Paid', + 'fulfilled' => 'Fulfilled', + 'cancelled' => 'Cancelled', + default => $status, + }; + } + /** A coloured status pill using theme tokens (dark-safe). */ private function pill(string $status): string { diff --git a/src/CommercePlugin.php b/src/CommercePlugin.php index c991ea6..9fb97bb 100644 --- a/src/CommercePlugin.php +++ b/src/CommercePlugin.php @@ -28,6 +28,7 @@ final class CommercePlugin implements Plugin public function register(PluginContext $context): void { $context->migrations()->register('001_orders', Schema::all()); + $context->migrations()->register('002_order_events', Schema::events()); $context->capabilities()->declare('Commerce', ['read', 'write']); $storage = static fn (): PluginStorage => $context->storage(); @@ -50,7 +51,7 @@ public function register(PluginContext $context): void 'commerce', 'Commerce', '🧾', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('status')), + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('status'), $r->query('order')), self::ID . ':write', ); $context->adminPages()->action('commerce', 'place', static function (Request $r) use ($orders): Response { @@ -73,7 +74,7 @@ public function register(PluginContext $context): void ]; $email = trim((string) ($r->input('customer_email') ?? '')) ?: null; try { - $orders->place([$line], $email, date('Y-m-d H:i:s')); + $orders->place([$line], $email, date('Y-m-d H:i:s'), 'admin-ui'); return Response::redirect('/admin/commerce?ok=placed'); } catch (\NimbusCMS\Inventory\InsufficientStock) { return Response::redirect('/admin/commerce?err=short'); @@ -90,9 +91,9 @@ public function register(PluginContext $context): void // 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')), + 'pay' => static fn (OrderBook $o, string $ref): array => $o->pay($ref, date('Y-m-d H:i:s'), 'admin-ui'), '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')), + 'cancel' => static fn (OrderBook $o, string $ref): array => $o->cancel($ref, date('Y-m-d H:i:s'), 'admin-ui'), ] as $action => $run) { $context->adminPages()->action('commerce', $action, static function (Request $r) use ($orders, $run, $action): Response { $ref = trim((string) ($r->input('reference') ?? '')); diff --git a/src/CommerceToolset.php b/src/CommerceToolset.php index 2f04adb..a6fe43c 100644 --- a/src/CommerceToolset.php +++ b/src/CommerceToolset.php @@ -68,7 +68,7 @@ protected function tools(): array */ private function place(array $a, TokenPrincipal $p, EntryOpContext $c): array { - return $this->guard(function () use ($a): array { + return $this->guard(function () use ($a, $p): array { /** @var list $lines */ $lines = []; foreach (is_array($a['lines'] ?? null) ? $a['lines'] : [] as $ln) { @@ -77,7 +77,7 @@ private function place(array $a, TokenPrincipal $p, EntryOpContext $c): array } } $email = isset($a['customer_email']) && is_string($a['customer_email']) ? $a['customer_email'] : null; - return ['ok' => true, 'order' => $this->orders->place($lines, $email, $this->now())]; + return ['ok' => true, 'order' => $this->orders->place($lines, $email, $this->now(), $p->name)]; }); } @@ -87,7 +87,7 @@ private function place(array $a, TokenPrincipal $p, EntryOpContext $c): array */ private function pay(array $a, TokenPrincipal $p, EntryOpContext $c): array { - return $this->guard(fn (): array => ['ok' => true, 'order' => $this->orders->pay($this->ref($a), $this->now())]); + return $this->guard(fn (): array => ['ok' => true, 'order' => $this->orders->pay($this->ref($a), $this->now(), $p->name)]); } /** @@ -105,7 +105,7 @@ private function fulfil(array $a, TokenPrincipal $p, EntryOpContext $c): array */ private function cancel(array $a, TokenPrincipal $p, EntryOpContext $c): array { - return $this->guard(fn (): array => ['ok' => true, 'order' => $this->orders->cancel($this->ref($a), $this->now())]); + return $this->guard(fn (): array => ['ok' => true, 'order' => $this->orders->cancel($this->ref($a), $this->now(), $p->name)]); } /** diff --git a/src/OrderBook.php b/src/OrderBook.php index 453e6a7..a3f17eb 100644 --- a/src/OrderBook.php +++ b/src/OrderBook.php @@ -62,7 +62,7 @@ private function stock(): ?ReservationPort * @throws \RuntimeException if no inventory plugin is installed * @throws \InvalidArgumentException on an empty order */ - public function place(array $lines, ?string $customerEmail, string $now): array + public function place(array $lines, ?string $customerEmail, string $now, string $actor = 'system'): array { $port = $this->stock(); if ($port === null) { @@ -73,7 +73,7 @@ public function place(array $lines, ?string $customerEmail, string $now): array } $ref = $this->newReference(); - $this->storage()->transaction(function () use ($lines, $customerEmail, $now, $ref, $port): void { + $this->storage()->transaction(function () use ($lines, $customerEmail, $now, $ref, $port, $actor): void { $s = $this->storage(); $oid = $s->insert( 'INSERT INTO ' . Schema::ORDER . ' (reference, status, customer_email, currency, total, placed_at, updated_at) @@ -97,6 +97,7 @@ public function place(array $lines, ?string $customerEmail, string $now): array 'UPDATE ' . Schema::ORDER . ' SET total = (SELECT COALESCE(SUM(qty * unit_price), 0) FROM ' . Schema::LINE . ' WHERE order_id = :oid) WHERE id = :oid2', ['oid' => $oid, 'oid2' => $oid], ); + $this->recordEvent($oid, self::PENDING, $actor, $now); }); $this->announce('placed', $ref); @@ -108,9 +109,16 @@ public function place(array $lines, ?string $customerEmail, string $now): array * * @return array */ - public function pay(string $ref, string $now): array + public function pay(string $ref, string $now, string $actor = 'system'): array { - $this->transition($ref, self::PENDING, self::PAID, $now); + $order = $this->requireOrder($ref); + if ($order['status'] !== self::PENDING) { + throw new IllegalTransition((string) $order['status'], self::PAID); + } + $this->storage()->transaction(function () use ($order, $actor, $now): void { + $this->setStatus((int) $order['id'], self::PAID, $now); + $this->recordEvent((int) $order['id'], self::PAID, $actor, $now); + }); $this->announce('paid', $ref); return $this->get($ref) ?? throw new \RuntimeException('Unknown order.'); } @@ -132,6 +140,7 @@ public function fulfil(string $ref, string $actor, string $now): array $this->stock()?->issue((string) $ln['sku_code'], (string) $ln['location'], (string) $ln['qty'], $this->lineRef($ref, (int) $ln['id']), $actor); } $this->setStatus((int) $order['id'], self::FULFILLED, $now); + $this->recordEvent((int) $order['id'], self::FULFILLED, $actor, $now); }); $this->announce('fulfilled', $ref); @@ -143,7 +152,7 @@ public function fulfil(string $ref, string $actor, string $now): array * * @return array */ - public function cancel(string $ref, string $now): array + public function cancel(string $ref, string $now, string $actor = 'system'): array { $order = $this->requireOrder($ref); if ($order['status'] === self::FULFILLED) { @@ -153,11 +162,12 @@ public function cancel(string $ref, string $now): array return $this->get($ref) ?? throw new \RuntimeException('Unknown order.'); } - $this->storage()->transaction(function () use ($order, $ref, $now): void { + $this->storage()->transaction(function () use ($order, $ref, $actor, $now): void { foreach ($this->linesOf((int) $order['id']) as $ln) { $this->stock()?->release($this->lineRef($ref, (int) $ln['id'])); } $this->setStatus((int) $order['id'], self::CANCELLED, $now); + $this->recordEvent((int) $order['id'], self::CANCELLED, $actor, $now); }); $this->announce('cancelled', $ref); @@ -208,18 +218,39 @@ private function requireOrder(string $ref): array return $order; } - private function transition(string $ref, string $from, string $to, string $now): void + private function setStatus(int $orderId, string $status, string $now): void { - $order = $this->requireOrder($ref); - if ($order['status'] !== $from) { - throw new IllegalTransition((string) $order['status'], $to); - } - $this->setStatus((int) $order['id'], $to, $now); + $this->storage()->execute('UPDATE ' . Schema::ORDER . ' SET status = :st, updated_at = :now WHERE id = :oid', ['st' => $status, 'now' => $now, 'oid' => $orderId]); } - private function setStatus(int $orderId, string $status, string $now): void + /** + * Append one row to the order's append-only event log (the timeline). Called + * inside each transition's transaction; `actor` is server-set by the caller + * (the admin action or the token principal), never from request input. + */ + private function recordEvent(int $orderId, string $status, string $actor, string $now): void { - $this->storage()->execute('UPDATE ' . Schema::ORDER . ' SET status = :st, updated_at = :now WHERE id = :oid', ['st' => $status, 'now' => $now, 'oid' => $orderId]); + $this->storage()->insert( + 'INSERT INTO ' . Schema::EVENT . ' (order_id, status, actor, occurred_at) VALUES (:o, :s, :a, :n)', + ['o' => $orderId, 's' => $status, 'a' => $actor, 'n' => $now], + ); + } + + /** + * The order's lifecycle timeline (append-only), oldest first. + * + * @return list> + */ + public function timeline(string $ref): array + { + $order = $this->storage()->selectOne('SELECT id FROM ' . Schema::ORDER . ' WHERE reference = :ref', ['ref' => $ref]); + if ($order === null) { + return []; + } + return $this->storage()->select( + 'SELECT status, actor, occurred_at FROM ' . Schema::EVENT . ' WHERE order_id = :oid ORDER BY id', + ['oid' => (int) $order['id']], + ); } private function lineRef(string $orderRef, int $lineId): string diff --git a/src/Schema.php b/src/Schema.php index 071396f..9c49e23 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -13,6 +13,7 @@ final class Schema { public const ORDER = 'commerce_order'; public const LINE = 'commerce_order_line'; + public const EVENT = 'commerce_order_event'; /** @return list each statement individually idempotent (ADR 0005) */ public static function all(): array @@ -42,4 +43,26 @@ public static function all(): array ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', ]; } + + /** + * The append-only order event log (Phase 2) — one row per lifecycle transition + * (placed/paid/fulfilled/cancelled) with the actor and time, so the order detail + * can show a real timeline. Never updated or deleted; the order's own status + * stays the authoritative current state. + * + * @return list + */ + public static function events(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::EVENT . ' ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(20) NOT NULL, + actor VARCHAR(120) NOT NULL, + occurred_at DATETIME NOT NULL, + INDEX idx_order (order_id, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } } diff --git a/tests/ChoreographyTest.php b/tests/ChoreographyTest.php index 1da0899..25f9ef3 100644 --- a/tests/ChoreographyTest.php +++ b/tests/ChoreographyTest.php @@ -40,10 +40,10 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all()] as $sql) { + foreach ([...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all(), ...CommerceSchema::events()] as $sql) { $db->execute($sql); } - foreach ([InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE] as $t) { + foreach ([InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE, CommerceSchema::EVENT] as $t) { $db->execute('TRUNCATE ' . $t); } diff --git a/tests/CommerceAdminActionsTest.php b/tests/CommerceAdminActionsTest.php index e7b887f..8f34c37 100644 --- a/tests/CommerceAdminActionsTest.php +++ b/tests/CommerceAdminActionsTest.php @@ -40,10 +40,10 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach ([...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all()] as $sql) { + foreach ([...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all(), ...CommerceSchema::events()] as $sql) { $this->db->execute($sql); } - foreach ([InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE] as $t) { + foreach ([InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE, CommerceSchema::EVENT] as $t) { $this->db->execute('TRUNCATE ' . $t); } diff --git a/tests/CommerceAdminTest.php b/tests/CommerceAdminTest.php index f130ac4..bc81684 100644 --- a/tests/CommerceAdminTest.php +++ b/tests/CommerceAdminTest.php @@ -30,17 +30,18 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::all() as $sql) { + foreach ([...Schema::all(), ...Schema::events()] as $sql) { $this->db->execute($sql); } $this->db->execute('TRUNCATE ' . Schema::ORDER); $this->db->execute('TRUNCATE ' . Schema::LINE); + $this->db->execute('TRUNCATE ' . Schema::EVENT); $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 + private function order(string $ref, string $status, string $currency, string $total, string $sku): int { $oid = $this->storage->insert( 'INSERT INTO ' . Schema::ORDER . ' (reference, status, customer_email, currency, total, placed_at, updated_at) @@ -51,6 +52,15 @@ private function order(string $ref, string $status, string $currency, string $to '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'], ); + return $oid; + } + + private function event(int $oid, string $status, string $when): void + { + $this->storage->insert( + 'INSERT INTO ' . Schema::EVENT . ' (order_id, status, actor, occurred_at) VALUES (:o, :s, :a, :n)', + ['o' => $oid, 's' => $status, 'a' => 'admin-ui', 'n' => $when], + ); } public function test_status_pills_use_theme_tokens_not_hard_coded_colours(): void @@ -114,4 +124,39 @@ public function test_the_place_form_suggests_previously_ordered_skus(): void self::assertStringContainsString('', $html); self::assertStringContainsString('