diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php index 4cfc9c3..e0738a4 100644 --- a/src/StorefrontCart.php +++ b/src/StorefrontCart.php @@ -25,7 +25,10 @@ final class StorefrontCart { public const COOKIE = 'nb_cart'; + /** A short-lived cookie naming the visitor's just-placed order, so only they see its confirmation. */ + public const ORDER_COOKIE = 'nb_order'; private const COOKIE_TTL = 14 * 86400; + private const ORDER_TTL = 3600; /** @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent */ public function __construct(private \Closure $cart) @@ -48,8 +51,73 @@ public function cartSection(Request $request): PageView ], ['title' => 'Your cart'], 200, true); } + /** The `/checkout` page — the order summary + a customer form. Private. */ + public function checkoutSection(Request $request): PageView + { + $port = ($this->cart)(); + $meta = $this->existing($request, $port); + $contents = ($port !== null && $meta !== null) ? $port->contents($meta['token']) : ['lines' => [], 'total' => '0.00', 'count' => 0]; + + return new PageView('shop-checkout', [ + 'cart' => $contents, + 'csrf' => $meta['csrf'] ?? '', + 'available' => $port !== null, + ], ['title' => 'Checkout'], 200, true); + } + + /** + * The `/order/{ref}` confirmation — shown **only** to the visitor who placed it + * (the `nb_order` cookie must name this ref), so an order ref can't be guessed + * to read someone else's confirmation. Any mismatch → the themed 404. Private. + */ + public function orderSection(Request $request): ?PageView + { + $ref = $this->refFromPath($request->path); + if ($ref === null || $request->cookie(self::ORDER_COOKIE) !== $ref) { + return null; + } + return new PageView('shop-order', ['ref' => $ref], ['title' => 'Order received'], 200, true); + } + // --- actions (POST /ext) -------------------------------------------- + /** + * POST checkout: place the cart as an order (server-side prices, atomic stock + * reservation) and confirm. Always CSRF-verified (a cart always pre-exists at + * checkout). On success, redirect to the private confirmation and drop the + * one-time order cookie; on any failure, back to the cart. + */ + public function checkout(Request $request): Response + { + $port = ($this->cart)(); + if ($port === null) { + return Response::redirect('/shop'); + } + $token = $request->cookie(self::COOKIE); + if ($token === null) { + return Response::redirect('/cart'); + } + $meta = $port->getOrCreate($token); + if ($meta['token'] !== $token || !$port->csrfOk($token, $request->input('_cart_csrf'))) { + return Response::redirect('/cart'); + } + + $customer = [ + 'name' => trim((string) ($request->input('name') ?? '')), + 'email' => trim((string) ($request->input('email') ?? '')), + ]; + try { + $ref = $port->checkout($token, $customer); + } catch (\InvalidArgumentException | \RuntimeException) { + // Empty cart, or stock that vanished between browsing and checkout — + // send them back to the cart rather than 500. + return Response::redirect('/cart'); + } + + return Response::redirect('/order/' . rawurlencode($ref)) + ->withCookie(self::ORDER_COOKIE, $ref, self::ORDER_TTL); + } + /** POST add: {sku, qty}. Mints the cart on first add (sets the cookie). */ public function add(Request $request): Response { @@ -120,4 +188,14 @@ public function existing(Request $request, ?CartPort $port): ?array $meta = $port->getOrCreate($token); return $meta['token'] === $token ? $meta : null; } + + /** The order reference from a `/order/{ref}` path, or null for the bare `/order`. */ + private function refFromPath(string $path): ?string + { + if (!str_starts_with($path, '/order/')) { + return null; + } + $ref = rawurldecode(explode('/', substr($path, strlen('/order/')), 2)[0]); + return trim($ref) === '' ? null : $ref; + } } diff --git a/src/StorefrontPlugin.php b/src/StorefrontPlugin.php index 607d055..35d72fb 100644 --- a/src/StorefrontPlugin.php +++ b/src/StorefrontPlugin.php @@ -46,11 +46,14 @@ public function register(PluginContext $context): void // The themed public sections (ADR 0023): the catalog at /shop, and the cart. $context->pages()->register('shop', new StorefrontResolver($port, $cartCsrf), $templates); $context->pages()->register('cart', $cart->cartSection(...), $templates); + $context->pages()->register('checkout', $cart->checkoutSection(...), $templates); + $context->pages()->register('order', $cart->orderSection(...), $templates); - // The cart mutations — public POST actions (ADR 0017), CSRF-guarded, that - // redirect back to /cart. + // The cart + checkout mutations — public POST actions (ADR 0017), + // CSRF-guarded, that redirect (POST-redirect-GET) to a private page. $context->routes()->post('shop', '/cart/add', static fn (Request $r, array $p): Response => $cart->add($r)); $context->routes()->post('shop', '/cart/update', static fn (Request $r, array $p): Response => $cart->update($r)); + $context->routes()->post('shop', '/checkout', static fn (Request $r, array $p): Response => $cart->checkout($r)); // Teach an agent what the storefront is (ADR 0013). $context->skills()->register('Storefront', Guide::text()); diff --git a/templates/shop-checkout.php b/templates/shop-checkout.php new file mode 100644 index 0000000..0bc823a --- /dev/null +++ b/templates/shop-checkout.php @@ -0,0 +1,42 @@ +>,total:string,count:int} $cart + * @var string $csrf + * @var bool $available + */ +?> +
+

