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>
126 lines
5.1 KiB
Markdown
126 lines
5.1 KiB
Markdown
# 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`.
|