Skip to content

Commerce

Purpose

Production Commerce/marketplace site: public catalog, guest browsing, authenticated checkout, real order history, and Admin-managed categories/products/images.

Current functional status

No longer localStorage-backed. As of feature/commerce-production-backend, Commerce is a D1-backed production application:

  • Public catalog (categories, products, product images) is read from D1 via Commerce's own Worker.
  • Checkout is real: the server reloads every product from D1 and computes price/quantity/total itself - the browser never supplies (or is trusted for) a price, name, or total.
  • Orders are real rows in the shared orders/order_items/order_address tables, created through the same server-to-server-only central API surface introduced by PR #9 (POST /api/commerce/orders), now extended to accept real line items and a delivery address.
  • Customer identity comes from the existing Corporate customer session - see "Customer identity architecture" below. There is no second customer database and no separate Commerce login.
  • The guest cart remains client-side/localStorage (apps/commerce-web/src/cart.ts) - a deliberate, documented exception, not an oversight. It only ever stores {productId, quantity}; nothing from it is trusted at checkout (see "Cart & checkout" below). Products, categories, and orders are all D1-backed - the cart is the one and only browser-local piece of state remaining.
  • Category visibility is enforced, not just product status: a product is only ever publicly visible when BOTH commerce_products.status='Active' AND its commerce_categories.status='Active'
  • an Active product filed under a deactivated category does not appear in any public listing, featured section, or product detail response. See "Category/product visibility" below.

Source directory

