Commit Graph

134 Commits

Author SHA1 Message Date
Víctor Falcón 2b2e4c4a87
feat: reuse the upgrade modal at more upsell points and attribute revenue (#699)
> **Stacked on #696.** That PR introduced the AI-categorization upgrade
dialog this one generalizes. It targets `main` so CI runs, so until #696
merges this PR's diff also contains #696's commit — review/merge #696
first, then this diff resolves to just its own three commits.

## What & why

The contextual "this is a paid feature → pick a plan → checkout" modal
built for AI categorization is now a **shared component** reused at two
more Pro-feature entry points, and every checkout it starts is
**attributed to the upsell point** so we can measure revenue per point.

### 1. Reusable upgrade modal

Extracted `AiUpgradeDialog` (+ `PlanCard`) out of `settings/billing.tsx`
into a shared `UpgradeDialog`
(`resources/js/components/subscription/upgrade-dialog.tsx`). It reads
`pricing`/`locale` from `usePage`, takes `title` / `description` /
`source`, renders the plan picker, and links to Stripe checkout. Used
at:

| Point | Trigger | Copy |
|---|---|---|
| AI categorization | Manage Plan toggle (unchanged) | "AI
categorization is a paid feature" |
| **Connections** | "Connect Bank" | "Bank connections are a paid
feature" |
| **Connected accounts** | Create Account → "Connected" | "Connected
accounts are a paid feature" |

Both new points already gated on `isFreePlan`, so the modal only shows
to free users. The old plain `UpgradeConnectionDialog` (which just
routed to billing) is deleted.

### 2. Revenue attribution

The upsell `source` is captured two ways:

- **Intent** — a PostHog `upgrade_checkout_started` event (`{ source,
plan }`) fires on click, matching the repo's existing `{ source }` event
convention.
- **Revenue** — `source` rides the checkout URL (`?plan=&source=`), and
`SubscriptionController::checkout` validates it against the new
`App\Enums\UpsellSource` enum and attaches it as Stripe **subscription
metadata** (`withMetadata`). When the subscription webhook lands,
`PersistUpsellSourceFromStripe` (on Cashier's `WebhookHandled`, so the
row already exists) copies it onto a new `subscriptions.upsell_source`
column — **write-once** (`whereNull`), so a later `subscription.updated`
never overwrites the original attribution.

Measuring revenue per point is then a group-by on
`subscriptions.upsell_source` joined to invoices, using existing local
tooling.

### Scoping notes (from review)
- **Paywall & the billing on-page upgrade button are intentionally left
untagged** (`upsell_source = NULL`). "Upsell source" means a
*feature-gate nudge*; the paywall/billing pages are the baseline upgrade
surface, not a nudge — so they form the null/baseline bucket rather than
getting their own enum value.
- The listener is **synchronous** (not `ShouldQueue` like the sibling
Discord listener) on purpose: a single indexed, idempotent `UPDATE`
doesn't warrant a queue-worker dependency for attribution.
- The PostHog event fires just before a full-page navigation; posthog-js
flushes via beacon but the event (analytics only, not the revenue source
of truth) could occasionally be lost. Cheap to harden later if the
funnel proves lossy.

## Tests
- `upgrade-dialog.test.tsx` — renders per-feature copy, checkout link
carries `plan` + `source`, click fires the PostHog event.
- `SubscriptionTest` — checkout tags valid sources as Stripe metadata
and ignores unknown ones.
- `PersistUpsellSourceFromStripeTest` — persists from webhook metadata,
doesn't overwrite an existing attribution, ignores unknown/absent
sources.

## Demo

Free user hitting both new upsell points (Connections → "Connect Bank",
Accounts → "Connected"):

<!-- 📎 PLACEHOLDER: drag in
~/Downloads/upsell-points-connections-accounts.mp4 -->


https://github.com/user-attachments/assets/a27435d9-2296-4dc9-ae7f-753b6f7950f0



> Note: the clip ends at the modal (doesn't cross into Stripe) because
this local dev env has a pre-existing Stripe tax-rate 500 on
`/subscribe/checkout`, unrelated to this change. The `source` reaching
the checkout URL is verified in the browser and by the tests.
2026-07-18 12:53:20 +00:00
Víctor Falcón 52aae3fd67
feat: prompt free users to subscribe when enabling AI categorization (#696)
## Problem

On **Settings › Manage Plan**, a free (non-subscribed) user who ticked
**"Allow AI categorization"** silently recorded AI consent (`POST
/ai/consent`). That flipped `hasActiveAiConsent()` to `true`, which
caused two things on the **next navigation / refresh**:

1. `EnsureUserIsSubscribed` hard-redirected them to `/subscribe`, on
every gated page.
2. The paywall's `canUseFreePlan` (`!hasBankConnections &&
!hasActiveAiConsent`) became `false`, so the **"Continue for free"**
escape hatch disappeared.

Net effect: enabling AI locked a free user out of the app behind the
paywall, with no explanation and no way back.

## Fix

For a **free user**, ticking the box no longer records consent. Instead
it opens a contextual dialog that explains AI is a paid feature and
offers **Monthly / Annual** plans that start Stripe checkout. Because no
consent is recorded:

- Nothing locks them out — dismissing the dialog ("Maybe later") leaves
the app fully usable.
- **Pro users** keep the existing direct-consent behavior.
- A free user who somehow already has consent can still **untick to
revoke** and escape the lock.

The now-inaccurate "you can give consent now…" note was removed (a free
user can no longer give consent from here).

## Tests

- New `resources/js/pages/settings/billing.test.tsx` covers three paths:
free user → modal opens & **no consent POSTed**; pro user → consent
recorded directly; free user with existing consent → untick **revokes**.

## Demo

**Before** — enabling AI silently locks the free user behind the paywall
(no "Continue for free"):

<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-paywall.mp4 -->


https://github.com/user-attachments/assets/a72790a4-2b2d-4af1-9f8f-4a16f821eaef



**After** — enabling AI shows a contextual subscribe modal; dismissing
it keeps the app working (no lock):

<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-new-behavior.mp4
-->


https://github.com/user-attachments/assets/4a1c76d6-6c4c-4e94-baea-99e7c302b3df



> Note: the "after" clip ends at the modal + "no lock" proof rather than
crossing into Stripe Checkout, because this local dev env has a
pre-existing Stripe misconfig (`No such tax rate 'txr_…'`) that 500s on
`/subscribe/checkout` — unrelated to this change (the dialog's Upgrade
button reuses the same `checkout.url()` as the existing on-page Upgrade
button).
2026-07-18 14:45:56 +02:00
Víctor Falcón 2d7e905ddd
feat(mcp): lead with Claude Desktop/ChatGPT connect steps, collapse tokens (#695)
## What & why

The **AI Connector** settings page was built token-first, but tokens are
only needed for **Claude Code**. Most people connect via **Claude
Desktop** or **ChatGPT**, which sign in with OAuth (no token). This
reorders the page around that reality.

## Changes

- **Lead with the connection instructions.** A **Claude Desktop /
ChatGPT** tab selector (`ToggleGroup`) is now the first card. Each tab
renders the exact ordered steps for that app, with the copyable
connector URL inline.
- **ChatGPT developer mode.** The ChatGPT tab's first step tells the
user to turn on developer mode, which is currently required to add a
custom connector.
- **Collapse the token stuff.** Token management (Create a token, Your
tokens, rotate/revoke) and the Claude Code CLI command moved into a
**"Connect with Claude Code"** section that is **collapsed by default**.
It auto-opens right after a token is minted so the one-time secret and
the list stay visible.
- **Copy tweaks.** The one-time-secret alert stays pinned at the top;
the non-Pro alert copy no longer references "create a token" as the
primary action.

## Review notes (findings applied)

- **Bug fix:** rotate/revoke used `router` visits without
`preserveState`, which remounted the page and snapped the collapsed
section shut (and reset the selected tab). Both now pass `preserveState:
true`.
- Trigger markup made valid (no block elements inside `<button>`),
consistency nits (`cursor-pointer`, chevron animation), and orphaned
`es.json` keys removed.

## Trade-off to confirm

Per the request, rotate/revoke now live inside the collapsed "Connect
with Claude Code" section. Revocation is therefore one expand away
rather than always visible. Flagging in case we want to surface revoke
outside the collapsible later.

## Demo


https://github.com/user-attachments/assets/82b4060d-b7fe-40b3-a3a6-3d9d5425800c



## QA

Browser QA (Playwright, Pro account) covering: initial layout
(instructions first, token section collapsed), switching Claude Desktop
↔ ChatGPT tabs, expanding the developer section, creating a token
(one-time secret shown), and **revoking a token with the section staying
open** (verifies the `preserveState` fix). All steps passed.
2026-07-18 09:49:32 +00:00
Víctor Falcón 5621e90879
feat: streamline the subscription paywall and add a support escape hatch (#694)
## What

Several related tweaks to the subscription paywall shown before a user
subscribes:

1. **Remove the Balance stat** from the top stat card.
2. **Mobile dismiss (X)** replacing the bottom "Continue for free"
button on small screens (when the free plan is available).
3. **Support button** when the paywall *can't* be skipped.
4. **Copy updates** to the social-proof slider.

## 1. Remove the Balance stat

The Balance column showed summed account balances per currency (e.g.
`58.031 MXN 65 US$`). As a variable-length, multi-currency string it
overflowed the stat card on mobile and broke the four-column layout. The
remaining stats (Accounts, Transactions, Categories) are short integer
counts, so it now reads as a clean three-column row.

- `SubscriptionController@getUserStats`: also drops the
`balancesByCurrency` computation — which ran an **N+1 query** (one
`AccountBalance` lookup per account) — and the never-rendered
`automationRulesCount`.

## 2. Mobile dismiss (X)

The "Continue for free" escape (shown only when `canUseFreePlan`) now
renders on **mobile** as a dismiss **X** fixed to the top-right corner
instead of a full-width bottom button, matching the common
mobile-paywall pattern. Same 5s delayed fade-in, same action (continue
on the free plan). On **desktop (md+)** the bottom button is unchanged.
Reuses the app's `MobileBackButton` treatment (44px target, rounded
pill, border/shadow/backdrop-blur) for legibility.

## 3. Support escape hatch (`!canUseFreePlan`)

When the user has **no** free-plan escape, the paywall previously
offered no way out. It now shows a subtle **help/support** affordance in
the same slots the free-plan escape uses — bottom button on desktop,
top-right corner on mobile. It fades in slightly later than the
free-plan escape (**7s vs 5s**) and is deliberately low-key (muted
ghost, no pill) so it doesn't compete with the subscribe CTA. It opens
the existing `SupportDialog` (join the community / email support) — the
same one behind the user-menu "Support" entry.

The two escapes are mutually exclusive per page load, so the free-button
timer collapses into one `escapeVisible` timer whose delay depends on
`canUseFreePlan`.

## 4. Copy updates

Shortened the social-proof lines (`taking control of their finances` →
`trusting us`, etc.), bumped the user count to `2,500+ users`, slightly
reduced the proof icon. `lang/es.json` updated to match.

## QA

Real browser QA across all four states (see Demo), each ending by
exercising the actual action:
- **With free plan** → the X (mobile) / "Continue for free" (desktop)
navigates to `/dashboard`.
- **Without free plan** → the "Need help?" button opens the support
modal (Join the community / Email support).

No console errors from the paywall.

## Demo

**Desktop — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-desktop-with-free.mp4 here -->


https://github.com/user-attachments/assets/f3c943d8-5dfd-4b08-8f94-1683d38b11f7



**Desktop — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-desktop-no-free.mp4 here -->


https://github.com/user-attachments/assets/f7542b41-a0d1-491e-ab8b-4734d1c77af2



**Mobile — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-mobile-with-free.mp4 here -->


https://github.com/user-attachments/assets/6265ab86-cea2-4ade-9720-17ea8b003b1c



**Mobile — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-mobile-no-free.mp4 here -->


https://github.com/user-attachments/assets/e53d68ee-3ff9-4091-bba8-028489dd0b8d
2026-07-18 11:13:14 +02:00
Víctor Falcón 6d5f440727
feat(mcp): add OAuth 2.1 for Claude Desktop & ChatGPT connectors (Phase 3) (#691)
## MCP Phase 3 — OAuth 2.1 for Claude Desktop/web & ChatGPT connectors

Phase 1 shipped a read-only MCP server (#689); Phase 2 added write tools
+ the read/read_write token scope (#690). This phase adds **OAuth 2.1
(Authorization Code + PKCE)** so Anthropic's Claude Desktop/web custom
connectors and OpenAI's ChatGPT connectors can authenticate — those
clients sign in with OAuth rather than pasting a static bearer token, so
until now they only saw a "coming soon" note.

It reuses `laravel/mcp`'s built-in OAuth support (inert until Passport
is installed) wired to `laravel/passport ^13`. We do not hand-write the
authorization server, discovery endpoints, DCR endpoint, or the
`WWW-Authenticate` challenge — the package provides all of it.

### What's in it
- **`laravel/passport ^13`** + an `api` (passport) guard alongside the
existing session `web` guard; Passport migrations (UUID user columns),
config, and signing keys.
- **`Mcp::oauthRoutes()`** — RFC 8414/9728 discovery, RFC 7591 DCR
(`oauth/register`), and the `mcp:use` scope.
- **A second MCP endpoint `POST /mcp/oauth`** guarded by `auth:api`. The
existing Sanctum `/mcp` endpoint (Claude Code static PAT) is left 100%
unchanged.
- **On-brand OAuth consent screen** (Blade, light + dark, localized)
naming the connecting client and its redirect host, and stating plainly
what the connection can do (read/analyse + make changes, bank-connected
data excepted).
- **Settings UI**: the "Claude Desktop & ChatGPT" block now shows real
connect instructions (the `/mcp/oauth` URL to add as a custom connector,
no token needed) instead of "coming soon".

## Decision #1 — OAuth connections have read + write access

`laravel/mcp` advertises and uses a single `mcp:use` scope; it has no
read/write granularity, so there is no per-connection scope choice over
OAuth. **OAuth connections get full read + write access**, gated by the
user explicitly approving the connection on the Whisper Money consent
screen. (An earlier revision made them read-only; that restriction has
been lifted per request.)

`WriteTool` (`app/Mcp/Tools/WriteTool.php`) grants writes when the
request resolves through the `api` (Passport) guard **or** carries a
Sanctum `mcp:write` ability; a read-only Sanctum PAT is still rejected.
Bank-connected accounts and their transactions remain read-only for
every caller (only manual data can be created/edited/deleted; any
transaction can still be categorised/labelled). The consent screen and
settings copy state the read + write capability and the bank-connected
exception.

Possible follow-up: a consent-time read-only/read-write toggle, if
per-connection granularity is wanted (not offered by the standard MCP
OAuth flow's single scope).

## Other locked decisions
- **Route topology**: a separate `/mcp/oauth` endpoint rather than
multi-guarding `/mcp`. Keeps the Claude Code path unchanged (its
`abilities:mcp:read` gate would 403 an OAuth `mcp:use` token) and gives
each client type a clean documented URL. The package's nested discovery
`/.well-known/oauth-protected-resource/mcp/oauth` returns `resource =
url('/mcp/oauth')`.
- **Registration**: ship DCR (`oauth/register`). Redirect allowlist
tightened to `https://claude.ai` and `https://chatgpt.com` only — no
wildcard. CIMD is a possible later enhancement; both clients accept DCR.

## Deviation from the original plan — the User model is untouched
The plan proposed aliasing Passport's `HasApiTokens` trait alongside
Sanctum's (with `insteadof`/`as`) and implementing `OAuthenticatable`.
**Both are impossible here and, it turns out, unnecessary:**
- The two `HasApiTokens` traits declare an **incompatible `$accessToken`
property** (Sanctum untyped vs Passport `?ScopeAuthorizable`), which is
a hard PHP fatal that `insteadof` cannot resolve (it only resolves
methods).
- `OAuthenticatable::tokens(): HasMany` is incompatible with Sanctum's
canonical `tokens(): MorphMany`, and the Claude Code PAT suite depends
on Sanctum's `tokens()`. The interface is never enforced at runtime by
Passport (docblock-only).
- Passport's resource guard only calls `$user->withAccessToken()`, which
Sanctum already provides (untyped, so it accepts the Passport
`AccessToken`); and Passport's `AccessToken::can()` makes
`tokenCan('mcp:write')` behave correctly for OAuth tokens. So Sanctum
stays canonical and the Claude Code PAT path is genuinely unchanged.

## Signing keys (deploy note)
Passport signs OAuth tokens with a key pair. This PR provisions it
everywhere it's needed: CI (`passport:keys` before tests), the
production Docker entrypoint (generates into the persisted `storage/`
volume unless provided via `PASSPORT_PRIVATE_KEY`/`PASSPORT_PUBLIC_KEY`
env), `worktree.sh`, and a documented `.env.example` entry. **For a
multi-instance deployment, set `PASSPORT_*` env** so every instance
validates tokens with the same key.

## Tests (`tests/Feature/Mcp/McpOAuthTest.php`)
Discovery metadata (RFC 9728/8414), the mandatory **401 bootstrap**
challenge + `WWW-Authenticate` header, DCR (allowed + rejected redirect
URIs), the full **Authorization Code + PKCE** flow reaching a read tool,
and **write access over OAuth** (an OAuth connection calling
`create_label` succeeds and the row is created). The existing
`McpTokenTest` / `Mcp/*` suites (incl. the read-only Sanctum PAT
guardrail) and `LocalizationTest` still pass unchanged.

## QA
- **Protocol** (curl, over HTTPS): both discovery endpoints return the
exact required JSON; unauthenticated `POST /mcp/oauth` returns `401` +
`WWW-Authenticate: Bearer …
resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"`;
DCR accepts `claude.ai`/`chatgpt.com` callbacks and rejects others with
`400 invalid_redirect_uri`.
- **Browser**: consent screen verified in light and dark mode (client
name, signed-in email, redirect host, read + write capability +
bank-connected read-only note, Cancel/Connect); updated settings page
verified. No JS errors.
- Full PKCE token exchange + a write tool call is covered by the green
Pest e2e test.

## Fast-follows (not in this PR)
- **"Connected apps" revoke UI** — `McpTokenController` manages only
Sanctum PATs today, so there's no in-app revoke for OAuth grants yet.
The consent copy says "disconnect from the connected app" for now; a
Passport-grant list + revoke is the top follow-up (more important now
that OAuth grants can write).
- CIMD registration; optional consent-time read-only/read-write toggle.

## Stacking
Was developed stacked on `mcp-write-tools` (#690), itself on #689.
**Both have since merged to `main`**, so this branch was rebased onto
`main` (`git rebase --onto origin/main mcp-write-tools`) and targets
`main` directly.
2026-07-17 19:10:48 +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 782ec2f2e9
feat(currencies): add Hong Kong Dollar (HKD) (#688)
Adds HKD (Hong Kong Dollar) as a selectable currency for both users
(primary/display) and individual accounts, following
`docs/adding-a-currency.md`.

## Changes
- `config/currencies.php` — new entry with `allows_primary => true`,
`allows_account => true`
- `lang/es.json` — Spanish name ("Dólar de Hong Kong")
- `lang/fr.json` — French name ("Dollar de Hong Kong")

## Verification
- Provider covers `hkd` at a sane rate (EUR→HKD ≈ 8.99) via
`@fawazahmed0/currency-api`.
- No custom symbol added — `Intl.NumberFormat` renders "HK$" on its own
(symbol map is optional per the doc).
- Validation, dropdowns, and conversion all derive from config
automatically; no code changes needed.
2026-07-17 11:46:21 +00:00
Víctor Falcón 808d41eee2
feat(currencies): add Guatemalan Quetzal (GTQ) (#685)
## What

Adds the **Guatemalan Quetzal (GTQ)** as a selectable currency, both as
a primary/display currency and as an account currency.

Following `docs/adding-a-currency.md`, this is a config-driven change:
validation, the Inertia dropdown props, and conversion all derive from
the config entry automatically.

## Changes

- `config/currencies.php` — new `GTQ` entry (`allows_primary` +
`allows_account` both `true`)
- `lang/es.json` — Spanish name: `Quetzal guatemalteco` (enforced
locale)
- `lang/fr.json` — French name: `Quetzal guatémaltèque` (optional
locale)

## Compatibility with the conversion system (verified)

- **ISO 4217:** `GTQ` is the current code (not a
deprecated/pre-redenomination code — avoids the GHC-style ×10000 trap).
- **Provider coverage:** `@fawazahmed0/currency-api` serves `gtq` at a
sane rate (`1 GTQ ≈ 0.131 USD` → ~7.6 GTQ/USD).
- **Live conversion (tinker):** `100 GTQ → 13.13 USD` and `100 EUR →
873.19 GTQ` — both directions return real converted amounts, not the
unconverted passthrough.
- **Options + translation:** `CurrencyOptions` returns GTQ in both
`primaryOptions()` and `accountOptions()`, and renders `Quetzal
guatemalteco` under the `es` locale.

## Tests

```
php artisan test --compact tests/Feature/CurrencyConversionServiceTest.php tests/Feature/LocalizationTest.php
# 16 passed
```

Skipped the optional custom symbol in `resources/js/utils/currency.ts` —
`getCurrencySymbol` falls back to the code and `Intl.NumberFormat`
renders its own glyph (GHS skipped it too).
2026-07-16 17:23:37 +02:00
Víctor Falcón 7aa32dab0f
feat(currencies): add Swedish Krona (SEK) (#684)
## What

Adds **Swedish Krona (SEK)** as a currency available for both user
primary/display currencies and individual account currencies (including
bank accounts).

## Why

Config-driven per `docs/adding-a-currency.md`.
`App\Services\CurrencyOptions` reads `config/currencies.php` and feeds
validation (`in:` rules), the Inertia dropdown props, and conversion —
so a single config entry wires the whole feature.

## Changes

- `config/currencies.php` — SEK entry (`allows_primary: true`,
`allows_account: true`)
- `lang/es.json` — `"Swedish Krona": "Corona sueca"` (enforced locale)
- `lang/fr.json` — `"Swedish Krona": "Couronne suédoise"` (optional)
- `resources/js/utils/currency.ts` — `SEK: 'kr'` short symbol

## Verification

Confirmed the `@fawazahmed0/currency-api` provider covers `sek` at a
sane rate (SEK→EUR ≈ 0.091 → EUR→SEK ≈ 11).

## Test

```bash
php artisan test --compact tests/Feature/CurrencyConversionServiceTest.php tests/Feature/LocalizationTest.php
```
2026-07-16 14:25:33 +00: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 91ffeb917d
fix(amount-input): allow negative amounts via a sign toggle on iOS (#674)
## Problem

On the create/edit transaction form, the amount field uses
`inputMode="decimal"`. On iOS the numeric keypad that this triggers has
**no minus key**, so users cannot type the leading `-` the input relies
on to record a negative amount (e.g. an outgoing transfer).

## Fix

Add an opt-in `allowNegative` prop to `AmountInput` that renders a small
`+`/`−` sign-toggle button at the **start** of the field. It works with
any keyboard, so it does not depend on the OS keypad. It is enabled only
on the create-transaction amount input; budgets, balances and loan
fields keep their current UI.

- The button turns red with a `−` when the amount is negative, and shows
`+` otherwise (`aria-pressed` reflects the state).
- Toggle fires on `click` (works for both pointer and keyboard);
`onPointerDown` `preventDefault` keeps the input focused to avoid a
blur/reformat race.
- A leading `-` set via the toggle **before** typing is preserved on
focus, so the sign is not silently dropped.
- The currency symbol shifts right when the toggle is present so the two
never overlap; layout is unchanged when `allowNegative` is off.

## Refactor

Extracted the repeated `evaluateMathExpression(x) ?? parseInputValue(x)`
string→cents resolution (previously duplicated across blur, Enter, and
the new toggle) into a single `resolveCents()` helper.

## Tests

`resources/js/components/ui/amount-input.test.tsx` — the toggle is
hidden unless `allowNegative`, flips positive↔negative, and preserves
the sign when focusing after toggling an empty field.

## QA

Manually verified in the running app (create-transaction dialog): typing
an amount and toggling the sign both ways updates the value and the
button state correctly, on both prefix (USD `$`) and suffix (EUR `€`)
currency layouts.
2026-07-14 16:13:39 +00:00
Víctor Falcón ca2e5c09b3
fix(balances): allow saving a zero balance (#664)
## Why

A balance of exactly **0** could not be saved. Zero is a perfectly valid
balance for any account (an emptied wallet, a paid-off loan, a closed
position), so the app was rejecting legitimate input.

## Root cause

The shared `AmountInput` renders a numeric value of `0` as an **empty
string** (so the field shows the `0.00` placeholder instead of a literal
"0.00"). Several balance forms combined that with the native HTML
`required` attribute — which then rejected the *only* value that renders
empty: `0`. `required` on this input never guarded against a missing
value (the state is always numeric; empty == 0), it only ever blocked
zero.

The backend already accepted `0` (`required|integer`, and Laravel's
`required` treats integer `0` as present), so this was purely a frontend
constraint.

## Changes

- Drop `required` from the balance `AmountInput` in the **Update
balance** dialog and the **Balance history** edit modal.
- Remove the explicit `balanceInCents === 0` guard (and `required`) in
the onboarding **Set balance** step.
- Extend the same fix to the CSV-import **reference balance** field
(identical root cause; the compute path already gates on
`referenceBalance !== null`, so an explicit `0` is a valid anchor).
- Remove the now-orphaned `"Please enter a balance"` translation from
`es.json` / `fr.json`.
- Add feature tests asserting a zero balance can be stored and set as
the current balance.

Transaction and budget amount inputs keep `required` — `0` is not a
meaningful value there, so they are intentionally untouched.

## QA

- **Onboarding balance step**: saving with an empty/zero field now
advances instead of showing "Please enter a balance". 
- **Update balance dialog**: set €1,000.00, then overwrote it with €0.00
→ dialog closed and the record persisted. Verified in the DB
(`account_balances.balance = 0`). 
- Backend contract locked by new tests in
`AccountBalanceControllerTest`.

## Demo

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

_Video to attach: `~/Downloads/allow-zero-balances-demo.mp4`_
2026-07-10 15:02:38 +02:00
Víctor Falcón 362ac445ea
fix(transactions): keep saved-filter delete button visible on touch and confirm before deleting (#648)
## Problem

The delete button on a saved filter was only revealed on **hover**. On
desktop that's fine, but on touch devices the button is invisible yet
still clickable, so users kept deleting saved filters by accident. There
was also no confirmation step before a filter was deleted.

## Changes

- **Always visible on touch** — the hover-reveal is now gated behind
`@media (hover: hover)`. On hover-capable devices the trash icon still
appears only on hover; on touch devices (`hover: none`) it stays
visible.
- **Confirm before deleting** — deleting a saved filter now opens an
`AlertDialog` confirmation ("Are you sure you want to delete "…"?"),
matching the existing delete-confirmation pattern used across the app.
Cancel/Escape dismiss without deleting.

## QA

Tested end-to-end in a real browser (mobile emulation, `hover: none`):

- Desktop (`hover: hover`): delete button computes `opacity: 0` until
hover ✓
- Touch (`hover: none`): delete button computes `opacity: 1`, visible
without hover ✓
- Tapping the trash icon opens the confirmation dialog with the correct
filter name ✓
- **Cancel** keeps the filter (verified in DB) ✓
- **Delete** removes it (verified in DB) ✓

## Demo
<img width="1265" height="1277" alt="qa-01-desktop-dropdown"
src="https://github.com/user-attachments/assets/29915c68-0d6e-49da-839d-ee75462338ae"
/>
<img width="1265" height="1277" alt="qa-02-confirm-dialog"
src="https://github.com/user-attachments/assets/a1bee93c-e4fe-4652-a545-35136b427772"
/>
<img width="1265" height="1277" alt="qa-03-after-delete"
src="https://github.com/user-attachments/assets/c7d66a4c-d27a-4d04-8506-05d797d4630f"
/>
<img width="389" height="844" alt="qa-04-mobile-dropdown"
src="https://github.com/user-attachments/assets/9d172e69-3a34-4fdf-a6ce-5771be23f45b"
/>
2026-07-06 09:37:38 +00:00
Víctor Falcón 2aebe45d1f
feat(currency): add GHS (Ghanaian Cedi) (#644)
## What
Add the Ghanaian Cedi as a supported currency.

## Note on GHC vs GHS
This was requested as "GHC". `GHC` is the **deprecated** pre-2007 code:
the conversion provider (`@fawazahmed0/currency-api`) still exposes it,
but its rate is scaled **×10 000** versus the modern cedi (≈113,749
GHC/USD vs ≈11.37 GHS/USD), because Ghana redenominated in 2007 (1 GHS =
10 000 GHC). Adding GHC would leave real-world balances inflated 10
000×. The current ISO 4217 code is **GHS**, which the provider covers at
the correct rate — so we add GHS.

## Compatibility
Provider covers `ghs` (standard ISO 4217). The conversion service
lowercases codes and fetches `ghs.min.json` — same path RSD/NZD use.
`exchange_rates` stores rates as JSON, so any 3-letter code works.
Validation rules and Inertia currency props auto-derive from config.

## Changes
- `config/currencies.php` — GHS entry (`allows_primary` +
`allows_account`)
- `lang/es.json` — Spanish translation
- `lang/fr.json` — French translation

Follows the RSD addition (#567) exactly; no code changes needed.
2026-07-04 20:36:11 +00:00
Víctor Falcón 6ff7edf193
feat(currencies): add Nigerian Naira (NGN) (#642)
## Summary

Adds the Nigerian Naira (NGN, ₦) as a selectable currency.

Conversion support required no code change: rates come from the
fawazahmed0 currency-api CDN, which already serves NGN (verified live,
e.g. `EUR→NGN ≈ 1566.8`). `ExchangeRateService` /
`CurrencyConversionService` are currency-agnostic, so NGN converts as
soon as it's a valid option.

## Changes

- `config/currencies.php` — add NGN entry (allowed as both primary and
account currency).
- `resources/js/utils/currency.ts` — add `₦` to the symbol map.
- `lang/es.json` — Spanish translation for the currency name.
- `tests/Feature/CurrencyOptionsTest.php` — assert NGN is exposed as a
primary and account currency.

## Testing

- `php artisan test tests/Feature/CurrencyOptionsTest.php
tests/Feature/CurrencyConversionServiceTest.php
tests/Feature/LocalizationTest.php` — pass
- `vendor/bin/pint`, `bun run format`, `bun run lint`, `vitest run
currency.test.ts` — pass
2026-07-04 19:10:58 +00:00
Víctor Falcón 02087abcc7
feat(welcome): add Francisco Montes testimonial (#636)
## Summary
- Add a new landing-page testimonial from Francisco Montes, sourced from
user feedback received by email.
- Add the matching Spanish translation in `lang/es.json` (required by
the translation CI check).

The quote was edited for clarity while keeping the user's own words and
intent. It sits just before the co-owner note, which stays last.

## Testing
- `python3 -c "import json; json.load(open('lang/es.json'))"` (valid
JSON)
- Full test suite not run locally: this worktree has no `vendor/`
installed. CI will run `./vendor/bin/pest` including the translation-key
check.
2026-07-04 14:08:33 +00:00
Víctor Falcón 10442c1e32
feat(onboarding): auto-enable AI for connected banks, ask the rest (#618)
## Why

During onboarding we prompt for AI suggestions only because it's a
**paid feature** — enabling it forces the user to pick a plan at the end
of onboarding. A user who has **already connected a bank** is committed
to a paid plan regardless, so the prompt gives them nothing new. For
them we should just turn AI on.

## What

- **Connected bank → activate AI directly.** The consent prompt is
skipped and consent is recorded automatically (reuses the existing
`/ai/consent` endpoint, so the categorization backfill still runs).
`setBusy` batches with the state update so the consent screen never
flashes.
- **No bank → unchanged opt-in.** Free users still explicitly accept.
The notice is reworded to make the paid framing clear: *"AI suggestions
are a paid feature. Enable them and you'll choose a plan at the end of
the onboarding."* (translated in `es.json`).
- No backend change needed:
`RuleSuggestionController::requiresUpgrade()` already treats
bank-connected users as not needing an upgrade, and
`SubscriptionController` already gates the free plan on `!hasBank &&
!hasConsent`.

## Tests

Browser tests in `OnboardingFlowTest`:
- Connected bank → consent prompt skipped and `hasActiveAiConsent()`
becomes true.
- No bank → prompt (with the paid notice) shown; consent recorded only
after accepting.
- `/subscribe` forces a plan (no "Continue for free") when a bank is
connected or AI consent is active.

Vitest specs for `StepAiSuggestions` updated (new prop + reworded
notice) plus a new case asserting bank-connected users auto-activate.

All green: 5/5 browser tests (24 assertions), 6/6 vitest.
2026-07-01 07:26:52 +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 af64f56399
feat(onboarding): clarify the "categorize at least 5" goal in the categorizer (#616)
## Why

Feedback from onboarding users: in the categorizer step people often
**don't realize they need to categorize at least 5 transactions** before
*Continue* unlocks, and can't tell why the button is disabled. Worse,
the old counter switched to **"N remaining"** after 5 (showing *every*
uncategorized transaction, e.g. "195 remaining"), which made several
users think they had to categorize **all** of them.

## What changed

### Categorizer clarity (`step-categorize-transactions.tsx`)
- **New full-width progress banner** above the transaction card,
replacing the cramped corner counter that was getting truncated on
mobile:
- explicit instruction: *"To continue, you need to categorize at least 5
transactions."* (reuses the existing intro string)
  - a **5-segment progress bar** that fills as you go
  - the live **X/5** count
  - a reassurance line: *"You do not need to categorize all of them."*
- When the minimum is reached (or the list is exhausted), the banner
turns **green** — *"Done! You can continue now, or keep categorizing if
you want."* — and the **Continue** button gets a ring so it's obvious
the step is unlocked.
- **Removed the misleading "N remaining"** display.

### Onboarding layout & toasts
- **Mobile top spacing** (`onboarding-layout.tsx`): reduced the large
top padding on the step content on mobile so it sits closer to the
progress dots (unchanged on desktop).
- **Toasts during onboarding** (`app.tsx`): onboarding has no bottom
navigation bar, so toasts now render **bottom-center, flush to the
bottom** instead of being lifted 110px to clear the (absent) mobile tab
bar. Behavior elsewhere in the app is unchanged.

Two new strings added to `lang/es.json`.

## Notes

- The `canContinue` / `minimumRequired` gating logic is unchanged.
- Verified: `lint` clean, `es.json` valid, `LocalizationTest` passes,
full CI green.
- Recorded mobile + desktop walkthroughs locally.
2026-07-01 08:34:51 +02:00
Víctor Falcón d55e15bb4f
feat(landing): clarify AI framing and add testimonials (#613)
## Summary

Landing page copy and social proof around AI, made consistent with our
privacy stance.

- **Clarify AI as a privacy-respecting feature.** Name AI explicitly
where transactions are auto-categorized, replace the contradictory "no
AI snooping" line with an "our own AI" framing, and add an FAQ stating
the AI serves you and never trains on, sells, or shares your data.
- **Add six testimonials**, four of which highlight AI and automation
(AI categorization that learns from corrections, automatic bank sync,
the privacy framing of our own AI, and CSV auto-mapping). The other two
cover the all-in-one-place and clean-UI experience.

## Notes

- All new strings have matching `lang/es.json` translations;
`LocalizationTest` passes.
- New testimonials use placeholder gravatar hashes that 404 and fall
back to the generated `Facehash` avatar (no real photo needed).

## Test plan

- [x] `php artisan test tests/Feature/LocalizationTest.php`
- [x] Prettier formatting
2026-06-30 09:40:30 +00:00
Víctor Falcón 6727a9c64a
feat(ai): learn from category corrections so the AI stops repeating the same mistake (#608)
## Problem

Users keep correcting the same transactions over and over. The AI
mislabels a merchant (e.g. supermarket → fuel), the user fixes it, and
the next near-identical transaction from that merchant gets mislabeled
the same way again. Today a correction is logged and the offending ai
rule is self-healed, but the system only *forgets* its mistake — it
never *remembers* the user's fix.

## Approach

A correction now becomes a deterministic, forward-looking
`AutomationRule` (new `RuleOrigin::Correction`). The next matching
transaction is categorized by that rule **before the model ever runs**
(`ApplyAutomationRules` is synchronous and runs ahead of AI
categorization), ending the loop. Zero model cost, instant, reuses the
existing rule engine.

**Matching key** (in order):
1. **Merchant** (`creditor_name`/`debtor_name`, exact `==`) when present
— stable even as the description varies.
2. Otherwise the **description's distinctive tokens** (`in` /
AND-of-`in`), extracted by the shared `DescriptionTokenizer` (noise
tokens dropped by document frequency, language-agnostic), **guarded**
against over-broad rules that could silently mis-file en masse. If
guarded out → nothing is learned, silently.

## Deliberate decisions (from a design walkthrough)

- **Forward-only**: never retroactively re-categorizes existing
transactions.
- **Learn only from system categorizations** (AI label, ai rule, or a
prior correction rule) — never from one-off manual filing, bank
categories, or the user's own hand-authored rules.
- **A key lives in exactly one correction rule**, so changing your mind
moves it to the new category. Correcting a transaction a prior
correction rule categorized is also learnable, so correction rules stay
fixable in-flow.
- Correcting to *uncategorized* learns nothing but still self-heals the
ai rule.
- **Safety net**: correction rules are visible/editable in
`settings/automation-rules` (marked with the AI sparkle, tooltip
"Learned from your correction"); the transactions table shows a toast
with an instant **Undo**.

## Review pass (two independent agents + live QA)

- **HIGH fix** (`67fc4293`): an ai rule could out-rank a freshly learned
correction and re-apply the wrong category (when the corrected
transaction was a *direct* model label with no rule id). Now every ai
rule holding the merchant is swept on correction. Regression test added.
- **Refactor** (`ca743fde`): collapsed duplicated clause-append logic;
removed a speculative unused enum helper.
- **Coverage** (`12ceb0f7`): debtor_name path, single-token description
clause, encrypted-description fail-safe.
- **Toast conflict fix** (`382c8169`, found in live QA): correcting an
AI transaction fired both the new "Learned …" toast and the pre-existing
"Transaction categorized → Automatize" prompt, which invited the user to
manually create the rule the correction had just created. Made them
mutually exclusive. Adds a browser test for the inline-correction flow.
- **Settings icon** (`f3f882b6`): correction rules now show the AI
sparkle in settings, like ai rules.

## Verified end-to-end (against a running instance)

Drove the real UI with a browser: correcting an AI-mislabeled
transaction creates the correction rule, shows the "Learned · Undo"
toast (no competing Automatize prompt), and Undo deletes the rule while
keeping the correction. Confirmed for **merchant** keys and the
**description-only** path — including that a later "practically
identical" description (different surrounding text, no merchant) is
caught by the rule, while a near-miss sharing only one distinctive token
is correctly **not** caught.

## Open question for reviewers

**`debtor_name` (P2P) as a rule key.** For incoming transfers the
merchant key falls back to the sender's name, so correcting one can
create a rule keyed on a person's name (useful for recurring transfers
from a roommate, but a possible privacy surprise; the name appears in
the rule title in settings). This matches the existing tier-2 learner's
behaviour. Keep as-is, or restrict correction rules to `creditor_name`
only? Happy to change.

## Testing

- `tests/Feature/Ai/CategoryOverrideHandlerTest.php`: merchant +
description learning, next-transaction match, over-broad rejection,
change-of-mind move, correct-to-null self-heal, the HIGH regression,
debtor_name, single-token, encrypted fail-safe.
- `tests/Browser/CategoryCorrectionLearningTest.php`: inline correction
→ toast → learned rule → undo.
- `automation-rule-title.test.tsx`: the AI sparkle shows for `ai` and
`correction`, not `user`.
- Full AI suite green (106 tests); transaction/bulk-update suites green
(62). Pint + Prettier + ESLint clean.

No new dependency, no migration (the `origin` column is a free-text
string). The feature is implicitly gated by AI categorization — with no
AI categorization there is nothing to correct and nothing is learned.
2026-06-29 19:12:15 +02:00
Víctor Falcón 09d6e8ee6c
feat(subscriptions): reframe pay_now paywall copy around try-and-refund (#605)
## What

Follow-up to #600. Reframes the **pay_now** paywall message so the
emphasis is on *trying it risk-free*, not on being charged today.

**Before**
> You'll be charged €3.99 today
> Changed your mind? Get a full refund yourself from Settings within the
first 3 days — no questions asked.

**After**
> **Try it for 3 days**
> Don't like it? Get a full refund with one tap from Settings — no
questions asked.
> _You pay €3.99 today, refunded in full if you cancel within 3 days._
(muted footnote)

## Why

The upfront-charge headline reads as friction. Leading with "try it /
one-tap refund" frames pay_now as risk-free while the charge stays
**honestly disclosed** as a muted footnote — so it's never a surprise at
refund time (and avoids chargeback risk). Amount and day counts stay
dynamic per plan/variant.

## Notes
- Only the pay_now branch of `TrialTerms` changes; control/reduced
trials untouched.
- Spanish + French translations updated; the two orphaned old keys
removed.
- Lint, tsc (paywall), and `LocalizationTest` pass; no test asserted the
literal copy.
2026-06-28 10:40:59 +00: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 e5350ff1a6
feat(subscriptions): trial/pricing A/B/C experiment (#600)
## What

A 3-way experiment on how the paid plan is offered, plus per-variant
measurement. New signups (on/after `SUBSCRIPTION_EXPERIMENT_STARTED_AT`)
are split evenly into:

- **control** — current 15-day trial.
- **reduced_trial** — shorter trial: 3 days monthly, 7 days yearly.
- **pay_now** — charged immediately (no trial), with a self-service
money-back guarantee for the first 3 days.

Earlier users stay **legacy** and keep the 15-day trial. **While
`started_at` is null the experiment is off and everyone behaves like
control — inert until activated via env.**

## How it works

- **Assignment** — `App\Features\SubscriptionExperiment` (Pennant),
deterministic even split by a stable hash of the user id. QA can force a
variant with `feature:enable`.
- **Offer policy** — `ExperimentOffer` is the single source of truth for
trial days per plan, the pay-now flag, the refund window and refund
eligibility; shared by checkout, paywall and billing.
- **Checkout** — trial length comes from the variant (`trialDays(0)` for
pay_now → immediate charge).
- **Onboarding clarity** — the paywall states the exact terms above the
CTA: trial length for the selected plan, or "charged €X today + 3-day
money-back guarantee" for pay_now.
- **Self-service refund (pay_now)** — Settings → Billing, within the
window: refunds the upfront charge, `cancelNow`, revokes bank
connections keeping imported data. `refunded_at` records it and blocks a
second refund. Crash-safe ordering: the refund is stamped before
cancel/disconnect, which run best-effort in a try/catch.

## Measurement

`stats:experiment-funnel` (weekly → Discord): per-variant funnel
(assigned, subscribed, status breakdown, refunds) with a **net-active
rate** gated by each variant's decision window (control 15d / reduced 7d
/ pay_now 3d) so cohorts are read at equal age. Attribution reads the
variant Pennant actually served each user, so the report can't drift
from what users experienced. It also reports **MRR** (monthly run-rate
of mature net-active subs, yearly normalised ÷12) and **ARPU** (MRR ÷
assigned) per variant — ARPU is the revenue metric for the winner
decision. Plus a winner can be pinned org-wide with
`SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT` (env, no deploy).

## Config (env)

- `SUBSCRIPTION_EXPERIMENT_STARTED_AT` — activates the experiment
(launch date). Null = off.
- `SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY` (3), `..._YEARLY` (7),
`..._REFUND_WINDOW_DAYS` (3)

## Tests

- **Feature/unit:** assignment, offer policy, checkout wiring, refund
eligibility, the refund action incl. idempotency + crash-safe ordering
(Stripe mocked), and the funnel collector/command. ES + FR translations.
- **Browser** (`tests/Browser/SubscriptionRefundTest.php`): the
self-service refund UX end to end — card visibility + deadline, two-step
confirm, back-out, the refund control disappearing after confirming, and
gating (window passed / non-pay_now hidden). The `RefundSelfServe`
action is doubled so it never hits Stripe but applies the same DB
effect. Screenshots: `refund-card-visible`, `refund-confirm-step`,
`refund-completed`.
- Full non-browser suite green (the one failing `DashboardTest` is
pre-existing on `main` — Inertia 409 from the unbuilt local manifest).
Pint + ESLint + tsc (changed files) clean.

## Two independent reviews — acted on

**Fixed:** refund atomicity/idempotency (major) · funnel attribution now
reads Pennant's served value instead of recomputing, killing
report-vs-runtime drift (major) · pay_now copy shows the exact amount
charged · throttle + block-demo on the refund route · `resolve(?User)`
nullable · French translations.

**Reviewer notes (deferred, low value):**
- `refunded_at` is not cast to Carbon on Cashier's `Subscription` (safe
today — only null-compared; would need a custom Cashier model).
- `ExperimentFunnelCollector` walks users in PHP via `chunkById`; fine
at current volume, can move to grouped SQL if it grows.

## Confidence: 85 / 100

The critical money path is now **verified live against the Stripe
sandbox** (see below), which removes the earlier cap. All gates are
green and the acceptance criteria are met. Held at 85 (not higher)
because the browser UI test runs in CI rather than locally, and the
pay_now *hosted-checkout + webhook* leg reuses the standard Cashier
checkout already proven by the control flow (only `trialDays(0)`
differs) but wasn't re-driven through the hosted page. Given it moves
money + disconnects accounts, a human glance is still warranted before
enabling.

## Sandbox verification (live Stripe test mode)

`php artisan stripe:verify-refund` creates a real immediately-charged
subscription with a test card, runs the actual `RefundSelfServe`, and
checks the Stripe API. Result:

```
PASS  subscription active after immediate charge   (pay_now, no trial)
PASS  canSelfRefund is true before refund
PASS  latestPayment() resolves a payment intent
PASS  refunded_at is stamped
PASS  subscription is canceled
PASS  canSelfRefund is false after refund
PASS  Stripe charge shows a full refund   (refunded=true)
```

The command is committed and guarded to Stripe test keys /
non-production, so it can be re-run before each launch toggle.


## Launch checklist

1. Stripe-sandbox smoke: `php artisan stripe:verify-refund` (done —
passing). Optionally also drive the hosted pay_now checkout once for
monthly + yearly to confirm the webhook leg.
2. Set `SUBSCRIPTION_EXPERIMENT_STARTED_AT` to the launch date (set
once; don't backdate).
3. Watch `stats:experiment-funnel`; a clean cohort baseline lands once
each variant's window matures.
2026-06-27 18:00:15 +02:00
Víctor Falcón e4be39be12
feat(integration-requests): add done status and fix review command crash on orphaned author (#601)
## Summary

- **Fix:** `integration-requests:review` crashed with `Attempt to read
property "email" on null` when a request's author no longer exists. The
command now renders a dash instead of assuming the `user` relation is
present.
- **Feature:** new terminal `done` status that marks an integration
request as shipped.

## The `done` status

Modeled after `not_doable` (a frozen, terminal state):

- Shown on the board, sinking to the bottom regardless of votes.
- Can no longer be voted on or unvoted (`removeVote` returns 404).
- Drops its public comment when set, so the board shows no stale note.
- Available in both `integration-requests:review` flows (pending review
and `--all`).

Frontend gets a `Done` badge and an `isFrozen()` helper that replaces
the scattered `=== 'not_doable'` checks gating the vote/unvote buttons.

## Tests

- 6 new feature tests: visibility without comment, bottom ordering,
vote/unvote blocked, command sets `done` and clears the comment, and the
command tolerating an orphaned author.
- `php artisan test tests/Feature/IntegrationRequestTest.php` → 34
passed.
- Board vitest suite, Pint, ESLint and Prettier all green.
2026-06-27 14:42:09 +00:00
Víctor Falcón d7bc4e6707
feat(transactions): reorder filters and switch accounts to a logo dropdown (#598)
## What

Updates the transaction filters popover:

- **Reordered** the filter sections to: **Date, Amount, Category,
Labels, Accounts, AI, Counterparties**.
- **Accounts** now use a searchable dropdown (the same pattern as
Categories/Labels) instead of toggle badges. Each option shows the
**bank logo** as its icon (with a `Building2` fallback for accounts
without a logo/bank).

## Why

The badge list didn't scale well with many accounts and was visually
inconsistent with the other multi-select filters. A dropdown keeps the
popover compact and adds search.

## Notes

- Fixed `handleAccountToggle` parameter type (`number` → `UUID`) to
match `accountIds`.
- Each account `CommandItem` uses a unique `value` (`name + id`) so
accounts that share a name aren't collapsed by cmdk's default filtering.
- Added Spanish translations for the new keys (`Select accounts...`,
`Search accounts...`).

## Tests

- New `transaction-filters.test.tsx` covering select/deselect of an
account and the empty state.
- Full front-end suite green (244 tests), ESLint/Prettier clean,
localization test passes.
2026-06-26 20:09:02 +02: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 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 b0e74fac2c
feat(welcome): add Haru testimonial with Discord avatar (#577)
## Summary
- Update the existing **Haru** testimonial on the landing page with
their Discord feedback
- Use Haru's Discord avatar via a new optional `avatar` field on
testimonials (falls back to Gravatar when absent)
- Update the matching `es.json` translation

## Notes
The Discord CDN avatar degrades gracefully to the `Facehash` fallback if
the URL ever expires (Discord rotates the hash when a user changes their
avatar).
2026-06-22 09:10:43 +00: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 64827fabae
feat(open-banking): allow re-connecting a bank behind a replace warning (#570)
## Why

Users with several accounts at the same bank (for example a partner's
account at the same bank) need to connect the same Enable Banking ASPSP
more than once. Previously an already-connected bank was hard-blocked:
disabled in the connect dialog list, and filtered out entirely from the
onboarding inline flow with a "you already have this connected, go
reconnect" message.

## What changed

- The already-connected bank stays **selectable** and is marked with an
**"Already connected"** badge instead of being blocked/hidden.
- On the confirm step, when the selected bank already has a **live**
connection, a destructive warning explains that authorizing with the
**same bank login replaces the existing session and stops it working** —
continue only when adding a *different* account.
- The **Connect** button is gated behind an acknowledgement checkbox ("I
understand the existing connection may stop working") so a working
connection can't be replaced by accident.
- New shared `ReplaceConnectionWarning` component keeps the dialog and
inline flows from drifting apart (the same divergence #569 unified).

## Notes

- **No backend changes.** `AuthorizationController@store` already
created a fresh connection for a repeated bank; the block was
frontend-only.
- Out of scope: the previous `BankingConnection` row stays `active` in
our DB after the user re-authorizes the same login, even though Enable
Banking has invalidated it. It self-corrects on the next sync failure.
Auto-marking/cleaning the superseded connection on callback is a
separate backend follow-up.

## Testing

- New `connect-account-dialog.test.tsx`: already-connected bank is
selectable + badged, Connect is gated behind the acknowledgement, and a
fresh bank shows no warning.
- Full JS suite (234 tests) and `LocalizationTest` pass; lint and format
clean.
2026-06-20 17:23:49 +00:00
Nenad Vajagic 934e16c0fa
feat(currency): add RSD (Serbian Dinar) (#567)
## What
Add Serbian Dinar (RSD) as a supported currency.

## Compatibility
Conversion provider `@fawazahmed0/currency-api` covers RSD (standard ISO
4217). Conversion service lowercases codes and fetches `rsd.min.json` -
same path NZD uses. `exchange_rates` table stores rates as JSON, so any
3-letter code works. Validation rules and Inertia currency props
auto-derive from config via `CurrencyOptions`.

## Changes
- `config/currencies.php` - RSD entry (`allows_primary` +
`allows_account`)
- `lang/es.json` - Spanish translation
- `lang/fr.json` - French translation

## Tests
Haven't tested them locally, but should pass
2026-06-20 12:46:26 +02:00
Víctor Falcón ce6bfc9c56
feat(drip): email users stuck on the paywall a day after onboarding (#562)
## What

Sends an automated follow-up email the day after a user finishes
onboarding but stays stuck on the paywall, asking why they didn't
continue and inviting a direct reply.

## Why

Users who connect a bank account during onboarding but never pick a plan
are permanently redirected to the paywall by `EnsureUserIsSubscribed` —
they finish onboarding and then can't access the app. This reaches out
to understand the friction and recover them.

## Who gets the email (the genuinely stuck cohort)

- `onboarded_at` is calendar-yesterday (`whereDate('onboarded_at',
today()->subDay())`)
- Has an active banking connection (`bankingConnections()->exists()`)
- No Pro plan (`hasProPlan()` is false)
- Hasn't already received this email (idempotent via `UserMailLog`)

Users **without** a bank connection are excluded: they fall through to
free access after seeing the paywall once, so they aren't stuck.

## How

- New `email:paywall-follow-up` command scans the cohort daily and
queues the job — scheduled `dailyAt('10:00')` Europe/Madrid in
`routes/console.php`.
- New `DripEmailType::PaywallFollowUp` (`paywall_follow_up`) +
idempotent send job following the existing drip pattern.
- Short, human Markdown email with an explicit `replyTo`
(`hi@whisper.money`) so replies reach a real inbox.
- Spanish copy added to `lang/es.json`.

## Tests

10 new tests (correct cohort matched; wrong day / no bank / Pro plan
excluded; no double-send; no-op when drip disabled). `pint` clean.

## Note

There was no existing user-scanning drip command (current drips dispatch
with `delay()` on `Registered`). Since "stuck on paywall" state isn't
known at registration time, a daily scanning command is the right fit.
2026-06-19 14:11:12 +00:00
Víctor Falcón ae59c90f2c
AI auto-categorization: open to pro + consent, nudge free users (#561)
## What

Two related changes to the AI auto-categorization feature.

### 1. Open the gate to pro + consent (drop the new-signups-only cohort)

Eligibility for AI auto-categorization was: kill switch **+ pro plan +
active AI consent + a Pennant rollout flag** that only resolved for
users created after `ai_categorization.rollout_after`. That last cohort
gate limited the feature to a handful of recent signups (6 eligible
users in prod).

The gate is now just **kill switch + pro plan + active AI consent**, so
every consented pro user is eligible regardless of signup date.

- `allows()` and `allowsBackfill()` became identical and collapse into a
single `allows()`; `CategorizeBackfillCommand` calls it.
- The `AiCategorization` Pennant feature and the
`ai_categorization.rollout_after` config are now dead and removed. The
weekly cohort report already derives its release marker from the first
`ai_consents.accepted_at`, so nothing depends on the config.

> Note: the `AI_CATEGORIZATION_ROLLOUT_AFTER` env var must be removed
from the production environment — it is no longer read.

### 2. Nudge free users that AI could categorize their transactions

Free-plan users now see a subtle AI sparkle on uncategorized rows,
reusing the trailing icon slot already in `CategoryCell` (no layout
change). It only shows when subscriptions are enforced and the user is
not pro; clicking it routes to `/settings/billing`.

To keep it subtle, the sparkle is sampled to a share of rows via a
deterministic function of the transaction id (its last byte mapped onto
a 0-100 threshold), so the same rows decide the same way across reloads
instead of flickering or marking every row.

The share is **configurable** via `ai_categorization.upsell_sample_rate`
(env `AI_CATEGORIZATION_UPSELL_SAMPLE_RATE`, default 40), exposed to the
frontend as the `aiCategorizationUpsellRate` Inertia prop — no rebuild
needed to retune it.

## Tests

- PHP: `AiCategorizationGateTest` updated (rollout case dropped);
job/listener tests no longer activate the removed feature.
- JS: `ai-upsell-sample.test.ts` covers determinism, the 0/100 bounds,
the threshold boundary, and the per-rate split.
- Manually verified in-app (Playwright): nudge renders for a free,
consented, bankless user; rate matches config.

## Follow-ups (not in this PR)

- Remove the `AI_CATEGORIZATION_ROLLOUT_AFTER` prod env var.
- Backfilling existing pro+consent users' history still requires running
`ai:categorize-backfill {user}`.
- The nudge only reaches free users who pass the paywall (no bank
connection + paywall already seen); free users with a bank connection
never reach `/transactions`. Consider surfacing it on the paywall too.
2026-06-19 14:08:40 +00:00
Víctor Falcón a9b90a200e
feat(connections): manage which accounts a bank connection syncs (#558)
## Why

During the bank connection flow (Enable Banking) a user picks which
accounts to sync and can skip the rest. Until now there was **no way
back**: a skipped account couldn't be enabled later, a synced account
couldn't be moved or turned off, and accounts the bank added afterwards
were invisible. Skipped accounts aren't persisted anywhere —
`pending_accounts_data` is cleared once mapping completes — so the app
simply forgets they exist.

This adds a per-connection **Manage Accounts** screen that closes that
gap.

## What

- **Known accounts render from the DB** (no provider call). For each
synced account the user can:
- **Stop syncing** → the account is unlinked and becomes a regular
manual account. Non-destructive: it keeps all its imported transactions.
- **Change destination** → move syncing to another compatible manual
account (unlinks the previous holder, links the new one with incremental
sync).
- **Load accounts** button re-fetches the consent's account list from
the provider *on demand* (`getSession` + `getAccount` only for uids we
don't already know) to surface newly available / previously skipped
accounts, which can then be created as new accounts or linked to
existing manual ones.
- The provider is **only ever hit on refresh**; every mutation is
DB-only, so the screen still works (read + rearrange) when the consent
has expired.

### Design notes

- No new column / cache: the default view derives entirely from the
`accounts` table, and refresh is on-demand and ephemeral. Re-fetching is
mandatory anyway (existing connections have no stored snapshot, and only
a live call can reveal accounts the bank added later).
- Scoped to **Enable Banking** for now; the approach is column-free so
extending to the crypto/API-key providers later is trivial.

## Endpoints

- `GET open-banking/connections/{connection}/accounts` — manage screen
(`discoveredAccounts` computed only when `?refresh=1`)
- `POST open-banking/connections/{connection}/accounts/map` — create /
link / change destination for one bank account
- `POST open-banking/connections/{connection}/accounts/{account}/unlink`
— stop syncing

## Testing

- New `ConnectionAccountTest` (7 tests): index props, refresh discovery
(excludes already-synced uids), create,
change-destination/unlink-previous, non-destructive unlink keeps
transactions, ownership 403, account-mismatch 404.
- Full `tests/Feature/OpenBanking` suite green (268), plus pint, eslint,
prettier and the enforced `es` localization test.

## Deliberately skipped

- **Subscription gating** on refresh/map — matches the currently ungated
`disconnect`. Easy follow-up if free users shouldn't trigger live
provider calls.
- Other providers (Enable Banking only, as agreed).
2026-06-18 16:22:49 +02:00
Víctor Falcón 6e6433c6ad
feat(open-banking): disable already-connected banks in the connect picker (#556)
## Why

Several production users ended up with **two connections to the same
bank** (or duplicate accounts for the same IBAN) with EnableBanking. The
auto-invalidation we assumed exists only happens on the dedicated
*reconnect* flow, which reuses the same `BankingConnection` row.
`AuthorizationController::store()` always creates a brand-new connection
and never checks for an existing one, so re-adding an already-connected
bank from scratch silently duplicates it.

## What

- In the connect picker, already-connected EnableBanking banks are no
longer **hidden** — they are shown **disabled with a tooltip**: *"You
already have a connection with this bank. Reconnect it."* This both
prevents the duplicate and guides the user to the right action.
- Connections in `pending` state are excluded, so a stale/abandoned
attempt no longer hides a bank forever (a latent bug in the old
hide-based filter).
- The same dialog opened from the **create-account flow**
(`settings/accounts`) received no `connections`, so nothing was
deduplicated there. The user's banking connections are now shared as a
lightweight global Inertia prop (`bankingConnections`) and fed into both
entry points, so they behave identically. As a bonus, crypto providers
(Binance, etc.) are now also de-duplicated in that flow.

## Notes / follow-ups

- Existing dirty data (the ~10 affected users) is intentionally left
as-is per product decision.
- The onboarding inline variant (`connect-account-inline.tsx`) still
hides rather than disables; unifying the two near-duplicate connect
components is a deliberate follow-up.
- No backend guard was added to `store()`; this is UI-level prevention.
A server-side guard is the robust follow-up if needed.

## Tests

- `tests/Feature/InertiaSharedDataTest.php` — new shared prop is present
and shaped correctly.
- `resources/js/components/open-banking/connect-account-dialog.test.tsx`
— `alreadyConnectedBankNames` includes active/error/expired, excludes
pending and non-EnableBanking providers.
2026-06-18 15:08:09 +02:00
Víctor Falcón 0ea54fa0d7
feat(integration-requests): multi-vote, per-plan quota and undo (#554)
## Summary
- Raise the monthly integration-action quota for paying users: free plan
keeps **3** actions, pro plan gets **9** (resolved via
`User::hasProPlan()`, so self-hosted instances with subscriptions
disabled get the full quota).
- Votes are no longer toggles. Each vote spends one action and pushes
the request up the board, so a user can back the same integration
multiple times. The `(integration_request_id, user_id)` unique index is
dropped (with a standalone FK index added first, since MySQL relied on
the unique one).
- Let users undo a vote, **but only one cast in the current month**. The
refund maps back to the current quota and previous months' tallies stay
locked. The board exposes `can_unvote` and shows a `ChevronDown` control
next to the upvote button, backed by a new `DELETE
/integration-requests/{id}/vote` route.

## Tests
- Feature tests for the free (3) and pro (9) quotas, repeated voting up
to the limit, undoing a current-month vote (and recovering the action),
and the previous-month vote being non-removable.
- Updated the board fixture and added the `Remove one vote` Spanish
translation.
2026-06-18 10:06:08 +00:00
Víctor Falcón da88adbee3
feat(integration-requests): markdown comments and in-progress status (#553)
## What

- **Markdown comments**: admin comments on the integration board now
render as markdown (`react-markdown` + `remark-gfm`) instead of plain
text. Links open in a new tab; blockquotes, lists and bold are styled.
Comments are admin-only (set via CLI), so the content is trusted.
- **`in_progress` status**: new status, visible to everyone and votable
like `approved`, with an optional public comment. Lets the admin signal
an integration is actively being built.
- **`integration-requests:review` rework**:
- Any decision can now move a request to any status (approve / in
progress / reject / not doable), not just the pending ones.
- New `--all` flag prints the full list with a `#` column so the admin
can pick a request by number and change its status.
- `not doable` requires a comment; `in progress` allows an optional one;
approve/reject clears any stale public comment.

## New dependencies

- `react-markdown`, `remark-gfm` (approved with the author).

## Tests

- 24 feature tests passing, incl. `--all` status change, `in_progress`
visibility + voting, and updated review-command option sets.
- Frontend vitest covering markdown rendering (link + blockquote).
2026-06-18 08:36:43 +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 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
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
Víctor Falcón 4a891a5906
feat(support): add support link with community-first help modal (#542)
## What

Adds a **Support** entry to the user dropdown menu (below Roadmap) for
reporting any kind of error.

Clicking it opens a modal that:
1. **Leads with the community** — a primary Discord button so users can
message us directly.
2. **Offers email as a fallback** — opens a
`mailto:support@whisper.money` pre-filled with a bug-report prompt plus
diagnostics: **user UUID, app version, locale, current page URL, and
browser user-agent**.

## Why

Give users a clear, low-friction path to report bugs and get help, while
steering them to the community first.

## Changes

- `resources/js/components/support-dialog.tsx` (new) — the modal +
mailto builder.
- `resources/js/components/user-menu-content.tsx` — Support menu item +
`onOpenSupport` prop.
- `resources/js/components/nav-user.tsx` — dialog state; rendered as a
sibling of the dropdown (Radix unmounts dropdown content on close).
- `resources/js/components/user-menu-content.test.tsx` — updated + new
click test.
- `lang/es.json`, `lang/fr.json` — 7 translation keys each.

## Notes

- Diagnostic labels (User ID, Browser, etc.) are intentionally left
untranslated so the support team reads them in English.
- No backend route needed — uses a `mailto:` link.

## Testing

- `bun run test resources/js/components/user-menu-content.test.tsx` 
- `vendor/bin/pest tests/Feature/LocalizationTest.php`  (es enforced)
- eslint + prettier 
2026-06-16 13:16:04 +02:00
Víctor Falcón 06effb5e6e
fix(i18n): keep Discord brand name untranslated in French (#541)
## What

French translation rendered the **Discord** brand name as "Discorde".
Brand names stay as-is — fixed `lang/fr.json` line 581.

## Check

Swept all brand tokens (Discord, Stripe, GitHub, Google, etc.) in
`fr.json`. Only this one was mistranslated; the rest keep the brand
correctly.
2026-06-15 19:35:07 +02:00
elnuage a38ed69d2e
feat(i18n): add French translation support (#532)
Hello,

Here is my pull request for the official french translation.

I can change some traduction if you want

---------

Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-06-15 19:15:43 +02:00
Víctor Falcón 7dde67c606
feat(ai): share AI sparkle icon and mark AI-generated rules (#538)
## What

- **Extract the AI sparkle into a shared component.** Moves the
Gemini-style gradient sparkle from
`components/transactions/ai-sparkle-icon` →
`components/ui/ai-sparkle-icon`. It's now the single icon to reuse
wherever a feature involves AI. Existing transaction-list usages
(`category-cell`, `transaction-filters`) point at the new path; no
visual change there.
- **Mark AI-generated automation rules.** In the Automation Rules table,
the rule **title** is now prefixed with the sparkle (tooltip +
`aria-label "Created by AI"`) when the rule's `origin` is `ai`.
Extracted into a small `AutomationRuleTitle` component.

The backend `origin` enum (`RuleOrigin`: user/ai) already shipped to
`main`; this PR is the frontend surface plus the `origin` field on the
TS `AutomationRule` type.

## Tests

- New `automation-rule-title.test.tsx`: sparkle shown for `origin:
'ai'`, absent for `'user'`.
- `bun run format`, `bun run lint` clean; vitest green.

## Note

Per review, the marker sits before the rule **name**, not next to the
category badge.
2026-06-15 18:22:11 +02: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