From 58c37d0421a6fdc087798e76106e63179418a1b6 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 30 Aug 2026 23:01:56 -0400 Subject: [PATCH] feat: public cart domain + CartPort (ADR 0026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commerce gains the public shopping cart: commerce_cart (opaque-random cart_token PK + per-cart csrf secret) + commerce_cart_line storing ONLY {sku, qty} — never a price. A published CartPort (ADR 0019) the Storefront drives: getOrCreate/add/setQty/remove/contents/checkout. Security controls (ADR 0026): price + totals resolved SERVER-SIDE from the Inventory item (CatalogReadPort) at render and checkout — the client sends sku+qty only; add accepts only ACTIVE items + bounded whole quantities + a line cap; the cart token is server-random (a client can't choose it); csrf verified constant-time; checkout places via OrderBook (atomic stock reservation) then clears the cart. Abandoned carts GC'd by a maintenance task. PHPStan max, 37 tests, cs-fixer green. Co-Authored-By: Claude Opus 4.8 --- src/Cart.php | 240 +++++++++++++++++++++++++++++++ src/CartAdapter.php | 57 ++++++++ src/CartPort.php | 55 +++++++ src/CommercePlugin.php | 13 ++ src/Schema.php | 35 +++++ tests/CartTest.php | 177 +++++++++++++++++++++++ tests/PackageIntegrationTest.php | 2 +- 7 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 src/Cart.php create mode 100644 src/CartAdapter.php create mode 100644 src/CartPort.php create mode 100644 tests/CartTest.php diff --git a/src/Cart.php b/src/Cart.php new file mode 100644 index 0000000..85d2968 --- /dev/null +++ b/src/Cart.php @@ -0,0 +1,240 @@ +storage()->selectOne('SELECT cart_token, csrf FROM ' . Schema::CART . ' WHERE cart_token = :t', ['t' => $token]); + if ($row !== null) { + return ['token' => (string) $row['cart_token'], 'csrf' => (string) $row['csrf']]; + } + } + $new = bin2hex(random_bytes(32)); + $csrf = bin2hex(random_bytes(32)); + $this->storage()->execute( + 'INSERT INTO ' . Schema::CART . ' (cart_token, csrf, created_at, updated_at) VALUES (:t, :c, :now, :now2)', + ['t' => $new, 'c' => $csrf, 'now' => $now, 'now2' => $now], + ); + return ['token' => $new, 'csrf' => $csrf]; + } + + /** Constant-time check that `$submitted` matches the cart's CSRF secret. */ + public function csrfOk(string $token, ?string $submitted): bool + { + if (!is_string($submitted) || $submitted === '') { + return false; + } + $row = $this->storage()->selectOne('SELECT csrf FROM ' . Schema::CART . ' WHERE cart_token = :t', ['t' => $token]); + return $row !== null && hash_equals((string) $row['csrf'], $submitted); + } + + /** + * Add `$qty` of an **active** SKU to the cart (incrementing an existing line). + * Rejects an unknown/inactive SKU, a non-positive/over-cap/non-integer qty, and + * a cart already at its line cap. + */ + public function add(string $token, string $sku, string $qty, string $now): void + { + $sku = trim($sku); + $n = $this->qty($qty); + if (($this->catalog())?->get($sku) === null) { + throw new \InvalidArgumentException('That product is not available.'); + } + + $s = $this->storage(); + $existing = $s->selectOne('SELECT qty FROM ' . Schema::CART_LINE . ' WHERE cart_token = :t AND sku_code = :sku', ['t' => $token, 'sku' => $sku]); + if ($existing === null) { + $count = $s->selectOne('SELECT COUNT(*) AS n FROM ' . Schema::CART_LINE . ' WHERE cart_token = :t', ['t' => $token]); + if ($count !== null && (int) $count['n'] >= self::MAX_LINES) { + throw new \InvalidArgumentException('Your cart is full.'); + } + $s->execute('INSERT INTO ' . Schema::CART_LINE . ' (cart_token, sku_code, qty, added_at) VALUES (:t, :sku, :qty, :now)', ['t' => $token, 'sku' => $sku, 'qty' => $n, 'now' => $now]); + } else { + $s->execute('UPDATE ' . Schema::CART_LINE . ' SET qty = :qty WHERE cart_token = :t AND sku_code = :sku', ['qty' => min(self::MAX_QTY, (int) $existing['qty'] + $n), 't' => $token, 'sku' => $sku]); + } + $this->touch($token, $now); + } + + /** Set the exact quantity of a line; 0 removes it. */ + public function setQty(string $token, string $sku, string $qty, string $now): void + { + $sku = trim($sku); + $raw = trim($qty); + if ($raw === '0') { + $this->remove($token, $sku, $now); + return; + } + $n = $this->qty($qty); + $this->storage()->execute('UPDATE ' . Schema::CART_LINE . ' SET qty = :qty WHERE cart_token = :t AND sku_code = :sku', ['qty' => $n, 't' => $token, 'sku' => $sku]); + $this->touch($token, $now); + } + + public function remove(string $token, string $sku, string $now): void + { + $this->storage()->execute('DELETE FROM ' . Schema::CART_LINE . ' WHERE cart_token = :t AND sku_code = :sku', ['t' => $token, 'sku' => trim($sku)]); + $this->touch($token, $now); + } + + /** + * The cart's contents, priced **live** from the Inventory item — a line whose + * item is no longer active/available is dropped (it can't be bought). Prices + * and totals are server-computed; nothing here is client-influenced. + * + * @return array{lines:list,total:string,count:int} + */ + public function contents(string $token): array + { + $rows = $this->storage()->select('SELECT sku_code, qty FROM ' . Schema::CART_LINE . ' WHERE cart_token = :t ORDER BY added_at', ['t' => $token]); + $catalog = $this->catalog(); + $lines = []; + $total = 0.0; + foreach ($rows as $r) { + $sku = (string) $r['sku_code']; + $item = $catalog?->get($sku); + if ($item === null) { + continue; // inactive/removed since added — not purchasable + } + $qty = (int) $r['qty']; + $price = (string) $item['price']; + $lineTotal = number_format((float) $price * $qty, 2, '.', ''); + $total += (float) $lineTotal; + $lines[] = [ + 'sku_code' => $sku, + 'name' => (string) $item['name'], + 'unit' => $item['unit'] === null ? null : (string) $item['unit'], + 'qty' => $qty, + 'unit_price' => $price, + 'line_total' => $lineTotal, + 'availability' => (string) $item['availability'], + ]; + } + return ['lines' => $lines, 'total' => number_format($total, 2, '.', ''), 'count' => count($lines)]; + } + + /** + * Place the cart as an order and clear it. Prices are resolved server-side from + * the item (never the client), stock is reserved atomically by {@see OrderBook} + * (a failed reserve rolls the whole order back), and only then is the cart + * cleared. Returns the order reference. + * + * @param array{name?:string,email?:string} $customer + * @throws \InvalidArgumentException when the cart is empty (nothing purchasable) + */ + public function checkout(string $token, array $customer, string $now): string + { + $contents = $this->contents($token); + if ($contents['lines'] === []) { + throw new \InvalidArgumentException('Your cart is empty.'); + } + $lines = array_map( + static fn (array $l): array => ['sku' => $l['sku_code'], 'qty' => (string) $l['qty'], 'unit_price' => $l['unit_price']], + $contents['lines'], + ); + $email = isset($customer['email']) && filter_var($customer['email'], FILTER_VALIDATE_EMAIL) !== false ? (string) $customer['email'] : null; + + $order = $this->orders->place($lines, $email, $now, 'storefront'); + $this->clear($token, $now); + return (string) $order['reference']; + } + + /** Empty a cart (keeps the cart row + token). */ + public function clear(string $token, string $now): void + { + $this->storage()->execute('DELETE FROM ' . Schema::CART_LINE . ' WHERE cart_token = :t', ['t' => $token]); + $this->touch($token, $now); + } + + /** + * Remove carts untouched for {@see TTL_DAYS} (the GC maintenance task). + * + * @return int carts removed + */ + public function gc(string $now): int + { + $cutoff = date('Y-m-d H:i:s', (int) strtotime($now) - self::TTL_DAYS * 86400); + $s = $this->storage(); + $old = $s->select('SELECT cart_token FROM ' . Schema::CART . ' WHERE updated_at < :cut', ['cut' => $cutoff]); + foreach ($old as $c) { + $s->execute('DELETE FROM ' . Schema::CART_LINE . ' WHERE cart_token = :t', ['t' => (string) $c['cart_token']]); + } + return $s->execute('DELETE FROM ' . Schema::CART . ' WHERE updated_at < :cut', ['cut' => $cutoff]); + } + + // --- internals ------------------------------------------------------- + + /** A positive integer quantity within [1, MAX_QTY]; rejects 0/neg/decimal/non-numeric. */ + private function qty(string $qty): int + { + $q = trim($qty); + if (preg_match('/^\d+$/', $q) !== 1) { + throw new \InvalidArgumentException('Enter a whole quantity.'); + } + $n = (int) $q; + if ($n < 1 || $n > self::MAX_QTY) { + throw new \InvalidArgumentException('Choose a quantity between 1 and ' . self::MAX_QTY . '.'); + } + return $n; + } + + private function touch(string $token, string $now): void + { + $this->storage()->execute('UPDATE ' . Schema::CART . ' SET updated_at = :now WHERE cart_token = :t', ['now' => $now, 't' => $token]); + } + + private function catalog(): ?CatalogReadPort + { + return ($this->catalog)(); + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/CartAdapter.php b/src/CartAdapter.php new file mode 100644 index 0000000..22353dd --- /dev/null +++ b/src/CartAdapter.php @@ -0,0 +1,57 @@ +cart->getOrCreate($token, $this->now()); + } + + public function csrfOk(string $token, ?string $submitted): bool + { + return $this->cart->csrfOk($token, $submitted); + } + + public function add(string $token, string $sku, string $qty): void + { + $this->cart->add($token, $sku, $qty, $this->now()); + } + + public function setQty(string $token, string $sku, string $qty): void + { + $this->cart->setQty($token, $sku, $qty, $this->now()); + } + + public function remove(string $token, string $sku): void + { + $this->cart->remove($token, $sku, $this->now()); + } + + public function contents(string $token): array + { + return $this->cart->contents($token); + } + + public function checkout(string $token, array $customer): string + { + return $this->cart->checkout($token, $customer, $this->now()); + } +} diff --git a/src/CartPort.php b/src/CartPort.php new file mode 100644 index 0000000..5f103e2 --- /dev/null +++ b/src/CartPort.php @@ -0,0 +1,55 @@ +services()->get(CartPort::class)` + * (null when Commerce is absent → the storefront hides the cart), and never + * touches Commerce's tables. + * + * Authorisation is the opaque `cart_token` (the cookie) alone; every method takes + * it. Prices are resolved server-side — the caller passes a SKU and a quantity, + * never a price. State-changing calls are guarded by the per-cart CSRF secret + * ({@see csrfOk}). + */ +interface CartPort +{ + /** + * Resolve the cart for a client token or mint a fresh one (a client can't + * choose its own token). Returns the authoritative token — set it as the + * cookie — and the cart's CSRF secret to render into forms. + * + * @return array{token:string,csrf:string} + */ + public function getOrCreate(?string $token): array; + + /** Constant-time check that a submitted CSRF token matches the cart's secret. */ + public function csrfOk(string $token, ?string $submitted): bool; + + /** Add a quantity of an active SKU (validated; rejects unknown/inactive/bad-qty). */ + public function add(string $token, string $sku, string $qty): void; + + /** Set a line's exact quantity (0 removes it). */ + public function setQty(string $token, string $sku, string $qty): void; + + public function remove(string $token, string $sku): void; + + /** + * The cart priced live from the catalog (server-side). + * + * @return array{lines:list,total:string,count:int} + */ + public function contents(string $token): array; + + /** + * Place the cart as an order (server-side prices, atomic stock reservation) and + * clear it. Returns the order reference. + * + * @param array{name?:string,email?:string} $customer + */ + public function checkout(string $token, array $customer): string; +} diff --git a/src/CommercePlugin.php b/src/CommercePlugin.php index bf15923..239281f 100644 --- a/src/CommercePlugin.php +++ b/src/CommercePlugin.php @@ -9,6 +9,7 @@ use Nimbus\Plugin\Plugin; use Nimbus\Plugin\PluginContext; use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Inventory\CatalogReadPort; use NimbusCMS\Inventory\ReservationPort; /** @@ -29,6 +30,7 @@ public function register(PluginContext $context): void { $context->migrations()->register('001_orders', Schema::all()); $context->migrations()->register('002_order_events', Schema::events()); + $context->migrations()->register('003_cart', Schema::cart()); $context->capabilities()->declare('Commerce', ['read', 'write']); $storage = static fn (): PluginStorage => $context->storage(); @@ -41,6 +43,17 @@ public function register(PluginContext $context): void $orders = new OrderBook($storage, $stock, $emit); + // The public cart (ADR 0026): its own tables, priced server-side from the + // Inventory catalog port, placed through OrderBook. Published so the + // Storefront can drive checkout without touching Commerce's tables. + $catalog = static fn (): ?CatalogReadPort => $context->services()->get(CatalogReadPort::class); + $cart = new Cart($storage, $catalog, $orders); + $context->services()->provide(CartPort::class, new CartAdapter($cart)); + + // Sweep abandoned carts (a client row per anonymous visitor) on the + // maintenance schedule, so the table can't grow without bound. + $context->maintenance()->register('commerce-cart-gc', static fn (): int => $cart->gc(date('Y-m-d H:i:s'))); + $context->mcp()->register(new CommerceToolset($orders)); // Admin page: an orders overview + place form + per-row lifecycle buttons diff --git a/src/Schema.php b/src/Schema.php index 9c49e23..d39d627 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -14,6 +14,41 @@ final class Schema public const ORDER = 'commerce_order'; public const LINE = 'commerce_order_line'; public const EVENT = 'commerce_order_event'; + public const CART = 'commerce_cart'; + public const CART_LINE = 'commerce_cart_line'; + + /** + * The public shopping cart (ADR 0026). A cart is authorised solely by its + * opaque, cryptographically-random `cart_token` (the cookie) — never a + * guessable id — and carries a per-cart `csrf` secret rendered into every form + * and verified on the state-changing POSTs (core CSRF is session/admin-only). + * A line stores **only** `{sku, qty}` — **never a price**; price is resolved + * server-side from the Inventory item at render and at checkout, so a client + * can never influence what it pays. Abandoned carts are GC'd by a maintenance + * task. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function cart(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::CART . ' ( + cart_token VARCHAR(64) NOT NULL PRIMARY KEY, + csrf VARCHAR(64) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_cart_updated (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'CREATE TABLE IF NOT EXISTS ' . self::CART_LINE . ' ( + cart_token VARCHAR(64) NOT NULL, + sku_code VARCHAR(80) NOT NULL, + qty INT UNSIGNED NOT NULL, + added_at DATETIME NOT NULL, + PRIMARY KEY (cart_token, sku_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } /** @return list each statement individually idempotent (ADR 0005) */ public static function all(): array diff --git a/tests/CartTest.php b/tests/CartTest.php new file mode 100644 index 0000000..4fc15b7 --- /dev/null +++ b/tests/CartTest.php @@ -0,0 +1,177 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...InventorySchema::items(), ...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all(), ...CommerceSchema::events(), ...CommerceSchema::cart()] as $sql) { + $db->execute($sql); + } + foreach ([InventorySchema::ITEM, InventorySchema::CATEGORY, InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE, CommerceSchema::EVENT, CommerceSchema::CART, CommerceSchema::CART_LINE] as $t) { + $db->execute('TRUNCATE ' . $t); + } + + $storage = static fn (): PluginStorage => new PluginStorage($db); + $this->catalog = new Catalog($storage); + $this->ledger = new Ledger($storage); + $this->port = new ReservationAdapter($this->ledger, new Reservations($storage, $this->ledger)); + $port = $this->port; + $orders = new OrderBook($storage, static fn (): ReservationPort => $port); + $catalogPort = new CatalogReadAdapter($this->catalog); + $this->cart = new Cart($storage, static fn (): CatalogReadPort => $catalogPort, $orders); + + // Seed a sellable, stocked item. + $this->catalog->saveItem('apple', ['name' => 'Apple', 'price' => '0.50', 'active' => true], self::T); + $this->catalog->saveItem('hidden', ['name' => 'Secret', 'price' => '1.00', 'active' => false], self::T); + $this->ledger->receive('apple', $this->ledger->ensureLocation('main', 'Main', self::T), '100', 'each', 'seed', self::T); + } + + private function newCart(): string + { + return $this->cart->getOrCreate(null, self::T)['token']; + } + + public function test_add_and_contents_price_server_side(): void + { + $t = $this->newCart(); + $this->cart->add($t, 'apple', '3', self::T); + + $c = $this->cart->contents($t); + self::assertSame(1, $c['count']); + self::assertSame('apple', $c['lines'][0]['sku_code']); + self::assertSame(3, $c['lines'][0]['qty']); + self::assertSame('0.50', $c['lines'][0]['unit_price'], 'price comes from the item, not the client'); + self::assertSame('1.50', $c['lines'][0]['line_total']); + self::assertSame('1.50', $c['total']); + } + + public function test_price_follows_the_item_not_a_stored_snapshot(): void + { + $t = $this->newCart(); + $this->cart->add($t, 'apple', '2', self::T); + // The merchant changes the price; the cart reflects the live price. + $this->catalog->saveItem('apple', ['price' => '0.75'], self::T); + self::assertSame('1.50', $this->cart->contents($t)['total']); + } + + public function test_an_inactive_or_unknown_sku_cannot_be_added(): void + { + $t = $this->newCart(); + foreach (['hidden', 'no-such'] as $sku) { + try { + $this->cart->add($t, $sku, '1', self::T); + self::fail("adding {$sku} should be refused"); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + self::assertSame(0, $this->cart->contents($t)['count']); + } + + public function test_quantity_must_be_a_bounded_whole_number(): void + { + $t = $this->newCart(); + foreach (['0', '-1', 'abc', '1.5', '1000000'] as $bad) { + try { + $this->cart->add($t, 'apple', $bad, self::T); + self::fail("qty {$bad} should be rejected"); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } + + public function test_set_qty_zero_removes_the_line(): void + { + $t = $this->newCart(); + $this->cart->add($t, 'apple', '2', self::T); + $this->cart->setQty($t, 'apple', '0', self::T); + self::assertSame(0, $this->cart->contents($t)['count']); + } + + public function test_a_client_cannot_choose_its_own_cart_token(): void + { + // A token the client invents does not become a cart — a fresh random one is minted. + $out = $this->cart->getOrCreate('attacker-chosen-token', self::T); + self::assertNotSame('attacker-chosen-token', $out['token']); + self::assertSame(64, strlen($out['token']), 'a 32-byte random hex token'); + } + + public function test_csrf_secret_is_checked_constant_time(): void + { + $c = $this->cart->getOrCreate(null, self::T); + self::assertTrue($this->cart->csrfOk($c['token'], $c['csrf'])); + self::assertFalse($this->cart->csrfOk($c['token'], 'wrong')); + self::assertFalse($this->cart->csrfOk($c['token'], null)); + } + + public function test_checkout_places_a_server_priced_order_reserves_stock_and_clears_the_cart(): void + { + $t = $this->newCart(); + $this->cart->add($t, 'apple', '4', self::T); + + $ref = $this->cart->checkout($t, ['name' => 'Sam', 'email' => 'sam@example.test'], self::T); + + self::assertNotSame('', $ref); + // Stock reserved (4 of 100 held), cart emptied. + self::assertSame('96.0000', $this->port->available('apple', 'main')); + self::assertSame(0, $this->cart->contents($t)['count']); + } + + public function test_checkout_of_an_empty_cart_is_refused(): void + { + $t = $this->newCart(); + $this->expectException(\InvalidArgumentException::class); + $this->cart->checkout($t, ['email' => 'a@b.test'], self::T); + } + + public function test_gc_removes_carts_untouched_past_the_ttl(): void + { + $old = $this->cart->getOrCreate(null, '2026-01-01 09:00:00')['token']; + $this->cart->add($old, 'apple', '1', '2026-01-01 09:00:00'); + // 20 days later. + $removed = $this->cart->gc('2026-01-21 09:00:00'); + self::assertGreaterThanOrEqual(1, $removed); + self::assertSame(0, $this->cart->contents($old)['count'], 'the old cart and its lines are gone'); + } +} diff --git a/tests/PackageIntegrationTest.php b/tests/PackageIntegrationTest.php index b726c6e..4eb302d 100644 --- a/tests/PackageIntegrationTest.php +++ b/tests/PackageIntegrationTest.php @@ -73,7 +73,7 @@ public function test_discovery_registers_the_migration_capability_toolset_and_gu self::assertSame([], $diagnostics, 'a correctly installed package must load cleanly'); self::assertSame([CommercePlugin::ID => $this->manifest()['name']], $loader->registered()); - self::assertSame(['nimbuscms.commerce:001_orders', 'nimbuscms.commerce:002_order_events'], array_column($migrations->all(), 'name')); + self::assertSame(['nimbuscms.commerce:001_orders', 'nimbuscms.commerce:002_order_events', 'nimbuscms.commerce:003_cart'], array_column($migrations->all(), 'name')); self::assertSame([CommercePlugin::ID], $capabilities->managementResources()); self::assertCount(1, $mcpToolsets->all(), 'its MCP toolset'); self::assertNotSame([], $skills->documents(), 'its agent guide');