Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 86 additions & 7 deletions src/CommerceAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = '<div class="nb-notice nb-notice-' . ($kind === 'ok' ? 'ok' : 'error') . '">' . $this->e($msg) . '</div>';
}
$banner = $this->notice($notice);

$lines = [];
foreach ($s->select('SELECT order_id, sku_code, qty, unit_price FROM ' . Schema::LINE . ' ORDER BY id') as $l) {
Expand Down Expand Up @@ -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']) . ' × <code>' . $this->e((string) $ln['sku_code']) . '</code>';
}
$html .= '<tr><td data-label="Order"><code>' . $this->e((string) $o['reference']) . '</code></td>'
$html .= '<tr><td data-label="Order">' . $this->orderLink((string) $o['reference']) . '</td>'
. '<td data-label="Status">' . $this->pill((string) $o['status']) . '</td>'
. '<td data-label="Customer">' . $this->e((string) ($o['customer_email'] ?? '—')) . '</td>'
. '<td data-label="Items" class="nb-muted">' . implode(', ', $items) . '</td>'
Expand All @@ -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 = '<div class="nb-page-head"><h1>Commerce</h1></div>' . $this->notice($notice)
. '<p style="margin:-8px 0 16px"><a href="/admin/commerce">&larr; All orders</a></p>';

if ($order === null) {
return $html . '<h2 style="margin-top:0">Order <code>' . $this->e($ref) . '</code></h2>'
. '<p class="nb-muted">No order with that reference. Check the <a href="/admin/commerce">orders list</a>.</p>';
}

$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 .= '<h2 style="margin-top:0">Order <code>' . $this->e((string) $order['reference']) . '</code> ' . $this->pill((string) $order['status']) . '</h2>'
. '<p class="nb-muted">' . $this->e((string) ($order['customer_email'] ?? '—')) . ' · '
. $this->money((string) $order['total'], (string) $order['currency']) . ' · placed ' . $this->e((string) $order['placed_at']) . '</p>'
. '<div style="margin:1rem 0">' . $this->actions((string) $order['reference'], (string) $order['status'], $csrf) . '</div>';

// Lines
$html .= '<h3>Lines</h3><div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th>SKU</th><th style="text-align:right">Qty</th><th style="text-align:right">Unit price</th><th style="text-align:right">Line total</th></tr></thead><tbody>';
foreach ($lines as $ln) {
$lineTotal = number_format((float) $ln['qty'] * (float) $ln['unit_price'], 2, '.', '');
$html .= '<tr><td data-label="SKU"><code>' . $this->e((string) $ln['sku_code']) . '</code></td>'
. '<td data-label="Qty" style="text-align:right">' . $this->e((string) $ln['qty']) . '</td>'
. '<td data-label="Unit price" style="text-align:right">' . $this->money((string) $ln['unit_price'], (string) $order['currency']) . '</td>'
. '<td data-label="Line total" style="text-align:right">' . $this->money($lineTotal, (string) $order['currency']) . '</td></tr>';
}
$html .= '</tbody></table></div>';

// Timeline
$html .= '<h3 style="margin-top:1.5rem">Timeline</h3>';
if ($events === []) {
$html .= '<p class="nb-muted">No recorded events.</p>';
} else {
$html .= '<div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th>Event</th><th>By</th><th>When</th></tr></thead><tbody>';
foreach ($events as $e) {
$html .= '<tr><td data-label="Event">' . $this->pill((string) $e['status']) . ' ' . $this->e($this->statusLabel((string) $e['status'])) . '</td>'
. '<td data-label="By">' . $this->e((string) $e['actor']) . '</td>'
. '<td data-label="When" class="nb-muted">' . $this->e((string) $e['occurred_at']) . '</td></tr>';
}
$html .= '</tbody></table></div>';
}

return $html;
}

private function notice(?string $notice): string
{
if ($notice === null || !isset(self::NOTICES[$notice])) {
return '';
}
[$kind, $msg] = self::NOTICES[$notice];
return '<div class="nb-notice nb-notice-' . ($kind === 'ok' ? 'ok' : 'error') . '">' . $this->e($msg) . '</div>';
}

/** An order reference as a link to its detail view. */
private function orderLink(string $ref): string
{
return '<a href="/admin/commerce?order=' . $this->e(rawurlencode($ref)) . '"><code>' . $this->e($ref) . '</code></a>';
}

/** 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
{
Expand Down
9 changes: 5 additions & 4 deletions src/CommercePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 {
Expand All @@ -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');
Expand All @@ -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') ?? ''));
Expand Down
8 changes: 4 additions & 4 deletions src/CommerceToolset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{sku:string,location?:string,qty:string,unit_price?:string}> $lines */
$lines = [];
foreach (is_array($a['lines'] ?? null) ? $a['lines'] : [] as $ln) {
Expand All @@ -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)];
});
}

Expand All @@ -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)]);
}

/**
Expand All @@ -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)]);
}

/**
Expand Down
59 changes: 45 additions & 14 deletions src/OrderBook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -108,9 +109,16 @@ public function place(array $lines, ?string $customerEmail, string $now): array
*
* @return array<string,mixed>
*/
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.');
}
Expand All @@ -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);
Expand All @@ -143,7 +152,7 @@ public function fulfil(string $ref, string $actor, string $now): array
*
* @return array<string,mixed>
*/
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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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<array<string,mixed>>
*/
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
Expand Down
23 changes: 23 additions & 0 deletions src/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> each statement individually idempotent (ADR 0005) */
public static function all(): array
Expand Down Expand Up @@ -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<string>
*/
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',
];
}
}
Loading
Loading