Skip to content

v0.4.0 — Phase 4: billing infrastructure, Stripe-ready

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05
· 8 commits to main since this release

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.