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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
125
docs/api/checkout.md
Normal 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
81
docs/api/customers.md
Normal 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
148
docs/api/orders.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user