## What
An A/B price experiment to find the price that **maximizes contribution
margin per new user**, not just conversion. **Inert until
`PRICE_EXPERIMENT_STARTED_AT` is set** — merging changes nothing in
production.
| Arm | Monthly | Annual (= monthly × 6) |
|-----|---------|------------------------|
| A · control | €3.99 | €23.88 *(unchanged)* |
| B · high | €8.99 | €53.94 |
New signups only; earlier users stay `legacy`/control. Two arms rather
than the originally designed A/B/C: at current signup volume three arms
leave the CM metric underpowered, and the wide €3.99↔€8.99 gap maximizes
the detectable signal.
## Rebuilt on top of #762
This branch was written against the #600 trial experiment. #762 then
ended that experiment and deleted `ExperimentOffer`,
`SubscriptionExperiment`, `ExperimentFunnelCollector`,
`ProportionSignificance` and `BinomialProportion` — the exact foundation
this extended.
Rather than resurrect them, the branch was reset onto `main` and
rewritten: **1478 → 401 insertions, 19 → 8 files.** The previous version
is preserved at the tag
[`price-experiment-full`](https://github.com/whisper-money/whisper-money/tree/price-experiment-full).
## How it works
- **`App\Services\Subscriptions\PriceExperiment`** — `variantFor` /
`plansFor` / `lookupKeyFor`, gated by `PRICE_EXPERIMENT_STARTED_AT`,
winner pinned via `PRICE_EXPERIMENT_FORCE_VARIANT` (env-only, no
deploy).
- Assignment is a **salted hash**, `crc32('price:'.$id) % 2` —
deliberately *not* a stored Pennant feature. Nothing needs persisting,
reading back, or purging when the experiment ends (#762 needed a
migration to delete 1,890 stored assignments). It also costs no query on
the render path, and a report can reproduce the split in SQL with
`CRC32(CONCAT('price:', id))`. The salt keeps the split independent from
any other crc32-based one on the same ids.
- Checkout resolves the lookup key **server-side only**, so a client
can't self-select the cheaper price. `HandleInertiaRequests` makes the
shared `pricing.plans` prop variant-aware, so the paywall and the
upgrade dialogs show exactly what will be charged — no frontend change
needed.
- **`stripe:sync-prices`** now also creates the variant tiers.
## Measurement is deliberately not in this PR
The previous version shipped ~1100 lines of measurement:
`stats:price-experiment-funnel`, its collector, `WelchTTest`, `Normal`,
`SampleRatioMismatch`, `MonthlyEquivalentPrices`, a weekly schedule
entry and their tests. All of it is cut here.
Every input is reconstructible at any time — the bucket is
deterministic, and `created_at`, subscriptions and connections are all
stored — so no data is lost by not capturing it weekly. And the design
calls for deciding **only at a pre-registered horizon**, which made a
weekly Discord report whose largest field read *"⚠️ MONITORING ONLY — do
not call a winner from this"* mostly ceremony.
It gets rebuilt from the tag when there is data worth reading. Checking
mid-flight that the split is really 50/50 needs no code:
```sql
SELECT CRC32(CONCAT('price:', id)) % 2 AS arm, COUNT(*)
FROM users WHERE created_at >= '<started_at>' GROUP BY arm;
```
The analysis design itself still stands and is recorded for the horizon:
CM/user primary via Welch (the €8.99 arm carries far more revenue
variance, so pooled-variance is invalid), conversion as a Fisher-exact
guardrail against control, SRM chi-square on assigned and matured
counts, cost from *currently-active* connections, and a guard that every
arm price id resolves in the Stripe product map.
## Incidental
`SyncStripePricesCommand::handle` was already at cyclomatic complexity
11 on `main`; touching one line surfaced it in the `crap` check. The
per-plan body moved into `syncPlan()` — pure extraction, same output.
## Before launching (in order)
1. `php artisan stripe:sync-prices` — creates the €8.99 / €53.94 tiers
under the lookup keys `whisper_pro_monthly_high` and
`whisper_pro_yearly_high`. **Must run before step 2.**
2. Set `PRICE_EXPERIMENT_STARTED_AT` to the launch date.
3. Compute and pre-register the horizon N from the real signup rate — no
peeking before it.
The #600 trial experiment is over, so price is automatically the only
moving variable; there is nothing to pin.
## Tests
Pest coverage for the gate, the forced-variant pin, the salt, split
stability per user, the variant prices and lookup keys, the paywall
prop, server-side checkout resolution, and variant syncing in
`stripe:sync-prices`. Affected suites green locally; `pint` and `php
artisan crap` clean.
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.
Fixes
[PHP-LARAVEL-5B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-5B).
## What broke
`demo:reset` seeds a fabricated Stripe subscription so the demo account
gets Pro access without touching Stripe. The id was the hardcoded
literal `sub_demo_free_forever`, and `subscriptions.stripe_id` is
unique.
`--email` (#753, shipped this morning) made a second seeded account
possible. `createSubscription()` deletes only the *current* user's
subscriptions before inserting, so the first `demo:reset --email` run
after the public demo account existed died on:
```
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry
'sub_demo_free_forever' for key 'subscriptions.subscriptions_stripe_id_unique'
```
`createSubscription()` is the last step of `handle()`, so the reviewer
account was left fully seeded but with **no subscription at all** — an
app-store reviewer signing in lands on the paywall instead of the Pro
app.
## The fix
Derive the fake id from the user: `sub_demo_{$user->id}`. Matches what
`E2eBankingFixtureCommand` already does (`sub_e2e_.$user->id`). Nothing
reads the old literal — entitlement goes through Cashier's
`subscribed('default')`, which only looks at `stripe_status`. Production
is self-healing: the next run replaces the old row, and the demo account
keeps working until then.
## Second commit: seeded accounts and the Stripe billing paths
Fixing the collision means reviewer accounts now genuinely have
`hasProPlan() === true`, which switches on paths that were unreachable
while they were paywalled. `isDemoAccount()` is equality against
`config('app.demo.email')`, so an `--email` account is **not** a demo
account and skipped every existing guard:
- `SubscriptionController::billingPortal` would call
`createAsStripeCustomer()` — creating a real Stripe customer in
production — and drop the reviewer on an empty portal.
- Bucketed into `PAY_NOW`, `canSelfRefund()` returns true, so the refund
box renders; `RefundSelfServe::handle` then calls
`$subscription->latestPayment()` on `sub_demo_<uuid>` → Stripe 404,
thrown above the `try`, 500 plus a false `🔴 Self-service refund FAILED —
the user may have been charged without a refund` Discord alert.
`User::hasSeededSubscription()` keys off the fabricated id prefix rather
than one hardcoded email, so it also covers the e2e fixture account.
Real Stripe ids are `sub_` + alphanumerics with no further underscore,
so no paying user can match it.
## Tests
- `demo:reset subscribes a named account even when the public demo
account already exists` — seeds the public demo account, then a named
one. Verified it fails on the pre-fix code with the **identical**
SQLSTATE 1062 message from the Sentry event, and asserts the two ids
differ so a regression back to a shared literal is caught.
- `a seeded reviewer account cannot reach the billing portal`, `blocks a
self-refund on a seeded demo subscription`.
Local: `ResetDemoAccountCommandTest` 7/7, `SelfServeRefundTest` +
`DemoAccountRestrictionsTest` + `SubscriptionTest` 59/59.
## Not fixed here (found during review, out of scope)
- `ResetDemoAccountCommand` falls back to
`Bank::factory()->create(['user_id' => null])` when a named bank is
missing, permanently adding randomly-named **global** banks visible to
every user. `firstOrCreate(['name' => …, 'user_id' => null])` would fix
it.
- Seeded accounts count as paid in `SubscriptionFunnelCollector` /
`ExperimentFunnelCollector` unless their email is in
`AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS`.
- `handle()` runs without a transaction, so a failure mid-reseed still
leaves a half-seeded account.
> **Stacked on #696.** That PR introduced the AI-categorization upgrade
dialog this one generalizes. It targets `main` so CI runs, so until #696
merges this PR's diff also contains #696's commit — review/merge #696
first, then this diff resolves to just its own three commits.
## What & why
The contextual "this is a paid feature → pick a plan → checkout" modal
built for AI categorization is now a **shared component** reused at two
more Pro-feature entry points, and every checkout it starts is
**attributed to the upsell point** so we can measure revenue per point.
### 1. Reusable upgrade modal
Extracted `AiUpgradeDialog` (+ `PlanCard`) out of `settings/billing.tsx`
into a shared `UpgradeDialog`
(`resources/js/components/subscription/upgrade-dialog.tsx`). It reads
`pricing`/`locale` from `usePage`, takes `title` / `description` /
`source`, renders the plan picker, and links to Stripe checkout. Used
at:
| Point | Trigger | Copy |
|---|---|---|
| AI categorization | Manage Plan toggle (unchanged) | "AI
categorization is a paid feature" |
| **Connections** | "Connect Bank" | "Bank connections are a paid
feature" |
| **Connected accounts** | Create Account → "Connected" | "Connected
accounts are a paid feature" |
Both new points already gated on `isFreePlan`, so the modal only shows
to free users. The old plain `UpgradeConnectionDialog` (which just
routed to billing) is deleted.
### 2. Revenue attribution
The upsell `source` is captured two ways:
- **Intent** — a PostHog `upgrade_checkout_started` event (`{ source,
plan }`) fires on click, matching the repo's existing `{ source }` event
convention.
- **Revenue** — `source` rides the checkout URL (`?plan=&source=`), and
`SubscriptionController::checkout` validates it against the new
`App\Enums\UpsellSource` enum and attaches it as Stripe **subscription
metadata** (`withMetadata`). When the subscription webhook lands,
`PersistUpsellSourceFromStripe` (on Cashier's `WebhookHandled`, so the
row already exists) copies it onto a new `subscriptions.upsell_source`
column — **write-once** (`whereNull`), so a later `subscription.updated`
never overwrites the original attribution.
Measuring revenue per point is then a group-by on
`subscriptions.upsell_source` joined to invoices, using existing local
tooling.
### Scoping notes (from review)
- **Paywall & the billing on-page upgrade button are intentionally left
untagged** (`upsell_source = NULL`). "Upsell source" means a
*feature-gate nudge*; the paywall/billing pages are the baseline upgrade
surface, not a nudge — so they form the null/baseline bucket rather than
getting their own enum value.
- The listener is **synchronous** (not `ShouldQueue` like the sibling
Discord listener) on purpose: a single indexed, idempotent `UPDATE`
doesn't warrant a queue-worker dependency for attribution.
- The PostHog event fires just before a full-page navigation; posthog-js
flushes via beacon but the event (analytics only, not the revenue source
of truth) could occasionally be lost. Cheap to harden later if the
funnel proves lossy.
## Tests
- `upgrade-dialog.test.tsx` — renders per-feature copy, checkout link
carries `plan` + `source`, click fires the PostHog event.
- `SubscriptionTest` — checkout tags valid sources as Stripe metadata
and ignores unknown ones.
- `PersistUpsellSourceFromStripeTest` — persists from webhook metadata,
doesn't overwrite an existing attribution, ignores unknown/absent
sources.
## Demo
Free user hitting both new upsell points (Connections → "Connect Bank",
Accounts → "Connected"):
<!-- 📎 PLACEHOLDER: drag in
~/Downloads/upsell-points-connections-accounts.mp4 -->
https://github.com/user-attachments/assets/a27435d9-2296-4dc9-ae7f-753b6f7950f0
> Note: the clip ends at the modal (doesn't cross into Stripe) because
this local dev env has a pre-existing Stripe tax-rate 500 on
`/subscribe/checkout`, unrelated to this change. The `source` reaching
the checkout URL is verified in the browser and by the tests.
## What
Several related tweaks to the subscription paywall shown before a user
subscribes:
1. **Remove the Balance stat** from the top stat card.
2. **Mobile dismiss (X)** replacing the bottom "Continue for free"
button on small screens (when the free plan is available).
3. **Support button** when the paywall *can't* be skipped.
4. **Copy updates** to the social-proof slider.
## 1. Remove the Balance stat
The Balance column showed summed account balances per currency (e.g.
`58.031 MXN 65 US$`). As a variable-length, multi-currency string it
overflowed the stat card on mobile and broke the four-column layout. The
remaining stats (Accounts, Transactions, Categories) are short integer
counts, so it now reads as a clean three-column row.
- `SubscriptionController@getUserStats`: also drops the
`balancesByCurrency` computation — which ran an **N+1 query** (one
`AccountBalance` lookup per account) — and the never-rendered
`automationRulesCount`.
## 2. Mobile dismiss (X)
The "Continue for free" escape (shown only when `canUseFreePlan`) now
renders on **mobile** as a dismiss **X** fixed to the top-right corner
instead of a full-width bottom button, matching the common
mobile-paywall pattern. Same 5s delayed fade-in, same action (continue
on the free plan). On **desktop (md+)** the bottom button is unchanged.
Reuses the app's `MobileBackButton` treatment (44px target, rounded
pill, border/shadow/backdrop-blur) for legibility.
## 3. Support escape hatch (`!canUseFreePlan`)
When the user has **no** free-plan escape, the paywall previously
offered no way out. It now shows a subtle **help/support** affordance in
the same slots the free-plan escape uses — bottom button on desktop,
top-right corner on mobile. It fades in slightly later than the
free-plan escape (**7s vs 5s**) and is deliberately low-key (muted
ghost, no pill) so it doesn't compete with the subscribe CTA. It opens
the existing `SupportDialog` (join the community / email support) — the
same one behind the user-menu "Support" entry.
The two escapes are mutually exclusive per page load, so the free-button
timer collapses into one `escapeVisible` timer whose delay depends on
`canUseFreePlan`.
## 4. Copy updates
Shortened the social-proof lines (`taking control of their finances` →
`trusting us`, etc.), bumped the user count to `2,500+ users`, slightly
reduced the proof icon. `lang/es.json` updated to match.
## QA
Real browser QA across all four states (see Demo), each ending by
exercising the actual action:
- **With free plan** → the X (mobile) / "Continue for free" (desktop)
navigates to `/dashboard`.
- **Without free plan** → the "Need help?" button opens the support
modal (Join the community / Email support).
No console errors from the paywall.
## Demo
**Desktop — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-desktop-with-free.mp4 here -->
https://github.com/user-attachments/assets/f3c943d8-5dfd-4b08-8f94-1683d38b11f7
**Desktop — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-desktop-no-free.mp4 here -->
https://github.com/user-attachments/assets/f7542b41-a0d1-491e-ab8b-4734d1c77af2
**Mobile — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-mobile-with-free.mp4 here -->
https://github.com/user-attachments/assets/6265ab86-cea2-4ade-9720-17ea8b003b1c
**Mobile — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-mobile-no-free.mp4 here -->
https://github.com/user-attachments/assets/e53d68ee-3ff9-4091-bba8-028489dd0b8d
## What
A 3-way experiment on how the paid plan is offered, plus per-variant
measurement. New signups (on/after `SUBSCRIPTION_EXPERIMENT_STARTED_AT`)
are split evenly into:
- **control** — current 15-day trial.
- **reduced_trial** — shorter trial: 3 days monthly, 7 days yearly.
- **pay_now** — charged immediately (no trial), with a self-service
money-back guarantee for the first 3 days.
Earlier users stay **legacy** and keep the 15-day trial. **While
`started_at` is null the experiment is off and everyone behaves like
control — inert until activated via env.**
## How it works
- **Assignment** — `App\Features\SubscriptionExperiment` (Pennant),
deterministic even split by a stable hash of the user id. QA can force a
variant with `feature:enable`.
- **Offer policy** — `ExperimentOffer` is the single source of truth for
trial days per plan, the pay-now flag, the refund window and refund
eligibility; shared by checkout, paywall and billing.
- **Checkout** — trial length comes from the variant (`trialDays(0)` for
pay_now → immediate charge).
- **Onboarding clarity** — the paywall states the exact terms above the
CTA: trial length for the selected plan, or "charged €X today + 3-day
money-back guarantee" for pay_now.
- **Self-service refund (pay_now)** — Settings → Billing, within the
window: refunds the upfront charge, `cancelNow`, revokes bank
connections keeping imported data. `refunded_at` records it and blocks a
second refund. Crash-safe ordering: the refund is stamped before
cancel/disconnect, which run best-effort in a try/catch.
## Measurement
`stats:experiment-funnel` (weekly → Discord): per-variant funnel
(assigned, subscribed, status breakdown, refunds) with a **net-active
rate** gated by each variant's decision window (control 15d / reduced 7d
/ pay_now 3d) so cohorts are read at equal age. Attribution reads the
variant Pennant actually served each user, so the report can't drift
from what users experienced. It also reports **MRR** (monthly run-rate
of mature net-active subs, yearly normalised ÷12) and **ARPU** (MRR ÷
assigned) per variant — ARPU is the revenue metric for the winner
decision. Plus a winner can be pinned org-wide with
`SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT` (env, no deploy).
## Config (env)
- `SUBSCRIPTION_EXPERIMENT_STARTED_AT` — activates the experiment
(launch date). Null = off.
- `SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY` (3), `..._YEARLY` (7),
`..._REFUND_WINDOW_DAYS` (3)
## Tests
- **Feature/unit:** assignment, offer policy, checkout wiring, refund
eligibility, the refund action incl. idempotency + crash-safe ordering
(Stripe mocked), and the funnel collector/command. ES + FR translations.
- **Browser** (`tests/Browser/SubscriptionRefundTest.php`): the
self-service refund UX end to end — card visibility + deadline, two-step
confirm, back-out, the refund control disappearing after confirming, and
gating (window passed / non-pay_now hidden). The `RefundSelfServe`
action is doubled so it never hits Stripe but applies the same DB
effect. Screenshots: `refund-card-visible`, `refund-confirm-step`,
`refund-completed`.
- Full non-browser suite green (the one failing `DashboardTest` is
pre-existing on `main` — Inertia 409 from the unbuilt local manifest).
Pint + ESLint + tsc (changed files) clean.
## Two independent reviews — acted on
**Fixed:** refund atomicity/idempotency (major) · funnel attribution now
reads Pennant's served value instead of recomputing, killing
report-vs-runtime drift (major) · pay_now copy shows the exact amount
charged · throttle + block-demo on the refund route · `resolve(?User)`
nullable · French translations.
**Reviewer notes (deferred, low value):**
- `refunded_at` is not cast to Carbon on Cashier's `Subscription` (safe
today — only null-compared; would need a custom Cashier model).
- `ExperimentFunnelCollector` walks users in PHP via `chunkById`; fine
at current volume, can move to grouped SQL if it grows.
## Confidence: 85 / 100
The critical money path is now **verified live against the Stripe
sandbox** (see below), which removes the earlier cap. All gates are
green and the acceptance criteria are met. Held at 85 (not higher)
because the browser UI test runs in CI rather than locally, and the
pay_now *hosted-checkout + webhook* leg reuses the standard Cashier
checkout already proven by the control flow (only `trialDays(0)`
differs) but wasn't re-driven through the hosted page. Given it moves
money + disconnects accounts, a human glance is still warranted before
enabling.
## Sandbox verification (live Stripe test mode)
`php artisan stripe:verify-refund` creates a real immediately-charged
subscription with a test card, runs the actual `RefundSelfServe`, and
checks the Stripe API. Result:
```
PASS subscription active after immediate charge (pay_now, no trial)
PASS canSelfRefund is true before refund
PASS latestPayment() resolves a payment intent
PASS refunded_at is stamped
PASS subscription is canceled
PASS canSelfRefund is false after refund
PASS Stripe charge shows a full refund (refunded=true)
```
The command is committed and guarded to Stripe test keys /
non-production, so it can be re-run before each launch toggle.
## Launch checklist
1. Stripe-sandbox smoke: `php artisan stripe:verify-refund` (done —
passing). Optionally also drive the hosted pay_now checkout once for
monthly + yearly to confirm the webhook leg.
2. Set `SUBSCRIPTION_EXPERIMENT_STARTED_AT` to the launch date (set
once; don't backdate).
3. Watch `stats:experiment-funnel`; a clean cohort baseline lands once
each variant's window matures.
## Summary
Lets existing (non-onboarding) users discover and manage AI
categorization, gated behind a new Pennant feature flag. When a flagged
paid user grants consent, every uncategorized transaction is categorized
in the background with live, on-screen feedback.
Enable per user: `php artisan feature:enable AiConsentSettings
user@example.com`
## What's included
**Feature flag**
- New `AiConsentSettings` Pennant flag (off by default), exposed to the
frontend via the shared `features` prop.
**Billing settings (`/settings/billing`)**
- When the flag is active, a section to grant or revoke AI consent
(checkbox → `POST`/`DELETE /ai/consent`).
**Transactions page**
- A consent prompt rendered as the first row of the transactions table
(new `DataTable` `topRow` slot), styled with the shared gradient
`AiSparkleIcon`. Only shown to paid users with the flag who haven't
consented yet.
- An inline "Enable AI" button records consent without leaving the page.
**Backfill on consent**
- Granting consent dispatches a queued
`CategorizeUncategorizedTransactionsJob` that categorizes all of the
user's uncategorized transactions, **most recent first**, recording
progress in the cache.
- The transactions page polls a new status endpoint (`GET
/ai/categorization/{jobId}/status`): visible uncategorized rows show a
spinner + pulse while the backfill runs, categories stream in via
partial reloads, and a toast tracks `processed / total` until
completion.
## Notes / decisions
- **Polling, not websockets** — the project has no broadcasting
configured; this mirrors the existing `ReEvaluateTransactionRules` job +
status-endpoint pattern.
- The backfill job is kept separate from
`CategorizeOnboardingTransactionsJob` so the onboarding pass stays
progress-free (small, commented duplication).
- Reuses the existing `AiCategorizationGate` (config kill-switch + pro
plan + active consent) — free users and disabled-AI never dispatch the
job.
- Not included (YAGNI): cancelling an in-flight backfill on revoke;
persisting the spinner across full page reloads.
## Testing
- New Pest coverage: feature-flag exposure, billing/transactions consent
props, consent-triggered dispatch + gating, the status endpoint
(200/404), job progress recording, and recent-first ordering.
- `php artisan test --exclude-testsuite=Browser` passes (one unrelated,
pre-existing `DashboardTest` failure on `main`).
- Pint, Prettier and ESLint clean.
## What
Forces users who have accepted AI consent to choose a plan, the same way
connecting a bank already does.
## Why
AI suggestions are a paid-plan feature (`PlanFeature::AiSuggestions`),
and the onboarding notice tells the user *"AI suggestions are a Standard
Plan feature. You'll choose a plan at the end of the onboarding."* But
that was never enforced: `EnsureUserIsSubscribed` only gated on bank
connections, never on AI consent. A user who accepted AI without
connecting a bank saw the paywall once, got `paywall_seen_at` marked,
and then fell through to **free** access — making the notice a false
promise.
## Change
- `EnsureUserIsSubscribed`: a non-Pro user with an **active AI consent**
is now kept on the paywall on every request, exactly like a
bank-connected user (added `&& ! $user->hasActiveAiConsent()` to the
free-access branch).
- `SubscriptionController::index`: `canUseFreePlan` is now `false` when
the user has an active AI consent, so the paywall stops offering the
free option to these users (keeps page and middleware consistent).
- Revoking AI consent (`hasActiveAiConsent()` → false) restores
free-plan access — a clean escape hatch.
Onboarding itself is unaffected: the onboarding / AI-consent /
rule-suggestion routes live in the `auth,verified` group **without** the
`subscribed` middleware, so consenting mid-onboarding does not lock the
user out of finishing. The paywall only kicks in afterward, when
accessing the app — which is the intended behavior and matches the
notice.
## Tests
4 new tests in `SubscriptionTest` (forced to paywall even after seeing
it; `canUseFreePlan` false; subscribed users with consent still get
access; revoking consent restores free access). Full `SubscriptionTest`
green (38 passed). `pint` clean.
## ⚠️ Behavior change for existing users
This applies retroactively. From prod, **34 users currently have an
active AI consent**; any of them on the free plan today (no bank,
`paywall_seen_at` set) will be redirected to the paywall after deploy
and must subscribe or revoke consent. If we want to grandfather existing
consenters and only enforce this going forward, that needs an extra
condition (e.g. consent recorded after a cutoff date) — flag me and I'll
add it.
## Summary
- add paywall settings CTA for onboarded users with ended canceled
subscriptions and bank connections
- keep onboarding and normal unpaid connected users out of this escape
path
- cover paywall props and connection settings access
## Tests
- php artisan test --compact tests/Feature/SubscriptionTest.php
- npx eslint resources/js/pages/subscription/paywall.tsx
## Summary
Wires up the launch flow: each waitlist lead gets a per-cohort
invitation email, a personal single-use Stripe promo code matching their
reward, and a signed landing link that unlocks register/install.
## Cohorts (resolved at send-time, by queue rank ASC, ignoring
`position` null/0)
| Cohort | Rule | Reward | Stripe coupon |
|---|---|---|---|
| `founder` | ranks 1–10 | Free forever | `wm_founder_forever` (100%
off, forever) |
| `founder_referrer` | referred any current founder (overrides rank) |
Free forever | `wm_founder_forever` |
| `early_bird` | ranks 11–100 | 2 mo free monthly / 3 mo free yearly
first year | `wm_earlybird_monthly` (100% off, 2 mo, monthly only) +
`wm_earlybird_yearly` (25% off once, yearly only) |
| `waitlist` | ranks 101+ | same as early bird | same coupons |
## What's added
- **DB**: `cohort`, `promo_code_monthly`, `promo_code_yearly`,
`invitation_sent_at` on `user_leads`.
- **Services**: `LeadCohortResolver`, `LeadPromoCodeAllocator`.
- **Commands**:
- `php artisan stripe:ensure-launch-coupons` — idempotent Stripe coupon
setup (run once per env).
- `php artisan leads:send-invitations --limit=N [--cohort=…] [--dry-run]
[--force]` — wave-by-wave delivery, idempotent across runs
(`invitation_sent_at` gate). Lazily generates Stripe promo codes per
lead it touches.
- **Checkout**: `SubscriptionController::checkout` resolves the auth
user's `UserLead` by email and applies the matching promo code
(`monthly`/`yearly`) via Cashier's `withPromotionCode()`.
- **Invite link**:
`LandingAuthOverrideService::generateInvitationUrl($leadId, days: 30)` —
signed lead-bound URL that unlocks auth buttons (existing override
cookie) and stores `invited_lead_id` in session. The register view
prefills the email from the lead.
- **Emails**: 4 cohort markdown templates (`founder`,
`founder-referrer`, `early-bird`, `waitlist`) with per-cohort subject +
body. Locale set from `$lead->preferredLocale()`. 28 Spanish keys added.
## Tests (Pest)
- `LeadCohortResolverTest` — boundaries + founder-referrer override +
null/0 position skip.
- `SendUserLeadInvitationsTest` — batch ordering, idempotent across
runs, ignores null/0, persists cohort.
- `UserLeadInvitationTest` — per-cohort body content, signed lead-bound
signup URL, Spanish locale.
- `LandingAuthOverrideTest` — invitation URL unlocks + stores session
lead.
Full suite green (1262 passed).
## Launch checklist
1. Merge + deploy.
2. `php artisan stripe:ensure-launch-coupons` against staging Stripe →
verify in dashboard → run on prod.
3. Optional preflight: `php artisan leads:send-invitations --limit=10
--dry-run`.
4. Wave-by-wave: `php artisan leads:send-invitations --limit=50` per
day.
5. Confirm `HIDE_AUTH_BUTTONS=true` on prod so only signed invite links
unlock register/install.
## Notes / non-goals
- No `User → UserLead` foreign key — checkout matches by email per
request.
- Existing `FOUNDER` promo flow is untouched and still kicks in if a
user has no lead-specific code.
- `founder_referrer` cohort is empty in current prod data; logic is in
place for when founders start referring.
## Summary
Adds a 15-day trial to the monthly and yearly plans. Configurable per
plan (or disabled) via config.
## Changes
- `config/subscriptions.php` — new `trial_days` key per plan (defaults:
monthly=15, yearly=15). Env overrides: `STRIPE_PRO_MONTHLY_TRIAL_DAYS`,
`STRIPE_PRO_YEARLY_TRIAL_DAYS`. Set to `0` to disable.
- `SubscriptionController::checkout` — applies `trialDays()` on the
Cashier subscription builder when `trial_days > 0`.
- Tests — assert `trial_days` surfaced in pricing props; assert
`trialDays(15)` applied on checkout; assert skipped when `0`.
## Notes
Stripe Checkout enforces a **minimum 2-day trial**. Values of `1` will
fail at Stripe. `0` disables cleanly.
## Test plan
```
php artisan test --compact tests/Feature/SubscriptionTest.php
```
## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows
## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`
## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)
## Summary
- Users without a Stripe customer ID (`stripe_id = null`) would hit an
`InvalidCustomer` exception when visiting the billing portal
- Added a `hasStripeId()` check before calling
`redirectToBillingPortal()`, creating the Stripe customer on-the-fly if
needed
- Added two tests covering both branches (with and without an existing
Stripe customer ID)
## Summary
- **Dynamic Stripe price resolution**: Replaces hardcoded
`stripe_price_id` env vars with lookup-key-based resolution
(`stripe_lookup_key`). A new `php artisan stripe:sync-prices` command
creates/updates Stripe prices from `config/subscriptions.php`
automatically.
- **Locale-aware currency formatting**: Replaces all
`getCurrencySymbol() + toFixed(2)` patterns with `formatCurrency()`
(backed by `Intl.NumberFormat`) across `welcome.tsx`, `paywall.tsx`,
`billing.tsx`, and `step-create-account.tsx`, so symbol position and
separators are correct for the user's locale (e.g. `3,90 €` in Spanish).
- **EUR defaults and updated plan prices**: Cashier currency defaulted
to EUR, plan prices updated to €7.80/month and €46.80/year, and
`pricing.currency` is now shared as an Inertia prop.
- **Promo/discount cleanup**: Removed all FOUNDER discount mentions and
Discord community links from the paywall, landing pricing section, and
invitation email.
## Summary
- Open Banking users who complete onboarding **without** connecting a
bank are no longer blocked at `/subscribe` — they can continue for free
via a new \"Continue for free\" button on the paywall
- Users who **did** connect a bank during onboarding see the standard
paywall with no free option (bank sync is a paid/Standard feature)
- Renamed the paid plan badge from **Pro** → **Standard** in the
onboarding UI
## Changes
### Backend
- `EnsureUserIsSubscribed` middleware: grants free access when
`open-banking` feature is active and the user has no
`banking_connections`
- `SubscriptionController::index()`: passes a `canUseFreePlan` boolean
prop to the paywall page
### Frontend
- `paywall.tsx`: accepts `canUseFreePlan` prop and renders a "Continue
for free" button that navigates to the dashboard
- `step-create-account.tsx`: badge and info text updated from "Pro" →
"Standard"
### Tests
- Two new browser tests in `OnboardingFlowTest.php`:
- Manual account flow → `/subscribe` shows "Continue for free"
- BBVA connected bank flow (EnableBanking sandbox) → `/subscribe` does
NOT show "Continue for free"
## Summary
<img width="1220" height="1001" alt="whispermoney test_"
src="https://github.com/user-attachments/assets/c35751d5-385b-449c-81d6-14b5b6577ff2"
/>
Introduces a fully-functional demo account that lets prospective users
explore Whisper Money without creating an account. Users can click
"Check Demo" on the welcome page to instantly access a pre-populated
account with realistic financial data spanning 12 months.
## What's New
**Try Before You Sign Up**
- New "Check Demo" button on the welcome page for instant access
- Pre-configured demo account with real-world financial scenarios
- 12 months of sample transactions across multiple account types
(checking, savings, credit cards, investments)
- Pre-built automation rules, labels, and categories to showcase the
full app experience
**Demo Account Limitations**
- Demo accounts are read-only for sensitive operations (can't change
password, email, or payment settings)
- Clear messaging throughout the UI when demo restrictions apply
- Settings pages show helpful notices about demo limitations
- Automatic daily reset to maintain fresh demo experience
**Developer Experience**
- `php artisan demo:reset` command for manual resets
- Configurable via environment variables (DEMO_EMAIL, DEMO_PASSWORD,
DEMO_ENCRYPTION_KEY)
- Comprehensive test coverage for demo restrictions and data generation
## User Impact
This feature removes the friction of signing up before understanding the
product's value. Users can:
- Explore all features with realistic data
- Test automation rules and see them in action
- View charts and insights based on a year of financial activity
- Experience the full privacy-first encryption workflow
- Understand the product before committing to create an account
Perfect for demos, screenshots, documentation, and helping users make
informed decisions about whether Whisper Money fits their needs.
## Summary
Implements a complete user onboarding flow that guides new users through
setting up their account after registration.
## Features
- **Step-by-step wizard UI** with progress indicator and smooth
animations
- **E2E Encryption setup** with password strength indicator and
explanation of how encryption works
- **Account creation** supporting all account types (checking, savings,
credit card, investment, pension)
- **Category customization** with explanation of category types
(expenses, income, transfer)
- **Smart rules explanation** covering why there is no AI
auto-categorization (privacy & E2EE)
- **Transaction/balance import** based on account type
- **Sync integration** to ensure data consistency with backend
## Flow Diagram
```mermaid
flowchart TD
A[User Registration] --> B[Welcome]
B --> E[Encryption Explained]
E --> C{Has encryption key?}
C -->|No|F[Encryption Setup]
C -->|Yes|G{Has Existing Accounts?}
G -->|Yes| H[Show Existing Accounts]
G -->|No| I[Create First Account]
H --> J[Continue]
I --> K{Account Type?}
K -->|Checking/Savings/Credit Card| L[Import Transactions]
K -->|Investment/Pension| M[Import Balances]
J --> N[Category Types Explanation]
L --> N
M --> N
N --> O[Customize Categories]
O --> P[Smart Rules Explanation]
P --> Q[More Accounts?]
Q -->|Add More| I
Q -->|Finish| R[Complete]
R --> S[Redirect to Dashboard]
S --> T{Subscribed?}
T -->|No| U[Subscribe Page]
T -->|Yes| V[Dashboard]
```
## Technical Changes
### Backend
- Added `onboarded_at` field to users table with migration
- Created `EnsureOnboardingComplete` middleware for redirect logic
- Created `OnboardingController` with index and complete actions
- Custom `RegisterResponse` to redirect new users to onboarding
- Updated `AccountController::store` to return JSON for fetch requests
### Frontend
- `OnboardingLayout` - fullscreen layout with step progress
- `useOnboardingState` hook - manages step navigation and state
- 12 step components for each onboarding screen
- Backend sync after account creation and imports
### Tests
- Feature tests for onboarding middleware
- Updated existing tests to use `onboarded()` factory state