Checkout

+ + +

Your cart is empty. Browse the shop.

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

Payment is arranged after you place your order.

+
+ +
diff --git a/templates/shop-order.php b/templates/shop-order.php new file mode 100644 index 0000000..d078c48 --- /dev/null +++ b/templates/shop-order.php @@ -0,0 +1,15 @@ + +
+

Order received

+

Thank you — your order has been placed.

+

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

+

Continue shopping

+
diff --git a/tests/StorefrontCartTest.php b/tests/StorefrontCartTest.php index ad4d977..91c484d 100644 --- a/tests/StorefrontCartTest.php +++ b/tests/StorefrontCartTest.php @@ -84,6 +84,50 @@ public function test_without_commerce_a_mutation_just_redirects(): void self::assertSame(302, $res->status); self::assertSame('/shop', $res->headers['Location'] ?? null); } + + public function test_the_checkout_page_is_private(): void + { + $view = $this->cart->checkoutSection($this->request('GET')); + self::assertSame('shop-checkout', $view->template); + self::assertTrue($view->private); + } + + public function test_checkout_requires_csrf_and_confirms_to_a_private_gated_order(): void + { + $token = $this->port->seed('sec'); + + // Wrong CSRF → back to cart, no order. + $bad = $this->cart->checkout($this->request('POST', ['name' => 'Sam', 'email' => 'sam@x.test', '_cart_csrf' => 'nope'], $token)); + self::assertSame('/cart', $bad->headers['Location'] ?? null); + self::assertSame([], $this->port->checkedOut); + + // Correct CSRF → order placed, redirect to the confirmation, order cookie set. + $ok = $this->cart->checkout($this->request('POST', ['name' => 'Sam', 'email' => 'sam@x.test', '_cart_csrf' => 'sec'], $token)); + self::assertSame('/order/ORD-TEST', $ok->headers['Location'] ?? null); + self::assertStringContainsString(StorefrontCart::ORDER_COOKIE . '=ORD-TEST', $ok->headers['Set-Cookie'] ?? ''); + self::assertCount(1, $this->port->checkedOut); + self::assertSame('sam@x.test', $this->port->checkedOut[0]['email']); + } + + public function test_checkout_without_a_cart_cookie_redirects_to_cart(): void + { + $res = $this->cart->checkout($this->request('POST', ['name' => 'Sam', 'email' => 'a@b.test'])); + self::assertSame('/cart', $res->headers['Location'] ?? null); + self::assertSame([], $this->port->checkedOut); + } + + public function test_the_order_confirmation_is_gated_to_the_order_cookie(): void + { + // With the matching order cookie → the confirmation renders (private). + $seen = $this->cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST'])); + self::assertInstanceOf(PageView::class, $seen); + self::assertTrue($seen->private); + self::assertSame('ORD-TEST', $seen->data['ref']); + + // Without the cookie (a guesser), or a mismatched ref → the themed 404. + 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']))); + } } /** A minimal CartPort double that records what the storefront asked it to do. */ @@ -97,6 +141,8 @@ final class FakeCartPort implements CartPort public array $added = []; /** @var list */ public array $setQ = []; + /** @var list */ + public array $checkedOut = []; /** Seed a pre-existing cart, returning its token. */ public function seed(string $csrf): string @@ -143,6 +189,7 @@ public function contents(string $token): array public function checkout(string $token, array $customer): string { + $this->checkedOut[] = $customer; return 'ORD-TEST'; } }