whisper-money/app/Models
Víctor Falcón 8fef50f829
fix(subscriptions): assign the price arm before registration, not after (#792)
Fixes a design flaw in #700, before the experiment has any exposure.

## The problem

#700 drew the arm from `crc32('price:' . $user->id)`, which can only
happen once the user exists. But the landing quotes a price to anonymous
visitors, and `plansFor(null)` had no arm to apply — so **every visitor
saw €3.99**, and half of them were switched to €8.99 after registering.

That biases the result in both directions at once:

- **Against `high`** — it pays a penalty that isn't price sensitivity
but a price that moved after being advertised. Someone who'd have
happily paid €8.99 quoted upfront leaves because they feel baited.
- **In favour of `high`** — its funnel only contains people who already
decided to sign up under a €3.99 promise. In a world where we actually
charge €8.99, the landing says €8.99 and some of them never register at
all. The experiment is blind to that drop-off.

Neither bias is recoverable from the data, and they don't cancel: a
narrow loss for `high` would be uninterpretable.

## The fix

Draw the arm for the **anonymous visitor** on their first page view,
keep it in a year-long cookie, and freeze it onto `users.price_arm` at
registration. The landing quotes what checkout will charge, and the
whole funnel is measured under one price.

| | before | after |
|---|---|---|
| assigned at | registration | first page view |
| source | `crc32('price:' . id) % 2` | random 50/50 draw |
| stored in | nothing (recomputed) | cookie → `users.price_arm` |
| landing shows | always control | the visitor's arm |

Details worth a look in review:

- **The draw is random, not a hash.** There's no stable identifier
before the user exists. This is what forces the column — the arm has to
outlive the cookie.
- **The middleware writes the arm onto the current request**, not just
the response cookie. The cookie only reaches the browser *after* this
response, and the first view is the landing — the one page that most
needs the right price.
- **Checkout reads `users.price_arm` only, never the cookie.** Editing
your cookie after signing up doesn't get you the cheap price. There's a
test that asserts exactly this, with the cookie set to `control` and the
stored arm `high`.
- **No arm = control.** Users from before the experiment, cookies
blocked, arriving at a deep link. `sanitize()` narrows both the cookie
and the column, since a user controls the former.
- **`force_variant` now also stops the draw** — a winner being rolled
out means the split is over.
- **`price_arm` is in `$hidden`.** The frontend has no use for it, and
it needn't be visible in the Inertia payload.

## Exposure

**None.** The experiment started at 14:00 UTC and had **0 signups past
the cutoff** when this was written, so no arm needs reconciling and no
user changes price. This is the last moment this change is free.

## Reading results

The CRC32 expression from #700 is obsolete:

```sql
SELECT COALESCE(price_arm, 'legacy') AS arm, COUNT(*)
FROM users WHERE created_at >= '<started_at>' GROUP BY arm;
```

## Still open

The landing now shows €8.99 to half of anonymous visitors, which means
the experiment can finally affect signup volume itself. That's the point
— but it also means a drop in registrations is a *result*, not a bug,
and shouldn't be rolled back on reflex.

## Tests

19 tests in `PriceExperimentTest`, rewritten around the new mechanism:
the draw, the gate, the forced winner, the cookie→prop path on the first
visit, freezing at registration, and checkout ignoring the cookie.
`Auth`, `SubscriptionTest`, `InertiaSharedDataTest`, `CashflowPageTest`
and `SyncStripePricesCommandTest` all green locally (122 tests). PHPStan
and `crap` clean.
2026-08-13 08:14:37 +00:00
..
Concerns feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
Account.php feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
AccountBalance.php chore: upgrade Laravel 12 to 13 (#242) 2026-03-25 12:56:33 +00:00
AccountImportConfig.php feat(import): persist per-account import configuration on the backend (#698) 2026-07-18 16:10:40 +02:00
AiConsent.php feat(email): follow up after post-onboarding AI consent (#596) 2026-06-26 17:56:06 +00:00
AutomationRule.php fix(banking): strip the ISO 20022 remittance tag from transaction descriptions (#744) 2026-08-09 18:11:05 +02:00
AutomationRuleLabel.php feat: add transaction labels feature (#24) 2025-12-13 13:02:19 +01:00
Bank.php refactor(api): standardize serialization via model $hidden (#492) 2026-06-05 13:57:34 +02:00
BankingConnection.php fix(open-banking): unblock account mapping when the bank reports accounts without a uid (#746) 2026-08-09 18:41:35 +02:00
BankingSyncLog.php fix(banking): retry failed sync connections and log every sync attempt (#251) 2026-03-31 11:34:35 +01:00
Budget.php feat(budgets): add per-budget email notifications (#731) 2026-07-24 12:52:03 +02:00
BudgetPeriod.php feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
BudgetTransaction.php fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183) 2026-03-02 12:22:30 +00:00
Category.php feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
CategoryCorrection.php feat(ai): auto-categorize transactions with AI (behind flag) (#535) 2026-06-15 16:35:20 +02:00
EncryptedMessage.php fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183) 2026-03-02 12:22:30 +00:00
ExchangeRate.php Preload exchange rates (#362) 2026-05-06 16:35:49 +01:00
IntegrationRequest.php feat(integration-requests): add not-doable status with a public comment (#552) 2026-06-17 16:36:32 +02:00
IntegrationRequestVote.php feat(integration-requests): community board to request & vote bank integrations (#550) 2026-06-17 12:50:51 +00:00
Label.php feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
LabelTransaction.php feat: add transaction labels feature (#24) 2025-12-13 13:02:19 +01:00
LoanDetail.php refactor(api): standardize serialization via model $hidden (#492) 2026-06-05 13:57:34 +02:00
McpToolCall.php feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
RealEstateDetail.php refactor(api): standardize serialization via model $hidden (#492) 2026-06-05 13:57:34 +02:00
RuleSuggestion.php feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
SavedFilter.php feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
Space.php feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
SpaceInvitation.php feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
StuckCohortSnapshot.php feat(stats): weekly paywall stuck-cohort report to Discord (#563) 2026-06-19 14:12:51 +00:00
SuggestionRun.php feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
Transaction.php feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
User.php fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +00:00
UserLead.php refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
UserMailLog.php feat: Send custom emails to users (#52) 2026-01-09 09:33:19 +01:00
UserSetting.php feat(budgets): add per-budget email notifications (#731) 2026-07-24 12:52:03 +02:00