apps/commerce-web/ - src/ (SPA) and worker/ (the app's own Cloudflare Worker, new in this PR).

Framework

React 18 + TypeScript + Vite (SPA), Cloudflare Worker (API) sharing the same D1 database as every other app in this repo.

Commands

npm run dev:commerce
npm run build:commerce

Output

apps/commerce-web/dist/ (static assets) + apps/commerce-web/worker/index.ts (Worker script).

Deployment mechanism

Cloudflare Worker with Static Assets, name akshaya-commerce. wrangler deploy --config apps/commerce-web/wrangler.jsonc. Domain: commerce.akshayagroupglobal.com. run_worker_first: ["/api/*"] - only API routes run through the Worker; everything else falls through to the static SPA build, same pattern already used by Corporate (/customer-portal*, /api/customer/*) and Software (/freelancer-portal*, /api/freelancer/*).

Customer identity architecture

Commerce does not duplicate customer authentication. The existing Corporate Worker (apps/corporate-web/worker/) remains the single source of truth for users, customer_profiles, auth_sessions, password hashing, lockout rules and session security - none of that changed.

Commerce needs a way to know which authenticated customer is checking out without trusting a browser-supplied id. The bridge:

Customer session on Corporate (host-only cookie, akshayagroupglobal.com)
  -> POST https://akshayagroupglobal.com/api/customer/commerce-token   (requireCustomer + CSRF)
  -> short-lived (5 min) signed Commerce token (HS256 JWT, HMAC-SHA256 via Web Crypto)
  -> browser holds the token in memory only (React state, never localStorage)
  -> Commerce Worker verifies the token (apps/commerce-web/worker/commerce-token.ts)
  -> Commerce Worker calls the central operations API
  -> X-Akshaya-User-Id is attached server-side only, from the verified token's `sub` claim
  • Token claims: sub (user id), role: 'Customer', aud: 'commerce', iat, exp, jti. Never email/mobile/password.
  • Signing: hand-rolled HS256 (a real, standard JWT shape) with Web Crypto - no JWT library dependency added, since the claim set is small and this repo has none today. Implemented in apps/corporate-web/worker/commerce-token.ts (signs) and duplicated verbatim in apps/commerce-web/worker/commerce-token.ts (verifies) - see that file's header comment for why it's duplicated rather than shared, and keep both in sync if you ever change the claim shape.
  • Secret: CUSTOMER_SSO_SECRET, a Worker secret bound to both corporate-web and commerce-web. See CI/CD → Secrets.
  • CSRF/CORS: POST /api/customer/commerce-token goes through the exact same cross-origin (same-site) CSRF pattern already used for the Freelancer Marketplace's calls into Corporate - a narrow origin allowlist (https://commerce.akshayagroupglobal.com added to CORS_ALLOWED_ORIGINS) plus a required custom header (X-Akshaya-Commerce-Request: commerce) that a plain HTML form cannot set, forcing a CORS preflight. See checkCustomerMutationOrigin in apps/corporate-web/worker/index.ts.
  • Sign-in redirect: reuses the existing, already-shared Customer Account redirect mechanism (customerPortalHref/isSafeReturnPath in packages/ui/src/index.tsx, and the portal-side return-context.mjs host allowlist) - 'commerce' was already a recognized source before this PR. A guest who clicks checkout is sent to https://akshayagroupglobal.com/customer-portal/?source=commerce&returnTo=<path> and lands back on the exact Commerce page they started from after signing in.

Cart & checkout

The cart (apps/commerce-web/src/cart.ts) is intentionally client-side/localStorage - guests must be able to browse and add to cart without an account. It stores only productId + quantity. Displayed prices while shopping are read from the last catalog response, purely for the shopper's estimate.

At checkout:

  1. The SPA sends { items: [{productId, quantity}], address, paymentMethod } to this Worker's own POST /api/commerce/checkout - no price, name or total field exists in this request shape at all, so there is nothing for a compromised/buggy browser to lie about.
  2. The Commerce Worker verifies the Commerce token, then forwards the request as-is to the central operations API's POST /api/commerce/orders, attaching Authorization: Bearer <OPERATIONS_API_TOKEN> and X-Akshaya-User-Id: <verified sub> - both added server-side, never read from the incoming request.
  3. The central API (workers/api/src/commerce-orders.ts + entry.ts) is the sole authority on pricing: it re-fetches every product from D1 by id, checks status='Active' and stock_quantity >= quantity, and computes unitPrice/lineTotal/order total itself. Even if the Commerce Worker were compromised, it cannot set an arbitrary price - the field doesn't exist on the wire.
  4. Order header, line items, and delivery address are written in one D1 batch(), alongside a conditional stock decrement (WHERE stock_quantity >= ?) and an inventory_movements audit row per line. batch() genuinely is a transaction - Cloudflare documents it as running in an implicit transaction, and a real SQL execution error in any statement rolls the whole batch back. The race described here isn't a transaction failure: UPDATE ... WHERE stock_quantity >= ? matching zero rows (stock sold out between the read and the batch running) is not a SQL error - it's a successful statement that happens to affect 0 rows, so it doesn't abort anything. A transaction can't roll itself back over a "successful" result it has no way to know was semantically wrong. So when that happens, the order/item/address rows are already committed alongside a stock update that silently did nothing - a compensating batch afterward deletes what was just created and reverts any stock that did decrement, returning 409. See commerce-orders.ts's and entry.ts's comments for the full detail.

Payments

Cash on Delivery (COD) only, for now. workers/api/src/commerce-orders.ts's COMMERCE_PAYMENT_METHODS accepts exactly one value, 'COD' - UPI and Card are rejected by the central API regardless of what the Commerce Worker or the browser sends, and the Commerce checkout UI does not offer them at all. This is deliberate, not an oversight: this repo has no payment gateway integration (no capture, no webhook, no reconciliation), so accepting an "online" payment method today would let an order be marked Confirmed with no actual payment having been verified.

Razorpay integration is a planned future enhancement, not implemented in this PR. When it lands, the expected shape is: Commerce Worker creates a Razorpay order, the browser completes payment via Razorpay Checkout, Razorpay's webhook (verified server-side, signature-checked) is the only thing allowed to transition an online-payment order from a PendingPayment-style status to Confirmed - never the client redirect alone, since that can be spoofed or simply never fire. Until that lands, COMMERCE_PAYMENT_METHODS should not be widened to include UPI/Card.

Category/product visibility

A product is only ever publicly visible when both it and its category are Active - commerce_products.status='Active' AND commerce_categories.status='Active'. Enforced in apps/commerce-web/worker/catalog.ts with an INNER JOIN commerce_categories c ON c.id = p.category_id AND c.status='Active' present, unconditionally, in every product list/count/detail query - not a runtime check applied after the fact, so there is no parameter combination (search, category filter, sort, featured, pagination) that can bypass it. Applies identically to the product list, the homepage's featured-products section (same query, featured=1), and product detail - there is only one query-building path for all three. Covered by catalog.test.ts, including a real SQLite execution test (via node:sqlite) against every Active/Inactive × Active/Inactive combination plus a dangling category_id.

The same list/detail queries also expose exactly one primary_image_key per product (the lowest sort_order image, via a correlated subquery) - never the full image list, so a catalog page never leaks every R2 key for every product on it. The full gallery is only returned from the single- product detail response.

Order visibility (Customer Account integration)

Commerce orders use the same orders/order_items/order_address tables Corporate's existing GET /api/customer/me already reads (WHERE user_id=?) - a Commerce order therefore appears there automatically, with no separate ownership model. Commerce's own "My Orders" page (GET /api/commerce/my-orders) reads the same tables directly from its own D1 binding, scoped unconditionally to the verified token's sub - there is no code path that accepts a caller-supplied customer id, so one customer can never retrieve another's orders through this route.

Fulfilment

Placing an order does not automatically create a Logistics shipment. Unchanged from PR #9: POST /api/commerce/orders/:id/fulfil remains the Commerce → Logistics handoff (server-to-server only, still propagates user_id onto the generated shipment) - but nothing in this PR's cart/checkout flow calls it. A confirmed order's status stays Confirmed - ready for fulfilment until something (today: a manual/future Admin action) explicitly calls /fulfil for that order id. The Commerce UI reflects this - it never claims an order "automatically hands off" anywhere. A full Logistics UI/backend (to actually drive that handoff) is explicitly out of scope here.

Admin catalog management

Extends the existing Admin application - both the Worker API (apps/admin-web/worker/index.ts) and the React UI (apps/admin-web/src/App.tsx, new Categories and Products sidebar sections) - rather than introducing another admin login. Every route below sits behind the same requireAdmin gate as the rest of Admin's API; the UI is reached through the same authenticated Admin session as Customers/Freelancers/Orders/Shipments.

GET    /api/commerce/categories
POST   /api/commerce/categories
PATCH  /api/commerce/categories/:id

GET    /api/commerce/products
POST   /api/commerce/products
PATCH  /api/commerce/products/:id

GET    /api/commerce/products/:id/images        (list)
POST   /api/commerce/products/:id/images        (multipart upload)
DELETE /api/commerce/products/:id/images/:imageId

GET    /api/orders/:id                          (order detail: header + items + address)

Categories UI: list, create, edit (name/slug/description/sort order), activate/deactivate - no delete button anywhere. Products UI: list with status/category filters, create/edit covering every field (category, SKU, name, slug, brand, short/long description, price, compare-at price, stock quantity, status, featured), activate/deactivate, and a per-product image manager (upload, thumbnail grid, delete) shown inline. No hard delete for categories or products (an order_items row may reference a product) - status='Inactive' is the only supported removal path, same discipline as freelancer/requirement moderation elsewhere in Admin.

Product images / R2

Product images reuse the existing R2 bucket (akshaya-group-global-files), the same freelancer-avatar security pattern (magic-byte MIME sniffing, size cap, server-generated key, no directory nesting), served through the same public, unauthenticated /api/files/:key route in the central API:

  • Upload: apps/admin-web/worker/index.ts's POST /api/commerce/products/:id/images, requireAdmin-gated, 4 MB cap, JPEG/PNG/WebP only (sniffed, not just declared Content-Type), key format product-image-<productId>-<uuid>.<ext> with no directory nesting.
  • Public read: workers/api/src/public-file-access.ts's allowlist was extended (not loosened) to recognize this exact new key shape alongside the pre-existing freelancer-avatar-... shape - every other object, including private/-prefixed embroidery references, is unaffected and stays rejected. Proven by tests in public-file-access.test.ts.
  • Upload consistency: if the R2 put succeeds but the following D1 insert fails, the R2 object is deleted immediately (a manual compensating action) rather than left behind as an untracked but still-publicly-fetchable orphan - its key already matches the public allowlist regardless of whether any D1 row references it.
  • Delete consistency and ordering: verifies the image row belongs to the given product and that the R2 key was actually generated for that product (ownedProductImageKey) - never deletes an arbitrary caller-supplied key. The R2 object is deleted first; the commerce_product_images row is only removed after that succeeds. If the R2 delete fails, the route returns 500 and deliberately leaves the D1 row in place (state stays consistent and recoverable - retry later) rather than removing the D1 row first, which would risk leaving the R2 object as an untracked public orphan if the R2 delete then failed.

Environment variables / configuration

apps/commerce-web/wrangler.jsonc:

Name Kind Purpose
OPERATIONS_API_BASE vars (non-secret) Which host to call for privileged order creation - https://api.akshayagroupglobal.com
OPERATIONS_API_TOKEN Worker secret Server-to-server credential for the central operations API - see CI/CD → Secrets
CUSTOMER_SSO_SECRET Worker secret Verifies the Commerce handoff token minted by Corporate
DB D1 binding Same shared database (akshaya-group-global) - used for public catalog reads and the customer's own order history, never for privileged order writes

apps/corporate-web/wrangler.jsonc needs the same CUSTOMER_SSO_SECRET bound (it signs the token that commerce-web verifies).

commerce.akshayagroupglobal.com

Dependencies on shared packages

@akshaya/ui (including customerPortalHref/isSafeReturnPath), @akshaya/shared.

Data model

See Architecture → Data Platform for the full migration 0010 schema (commerce_categories, commerce_products, commerce_product_images, order_items, order_address, inventory_movements).

Known limitations / follow-ups (deliberately not solved in this PR)

  • Inventory reservation: stock is decremented at order-creation time with a best-effort conditional UPDATE + compensating rollback (see "Cart & checkout" above), not a true reserved hold while a customer is mid-checkout. Two customers racing for the last unit will not both succeed (the conditional UPDATE prevents overselling), but there's no "hold for N minutes while in cart" mechanism. Documented as a follow-up, not implemented.
  • Order cancellation / refund flow: not implemented - inventory_movements gives an audit trail to build one on top of, but no cancellation endpoint exists yet.
  • Seller/marketplace multi-vendor model: the previous localStorage demo had a "Sellers" concept; the production schema does not (single-seller/Akshaya-operated catalog only, matching the in-scope requirements for this PR).
  • Full Logistics UI/backend, driver management, route optimization, wallet/escrow, commissions, bidding, disputes, chat, recommendations, multi-warehouse inventory: explicitly out of scope, per the platform-hardening/production-backend PRs' own scope boundaries.
  • Investors: remains excluded/inactive - unrelated to and unaffected by this PR.

Troubleshooting

  • If the static site looks stale after deploy, see Cloudflare → Static Assets.
  • If a direct browser call to api.akshayagroupglobal.com/api/commerce/orders returns 401/403, that is expected; the operational endpoint is server-to-server only - checkout must go through commerce.akshayagroupglobal.com/api/commerce/checkout instead.
  • POST /api/customer/commerce-token returning 503 means CUSTOMER_SSO_SECRET is not configured (or too short) on corporate-web.
  • Checkout returning 401 from the Commerce Worker means the Commerce token is missing, expired, malformed, or was verified with a CUSTOMER_SSO_SECRET that doesn't match the one Corporate signed with - confirm both Workers have the same secret value.
  • Checkout returning 409 with "went out of stock" means a concurrent order won the race for the same stock - this is the compensating-rollback path described above working as intended, not a bug.