Repository navigation
v0.4.0 — Phase 4: billing infrastructure, Stripe-ready
Overview
Phase 4 introduces the billing layer. Genuflow now tracks each brand's plan and product usage, enforces per-plan limits at registration time, and is fully wired for Stripe Checkout and webhook ingestion — without taking a hard dependency on a Stripe account. When `STRIPE_SECRET_KEY` is set, real Stripe flows engage; when it is not, the same endpoints respond with placeholder behaviour so the integration can be exercised end-to-end during development.
The implementation is intentionally SDK-free. Webhook signature verification is hmac+sha2; Checkout session creation is one POST via `reqwest` over `rustls-tls`. No `async-stripe` dependency, no native OpenSSL link.
Schema
Two new migrations, applied automatically by the API on startup.
`migrations/0006_subscriptions.sql`
One row per brand (UNIQUE on `brand_id`):
| Column | Type | Notes |
|---|---|---|
| `id` | UUID | `gen_random_uuid()` |
| `brand_id` | UUID | FK `brands(id)`, ON DELETE CASCADE, UNIQUE |
| `plan` | TEXT | CHECK IN (`atelier`, `maison`, `couture`) |
| `status` | TEXT | CHECK IN (`active`, `past_due`, `canceled`, `trialing`) |
| `stripe_customer_id` | TEXT | nullable, set by webhook |
| `stripe_subscription_id` | TEXT | nullable, set by webhook |
| `current_period_end` | TIMESTAMPTZ | nullable |
| `created_at` | TIMESTAMPTZ | `NOW()` |
Includes a one-shot back-fill that gives every pre-existing brand the default `(atelier, active)` subscription.
`migrations/0007_usage_records.sql`
Periodic snapshots keyed by `(brand_id, period_start)`. The anchor for future usage-based reconciliation when Stripe enters the loop.
Types and errors
In `crates/common`:
- `Plan { Atelier, Maison, Couture }` with `product_limit() -> Option` returning `Some(500)`, `Some(10_000)`, and `None` respectively.
- `SubscriptionStatus { Active, PastDue, Canceled, Trialing }` — vocabulary mirrors Stripe so webhook ingestion is a direct field copy.
- `Subscription` — full record type.
- `AppError::PaymentRequired(String)` → HTTP 402.
DB layer
| Module | Functions |
|---|---|
| `db::subscriptions` | `get_by_brand`, `create`, `update_status`, `update_stripe_ids` |
| `db::usage_records` | `create`, `list_by_brand` |
| `db::products` | New: `create_with_plan_check` — atomic plan-checked insert |
TOCTOU-safe registration
`create_with_plan_check` wraps the count check and the INSERT in a single transaction. The brand's `subscriptions` row is taken with `SELECT ... FOR UPDATE` at the top of the transaction, which serializes all concurrent product inserts for that brand at the row-lock level. Two concurrent requests at count = 499 / limit = 500 cannot both pass the check and both insert. Returns:
```rust
pub enum RegisterOutcome {
Created(Product),
LimitExceeded { count: i64, limit: i64 },
}
```
Couture (unlimited) skips the count entirely but still acquires the row lock — uniform lock semantics for future per-brand state writes.
API endpoints
| Endpoint | Behaviour |
|---|---|
| `POST /auth/register` | After creating the brand and user, inserts `(Atelier, Active)` into `subscriptions`. |
| `POST /v1/products` | Plan limit enforced atomically via `create_with_plan_check`. Exceeding returns 402 with `"product limit reached (N/M); upgrade your plan to register more"`. |
| `GET /v1/billing` | Returns `{ plan, status, product_count, product_limit, current_period_end, stripe_configured }`. Falls back to `(Atelier, Active)` for legacy brands without a subscription row. |
| `POST /v1/billing/create-checkout-session` | With Stripe configured: POSTs to `api.stripe.com/v1/checkout/sessions` with the brand id round-tripped via `client_reference_id`, returns the hosted URL. Without Stripe: returns a placeholder URL pointing back to `/billing?placeholder=1`. Stripe errors are logged via `tracing` and surface to the client as a generic `"payment provider error"`; Stripe response bodies are never echoed. |
| `POST /webhooks/stripe` | Public, no middleware, raw body via `axum::body::Bytes`. |
Webhook handler
`crates/api/src/routes/webhooks.rs`:
- Reads the raw body as `Bytes` before any JSON parsing.
- Validates the `Stripe-Signature` header per Stripe's documented `t=…,v1=…` scheme: HMAC-SHA256 over `{timestamp}.{raw_body}`, 5-minute timestamp tolerance, constant-time comparison via `subtle::ct_eq`.
- Only deserializes the JSON after the signature passes.
- Returns 503 when `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` are not set.
Event dispatch:
- `checkout.session.completed` → `update_stripe_ids` (round-trips `client_reference_id = brand_id`).
- `customer.subscription.updated` / `customer.subscription.deleted` → `update_status`. Plan is inferred from `items.data[0].price.id` by matching against `STRIPE_PRICE_ID_MAISON` / `STRIPE_PRICE_ID_COUTURE` env vars.
- Any other event type is acknowledged with 200 so Stripe doesn't retry.
Dependencies
`crates/api/Cargo.toml` gains:
- `hmac = "0.12"`
- `subtle = "2"` — constant-time hex comparison
- `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }`
No `async-stripe`, no `native-tls`, no system OpenSSL link.
Frontend
- `lib/api.ts` — `Plan`, `SubscriptionStatus`, `BillingResponse`, `getBilling()`, `createCheckoutSession()`.
- `app/(dashboard)/billing/` — server-rendered page with a client `BillingPanel` component. Renders the current plan badge with a status pill, a usage bar that tints red above 85% utilization, the billing period card, and a three-tier plan strip with Upgrade buttons. Honors `?session=success`, `?session=cancel`, and `?placeholder=1` for visual feedback after the Checkout round-trip. Surfaces a banner when `stripe_configured` is `false`.
- `components/sidebar.tsx` — new `Billing` nav item between API keys and Settings.
- `middleware.ts` — `/billing` added to the auth-protected matcher.
Operational notes
To enable real Stripe in any environment, set these env vars on the API service:
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_PRICE_ID_MAISON`
- `STRIPE_PRICE_ID_COUTURE`
Without them, billing endpoints run in placeholder mode. Postgres will auto-apply migrations 0006 and 0007 on next API start, including the back-fill of existing brands.
Verified gates
- `cargo clippy --workspace -- -D warnings` — clean.
- `cargo test -p api --bin genuflow` — 4/4 webhook signature tests pass (valid signature, tampered body, expired timestamp, wrong secret).
- `next build` — 29 routes, `/billing` server-rendered.