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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-23 10:48:11 +01:00
parent 9a111c8dde
commit ea7d9b04cc
9 changed files with 363 additions and 6 deletions

View File

@@ -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

View File

@@ -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.

125
docs/api/checkout.md Normal file
View File

@@ -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`.

81
docs/api/customers.md Normal file
View File

@@ -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>` | `Customer` |
| GET | `/api/customers/me/addresses` | — | `Address[]` |
| POST | `/api/customers/me/addresses` | `Address` | `Address[]` |
| PATCH | `/api/customers/me/addresses/:id` | `Partial<Address>` | `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`.

148
docs/api/orders.md Normal file
View File

@@ -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=<orderId>`, 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.

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 = [

View File

@@ -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