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
78 changes: 78 additions & 0 deletions src/StorefrontCart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
{
Expand Down Expand Up @@ -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;
}
}
7 changes: 5 additions & 2 deletions src/StorefrontPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
42 changes: 42 additions & 0 deletions templates/shop-checkout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
/**
* Default checkout page (ADR 0026). Private (no-store). The order summary + a
* customer form; the form carries the per-cart CSRF token. Every value escaped.
*
* @var callable(?string):string $e
* @var array{lines:list<array<string,mixed>>,total:string,count:int} $cart
* @var string $csrf
* @var bool $available
*/
?>
<div class="sf-wrap">
<h1>Checkout</h1>

<?php if (!$available || $cart['count'] === 0): ?>
<p class="sf-empty">Your cart is empty. <a href="/shop">Browse the shop</a>.</p>
<?php else: ?>
<div class="sf-summary">
<?php foreach ($cart['lines'] as $line): ?>
<div class="sf-summary-row">
<span><?= $e((string) $line['qty']) ?>× <?= $e($line['name']) ?></span>
<span><?= $e($line['line_total']) ?></span>
</div>
<?php endforeach; ?>
<div class="sf-summary-row sf-summary-total"><strong>Total</strong> <strong><?= $e($cart['total']) ?></strong></div>
</div>

<form class="sf-checkout" method="post" action="/ext/shop/checkout">
<input type="hidden" name="_cart_csrf" value="<?= $e($csrf) ?>">
<div class="sf-field">
<label for="co-name">Name</label>
<input id="co-name" type="text" name="name" required maxlength="120" autocomplete="name">
</div>
<div class="sf-field">
<label for="co-email">Email</label>
<input id="co-email" type="email" name="email" required maxlength="191" autocomplete="email">
</div>
<button type="submit" class="sf-btn sf-btn-primary">Place order</button>
<p class="sf-muted">Payment is arranged after you place your order.</p>
</form>
<?php endif; ?>
</div>
15 changes: 15 additions & 0 deletions templates/shop-order.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
/**
* Default order-confirmation page (ADR 0026). Private (no-store); shown only to
* the visitor who placed the order (gated by the order cookie). Every value escaped.
*
* @var callable(?string):string $e
* @var string $ref
*/
?>
<div class="sf-wrap sf-order">
<h1>Order received</h1>
<p>Thank you — your order <strong><?= $e($ref) ?></strong> has been placed.</p>
<p class="sf-muted">We'll be in touch to arrange payment and delivery.</p>
<p><a class="sf-btn" href="/shop">Continue shopping</a></p>
</div>
47 changes: 47 additions & 0 deletions tests/StorefrontCartTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -97,6 +141,8 @@ final class FakeCartPort implements CartPort
public array $added = [];
/** @var list<array{0:string,1:string}> */
public array $setQ = [];
/** @var list<array{name?:string,email?:string}> */
public array $checkedOut = [];

/** Seed a pre-existing cart, returning its token. */
public function seed(string $csrf): string
Expand Down Expand Up @@ -143,6 +189,7 @@ public function contents(string $token): array

public function checkout(string $token, array $customer): string
{
$this->checkedOut[] = $customer;
return 'ORD-TEST';
}
}
Loading