# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The current version is the `version` field of `package.json`.

## [Unreleased]

### Added

- **Stripe billing groundwork (`SUBSCRIPTIONS.md` §1).** `laravel/cashier` v16 installed and its
  migrations published: `subscriptions` / `subscription_items` tables, and `stripe_id`, `pm_type`,
  `pm_last_four`, `trial_ends_at` columns on `users`.
  - `config/services.php` — `stripe.pro_price`, read from `STRIPE_PRO_PRICE_ID`.
  - `bootstrap/app.php` — `stripe/*` excluded from CSRF validation so Stripe's signed webhooks
    are not rejected.
  - `.env` / `.env.example` — `STRIPE_KEY`, `STRIPE_SECRET`, `STRIPE_WEBHOOK_SECRET`,
    `STRIPE_PRO_PRICE_ID`, `CASHIER_CURRENCY=eur`. The 1-year Product/Price itself is created by
    hand in the Stripe Dashboard, not in code.
  - The checkout flow, webhook sync and `/settings/subscription` page followed in §3, §4 and §7.
- **Subscription checkout (`SUBSCRIPTIONS.md` §3).** `SubscriptionController` with `edit()`,
  `checkout()` and `portal()`, a `SubscriptionCheckoutRequest` requiring an explicit `auto_renew`
  choice, and three routes under `settings/subscription`. Declining auto-renewal sets
  `subscription_data.cancel_at` one year out on the Checkout session, so Stripe cancels the
  subscription instead of billing it again; accepting it leaves normal recurring billing. Both
  Stripe redirects go through `Inertia::location()`, since an Inertia XHR cannot follow a 303 to
  another origin. Guarded against a subscribed user creating (and paying for) a second
  subscription, and against opening the billing portal without a Stripe customer.
  Covered by `tests/Feature/Settings/SubscriptionTest.php` (7 tests, no Stripe API calls).
- **`App\Models\Subscription`** — extends Cashier's, adds a `cancel_at` column (new migration) and
  an `autoRenews()` accessor, registered via `Cashier::useSubscriptionModel()` in
  `AppServiceProvider`. Cashier's own table has no column for "will this renew", so the choice made
  at checkout is remembered locally instead of costing a Stripe API call per page load.
- **Accreditation endpoint** — `POST /api/user/accreditation` (Sanctum), gated on a paid plan.
  Bumps `user_accredited` on every call and sets `user_first_accredited` only once. New
  `user_first_accredited` column, exposed through `AuthResource`.
- **Training completion** — `PATCH /api/user/training`, plus the training columns on `users` and
  their `AuthResource` fields.
- **Stripe webhook sync (`SUBSCRIPTIONS.md` §4).** `StripeWebhookController` extends Cashier's and,
  after each `customer.subscription.created` / `updated` / `deleted`, mirrors the Stripe payload
  onto the two things Cashier does not own: `users.user_plan` (`pro` while the Stripe status is
  `active`, `free` otherwise) and the local `subscriptions.cancel_at`, so an auto-renewal turned off
  from the Stripe portal is reflected in the app. `AppServiceProvider` calls `Cashier::ignoreRoutes()`
  so this controller serves `POST stripe/webhook` instead of Cashier's own.
  Covered by `tests/Feature/StripeWebhookTest.php` (7 tests).
- **`/settings/subscription` page (`SUBSCRIPTIONS.md` §7).** Two states: without a subscription, a
  `Renew automatically every year` preference form (the choice Stripe's hosted Checkout cannot ask,
  since it is a session-creation parameter); with one, a read-only status and a
  `Manage my subscription` button to the Stripe portal — deliberately no checkbox there, where it
  would be inert. Plus the `Subscription` entry in the settings sidebar.
- **Subscription state on the mobile API (`SUBSCRIPTIONS.md` §6).** `AuthResource` now serves
  `subscribed`, `subscription_ends_at` and `subscription_auto_renews` alongside the accreditation
  dates, so `coloid-api` and the mobile app can show the 1-year Pro billing state without a second
  endpoint. Covered by `tests/Feature/Api/AuthResourceSubscriptionTest.php`.
- **A `stripe` log channel** (`storage/logs/stripe.log`, daily, kept 90 days via
  `LOG_STRIPE_DAILY_DAYS`) carrying the billing trail: checkout sessions opened (with the
  `auto_renew` choice and the resulting `cancel_at`), billing portal visits, failures of either,
  and every webhook that changes a plan or a renewal date. Deliberately off the default stack so
  the money-related history reads on its own. Every delivery is logged on arrival, *before*
  dispatch, so an event nothing handles is still visible; unknown customers and missing local
  subscription rows are logged as warnings rather than passing silently.
- `SUBSCRIPTIONS.md` — the 8-step Stripe/Cashier implementation plan and its running status table.

### Changed

