Commit Graph

103 Commits

Author SHA1 Message Date
Víctor Falcón 4c83cb8b33
feat(import): persist per-account import configuration on the backend (#698)
## What & why

Import configuration (the CSV/Excel column mapping and date format a
user
sets up per account) was stored in the browser's `localStorage`, so it
never
followed the user to another device. Importing the same account's
statement on
mobile — or on a new machine — meant reconfiguring the mapping from
scratch.

This moves that configuration to the backend, keyed per account and per
import
type, so it's preconfigured and loaded automatically wherever the user
imports.

## Demo

Cross-device QA: import transactions on "device 1" with a custom column
mapping, then clear **all** cookies + local/session storage (simulating
a
fresh device), log back in, and start a new import — the mapping
(Description → *Movement*, Amount → *How Much*, date format
*DD-MM-YYYY*)
is auto-loaded from the backend with no manual setup.


https://github.com/user-attachments/assets/e449dfe9-3970-48d4-97ca-9e415c73835b



## How

- New `account_import_configs` table: one row per `(account_id, type)`
where
`type` is `transaction` or `balance`. The mapping + date format are
stored as
  an opaque `config` JSON blob (exactly what the client sends).
- `GET/PUT /api/accounts/{account}/import-config`
(`AccountImportConfigController`),
authorized via the existing `AccountPolicy` (`view`/`update`) — mirrors
  `AccountBalanceController`. `PUT` upserts on `(account_id, type)`.
- Frontend: the two import-config storage helpers now read/write the
endpoint
via `axios` instead of `localStorage` (merged into a single module — the
  transaction and balance variants shared the same logic).
- The saved config is fetched **off the file-parse critical path**: the
parsed
file shows immediately with auto-detected columns, and the saved mapping
is
applied when it arrives (guarded so a slow load can't clobber a file
picked
afterwards). A slow or hanging request never blocks the preview or Next
  button. When no config exists or the request fails, it falls back to
  auto-detection exactly as before.

## Notes / decisions

- **No localStorage migration.** Existing per-device configs aren't
migrated;
on the next import the mapping is auto-detected and re-saved to the
backend,
so it self-heals after one import. The old `import_config_account_*`
keys are
  simply no longer read.
- The mapping-validity check against the actual file headers stays
client-side
  (it depends on the just-parsed file).

## Testing

- `tests/Feature/AccountImportConfigTest.php` (9 tests): auth required,
  cross-user 403 on read and write, save→persist→load round-trip, upsert
  de-duplication, transaction/balance independence, and validation
  (unknown type, missing column mapping).
- Sibling suites green (`AccountBalanceControllerTest`,
`SavedFilterTest`) —
no regression from the new `Account::importConfigs()` relation or
routes.
2026-07-18 16:10:40 +02:00
Víctor Falcón 5d7b655111
feat(mcp): add write tools (Phase 2) (#690)
## MCP Phase 2 — write tools

> **Stacked on #689** (`mcp-functionality`). Base this PR on
`mcp-functionality`, not `main`, and merge it **after** #689.

Phase 1 shipped a read-only MCP server for Pro accounts. This adds the
**write** surface and re-enables the read/read-write token scope the UI
dropped in PR1.

### Write tools
A new `WriteTool` base extends `McpTool`: on top of the Pro-plan gate it
requires the calling token to carry `mcp:write`, returning a clear error
for read-only tokens. Each concrete tool is annotated `#[IsDestructive]`
(PHP attributes aren't inherited, so the annotation lives on each tool,
not the base — a docblock on `WriteTool` notes this).

- `create_transaction` — manual (non-connected) accounts only; forces
`source = manually_created`.
- `update_transaction` / `delete_transaction` — manually-created
transactions only; bank/imported ones stay locked.
- `categorize_transaction` — sets/clears the category on **any**
transaction (imported included), marking it `category_source = manual`.
- `label_transaction` — add/remove labels on **any** transaction.
- `create_balance` — balance snapshot on manual accounts only.
- `create_category` / `update_category` / `delete_category` — mirrors
the settings controller (parent/depth/cycle rules, cashflow derivation,
child strategies).
- `create_label` / `update_label` / `delete_label`.
- `create_automation_rule` / `update_automation_rule` /
`delete_automation_rule` — JsonLogic conditions + category/label
actions, at least one action required.
- `list_labels` — a small **read** tool added so label ids are
discoverable (label/automation tools are unusable without it).

### Guardrails
Write tools never touch bank-sourced data: the existing
`TransactionSource` enum and `Account::isConnected()` are the barriers,
reused not reinvented. There is no server-side write confirmation
(client-controlled, accepted decision) — hence `#[IsDestructive]`.

### Token scope
`StoreMcpTokenRequest` re-adds `scope` (`read` | `read_write`); the
controller grants `['mcp:read']` or `['mcp:read', 'mcp:write']`. The
settings page gets its scope selector back with honest copy (new strings
added to `lang/es.json`). The `/mcp` route stays gated on
`abilities:mcp:read` — any MCP token can connect and read; the per-tool
`mcp:write` check is what blocks writes.

### Tests
Happy path + guardrail failures for every write tool, the
read-only-token rejection (via a real read-only PAT so the `tokenCan`
gate runs exactly as over HTTP), cross-user isolation, the inherited Pro
gate, and read/read_write scope validation.

### Notes
- `AutomationRule::labels()` gained a generic return annotation (needed
for larastan level 5 on the new label mapping).

### Verification
- `vendor/bin/pint --test` 
- `vendor/bin/phpstan analyse` (larastan level 5) — 0 errors 
- `php artisan test tests/Feature/Mcp
tests/Feature/Settings/McpTokenTest.php
tests/Feature/LocalizationTest.php` 
- `prettier --check` / `eslint` on `settings/mcp.tsx` 
2026-07-17 15:25:03 +00:00
Víctor Falcón fb1adfc484
feat(mcp): read-only MCP server for Pro accounts (#689)
## What & why

Adds a **read-only MCP server** so a paid ("Pro") user can connect
Whisper Money to their own AI assistant (Claude web/desktop, Claude
Code, ChatGPT) and analyse their own finances — spending, cashflow, net
worth, transactions.

This is **Phase 1 (PR1): read-only**. Write tools (create/edit/delete
transactions, categories, labels, rules, balances) are a deliberate
follow-up (PR2); the token plumbing already reserves an `mcp:write`
ability for them.

## How it works

- **Transport:** remote streamable HTTP server via `laravel/mcp`,
mounted at `/mcp` (`routes/ai.php`).
- **Auth:** Sanctum personal access tokens with **MCP-only abilities**
(`mcp:read`). The route is gated by `auth:sanctum` +
`abilities:mcp:read` + `throttle:60,1`, so a future public-API token
(different ability) can't reach it and vice versa.
- **Pro gating** is enforced **per request inside the tools**
(`User::canUseFeature(PlanFeature::McpAccess)`), so a lapsed
subscription stops working on its own without the user revoking the
token. Free users can still create tokens (marked **PRO** in the UI) but
every call returns a "paid plan required" error with an upgrade URL.
- **Consent:** connecting is the consent — a clearly-weighted
data-egress disclaimer + per-client connection instructions on the
settings page. No separate checkbox (by design).

## Tools (all read-only)

| Tool | Scope |
|------|-------|
| `search_transactions` | space-scoped (optional `space`, defaults to
personal) |
| `list_accounts`, `list_categories`, `list_spaces` | space-scoped |
| `spending_by_category`, `get_cashflow`, `get_net_worth` | user's whole
account (reuse existing analytics services/controllers) |

Recurring-charge detection is left to the agent over
`search_transactions` results (no dedicated tool).

## Settings → MCP access

New page to create / rotate / revoke tokens (name + one-time secret
reveal), with `last_used_at`, a PRO badge, the egress disclaimer, and
copy-paste connection instructions for Claude (web/desktop), Claude Code
and ChatGPT.

## Tests

- Tool behaviour + Pro gating + **cross-user / cross-space isolation**
(`tests/Feature/Mcp/McpToolsTest.php`).
- HTTP auth boundary: 401 without a token, 403 without `mcp:read`, 200
with it (`tests/Feature/Mcp/McpEndpointAuthTest.php`).
- Token CRUD + ownership + free-tier creation
(`tests/Feature/Settings/McpTokenTest.php`).

## Reviewed & adjusted

Ran technical + product reviews and applied the fixes: kept PR1 strictly
read-only (dropped a UI scope selector that promised non-existent
write), routed gating through the `PlanFeature` convention, put token
rotation behind a confirmation, removed a silent on-load clipboard copy,
weighted the egress disclaimer, and fixed a `list_spaces` N+1.

### Known, deliberate tradeoffs
- `get_cashflow` / `get_net_worth` / `spending_by_category` reuse the
existing **user-scoped** analytics controllers/services, so they cover
the whole account rather than a single space (documented in the server
instructions). Per-space analytics is a follow-up.
- Space tools scope by `space_id` gated by membership
(`accessibleSpaces`) — the intended shared-tenant model — rather than a
per-row `user_id` filter.

## Not runnable in this environment
Browser QA of the settings page wasn't run here (no local
`node_modules`); that surface relies on CI build/typecheck/lint and
follows existing settings-page conventions.

---

## Updates since opening

- **Behind a feature flag.** New `App\Features\Mcp` (default off) hides
the whole settings screen — the nav item and every `settings/mcp*` route
(404 when off). Pro-plan gating still happens per request. Enable it
with `php artisan feature:enable "App\Features\Mcp" <email|all|25%>`.
- **Renamed** the user-facing page from "MCP access" to **"AI
Connector"** (nav, title, breadcrumb) so non-technical users understand
it. Route names, files and the feature stay internal.
- **Shared `ProBadge`** component (amber), now used on both the AI
Connector and billing pages instead of an inline badge.
- **Softer data-egress notice** (amber shield icon instead of a red
alert) and plainer copy throughout.
- **Accurate connection instructions (important).** Verified against the
official docs: a personal access token works with **Claude Code** today.
**Claude Desktop** and **ChatGPT** custom connectors authenticate over
**OAuth** and do not accept a static token, so they're now marked
**"coming soon"**. OAuth is the real unlock for those clients and is the
recommended follow-up (it also maps to the deferred write-tools work).
Sources: [Claude custom
connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp),
[ChatGPT developer
mode](https://developers.openai.com/api/docs/guides/developer-mode).
- UI reviewed in a real browser; layout/alignment checked across states
(empty, new-token reveal, token list).
2026-07-17 16:54:15 +02:00
Víctor Falcón a873582191
feat(transactions): allow editing all fields of manual transactions (#683)
## What & why

Until now only the **category, notes, labels and (for manual
transactions) the description** could be changed after a transaction was
created — the **amount and date were immutable and the account was
create-only**. Users creating transactions by hand had no way to fix a
wrong amount or date.

This lets **manually created transactions edit every field at any time
after creation**: account, date, description and amount, on top of
category/labels/notes. **Imported / bank-synced transactions keep
amount, date, account, currency and description locked** to their source
data.

## Changes

**Backend**
- `UpdateTransactionRequest` now validates `amount`, `transaction_date`,
`account_id` and `currency_code` **only when the transaction's `source`
is `manually_created`**. For imported transactions those keys are not
validated, so `validated()` drops them and they can't be changed even
via a crafted request.
- `TransactionController::update()` moves the manual account balance to
match an edited amount/date/account, **opt-in via the same
`update_balance` flag used by create/delete**. It snapshots the pre-edit
state, and only rebalances when one of amount/date/account actually
changed. Connected accounts are skipped inside the adjuster.
- `ManualBalanceAdjuster` was refactored to a shared private `adjust()`
primitive (removing duplication between the existing create/delete
paths) and gains `reverseCreatedTransaction()` so an edit can reverse
the old contribution and apply the new one.

**Frontend**
- `edit-transaction-dialog.tsx` unifies the create/edit editability
decision behind a single `canEditAllFields` flag and renders the
account, date and amount inputs (plus the "update account balance"
checkbox) when editing a manual transaction.
- `transaction-sync.ts` `update()` gains an `{ updateBalance }` option,
mirroring `create()`.

## Known limitation (by design)

Like create/delete, the balance update trusts the opt-in flag and nudges
a **single dated snapshot** — it doesn't cascade to later snapshots and
keeps no record of whether creation adjusted the balance. So it's exact
for the common case (recent transaction, flag used consistently) and can
drift otherwise. This matches the app's existing snapshot-based balance
model; a transaction-derived balance would be a separate, larger change.
Documented with a `ponytail:` comment at the call site.

## Tests

- **Feature (`tests/Feature/TransactionTest.php`)**: manual transaction
edits amount/date/account/currency; imported transaction cannot; balance
moves by the delta on amount change; no change when not requested;
moving between accounts reverses the old and credits the new;
currency-only edit doesn't rebalance; connected accounts never change.
All green locally (55 passed).
- **Component (`edit-transaction-dialog.test.tsx`)**: manual transaction
shows editable amount/date/description; imported keeps them read-only.

## QA

Real browser QA (Playwright) against the running app with the
`demo@whisper.money` data:
- Opened a manual transaction → edited date, description and amount,
kept "update account balance" checked, saved. Verified in the DB:
`amount -4200 → -7550`, `date 2026-07-10 → 2026-07-12`, description
updated, and the account balance snapshots moved accordingly.
- Opened an imported transaction → amount and description render
read-only (locked).

## Demo

https://github.com/user-attachments/assets/1c8790e8-31f5-4283-b260-353650ad007c
2026-07-16 08:59:10 +02:00
Víctor Falcón 6f72c43cce
feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650)
## 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.
2026-07-09 14:26:07 +02:00
Víctor Falcón 26875bbfff
refactor: consolidate duplicated financial calculations (#643)
## Summary

Wave 2 structural refactor: the same financial math was copy-pasted
across the
dashboard and the analytics API endpoints, so it could silently diverge
between
screens — a real risk in a finance app. This consolidates the duplicated
calculations into single sources of truth, kills a net-worth N+1, and
aligns
the PHP/TS rule engines and the `TransactionSource` enum. Every change
is
**behavior-preserving**; the numeric outputs of every endpoint are
unchanged and
are locked down with characterization/parity tests.

Builds on merged #640 (Wave 1); no file overlap. No dependencies
changed.

## Changes (per commit)

- **Consolidate savings-rate math into `CashflowSummaryService`** —
`savings_rate`
  and `net` were byte-identical inline in `DashboardController` and
`Api/CashflowAnalyticsController`. Extracted to
`CashflowSummaryService::summarize(income, expense)`;
  both controllers now spread its result (same keys, same values).
- **Move income/expense-side classification onto the `Transaction`
model** —
  the income/expense side test was reimplemented in three places
(`Api/TransactionAnalysisController`, `Api/CashflowAnalyticsController`,
`DashboardController`). Now `Transaction::isIncomeSide()` /
`isExpenseSide()`.
- **Extract duplicated `getCategorySpending` into
`CategorySpendingService`** —
  the tree-rollup expense-spending query was duplicated verbatim between
  `DashboardController` and `Api/DashboardAnalyticsController`. Moved to
  `CategorySpendingService::forPeriod()` (drill-parent parameterized).
- **Batch net-worth balance lookups to kill the per-account N+1** —
  `Api/DashboardAnalyticsController::calculateNetWorthAt` ran one
`AccountBalance` query per account per compared period. Now uses the
existing
`BalanceLookup::forAccounts()` (fixed 3 queries via carry-forward seed +
in-range records), reproducing the exact "latest balance <= date, else
0"
  semantics.
- **Align server rule normalization with the client and lock it with
parity
  fixtures** — `AutomationRuleService::normalizeRuleJson` protected only
  `['description','notes']` while `rule-engine.ts` also protected
`creditor_name`/`debtor_name`. Aligned to the superset (structurally a
no-op
since those var names are already lowercase, so no matching change) and
added
shared PHP+TS parity fixtures so the two engines can never drift
unnoticed.
- **Add missing `TransactionSource` cases to the TS type** —
`transaction.ts`
was missing `enablebanking`/`wise`; now mirrors
`App\Enums\TransactionSource`.
- **Guard `sumTransactions` against unsupported category types**
(reviewer fix) —
replaced the income/expense ternary that silently treated any non-Income
type
  as expense with a `match` that throws on Savings/Investment/Transfer.
- **Document category eager-load expectation on `Transaction` side
methods**
  (reviewer fix) — doc-only note to prevent a future N+1.

## Test plan

New tests:
- `tests/Unit/Services/CashflowSummaryServiceTest.php` —
net/savings-rate/rounding/div-by-zero.
- `tests/Feature/TransactionSideClassificationTest.php` — income/expense
side across signs, uncategorized, and transfer/savings/investment =
neither side.
- `tests/Feature/DashboardAnalyticsTest.php` — new net-worth test
asserts both the values (600000 / 540000) and a flat balance-query count
(<= 3) regardless of account count.
- `tests/Feature/RuleEngineParityTest.php` +
`resources/js/lib/rule-engine-parity.test.ts` +
`tests/Fixtures/rule-engine-parity.json` — one shared fixture set
driving both the PHP and TS rule engines.

Results (targeted, local):
- `--filter=Cashflow` (exclude Browser): 57/57 passed
- `--filter=DashboardAnalytics`: 41/41 passed
- `--filter=AutomationRule`: 60/60 passed
-
`--filter=TransactionSideClassification|CashflowSummaryService|RuleEngineParity`:
21/21 passed
- `bun run test rule-engine` (vitest): 13/13 passed
- `vendor/bin/pint --test`: pass; `bun run lint`: 0 errors; `bun run
format:check`: clean
- `bun run types`: 157 errors (unchanged pre-existing baseline), 0 in
touched files

Note: the 6 `Cashflow*` Browser tests fail locally only on "Vite
manifest not found" (no build present); they are environmental, not
logic, and pass in CI.

## Reviewer findings

Two read-only reviewers (architecture/quality and product/behavior)
reviewed the diff.

**Addressed**
- Both flagged that `sumTransactions` silently treated any non-Income
type as expense — added a throwing `match` guard.
- Eager-load expectation documented on the `Transaction` side methods.

**Verified identical** (behavior reviewer):
income/expense/net/savings_rate across all three endpoints; net worth
for both compared dates including no-record / all-records-after-range /
same-date edge cases; category spending (uncategorized excluded,
soft-deleted categories excluded, rollup/drill preserved); rule-engine
normalization output; multi-currency conversion.

**Deferred (documented)**
- The income/expense **summation** itself is still computed three ways
with differing uncategorized-transaction handling (dashboard
`whereExists` + sign vs analytics `join` excluding uncategorized vs
in-memory `isIncomeSide`). Unifying it would change numbers, so it is
out of scope for this behavior-preserving PR — worth a dedicated
follow-up.
- `savings_rate` keeps its `int|float` union (int `0` when income is 0).
Intentionally preserved to keep JSON output byte-identical.
- `BalanceLookup`'s `empty()` guard never short-circuits a `Collection`,
so a zero-account user runs 3 empty (harmless) queries. Left untouched —
it lives in a shared, unchanged service and only affects a no-account
edge case.

Do not merge before Wave 1 (#640) is in main.
2026-07-04 22:26:44 +02:00
Víctor Falcón 27919027fe
chore: harden Inertia boundary, CI type-check, and test isolation (#640)
## Summary

Wave 1 hardening: privacy/security quick wins on the Inertia boundary, a
CI safety net, and stricter test isolation. Four focused changes plus
two review fixes, each in its own commit. No dependency or
product-behavior changes.

## Changes (by commit)

1. **Hide sensitive User fields from serialization** — `User::$hidden`
only covered password / 2FA / remember_token, leaving Cashier billing
columns (`stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`) and
the legacy `encryption_salt` exposed in every serialized User, including
the Inertia-shared `auth.user` prop. None are read by the frontend and
Cashier keeps reading them server-side, so hiding them is invisible to
the UI and billing.
2. **Advisory frontend type-check in CI + duplicate `currency_code`
fix** — the CI linter job never ran `tsc`, so type errors accumulated
unseen. Adds a `Type Check Frontend` step running `bun run types`. It is
`continue-on-error: true` on purpose: the codebase already carries ~157
pre-existing `tsc --noEmit` errors, so gating on it now would turn CI
red on unrelated code. It surfaces type output today and should be
flipped to blocking once the backlog is cleared. Also removes a
duplicate `currency_code` member on the `User` TS interface (declared
twice, `CurrencyCode` and `string | null`); the TS language server flags
it, `tsc` masks it under `skipLibCheck`.
3. **Move residual-encryption cleanup out of Inertia `share()` into a
queued job** — `share()` is a shared-data provider and must be
read-only, but it ran a `DELETE`+`UPDATE` against the user on every
non-API web GET to purge the leftover encryption salt /
`EncryptedMessage` once a user had no encrypted data left. The existing
`encryption:*` commands do not cover this case (both target users who
*still* have encrypted data; this finalizes users who *finished*
decrypting). The work now goes to a new idempotent
`PurgeResidualEncryptionArtifactsJob` dispatched from `share()`,
preserving the eventual-cleanup semantics without writing during the
render.
4. **Block stray HTTP in the Feature test suite** — adds
`Http::preventStrayRequests()` in a Feature-scoped `beforeEach` so any
unfaked outbound request fails loudly instead of hitting the network. A
representative HTTP-touching subset (open banking,
exchange-rate/currency, AI categorization + AI/stats reports, analytics,
Discord, Stripe, bank logos) was run with the guard active; no test
relied on a real request, so no fakes had to be added.

### Review fixes (after the two-reviewer pass)

5. **Purge check uses `name_iv` source of truth, not the stale
`encrypted` flag** — the destructive purge decided "no encrypted
accounts" from `accounts.encrypted`, a flag the codebase already treats
as unreliable (see the
`align_accounts_encrypted_flag_with_plaintext_names` migration and
`FindsUsersWithLegacyEncryption`, which key off `name_iv`). An account
with an encrypted name but a stale `encrypted=false` flag could have its
key material destroyed. The job's guard now matches the canonical `*_iv`
predicate exactly; the loose flag gate in `share()` is kept only as a
cheap dispatch filter, and the job re-verifies with the safe predicate
before touching anything.
6. **Deduplicate purge dispatches with `ShouldBeUnique`** — `share()`
runs on every web GET, so an affected user re-enqueued the job on each
page load until a worker cleared the salt. The job is now
`ShouldBeUnique` keyed by user id, collapsing repeat dispatches into one
pending job.

## Test plan

- New/updated tests, all green:
- `InertiaSharedDataTest`: `auth.user` omits all sensitive fields; a web
GET no longer mutates the user inline and instead queues the cleanup
(and does not queue it when there is no salt).
- `PurgeResidualEncryptionArtifactsJobTest`: clears salt + message when
no `*_iv` data remains; keeps them when an encrypted transaction, an
encrypted account name, or a stale-flag-but-encrypted-name account
exists; no-op when salt already null.
- `StrayHttpRequestGuardTest`: an unfaked request throws
`StrayRequestException`; a matched fake still resolves.
- Green locally: `vendor/bin/pint --test`, `bun run lint` (0 errors),
`bun run format:check`, `bun run test` (254 frontend tests), targeted
backend
`--filter=InertiaSharedData|PurgeResidualEncryptionArtifactsJob|StrayHttpRequestGuard|Encryption`
(24 tests). Ran ~700 HTTP-touching Feature tests with the stray-request
guard active with no guard-induced failures.
- `bun run types` still reports the ~157 pre-existing errors (unchanged
set; this PR adds none) — that is exactly why the CI step is advisory
for now.

## Reviewer findings — addressed vs deferred

**Addressed**
- 🟠 Destructive purge keyed off the stale `accounts.encrypted` flag
instead of the `name_iv` source of truth → fixed in commit 5 (job now
mirrors `FindsUsersWithLegacyEncryption`; added a regression test for
the stale-flag case).
- 🟡 Per-request dispatch amplification with no dedup → fixed in commit 6
via `ShouldBeUnique`.

**Deferred (with rationale)**
- 🟠 "Three divergent copies of the legacy-encryption query" —
substantively resolved: the job now matches
`FindsUsersWithLegacyEncryption` exactly. The only remaining
`encrypted`-flag use is the `hasEncryptedAccounts` **UI prop** in
`share()`, which is a separate, pre-existing frontend concern; changing
it would alter which accounts the UI treats as encrypted and is out of
scope here. A full extraction into one shared scope would require
restructuring the trait (it builds a `User` query, not a per-model
boolean) and is not a Wave 1 quick win.
- 🟢 Redundant `->fresh()` / null guard in the job — kept deliberately:
it is the idempotency guard that makes the re-check read committed state
on the sync path (the second reviewer credited it as what makes repeat
dispatches safe).
- 🟢 `continue-on-error` shows the type-check step green — acknowledged;
flipping to blocking (or failing on an increase over a committed
baseline) is the follow-up once the ~157-error backlog is cleared.
- 🟢 (Product review) Theoretical one-request SSR/client
`hasEncryptionSetup` diff from async salt clearing — invisible in
practice (the lock button gates on `hasEncryptedAccounts ||
hasEncryptedTransactions`, false in both SSR and client), and `ssr.tsx`
is untouched. No action.

Product-bug reviewer verdict: no user-facing regressions. Hiding the 5
fields does not affect Cashier (raw attribute access),
`EncryptionController`, notifications, or any API/JSON path; the
`currency_code` dedup is runtime-identical; the cleanup timing change is
client-absorbed.
2026-07-04 18:57:58 +00:00
Víctor Falcón 7e36bbafef
feat(ai): dismissable AI consent banner that stops after the first decision (#617)
## What

The transactions AI consent banner now lets users decline, tells them
the outcome, and stops appearing once they've decided.

- **Dismiss option**: an X button before "Enable AI" permanently hides
the banner without granting consent.
- **Show only until the first decision**: the choice is persisted on
`users.ai_consent_prompt_dismissed_at` (set on both accept and dismiss).
The banner shows only while the user hasn't responded and never
reappears afterwards — even if they later revoke consent from settings.
- **Clear feedback**: accepting or dismissing shows a toast stating
whether AI was enabled and that the choice can be changed anytime in
**Settings > Manage Plan**.
- **Full-width layout on desktop**: the banner content spans the full
available width (button pushed to the right, text on a single line). The
narrow stacked layout is kept for mobile only.

## How

- New `POST ai/consent/dismiss` endpoint
(`AiConsentController::dismiss`).
- `User::dismissAiConsentPrompt()` (idempotent) +
`hasDismissedAiConsentPrompt()`; `store` now also marks the prompt
dismissed on accept.
- Migration adds the nullable `ai_consent_prompt_dismissed_at`
timestamp.
- `TransactionController` passes `aiConsentPromptDismissed` to the page.

## Tests

- `AiConsentTest`: idempotent dismissal, dismissal without consent, and
accept marking the prompt dismissed.
- Full consent + backfill suite and the `create a transaction` browser
test pass; pint / lint / format clean.
2026-07-01 07:26:36 +00:00
Víctor Falcón 986f43705a
fix(analysis): respect category types like the cashflow screen (#612)
## Why

On the **analysis** drawer, income/expense were derived from each
transaction's amount sign alone, ignoring the category type.
Transactions in `transfer` / `savings` / `investment` categories
therefore leaked into income/expense totals and every breakdown — e.g.
moving money between your own accounts inflated both sides. The
**cashflow** screen already keys off the category type; analysis now
does the same.

## What changed

Three commits:

1. **fix(analysis): exclude transfer, savings and investment categories
from income and expense**
Only `expense` categories (or uncategorized outflows) count as expenses,
and only `income` categories (or uncategorized inflows) count as income.
`transfer` / `savings` / `investment` are internal movements and sit on
neither side — identical to how cashflow computes income/expense/net.

2. **refactor(analytics): de-duplicate currency conversion and
category-type helpers**
`convertTransactionAmount()` / `preloadExchangeRates()` were
byte-identical in three controllers, and `categoryType()` in two.
Extracted the currency helpers into a shared
`Api\Concerns\ConvertsTransactionCurrency` trait (following the existing
`OpenBanking/Concerns` pattern) and moved `categoryType()` onto the
`Transaction` model. No behavior change.

3. **fix(analysis): net refunds within a category so totals reconcile
with cashflow**
Classification keyed off each transaction's own sign dropped contra-sign
rows entirely, so a refund booked to an expense category disappeared
instead of netting the spend down — and analysis disagreed with cashflow
on the same data (a `-10000` charge + `3000` refund read as `10000`
spent, not `7000`). A transaction's side is now decided by its category
type and signed amounts are summed before clamping, mirroring cashflow's
reconciliation across summary, category, payee, account, tag and
over-time. The largest-expenses list still shows only genuine outflows.

## Tests

Added analysis coverage for: transfer/savings/investment exclusion
(summary, breakdowns, largest, over-time), refund netting (asserts
parity with cashflow's `7000`), income-category reversals,
foreign-currency conversion, and uncategorized inflows. Full non-browser
suite green (1823 passed); `pint --test`, `bun run format`, `bun run
lint` all clean.

## Follow-up for product (not in this PR)

On **cashflow**, savings/investment outflows are excluded from expense
but re-surfaced in a dedicated "Saved & Invested" card. The **analysis**
drawer has no equivalent surface, so money categorized as
savings/investment is now correctly excluded from spending but isn't
shown anywhere. If we want analysis to fully account for it, we should
add a small "set aside" summary there. Flagged for a product decision
rather than bundled into this bugfix.
2026-06-29 21:04:18 +02:00
Víctor Falcón ee69c51a84
feat(transactions): make new-transaction marker cross-device (#611)
## What

The "new since last visit" highlight (#609) stored its marker in
`localStorage` (`transactions-last-visit`), so it was **per-device**:
each device kept its own last-visit timestamp and re-flagged the same
rows. This moves the marker to a per-user server column so a single
"last visit" is shared across all your devices.

## How

- New nullable `transactions_last_visited_at` column on `users` (same
pattern as `last_active_at` / `paywall_seen_at`).
- `TransactionController@index` reads the stored marker, renders the
list with the **old** value (`lastVisitAt` prop), then advances it
forward to the newest `created_at` it served.
- Frontend drops the `localStorage` read/write and just freezes the
`lastVisitAt` prop at mount; `isNewSince` per-row logic is unchanged.

## Why newest-served `created_at` and not `now()`

Advancing to `now()` would mark a back-dated synced row (old
`transaction_date`, lands on a later page that wasn't served) as seen,
so it would never be highlighted — the exact "hiding" failure the
per-row design avoids. Advancing only to what was actually served keeps
the feature's "err on showing, never hiding" stance. The marker only
ever moves forward.

## Notes

- Same filter caveat as before: opening the list with a filter applied
advances the marker using the filtered payload. Carried over from the
original `localStorage` behavior; not a regression.
- Removed the now-dead `loadLastVisit` / `saveLastVisit` /
`newestCreatedAt` helpers and their tests.

## Tests

- `NewTransactionsMarkerTest` (feature): null marker on first visit +
stores newest served; later visit sees previous marker + advances
forward; marker never moves backward.
- `new-transactions.test.ts` (`isNewSince`), Pint, ESLint, Prettier all
pass.
2026-06-29 19:11:37 +02:00
Víctor Falcón 777dfc07b2
feat(dashboard): add accounts manager dialog with visibility toggle and reorder (#604)
## What

Adds an **Edit** button (icon-only, top-right) to the dashboard accounts
grid, next to a new **Accounts** section title. It opens a dialog for
managing accounts:

```
Accounts                     [edit]

[ acc 1        ] [ acc 2        ]
[              ] [              ]
```

Each dialog row shows:
- the bank logo + account name
- an **eye toggle** (open/closed) to hide or show the account on the
dashboard grid
- a **drag handle** to reorder

## Behavior

- **Visibility**: hidden accounts disappear from the grid but stay in
the net worth chart/totals and remain in the dialog so they can be
re-enabled. Backed by a new `hidden_on_dashboard` column.
- **Reorder**: moved entirely into the dialog (over *all* accounts,
including hidden) instead of dragging the cards. This keeps a single
source of truth for `position` — dragging visible-only cards would have
left hidden accounts' positions inconsistent. The card-level drag handle
was removed.
- Both actions use optimistic UI, mirroring the existing reorder flow.

## Changes

- Migration: `hidden_on_dashboard` boolean on `accounts` (hidden from
default serialization like `position`, surfaced explicitly in the net
worth evolution payload).
- `AccountController::updateVisibility` +
`UpdateAccountVisibilityRequest` (owner-authorized) and the
`accounts.visibility` route.
- New `AccountsManagerDialog` component; dashboard header + plain grid;
removed the now-unused `dragHandle` prop from `AccountBalanceCard`.
- New `es.json` keys.

## Tests

Added feature tests for the visibility endpoint (toggle, validation,
ownership). Full `AccountControllerTest` and `DashboardAnalyticsTest`
suites pass; lint and types are clean on the touched files.
2026-06-27 16:11:25 +00:00
Víctor Falcón 934d834ab3
feat(email): follow up after post-onboarding AI consent (#596)
## 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.
2026-06-26 17:56:06 +00:00
Víctor Falcón 291cfbe261
feat(ai): record the model behind each AI categorization (#594)
## Why

We pay for AI transaction categorization on Gemini, but we couldn't tell
which model produced any given suggestion. The `gemini-flash-latest`
alias used by categorization is **floating** — it silently drifted from
the `flash-lite` tier up to `gemini-3.5-flash`, which is now the
dominant cost on the Gemini dashboard. Before swapping categorization to
a cheaper pinned model we need to measure accuracy per model, and for
that we have to know which model labelled each row.

We already store the AI-suggested category and its confidence on
`transactions`; this adds the missing third axis: the model.

## What

- New nullable `ai_model` column on `transactions`, written by
`CategorizeTransactions::recordOutcome` from
`config('ai_categorization.model')` whenever the model produces a
suggestion (above or below the label bar).
- The migration backfills existing AI-categorized rows
(`ai_suggested_category_at` not null) with `gemini-3.5-flash` — the
model `gemini-flash-latest` resolved to when those suggestions were
made. Done as a raw update so it doesn't bump every row's `updated_at`.
- `ai_model` added to `$fillable` and to `$hidden` (pure ops/measurement
metadata, no need to ship it to the frontend).

## Testing

- Extended `CategorizeTransactionsTest` to assert `ai_model` is stored
on both the auto-applied and below-bar paths.
- `php artisan test tests/Feature/Ai/CategorizeTransactionsTest.php` → 5
passed.

## Follow-ups (not in this PR)

- Pin `AI_CATEGORIZATION_MODEL` to an explicit cheaper tier (mirror the
existing `AI_SUGGESTIONS_MODEL=gemini-2.5-flash-lite`) and kill the
floating alias in the config defaults.
- Capture token usage from the SDK response for cost tracking.
2026-06-26 12:53:55 +00:00
Víctor Falcón 9a458b1031
feat(ai): manage AI consent outside onboarding with live backfill (#591)
## Summary

Lets existing (non-onboarding) users discover and manage AI
categorization, gated behind a new Pennant feature flag. When a flagged
paid user grants consent, every uncategorized transaction is categorized
in the background with live, on-screen feedback.

Enable per user: `php artisan feature:enable AiConsentSettings
user@example.com`

## What's included

**Feature flag**
- New `AiConsentSettings` Pennant flag (off by default), exposed to the
frontend via the shared `features` prop.

**Billing settings (`/settings/billing`)**
- When the flag is active, a section to grant or revoke AI consent
(checkbox → `POST`/`DELETE /ai/consent`).

**Transactions page**
- A consent prompt rendered as the first row of the transactions table
(new `DataTable` `topRow` slot), styled with the shared gradient
`AiSparkleIcon`. Only shown to paid users with the flag who haven't
consented yet.
- An inline "Enable AI" button records consent without leaving the page.

**Backfill on consent**
- Granting consent dispatches a queued
`CategorizeUncategorizedTransactionsJob` that categorizes all of the
user's uncategorized transactions, **most recent first**, recording
progress in the cache.
- The transactions page polls a new status endpoint (`GET
/ai/categorization/{jobId}/status`): visible uncategorized rows show a
spinner + pulse while the backfill runs, categories stream in via
partial reloads, and a toast tracks `processed / total` until
completion.

## Notes / decisions

- **Polling, not websockets** — the project has no broadcasting
configured; this mirrors the existing `ReEvaluateTransactionRules` job +
status-endpoint pattern.
- The backfill job is kept separate from
`CategorizeOnboardingTransactionsJob` so the onboarding pass stays
progress-free (small, commented duplication).
- Reuses the existing `AiCategorizationGate` (config kill-switch + pro
plan + active consent) — free users and disabled-AI never dispatch the
job.
- Not included (YAGNI): cancelling an in-flight backfill on revoke;
persisting the spinner across full page reloads.

## Testing

- New Pest coverage: feature-flag exposure, billing/transactions consent
props, consent-triggered dispatch + gating, the status endpoint
(200/404), job progress recording, and recent-first ordering.
- `php artisan test --exclude-testsuite=Browser` passes (one unrelated,
pre-existing `DashboardTest` failure on `main`).
- Pint, Prettier and ESLint clean.
2026-06-25 10:50:35 +02:00
Víctor Falcón f60e6d7035
feat(banking): add Interactive Brokers sync via Flex Web Service (#581)
## What

Adds **Interactive Brokers** as a banking sync provider (investment
account, balances only), mirroring the Indexa Capital integration.

It uses the **Flex Web Service** rather than IBKR's Web API: the Web API
requires registering as an IBKR third party (business entity, Compliance
approval, RSA-signed OAuth, ~3-5 weeks), which is overkill for read-only
balance sync. Flex is a read-only token + Query ID model that fits our
existing API-key provider shape.

### How the sync works
- The user creates an Activity Flex Query (NAV + Open Positions) and a
Flex Web Service token in their IBKR Client Portal, then pastes both.
- Client flow: `SendRequest` → reference code → poll `GetStatement` →
parse the XML statement.
- Mapping: `EquitySummaryByReportDateInBase@total` → `balance` (daily
rows give historical backfill on first sync); `Σ(OpenPosition
costBasisMoney × fxRateToBase) + cash` → `invested_amount`, so **profit
derives as `balance − invested_amount`** (unrealized P&L), like Indexa
Capital. Everything is already in base currency, so no FX conversion is
needed.
- One statement covers every account, so the syncer fetches once per
connection to respect IB's per-query rate limit.
- IB returns HTTP 200 with an error XML, so the client translates Flex
error codes into the exceptions the sync job already understands:
`RequestException(401)` for token problems, `RequestException(429)` for
throttling, `TransientBankingProviderException` otherwise.

### Connect flow
- New connect/update-credentials endpoints validate the credentials by
pulling a statement, then build pending accounts from it.
- Credentials reuse the encrypted `api_token` (Flex token) and
`api_secret` (Flex Query ID) columns — **no migration**.
- The IB option (two fields) is added to the connect dialog, inline
connect flow, and update-credentials dialog, with Spanish translations.

## Feature flag (why this is a draft)

Gated behind a Pennant feature `App\Features\InteractiveBrokers` (off by
default). It was built against documented/open-source Flex XML fixtures,
**not a live IBKR account** (we don't have one). Before enabling,
validate against a real account (beta tester or a free IBKR account):

```
php artisan feature:enable InteractiveBrokers user@example.com
```

If the parser needs tweaks against real XML, they should be minor
(field-name level).

## Tests
- Client + balance sync: NAV → balance, invested/profit, daily backfill,
since-date incremental, multi-account, GetStatement polling, token-401 /
rate-limit-429 mapping.
- Controller: feature-flag gate (403), valid/invalid credentials,
subscription gate, onboarding auto-create, validation.
- Factory wiring, enum cases, job-level sync, feature-flag visibility
(vitest).
- Spanish translations added (enforced by `LocalizationTest`).

All green: `pint --test`, `phpstan`, OpenBanking + localization suite,
vitest.
2026-06-23 11:39:24 +02:00
Víctor Falcón cd3080ec52
feat(accounts): reorder accounts with drag-and-drop (#575)
## What

Let users reorder their accounts by drag-and-drop. The order is shared
between the **dashboard** and the **accounts page**, and persisted
server-side.

## Why

The account order was fixed (by type, then name). Users want to put the
accounts they care about first, consistently across both views.

## How

**Backend**
- New `position` column on `accounts`, backfilled per user from the
previous type/name ordering so existing layouts are preserved.
- `PATCH /accounts/reorder` (`AccountController@reorder` +
`ReorderAccountsRequest`) persists the order and validates ownership of
every id.
- Dashboard and accounts queries now `orderBy('position')`. `position`
is cast to int and hidden from the serialized payload (order is conveyed
by array order).

**Frontend**
- Shared `SortableGrid` component built on `@dnd-kit` (new dependency).
Pointer drag starts after a small move (clicks still work); touch drag
starts on a long press, so quick swipes still scroll.
- The drag handle swaps with the account type icon on hover — top-right
on the dashboard card, bottom-left on the accounts card.
- The accounts page is now a flat list (type grouping dropped) so its
order matches the dashboard exactly.
- Reorder is optimistic and avoids refetching the deferred dashboard
metrics.
- Haptic feedback (`'selection'`, same as the mobile menu) fires when a
drag starts on touch.
- On mobile the accounts card stacks vertically (name / amount / trend)
and hides the redundant bank-name subtitle.

## Tests

- `reorder` persists positions and rejects accounts the user doesn't
own.
- Index ordering updated to assert `position` order.
- Existing account/dashboard/real-estate suites updated and green.

## Notes / follow-ups

- New accounts get `position = 0` (appear first) — can add `position =
max+1` on create later.
- On mobile the whole subtitle is hidden, including "Mortgage at X" for
real estate.
- Mobile drag-and-drop discoverability (the handle only shows on hover)
is still open — discussed but not yet decided.
2026-06-21 11:17:45 +02:00
Víctor Falcón 57f8c93e28
feat(stats): weekly paywall stuck-cohort report to Discord (#563)
## 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.
2026-06-19 14:12:51 +00:00
Víctor Falcón 801f6c7cd4
feat(integration-requests): add not-doable status with a public comment (#552)
## 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
2026-06-17 16:36:32 +02:00
Víctor Falcón 7e9122eaf4
feat(integration-requests): let the admin bypass limits and auto-approve (#551)
## What

The user matching \`ADMIN_EMAIL\` curates the integration-requests
board, so they get special treatment:

- **No monthly cap** — \`actionsRemaining()\` reports a full quota for
the admin, so neither the backend checks nor the frontend buttons ever
gate them.
- **Auto-approved proposals** — requests created by the admin are stored
as \`approved\` instead of \`pending\`, skipping moderation.

## How

- New \`User::isAdmin()\` helper, mirroring \`isDemoAccount()\`,
comparing against \`config('mail.admin_email')\` (already wired to
\`ADMIN_EMAIL\`).
- \`IntegrationRequestController::actionsRemaining()\` early-returns the
limit for the admin.
- \`IntegrationRequestController::store()\` sets \`status\` to
\`Approved\` for the admin.

## Tests

- Added a feature test covering the admin bypassing the monthly limit
and auto-approval. All 16 \`IntegrationRequestTest\` tests pass.
2026-06-17 14:12:51 +00:00
Víctor Falcón 5e8f227fbd
feat(integration-requests): community board to request & vote bank integrations (#550)
## 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).
2026-06-17 12:50:51 +00:00
Víctor Falcón 9328cd3e1b
feat(ai): persist AI categorization suggestions below the label bar (#547)
## Contexto

El categorizador solo persistía un resultado cuando la confianza
superaba la barra (`ai_categorization.label_confidence`, 0.7). Las
sugerencias por debajo del umbral se calculaban y se descartaban,
dejándonos sin ninguna visibilidad de qué proponía el modelo para las
transacciones que no se autocategorizaban.

## Cambio

Se guarda la sugerencia del modelo en **toda** transacción que el modelo
coloca, mediante columnas tipadas (en vez de un blob JSON):

- `ai_suggested_category_id` (FK a `categories`, nullable)
- `ai_suggested_category_at` (timestamp, nullable)
- `ai_confidence` (ya existía) — ahora se rellena **siempre**, no solo
al aplicar

Por debajo de la barra la transacción sigue sin categorizar, pero la
sugerencia queda guardada; a partir de la barra, además se autoaplica la
categoría como hasta ahora.

## Para qué sirve

- **Afinar el umbral 0.7 con datos reales**: `where
ai_suggested_category_id is not null and category_id is null` muestra
exactamente qué nos perdemos.
- Habilita una futura UI de "la IA cree que es X, ¿confirmas?"
directamente desde el FK.

## Tests

- `CategorizeTransactionsTest`: el caso sub-umbral ahora verifica que la
sugerencia se persiste; el caso aplicado verifica que además se guardan
los campos de sugerencia.
- Suite de IA + tests que tocan transacciones en verde (111 tests).
2026-06-17 08:24:30 +00:00
Víctor Falcón da0c8c58fc
refactor: centralize duplicated provider & locale keys into enums (#543)
## 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** ✓.
2026-06-16 13:43:14 +00:00
Toni Grunwald 1c5a76a3a4
feat: add Wise open banking integration with balance sync (#525)
## Summary
Adds **Wise** as an API-token banking provider — connect flow,
transaction sync, and per-wallet balance sync — and fixes two bugs that
kept business/extra profiles and balances from working.

## Changes
- **Balance sync** — new `WiseBalanceSyncService` pulls
borderless-account balances on every sync. Previously Wise balances were
never fetched, so the displayed balance went stale (frozen at the last
value).
- **Multi-profile connect** — `WiseController` now creates accounts for
**every** profile on the token (personal *and* business), not just one.
- **ID-format fix** — `external_account_id` is now
`"{profileId}:{currency}"`. The connect flow previously wrote the
**borderless-account id**, but both sync services parse the **profile
id** — so any UI-connected account (and every business profile) silently
failed to sync.
- **Tests** — `WiseBalanceSyncTest` (currency matching, idempotent
today-balance, skip-when-unmapped) and `WiseControllerTest`
(multi-profile pending accounts, onboarding auto-create, invalid token).

## Test plan
- [x] `php artisan test
tests/Feature/OpenBanking/WiseBalanceSyncTest.php
tests/Feature/OpenBanking/WiseControllerTest.php` — 6 passing / 28
assertions
- [x] `vendor/bin/pint` — clean
- [ ] CI (full pest + lint + build)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-06-16 13:23:25 +00:00
Toni Grunwald dbec1c4c13
feat: add catch-all budgets (#527)
## Summary
Adds **catch-all budgets** — a budget flagged `is_catch_all` absorbs
every expense-category transaction not already claimed by another
(non-catch-all) budget, so out-of-budget spending is still tracked.

## Changes
- Migration: `is_catch_all` boolean (default false) on `budgets`.
- `Budget` model: fillable + boolean cast.
- `BudgetTransactionService`: a catch-all budget matches expense
transactions whose `category_id` is not claimed by any non-catch-all
budget; period assignment mirrors the same rule.
- `BudgetController`: supports the flag.

## Notes
- Pre-existing WIP committed as-is; CI is the validation gate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-06-15 16:07:19 +00:00
Víctor Falcón 8013a0b6f2
feat(ai): auto-categorize transactions with AI (behind flag) (#535)
## 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"
/>
2026-06-15 16:35:20 +02:00
Víctor Falcón 2bfb569a22
fix(account): block deletion while subscription or trial is active (#531)
## Problem

Self-deleting an account via **Settings → Delete account** only called
`markAsDeleted()`. It never checked or cancelled the Stripe
subscription, so a user could delete their account while still
subscribed and **keep getting billed** afterwards. The `user:delete` CLI
command already cancelled the subscription, but the web path never got
that logic.

## Fix

Block account deletion while the user is **on a trial** or holds a
**valid, uncancelled subscription**, and direct them to cancel via the
billing portal first.

- `User::hasActiveSubscriptionOrTrial()` — true on a trial or a
`valid()` subscription; **grace-period** (already-cancelled)
subscriptions are excluded since they won't re-bill, so deletion is
still allowed.
- `ProfileController::destroy()` — rejects with a `subscription` error
before deleting (server-side guard, also covers non-UI calls).
- Delete-account page now receives `hasActiveSubscriptionOrTrial`; when
set, the UI shows a warning + **Manage billing** link and hides the
delete dialog.
- Spanish translations added for the two new strings.

The `user:delete` CLI is unchanged — it keeps its
auto-cancel-with-confirmation flow for admin use.

## Tests

- Active subscription → deletion blocked
- Trialing subscription → deletion blocked
- Grace-period (cancelled) subscription → deletion allowed
- Delete-account page exposes the flag (true/false)

All passing locally, alongside Pint / Prettier / ESLint.
2026-06-14 20:46:44 +02:00
Víctor Falcón 8056ede636
feat(ai): suggest automation rules during onboarding (#523)
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.
2026-06-13 22:51:15 +02:00
Víctor Falcón fcf2d3d1ad
feat(users): track last login and last active timestamps (#516)
## What

Adds two timestamp columns to `users`:

- **`last_logged_in_at`** — set by an `UpdateLastLoggedInAt` listener on
Laravel's `Login` event. Records each explicit authentication (login
form or remember-me re-auth). Does **not** update on plain session-based
requests.
- **`last_active_at`** — set by a `TrackLastActiveAt` middleware on
authenticated web requests. Records the last moment the user did
anything. Throttled to at most one write per 5 minutes to avoid a DB
write on every request.

## Why

`last_logged_in_at` answers "when did they last authenticate";
`last_active_at` answers "when were they last using the app" (any
screen/activity), which a session-based login does not capture.

## Tests

- Login records `last_logged_in_at`
- Authenticated request records `last_active_at`
- Throttle window respected (no rewrite within 5 min) and refreshed once
it passes

Migrations not yet run on environments.
2026-06-10 11:01:30 +02:00
Víctor Falcón 7cc49a5368
feat(analysis): project-aware transaction analysis (#513)
## What

Reworks the filtered **transaction analysis drawer** to behave like a
*project view* — a coherent set of related transactions (a trip, a
rental, a renovation, a side hustle) — and surfaces data that was
already computable but never shown.

### Two shapes, auto-detected
The drawer adapts to whether the set is **expense-only** or **income +
expense**, detected from the income share (`income >= 15% of expense`).
A stray refund won't flip a trip into a P&L view; a rental's rent will.

- **Expense mode:** total spent, daily average, cumulative spend line.
- **Income mode:** net result + margin %, income vs expense bars,
cumulative **net** line.

The mode can be overridden per filter (Auto / Expenses only / Income &
expenses), mirroring the existing daily-average override exactly:
remembered in the browser per filter fingerprint and synced to a
matching saved filter via a new `analysis_mode` column + PATCH endpoint.

### New panels (all from existing data)
- **Largest expenses** — top 5, expandable to 10, biggest spends first.
- **Spending by payee** — horizontal bars, gated to ≥2 named payees.
- **Spending by account** — list, gated to ≥2 accounts.

### Smarter table
The largest-expenses table hides any column the filter or the rows have
pinned to a single value (e.g. filtering by one label drops the Labels
column; a one-category result drops the Category column).

## Tests
- **Backend:** new Pest coverage for `largest_expenses`, payee/account
breakdowns, `cumulative_net`, and the `analysis_mode` endpoint
(`TransactionAnalysisTest`, `SavedFilterTest`) — all green.
- **Frontend:** 9 vitest cases covering mode detection/override, the day
override, and column hiding.
- New `__()` keys added to `lang/es.json`.

## Notes
- Adds migration `add_analysis_mode_to_saved_filters_table`.
2026-06-09 15:32:07 +02:00
Víctor Falcón 8375fd490e
feat(transactions): filtered analysis dashboard (#507)
## Summary

Adds an **Analysis** button to the transactions toolbar (left of
Categorize) that opens a bottom-sheet dashboard summarizing the
transactions matching the current filters. Built around the Miami-trip
use case: tag a trip, filter to it, and see where the money went.

Everything is behind the existing `TransactionAnalysis` feature flag
(button hidden + endpoint returns 403 when off).

## Behavior

- **Button**: hidden unless `features.transactionAnalysis`. Disabled
until at least one filter is applied — desktop shows a hover tooltip,
mobile a tap dialog, both reading *"Apply a filter to enable this
button."*
- **Drawer** (vaul bottom-sheet, like the importer): fetches
`/api/transactions/analysis` with the current filters on open. Skeleton
+ empty/error states.
- **Charts**: KPI cards · spending over time (income/expense bars +
cumulative line, auto daily/monthly bucketing) · category donut + ranked
list (only when >1 category) · per-tag bars (only when >1 label).
- **Manual day override**: the *Avg / day* card has an editor to
override the date span used for the daily average (tickets bought months
ahead skew the span). Resolution precedence: matching saved filter's
`analysis_days` → `localStorage[fingerprint]` → auto span. Edits persist
to localStorage always, and sync to the saved filter when the current
filters match one.

## Backend

- `Api/TransactionAnalysisController@summary` — reuses
`Transaction::applyFilters()` + `IndexTransactionRequest`, converts to
the user's base currency via `ExchangeRateService` (rates preloaded),
rolls categories up through `CategoryTree`.
- `GET /api/transactions/analysis`.
- `analysis_days` (nullable) on `saved_filters` + `PATCH
/api/saved-filters/{id}/analysis-days`.

## Tests

- Pest: analysis endpoint (flag gating, totals, category/tag breakdown,
day/month bucketing, cumulative) + saved-filter day override
(set/clear/forbidden/invalid).
- Vitest: button gating (hidden/disabled/opens) + day-override
resolution (auto / saved-precedence / localStorage fallback / persists
to saved filter).
- New `lang/es.json` keys for all added strings.

## Notes

- Data is always sourced from the backend.
- The flag is still off by default — enable `TransactionAnalysis`
per-user to try it.
2026-06-08 14:04:00 +02:00
Víctor Falcón a7dde5fbc5
feat(open-banking): finalize bank connection without a session via state token (#498)
## Why

iOS PWAs hand the bank redirect back to the system browser (Safari),
where the app session does not exist. The old `/open-banking/callback`
sat behind `auth` + `verified` middleware, so Safari hit it with no
session → bounced to `/login`, the callback body never ran, and the
connection stayed `pending` forever (4 stale rows observed in prod).

## What

Make the bank connection flow session-independent end to end.

### 1. State-token callback (session-independent)
- `store()` generates `Str::random(40)`, persists it on the connection,
and passes it to `startAuthorization` as `state`.
- `/open-banking/callback` is now **public**. It resolves the connection
from `state_token` (`resolveConnectionFromState`) and derives the owner
from the connection, not the session.
- Connection is finalized server-side (`session_id`, status,
`state_token = NULL`) before any redirect.
- New migration adds the nullable, unique `state_token` column; the
column is hidden on the model.

### 2. Completion page instead of login bounce
When the callback runs without a session (the Safari case), the
connection is already finalized server-side. Instead of bouncing the
user to `/login`, render a standalone "go back to the app" page — their
session lives in the PWA, not here.

- New page `resources/js/pages/open-banking/connection-complete.tsx`
(success / error states, dark mode).
- `finishRedirect()` renders the page when `!Auth::check()`; logged-in
users still get the normal redirect.

### 3. Onboarding: land on the connections step + poll cross-browser
- A logged-in user finishing the flow lands straight on the onboarding
connections step (`?step=create-account`), now resolved server-side via
a validated `initialStep` prop so the step is deterministic.
- While on that step the page polls every 4s (`usePoll`, `only:
['accounts']`), so a connection finalized in another browser (PWA →
Safari) shows up without a manual refresh.

## Flow

```
POST /authorize → stateToken = Str::random(40)
  → startAuthorization(state = stateToken)
  → create BankingConnection(pending, state_token)
EnableBanking → callback?code&state=stateToken  (PUBLIC, no session needed)
  → find connection WHERE state_token = state
  → createSession(code) → finalize (session_id, status, state_token = NULL)
  → session present? redirect to connections step / mapping
                   : render "go back to the app" completion page
PWA (logged in) → onboarding?step=create-account → polls accounts every 4s
```

## Tests

36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
2026-06-06 12:12:07 +02:00
Víctor Falcón 8df44c2ef4
feat(transactions): save and reuse transaction filters (#496)
## What

Adds **saved filters** on the transactions page so users can name a set
of filters and reuse them later. The whole feature is gated behind a new
\`transaction-analysis\` Pennant feature flag (off by default).

## Why

Reusing the same filter combinations (e.g. "Japan trip", "Utilities") is
currently manual every time. This ports the saved-filters work from the
\`analysis-page\` branch, scoped down to **only** the transactions page
— the analysis screen and unrelated query changes from that branch are
intentionally left out.

## Changes

**Feature flag**
- \`App\Features\TransactionAnalysis\` — class-based flag, resolves
\`false\` by default.
- Shared to the frontend as \`features.transactionAnalysis\` and added
to the \`Features\` type.

**Backend (saved filters)**
- \`SavedFilter\` model (UUID, \`filters\` json cast, user belongsTo) +
migration + factory.
- \`Api\SavedFilterController\` — user-scoped index/store/update/destroy
under \`/api/saved-filters\`, ownership enforced with \`abort_unless\`.
- \`StoreSavedFilterRequest\` / \`UpdateSavedFilterRequest\` — name
unique per user, snake_case filter rules.

**Frontend**
- \`transaction-filter-serialization.ts\` —
serialize/deserialize/fingerprint between UI filter state and the
persisted snake_case shape.
- \`SavedFilters\` dropdown — load/save/update/delete with active +
dirty indicators.
- Integrated into the filter bar via an opt-in \`enableSavedFilters\`
prop, so it renders **only** on the transactions page (budgets/accounts
reuse the same component and stay unchanged). Render is also gated by
the feature flag.

**Filter bar UX**
- Split search + filters + saved searches onto their own row, separate
from the actions row (Categorize / Add transactions / columns), which
were getting cramped.
- On mobile, the text search moves into the filters popover.

## Reviewer notes
- Backend endpoints are **not** flag-gated; only the UI is, per the
request.
- Two-layer scoping: page opt-in prop **and** feature flag — flag off
means no UI anywhere.
- Translation keys fall back to the key (English); \`es.json\` entries
not added, matching the source branch.

## Testing
- \`tests/Feature/SavedFilterTest.php\` — 9 passing (auth, per-user
listing/uniqueness, ownership on update/delete).
- Pint, Prettier, ESLint clean. No new TypeScript errors introduced.
2026-06-05 18:00:14 +02:00
Víctor Falcón af87ac7560
fix(transactions): combine category and label filters with OR (#495)
## Problem

On the transactions page, the **Category** and **Labels** filters were
combined with **AND**. Selecting both a category and a label only showed
transactions that matched the category *and* carried that label.

## Fix

In `Transaction::scopeApplyFilters`, both filters are now wrapped in a
single `where` group with the label condition using `orWhereHas`, so
they combine as **OR**: a transaction matches if it's in a selected
category **or** has a selected label.

- Multi-category OR, category-tree expansion, and the *uncategorized*
option are preserved.
- Other filters (date, amount, account, search) remain AND.

## Tests

Added `category and label filters combine with OR` to
`TransactionFilterTest`. All 34 filter + bulk-update tests pass; pint
clean.
2026-06-05 17:11:02 +02:00
Víctor Falcón e62f1d6aac
refactor(api): standardize serialization via model $hidden (#492)
## What

Standardizes how models are serialized across web, API, and Sync
responses by relying on Eloquent `$hidden` + accessors on the models
themselves, instead of ad-hoc `select` scopes and per-controller field
picking.

Touches 9 models (`Account`, `Bank`, `Budget`, `Category`, `Label`,
`LoanDetail`, `RealEstateDetail`, `Transaction`, plus pivot hiding) and
the controllers/middleware that consumed the old ad-hoc shapes.

## Why

- Single source of truth for response shape lives on the model, aligned
with Wayfinder model typegen.
- Removes duplicated field-selection logic scattered across controllers.
- Continues the duplication-removal PR series (#475–#483).

## How

- Hide internal columns and pivots via `$hidden`; expose computed fields
via accessors.
- Controllers return full models / load full relations rather than
hand-picked columns.
- `HandleInertiaRequests` slimmed down to match.

## Notes for reviewers

- Per-commit breakdown: each model standardized in its own commit for
easy review.
- Tests added/updated for each model to assert the serialized shape
(hidden columns absent, relations present).
- Full suite: 1382 passed / 1 skipped / 0 failed. `pint` clean.
2026-06-05 13:57:34 +02:00
Víctor Falcón 1cc10566a3
feat: parent/child category tree (#474)
## Summary

Adds nested categories (parent → child, up to **3 levels**) across the
app. Children inherit their parent's type and cashflow direction, and
every category selector now renders the hierarchy as an indented tree.
Gated behind the `CategoryTree` Pennant flag (off by default).

## Backend

- **Migration**: nullable self-referencing `parent_id`; uniqueness
scoped per-parent via a `parent_unique_marker` virtual column (root
names stay unique). New composite unique created before dropping the old
one so the `user_id` FK keeps a supporting index.
- **`CategoryTree` service**: descendant/ancestor resolution, depth &
cycle checks, type cascade, subtree deletion.
- **Validation**: depth limit, cycle prevention, inherited + locked
child type.
- **Delete strategies**: reparent (default), promote to root, or cascade
(uncategorizes affected transactions).
- **Transaction filter** expands a selected parent to its descendants.
- **Cashflow Sankey & breakdown** roll up to top-level parents with
click-to-drill (children + a parent "direct" node).
- **Budgets** tracking a parent also count their children's
transactions.
- **Unified** the frontend category query behind
`Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every
selector receives the full Category shape, including `parent_id`.

## Frontend

- New `category-tree.ts` helpers (build/flatten/descendants/path,
tree-aware selection toggle + tri-state).
- **Settings page**: indented tree, sortable by name/color/type
(siblings sorted, hierarchy preserved), parent picker, delete-strategy
dialog.
- **Combobox** (transaction table cell + edit/create modal + parent
picker): indented tree, search keeps matches with their ancestors.
- **Transaction filter**: indented tree, tri-state cascading selection
(parent ↔ children), themed checkboxes, selected branches float to top
on open, ancestor-aware search.
- **Categorizer palette** and **budget multi-select**: same indented
tree + ancestor-aware search.
- **Sankey**: click a parent node to drill into its children, with
breadcrumb.
- Pennant `CategoryTree` flag gates the parent UI.

## Tests

- Pest: model/validation, delete strategies, filter expansion, cashflow
rollup/drill, budget child inclusion.
- Vitest: tree-aware selection logic.
- All green; Pint / ESLint / Prettier clean.

## Rollout

Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
2026-06-03 19:30:12 +02:00
Víctor Falcón 7784f0d4fb
refactor(banks): add Bank::availableForUser scope (#480)
## What

The "public banks + user's own banks" query was repeated in **5
controllers** (Transaction ×2, Settings/Bank, Budget, Cashflow,
Onboarding).

New `Bank::availableForUser($user)` scope; all 5 sites use it.

Bonus: the Onboarding variant had no `where()` grouping around
`whereNull/orWhere` — harmless today (no other constraints) but a latent
precedence bug. The scope always groups.

## Stats

- **-31 / +24 lines**, 5 call sites → 1 definition

## Checks

- `php artisan test
--filter="Bank|Transaction|Budget|Cashflow|Onboarding"` — 612 passed
(2707 assertions), 1 skipped
- `vendor/bin/pint --dirty` — pass

Part of duplication-removal series (#475–#479).
2026-06-03 17:59:24 +02:00
Víctor Falcón e178f1b1bd
feat(settings): let users disable bank transactions email (#472)
## Summary

Adds a **Notifications** section to the account settings page
(`/settings/account`, between Profile information and Update password)
where users can opt out of the daily "new transactions synced" email.

- New per-user preference `notify_on_bank_transactions_synced` on
`user_settings` (defaults to `true`, opt-out).
- `SendDailyBankTransactionsSyncedEmailJob` skips users who disabled it.
- Single generic `PATCH /settings/notifications` endpoint updates any
notification type via a key→column allowlist in
`NotificationPreferenceController::PREFERENCES`. Future notifications
only need a new entry there — no new route/controller.
- Email footer now links back to the settings section so users can
manage preferences.

## Testing

- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
2026-06-02 12:24:39 +02:00
Víctor Falcón 71dd6e2b7f
feat(budgets): track multiple categories and labels per budget (#466)
## Summary

Budgets previously tracked a single category **or** label (mutually
exclusive). This lets a budget span **multiple** categories and labels
at once, all pooling spend against one allocated amount per period.

Scope decided with the requester:
- **Shared pool** — one allocated amount; any tracked category/label
counts against it.
- **Multi categories + multi labels** on a single budget.
- **Create-only** — tracking is chosen at creation and locked afterward
(edit dialog shows it read-only).

## Changes

**Backend**
- New `budget_category` + `budget_label` pivot tables; data migration
copies existing `category_id`/`label_id` into them, then drops those
columns.
- `Budget` model: `categories()` / `labels()` belongsToMany.
- `BudgetTransactionService` matches transactions across **all** tracked
categories OR labels (live assignment + historical backfill).
- `StoreBudgetRequest` accepts `category_ids` / `label_ids` arrays,
requires ≥1 across both, validates ownership. `update` no longer touches
tracking.

**Frontend**
- Reusable `MultiSelect` (popover + command + badge).
- Create dialog uses multi-selects; cards and show page render tracked
categories/labels as badges; edit dialog shows them read-only.
- Dexie bumped to v10 (drops unused per-category allocations table, adds
`budget_labels`).

## Testing
- Updated all budget/transaction/listener/browser tests for the pivot
model; added cases for multi-category matching, mixed category+label
pooling, and store validation (empty selection + foreign-ownership).
- Added the new `__()` strings to `lang/es.json`.
- Local `pint`, `lint`, `format` pass. Relying on CI for the full suite.
2026-06-01 12:32:23 +02:00
Víctor Falcón 10da06ed84
feat(transactions): add counterparty fields (#440)
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns

## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent

Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.

## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
2026-05-27 16:20:55 +02:00
Víctor Falcón 7b03d7cf23
feat(leads): add user lead re-invite campaign (#432)
## Summary
- add re-invite tracking columns for user leads
- add queued re-invitation mailable and command
- default re-invite eligibility to leads invited at least 3 days ago
- localize re-invite email in Spanish for `es` leads
- add stats command output for conversion rate

## Tests
- vendor/bin/pint --dirty --format agent
- vendor/bin/phpstan analyse --memory-limit=2G
- php artisan test --compact
tests/Feature/Commands/SendUserLeadReInvitationsTest.php
tests/Feature/Mail/UserLeadReInvitationTest.php
tests/Feature/Commands/SendUserLeadInvitationsTest.php
tests/Feature/Mail/UserLeadInvitationTest.php
2026-05-26 08:35:31 +02:00
Víctor Falcón eaba315196
fix: allow managing canceled connections (#417)
## Summary
- add paywall settings CTA for onboarded users with ended canceled
subscriptions and bank connections
- keep onboarding and normal unpaid connected users out of this escape
path
- cover paywall props and connection settings access

## Tests
- php artisan test --compact tests/Feature/SubscriptionTest.php
- npx eslint resources/js/pages/subscription/paywall.tsx
2026-05-22 10:52:50 +01:00
Víctor Falcón 88faa5beb6
feat: keep past due subscriptions active (#416)
## Summary
- keep Stripe past_due subscriptions active during retry window
- share payment issue state with Inertia and show persistent toast
- send toast action directly to Stripe Billing Portal
- allow canceled users to fall back to free plan, while paid-only bank
connection access remains blocked
- add Spanish translations for new payment issue messages

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SubscriptionTest.php
tests/Feature/InertiaSharedDataTest.php
- php artisan test --compact tests/Feature/LocalizationTest.php
- npm test -- subscription-payment-issue-toast

Note: npm run types still fails on pre-existing unrelated TypeScript
errors.
2026-05-22 10:19:11 +01:00
Víctor Falcón e71a743a0a
feat: Coinbase banking integration (#388)
## Summary

Adds **Coinbase** as a banking/investment provider, alongside existing
Binance, Bitpanda, and Indexa Capital integrations. Connection auth uses
Coinbase Developer Platform (CDP) JWT (ES256) API keys.

Sync model mirrors Bitpanda: no historical balance reconstruction
(Coinbase API has no daily snapshot endpoint). Balance tracking starts
from connection date.

## Account model

One single **Crypto Portfolio** Whisper Account in the user's fiat
currency, aggregating all Coinbase wallets (crypto + fiat).

## Backend

- `app/Services/Banking/CoinbaseClient.php` — JWT-signed Coinbase
Advanced Trade client (`firebase/php-jwt` ES256). Per-request JWT, 120s
TTL, `uri` claim `"METHOD host/path"`. Methods:
`getAccounts`/`getAllAccounts` (cursor pagination), `getProduct`,
`getBestBidAsk` (batched), with 429 retry/backoff.
- `app/Services/Banking/CoinbaseBalanceSyncService.php` — Partitions
wallets into fiat vs crypto. Batched `best_bid_ask` for prices, USD
stablecoin shortcut (USDT/USDC/DAI/PYUSD/GUSD),
`CurrencyConversionService` fallback. Aggregates everything into user
fiat.
- `app/Http/Controllers/OpenBanking/CoinbaseController.php` +
`ConnectCoinbaseRequest.php` — mirror Bitpanda flow, validates
`api_key_name` (`organizations/{org}/apiKeys/{id}`) + PEM `private_key`.
Stores key_name in `api_token`, PEM in `api_secret` (already encrypted
TEXT).
- `BankingConnection::isCoinbase()`,
`SyncBankingConnectionJob::syncCoinbase()`, credential-update flow.
- `routes/web.php`: `POST /open-banking/coinbase/connect`.
- Bank seeder entry + factory `coinbase()` state.

## Frontend

- `connect-account-dialog.tsx` / `connect-account-inline.tsx`: Coinbase
appears in the bank picker. Confirm step shows `<Input>` for API key
name and `<Textarea>` (multi-line) for the PEM private key, with link to
CDP portal.
- `update-credentials-dialog.tsx`: Coinbase credentials editable.
- `settings/connections.tsx`: coinbase included in `isApiKeyProvider`.
- Wayfinder auto-generated `CoinbaseController.ts` +
`routes/open-banking/coinbase`.

## Tests

- `tests/Feature/OpenBanking/CoinbaseControllerTest.php` — happy path,
invalid creds (401 → 422), validation errors, subscription gate.
- `tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php` — mixed
crypto+fiat aggregation, USD stablecoin valuation, skip when
external_account_id missing.

All **217 OpenBanking tests pass**. Pint + ESLint clean.

## Follow-ups (not in this PR)

- Upload `storage/banks/logos/coinbase.png` to production storage (URL
referenced in seeder + frontend).
- Invested-amount calc from transaction history (deferred).
- Historical balance reconstruction (deferred — Coinbase has no daily
snapshot endpoint).
2026-05-13 19:53:30 +02:00
Víctor Falcón d9204bb3d6
fix(banking): dedup EnableBanking transactions by deterministic fingerprint (#390)
## Problem

Production user reported "inaccurate expenses, some appear multiple
times". DB inspection of their active BNP Paribas Fortis connection
confirmed duplicates growing every sync:

- `-3840` on `2026-05-11` x5
- `-2900` on `2026-05-08` x5
- `-2470` on `2026-05-12` x4
- 56 of 776 rows on the account had `external_transaction_id IS NULL`
- 16 (date, amount) duplicate groups, all with NULL upstream id

## Root cause

`TransactionSyncService::importTransaction()` short-circuited dedup when
both `transaction_id` and `entry_reference` were missing:

```php
$externalId = $data['transaction_id'] ?? $data['entry_reference'] ?? null;

if ($externalId) {
    // dedup check
}
// else: fall through, always insert
```

BNP returns no stable id for certain card transactions (`status:
"OTHR"`, `bank_transaction_code.code: "CCRD"`, foreign currency). Every
cron tick (every 6h) re-inserts a fresh copy.

Confirmed with prod data: of 16 NULL-id duplicate groups on this user,
**zero** ever got upgraded to a real id later. BNP simply doesn't issue
one.

## Fix

Deterministic per-transaction fingerprint, persisted in a new column,
protected by a unique index.

- **Migration**: adds `transactions.dedup_fingerprint` (nullable string,
80) and unique index on `(account_id, dedup_fingerprint)`. The unique
index is the real source of truth — it also closes the race between
overlapping sync runs that the prior `exists()` + `create()` pattern
couldn't.
- **`TransactionFingerprint::for($data)`**: two-mode fingerprint. If
`transaction_id` or `entry_reference` exists, the fingerprint is based
only on that canonical upstream id. If no upstream id exists, the
fallback fingerprint uses the prod-verified stable fields:
`booking_date`, amount, currency, credit/debit indicator,
creditor/debtor names + accounts, bank tx codes, reference number, and
remittance info. It intentionally excludes volatile fields (`status`,
`value_date`, raw `transaction_date`). Prefix `fp_` avoids mixing with
bank-issued ids.
- **`TransactionSyncService`**:
  - Always computes the fingerprint and writes it on every insert.
- Dedup lookup checks the fingerprint **and** (as a fallback) the legacy
`external_transaction_id` to gracefully handle rows imported before the
backfill runs.
- Wraps the insert in a `try/catch UniqueConstraintViolationException`
so concurrent syncs that pass `exists()` together don't crash.

## Rollout plan

Ship migration + service change → new duplicates stop. Existing
duplicate cleanup is intentionally out of scope for this PR.

## Tradeoff

Two genuinely distinct same-day, same-amount card transactions from the
same merchant on the same card collapse into one (no time-of-day in
raw_data for this BNP class). Today we over-count by 3–5x; after the fix
we may rarely under-count by 1. Acceptable net win, can monitor via logs
if needed.

## Tests

- `tests/Unit/Services/Banking/TransactionFingerprintTest.php` — 4 new
tests covering canonical id behavior and volatile-field exclusion.
- `tests/Feature/OpenBanking/TransactionSyncServiceTest.php` — 3 new
tests:
  - Dedupes payloads without an upstream id across consecutive syncs.
- Doesn't crash when a payload arrives later with an upstream id
(bounded behavior).
  - Dedupes against soft-deleted fingerprinted rows.
- Targeted suite green locally: **17 passed**.

## Files

-
`database/migrations/2026_05_13_085027_add_dedup_fingerprint_to_transactions_table.php`
*(new)*
- `app/Services/Banking/TransactionFingerprint.php` *(new)*
- `app/Services/Banking/TransactionSyncService.php`
- `app/Models/Transaction.php` (fillable)
- Tests above

## Out of scope

A smaller secondary pattern exists where BNP emitted distinct
`transaction_id`s for the same booking (Feb–Apr only, ~10 groups on the
reporter). Not addressed here because: (a) fingerprint includes
`transaction_id` so different ids = different fingerprints, (b) no
recent occurrences, (c) needs API-level analysis to determine when this
is genuine vs noise.
2026-05-13 11:30:11 +01:00
Víctor Falcón d140b4fd4c
fix(notifications): skip mail dispatch when recipient email is invalid (#387)
## Problem

Sentry:
[PHP-LARAVEL-1Z](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1Z)
— 5 events.

`ResendTransport::doSend` throws `TransportException: Invalid \`to\`
field` when a notifiable's email fails Resend's RFC validation
(malformed address, whitespace, etc.). The queued notification job dies
and we get paged.

Stacktrace path: `SendQueuedNotifications → NotificationSender →
MailChannel → ResendTransport`. Mail channel calls
`routeNotificationForMail()` on the notifiable to resolve the address;
both `User` and `UserLead` were returning the raw `email` column without
format checks.

## Fix

Validate the address in `routeNotificationForMail()` on both models:
- trim whitespace
- run `filter_var(..., FILTER_VALIDATE_EMAIL)`
- return `null` (skips the channel) when invalid
- log a warning with model id + notification class for triage

Other channels (database, etc.) keep working.

## Test

New `tests/Feature/RouteNotificationForMailTest.php` covers valid,
malformed, whitespace, and trimmed cases on both `User` and `UserLead`.

```
php artisan test --filter='RouteNotificationForMailTest|UserLeadTest|VerificationNotificationTest|UserLeadInvitationTest|SendUserLeadInvitations'
Tests:  48 passed
```

Fixes PHP-LARAVEL-1Z
2026-05-13 09:47:50 +01:00
Víctor Falcón caae0e8918
Preload exchange rates (#362)
## Summary
- preload dashboard exchange-rate ranges in one query
- memoize exchange-rate lookups per request
- cover Sentry N+1 regression

Fixes PHP-LARAVEL-1G

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/ExchangeRateServiceTest.php
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
- php artisan test --compact tests/Performance/ApiQueryCountTest.php
--filter='net worth evolution API'
- php artisan test --compact tests/Performance/PageQueryCountTest.php
--filter='dashboard'
2026-05-06 16:35:49 +01:00
Víctor Falcón 22043ced29
fix(budgets): remove Custom period type to fix duplicate-key crash (#355)
## Why merge this

Production bug
**[PHP-LARAVEL-1B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1B)**
— `/budgets/{budget}` throws a 500 (`UniqueConstraintViolationException`
on `budget_periods_budget_id_start_date_unique`) for the affected user.
Page is unusable for them until fixed.

## Root cause

The Custom period type let users create budgets with arbitrary
`period_duration`. Combined with `calculatePeriodDates()`'s day-of-month
snap (`startDate->day(period_start_day)`), short durations produced
periods where `end_date == start_date == period_start_day`. On the next
visit after that day, regeneration computed a new period whose start
snapped backward to the same day → unique key collision.

Concrete trace from the Sentry event:

- Budget: `Seguro de Salud`, `period_type=custom, period_duration=1,
period_start_day=1`, created `2026-05-05`.
- Initial period created at budget creation: `start=2026-05-01,
end=2026-05-01` (already 4 days stale because Custom snapped
`now()=05-05` back to day 1, then `addDays(1).subDay()` produced same
date).
- Today `05-05` → `getCurrentPeriod()` returns null → `generatePeriod()`
snaps back to `05-01` again → `INSERT (budget_id, 05-01)` → 1062.

Verified in prod DB: this is the **only** budget in the entire database
with `period_type='custom'` and the only period row with `end_date <=
start_date`.

## Fix

Custom is the only period type that exposes this snap-back collision
(Monthly/Weekly/Biweekly all use a self-consistent advance). It also has
no defensible UX — every legitimate use case is covered by
Monthly/Weekly/Biweekly. So we remove it end-to-end:

- `BudgetPeriodType::Custom` enum case dropped.
- `BudgetPeriodService`: Custom branches removed from
`calculatePeriodDates()` and `calculateNextPeriodStartDate()`.
- `Budget` model: `period_duration` removed from `$fillable` and
`casts()`.
- `StoreBudgetRequest` / `UpdateBudgetRequest`: `period_duration` rule
removed.
- `BudgetController`: `period_duration` no longer passed in
store/update.
- Create + edit budget dialogs: Custom option and `period_duration`
input removed.
- `ResetDemoAccountCommand`: Custom switch case removed.
- `BudgetFactory`: `custom()` state and `period_duration` default
removed.
- `types/budget.ts`: `'custom'` removed from `BUDGET_PERIOD_TYPES` and
`Budget.period_duration` field dropped.
- Migration `2026_05_05_132023_convert_custom_budgets_to_monthly`:
rewrites any existing `custom` rows to `monthly` with
`period_duration=null, period_start_day=1` so the dropped enum case
can't crash on hydration.

Period generation logic for Monthly/Weekly/Biweekly is **unchanged**.

## Manual prod cleanup (separate, after merge)

The single buggy budget in prod will be deleted manually:

```sql
DELETE FROM budget_periods WHERE id = '019df7fd-6073-731b-9c08-f3723515292b';
DELETE FROM budgets WHERE id = '019df7fd-6071-7219-b190-ece22ccdb63f';
```

## Tests

`tests/Feature/BudgetPeriodServiceTest.php` covers Monthly + Weekly
advance and Monthly first-period generation. Existing
`BudgetPeriodDateTest` unaffected.

## Risk

- `period_duration` column kept in DB (nullable) to avoid data loss; no
migration needed.
- Migration ensures any environment with `period_type='custom'` rows is
converted before the enum case is removed, preventing hydration errors.
- No customers actively rely on Custom: prod query confirmed only the
single broken budget uses it, and that one is being deleted.

Fixes PHP-LARAVEL-1B
2026-05-05 16:07:16 +02:00
Víctor Falcón f800847591
feat(banking): back off scheduler when EnableBanking returns 429 (#352)
## Problem

Production logs show repeated EnableBanking 429s on the same
connections, every cron cycle:

```
[2026-05-04 18:00:55] EnableBanking API error status:429
  body: [HUB046] Allowed number of accesses exceeded for consent
[2026-05-04 21:47:12] EnableBanking API error status:429
  body: Daily PSU not present consultation limit has been exceeded
[2026-05-05 00:01:41] same connection, same error
[2026-05-05 06:01:41] same connection, same error
```

Root cause: `SyncBankingConnectionJob` returned early on 429s without
persisting any backoff state. The scheduler kept re-dispatching the same
connection on every run, hammering the provider and burning the daily
quota.

## Fix

Persist a per-connection backoff window so the scheduler stops
re-dispatching until the provider quota resets.

- New `rate_limited_until` column on `banking_connections`.
- On 429: derive the window from
  1. `Retry-After` header if present,
2. "Daily ..." message → next UTC midnight (matches PSU daily limit
semantics),
  3. default 1 hour (consent / generic).
- Job short-circuits with a `Skipped` sync log if the window is still
active.
- Successful sync clears the window.
- `SyncAllBankingConnectionsJob` + `SyncBankingConnections` command
filter out connections still inside their backoff.

## Tests

- Existing rate-limit test updated (now also asserts the backoff is
set).
- New: daily message → next UTC midnight.
- New: `Retry-After` header honoured (1800s).
- New: rate-limited connection skipped without calling provider.
- New: successful sync clears `rate_limited_until`.
- New: scheduler excludes connections whose backoff has not expired.

`php artisan test --compact
--filter="SyncBankingConnectionJobTest|SyncRetryAndLoggingTest|SyncBankingConnectionsCommandTest"`
→ 70 passed.
2026-05-05 09:39:32 +02:00
Víctor Falcón ab3d6e9fca
feat(leads): cohort-based launch invitations with per-user Stripe coupons (#333)
## Summary

Wires up the launch flow: each waitlist lead gets a per-cohort
invitation email, a personal single-use Stripe promo code matching their
reward, and a signed landing link that unlocks register/install.

## Cohorts (resolved at send-time, by queue rank ASC, ignoring
`position` null/0)

| Cohort | Rule | Reward | Stripe coupon |
|---|---|---|---|
| `founder` | ranks 1–10 | Free forever | `wm_founder_forever` (100%
off, forever) |
| `founder_referrer` | referred any current founder (overrides rank) |
Free forever | `wm_founder_forever` |
| `early_bird` | ranks 11–100 | 2 mo free monthly / 3 mo free yearly
first year | `wm_earlybird_monthly` (100% off, 2 mo, monthly only) +
`wm_earlybird_yearly` (25% off once, yearly only) |
| `waitlist` | ranks 101+ | same as early bird | same coupons |

## What's added

- **DB**: `cohort`, `promo_code_monthly`, `promo_code_yearly`,
`invitation_sent_at` on `user_leads`.
- **Services**: `LeadCohortResolver`, `LeadPromoCodeAllocator`.
- **Commands**:
- `php artisan stripe:ensure-launch-coupons` — idempotent Stripe coupon
setup (run once per env).
- `php artisan leads:send-invitations --limit=N [--cohort=…] [--dry-run]
[--force]` — wave-by-wave delivery, idempotent across runs
(`invitation_sent_at` gate). Lazily generates Stripe promo codes per
lead it touches.
- **Checkout**: `SubscriptionController::checkout` resolves the auth
user's `UserLead` by email and applies the matching promo code
(`monthly`/`yearly`) via Cashier's `withPromotionCode()`.
- **Invite link**:
`LandingAuthOverrideService::generateInvitationUrl($leadId, days: 30)` —
signed lead-bound URL that unlocks auth buttons (existing override
cookie) and stores `invited_lead_id` in session. The register view
prefills the email from the lead.
- **Emails**: 4 cohort markdown templates (`founder`,
`founder-referrer`, `early-bird`, `waitlist`) with per-cohort subject +
body. Locale set from `$lead->preferredLocale()`. 28 Spanish keys added.

## Tests (Pest)

- `LeadCohortResolverTest` — boundaries + founder-referrer override +
null/0 position skip.
- `SendUserLeadInvitationsTest` — batch ordering, idempotent across
runs, ignores null/0, persists cohort.
- `UserLeadInvitationTest` — per-cohort body content, signed lead-bound
signup URL, Spanish locale.
- `LandingAuthOverrideTest` — invitation URL unlocks + stores session
lead.

Full suite green (1262 passed).

## Launch checklist

1. Merge + deploy.
2. `php artisan stripe:ensure-launch-coupons` against staging Stripe →
verify in dashboard → run on prod.
3. Optional preflight: `php artisan leads:send-invitations --limit=10
--dry-run`.
4. Wave-by-wave: `php artisan leads:send-invitations --limit=50` per
day.
5. Confirm `HIDE_AUTH_BUTTONS=true` on prod so only signed invite links
unlock register/install.

## Notes / non-goals

- No `User → UserLead` foreign key — checkout matches by email per
request.
- Existing `FOUNDER` promo flow is untouched and still kicks in if a
user has no lead-specific code.
- `founder_referrer` cohort is empty in current prod data; logic is in
place for when founders start referring.
2026-04-30 15:10:28 +01:00
Víctor Falcón 74cbdd42ef
feat(billing): apply Stripe tax rates to subscriptions (#325)
## Summary
Every subscription now gets Stripe tax rates attached automatically via
Cashier.

## Changes
- `config/subscriptions.php`: new `tax_rates` array, env
`STRIPE_TAX_RATES` (comma-separated), default
`txr_1TPfzrLRCmKA3oWMNWmkQeq2`
- `app/Models/User.php`: `taxRates()` reads from config — Cashier picks
it up automatically on `newSubscription()` checkout + subscription
creation
- `tests/Feature/SubscriptionTest.php`: 2 tests

## Applies to
- New checkout sessions (`SubscriptionController::checkout`)
- New subscriptions created via Cashier

## Existing subscriptions
Not updated automatically. To sync:
```php
$user->subscription('default')->syncTaxRates();
```

## Notes
Using hard-coded tax rate IDs (not Stripe Tax auto-calc). Switch to
`Cashier::calculateTaxes()` later if desired.
2026-04-24 14:07:58 +02:00