# Billing (platform)

## Purpose

Paid ServDiary access: one plan at **£49.99 per month** (GBP) with a **30-day free trial**. After public registration, the team owner must complete Stripe Checkout (Laravel Cashier) before using the product.

This is **platform**, not a removable product domain.

## Boundaries

- **Owns:** Cashier + Stripe on `Team`, subscribe/checkout routes, `EnsureTeamSubscribed` middleware, billing portal redirect, `config/billing.php`, checkout-session / customer subscription sync
- **Does not own:** Product CRM/scheduling features, invoice/quote charges to a team’s own customers (that is the Invoices domain)
- **Depends on (platform only):** User / Team / Fortify registration / Jetstream
- **Depends on (other domains):** none
- **Removable?** No — shared kernel. Signup cannot skip this flow.

## Models

The billable model is **`Team`**, not `User`. One subscription per workspace. Invited members inherit the team’s subscription.

| Model / table | Notes |
|---------------|--------|
| `teams` | Cashier customer columns: `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at` |
| `subscriptions` | Cashier subscriptions (`team_id` FK → `teams`, cascade delete) |
| `subscription_items` | Cashier subscription line items |

Schema is normalised (3NF). Display price `£49.99` in `config/billing.php` is **UI copy only**; Stripe is the source of truth for charging. Documented here as intentional denormalisation of the published price for the subscribe page and marketing site.

`Team::stripeEmail()` uses the owner’s email (teams have no email column).

## Plan

| Key | Value |
|------|--------|
| Price | £49.99 / month |
| Currency | GBP (`CASHIER_CURRENCY=gbp`) |
| Trial | 30 days, payment method collected up front |
| Stripe Price ID | `STRIPE_PRICE_ID` |

Create a recurring GBP price in the Stripe Dashboard and set `STRIPE_PRICE_ID`. Also set `STRIPE_KEY`, `STRIPE_SECRET`, and `STRIPE_WEBHOOK_SECRET`.

Webhook URL: `POST /stripe/webhook` (Cashier). Exclude from CSRF (`stripe/*`). Enable every event in `config/billing.php` `webhook_events` on **each** environment’s endpoint (sandbox ≠ production):

- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.updated` / `customer.deleted`
- `payment_method.automatically_updated`
- `invoice.payment_succeeded` / `invoice.payment_action_required`

Missing `customer.subscription.created` (or `checkout.session.completed`) is why a completed Checkout can still show the subscribe page: the local `subscriptions` row is never written.

`php artisan cashier:webhook` uses that list (including `checkout.session.completed`). It **creates a new** endpoint; it does not update one you already made in the Dashboard. Edit the existing endpoint’s events there, then resend any missed events for customers who already paid.

`APP_URL` must be the public host for that environment (e.g. `https://sandbox.servdiary.com`). Stripe Checkout `success_url` / `cancel_url` and the webhook URL are built from it. A webhook pointed at production or localhost will not update sandbox teams.

## Routes

### Web

| Method | URI | Name | Controller |
|--------|-----|------|------------|
| GET | `/subscribe` | `subscribe.show` | `SubscribeController@show` |
| POST | `/subscribe/checkout` | `subscribe.checkout` | `SubscribeController@checkout` |
| GET | `/subscribe/success` | `subscribe.success` | `SubscribeController@success` |
| GET | `/billing/portal` | `billing.portal` | `BillingPortalController` |

Auth: `auth:sanctum`, Jetstream session, `verified`. Checkout is throttled (`subscribe`, 5/min).

Fortify **register** always redirects to `subscribe.show` (`App\Http\Responses\RegisterResponse`). Login of an unsubscribed **owner** also goes to subscribe (`LoginResponse`). Customers, providers, and other members are switched onto a subscribed workspace they belong to and are not sent to Checkout.

### API

No subscribe checkout on the API. Mobile clients complete billing on the web. `POST /api/login` and `GET /api/me` include `subscribed`. Other authenticated API routes return **402** until the current team is subscribed.

