# P0 — Private listing checkout `cancel_at` fix

Production bug: publishing a personal listing with a fixed duration (6 or 12 weeks) returned HTTP 500 because Stripe Checkout rejected `subscription_data[cancel_at]`.

## What changed

- Checkout no longer sends `cancel_at` in `subscription_data` (duration remains in metadata).
- Billing success and `checkout.session.completed` validate the Checkout Session server-side (`status=complete`, `payment_status` is `paid` or `no_payment_required`) and read `listing_id`, `tier`, and `duration` from session metadata only.
- Single-use checkout sessions are recorded in `private_listing_checkout_sessions` with a **unique** `stripe_checkout_session_id`; success URL and webhook both claim via the same action (idempotent for the same listing).
- `cancel_at` and listing `expires_at` are computed from the Stripe subscription anchor (`billing_cycle_anchor` or `start_date`) plus N weeks, with `proration_behavior=none` (never `current_period_start`).
- If that `cancel_at` is in the past or less than ~1 hour away, the app sets `cancel_at_period_end=true` (with `proration_behavior=none`), aligns listing `expires_at` to `current_period_end`, logs a warning, and reports to Sentry.
- Subscription end scheduling runs via billing completion, webhooks, and hourly reconcile.
- Stripe API errors when starting checkout show a generic banner and are reported to Sentry.

## Deploy steps

Production on **srv07** runs **PHP 8.5**. Use `/opt/cpanel/ea-php85/root/usr/bin/php` for CLI and cron — on that host it is the only EasyApache PHP build with the **Redis** extension. Running Artisan with **ea-php84** fails at boot with `Class "Redis" not found`.

1. Deploy the application release as usual.
2. Run migrations (`private_listing_checkout_sessions`):

   ```bash
   cd ~/public_html/baymove.co.uk
   /opt/cpanel/ea-php85/root/usr/bin/php artisan migrate --force
   ```
3. Clear caches **without** `optimize:clear`:

   ```bash
   cd ~/public_html/baymove.co.uk
   /opt/cpanel/ea-php85/root/usr/bin/php artisan config:clear
   /opt/cpanel/ea-php85/root/usr/bin/php artisan route:clear
   /opt/cpanel/ea-php85/root/usr/bin/php artisan view:clear
   /opt/cpanel/ea-php85/root/usr/bin/php artisan optimize
   ```

4. Ensure cron runs the scheduler on the app host (cPanel example):

   ```bash
   * * * * * cd ~/public_html/baymove.co.uk && /opt/cpanel/ea-php85/root/usr/bin/php artisan schedule:run >> /dev/null 2>&1
   ```

   This runs `listings:reconcile-private-subscription-cancel-at` hourly alongside `listings:expire-due`.

## Stripe webhook (production) — required

Configure the production webhook endpoint (default path `/{CASHIER_PATH}/webhook`, usually `/stripe/webhook`) with:

| Requirement | Detail |
| --- | --- |
| **`STRIPE_WEBHOOK_SECRET`** | Set in production env to the signing secret shown in the Stripe Dashboard for this endpoint. Cashier uses it to verify webhook signatures. |
| **`checkout.session.completed`** | **Required.** Completes private listing checkout when the customer does not return via the success URL (same atomic session claim as the redirect). **Not** included in Cashier `WebhookCommand::DEFAULT_EVENTS` — add it manually on the endpoint in the Stripe Dashboard, or create/update the endpoint with the full event list (see below). |
| **`customer.subscription.created`** | **Required.** Applies `cancel_at` (or period-end fallback) on the new subscription. Included in Cashier defaults. |

`checkout.session.completed` is **not** one of Cashier’s default webhook events. Either:

- Open the production webhook in the [Stripe Dashboard](https://dashboard.stripe.com/webhooks), choose **Select events**, and add **`checkout.session.completed`** while keeping the existing Cashier events (`customer.subscription.*`, invoices, customers, and so on), or
- Register the endpoint with every event Cashier expects **plus** `checkout.session.completed`, for example:

  ```bash
  cd ~/public_html/baymove.co.uk
  /opt/cpanel/ea-php85/root/usr/bin/php artisan cashier:webhook --events=customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,customer.updated,customer.deleted,payment_method.automatically_updated,invoice.payment_action_required,invoice.payment_succeeded,checkout.session.completed
  ```

  Use your app’s public webhook URL if it differs from the default (`--url=...`). If your Cashier version does not accept `--events`, set the same list under `config/cashier.php` → `webhook.events` before running `/opt/cpanel/ea-php85/root/usr/bin/php artisan cashier:webhook`.

## Verification

1. Publish a personal listing with **6 weeks** — expect Checkout, not HTTP 500.
2. Complete payment — Stripe subscription **Cancel at** and listing `expires_at` should match (billing period start + 6 weeks), unless the period-end fallback applies.
3. Repeat for **12 weeks** and **Indefinite** (no subscription end date).
4. Confirm Sentry is not receiving `subscription_data[cancel_at]` errors after deploy.
