From ea7d9b04cc65b575f95401ca73dafc827249a1ed Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Apr 2026 10:48:11 +0100 Subject: [PATCH] docs: correct backend stack to PHP/MySQL and document checkout/orders/customers Swap lingering "Python/MySQL" wording for "PHP / MySQL" across the README, `src/api/` seam, the Pinia cart store, and the cart contract doc. Add endpoint specs for checkout (Stripe handoff + webhook), orders, and customers so the full plug-in surface is documented in the same style as cart.md. Co-Authored-By: Claude Opus 4.7 --- README.md | 5 +- docs/api/cart.md | 2 +- docs/api/checkout.md | 125 +++++++++++++++++++++++++++++++++++ docs/api/customers.md | 81 +++++++++++++++++++++++ docs/api/orders.md | 148 ++++++++++++++++++++++++++++++++++++++++++ src/api/cart.js | 2 +- src/api/index.js | 2 +- src/api/products.js | 2 +- src/stores/cart.js | 2 +- 9 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 docs/api/checkout.md create mode 100644 docs/api/customers.md create mode 100644 docs/api/orders.md diff --git a/README.md b/README.md index 37f1dbc..1aba0d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Kaiser Natron -Ecommerce frontend. Vue 3 + Vite + Tailwind v4. Backend (Python/MySQL) is plugged in at the `src/api/` boundary. +Ecommerce frontend. Vue 3 + Vite + Tailwind v4. Backend (PHP / MySQL, Stripe for payments) is plugged in at the `src/api/` boundary. ## Setup @@ -26,6 +26,9 @@ Browse the full system at `/design` when running `npm run dev`. This is the sing Endpoint specs for backend integration live under `docs/api/`: - [`docs/api/cart.md`](docs/api/cart.md) — cart endpoints, types, error codes, and how to swap the local implementation for HTTP. +- [`docs/api/checkout.md`](docs/api/checkout.md) — Stripe handoff: PaymentIntent creation, client-side confirmation, and the webhook that finalises the order. +- [`docs/api/orders.md`](docs/api/orders.md) — order lookup and customer order history. +- [`docs/api/customers.md`](docs/api/customers.md) — account, login, and address endpoints used by the checkout and account pages. ## Supply chain diff --git a/docs/api/cart.md b/docs/api/cart.md index 5783905..57158e6 100644 --- a/docs/api/cart.md +++ b/docs/api/cart.md @@ -2,7 +2,7 @@ The frontend talks to the cart through a small, stable surface exported from `src/api/cart.js`. That file is the only seam that changes when the -Python/MySQL backend comes online — everything above it (the Pinia +PHP / MySQL backend comes online — everything above it (the Pinia store, the `CartDrawer` component, pages that add to cart) keeps importing the same functions with the same signatures. diff --git a/docs/api/checkout.md b/docs/api/checkout.md new file mode 100644 index 0000000..c2d52ac --- /dev/null +++ b/docs/api/checkout.md @@ -0,0 +1,125 @@ +# Checkout & Payments + +Checkout is a thin orchestration layer on top of the cart. The frontend +hands off to Stripe for card capture, the backend creates and confirms +the order, and a webhook is the authoritative "paid" signal. + +The seam on the frontend side lives in `src/api/checkout.js` (to be +added alongside `cart.js` and `products.js`). Until the backend is +online, that module can stub the calls locally. + +## Flow at a glance + +``` + Browser (Vue) Backend (PHP) Stripe + ───────────── ───────────── ────── + 1. POST /api/checkout/intent ─────────────► create PaymentIntent + ◄───────────── clientSecret + 2. stripe.confirmCardPayment(clientSecret) ───────────► charge + ◄──────── status + 3. Stripe webhook ─────────► payment_intent.succeeded + → mark order paid, decrement stock + 4. GET /api/orders/:id ────────────► + ◄──────── Order (status: "paid") +``` + +The browser never sees raw card data — it posts directly to Stripe +using the `clientSecret`. The backend relies on the webhook, not the +browser, to decide whether an order is paid. + +## Endpoints + +| Method | Path | Body | Returns | +| ------ | -------------------------- | --------------------- | ---------------------- | +| POST | `/api/checkout/intent` | `CheckoutRequest` | `CheckoutIntent` | +| POST | `/api/checkout/confirm` | `{ orderId }` | `Order` | +| POST | `/api/webhooks/stripe` | Stripe event (signed) | `200 OK` (server-only) | + +- `/api/checkout/intent` takes the cart plus customer + shipping info, + creates a pending `Order` row, creates a Stripe PaymentIntent, and + returns the `clientSecret` for the browser to confirm. +- `/api/checkout/confirm` is optional: it lets the frontend poll once + after Stripe's client-side confirmation to get the final `Order` + shape, without waiting for the webhook. +- `/api/webhooks/stripe` is called by Stripe, not the browser. It + verifies the signature and is the only source of truth for + `status: "paid"`. + +## Types + +```ts +interface Address { + name: string + company?: string + street: string + postalCode: string + city: string + country: string // ISO 3166-1 alpha-2 + phone?: string +} + +interface CheckoutRequest { + email: string + shippingAddress: Address + billingAddress?: Address // defaults to shippingAddress + acceptsMarketing: boolean +} + +interface CheckoutIntent { + orderId: string // the Order row, status: "pending" + clientSecret: string // passed to stripe.confirmCardPayment + publishableKey: string // Stripe publishable key (pk_live_… / pk_test_…) + amount: number // EUR, informational — Stripe is authoritative + currency: "eur" +} + +interface Order { + id: string + status: "pending" | "paid" | "failed" | "refunded" | "cancelled" + items: CartLine[] // snapshot at checkout, not live cart + subtotal: number + shipping: number + tax: number + total: number + currency: "eur" + customer: { email: string; customerId?: string } + shippingAddress: Address + billingAddress: Address + createdAt: string // ISO-8601 UTC + paidAt?: string +} +``` + +## Stripe integration notes + +- Use the **Payment Element** (not the deprecated Card Element). It + supports SEPA + Apple/Google Pay with no extra frontend work. +- Publishable key is returned per-request so staging and production + can use separate Stripe accounts without a frontend rebuild. +- The webhook endpoint must verify `Stripe-Signature` against the + endpoint secret. Reject unsigned or stale events. +- Idempotency: the backend must treat the webhook as idempotent — the + same `payment_intent.succeeded` can arrive more than once. + +## Errors + +```json +{ "error": { "code": "checkout.cartEmpty", "message": "Cart is empty." } } +``` + +| Code | When | +| -------------------------- | -------------------------------------------------- | +| `checkout.cartEmpty` | No items in the session cart at intent creation. | +| `checkout.addressInvalid` | Shipping or billing address fails validation. | +| `checkout.stockChanged` | A line is out of stock since the cart was built. | +| `checkout.priceChanged` | Catalog price drifted from the cart snapshot. | +| `payment.declined` | Stripe reported a final decline. | +| `payment.authRequired` | 3-D Secure / SCA step pending — retry confirm. | + +HTTP status: + +- `200 OK` on success. +- `400 Bad Request` for validation errors. +- `409 Conflict` for `stockChanged` / `priceChanged` — the client + refreshes the cart and retries. +- `402 Payment Required` for `payment.declined`. diff --git a/docs/api/customers.md b/docs/api/customers.md new file mode 100644 index 0000000..479f7cc --- /dev/null +++ b/docs/api/customers.md @@ -0,0 +1,81 @@ +# Customers + +Customer-facing auth and profile endpoints. The shop supports guest +checkout — a customer account is optional but unlocks order history, +saved addresses, and faster checkout. + +The seam on the frontend side is `src/api/customers.js` (to be added). + +## Session model + +- Same httpOnly session cookie as the cart API. The cookie identifies + the session whether the caller is a guest or a logged-in customer. +- Logging in **upgrades** the current session: the cart and any + just-placed orders attached to it stay attached to the now-logged-in + customer. No merge dance required on the frontend. +- Logging out rotates the session and clears the cart. + +## Endpoints + +| Method | Path | Body | Returns | +| ------ | ---------------------------------- | ------------------------ | ---------- | +| POST | `/api/customers/register` | `RegisterRequest` | `Customer` | +| POST | `/api/customers/login` | `{ email, password }` | `Customer` | +| POST | `/api/customers/logout` | — | `200 OK` | +| GET | `/api/customers/me` | — | `Customer` \| `null` | +| PATCH | `/api/customers/me` | `Partial` | `Customer` | +| GET | `/api/customers/me/addresses` | — | `Address[]` | +| POST | `/api/customers/me/addresses` | `Address` | `Address[]` | +| PATCH | `/api/customers/me/addresses/:id` | `Partial
` | `Address[]` | +| DELETE | `/api/customers/me/addresses/:id` | — | `Address[]` | +| POST | `/api/customers/password/reset` | `{ email }` | `200 OK` | +| POST | `/api/customers/password/confirm` | `{ token, password }` | `200 OK` | + +`GET /api/customers/me` returns `null` (HTTP `200`) for guest sessions +so the frontend can branch on presence without a 401 round-trip. + +## Types + +```ts +interface RegisterRequest { + email: string + password: string + name: string + acceptsMarketing: boolean +} + +interface Customer { + id: string + email: string + name: string + defaultAddressId?: string + acceptsMarketing: boolean + createdAt: string +} +``` + +`Address` is defined in `checkout.md`. Addresses carry a server-issued +`id` when persisted against a customer. + +## Validation & security + +- Passwords: minimum 10 characters. Backend hashes with argon2id. +- Login and `password/reset` endpoints are rate-limited (5 req / min / + IP is a reasonable starting point — tighten as needed). +- Generic error message for bad credentials — do not distinguish + "unknown email" from "wrong password". +- Password reset tokens are single-use, expire in 30 minutes, and are + never logged. + +## Errors + +| Code | When | +| ---------------------------- | ------------------------------------------------ | +| `auth.invalidCredentials` | Login failed (generic — do not leak which part). | +| `auth.emailTaken` | Registration email already has an account. | +| `auth.passwordWeak` | Password fails the complexity rule. | +| `auth.rateLimited` | Too many attempts — back off. | +| `auth.tokenInvalid` | Reset token missing, expired, or used. | + +HTTP: `400` for validation, `401` for `invalidCredentials`, `409` for +`emailTaken`, `429` for `rateLimited`. diff --git a/docs/api/orders.md b/docs/api/orders.md new file mode 100644 index 0000000..17e299b --- /dev/null +++ b/docs/api/orders.md @@ -0,0 +1,148 @@ +# Orders API + +Orders are created as `pending` by the checkout intent endpoint (see +`checkout.md`) and transition to `paid` / `failed` / `refunded` based on +Stripe webhooks. The frontend reads orders to render the confirmation +page, the account order history, and the order detail view. + +The frontend consumes this surface through a future `src/api/orders.js` +module following the same pattern as `cart.js`. + +## Base URL and session + +- All endpoints are served under `/api`. +- Authorisation: the session cookie identifies the buyer. Guest orders + are scoped to the session cookie that created them; once a guest logs + in or registers, the backend MAY attach prior guest orders to the + customer record. +- Requests and responses are `application/json; charset=utf-8`. + +## Endpoints + +| Method | Path | Body | Returns | +| ------ | --------------------- | ---- | -------------- | +| GET | `/api/orders` | — | `OrderList` | +| GET | `/api/orders/:id` | — | `Order` | + +`GET /api/orders` returns only orders visible to the current session — +the logged-in customer's orders, or guest orders created during this +session. + +## Types + +```ts +type Money = number // EUR, 2dp +type OrderId = string +type ISO8601 = string + +type OrderStatus = 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled' +type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded' +type FulfilmentStatus = 'unfulfilled' | 'processing' | 'shipped' | 'delivered' + +interface OrderLine { + productId: string + title: string // snapshot at order time — safe to display as-is + size: string + quantity: number + unitPrice: Money + lineTotal: Money +} + +interface Order { + id: OrderId + number: string // human-readable, e.g. "KN-2026-0001" + status: OrderStatus + paymentStatus: PaymentStatus + fulfilmentStatus: FulfilmentStatus + createdAt: ISO8601 + paidAt?: ISO8601 + items: OrderLine[] + subtotal: Money + shipping: Money + tax: Money + total: Money + currency: string // ISO 4217 + shippingAddress: Address // shape defined in checkout.md + billingAddress: Address + email: string + trackingUrl?: string // set once the fulfilmentStatus is "shipped" +} + +interface OrderList { + items: Order[] // newest first + count: number +} +``` + +Line snapshots (`title`, `size`, `unitPrice`) are frozen at order +creation. If the catalogue changes later, existing orders keep +rendering the values the customer actually bought. + +## Example exchange + +Request: + +```http +GET /api/orders/ord_01HSX9Z0K3R7 HTTP/1.1 +``` + +Response: + +```json +{ + "id": "ord_01HSX9Z0K3R7", + "number": "KN-2026-0142", + "status": "paid", + "paymentStatus": "paid", + "fulfilmentStatus": "processing", + "createdAt": "2026-04-23T09:14:02.000Z", + "paidAt": "2026-04-23T09:14:47.000Z", + "items": [ + { + "productId": "kaiser-natron-pulver-250-g-grosspackung", + "title": "Kaiser-Natron® Pulver", + "size": "250 g Großpackung", + "quantity": 2, + "unitPrice": 4.49, + "lineTotal": 8.98 + } + ], + "subtotal": 8.98, + "shipping": 4.90, + "tax": 1.70, + "total": 15.58, + "currency": "EUR", + "shippingAddress": { "...": "see checkout.md Address" }, + "billingAddress": { "...": "see checkout.md Address" }, + "email": "ada@example.com" +} +``` + +## Errors + +```json +{ "error": { "code": "order.notFound", "message": "Unknown orderId." } } +``` + +Known codes: + +| Code | When | +| ------------------ | ------------------------------------------------------------------ | +| `order.notFound` | The order ID does not exist or is not visible to the session. | +| `order.forbidden` | The order exists but belongs to a different customer. | + +HTTP status codes: + +- `200 OK` on success. +- `401 Unauthorized` if authentication is required and absent. +- `403 Forbidden` for `order.forbidden`. +- `404 Not Found` for `order.notFound`. The backend MAY return `404` for + `order.forbidden` as well to avoid leaking order IDs. + +## Polling after checkout + +After Stripe redirects back to `/checkout/return?order=`, the +frontend polls `GET /api/orders/:id` with modest backoff (e.g. 1 s / 2 s +/ 4 s, stopping at 15 s) until `paymentStatus !== 'pending'`. If the +webhook is slow, the page shows a "payment processing" state and keeps +polling; it does not mark the order failed on its own. diff --git a/src/api/cart.js b/src/api/cart.js index 373c967..2995877 100644 --- a/src/api/cart.js +++ b/src/api/cart.js @@ -1,4 +1,4 @@ -// Cart API — the boundary between the Vue frontend and the Python/MySQL +// Cart API — the boundary between the Vue frontend and the PHP / MySQL // backend. Keep the shapes here stable: everything above this file // (components, pages, store consumers) calls these functions and never // talks to an endpoint directly. diff --git a/src/api/index.js b/src/api/index.js index 3834a1f..85a6463 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,5 +1,5 @@ // Barrel for the API boundary. Swap these imports for real backend calls -// when the Python/MySQL side lands — callers keep the same import path. +// when the PHP / MySQL side lands — callers keep the same import path. export { products, searchProducts, formatPrice } from './products.js' export { diff --git a/src/api/products.js b/src/api/products.js index f6eafd4..ec6c710 100644 --- a/src/api/products.js +++ b/src/api/products.js @@ -1,4 +1,4 @@ -// Product catalog — frontend fixture until the Python/MySQL backend takes over. +// Product catalog — frontend fixture until the PHP / MySQL backend takes over. // Shape and helper are the same surface the API module will eventually expose. export const products = [ diff --git a/src/stores/cart.js b/src/stores/cart.js index ed6d4f5..418674b 100644 --- a/src/stores/cart.js +++ b/src/stores/cart.js @@ -1,5 +1,5 @@ // Cart state — Pinia store. Persists to localStorage so the basket -// survives page reloads until the Python/MySQL backend takes over. +// survives page reloads until the PHP / MySQL backend takes over. // // Nothing outside this file should import the store directly — the rest // of the app goes through `src/api/cart.js`, which is the swap-point