## Gate

`EnsureTeamSubscribed` is appended to the `web` and `api` groups.

- Guests: ignored
- Superadmins: bypass (support / act-as-team)
- Users with no current team: ignored (profile-only Jetstream cases)
- Unsubscribed current team: switch to a **subscribed team the user already belongs to** (portal customers/providers often have an unsubscribed personal team)
- Still unsubscribed, **owner**: web → `/subscribe`; JSON → 402
- Still unsubscribed, **non-owner** (customer, provider, staff, editor): web → 403; JSON → 402. They are never sent to Checkout.

Exempt while unsubscribed: `subscribe.*`, `logout`, `cashier.webhook`, `current-team.update`, `api.logout`, `api.me`.

`$team->subscribed()` is true for **trialing** and **active** Cashier subscriptions. Access is gated on the **local** `subscriptions` row, not Stripe Checkout completion by itself.

Returning from Checkout (`GET /subscribe/success?session_id=…`) retrieves the Checkout Session and stores the subscription immediately so the owner is not blocked waiting for webhooks. Visiting `/subscribe` or logging in also lists open Stripe subscriptions for the team’s `stripe_id` when that row is still missing. `checkout.session.completed` is handled the same way as a backstop.

## Permissions

- **Start trial / checkout:** team owner only
- **Billing portal:** team owner only, and only once a Stripe customer exists
- Creating an additional team starts a **new** subscription for that workspace

## Artisan

- `php artisan billing:grant {email…}` — mark each owner’s current (or personal) team subscribed. Tries Stripe first when `teams.stripe_id` is set; otherwise writes a local **active** Cashier row (`sub_manual_…`). Use `--local` to skip Stripe.
- `php artisan cashier:webhook` — create a **new** Stripe webhook endpoint from `config/billing.php` `webhook_events`

This is operator-only. Public signup still cannot skip Checkout.

## Tests

- Path: `laravel/tests/Feature/Billing/SubscribeFlowTest.php`, `laravel/tests/Feature/Billing/GrantSubscriptionCommandTest.php`
- Also: `RegistrationTest` (redirect to subscribe), `AuthenticationTest` (subscribed factory still reaches dashboard), `LegalDocumentsTest` (fees copy), `Api\AuthTest` (`subscribed` flag)
- Factory: `User::factory()->withPersonalTeam()` creates a **local** trialing subscription (no Stripe). Public signup does not. Use `withUnsubscribedPersonalTeam()` for billing-gate tests.

## Operator setup

1. Stripe product + monthly GBP price £49.99
2. `.env`: `STRIPE_KEY`, `STRIPE_SECRET`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID`, `CASHIER_CURRENCY=gbp`
3. `php artisan migrate`
4. Point Stripe webhooks at `{APP_URL}/stripe/webhook` (sandbox and production are separate endpoints)
5. Enable every event in `config/billing.php` `webhook_events` on that endpoint (edit an existing endpoint; do not only run `cashier:webhook` if one already exists)
6. Resend missed events from Developers → Events for any team that already completed Checkout
7. For owners already stuck on subscribe: `php artisan billing:grant owner@example.com other@example.com` on that environment (deploys the command first). `--local` if they never became a Stripe customer.

## Notes for agents

- Do not move this into `app/Domains/`.
- Do not add extra plans or skip-checkout flags.
- Do not call live Stripe from Feature tests; mock checkout/start-sync actions and use factory subscriptions, or pass a constructed Checkout Session / webhook payload into the store helpers.
- Stripe Checkout is an **external** URL. The subscribe page must POST `/subscribe/checkout` as a **full-page form**, not Inertia `form.post` / Axios. An Inertia XHR that follows Cashier’s 303 to `checkout.stripe.com` fails CORS. If an Inertia visit still hits checkout, the controller returns `Inertia::location()` (`409` + `X-Inertia-Location`).
- After a successful Checkout, do not rely on webhooks alone. Success and login/subscribe pages must sync the local Cashier row (session id or customer list).
