## What
Once a month, email a CSV with the email address of every non-deleted
user to the owners.
- New `email:user-emails-report` command builds the CSV and sends
`UserEmailsReportEmail` with it attached as `text/csv`.
- Scheduled `monthlyOn(1, '09:05')` in `Europe/Madrid`, next to the
other `email:*` jobs.
- Recipients come from a comma-separated `REPORT_RECIPIENTS` env var.
The command fails loudly (exit 1, nothing sent) when it is unset, rather
than silently skipping.
The `SoftDeletes` global scope on `User` already excludes deleted users,
so no extra `whereNull` is needed.
## ⚠️ Required before this ships
Set `REPORT_RECIPIENTS` in the production environment, or the scheduled
command will fail every month:
```
REPORT_RECIPIENTS=first@example.com,second@example.com
```
Values are trimmed and empty entries dropped, so trailing commas and
spaces are safe.
## QA
No UI surface, so this was QA'd the way it is actually used: running the
command against the real local database (2520 users, 85 soft-deleted)
with mail captured by Mailhog.
| Check | Result |
| --- | --- |
| Command output | `Sent 2435 user email(s) as
user-emails-2026-08-12.csv.` (2520 − 85) |
| Message | 1 email, both recipients on a single `To` |
| Subject | `Monthly user emails export: 2435 users` |
| Attachment | one `text/csv` part,
`filename=user-emails-2026-08-12.csv`, 58 KB |
| CSV contents | `email` header + 2435 rows |
| Soft-deleted leakage | 0 overlap with the 85 soft-deleted addresses |
| Set equality | 0 rows in the CSV missing from the active set, 0 active
users missing from the CSV |
| Body | renders correctly in both the text and HTML parts |
| Schedule | `schedule:list` → `5 7 1 * *` (07:05 UTC = 09:05 CEST),
next due Sept 1 |
| Missing `REPORT_RECIPIENTS` | errors, exit 1, nothing sent |
| Recipient parsing | `" one@example.com , ,two@example.com,"` → two
clean recipients |
Tests: 2136 pass. `pint`, `phpstan`, `jscpd`, `crap`, `prettier` and
`eslint` all clean.
## Review notes
Two findings from review were raised rather than coded, since they are
product calls:
- **The export is not filtered by verification or consent.** It contains
every active address, including ~5.8% unverified ones. Literal "all
users", but those would bounce if the list is imported into a mail tool.
There is no marketing-consent flag anywhere in the schema, so nothing is
being ignored — just don't assume the list is filtered.
- **Privacy posture.** This puts the full user-email list into two
mailboxes every month, indefinitely, with no retention control. Worth a
conscious decision for an app positioned on not sharing user data.
`demo@whisper.money` is intentionally **not** excluded: the request was
every non-deleted user, and the existing exclusion precedent protects
the demo account from deletion, which is a different motive.
CSV formula injection (`=`, `+`, `-`, `@` local parts evaluating on
import into Sheets) was considered and skipped: zero such addresses
exist today and the only sensitive payload is the list itself, which the
recipients already own.
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.
## Problem
A catch-all budget ("Not budgeted") is supposed to absorb every expense
no other
budget covers. It decided that by looking at the **categories** other
budgets
track — labels were never considered. So for a user whose other budgets
track
spending **by label**, nothing was ever "claimed" and the catch-all
absorbed
everything, double counting it.
Found in production: a user with three label-only budgets (Padel, Yearly
Padel,
Miami Flight) had every labeled expense sitting in their catch-all as
well. Their
current catch-all period read **289,136 / 170,000 (170%, over budget)**
where the
right figure is **75,281 / 170,000 (44%)**. 185 assignment rows are
wrong across
2 users.
## Fix
Precedence is now decided by the budget periods that actually match the
transaction: if any budget already counts it — by category **or** by
label, in a
period covering its date — the catch-all stays out. The historical
backfill
mirrors that rule in SQL, and only treats a category or label as claimed
when the
claiming budget has a period overlapping the range being backfilled.
That last part matters: keying purely on "some budget tracks this label"
would
have dropped expenses whose label budget has no period covering their
date,
leaving them in **no** budget at all (23 rows of one production user,
~2,204 of
spend that would have silently disappeared from their budget view). Both
review
passes flagged it; there are now tests for it on both paths.
## Repairing existing data
```bash
php artisan budgets:reassign-labeled --user=<email> --dry-run
php artisan budgets:reassign-labeled --user=<email>
```
It re-derives every budget assignment of the labeled transactions
currently
sitting in a catch-all budget, with notifications suppressed — these are
historical rows, so a limit email would announce a threshold crossed
weeks ago.
Reassignment (rather than deleting the bad rows) is deliberate: 44 of
the
affected transactions are not in their label budget either, so a plain
delete
would have left them nowhere.
## Known follow-ups (not in this PR)
- **The stale state can reappear.** Catch-all membership now depends on
labels,
but three paths mutate labels without firing `TransactionUpdated`, so
nothing
reassigns: `AutomationRuleService::applyActions` (`saveQuietly` +
`syncWithoutDetaching`), its bulk `applyRuleActionsToTransactions`
(`LabelTransaction::insertOrIgnore`), and the `LabelTransaction` MCP
tool. This
is what left the 44 Miami rows out of their budget — the label was
attached ~10
minutes after the transaction's last save. The web paths are fine.
- Creating a label budget next to an existing catch-all does not release
the
catch-all's rows, and deleting one does not hand them back.
- The repaired catch-all periods keep their `over_limit_notified` flag
until the
next expense lands in them (the flag reset lives in the notification
path we
skip). Self-heals on the next assignment.
## Testing
`tests/Feature/CatchAllBudgetTest.php` — 11 tests: label claimed by
another
budget, label no budget tracks (with a *different* label claimed, so
"any claim"
is not enough), claiming budget with no covering period on both the
per-transaction and historical paths, and the repair command end to end
including `Mail::assertNothingSent()`.
Adds two code-quality signals to CI, with deliberately different
strengths.
## Duplication (blocks merges)
`bun run dry` runs jscpd as a step in the `linter` job, a required
check, so
copy-paste that pushes duplication above the threshold in `.jscpd.json`
blocks
the merge. Baseline today is 5.36% (PHP 6.01%, TSX 5.82%) and the
threshold sits
at 5.4. jscpd is pinned as a devDependency rather than run through
`bunx`: a
version bump changes the reported number and would fail unrelated PRs.
## Complexity (goes red, does not block)
`php artisan crap` reports cyclomatic complexity per method. A separate
`crap`
job reports the methods a PR touched that exceed complexity 10. It is
**not** a
required check: it goes red so the number is visible, but never blocks a
merge.
Folding it into the `linter` job would turn it into a gate, which is not
the
intent — please keep it out of branch protection.
### Why complexity decides the verdict and not CRAP
CRAP is `c² × (1 − coverage)³ + c`, so it is complexity penalised by
missing
tests. Measured here, it is the wrong signal for readability:
| | Methods over 30 | crapLoad | totalCrap |
|---|---:|---:|---:|
| Whole suite with real coverage | 15 (0.99%) | 100 | 5,031 |
Because `app/` is well covered, CRAP mostly ranks what is untested. The
two
rankings barely overlap:
| Rank | By CRAP | By complexity |
|---|---|---|
| 1 | `VerifyRefundFlowCommand::handle` (c=14, 0%) |
`ExperimentFunnelCollector::collect` (c=35, 99%) |
| 2 | `StripeCustomerResolver::label` (c=10, 0%) |
`DashboardAnalyticsController::accountBalanceEvolution` (c=30, 91%) |
| 3 | `WiseTransactionSyncService::parseActivity` (c=8, 0%) |
`UpdateTransaction::write` (c=20, 75%) |
The most complex method in the codebase — 35 branches — is 99% covered,
so it
scores CRAP 35 and lands 11th of 15, below an untested enum `label()` of
complexity 6. An agent guided by CRAP would write tests for a console
command
and leave the 35-branch method alone. So complexity triggers the check,
and CRAP
plus per-method coverage travel in the output as context for *how* to
fix it.
### Threshold
10, McCabe's number, just above this codebase's p95 of 8 (median 1, p99
14,
max 35). It fires only on methods a diff touches, so the 35 existing
offenders
only matter when someone edits them.
## The command
```bash
php artisan crap # whole project, ranked
php artisan crap --base=origin/main --no-coverage # what CI checks, <1s
php artisan crap --base=origin/main --json # for agents
```
- `--no-coverage` skips the coverage report and gives the same verdict,
since
complexity alone decides it. Without it, a crap4j report is required and
the
command refuses to guess when it is missing, printing the exact command
to
generate one.
- Exemptions live in `.crap-ignore.json` keyed by method, with a reason
that is
read in review. Entries that are no longer needed get reported for
deletion.
- Untracked files count whole — a new feature is mostly new files and
would
otherwise sail through unmeasured.
- Exit codes: 0 clean, 1 over threshold, 2 unusable input.
The counter is php-code-coverage's own
`CyclomaticComplexityCalculatingVisitor`,
so the numbers match the CRAP it reports: **1502 of 1503 methods
agree**. Its
wrapping visitor is not reused because that one asserts a method's
parent is a
class or a trait, which fails on enums.
## Notes for review
- `pcov.directory` is set explicitly in the tests job. Left to
autodetect, pcov
picks `src`, which does not exist in a Laravel app, and every method
silently
reports 0% coverage — which is how the first measurements of this metric
came
out wrong, with plausible-looking numbers.
- The `crap` job skips rather than fails when `tests` fails: without the
coverage
artifact, a red `crap` job would say nothing about complexity.
- The two checks cover each other's blind spot. Splitting a complex
method into
near-identical pieces to lower complexity raises duplication, and that
check
does block.
- **Not verified:** that paratest merges coverage across its 4 processes
in CI.
Locally only the serial run works — in parallel each worker starts its
own
MySQL testcontainer and they time out. If it misbehaves, the symptom is
empty
crap/coverage context columns, not a wrong verdict.
- Scope is PHP only. `resources/js` (392 files, 72k lines vs 435/37k in
`app/`)
has no complexity pipeline; the JSON says so explicitly rather than
letting
"whole project" be assumed.
## Testing
10 Pest tests covering the threshold verdict, the CCN counting rules
(including
match arms and closures nested in a method), enum methods, exemptions,
stale
exemptions, the missing-report refusal, the crap4j join, and the
reported scope.
`pint --test` and the full `phpstan` run are clean.
Brings every composer dependency to its latest available version,
Laravel included.
## What moved
Laravel 13.1.1 -> 13.24.0, plus every other in-range minor/patch
(cashier 16.5 -> 16.7, fortify, pennant, sanctum, tinker, sentry,
aws-sdk, resend 1.1 -> 1.7, larastan, pint, sail...).
Four majors that were being held back:
| Package | From | To | Note |
| --- | --- | --- | --- |
| laravel/mcp | 0.6.7 | 0.9.3 | Also unblocks boost 2.5.3 + wayfinder
0.1.21, which both conflict with mcp < 0.7 |
| laravel/ai | 0.7.2 | 0.10.3 | |
| pestphp/pest | 4.7.8 | 5.1.0 | Pulls phpunit 13; suite runs 25% faster
(380s -> 296s) |
| intervention/image | 3.11.8 | 4.2.1 | |
## Code the upgrades required
- **`SetBankLogoCommand`** — intervention v4 dropped
`ImageManager::read()` and `Image::toPng()`; now `decodeBinary()` and
`encode(new PngEncoder)`.
- **`GenerateStripePromotionCodesCommand`** — cashier 16.7 pulls
stripe-php v17.6 -> v20.3.1, which pins the API to `2026-06-24.dahlia`.
Promotion codes there take a nested `promotion: {type, coupon}` instead
of a top-level `coupon`. Caught by phpstan, not by a test.
- **`McpOAuthTest`** — mcp 0.9 returns 201 for dynamic client
registration (RFC 7591 says Created) instead of 200.
- **`SubscriptionExperimentTest`** — phpunit 12.5.33 stopped reindexing
arrays inside `assertEqualsCanonicalizing`, so the gapped keys
`array_unique` leaves behind broke the comparison. No entry in phpunit's
changelog, so this looks unintentional on their side; `array_values`
sidesteps it either way.
## Stripe API version
Worth flagging because it is the money path: the SDK sends
`Stripe-Version` on every request, so our own calls now speak dahlia
while webhooks keep arriving in whatever version the account is pinned
to. All 13 direct call sites were reviewed against the v20 shapes
(prices, promotionCodes, customers, subscriptions, paymentIntents,
coupons) — promotion codes were the only breakage, and phpstan is clean.
Separately, `PostStripeEventToDiscord` reads `current_period_end` off
the subscription object, which recent API versions moved onto
`items.data[]`. Pre-existing and cosmetic (a Discord line is silently
skipped), so it is left alone here.
## Left out
`inertiajs/inertia-laravel` 2.0 -> 3.3. It needs `@inertiajs/react` 3
alongside it plus a full UI pass, so it gets its own PR.
## Verification
- Full suite (Browser excluded): 2102 passed, 1 skipped, 1 incomplete
- Performance suite: 25 passed
- phpstan: clean · pint: clean · prettier/eslint: clean
- Browser suite (pest-plugin-browser 4.3 -> 5.0) and `bun run types` are
left to CI — the worktree has no build, and local `--parallel` can't run
because each paratest worker boots its own testcontainer whose `testing`
user lacks CREATE.
## What
We had no idea whether anyone actually uses the MCP server. This records
one row per tool call and adds a report to read it back: which tools get
used, by which users, and how much.
**Storage** — `mcp_tool_calls`: `user_id`, `tool`, `created_at`. Raw
rows rather than pre-aggregated counters, because the volume is a
handful of calls per Pro user per day and a `GROUP BY` then answers
whatever we want to ask later. No arguments and no financial content are
stored.
**Recording** — one `rescue()`d insert in `McpTool::handle()`, the base
class all 24 tools inherit with no overrides, so coverage is complete by
construction. It sits *after* `respond()` and skips error responses, so
the number means "calls that did something": a plan-gate rejection, a
read-only token rejected on a write, or a `ValidationException` for an
unreachable id is an attempt, not usage. `rescue()` keeps a failed
insert from ever breaking a working tool call while still reporting to
Sentry.
**Reading** — `php artisan stats:mcp-usage [--days=30] [--top=20]`:
```
MCP usage — last 30 days (since 2026-07-13)
Calls: 15 Users: 2
By tool
+---------------------+-------+-------+-------+
| Tool | Calls | % | Users |
+---------------------+-------+-------+-------+
| search_transactions | 10 | 66.7% | 2 |
| get_cashflow | 2 | 13.3% | 1 |
| get_net_worth | 2 | 13.3% | 1 |
| create_transaction | 1 | 6.7% | 1 |
+---------------------+-------+-------+-------+
By user (top 20)
+--------------------------------------------+-------+-------+---------------------+
| User | Calls | Tools | Last call |
+--------------------------------------------+-------+-------+---------------------+
| ana@example.com | 12 | 4 | 2026-08-11 08:06:59 |
| 20260811080659_bruno@example.com (deleted) | 3 | 1 | 2026-08-11 08:06:59 |
+--------------------------------------------+-------+-------+---------------------+
By day
+------------+-------+-------+
| Day | Calls | Users |
+------------+-------+-------+
| 2026-08-08 | 2 | 1 |
| 2026-08-11 | 13 | 2 |
+------------+-------+-------+
```
The per-user table joins `users` instead of eager-loading the relation:
`user:delete` soft-deletes, so the FK cascade never fires and the
relation's `deleted_at is null` scope would silently blank out exactly
the users we most want to see — the ones who churned. They render with a
`(deleted)` marker.
## QA
Driven through the real `/mcp` HTTP endpoint with real Sanctum bearer
tokens, against MySQL:
- 5 successful calls by a read+write user → 5 rows, right user, right
tool names.
- `get_net_worth` with missing arguments and `search_transactions` on an
unreachable space → both rejected, neither recorded.
- A read-only token: `list_accounts` recorded; `create_label` rejected
with "This token is read-only" and not recorded.
- Report checked at `--days` 1 / 30 / 200, with `--top 1` truncation,
with a churned (`markAsDeleted()`) user, and with no data at all.
48 MCP tests green, `pint` and `phpstan` clean.
## Deliberately left out
- **No Discord post or schedule.** The other `stats:*` commands post
weekly; whether MCP usage is worth that noise is a product call, and
it's one `Schedule::command()` line whenever we want it.
- **No client column.** `Auth::getDefaultDriver()` would tell us OAuth
(Claude Desktop / ChatGPT) vs personal token (Claude Code) at the
insert. It's not recoverable after the fact, so it's worth knowing we
skipped it — but it wasn't asked for.
- **No pruning.** Noted in the migration; add it if the table ever gets
big.
- **No collector service.** The sibling report commands extract one
because they feed both the console and Discord. This has one consumer.
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.
## Why
The ChatGPT app directory review needs a reviewer account: fully
featured, pre-loaded with sample data, reachable with an email and
password, no 2FA, and on the Pro plan our MCP tools require.
`demo:reset` already builds that dataset — 6 accounts, ~2k transactions,
12 months of balances, categories, labels, rules, budgets — and attaches
an active subscription, so it only needed to target an email other than
the public demo user.
## What
- `--email` / `--password` create or reset an arbitrary account instead
of the configured demo user. An explicit `--email` skips the
`app.demo.enabled` gate, since a named account is not the public demo.
- The account keeps `isDemoAccount() === false` (that check compares
against `app.demo.email`), so none of the demo UI restrictions apply to
it.
- `--imported` marks one account's transactions as bank-imported, so a
reviewer can verify that `update_transaction` and `delete_transaction`
refuse to touch synced data — one of the negative test cases in the
submission. That account gets no banking connection, so no sync ever
runs on it.
- The public `demo:reset` path is unchanged: same config, same gate,
same data.
Usage:
```
php artisan demo:reset --email=openai-review@whisper.money --password='...' --imported
```
## Testing
`tests/Feature/Console/ResetDemoAccountCommandTest.php` adds two cases
on top of the existing ones: a named account comes out on the Pro plan,
not flagged as the demo account, and holding both manual and imported
transactions; and `--email` without `--password` fails without creating
anything.
## What
The admin Discord channel is Spanish, but the four scheduled `stats:*`
reports posted English. They now post Spanish, and the three
cohort/experiment reports open with a short AI-written summary so a
reader understands the situation without decoding the table.
### 1. Spanish reports
`stats:daily-report`, `stats:subscription-funnel`,
`stats:experiment-funnel` and `stats:ai-cohort-report` now post Spanish
embed titles, field names, ASCII table headers, legends and disclaimers,
with Spanish dates (`sáb., 13 jun. 2026`). Table headers and cells stay
ASCII (`Semana`, `Variante`, `UMad`, `pdte`, …) so `sprintf`'s byte
padding keeps the columns aligned inside the code block.
Hardcoded, not `__()`: this is an internal channel, not user-facing UI,
so it never needs a second language and doesn't belong in
`lang/es.json`. Everything else — code, comments, PHPDoc, command
descriptions, `$this->info()` — stays English.
### 2. AI summary (best-effort)
New `ReportSummarizer` + `ReportSummaryAgent` (laravel/ai, same
Gemini-Flash pattern and `AI_PROVIDER` switch as the other AI features,
config in `config/ai_reports.php`). It prepends a few sentences to the
embed description that compare the current period against the previous
one (week over week for the two weekly reports, month over month for the
monthly cohort one), say what got better or worse, and call out
explicitly when a figure isn't conclusive — small sample, immature
cohort, signup surge week, or no previous period.
- **Data**: only what each collector already computes. The two cohort
reports pass their weekly series with the per-metric maturity flags; the
experiment report passes the per-variant figures plus the
already-rendered significance verdict (one source of truth), with money
figures nulled exactly where the table renders `—`, so the summary can't
report a zero where the reader sees no data.
- **Previous period**: the experiment report has no time series, so each
run caches its figures (with a capture timestamp) as the next run's
baseline. A same-day manual re-run doesn't overwrite it, and the model
is told to flag a gap that isn't roughly one period.
- **Degrades safely**: no API key, a provider outage, a bad provider
name or a slow response (30s timeout) → the report is posted unchanged,
without the summary. Transient provider errors are logged; anything else
is reported, matching `CategorizeTransactions` /
`LaravelAiRuleSuggestionGenerator`.
### 3. Fix found while reviewing: Discord's embed limits
The translated "Cómo leerlo" field came out at 1152 characters, past
Discord's 1024-character cap on a field value — Discord rejects the
**whole** payload, so the experiment funnel would have silently stopped
appearing in the channel every Monday (`DiscordWebhook` only logs the
400). Both long fields are now tighter (745 and 898 chars),
`DiscordWebhook` trims anything still over the limit instead of losing
the report, and a test measures every scheduled report's embed so
growing copy fails in CI rather than in production.
## Testing
- `tests/Feature/Ai/ReportSummarizerTest.php`: baseline in/out, same-day
re-run, dry run, backtick stripping, truncation, empty answer,
transient-vs-reported failures.
- `tests/Feature/DiscordReportEmbedLimitsTest.php`: all four embeds
inside Discord's limits, plus the trimming fallback.
- The four command tests: Spanish assertions, summary is the first thing
in the description, and the report is still posted when the AI throws.
- 64 tests / 338 assertions pass locally, plus PHPStan and Pint.
## QA
Ran all four commands against the local DB with `--no-discord` (and
dumped the real webhook payloads with the Discord call faked). Real
Gemini output, e.g. the experiment funnel on the second run:
> No se ha producido ningún cambio en las métricas del experimento
respecto a la ejecución previa del 10 de agosto de 2026. La tasa de
conversión sobre usuarios maduros se mantiene en el 4,7 % para la
variante reducida, el 4,0 % para pay_now y el 3,7 % para el control. Las
cifras no son conclusivas ya que las diferencias entre variantes no son
estadísticamente significativas, con un p-valor de 0,593 frente al
umbral α de 0,017.
Also verified with an unreachable provider: the report renders in full,
without the summary.
## Not included
- `stats:stuck-cohort-report` is still English. It posts to the same
webhook but isn't scheduled in `routes/console.php`, so it was out of
the four active reports; worth its own decision.
- The `—` and `⚡` cells are still a couple of bytes off inside the code
block (multibyte in `sprintf`). Pre-existing on `main`, unchanged here.
## Why
A support ticket: Bankinter transactions arrive with the raw ISO 20022
remittance tag in front of the text, so AI categorization reads the tag
instead of the merchant.
```
/TXT/D|SumUp *GELATERIA SALV
/TXT/H|TRANSF NOMI /AIGUA DE RIGAT, S ← the payroll that got categorized as Fuel
/TXT/CONDIS SANT JUST DESV07/07/26|20260714
```
`/TXT/` is the unstructured remittance tag, `D|`/`H|` is the debe/haber
marker (which only repeats the sign of the amount), and card payments
append the purchase and settlement dates. None of it describes the
transaction.
In production this hits **7297 transactions across 20 users** —
Bankinter (6569) and Unicaja Banco (728), which ships the same shape.
## What
**`RemittanceTagFormatter`** strips the tag, the credit/debit marker,
the card dates and the `#` marker on card charges. The tag identifies
itself, so the formatter is keyed on the **description** rather than on
a bank name — it works for any bank shipping the same shape instead of
needing a new class per bank. `BankFormatter::matches()` now takes the
description as well; `BbvaFormatter` keeps matching on the bank name.
**`banking:backfill-descriptions`** fixes the rows that are already
imported. It also rewrites the automation rules that match on the raw
text: **48 user-authored rules across 4 users** contain literals like
`/TXT/D|RECIBO VISA CLASICA`, and rewriting descriptions without
rewriting those rules would silently stop them from ever matching again.
A test pins that a rule still matches its transaction after both are
rewritten.
```
banking:backfill-descriptions [--user=email] [--dry-run] [-v]
```
## QA
Ran the formatter over **all 2755 distinct tagged descriptions in
production**: 0 no-ops, 0 leftover tags/dates/markers, 0 degenerate
output.
Ran the command against a database seeded with production-shaped rows
and rules:
```
===== DRY RUN (-v) =====
DRY RUN — no changes will be saved.
/TXT/D|SumUp *GELATERIA SALV → SumUp *GELATERIA SALV
/TXT/H|TRANSF NOMI /AIGUA DE RIGAT, S → TRANSF NOMI /AIGUA DE RIGAT, S
/TXT/EL CLANDESTI 27/07/26|20260803 → EL CLANDESTI
/TXT/CONDIS SANT JUST DESV07/07/26|20260714 → CONDIS SANT JUST DESV
/TXT/RECIBO MES TARJETA|20260806 → RECIBO MES TARJETA
/TXT/D|#RECOBRO RECIBO VISA → RECOBRO RECIBO VISA
/TXT/OPENAI *CHATGPT SUBSCR MP → OPENAI *CHATGPT SUBSCR MP
rule …080: {"in":["RECIBO VISA CLASICA",{"var":"description"}]}
8 transaction(s) and 2 automation rule(s) would be reformatted.
===== SECOND RUN =====
0 transaction(s) and 0 automation rule(s) reformatted. ← idempotent
```
The raw text is kept in `original_description`; an already-stored
original is never overwritten; untagged descriptions and rules are left
alone; transactions the server cannot read (`description_iv`) are
skipped.
## Not in this PR
**Already-categorized transactions keep their category.** The backfill
fixes the text, not the past AI decisions — 4005 of the affected rows
already have a category (1024 of them AI-sourced), and
`ai:categorize-backfill` only picks up uncategorized ones.
Rule-categorized rows are unaffected because the rules are migrated. If
we want the AI ones re-run, that's a separate deliberate step: clear
`category_source = 'ai'` on the affected rows, then run the existing
backfill command.
## Rollout
```bash
php artisan banking:backfill-descriptions --dry-run -v # inspect
php artisan banking:backfill-descriptions # apply
```
## Problem
The production image runs the queue workers via supervisor but **nothing
runs the Laravel scheduler** (there is no `schedule:work` program and no
cron running `schedule:run`). As a result, none of the commands in
`routes/console.php` ever fire automatically — most notably
`banking:sync` (every 6h), so self-hosted users have to trigger bank
synchronization by hand. The manual "sync" button works because it
dispatches the same job to the queue, which the existing workers pick
up; only the scheduled trigger was missing.
## Changes
- **`docker/supervisor/supervisord.conf`**: add a `[program:scheduler]`
running `php artisan schedule:work`, mirroring the existing worker
programs (autostart/autorestart, dedicated log). This is what makes
every scheduled command fire on its own cadence.
- **Gate the operator-only stats reports** (`stats:daily-report`,
`stats:subscription-funnel`, `stats:ai-cohort-report`,
`stats:experiment-funnel`) behind `config('subscriptions.enabled')` with
an early return. These are subscription-conversion telemetry destined
for the admin Discord; on a self-hosted instance
(`subscriptions.enabled=false`) `stats:daily-report` would otherwise
make an unnecessary live Stripe API call, and the others would build
reports for an unconfigured Discord channel. On the SaaS (subscriptions
enabled) behavior is unchanged.
The guard lives **inside each command** (not in `routes/console.php`) so
it stays unit-testable. `banking:cancel-free-enablebanking` was left
alone (already a safe no-op via `hasProPlan()` when subscriptions are
off), as were the drip-email commands (already gated by
`mail.drip_emails_enabled`).
## Testing
- `vendor/bin/pint --test` passes.
- New Pest test per gated command asserting it skips and makes no
external call when subscriptions are disabled; existing tests updated to
enable subscriptions so they still exercise the real path.
- Full suite green except 5 unrelated pre-existing environment failures
(timezone alias, dashboard 409, Passport OAuth key) that fail
identically on `main`.
- Verified end-to-end with a real supervisor run: the `scheduler`
program reaches `RUNNING`, `schedule:work` boots, and autorestart
resurrects it; `php artisan schedule:test --name=banking:sync`
dispatches successfully.
> Takes effect after rebuilding the production image.
## What
Adds an Artisan command to delete a specific user's AI consent by email:
```bash
php artisan ai:delete-consent user@example.com
```
It looks up the user by email and **hard-deletes** all their AI consent
records — as if they had never granted it (no `revoked_at` trail left
behind). Since `AiCategorizationGate` requires `hasActiveAiConsent()`,
AI stops being used for that account immediately (queued categorization
jobs re-check the gate at execution time and early-return).
The command is idempotent: it reports "nothing to do" when there's no
consent on record and fails cleanly when the email doesn't exist.
## Why
Two goals:
1. **Stop using AI for a given user** on request, with no lingering
consent record.
2. **Let users who enabled AI by mistake fall back to a free plan.**
Non-Pro users with active AI consent are pushed toward the paid plan by
`EnsureUserIsSubscribed`. Deleting consent moves them back into the
free-plan-eligible bucket. This billing-gating change is **intended**,
not a side effect.
## Notes / scope decisions
- **Hard delete, not revoke.** Removes every consent row for the user
(all scopes/versions) so there's no trace they ever consented.
- **Prompt flag untouched.** `ai_consent_prompt_dismissed_at` is left
as-is, so the in-app consent banner does not re-appear to nudge them
back on.
- **Live users only.** Deleted (soft-deleted) accounts are out of scope
— they can't use the app, so their consent is moot.
- **No subscription changes.** The command only touches AI consent; it
doesn't cancel Stripe subscriptions (`user:delete` already handles that
separately).
## Tests
New Pest feature test covering all three paths (delete all consent
records incl. a revoked one / no consent on record / user not found).
Green locally.
## What
Adds an artisan command that deletes all transactions and balances of a
specific user's **non-connected** (manual) accounts, identified by
email.
```bash
php artisan user:delete-manual-account-data user@example.com
```
## Behavior
- Looks up the user by email (`withTrashed`, matching `user:delete`).
- Selects accounts with `banking_connection_id IS NULL` (manual
accounts).
- Shows a confirmation prompt with the transaction/balance counts and
the number of affected accounts.
- On confirm, deletes inside a `DB::transaction`:
- Transactions via `forceDelete()` (they use `SoftDeletes`, so this also
purges already soft-deleted rows).
- Balances via `delete()` (`AccountBalance` has no `SoftDeletes`, so
it's a hard delete).
- The accounts themselves are **not** deleted — only their transactions
and balances.
Related `label_transaction`, `budget_transactions` and
`category_corrections` rows are cleaned up by existing
`cascadeOnDelete()` foreign keys.
## Tests
Pest feature test covering: manual-only deletion (connected accounts
untouched), cross-user isolation, soft-deleted transactions counted and
purged, connected-only user reports zero, cancellation, and
user-not-found.
QA skipped per request (backend maintenance command, covered by feature
tests).
## What & why
The `HIDE_AUTH_BUTTONS` flag and its signed-link auth-override existed
only to gate registration/login behind a waitlist during the launch
period. That period is long over, so this removes the flag and the whole
waitlist apparatus. Registration and login are now always open, still
governed by `REGISTRATION_ENABLED` (Fortify's registration feature),
which is untouched.
## Removed
- **`HIDE_AUTH_BUTTONS`** config/env and
**`LandingAuthOverrideService`** — the signed-link override, override
cookie, and `?force=` bypass — plus its
`GenerateLandingAuthLinkCommand`.
- **Waitlist lead-capture + invitation apparatus**: `UserLeadController`
+ `waitlist.*`/`user-leads.*` routes, `StoreUserLeadRequest`, the
landing `WaitlistForm`, `waitlist/*` pages, waitlist/invitation mails,
invitation & re-invitation commands, the lead verification notification,
`LeadCohort`/`LeadCohortResolver`/`LeadPromoCodeAllocator`, and the
lead→Resend segment sync (scheduled tasks removed too).
- Frontend `hideAuthButtons`/`forcedRegistration` threading and the
`?force=` query on auth links (header, welcome, register, login).
- All tests for the removed features; `landing.hide_auth_buttons` config
stubs stripped from surviving tests.
## Kept on purpose (data preservation)
The `user_leads` **table, its migrations, and a lean `UserLead` model
are retained** — no destructive migration. Pre-launch contacts are not
lost, and new signups whose email matches a preserved lead still receive
that lead's promo code at Stripe checkout
(`SubscriptionController::resolveLeadPromotionCodeId`, unchanged).
## Testing
- Full non-Browser Pest suite green locally (**1948 passed**), plus
Pint, Prettier, ESLint.
- Browser QA (Playwright) on the running app: landing no longer shows
the waitlist form and renders the register/login CTAs; `/register`
renders the form (previously returned null when hidden); `/login` shows
the "Sign up" link pointing to `/register` with no `?force`. 16/16
functional checks passed; only pre-existing external-resource 404s in
console (gravatar `d=404`, `via.placeholder.com` seed images).
## Demo
https://github.com/user-attachments/assets/18a70f6d-b82f-4d92-b9de-da2610dc017a
## What
`encryption:notify-removal` now prints, on every run, the total number
of **non-deleted** users who still hold legacy browser-encrypted data:
```
4 non-deleted user(s) still have encrypted data.
```
The count is taken before the billing exclusion, so it reflects the full
remaining scope of browser-encrypted data. Soft-deleted users are
excluded (model default scope), as are users whose data is already
plaintext.
## Why
It's the signal for deciding when the browser-side encryption code can
finally be removed: once this reaches zero, nothing in the app depends
on client-side encrypted columns anymore.
## Not changed
Email-sending logic is untouched. Subscribed users are still never
warned — the recipient set continues to go through
`excludeBilledUsers()` exactly as before. This PR only adds a reporting
line.
## Testing
- New test asserts the count spans everyone with encrypted data
(subscribed included), while excluding soft-deleted and plaintext users,
and that sending still skips the subscribed user.
- Full `NotifyEncryptedDataRemovalCommandTest` suite green.
## What
The same currency-symbol money formatter — a `match` on
`strtolower($currency)` returning a symbol, concatenated with
`number_format($cents / 100, 2)` — was copy-pasted across **five**
files:
- `app/Console/Commands/SendExperimentFunnelReportCommand.php`
(`money(int $cents, …)`)
- `app/Listeners/PostStripeEventToDiscord.php` (`money(int $amount, …)`)
- `app/Console/Commands/SyncStripePricesCommand.php` (`formatAmount(int
$amountInCents, …)`)
- `app/Console/Commands/StripeSubscriptionStatsCommand.php`
(`format(float $amount, …)`)
- `app/Console/Commands/SendDailyStatsReportCommand.php` (`money(float
$amount, …)`)
This extracts it into a single `App\Support\Money::format(int $cents,
string $currency): string` helper and removes the duplicated logic from
every site.
## Zero behavior change
The five copies were **not** verbatim — they diverged on three axes:
unit (cents vs. major units), symbol coverage, and default casing. The
shared helper is the **union** of the existing maps
(`eur`/`gbp`/`usd`/`jpy`/`brl`, otherwise uppercased code + trailing
space), and reproduces every call site's output for the currencies each
actually receives:
- **Cents-based sites** (experiment funnel, Discord listener, price
sync) call the helper directly.
- The experiment funnel only ever passes the Cashier currency
(uppercased `EUR` from the collector), so its narrower map / raw-case
default was a dead branch.
- The price sync command only formats `config('cashier.currency')`
(`eur`), so it never hit `usd`/`brl` — adding those symbols is a no-op
here.
- **Major-unit sites** (`StripeSubscriptionStatsCommand`,
`SendDailyStatsReportCommand`) hold MRR as floats, so their private
method now converts to cents (`(int) round($amount * 100)`) and
delegates; call sites are untouched. `number_format` of the
round-tripped value is identical to the original.
## Tests
- New `tests/Unit/Support/MoneyTest.php` covering `eur`, `gbp`, `usd`,
`jpy`, `brl`, an unknown currency (`chf` → `CHF 3.99`),
case-insensitivity, thousands separator, and zero.
- The existing command feature tests (which assert exact rendered
strings like `€10.00`, `€19.99`, `€9.99 / month → €3.99 / month`) still
pass unchanged.
`pint`, `phpstan`, and the affected feature + unit tests are green
locally (42 passing).
## Summary
Audited `stats:experiment-funnel` end-to-end (data acquisition, queries,
per-row counting, financial math, and statistics) and fixed the issues
that made it misleading for the win/no-win decision. The report used to
rank variants by an **absolute** contribution-margin total that
mechanically favoured whichever variant matured fastest, and it declared
statistical significance with a normal approximation that is invalid at
the small conversion counts this experiment has.
## What changed
**Presentation & comparability**
- Replace `A2P%` (numerator not a subset of the denominator → could
exceed 100%) with `Conv%` = conversions ÷ matured-assigned — always
≤100% and comparable across variants.
- Print `MatU` (matured cohort size) so the rate denominators are
visible and the table reconciles; revive `ARPU`.
- Reframe the guidance: compare on per-user `Conv%`/`ARPU`, not the
absolute `MRR`/`Cost`/`Burn`/`CM` totals (which scale with `MatU`).
**Data correctness**
- Count soft-deleted users (`withTrashed`) — they were assigned a
variant and their connections incurred real cost.
- Attribute by the deterministic `SubscriptionExperiment::bucket()`
instead of the resolved Pennant flag, so setting `force_variant` (the
winner rollout switch) no longer collapses the whole report onto one
variant. Also stops writing Pennant rows as a side effect.
- Resolve `MRR` for subscriptions on rotated/archived Stripe price ids
(fetch prices by product), warn on any net-active sub whose price is
unmapped, round yearly ÷ 12, and skip foreign-currency prices.
- `Burn` counts only users who never earned net revenue (no
subscription, or paid-then-refunded) — a paid-then-churned user is no
longer booked as connect-and-leave leak.
**Statistics**
- Measure conversion as "ever charged, net of refund" (time-invariant)
instead of a live active-now snapshot that biases older cohorts (which
have had longer to churn).
- Decide significance with **Fisher's exact test** (exact at any sample
size), Bonferroni-corrected over the three arms, instead of the
normal-approx z that overstates evidence when expected cell counts fall
below 5. Add a Newcombe difference-of-proportions CI and a small-sample
caveat.
- Extract the inference into `App\Services\Stats\ProportionSignificance`
(+ a `BinomialProportion` value object), with unit tests that pin the
exact interval and p-value numbers.
## Validation
Reconstructed every column in raw SQL against a production dump — all
reconcile **to the cent / to the row**. Independent recomputation of the
statistics (Wilson, Fisher exact, Newcombe, z) matches the command's
output.
## Testing
26 tests (20 feature + 6 unit, 94 assertions). `pint` and
`phpstan`/larastan green.
## Note
This branch also carries two small pre-existing commits unrelated to the
funnel (`fix(categories): fall back to gray…`, `chore(schedule): stop
scheduling the stuck cohort report`). Happy to split them into their own
PR if preferred.
## What
Reworks `stats:experiment-funnel` from a pure conversion funnel into a
**contribution-margin** one, so the 3-way trial/pricing experiment can
be judged on margin — not just conversion. Signups aren't free: each
bank connection costs money per month, so trials that connect banks and
never pay are burned cash, which the old report was blind to.
## Funnel
Per variant: **assigned → activated → carded → net-paying**
- **activated** = connected ≥1 bank connection **OR** gave AI consent
(triggered paid infrastructure), whether or not they paid.
- **carded** = completed Stripe Checkout (card on file).
- **net-paying** = live, non-refunded, mature subscription.
The **activated → carded** gap is where a user connects a bank and walks
away without paying — the exact leak the report now puts a number on.
## Metrics
| Column | Meaning |
|---|---|
| `A2P%` | net-paying ÷ activated (mature) |
| `Cost` | mature-cohort connections × cost/connection |
| `Burn` | connection cost of mature non-payers (money lost) |
| `CM` | MRR − Cost (the decision metric) |
- New `--cost-per-connection` option, default **0.40** per connection.
- Connection count includes soft-deleted/revoked connections (they still
cost money).
- All money/rate columns are gated on each variant's maturity window;
`Cost`/`Burn`/`CM` print `—` until a variant has mature volume. Legend
notes that raw `Assg`/`Actd`/`Card` are lifetime counts while
rates/money are mature-cohort only.
- Existing MRR/ARPU/Net% fields stay on the collector; dropped from the
printed table.
## Tests
Added coverage for activation (bank OR AI),
cost/burn/contribution-margin math, and the cost-per-connection
argument. Full file green (12 passed).
## Why
In the experiment funnel report, variants with a trial (control,
reduced) show `0` under Actv/Cncl/Rfnd while their signups are still
mid-trial — so the table looked empty even though many of those trials
have already been canceled by the user (Cashier keeps serving the trial
until it ends, then simply doesn't charge). That "already lost, just not
settled yet" cohort was invisible.
On prod right now: 28 subscriptions in `trialing`, of which **10 are
already scheduled to cancel** — a leading churn signal the report was
hiding.
## What
- `ExperimentFunnelCollector`: new per-variant `trialingCanceling`
counter = `stripe_status === 'trialing'` **and** `ends_at !== null`.
- Report table: two new columns — `Trl` (currently in trial, previously
collected but never printed) and `TrlX` (of those, already scheduled to
cancel and won't convert). Discord legend updated.
- Test covering the new counter.
`Trl`/`TrlX` are orthogonal leading indicators; the mature Net%/MRR/ARPU
metrics are unchanged.
## Testing
`php artisan test
tests/Feature/SendExperimentFunnelReportCommandTest.php` — 9 passed.
## Spaces / Business plan — Phase 0: invisible foundation
First of **three stacked PRs** introducing multi-tenant **Spaces** (the
basis for the Business plan). This one is a **pure, behaviour-preserving
foundation**: it can ship to production on its own with zero
user-visible change.
- **Stacked PRs:** this → `enterprise-spaces-ui` (Phase 1+2) →
`enterprise-spaces-invitations` (Phase 3).
### What a Space is
A Space groups its own accounts, connections, transactions, categories,
labels, budgets and rules. **Every user gets one invisible "Personal"
space**, provisioned automatically on creation — so the architecture is
identical for free, Standard and Business accounts, even though only
Business will ever see more than one.
### What this PR does (no behaviour change)
- `spaces`, `space_user`, `space_invitations` tables;
`users.current_space_id`; a nullable, indexed `space_id` on the 8 owned
tables (plain column, **no FK** — avoids a validating table-scan/lock on
`transactions` during a phased rollout).
- `Space` model + `BelongsToSpace` trait that stamps `space_id` on
create (from the row's user's current space; a transaction inherits its
account's space, so bank-sync lands rows correctly).
- Idempotent, chunked `spaces:backfill` command (run from a migration)
that gives every existing user a personal space and stamps their rows —
so the read switch in the next PR is safe.
- **Reads are untouched here** (still user-scoped); every user has
exactly one space, so behaviour is identical.
### Testing
- New `tests/Feature/Spaces/SpaceFoundationTest.php` (provisioning,
default-space stamping, account-anchored transaction space,
stale-pointer self-heal, backfill).
- Full suite green.
### Notes / deliberate simplifications
- `space_id` stays **nullable** (populated by backfill + on every
write); the NOT NULL constraint is deferred until prod is confirmed
fully backfilled.
- For very large `transactions` tables, `spaces:backfill` can be run
out-of-band before deploy so the migration's call is a no-op.
## What
The `encryption:delete-accounts` and `encryption:notify-removal`
commands target users who still hold legacy client-side encrypted data.
They should **never** warn or delete an account that is still being
billed.
## Changes
- Add `excludeBilledUsers()` to the shared
`FindsUsersWithLegacyEncryption` trait. It drops any user where
`hasActiveSubscriptionOrTrial()` is true — a valid subscription outside
its grace period, or an active generic trial. This reuses the existing
domain method (whose docblock already states such users must cancel
before deletion) instead of reimplementing Cashier's subscription-status
logic in SQL.
- Both commands apply the filter right after fetching their candidates.
- Eager-load `subscriptions` in the base query to avoid an N+1 when the
filter runs.
## Tests
- `it never deletes a subscribed user` (delete command)
- `it never warns a subscribed user` (notify command)
Both new tests plus the existing suite pass (8/8). Pint clean.
## What
Two artisan commands to retire the remaining legacy client-side
encrypted data in production (transactions with
`description_iv`/`notes_iv`, accounts with `name_iv`).
- **`encryption:notify-removal`** — queues a re-engagement/deletion
warning to every user who still has an encrypted transaction or account.
Reuses `SendUpdateEmailJob` (queue `emails`, dedup via `UserMailLog`,
50/day pacing). The email is framed as an **inactivity** cleanup (no
mention of encryption) and pitches recent additions (AI insights, bank
integrations, etc.).
- **`encryption:delete-accounts`** — soft-deletes and anonymizes
(`User::markAsDeleted()`) those users **only** if they have not signed
in within the grace window (`--days`, default 7, via `last_active_at`).
Anyone who came back is spared, and the demo account is always excluded.
Both support `--dry-run` and `--force`, and share the target query via
the `FindsUsersWithLegacyEncryption` concern so the warning and the
deletion always target the same set.
## Intended manual run
`notify-removal --dry-run` → run for real → wait 7 days →
`delete-accounts --dry-run` → run for real.
## Notes / scope
- Deletion is a **soft-delete + email anonymization** (accounts become
unusable); it does not hard-purge rows, and does not cancel Stripe
subscriptions or revoke bank connections (unlike `user:delete`). Legacy
inactive accounts are unlikely to have either; handle separately if
needed.
- Email copy is plain English (matches the existing `mail/updates/*`
convention), no new `__()` keys.
## Tests
`tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php` and
`DeleteEncryptedDataAccountsCommandTest.php` — 6 passing (targeting,
queue, dry-run, grace window, demo exclusion).
## What
`demo:reset` now bails out early with an info message when
`config('app.demo.enabled')` is falsy, instead of rebuilding the demo
account regardless of the `DEMO_ENABLED` flag.
## Why
The `DEMO_ENABLED` config existed but the command ignored it, so a
scheduled reset would recreate demo data on environments where the demo
account is turned off.
## What
Follow-up to the `--no-discord` flag added to `stats:experiment-funnel`.
Extends the same flag to the **other stats report commands that post to
Discord**, so any of them can be run on demand (e.g. inside the prod
container) and just printed to the console without posting:
```bash
php artisan stats:subscription-funnel --no-discord
php artisan stats:ai-cohort-report --no-discord
php artisan stats:stuck-cohort-report --no-discord
php artisan stats:daily-report --no-discord
```
## How
- The two **funnel** commands (`subscription-funnel`) already printed
their table to the console, so `--no-discord` just short-circuits the
`->send()`.
- The **cohort/daily** commands only ever built a Discord embed. A small
`App\Console\Commands\Concerns\RendersReportToConsole` trait renders an
embed (title / description / fields) as plain text, so `--no-discord`
prints a readable report to the console instead of posting.
- Default behaviour is unchanged: without the flag (and on the scheduled
weekly/daily runs) they post to Discord exactly as before.
## Tests
A `--no-discord` test added to each command (`Http::assertNothingSent()`
+ the command succeeds). Pint + Larastan + all four command suites
green.
> Note: the commands query the default DB connection, so for
**production** numbers run them inside the prod container (Coolify
terminal).
## What
Adds a `--no-discord` flag to `stats:experiment-funnel` so the
per-variant report can be run **on demand (e.g. in production) and only
printed to the console**, without posting to the Discord channel.
```bash
php artisan stats:experiment-funnel --no-discord
```
## Why
Handy for ad-hoc checks of the live experiment without spamming the
ops/Stripe Discord channel. The scheduled weekly run (and any run
without the flag) still posts to Discord exactly as before — default
behaviour is unchanged.
## Notes
- The console table already prints before the Discord step; the flag
just short-circuits the `->send()` and prints `Skipped Discord
(--no-discord).`
- Test added: with `--no-discord`, the command succeeds, prints the
table, and `Http::assertNothingSent()`.
- Pint + Larastan + the command's Pest suite green.
> Tip: the command queries the default DB connection, so to see
**production** numbers run it inside the prod container (e.g. via the
Coolify terminal).
## 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
- **Fix:** `integration-requests:review` crashed with `Attempt to read
property "email" on null` when a request's author no longer exists. The
command now renders a dash instead of assuming the `user` relation is
present.
- **Feature:** new terminal `done` status that marks an integration
request as shipped.
## The `done` status
Modeled after `not_doable` (a frozen, terminal state):
- Shown on the board, sinking to the bottom regardless of votes.
- Can no longer be voted on or unvoted (`removeVote` returns 404).
- Drops its public comment when set, so the board shows no stale note.
- Available in both `integration-requests:review` flows (pending review
and `--all`).
Frontend gets a `Done` badge and an `isFrozen()` helper that replaces
the scattered `=== 'not_doable'` checks gating the vote/unvote buttons.
## Tests
- 6 new feature tests: visibility without comment, bottom ordering,
vote/unvote blocked, command sets `done` and clears the comment, and the
command tolerating an orphaned author.
- `php artisan test tests/Feature/IntegrationRequestTest.php` → 34
passed.
- Board vitest suite, Pint, ESLint and Prettier all green.
## What
Adds a weekly **registration → subscription → paid** funnel so we can
baseline subscription conversion and, later, measure A/B tests against
it.
- `stats:subscription-funnel` artisan command — prints the cohort table
and posts it to Discord (same webhook as the other weekly reports).
- `SubscriptionFunnelCollector` — builds the age-normalized weekly
cohort series.
- Scheduled weekly, Mondays 09:15, next to the existing cohort reports.
## How the funnel is defined
Every stage is measured from each user's **own signup**, so weekly
cohorts are compared at the same age regardless of the calendar.
- **Registered** — signups that week.
- **Subscribed** — started a `default` plan ≤30d after signup (entered
checkout/trial).
- **Paid** — that plan billed past the 15d trial: currently `active`, or
`canceled` only after outliving the trial. `trial_ends_at` is cleared on
cancel, so subscription lifespan is the reliable "did it ever bill"
signal.
Both stages are bounded by the same subscription-creation window, which
guarantees the funnel invariant **registered ≥ subscribed ≥ paid** (a
test enforces it — I caught and fixed a window bug where `paid` could
exceed `subscribed`). Cohorts too young to score show `pend`; signup
surges (launch/marketing waves) are flagged with ⚡.
## Current baseline (prod, 2026-06-27)
| Stage | Count | Rate |
|---|---|---|
| Registered | 1,289 | — |
| Subscribed | 158 | 12.3% of registered |
| Trial resolved | 128 | conversion denominator |
| Paid after trial | 91 | **71%** of resolved · 7.1% of registered |
| Currently active | 75 | 82% of payers retained |
⚠️ ~940 of the 1,289 signups arrived in the last ~5 weeks (a
late-May/June surge); their trials haven't fully resolved, so
cohort-level conversion for that wave isn't measurable yet. The 71% is
status-based — a clean cohort baseline lands in ~4–6 weeks.
## Tests
5 Pest tests covering cohort counting, the attribution window, the
funnel invariant, maturity gating, and Discord delivery. All green, Pint
clean.
## What
Adds a lifecycle email sent **2 days after a user records AI consent**,
but only when that consent was given **outside of onboarding** — i.e.
more than 3 days after sign-up. This targets users who deliberately
opted into AI later (e.g. via the billing toggle / transactions banner
gated by the `AiConsentSettings` flag), not those who consented during
initial setup.
## How
- **`email:ai-consent-follow-up`** scheduled command (daily, 10:15
Europe/Madrid) selects active consents accepted exactly 2 days ago,
skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and
queues a job per user.
- **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a
`UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the
existing drip system — **no migration**.
- Email is localised automatically via the user's `HasLocalePreference`,
like every other drip email.
## Design notes
- **Scheduled command, not a 2-day delayed job** — survives worker
restarts and respects consent revoked in the meantime.
- **Exact "2 days ago" window**, matching the existing paywall drip. If
the scheduler misses a day that cohort isn't retried; kept consistent
with the other drips on purpose.
- Copy is a first draft — open to wording tweaks.
## Tests
`tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests
covering the happy path, onboarding exclusion (incl. the exact-3-days
boundary), timing window, revoked consent, the disabled-drip gate,
single-send + mail log, no-resend, and locale rendering. All green, Pint
clean.
## What
`feature:enable <feature> <target>` now accepts a percentage like `25%`
as the target, in addition to a user email or `all`.
```bash
php artisan feature:enable AiConsentSettings 25%
```
This activates the feature for a random `ceil(total * N/100)` sample of
the **current** users, persisted in Pennant's `features` table — no need
to edit the feature class and redeploy for a percentage-based rollout.
## Notes
- It targets the current user base. New users keep the feature's default
(`false`) unless the command is run again.
- Percentage is validated to be between 1 and 100.
- `feature:disable` was left unchanged — disabling for a random subset
has no clear use case.
## Test plan
- [x] `feature:enable AiConsentSettings 40%` enables the feature for
exactly 4 of 10 users
- [x] An out-of-range percentage (`0%`) fails
## What
Adds a weekly `stats:stuck-cohort-report` command that measures the
percentage of users "stuck" on the paywall, compares it to the previous
week, and posts the result to Discord.
## Definition of "stuck"
A user with a non-deleted banking connection and **no** valid
subscription:
- has a banking connection (`bankingConnections()` — SoftDeletes already
excludes deleted)
- has no subscription with `stripe_status` in `active` / `trialing` /
`past_due`, nor a `canceled` one still in grace (`ends_at > now()`)
Built with Eloquent (`whereHas` / `whereDoesntHave`), no raw SQL.
## Metric
`stuck_pct = stuck / onboarded users` (`onboarded_at` not null — the
population that reached the paywall). The Discord message also shows the
raw counts, not just the percentage.
## Week-over-week
Each run snapshots `date`, `onboarded_count`, `stuck_count`, `stuck_pct`
into a new `stuck_cohort_snapshots` table (`updateOrCreate` per day,
idempotent), then compares against the most recent prior snapshot to
report the delta in percentage points and stuck count. First run has no
prior → reports current only.
## Discord
Reuses the `SendAiCohortReportCommand` pattern: posts an embed via
`DiscordWebhook` to `config('services.discord.ai_cohort_webhook_url')`
(fallback `webhook_url`). No direct `env()`.
## Schedule
`Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid')`
in `routes/console.php`.
## Tests
7 tests (percentage calc across subscription statuses incl. canceled
in/out of grace, soft-deleted connection, non-onboarded excluded from
denominator, snapshot persistence, delta vs previous, single upsert per
day, Discord POST asserted via `Http::fake`). `pint` clean.
## What
Sends an automated follow-up email the day after a user finishes
onboarding but stays stuck on the paywall, asking why they didn't
continue and inviting a direct reply.
## Why
Users who connect a bank account during onboarding but never pick a plan
are permanently redirected to the paywall by `EnsureUserIsSubscribed` —
they finish onboarding and then can't access the app. This reaches out
to understand the friction and recover them.
## Who gets the email (the genuinely stuck cohort)
- `onboarded_at` is calendar-yesterday (`whereDate('onboarded_at',
today()->subDay())`)
- Has an active banking connection (`bankingConnections()->exists()`)
- No Pro plan (`hasProPlan()` is false)
- Hasn't already received this email (idempotent via `UserMailLog`)
Users **without** a bank connection are excluded: they fall through to
free access after seeing the paywall once, so they aren't stuck.
## How
- New `email:paywall-follow-up` command scans the cohort daily and
queues the job — scheduled `dailyAt('10:00')` Europe/Madrid in
`routes/console.php`.
- New `DripEmailType::PaywallFollowUp` (`paywall_follow_up`) +
idempotent send job following the existing drip pattern.
- Short, human Markdown email with an explicit `replyTo`
(`hi@whisper.money`) so replies reach a real inbox.
- Spanish copy added to `lang/es.json`.
## Tests
10 new tests (correct cohort matched; wrong day / no bank / Pro plan
excluded; no double-send; no-op when drip disabled). `pint` clean.
## Note
There was no existing user-scanning drip command (current drips dispatch
with `delay()` on `Registered`). Since "stuck on paywall" state isn't
known at registration time, a daily scanning command is the right fit.
## What
Two related changes to the AI auto-categorization feature.
### 1. Open the gate to pro + consent (drop the new-signups-only cohort)
Eligibility for AI auto-categorization was: kill switch **+ pro plan +
active AI consent + a Pennant rollout flag** that only resolved for
users created after `ai_categorization.rollout_after`. That last cohort
gate limited the feature to a handful of recent signups (6 eligible
users in prod).
The gate is now just **kill switch + pro plan + active AI consent**, so
every consented pro user is eligible regardless of signup date.
- `allows()` and `allowsBackfill()` became identical and collapse into a
single `allows()`; `CategorizeBackfillCommand` calls it.
- The `AiCategorization` Pennant feature and the
`ai_categorization.rollout_after` config are now dead and removed. The
weekly cohort report already derives its release marker from the first
`ai_consents.accepted_at`, so nothing depends on the config.
> Note: the `AI_CATEGORIZATION_ROLLOUT_AFTER` env var must be removed
from the production environment — it is no longer read.
### 2. Nudge free users that AI could categorize their transactions
Free-plan users now see a subtle AI sparkle on uncategorized rows,
reusing the trailing icon slot already in `CategoryCell` (no layout
change). It only shows when subscriptions are enforced and the user is
not pro; clicking it routes to `/settings/billing`.
To keep it subtle, the sparkle is sampled to a share of rows via a
deterministic function of the transaction id (its last byte mapped onto
a 0-100 threshold), so the same rows decide the same way across reloads
instead of flickering or marking every row.
The share is **configurable** via `ai_categorization.upsell_sample_rate`
(env `AI_CATEGORIZATION_UPSELL_SAMPLE_RATE`, default 40), exposed to the
frontend as the `aiCategorizationUpsellRate` Inertia prop — no rebuild
needed to retune it.
## Tests
- PHP: `AiCategorizationGateTest` updated (rollout case dropped);
job/listener tests no longer activate the removed feature.
- JS: `ai-upsell-sample.test.ts` covers determinism, the 0/100 bounds,
the threshold boundary, and the per-rate split.
- Manually verified in-app (Playwright): nudge renders for a free,
consented, bankless user; rate matches config.
## Follow-ups (not in this PR)
- Remove the `AI_CATEGORIZATION_ROLLOUT_AFTER` prod env var.
- Backfilling existing pro+consent users' history still requires running
`ai:categorize-backfill {user}`.
- The nudge only reaches free users who pass the paywall (no bank
connection + paywall already seen); free users with a bank connection
never reach `/transactions`. Consider surfacing it on the paywall too.
## What
- **Markdown comments**: admin comments on the integration board now
render as markdown (`react-markdown` + `remark-gfm`) instead of plain
text. Links open in a new tab; blockquotes, lists and bold are styled.
Comments are admin-only (set via CLI), so the content is trusted.
- **`in_progress` status**: new status, visible to everyone and votable
like `approved`, with an optional public comment. Lets the admin signal
an integration is actively being built.
- **`integration-requests:review` rework**:
- Any decision can now move a request to any status (approve / in
progress / reject / not doable), not just the pending ones.
- New `--all` flag prints the full list with a `#` column so the admin
can pick a request by number and change its status.
- `not doable` requires a comment; `in progress` allows an optional one;
approve/reject clears any stale public comment.
## New dependencies
- `react-markdown`, `remark-gfm` (approved with the author).
## Tests
- 24 feature tests passing, incl. `--all` status change, `in_progress`
visibility + voting, and updated review-command option sets.
- Frontend vitest covering markdown rendering (link + blockquote).
## Summary
Adds a `not_doable` status to integration requests for cases we've
reviewed but can't implement (e.g. no public API).
- **Applied with a comment** — the `integration-requests:review` command
now offers a `not doable` choice that requires a comment explaining why.
The comment is stored on the request and shown to users.
- **Shown at the end** — not-doable requests stay visible to everyone
but sink to the bottom of the board regardless of their votes, with the
comment rendered below the name.
- **Not votable** — voting is blocked both in the UI (disabled button)
and the backend (404), even for the author.
- **Rejected stays hidden** — rejected requests never appear in the list
(unchanged behaviour, now covered by a test).
## Changes
- `IntegrationRequestStatus` enum: new `NotDoable` case + label.
- Migration: nullable `comment` text column on `integration_requests`.
- `IntegrationRequestController`: list includes not-doable for everyone,
ordered last; vote guard rejects not-doable.
- `ReviewIntegrationRequestsCommand`: `not doable` option with a
required comment prompt.
- Board component: not-doable badge, comment, disabled voting.
- Factory `notDoable()` state, `es.json` translation, feature tests.
## Testing
- `php artisan test tests/Feature/IntegrationRequestTest.php` — 21
passed
- `vendor/bin/pint --dirty`, `bun run format`, `bun run lint` — clean
## What
A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.
## How it works
- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
- Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).
## Endpoints
- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.
## Tests
- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.
## Notes / follow-ups
- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
## What
Unifies keys that were repeated across the codebase into two PHP enums
as the single source of truth. Two independent commits:
### `refactor(banking)` — `BankingProvider` enum
- New `App\Enums\BankingProvider` (`indexacapital`, `binance`,
`bitpanda`, `coinbase`, `wise`, `enablebanking`).
- `BankingConnection`'s `provider` column is cast to the enum → magic
strings gone from the model, controllers (`match`), requests, jobs,
commands, factory and `where('provider', ...)`.
- Logic that was duplicated, now on the enum:
- `usesApiKey()` — API-key auth (everything except EnableBanking).
- `defaultAccountType()` — provider → account type (removes the
duplicated ternary in `CreatesAccountsFromPending` and
`AccountMappingController`).
### `refactor(i18n)` — `Locale` enum
- New `App\Enums\Locale` (`en`/`es`/`fr`).
- Unifies the `['en','es','fr']` list (validation in `SetLocale` and
`ProfileUpdateRequest` via `Rule::enum`) and the `Accept-Language`
detection (`Locale::detectFromHeader`), previously duplicated in
`SetLocale` and `CreateNewUser`.
## Interaction with Wise (PR #525)
Rebasing onto Wise surfaced two issues this PR fixes:
1. **`isWise()` was broken** once `provider` is cast (it compared `===
'wise'` against an enum → always `false`).
2. **Account type**: Wise uses an API key (✓ it gets the auth-failed
email) but is a **checking** account, not an investment one. Account
type now flows through `defaultAccountType()` (Wise/EnableBanking →
Checking; indexa/binance/bitpanda/coinbase → Investment).
## Behavior change (minor)
`CreateNewUser` now detects `fr` at registration (previously only
`es`/`en`), consistent with the existing French support.
## Out of scope
- Frontend (`['indexacapital','binance',...]`, `provider: string`):
syncing TS with the PHP enums would need typegen. It still receives the
string via `->value`.
- `TransactionSource::Wise/EnableBanking`: a separate enum (transaction
origin), not a duplicated check.
## Tests
- New: `tests/Unit/Enums/BankingProviderTest.php`,
`tests/Unit/Enums/LocaleTest.php`.
- `vendor/bin/pint --test` ✓ · Larastan ✓ · full suite (excluding
Browser) **1615/1616** ✓.
## What
Auto-categorizes transactions with AI (Gemini) for **pro +
AI-consented** users when no automation rule already matched. Ships the
full backend **behind a Pennant flag, off by default**, so it's
mergeable and testable in isolation; the UI is a deliberate follow-up.
## Why / cost
Prod check first: ~20k txns/month, **~52% of pro-user transactions are
uncategorized** after rules. At Gemini Flash-Lite rates the cost is a
**rounding error** — ~$0.13–$0.75/month for all pro users, single-digit
dollars even on full Flash. So the model is chosen for accuracy, not
price; the real constraints are trust, accuracy and privacy.
## How it works
**Two tiers** (every transaction is covered, rules are an optimization
on top):
- **Tier 1 – label** — a queued listener runs *after* the synchronous
rules; if still uncategorized and the user is eligible, the model picks
a **leaf** category (referenced by numeric index, never a UUID, so it
can't hallucinate one). Auto-applied only above the **label bar**
(`0.7`); below → left blank, no nag. Tagged `category_source = ai` +
`ai_confidence`, fully reversible.
- **Tier 2 – learn** — above the higher **rule bar** (`0.85`) *and* a
clean merchant key *and* the model flags the merchant unambiguous → the
merchant is appended to a single **ai-owned** automation rule for that
category (OR'd conditions, not rule-sprawl), so future transactions
match for free and consistently. AI rules sit at the lowest priority;
**user-owned rules are never touched**.
**Self-heal + signal** — when a user overrides an AI category, a
`category_correction` is logged (calibration signal, bucketable by
confidence) and the offending merchant condition is dropped from the ai
rule (deleted if empty). User rules and manual categories are untouched.
**Safety** — config kill switch + pro + active consent + gradual Pennant
rollout. Dedicated `ai` queue so Gemini never blocks bank syncs.
Encrypted (client-side) transactions are never sent.
**Backfill** — `ai:categorize-backfill {user}`, explicit opt-in,
batched, learns rules as it goes.
## Data model
- `transactions`: `category_source`, `ai_confidence`,
`categorized_by_rule_id`
- `automation_rules`: `origin` (`user`/`ai`)
- new `category_corrections` table
## Screenshots
<img width="921" height="384" alt="image"
src="https://github.com/user-attachments/assets/f04c2a03-b39e-4a3d-81eb-ecf26eaefb83"
/>
## What
Adds `stats:ai-cohort-report` — a monthly scheduled command that posts a
**weekly per-cohort** retention/conversion time series for the
onboarding AI suggestions feature to Discord.
## Design (pre/post, directional — not causal)
We measure whether shipping AI suggestions moves retention/conversion
among the users who can actually receive it. This is an **observational
pre/post** readout, deliberately chosen over a randomized A/B holdout
(low signup volume; we don't want to withhold the feature). It reports a
**correlation**, never a cause.
- **Cohort (ITT):** users who imported **≥50 transactions in their first
7 days** (a pre-treatment trait). We do *not* condition on who accepted
AI — that would reintroduce self-selection.
- **Release anchor `R`:** `MIN(ai_consents.accepted_at)`, planted by
self-accepting on deploy.
- **Metrics, all fixed-horizon from each user's own signup:**
- Retention — active ≥14d (`last_active_at ≥ signup+14d`)
- Trial — subscribed ≤14d
- Paid — `active` subscription ≤30d
- AI-acceptance — funnel-health line (descriptive, not causal)
- **Right-censoring:** too-young cohorts render as `pend`, never `0`.
- **Surge flagging:** weeks with outlier eligible volume (>2.5× median)
are flagged ⚡ so a one-time acquisition spike (e.g. the launch/YouTube
surge) isn't misread as an organic trend.
Staff/test accounts (incl. the one planting the anchor consent) are
excluded via `config('ai_suggestions.report.excluded_emails')`. Output
goes to `DISCORD_AI_COHORT_WEBHOOK_URL`, falling back to the shared
`DISCORD_WEBHOOK_URL`.
## Honest caveat (baked into every report footer)
A one-time launch+YouTube signup surge sits right before release and
there's no acquisition-source tracking, so cross-release comparisons mix
channels. Read **organic-vs-organic cohort trends over a quarter**, not
a month-one before/after diff.
## Deploy steps
1. Self-accept the AI consent in prod to plant `R`.
2. Add your email (+ staff) to `AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS`.
3. Set `DISCORD_AI_COHORT_WEBHOOK_URL` (or rely on the existing admin
webhook).
## Tests
`tests/Feature/SendAiCohortReportCommandTest.php` — 7 tests / 25
assertions covering eligibility, retention, trial/paid windows, release
anchor + pre/post split, maturity censoring, surge flagging, and Discord
delivery (incl. webhook fallback). Pint clean.
Suggests transaction categorization rules during onboarding.
After a sync or import, it groups the uncategorized transactions, asks
Gemini (via laravel/ai) to map the common merchants to categories, and
shows the results for review. The user edits or drops any and creates
the ones they want. During onboarding the accepted rules also categorize
existing transactions right away.
Off by default: it needs the `AiRuleSuggestions` Pennant flag and a
per-user AI consent. The model and thresholds are config-driven.
`ai:suggest-rules {user}` prints what a user would get.
The settings-page surface and monthly regeneration are a follow-up.
## What
Adds an `agent:db` artisan command so agents (and humans) can run read
queries against the local or production database from the CLI.
```bash
php artisan agent:db "select id, email from users limit 5" # local, JSON (default)
php artisan agent:db --format=table "select count(*) from transactions" # console table
php artisan agent:db --prod "select count(*) from users" # production
```
### Options
- `--format=json` (default) — pretty-printed JSON
- `--format=table` — classic console table
- `--prod` — target the production connection (new `prod` connection
backed by `PROD_DB_URL`)
## Notes
- Read-only: uses `DB::select()`, so it won't run
`INSERT`/`UPDATE`/`DELETE`. Query errors are caught and reported.
- Adds a `querying-the-database` skill documenting the command
(auto-activates on prod/DB questions).
- De-duplicates the skills tree: `.agents/skills` is now the canonical
directory and `.claude/skills` is a symlink to it (previously both were
tracked as identical copies).
## Test plan
- [x] `php artisan test --filter=AgentDatabaseCommand` (4 passing: json,
table, invalid format, query error)
- [x] `vendor/bin/pint`
## Why
Bank connection via Enable Banking is a critical flow that must keep
working in **both onboarding and settings**, including reconnecting an
expired consent. This adds regression coverage so it never silently
breaks.
## What
Two complementary layers:
### 1. CI regression — mocked Pest browser tests
`tests/Browser/BankConnectionFlowTest.php` drives the full UI →
`authorize` → `callback` → account mapping → sync flow for all three
scenarios, with the banking provider faked
(`tests/Support/FakeBankingProvider.php`). Deterministic, no external
calls.
- ✅ connect from onboarding (accounts auto-created)
- ✅ connect from settings (+ account mapping)
- ✅ reconnect an expired connection (re-matches accounts by IBAN)
**Runs on CI** in the existing `browser-tests` job (distributed across
shards automatically as a new test; `shards.json` timing entry will be
added next time `update-browser-shards` runs).
### 2. Live-sandbox verification — standalone Playwright script
`tests/Browser/live/connect-bank.mjs` runs the same three flows against
the **real Enable Banking sandbox** (Banco de Sabadell + BBVA) via the
dev server, for on-demand / nightly checks. Backed by a local-only
helper command `app/Console/Commands/E2eBankingFixtureCommand.php`.
**Does not run on CI** (needs the dev server, live sandbox, and
secrets). See `tests/Browser/live/README.md`.
#### Why the live flow can't be a normal Pest test
Enable Banking only redirects to the fixed registered URI
(`https://whisper.money.local/open-banking/callback`). A
`RefreshDatabase` Pest test runs an ephemeral server on a random port
with a transactional DB the external redirect can never reach. The
script instead drives the persistent dev server, captures the
`code`+`state` the bank redirects with, and replays the callback against
the dev server (the callback is stateless — keyed by `state_token`).
## Verification
- Mocked Pest tests: **3 passing**.
- Live script: **all 3 scenarios PASS** against the real sandbox
(Sabadell → 5 accounts + 328 tx synced; BBVA onboarding → 7 accounts;
expired → reconnect refreshes `valid_until` and retains accounts).
- `pint --test`, eslint, prettier clean.
> Note: while verifying locally, the dev DB was missing the
`state_token` migration (`2026_06_05_185212`), which made
`/open-banking/authorize` 500. Worth confirming it's applied in other
environments.
## What
Adds a `banking:disconnect` artisan command to disconnect (soft-delete)
one or more banking connections by ID, for ops use on prod.
```bash
php artisan banking:disconnect <id1>,<id2>,<id3>
php artisan banking:disconnect <id1>,<id2> --delete-accounts
```
## How
- Parses comma-separated IDs (trims spaces, dedups).
- Reuses the existing `DisconnectBankingConnection` action, which:
- Revokes the session on Enable Banking's side (`DELETE /sessions/{id}`)
when the connection is an active Enable Banking one.
- Sets status to `Revoked` and soft-deletes the connection.
- Default behavior unlinks linked accounts (kept as manual accounts).
`--delete-accounts` hard-deletes accounts, transactions and balances.
- Warns on unknown IDs, reports per-connection results, and exits with
failure if any ID was missing or any disconnect threw.
- A provider-side revoke failure does not block the soft-delete (action
catches + logs a warning), matching the existing
`banking:cancel-free-enablebanking` behavior.
## Tests
`tests/Feature/DisconnectBankingConnectionsCommandTest.php` — covers
multi-ID disconnect + session revoke, `--delete-accounts`, missing IDs,
and no-match. All pass.
## Why
The Stripe subscription events posted to the Discord admin feed weren't
useful (only status + raw `cus_123` id) and sometimes arrived duplicated
— including two cancellation messages for the same subscription.
## What changed
**Richer messages** (`PostStripeEventToDiscord`)
- Customer name + email (resolved via the Stripe API), plan + interval
(`€19.99 / month`), status, subscription id.
- A `Changed` field built from Stripe's `previous_attributes` diff (e.g.
`Status: trialing → active`).
- Cancellation reason, trial-end and period-end dates where relevant.
- Invoices now show invoice number + subscription id.
**Deduplication (two causes fixed)**
- *Webhook/queue retries*: guard on the Stripe event id via
`Cache::add(..., 24h)` — first delivery wins, retries skipped.
- *Double cancellation*: Stripe fires `subscription.updated` (cancel
scheduled) **then** `subscription.deleted`. We now label the scheduled
one `🗓️ Cancellation scheduled` distinctly from `👋 Subscription
cancelled`, and only post `subscription.updated` when something
meaningful changed (status, plan, or cancellation), dropping trivial
churn.
**New** `StripeCustomerResolver` service — resolves `Name (email)` from
a customer id, falls back to the id on any error. Injectable so it's
stubbed in tests (no network).
## Tests
9 cases covering rich fields, dedup, scheduled-vs-deleted cancellation,
status-change diff, trivial-update skip, deletion reason, and invoice
amounts. All passing.
Note: this adds one Stripe API call per subscription/invoice event, made
inside the queued listener (no webhook latency).
## What
Adds a private Discord admin feed with two flows, both posting through
one webhook (`DISCORD_WEBHOOK_URL`):
### 1. Daily stats — `stats:daily-report`
Scheduled **09:00 Europe/Madrid**. Posts an embed with:
- New users created **yesterday** (Madrid calendar day, converted to UTC
for the query)
- Total users
- Active/trialing counts + current & projected **MRR / ARR** per
currency
Reuses the #457 Stripe stats logic, extracted into
`SubscriptionStatsCollector` so the existing `stripe:subscription-stats`
command and this report share one source of truth.
### 2. Stripe events — `PostStripeEventToDiscord`
Queued listener on Cashier's `WebhookReceived`. Posts on:
- `customer.subscription.created` / `updated` / `deleted`
- `invoice.payment_succeeded` / `payment_failed`
## Setup
- `DISCORD_WEBHOOK_URL` — added to prod ✅ (set locally to test)
- Stripe dashboard must send the 5 event types above to the existing
Cashier `/stripe/webhook` endpoint
- A queue worker must be running (listener is `ShouldQueue`)
## Tests
13 passing: Discord client (3), daily report (2), Stripe event listener
(4), plus the existing stats command (4) still green after the refactor.
## What
Adds a `stripe:subscription-stats` Artisan command that prints
subscription stats to the CLI.
Shows, per currency:
- **Active subs** count + their MRR
- **Trialing subs** count + their MRR
- **Current MRR & ARR** (active subscriptions only)
- **Projected MRR & ARR** (if trialing subs convert to active)
## How
- Queries Stripe directly via `Cashier::stripe()`, auto-paginating
`active` and `trialing` subscriptions.
- MRR per subscription sums all items, normalizing each price to a
monthly value (handles day/week/month/year intervals, `interval_count`,
and quantity).
- Groups results per currency (EUR, BRL, …) since aggregating across
currencies would be incorrect.
```bash
php artisan stripe:subscription-stats
```
## Tests
4 Pest feature tests covering empty state, MRR/ARR math, per-currency
grouping, and quantity handling. Mocks the Stripe client.