whisper-money/resources/js/components
Víctor Falcón b6bc7756bc
feat(subscriptions): end the trial experiment and make the trial length per plan (#762)
Ends the trial/pricing A/B/C experiment. Everyone gets the control offer
— a free trial — and the trial length becomes a per-plan setting.

## Trial length

| Plan | Before | Now | Env override |
|---|---|---|---|
| Yearly | 15 days | **15 days** | `STRIPE_PRO_YEARLY_TRIAL_DAYS` |
| Monthly | 15 days | **7 days** | `STRIPE_PRO_MONTHLY_TRIAL_DAYS` |

**Note that monthly 15 → 7 is a new bet, not a rollback.** The control
arm was 15 days on both plans, and 7 days on monthly is a value the
experiment never tested (`reduced_trial` was monthly 3 / yearly 7). The
rationale is that the longer commitment earns the longer trial; it ships
here at the same time as the instrument that could measure it is
removed, so it will not be measurable as an isolated effect.

## Final experiment numbers

Archived here because `stats:experiment-funnel` and its collector are
deleted by this PR and the purge migration's `down()` is a no-op.

| Variant | Assigned | Subscribed | Active | Refunded |
|---|---|---|---|---|
| control | 597 | 46 | 13 | 0 |
| reduced_trial | 590 | 50 | 10 | 0 |
| pay_now | 609 | 41 | 21 | 18 |
| legacy | 94 | 55 | 31 | 0 |

## What is deleted

- `App\Features\SubscriptionExperiment` (the Pennant A/B/C assignment)
and the `ExperimentOffer` service.
- The `pay_now` self-service refund: `RefundSelfServe`, the
`settings.billing.refund` route, the controller actions and Discord
embeds, the money-back card in billing settings, and the
`stripe:verify-refund` sandbox command.
- The weekly `stats:experiment-funnel` report, its collector, and the
`ProportionSignificance` / `BinomialProportion` helpers it was the only
caller of, plus its schedule entry.
- The `subscriptions.experiment.*` config block and the orphaned
`es`/`fr` translation strings.
- A data migration purges the ~1,890 stored Pennant assignments.

`subscriptions.refunded_at` is deliberately **kept**: nothing reads it
anymore, but it is the only record of the 18 refunds the experiment
issued. The migration carries a comment saying so.

## Fixes found in review

- **The surviving funnel report was mis-scoring conversions.**
`SubscriptionFunnelCollector` compared every cancellation to one global
trial length. With trials now diverging per plan, a monthly subscriber
who was billed and cancelled on day 10 was scored as never having paid.
It now reads each subscription's own `trial_ends_at`, and the longest
plan trial is used only for deciding when a cohort is old enough to
score. Covered by two new tests.
- **The trial length swapped silently.** It lived on a single line under
the plan selector, which rewrote itself when the user switched plan. Now
that the plans genuinely differ, each plan card shows its own length.
- The report legend no longer quotes a single trial length for both
plans, and warns that the experiment weeks are still inside its window.

## Before merging

- [x] **Unset `SUBSCRIPTION_EXPERIMENT_STARTED_AT` in production** so no
new `pay_now` assignment happens while this waits. Anyone who checks out
under `pay_now` between now and the deploy is charged upfront and then
loses the one-tap refund they were promised at the point of payment.
Checked just before opening this PR: **0 `pay_now` subscriptions
currently inside the 3-day window**, so nobody is stranded today.
- [x] Drop the now-orphaned `SUBSCRIPTION_EXPERIMENT_*` variables from
the production env with the deploy.
- [x] If old containers are still serving while the purge migration
runs, a few assignments can be re-resolved and reappear. Harmless —
re-run `php artisan pennant:purge "App\Features\SubscriptionExperiment"`
once the deploy settles if you want the table clean.

Support note: a manual Stripe refund for a former `pay_now` user will
not disconnect their bank connections, which the automated flow used to
do.

## Demo


https://github.com/user-attachments/assets/3614d488-05c6-405d-a687-bbf45746879a


<!-- PLACEHOLDER: drag the QA video here -->

## QA

Browser-tested against the running app:

- Paywall: annual card shows "15 days free", monthly card "7 days free";
the terms line under the selector follows the selected plan (15 ↔ 7);
mobile viewport renders fine.
- Billing settings: no money-back card for a free user or an active
subscriber; `POST /settings/billing/refund` returns 404.
- No console or network errors on any screen.
- `stats:subscription-funnel` still renders and posts.
- The purge migration leaves 0 `SubscriptionExperiment` rows.

Full suite green (2045 tests) apart from the known local-only
`DashboardTest` 409; `pint`, `lint`, `format` and `build` all clean.
2026-08-12 10:59:55 +02:00
..
accounts feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
automation-rules
…
budgets
…
cashflow
…
categories refactor(frontend): call backend routes through Wayfinder instead of literal URLs (#772) 2026-08-11 13:26:10 +00:00
charts
…
dashboard refactor(frontend): call backend routes through Wayfinder instead of literal URLs (#772) 2026-08-11 13:26:10 +00:00
icons
…
integration-requests
…
labels
…
landing feat(landing): stop auto-redirecting signed-in visitors to the dashboard (#777)a 2026-08-11 14:02:18 +00:00
onboarding fix(observability): stop our own page navigations from reporting fake network errors (#780) 2026-08-11 16:12:00 +00:00
open-banking
…
partials feat(roadmap): add a public roadmap page mirrored from UserJot (#778) 2026-08-11 14:14:59 +00:00
shared refactor(settings): move the last two settings tables onto the shared table and hook (#775) 2026-08-11 13:54:18 +00:00
subscription feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
transactions fix(observability): stop our own page navigations from reporting fake network errors (#780) 2026-08-11 16:12:00 +00:00
ui
…
alert-error.tsx
…
app-content.tsx
…
app-error-boundary.test.tsx fix(ui): show a recoverable screen instead of a blank page when render throws (#758) 2026-08-10 15:02:39 +00:00
app-error-boundary.tsx fix(observability): stop our own page navigations from reporting fake network errors (#780) 2026-08-11 16:12:00 +00:00
app-logo-icon.tsx
…
app-logo.tsx
…
app-shell.tsx
…
app-sidebar-header.tsx
…
app-sidebar.tsx
…
appearance-tabs.tsx
…
bank-logo.tsx
…
breadcrumbs.tsx
…
delete-user.tsx
…
encryption-key-button.tsx
…
heading-small.tsx
…
heading.tsx
…
icon.tsx
…
input-error.tsx
…
mobile-back-button.tsx
…
nav-footer.tsx
…
nav-main.tsx
…
nav-user.tsx
…
pro-badge.tsx
…
sortable-grid.test.tsx
…
sortable-grid.tsx
…
support-dialog.tsx
…
text-link.tsx
…
two-factor-recovery-codes.tsx
…
two-factor-setup-modal.tsx
…
unlock-message-dialog.tsx
…
user-info.tsx
…
user-menu-content.test.tsx feat(feedback): move feedback and roadmap links from Canny to UserJot (#748) 2026-08-10 06:54:21 +00:00
user-menu-content.tsx feat(feedback): move feedback and roadmap links from Canny to UserJot (#748) 2026-08-10 06:54:21 +00:00