From 967b5543cee9d2d02ffb7fec7c0d40d5fda55a11 Mon Sep 17 00:00:00 2001 From: DanMat Date: Thu, 3 Sep 2026 13:35:11 -0400 Subject: [PATCH 1/2] feat: itemised order confirmation via OrderReadPort (ADR 0026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /order page (already IDOR-gated by the nb_order cookie) now shows what was bought, not just the reference. StorefrontCart reads Commerce's public-safe OrderReadPort and resolves each line's SKU to a display name via CatalogReadPort (active → name, else the SKU itself). Null-safe: no OrderReadPort (Commerce absent) → today's ref-only view, never a 500. The page stays private (no-store). Reviewed via both skills, security-green. Tests: itemised summary with name resolution + inactive-SKU fallback; ref-only fallback without Commerce. Co-Authored-By: Claude Opus 4.8 --- src/StorefrontCart.php | 57 +++++++++++++++++++++++++++++++--- src/StorefrontPlugin.php | 9 ++++-- templates/shop-order.php | 18 +++++++++++ tests/StorefrontCartTest.php | 60 ++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php index 148994d..aa7d2c4 100644 --- a/src/StorefrontCart.php +++ b/src/StorefrontCart.php @@ -8,6 +8,8 @@ use Nimbus\Http\Response; use Nimbus\Site\PageView; use NimbusCMS\Commerce\CartPort; +use NimbusCMS\Commerce\OrderReadPort; +use NimbusCMS\Inventory\CatalogReadPort; /** * The storefront's public cart + checkout face (ADR 0026). It owns the themed @@ -37,9 +39,16 @@ final class StorefrontCart /** The only notice codes a template will render — anything else is ignored (no reflection). */ private const NOTICES = ['unavailable', 'expired', 'empty', 'stock']; - /** @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent */ - public function __construct(private \Closure $cart) - { + /** + * @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent + * @param ?\Closure():?OrderReadPort $orderRead public-safe order read for the confirmation (ADR 0026); null → ref-only view + * @param ?\Closure():?CatalogReadPort $catalog resolves a line's SKU to a display name; null → the SKU itself + */ + public function __construct( + private \Closure $cart, + private ?\Closure $orderRead = null, + private ?\Closure $catalog = null, + ) { } // --- render (GET sections) ------------------------------------------ @@ -86,7 +95,47 @@ public function orderSection(Request $request): ?PageView if ($ref === null || $request->cookie(self::ORDER_COOKIE) !== $ref) { return null; } - return new PageView('shop-order', ['ref' => $ref], ['title' => 'Order received'], 200, true); + // The itemised receipt, or null → the theme falls back to the ref-only view. + return new PageView('shop-order', [ + 'ref' => $ref, + 'order' => $this->orderSummary($ref), + ], ['title' => 'Order received'], 200, true); + } + + /** + * The visitor's just-placed order, projected public-safe (no PII) by Commerce's + * {@see OrderReadPort} and enriched with each line's display name via the catalog. + * Null when Commerce is absent or the order can't be read — the confirmation then + * shows the reference alone. Only reached after the ORDER_COOKIE gate above. + * + * @return array{status:string,total:string,lines:list}|null + */ + private function orderSummary(string $ref): ?array + { + $port = $this->orderRead !== null ? ($this->orderRead)() : null; + if ($port === null) { + return null; + } + $order = $port->get($ref); + if ($order === null) { + return null; + } + $catalog = $this->catalog !== null ? ($this->catalog)() : null; + + $lines = []; + foreach ($order['lines'] as $line) { + // Active item → its name; an inactive/deleted SKU → the SKU itself (never blank/500). + $item = $catalog?->get($line['sku_code']); + $name = is_array($item) && is_string($item['name'] ?? null) ? $item['name'] : $line['sku_code']; + $lines[] = [ + 'name' => $name, + 'sku_code' => $line['sku_code'], + 'qty' => $line['qty'], + 'unit_price' => $line['unit_price'], + 'line_total' => $line['line_total'], + ]; + } + return ['status' => $order['status'], 'total' => $order['total'], 'lines' => $lines]; } // --- actions (POST /ext) -------------------------------------------- diff --git a/src/StorefrontPlugin.php b/src/StorefrontPlugin.php index d96ef11..5eecbc2 100644 --- a/src/StorefrontPlugin.php +++ b/src/StorefrontPlugin.php @@ -9,6 +9,7 @@ use Nimbus\Plugin\Plugin; use Nimbus\Plugin\PluginContext; use NimbusCMS\Commerce\CartPort; +use NimbusCMS\Commerce\OrderReadPort; use NimbusCMS\Inventory\CatalogReadPort; /** @@ -36,8 +37,12 @@ public function register(PluginContext $context): void // The cart, driven through Commerce's CartPort (ADR 0026) — null when // Commerce is absent, so a browse-only storefront still works. - $cartPort = static fn (): ?CartPort => $context->services()->get(CartPort::class); - $cart = new StorefrontCart($cartPort); + $cartPort = static fn (): ?CartPort => $context->services()->get(CartPort::class); + // The public-safe order read (ADR 0026) for the itemised confirmation, and + // the catalog port to resolve each line's SKU to a display name. Both + // resolved lazily; null-safe when their plugin is absent. + $orderRead = static fn (): ?OrderReadPort => $context->services()->get(OrderReadPort::class); + $cart = new StorefrontCart($cartPort, $orderRead, $port); $templates = dirname(__DIR__) . '/templates'; // The current cart's CSRF token, for add-to-cart forms on the shop pages. diff --git a/templates/shop-order.php b/templates/shop-order.php index d078c48..6810273 100644 --- a/templates/shop-order.php +++ b/templates/shop-order.php @@ -5,11 +5,29 @@ * * @var callable(?string):string $e * @var string $ref + * @var array{status:string,total:string,lines:list}|null $order */ +$order = $order ?? null; ?>

Order received

Thank you — your order has been placed.

+ + + + + + + + + + + + + + +
×
Total
+

We'll be in touch to arrange payment and delivery.

Continue shopping

diff --git a/tests/StorefrontCartTest.php b/tests/StorefrontCartTest.php index b7976d5..4cc4606 100644 --- a/tests/StorefrontCartTest.php +++ b/tests/StorefrontCartTest.php @@ -7,6 +7,8 @@ use Nimbus\Http\Request; use Nimbus\Site\PageView; use NimbusCMS\Commerce\CartPort; +use NimbusCMS\Commerce\OrderReadPort; +use NimbusCMS\Inventory\CatalogReadPort; use NimbusCMS\Storefront\StorefrontCart; use PHPUnit\Framework\TestCase; @@ -204,6 +206,64 @@ public function test_the_order_confirmation_is_gated_to_the_order_cookie(): void self::assertNull($this->cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', []))); self::assertNull($this->cart->orderSection(new Request('GET', '/order/OTHER', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST']))); } + + public function test_the_confirmation_is_itemised_with_resolved_names_and_a_sku_fallback(): void + { + $orderPort = new class () implements OrderReadPort { + public function get(string $ref): ?array + { + return $ref !== 'ORD-TEST' ? null : [ + 'reference' => 'ORD-TEST', 'status' => 'placed', 'total' => '3.80', 'placed_at' => '2026-01-01 09:00:00', + 'lines' => [ + ['sku_code' => 'avocado', 'qty' => 2, 'unit_price' => '0.90', 'line_total' => '1.80'], + ['sku_code' => 'discontinued', 'qty' => 1, 'unit_price' => '2.00', 'line_total' => '2.00'], + ], + ]; + } + }; + $catalog = new class () implements CatalogReadPort { + public function list(array $filters): array + { + return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 0, 'pages' => 0]; + } + + public function get(string $sku): ?array + { + // 'avocado' is active → a name; 'discontinued' is gone → null. + return $sku !== 'avocado' ? null : [ + 'sku_code' => 'avocado', 'name' => 'Avocado', 'price' => '0.90', 'unit' => 'each', + 'description' => null, 'image_media_id' => null, 'category_id' => null, + 'category' => null, 'featured' => false, 'availability' => 'in_stock', + ]; + } + + public function categories(): array + { + return []; + } + }; + + $port = $this->port; + $cart = new StorefrontCart(static fn (): CartPort => $port, static fn (): OrderReadPort => $orderPort, static fn (): CatalogReadPort => $catalog); + + $view = $cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST'])); + self::assertInstanceOf(PageView::class, $view); + $order = $view->data['order']; + self::assertSame('3.80', $order['total']); + self::assertSame('placed', $order['status']); + self::assertSame('Avocado', $order['lines'][0]['name'], 'an active SKU resolves to its name'); + self::assertSame('discontinued', $order['lines'][1]['name'], 'a gone SKU falls back to the SKU itself'); + self::assertSame('1.80', $order['lines'][0]['line_total']); + } + + public function test_the_confirmation_falls_back_to_ref_only_without_commerce(): void + { + // No order-read port wired → order is null, the page still renders. + $view = $this->cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST'])); + self::assertInstanceOf(PageView::class, $view); + self::assertSame('ORD-TEST', $view->data['ref']); + self::assertNull($view->data['order']); + } } /** A minimal CartPort double that records what the storefront asked it to do. */ From d31542efc0891d5cd26386cef9768e2ab2496bef Mon Sep 17 00:00:00 2001 From: DanMat Date: Thu, 3 Sep 2026 13:37:10 -0400 Subject: [PATCH 2/2] fix: drop redundant name guards (CatalogReadPort name is a typed non-null string) Co-Authored-By: Claude Opus 4.8 --- src/StorefrontCart.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php index aa7d2c4..5dbd9ac 100644 --- a/src/StorefrontCart.php +++ b/src/StorefrontCart.php @@ -126,7 +126,7 @@ private function orderSummary(string $ref): ?array foreach ($order['lines'] as $line) { // Active item → its name; an inactive/deleted SKU → the SKU itself (never blank/500). $item = $catalog?->get($line['sku_code']); - $name = is_array($item) && is_string($item['name'] ?? null) ? $item['name'] : $line['sku_code']; + $name = is_array($item) ? $item['name'] : $line['sku_code']; $lines[] = [ 'name' => $name, 'sku_code' => $line['sku_code'],