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
'
+ . '| SKU | Qty | Unit price | Line total |
';
+ foreach ($lines as $ln) {
+ $lineTotal = number_format((float) $ln['qty'] * (float) $ln['unit_price'], 2, '.', '');
+ $html .= '' . $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']) . ' |
';
+ }
+ $html .= '
';
+
+ // Timeline
+ $html .= 'Timeline
';
+ if ($events === []) {
+ $html .= 'No recorded events.
';
+ } else {
+ $html .= ''
+ . '| Event | By | When |
';
+ foreach ($events as $e) {
+ $html .= '| ' . $this->pill((string) $e['status']) . ' ' . $this->e($this->statusLabel((string) $e['status'])) . ' | '
+ . '' . $this->e((string) $e['actor']) . ' | '
+ . '' . $this->e((string) $e['occurred_at']) . ' |
';
+ }
+ $html .= '
';
+ }
+
+ 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('