- **Free users land on `/settings/subscription` after login** instead of the dashboard, on both the
  password and the two-factor path. Pro users still land on the dashboard. The destination comes
  from `User::homeRoute()`; a page the user was actually heading for (Laravel's *intended* URL)
  still wins.
- **`UserPlan::PREMIUM` renamed to `UserPlan::PRO`, value `premium` → `pro`** (`SUBSCRIPTIONS.md` §2),
  with a data migration that widens the `user_plan` constraint, moves the rows, then narrows it.
  Label and `options()` now read `Pro`. The accreditation gate and the TypeScript `User['user_plan']`
  union follow. **Consumers must be updated in lockstep** — `coloid-api` still compares `plan` to the
  literal `'premium'`. Both consumers have since been updated: the mobile app accepts **both**
  spellings, and `coloid-api` (branch `v2`) now compares to `'pro'` at `classes/api.php:1580` and
  `:2495`. Because that was a straight swap rather than a tolerant check, `coloid-api` v2 and this
  branch have to be deployed together — v2 against an Auth API still serving `premium` breaks the
  same two gates for the mirror reason.
- `User` now uses Cashier's `Billable` trait.

### Fixed

- **A blank `users.stripe_id` made `/settings/subscription` 500.** Cashier's `hasStripeId()` only
  checks for null, so an empty string read as "this user already has a Stripe customer" and every
  call built on it — checkout, the billing portal — asked Stripe for the customer with the empty id
  (`The resource ID cannot be null or whitespace`). `User::hasStripeId()` now treats blank as
  absent, so a customer is created instead, and a migration nulls the rows already in that state.
- **A rejected webhook signature left no trace.** Cashier's middleware rejects it before any
  controller runs, and a 403 `HttpException` is not reported, so a delivery Stripe made and the app
  refused was indistinguishable from one that never arrived — the single hardest thing to diagnose
  in this flow. `bootstrap/app.php` now logs `Webhook signature rejected` to the `stripe` channel
  and answers with a plain JSON 403; the `respond()` handler that turns every other 403 into the
  Inertia error page now leaves `stripe/*` alone, since the caller is a machine reading a status
  code.
- **Stripe API failures no longer surface as a bare 500.** `SubscriptionController::checkout()` and
  `portal()` catch `ApiErrorException`, log it to the `stripe` channel and return a retryable
  message as a `stripe` validation error, which the page renders through `<InputError>`. This
  matters most for the two misconfigurations that are easy to hit: a price id that is not a
  `price_…`, and the customer portal not being configured in the Stripe Dashboard (test mode has
  its own configuration).

- **`UserObserver::updated()` no longer crashes on a user without a profile.** It dereferenced
  `$user->profile` unconditionally, so any update outside an HTTP request — precisely what the
  Stripe webhook does when syncing `user_plan` — threw, which would have made Stripe retry the
  webhook indefinitely while the plan never applied. Covered by `tests/Feature/UserObserverTest.php`.
  This also fixes 4 previously failing tests (logout, 2FA redirect, password update, account
  deletion), which were hitting the same null profile.

### Known issues

- **Cancelling does not end access immediately, by design.** Stripe keeps the subscription `active`
  until the paid period ends and only stops billing, so the page shows Pro with an expiry date
  rather than reverting to free on the day of cancellation. `user_plan` flips to `free` when
  `customer.subscription.deleted` fires at the period end.
- **The expiry date renders in the browser's locale inside an English sentence** ("Expires on
  28 août 2027"), because `Subscription.vue` formats it with `toLocaleDateString(undefined, …)`
  while the rest of the UI is English. Cosmetic; pick a locale when the app's own language story
  is settled.
- **`STRIPE_WEBHOOK_SECRET` must be set on every reachable environment.** With an empty secret
  Cashier does not register signature verification, and `stripe/*` is already excluded from CSRF —
  so any unauthenticated POST could grant itself the `pro` plan now that §4 syncs `user_plan` from
  the payload.
- **No renewal date is available for an auto-renewing subscription.** Cashier's `subscriptions`
  table has no `current_period_end`, and `ends_at` is only written on cancelation, so the `endsAt`
  prop is null whenever the subscription renews, and so is the mobile API's `subscription_ends_at`.
  Both surfaces say "Renews automatically" without a date rather than query Stripe on every page
  load; the Stripe portal shows the real date.
- 14 Feature tests across the auth and settings suites still fail (down from 18 before the
  `UserObserver` fix). Pre-existing on `main`, unrelated to billing, not yet diagnosed.
- `UserObserver::updated()` assigns the recomposed `$user->name` after the save, so it is never
  persisted. Long-standing; left alone because saving inside `updated()` risks recursion.

## [1.0.9]

Baseline. Changes up to this point are recorded in the git history only — see
`git log 81fac6d` and earlier.
