Compare commits

...

450 Commits

Author SHA1 Message Date
Víctor Falcón 8172ca78d7
feat(welcome): add Miguel Ángel SB testimonial to the landing page (#703)
## What

Adds a new user testimonial from **Miguel Ángel SB** to the landing page
testimonials marquee (`resources/js/pages/welcome.tsx`), plus its
Spanish translation in `lang/es.json`.

The quote is a distilled English version of the positive closing of a
feedback email he sent us. The Spanish translation stays faithful to his
original wording.

> I love the style, the intuitive interface, how easy it is to use. If
it keeps growing like this, Whisper Money will be my finance app of
choice. You can tell it is built with passion — and things made with
passion can only end up a success.

## Notes

- Inserted just before the `Víctor Falcón (co-owner)` entry, which stays
last.
- `gravatar` is the MD5 of his email; he has no Gravatar, so the card
falls back to the generated Facehash avatar (same `d=404` mechanism as
most other entries).
- The English `__()` key is mirrored in `lang/es.json`, so the
localization test passes and Spanish users see the translated copy.

## QA

-  English card renders with the quote and Facehash fallback avatar.
-  Spanish (`?lang=es`) renders the translated quote — no English
fallback.
-  Row-splitting balances correctly with the new count; co-owner entry
remains last.
-  No new console errors (the Gravatar 404s are the intended fallback
path, shared by existing entries).
-  `bun run format` and `bun run lint` clean.

## Demo


https://github.com/user-attachments/assets/f7973c65-b719-4db5-801f-724784947e2b
2026-07-20 13:34:53 +00:00
Víctor Falcón 2cca291773
fix(pwa): keep mobile OAuth consent from being swallowed by the installed app (#701)
## Problem

On mobile, starting an OAuth connection from ChatGPT (MCP connector)
opens the **installed Whisper Money PWA** and lands on the **dashboard**
instead of the OAuth consent screen — so the connection can never be
approved.

## Cause

The web app manifest declares `scope: "/"`, so the installed PWA claims
the entire origin, including `/oauth/authorize`. On Android the OAuth
link gets captured into the app, and the default launch behavior just
**focuses the already-open window (the dashboard) and drops the incoming
URL** — the consent page never loads.

Narrowing `scope` to exclude `/oauth` isn't viable: the app's routes are
flat (`/dashboard`, `/transactions`, `/accounts`, …) and share no
prefix, so a narrower scope would push half the app out to the browser.

## Fix

- `site.webmanifest`: add `launch_handler.client_mode: "focus-existing"`
so the launcher hands the app the captured target URL via the launch
queue.
- `app.tsx`: a `launchQueue` consumer that navigates to the target URL
when it's an `/oauth/` path. Every other launch is a no-op (keeps its
place).

Low blast radius — additive manifest field + a guard scoped to `/oauth/`
paths; existing (desktop) OAuth is unaffected.

## Verification

Cannot be tested off-device. After deploy, on the phone:
1. **Reinstall the PWA** (remove from home screen, re-add) so Android
re-fetches the manifest.
2. Retry the ChatGPT OAuth connect → should land on the "Connect ChatGPT
to Whisper Money" consent screen.

Depends on `launch_handler`/`launchQueue` (Chrome/Edge 102+, covers
nearly all Android).
2026-07-18 19:37:04 +02:00
Víctor Falcón 4c83cb8b33
feat(import): persist per-account import configuration on the backend (#698)
## What & why

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

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

## Demo

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


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



## How

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

## Notes / decisions

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

## Testing

- `tests/Feature/AccountImportConfigTest.php` (9 tests): auth required,
  cross-user 403 on read and write, save→persist→load round-trip, upsert
  de-duplication, transaction/balance independence, and validation
  (unknown type, missing column mapping).
- Sibling suites green (`AccountBalanceControllerTest`,
`SavedFilterTest`) —
no regression from the new `Account::importConfigs()` relation or
routes.
2026-07-18 16:10:40 +02:00
Víctor Falcón 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 89174af4e6
feat(transactions): add delete button to the transaction edit modal (#697)
## Why

Until now the only way to delete a transaction was the row's three-dots
menu in the list. Many users click a transaction to open the edit modal
and look for a delete option there — but there wasn't one. This adds a
**Delete** button inside the edit modal, with the same confirmation
step.

## What

- Adds a destructive **Delete** button to the transaction edit modal
footer (`edit-transaction-dialog.tsx`), shown only in **edit** mode.
- Clicking it closes the modal and opens the **existing**
delete-confirmation dialog — the same `AlertDialog` (and manual-account
"update balance" checkbox) already used by the three-dots menu. No new
delete logic, confirmation UI, or copy was introduced.
- Wired via a new optional `onDelete` prop, passed as
`setDeleteTransaction` from the two parents that render the edit modal
(`transaction-list.tsx`, `pages/transactions/index.tsx`). Because
`transaction-list` also backs the Accounts and Budgets pages, the button
appears consistently everywhere the edit modal opens.

## Reviews applied

Ran technical + product reviews on the diff. Applied: added
`dark:hover:bg-destructive/20` to match the sibling delete button's
dark-mode hover. Dropped (with reason): premature shared-component
extraction, pre-existing cross-file duplication, and pre-existing "no
toast on delete failure" — all out of scope for this change.

## QA

Real browser QA (Playwright) against the running app on real data:

- Delete button appears in the edit modal (verified on an imported
transaction).
- Delete → confirmation dialog opens and the edit modal closes.
- **Cancel** returns to the list; page stays fully interactive (`body`
pointer-events `auto` — no stuck Radix scroll-lock).
- **Confirm** removes the row from the list and soft-deletes it in the
DB (verified `deleted_at` was set). Restored afterward.
- Page remains interactive after the stacked-dialog flow (reopened
another transaction cleanly).

Unit tests added in `edit-transaction-dialog.test.tsx` cover: click
delegates to `onDelete` + closes the modal; button hidden without a
handler; button hidden in create mode.

## Demo


https://github.com/user-attachments/assets/e13dad30-0779-46f2-8cc4-84ea70568741
2026-07-18 11:03:09 +00: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 41ac3e90e1
fix(mcp): keep Passport signing keys at 600 in production (#693)
## Problem
Production logs a warning on every `/oauth/authorize` and `/mcp/oauth`
request (Sentry PHP-LARAVEL-4N / PHP-LARAVEL-4M):

> Key file "file:///app/storage/oauth-private.key" permissions are not
correct, recommend changing to 600 or 660 instead of 775

The keys exist (the Docker entrypoint generates them), but the
entrypoint's recursive `chmod -R 775 /app/storage` — which runs *after*
key generation to fix storage ownership — widens
`oauth-private.key`/`oauth-public.key` to 775. league/oauth2-server
(used by Passport) checks the key-file mode when it loads the key and
rejects anything but 600/660, emitting this warning. A 775 private
signing key is also world/group-readable, which it should never be.

## Fix
Re-tighten the two Passport key files to `600` in
`docker/entrypoint.sh`, right after the recursive storage `chmod`. It
runs on every boot, so it also repairs keys already sitting at 775 on a
persisted `storage/` volume.

## Notes
- No app-code change — deploy script only.
- CI (`passport:keys`) and local/worktree key generation already produce
600 keys and never run the recursive 775 chmod, so this is
production-entrypoint-only.
- Follow-up to #691 (OAuth 2.1 for Claude Desktop & ChatGPT).
2026-07-17 21:20:00 +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 4cd7619791
feat(encryption): report count of users still holding encrypted data (#687)
## What

`encryption:notify-removal` now prints, on every run, the total number
of **non-deleted** users who still hold legacy browser-encrypted data:

```
4 non-deleted user(s) still have encrypted data.
```

The count is taken before the billing exclusion, so it reflects the full
remaining scope of browser-encrypted data. Soft-deleted users are
excluded (model default scope), as are users whose data is already
plaintext.

## Why

It's the signal for deciding when the browser-side encryption code can
finally be removed: once this reaches zero, nothing in the app depends
on client-side encrypted columns anymore.

## Not changed

Email-sending logic is untouched. Subscribed users are still never
warned — the recipient set continues to go through
`excludeBilledUsers()` exactly as before. This PR only adds a reporting
line.

## Testing

- New test asserts the count spans everyone with encrypted data
(subscribed included), while excluding soft-deleted and plaintext users,
and that sending still skips the subscribed user.
- Full `NotifyEncryptedDataRemovalCommandTest` suite green.
2026-07-17 09:29:33 +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 9312d24f93
fix(balances): propagate retroactive transaction changes to later balances (#682)
## Problem

When a transaction is created (or deleted) with the **update balance**
option on
a **past date**, only that day's balance snapshot was adjusted. Balances
are
stored as sparse per-date snapshots read with carry-forward semantics
(`BalanceLookup::getBalanceAt` returns the most recent snapshot
on/before a
date), so later snapshots — most importantly *today's current balance* —
kept
their stale value.

Reproduction: yesterday = 10, today = 10. Add a −5 expense dated
yesterday with
"update balance" on. Before this fix, yesterday became 5 but today
stayed 10.
Expected: both become 5.

## Fix

`ManualBalanceAdjuster` now shifts the transaction's own day **and every
later
snapshot** by the same delta, on both create and delete:

- `applyCreatedTransaction` seeds a snapshot on the transaction's date
from the
carried-forward balance (when none exists yet), then increments that
date and
  all later snapshots by `+amount`.
- `reverseDeletedTransaction` now applies the exact inverse from the
transaction's own date forward (previously it only ever adjusted
*today*,
which left the past day stale and would corrupt a create→delete
round-trip of
  a back-dated transaction).

The shared forward-shift is extracted into `shiftBalancesFrom(delta)`,
used by
both paths.

## Also in this PR (small UI fix)

The balance history modal (`BalancesModal`) rendered each row's
date/balance
text top-aligned while the action icons were centered, because the base
`TableCell` defaults to `align-top`. Center the data-row cells so every
column
lines up. Scoped to this modal — the shared `TableCell` is untouched.

## Notes / intended semantics

- **Later user-set balances shift too.** Snapshots are treated as points
on one
running balance, so backfilling/removing a past transaction moves
today's
balance as well. That is exactly the reported expectation ("today should
also
change"). We deliberately do **not** try to distinguish "anchor"
snapshots
(a value the user reconciled by hand) from transaction-derived ones —
there is
no such distinction in the schema, and adding one is a separate product
  decision.
- **Pre-existing, out of scope:** editing a transaction's amount/date
never
  adjusts balances (`amount`/`transaction_date` aren't editable via
`UpdateTransactionRequest`), and the create vs. delete "update balance"
toggles are independent — creating with the box off but deleting with it
on
  can over-correct. Neither is introduced here.

## Tests

- New: creating a past-dated transaction updates that date **and** every
later
  balance (the exact reported bug).
- Rewrote the delete test to assert the reverse propagates from the
transaction's date forward (the old test encoded the now-removed "always
write
  today" behavior).
- Full `TransactionTest` suite green (49 passed).

## QA

Verified end-to-end in the running app on a manual account (yesterday =
10.00,
today = 10.00): created a −5.00 expense dated yesterday with "update
balance"
on. The balance history modal shows **both** records — yesterday and
today —
drop from 10.00 to 5.00, and the DB confirms both snapshots = 500.

## Demo

https://github.com/user-attachments/assets/18aa043d-37aa-4073-990c-a269c982a818
2026-07-16 08:38:02 +02:00
Víctor Falcón c7719fe5ad
docs: document adding a new currency (#681)
## Summary

Adds `docs/adding-a-currency.md`, mirroring
`docs/adding-a-banking-provider.md` but for currencies. Distilled from
the NGN (#642) and GHS (#644) additions.

The gist: adding a currency is a config change, not a feature.

- **Required**: one entry in `config/currencies.php`. Validation (Form
Requests), the Inertia dropdown props, and conversion all auto-derive
from it via `CurrencyOptions` / `CurrencyConversionService`.
- **Translation**: add the name to `lang/es.json` (enforced),
`lang/fr.json` optional. Flagged that `LocalizationTest` won't
auto-catch a missing one, since the name is translated in PHP, not
scanned from TS source.
- **Optional**: a custom short symbol in
`resources/js/utils/currency.ts`.
- **Two gotchas**: use the current ISO 4217 code (the GHC→GHS lesson
from #644), and verify the provider covers the code at the right rate
via the CDN URL.

## Testing

Docs-only change; no code touched.
2026-07-15 12:32:18 +00:00
Víctor Falcón 6490729fcc
refactor: extract duplicated money formatter into App\Support\Money (#680)
## What

The same currency-symbol money formatter — a `match` on
`strtolower($currency)` returning a symbol, concatenated with
`number_format($cents / 100, 2)` — was copy-pasted across **five**
files:

- `app/Console/Commands/SendExperimentFunnelReportCommand.php`
(`money(int $cents, …)`)
- `app/Listeners/PostStripeEventToDiscord.php` (`money(int $amount, …)`)
- `app/Console/Commands/SyncStripePricesCommand.php` (`formatAmount(int
$amountInCents, …)`)
- `app/Console/Commands/StripeSubscriptionStatsCommand.php`
(`format(float $amount, …)`)
- `app/Console/Commands/SendDailyStatsReportCommand.php` (`money(float
$amount, …)`)

This extracts it into a single `App\Support\Money::format(int $cents,
string $currency): string` helper and removes the duplicated logic from
every site.

## Zero behavior change

The five copies were **not** verbatim — they diverged on three axes:
unit (cents vs. major units), symbol coverage, and default casing. The
shared helper is the **union** of the existing maps
(`eur`/`gbp`/`usd`/`jpy`/`brl`, otherwise uppercased code + trailing
space), and reproduces every call site's output for the currencies each
actually receives:

- **Cents-based sites** (experiment funnel, Discord listener, price
sync) call the helper directly.
- The experiment funnel only ever passes the Cashier currency
(uppercased `EUR` from the collector), so its narrower map / raw-case
default was a dead branch.
- The price sync command only formats `config('cashier.currency')`
(`eur`), so it never hit `usd`/`brl` — adding those symbols is a no-op
here.
- **Major-unit sites** (`StripeSubscriptionStatsCommand`,
`SendDailyStatsReportCommand`) hold MRR as floats, so their private
method now converts to cents (`(int) round($amount * 100)`) and
delegates; call sites are untouched. `number_format` of the
round-tripped value is identical to the original.

## Tests

- New `tests/Unit/Support/MoneyTest.php` covering `eur`, `gbp`, `usd`,
`jpy`, `brl`, an unknown currency (`chf` → `CHF 3.99`),
case-insensitivity, thousands separator, and zero.
- The existing command feature tests (which assert exact rendered
strings like `€10.00`, `€19.99`, `€9.99 / month → €3.99 / month`) still
pass unchanged.

`pint`, `phpstan`, and the affected feature + unit tests are green
locally (42 passing).
2026-07-15 09:47:53 +02:00
Víctor Falcón 3db03de86d
fix(stats): correct the trial/pricing experiment funnel report (#679)
## Summary

Audited `stats:experiment-funnel` end-to-end (data acquisition, queries,
per-row counting, financial math, and statistics) and fixed the issues
that made it misleading for the win/no-win decision. The report used to
rank variants by an **absolute** contribution-margin total that
mechanically favoured whichever variant matured fastest, and it declared
statistical significance with a normal approximation that is invalid at
the small conversion counts this experiment has.

## What changed

**Presentation & comparability**
- Replace `A2P%` (numerator not a subset of the denominator → could
exceed 100%) with `Conv%` = conversions ÷ matured-assigned — always
≤100% and comparable across variants.
- Print `MatU` (matured cohort size) so the rate denominators are
visible and the table reconciles; revive `ARPU`.
- Reframe the guidance: compare on per-user `Conv%`/`ARPU`, not the
absolute `MRR`/`Cost`/`Burn`/`CM` totals (which scale with `MatU`).

**Data correctness**
- Count soft-deleted users (`withTrashed`) — they were assigned a
variant and their connections incurred real cost.
- Attribute by the deterministic `SubscriptionExperiment::bucket()`
instead of the resolved Pennant flag, so setting `force_variant` (the
winner rollout switch) no longer collapses the whole report onto one
variant. Also stops writing Pennant rows as a side effect.
- Resolve `MRR` for subscriptions on rotated/archived Stripe price ids
(fetch prices by product), warn on any net-active sub whose price is
unmapped, round yearly ÷ 12, and skip foreign-currency prices.
- `Burn` counts only users who never earned net revenue (no
subscription, or paid-then-refunded) — a paid-then-churned user is no
longer booked as connect-and-leave leak.

**Statistics**
- Measure conversion as "ever charged, net of refund" (time-invariant)
instead of a live active-now snapshot that biases older cohorts (which
have had longer to churn).
- Decide significance with **Fisher's exact test** (exact at any sample
size), Bonferroni-corrected over the three arms, instead of the
normal-approx z that overstates evidence when expected cell counts fall
below 5. Add a Newcombe difference-of-proportions CI and a small-sample
caveat.
- Extract the inference into `App\Services\Stats\ProportionSignificance`
(+ a `BinomialProportion` value object), with unit tests that pin the
exact interval and p-value numbers.

## Validation

Reconstructed every column in raw SQL against a production dump — all
reconcile **to the cent / to the row**. Independent recomputation of the
statistics (Wilson, Fisher exact, Newcombe, z) matches the command's
output.

## Testing

26 tests (20 feature + 6 unit, 94 assertions). `pint` and
`phpstan`/larastan green.

## Note

This branch also carries two small pre-existing commits unrelated to the
funnel (`fix(categories): fall back to gray…`, `chore(schedule): stop
scheduling the stuck cohort report`). Happy to split them into their own
PR if preferred.
2026-07-15 09:17:32 +02:00
Víctor Falcón d7963736d1
fix(banking): treat EnableBanking upstream 5xx as transient, not reportable (#678)
## Problem

`EnableBankingProvider::getTransactions` and `getBalances` classify a
set of expected `RequestException`s (401 expired session, 400
inaccessible account, 422 wrong period, 400 ASPSP_ERROR) but let
**everything else** fall through to a raw `throw $e`. An upstream **500
`Internal server error`** — from EnableBanking itself or the ASPSP
behind it — therefore propagates as a plain `RequestException` and gets
`report()`ed as an app error.

**Sentry:** `PHP-LARAVEL-3J` — `Illuminate\Http\Client\RequestException:
HTTP request returned status code 500` in
`EnableBankingProvider::getTransactions`, **36 events / 3 users** since
2026-06-16 (regressed 2026-07-15). Nothing per-event our code can do
about the bank's server erroring.

## Fix

A 5xx is a transient server-side failure — the same class as a
`ConnectionException` (no response), which is **already** wrapped as
`TransientBankingProviderException` (logged at `warning`, retried,
self-healing, `ShouldntReport`). Classify any upstream 5xx the same way
in both `getTransactions` and `getBalances`, via a small
`isTransientServerError()` helper placed alongside the other
classifiers:

```php
private function isTransientServerError(RequestException $e): bool
{
    return $e->response->status() >= 500;
}
```

So provider outages retry/self-heal instead of paging Sentry.

## Not changed

All non-5xx paths are untouched and stay reportable — 401/400/422/ASPSP
keep their dedicated exceptions, and genuine 4xx client errors (e.g. the
existing "keeps non-ASPSP client errors reportable" / "keeps unrelated
422 validation errors reportable" tests) still surface.

## Test

Added two cases to `EnableBankingProviderTest.php` (an upstream 500 on
`getTransactions` and on `getBalances` →
`TransientBankingProviderException` / `ShouldntReport`). Full provider
suite: **14/14 green** locally.

🤖 Found and fixed autonomously via the Sentry monitoring loop.
2026-07-15 06:40:55 +00:00
Víctor Falcón 9e237802f6
docs: document running the dev server for QA (#677)
## What

Adds a "Running the server for QA" subsection to the project `CLAUDE.md`
so commands/skills that ask the user to open the app in Chrome have
clear, project-level steps.

## Steps documented

1. First run in a worktree? Prepare it with `./worktree.sh
<main-repo-path>`.
2. Start the server if it isn't running: `composer run dev`.
3. Get the dynamic per-worktree URL — `composer run dev` prints and
copies it to the clipboard on startup; don't guess it.

Docs-only change.
2026-07-14 21:38:11 +00:00
Víctor Falcón 55eff788e8
chore(schedule): stop scheduling the stuck cohort report (#676)
Removes the weekly schedule for `stats:stuck-cohort-report`. The command
stays available to run manually via artisan; it just no longer fires on
the Monday 09:00 cron.
2026-07-14 21:22:58 +00:00
Víctor Falcón 19e0c24f9a
fix(categories): fall back to gray when category color is unknown (#675)
## Problem

`getCategoryColorClasses(color)` in `resources/js/types/category.ts`
returned `colorMap[color]` directly. When a category's `color` is
**not** one of the known `CATEGORY_COLORS` (a legacy value, or any color
the frontend map doesn't cover), the lookup is `undefined`. Consumers
then read `.bg` off it — e.g. the transaction categorizer command
palette:

```tsx
const colorClasses = getCategoryColorClasses(category.color);
// ...
className={cn(..., colorClasses.bg)}   // 💥 Cannot read properties of undefined (reading 'bg')
```

This threw `TypeError: Cannot read properties of undefined (reading
'bg')` and crashed the categorize-transactions view.

**Sentry:** `PHP-LARAVEL-4H` (frontend, error/high) — first seen
2026-07-14, in `use-categorize-transactions` chunk,
`getCategoryColorClasses` → `Array.map` over categories.

## Fix

Mirror the guard that already exists in the sibling
`getCategoryChartColor` (which does `?? 'var(--color-gray-500)'`): fall
back to the gray classes when the color isn't in the map.

```diff
- return colorMap[color];
+ return colorMap[color] ?? colorMap.gray;
```

One line, behavior-preserving for all valid colors, and it turns a crash
into a neutral gray swatch for the rare out-of-enum color.

## Test

Added two cases to `resources/js/types/category.test.ts`:
- a known color still returns its own classes,
- an unknown color falls back to gray instead of returning `undefined`.

Ran locally (minimal vitest config): 4/4 pass.

🤖 Found and fixed autonomously via the Sentry monitoring loop.
2026-07-14 22:24:52 +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 b9b507005f
feat(cashflow): expand income categories and fix Sankey label overlap (#672)
## Summary

Two improvements to the Cashflow Sankey chart, plus a fix reported for
real users whose chart rendered with overlapping labels.

### 1. Fix overlapping labels on small categories
recharts spaces same-column Sankey nodes by exactly `nodePadding` and
distributes the height **proportionally to value**, so tiny categories
end up separated by only that padding regardless of canvas height. It
was 24px while a two-line label is 30px tall, so adjacent labels
overlapped (e.g. *Regalos* / *Otros* in the monthly view, *Ingreso
desconocido* / *Pagos devueltos* in the yearly view).

Set `NODE_PADDING` above the label height so two adjacent labels can
never collide, whatever the bar size. This is independent of width, so
it fixes desktop and mobile alike.

### 2. Expand income categories (drill-down)
Income categories can now be drilled down into their subcategories,
mirroring the existing expense behaviour. The `sankey` endpoint already
returns both sides for a given `parent`, so this is frontend-only:

- The open drill-down is keyed by `{id, kind}` — a category can
legitimately appear on both the income and expense side.
- Income subcategories link `child -> parent` and form a column to the
**left** of the parent (expense subcategories stay on the right).
- The chart-height budget now accounts for the combined leftmost column
(income siblings + subcategories share it), so a many-source drill-down
can't drive recharts to a negative row scale and break the whole chart.
- The expand chevron points the way the column opens: income shows `<`
collapsed / `>` expanded; expense keeps `>` collapsed / `v` expanded.

The income/expense node-building was also de-duplicated into a single
helper.

## Test plan
- `sankey-chart.test.tsx`: 7 unit tests green (added income-expansion
coverage alongside the existing expense one).
- `tsc --noEmit`, `eslint`, `prettier` clean.
- Manual browser QA (Playwright) on a real account, desktop **and**
mobile: monthly + yearly views (overlap gone), expand income, switch to
expand expense (no regression), collapse, chevron direction, responsive
horizontal scroll.

## Demo

<!-- PLACEHOLDER: drag the demo video here -->
_📎 Attach the demo video here — file generated at
`~/Downloads/sankey-cashflow-demo.mp4`_
2026-07-13 07:28:14 +00:00
Víctor Falcón cbeea2fc34
fix(accounts): show credit cards as positive and exclude them from net worth (#673)
## Summary

A credit card is a spending account, not wealth. This change:

- **Excludes credit cards from net worth entirely** — they no longer add
to *or* subtract from the net worth total, the evolution chart, or its
trends. They are filtered out at the source, so every net-worth
aggregation (backend summary + frontend chart/trend/MoM) is consistent.
- **Shows the credit card balance as a positive figure** on the
per-account cards, on both the dashboard and the Accounts page (like any
other account).
- **Fixes a loan sign inconsistency**: a loan used to show *positive* on
the Accounts page but *negative* on the dashboard. The Accounts list now
applies the liability sign too, so loans render negative in both places.

### Why

A user reported that a credit card showed as a positive balance on the
Accounts screen but dragged their net worth down on the dashboard — the
same account rendered with opposite signs on the two screens. The root
cause was that credit cards were modelled as liabilities (like loans)
and each screen computed the balance through a different path. Product
decision: a credit card is spendable credit, so its balance is shown
as-is and simply doesn't participate in net worth.

## Behavior

| Account (e.g. 90k / 50k) | Per-account cards (dashboard + Accounts) |
Net worth chart & total |
| --- | --- | --- |
| **Credit card** | `+90,000` | **excluded** (not counted) |
| **Loan** | `−50,000` (was `+50,000` on Accounts) | subtracts
(unchanged) |
| Checking / savings / … | unchanged | unchanged (asset) |

## ⚠️ Existing-data impact

Previously credit cards were forced to **reduce** net worth
(`-abs(balance)`). With this change they are excluded, so **users with
existing credit-card accounts will see their net worth rise** by
whatever their cards used to subtract. This is a deliberate semantic
change, not a migration — no stored balances are altered.

## Implementation

- `AccountType::countsInNetWorth()` — new predicate, `false` only for
credit cards; `calculateNetWorthAt()` skips excluded types.
- `AccountType::reducesNetWorth()` / `LIABILITY_TYPES` — now only
`loan`, so `netWorthContribution()` renders credit cards positive on the
per-account cards.
- `net-worth-chart.tsx` — credit cards filtered out of
`includedAccounts` (one point that cascades to segments, totals, trends
and `useChartViews`).
- `Accounts/Index.tsx` — applies `netWorthContribution()` so loans
render negative, matching the dashboard.

## Testing

- Backend: `AccountTypeTest` (32) and `DashboardAnalyticsTest` (credit
card excluded, loan still subtracts) — green.
- Frontend: `chart-calculations.test.ts` (37) — green.
- `pint`, `prettier`, `eslint` — clean.

Browser QA skipped per request (the logged-in flow is also currently
blocked locally by pending Spaces migrations). Logic is fully covered by
the tests above.
2026-07-13 07:27:52 +00:00
Víctor Falcón c0816f9633
ci: gate PR titles via the required linter job (#671)
## Problem

The standalone `pr-title.yml` workflow produced a `Conventional PR
title` status check, but that context was **not** in `main`'s
branch-protection required list (`tests`, `linter`, `static-analysis`,
`browser-tests`, `performance-tests`). Auto-merge only waits on required
checks, so a **failing** title check never blocked a merge — which is
exactly what happened.

## Fix

Fold the same validation into the `linter` job, which is already a
required check. A bad PR title now fails `linter` → blocks the merge
(and auto-merge waits). The standalone workflow is removed as redundant.

Since required checks live in branch protection (not in the repo — no
config-as-code here), hanging the validation off an already-required job
is the only way to enforce it via a PR.

## Trade-off

The title re-validates on `push`/`synchronize` rather than on the
`edited` event, so correcting a bad title after opening a PR needs a new
push (a re-run won't pick up the edited title). Acceptable for the
simpler, single-mechanism setup.
2026-07-12 17:04:31 +00:00
Víctor Falcón 7281b610b5
Render the Cashflow Sankey with recharts (responsive + expense drill-down) (#670)
## What & why

The Cashflow "Money Flow" diagram was a bespoke SVG Sankey that laid out
nodes and links by hand. It didn't hold up on small screens (fixed 400px
min-width, cramped flows) and was ~950 lines of custom geometry to
maintain.

This replaces it with recharts' `<Sankey>` (already a dependency) inside
a `ResponsiveContainer`, letting recharts own the layout. Net **−950 /
+515** lines across the component.

## What's kept

- Income → **Net** hub → expense flow.
- Small-category **"Other"** grouping (`groupSmallCategories`).
- Privacy-mode masking on every amount (hub + categories +
subcategories).
- Click-through to a category's transactions.
- Category colors, resolved via `categoryBarColor` (same as the
breakdown cards).

## What's new

- **Drill-down (expense side):** click an expense category with children
to expand its subcategories into their own column; recharts re-lays-out
around it. One open at a time, a chevron marks expandable parents, and
the expanded parent's label moves onto its bar. Subcategories and
childless categories still click through to their transactions. Data
comes from the existing `GET /api/cashflow/sankey?...&parent=<id>`
endpoint, fetched lazily and reset when the period changes.
- **Mobile:** the chart scrolls horizontally with a comfortable
min-width (same pattern as the Trend chart) instead of crushing the
flows; canvas height grows with the busiest column so labels don't
collide.
- `align="left"` so an expanded parent's subcategories line up beside it
instead of collapsing into the last column and crossing every flow.

## What's dropped (intentional)

- The "Other" popover breakdown — the per-category detail already lives
in the breakdown cards below the chart.
- Income-side drill-down — with the central hub, expanding an income
source would split the income column across two depths (visually
broken); income categories rarely have children. Income keeps
click-through.

## Review

Two parallel reviews (technical + bugs/side-effects) ran over the diff.
Applied:
- **Category colors:** resolve the `CategoryColor` keyword via
`categoryBarColor` instead of using it raw as an SVG `fill` — non-CSS
keywords (`slate`, `neutral`, `amber`, `rose`) were rendering **black**.
(Pre-existing bug carried over from the old chart.)
- Skip top-level categories with `amount <= 0` so they don't become
stray unlinked nodes.
- Keep an expanding parent's label beside its bar until subcategories
load (no flicker); collapse again if the fetch fails.
- Cap right-side label width so a parent left of an expanded column
can't stretch its label across it.
- Memoize the cashflow `period` so the drill-down fetch effect doesn't
re-run on unrelated re-renders.

Dropped (with reason): re-adding the "Other" popover and income
drill-down (intentional, above); wrapping aria-labels in `__()` (would
add i18n keys, and matches the prior behavior); the recharts node/link
prop casts (version is pinned; params are hand-typed to document the
fields used).

## Testing

- 6 Vitest unit tests (render, "Other" grouping, empty state, drill-down
with a mocked fetch, expand affordance, privacy masking) — green.
- `tsc --noEmit` clean for the touched files; ESLint + Prettier clean.

> **Note on browser QA:** I did not record an automated demo video. The
local DB holds real user financial data (won't log in / record), and
creating a synthetic test/demo account is currently blocked by pending
Spaces migrations in the local DB (`current_space_id` column missing —
unrelated to this change). The chart was verified interactively during
development (desktop render, mobile layout, drill-down, and the
black-color fix). Happy to record a walkthrough if we seed a safe demo
account.
2026-07-12 16:26:17 +00:00
Víctor Falcón cd918523e8
fix(banking): stop duplicating EnableBanking transactions with positional entry_reference (#669)
## Problem

A user reported the same bank transactions appearing twice.
Investigation in prod showed this is a systematic dedup bug in the
EnableBanking sync affecting a few hundred rows across several
users/accounts.

## Root cause

The affected banks don't return a real `transaction_id`; their only id
is a **positional** `entry_reference` of the form
`{booking_date}.{index}` (e.g. `YYYY-MM-DD.0`). That field is **null the
day a transaction first appears** and only **populated on a later
sync**.

`TransactionFingerprint::for()` preferred `entry_reference` when present
and fell back to a content hash when absent. So the same transaction
produced two different fingerprints:

| | First sync (same day) | Later sync |
|---|---|---|
| `entry_reference` | `null` | `YYYY-MM-DD.0` |
| `dedup_fingerprint` | content-based | id-based |
| `external_transaction_id` | `null` | `YYYY-MM-DD.0` |

Neither the fingerprint nor the `external_transaction_id` dedup path
matched across the two syncs → duplicate row. The one-shot historical
first sync has no duplicates; the duplicates start with the daily
incremental syncs.

## Fix

Treat a positional (`^\d{4}-\d{2}-\d{2}\.\d+$`) `entry_reference` as "no
stable id" and fall through to the content hash, which is identical on
both syncs. Genuine `transaction_id` and non-positional
`entry_reference` keep keying exactly as before.

## Trade-off (accepted, follow-up tracked)

The positional index is also the only field that distinguishes two
genuinely-distinct same-day transactions with byte-identical content.
Falling to the content hash collapses them to one fingerprint, so only
the first is kept — a rare silent under-count. We accept it here over
the systematic duplication it fixes; **existing rows are unaffected**
(their distinct positional value is still stored in
`external_transaction_id` and caught by the fallback dedup path on
re-sync). Fixing both cases needs occurrence-aware dedup in the consumer
(a schema change) — left as a follow-up and documented in the code.

## Tests

- Regression: a positional `entry_reference` matches the same
transaction seen earlier without one.
- Boundary: non-positional references still key on `entry_reference`.
- `php artisan test
tests/Unit/Services/Banking/TransactionFingerprintTest.php` → 6 passed.
`pint` clean.

## Data cleanup (separate, after deploy)

The already-duplicated rows still need a one-off cleanup (soft-delete
the later copy per group, keeping the content-fingerprint original). It
must run **after** this fix ships, otherwise the next daily sync
re-creates them. Not included in this PR.
2026-07-12 16:25:13 +00:00
Víctor Falcón dada23cd84
feat(stats): add unit-economics funnel + connection cost to experiment report (#666)
## What

Reworks `stats:experiment-funnel` from a pure conversion funnel into a
**contribution-margin** one, so the 3-way trial/pricing experiment can
be judged on margin — not just conversion. Signups aren't free: each
bank connection costs money per month, so trials that connect banks and
never pay are burned cash, which the old report was blind to.

## Funnel

Per variant: **assigned → activated → carded → net-paying**

- **activated** = connected ≥1 bank connection **OR** gave AI consent
(triggered paid infrastructure), whether or not they paid.
- **carded** = completed Stripe Checkout (card on file).
- **net-paying** = live, non-refunded, mature subscription.

The **activated → carded** gap is where a user connects a bank and walks
away without paying — the exact leak the report now puts a number on.

## Metrics

| Column | Meaning |
|---|---|
| `A2P%` | net-paying ÷ activated (mature) |
| `Cost` | mature-cohort connections × cost/connection |
| `Burn` | connection cost of mature non-payers (money lost) |
| `CM` | MRR − Cost (the decision metric) |

- New `--cost-per-connection` option, default **0.40** per connection.
- Connection count includes soft-deleted/revoked connections (they still
cost money).
- All money/rate columns are gated on each variant's maturity window;
`Cost`/`Burn`/`CM` print `—` until a variant has mature volume. Legend
notes that raw `Assg`/`Actd`/`Card` are lifetime counts while
rates/money are mature-cohort only.
- Existing MRR/ARPU/Net% fields stay on the collector; dropped from the
printed table.

## Tests

Added coverage for activation (bank OR AI),
cost/burn/contribution-margin math, and the cost-per-connection
argument. Full file green (12 passed).
2026-07-10 18:06:01 +00:00
Víctor Falcón 815ca6244c
feat(stats): surface trials scheduled to cancel in experiment funnel (#665)
## Why

In the experiment funnel report, variants with a trial (control,
reduced) show `0` under Actv/Cncl/Rfnd while their signups are still
mid-trial — so the table looked empty even though many of those trials
have already been canceled by the user (Cashier keeps serving the trial
until it ends, then simply doesn't charge). That "already lost, just not
settled yet" cohort was invisible.

On prod right now: 28 subscriptions in `trialing`, of which **10 are
already scheduled to cancel** — a leading churn signal the report was
hiding.

## What

- `ExperimentFunnelCollector`: new per-variant `trialingCanceling`
counter = `stripe_status === 'trialing'` **and** `ends_at !== null`.
- Report table: two new columns — `Trl` (currently in trial, previously
collected but never printed) and `TrlX` (of those, already scheduled to
cancel and won't convert). Discord legend updated.
- Test covering the new counter.

`Trl`/`TrlX` are orthogonal leading indicators; the mature Net%/MRR/ARPU
metrics are unchanged.

## Testing

`php artisan test
tests/Feature/SendExperimentFunnelReportCommandTest.php` — 9 passed.
2026-07-10 19:22:58 +02: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 6f72c43cce
feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650)
## Spaces / Business plan — Phase 0: invisible foundation

First of **three stacked PRs** introducing multi-tenant **Spaces** (the
basis for the Business plan). This one is a **pure, behaviour-preserving
foundation**: it can ship to production on its own with zero
user-visible change.

- **Stacked PRs:** this → `enterprise-spaces-ui` (Phase 1+2) →
`enterprise-spaces-invitations` (Phase 3).

### What a Space is
A Space groups its own accounts, connections, transactions, categories,
labels, budgets and rules. **Every user gets one invisible "Personal"
space**, provisioned automatically on creation — so the architecture is
identical for free, Standard and Business accounts, even though only
Business will ever see more than one.

### What this PR does (no behaviour change)
- `spaces`, `space_user`, `space_invitations` tables;
`users.current_space_id`; a nullable, indexed `space_id` on the 8 owned
tables (plain column, **no FK** — avoids a validating table-scan/lock on
`transactions` during a phased rollout).
- `Space` model + `BelongsToSpace` trait that stamps `space_id` on
create (from the row's user's current space; a transaction inherits its
account's space, so bank-sync lands rows correctly).
- Idempotent, chunked `spaces:backfill` command (run from a migration)
that gives every existing user a personal space and stamps their rows —
so the read switch in the next PR is safe.
- **Reads are untouched here** (still user-scoped); every user has
exactly one space, so behaviour is identical.

### Testing
- New `tests/Feature/Spaces/SpaceFoundationTest.php` (provisioning,
default-space stamping, account-anchored transaction space,
stale-pointer self-heal, backfill).
- Full suite green.

### Notes / deliberate simplifications
- `space_id` stays **nullable** (populated by backfill + on every
write); the NOT NULL constraint is deferred until prod is confirmed
fully backfilled.
- For very large `transactions` tables, `spaces:backfill` can be run
out-of-band before deploy so the migration's call is a no-op.
2026-07-09 14:26:07 +02:00
Víctor Falcón abe31ff967
Update README.md (#662) 2026-07-09 09:16:29 +00:00
Víctor Falcón 08d367a5c8
fix(balances): stop historical-balance generator OOM on ancient purchase/loan dates (PHP-LARAVEL-49) (#661)
## What & why

A queued historical-balance generator job exhausted the worker's 128MB
PHP memory limit and **died on every retry** — Sentry `PHP-LARAVEL-49` +
`PHP-LARAVEL-4A` (two fingerprints of the *same* poison message:
identical `trace_id`/`message.id`, `retry.count` 1 then 2, OOM at
slightly different `Arr.php` lines).

### Root cause (trace-confirmed)
The Sentry trace localized the OOMing message to
`App\Jobs\GenerateHistoricalRealEstateBalancesJob`, dispatched from
`Settings\AccountController::store` for the "older than 12 months" slice
of history. The fatal frame is `Arr::map` inside Eloquent's
`AccountBalance::upsert()` value-prep (`addUniqueIds`/`addTimestamps`
per-row `array_merge`, `Query\Builder::upsert` `Arr::flatten` bindings,
and a compiled multi-row SQL string — all **O(n)** over the rows).

`purchase_date` (`before_or_equal:today`) and `loan_start_date` (no
constraint) had **no lower bound**, so a mistyped/ancient year (e.g.
`0201`, `1080`) made the generator build a **multi-century monthly
series** and hand it all to a single `upsert`, blowing past 128MB.

## The fix (two commits)

1. **`fix(balances): upsert historical balances in batches`** — build +
upsert in batches of 500 (`UPSERT_CHUNK_SIZE`) in both
`RealEstateBalanceGeneratorService` and `LoanBalanceGeneratorService`,
instead of one giant operation. Same rows, same `(account_id,
balance_date)` conflict target — peak memory is now bounded regardless
of series length. Defense-in-depth.
2. **`fix(accounts): floor purchase/loan start dates at 1900`** — the
actual root cause: add `after_or_equal:1900-01-01` to `purchase_date`
and `loan_start_date`. Rejects the data-entry typos that drive the
runaway series (and the resulting DB bloat / misleading multi-century
net-worth ramp) while accepting any legitimately old asset in a
personal-finance app.

## Review (two agents, before this PR)
Both reviewed against the live trace and the code.

- **Root cause & fix confirmed** — the batching caps exactly the
`Arr::map`-over-N-rows frame from the trace; no independent OOM source
exists (no per-row model events; `upsert` bypasses events).
- **Chunking is correct** — the unique index `(account_id,
balance_date)` exists, so the conflict target is real; `buildDateList`
yields a strictly-increasing de-duplicated list, so batches are disjoint
(no dropped/duplicated dates, no off-by-one). The fresh per-row `id`
UUID is discarded on conflict → idempotent retries.
- **No material user-facing regression.** Balance consumption
(dashboard/net-worth) is date-range-clamped. Batching introduces a
theoretical partial-write-on-mid-job-failure window, but it's idempotent
and self-heals on retry — agents advised **against** wrapping it in a
transaction (holds locks, doesn't help memory).

Applied recommendation #2 (the date floor) as its own commit. Deferred
(both agents agree): extracting the two near-identical generators into a
shared base/trait — a larger refactor that shouldn't ride a hotfix.

## Tests
- Regression test in each service: generate a ~46-year (>500-point)
series and assert the batched writes produce no dropped/duplicated dates
and keep endpoints anchored (crosses multiple batches).
- Validation test: a pre-1900 `purchase_date` is rejected at
`accounts.store`.

Note: the PHP feature suite boots a MySQL testcontainer (Docker) that
isn't available in my environment, so these were validated by
static/`pint`/`php -l` locally and rely on CI for execution — auto-merge
is gated on green CI.

Fixes PHP-LARAVEL-49
Fixes PHP-LARAVEL-4A
2026-07-08 21:34:43 +00:00
Víctor Falcón 1a62b8f42e
fix(dashboard): stop negative asset balances inflating net worth (#660)
## Problem

An account of type `checking`/`savings` with a genuinely **negative**
balance was:
- displayed as **positive** on the dashboard account card, and
- **added** to the net worth chart instead of subtracted,

while the **accounts page** rendered the same balance correctly as
negative.

## Root cause

The dashboard used `getAccountSign(type) * Math.abs(value)`. Liabilities
(`credit_card`/`loan`) are stored as positive magnitudes, so `-abs()`
was
correct for them — but for an **asset** holding a negative balance,
`+abs()`
flipped the sign to positive and summed it into net worth. The accounts
page
was correct because it uses the raw signed balance from
`AccountMetricsService`
with no sign transform.

## Fix

- Add `netWorthContribution(type, balance)` in `chart-calculations.ts`:
liabilities subtract their magnitude, assets keep their real sign (so an
  overdrawn asset correctly reduces net worth).
- Dashboard account card (`use-dashboard-data.ts`) now shows the raw
signed
  balance, matching the accounts page.
- Net worth headline total, short/long trend indicators and the
stacked-asset
total (`net-worth-chart.tsx`) use the raw signed value instead of
`Math.abs`.

## Tests

- 3 new unit tests in `chart-calculations.test.ts`, including a
regression case
  for a negative asset balance. Full suite: 37 passing.
2026-07-08 15:22:16 +00:00
Víctor Falcón e339aaa193
fix(chart): upgrade recharts to 3.9.2 to stop the mobile dashboard render loop (PHP-LARAVEL-47) (#659)
## What & why

The dashboard white-screened with React **"Maximum update depth
exceeded"** on mobile (iOS 18.7 / Opera Touch), fingerprinted in Sentry
as **PHP-LARAVEL-47** (`https://whisper.money/dashboard`).

### Root cause (upstream, recharts < 3.9.0)
The crashing first-party frame is inside recharts' internal
`CartesianChart` → `useElementOffset`
(`node_modules/recharts/es6/util/useElementOffset.js`). In < 3.9.0 that
hook:

- memoizes its measuring **callback ref** with `useCallback(...,
[lastBoundingBox.width, .height, .top, .left, ...])`, so **every
`setState` changes the ref identity → React re-invokes the ref → it
re-measures on each render**, and
- reads **viewport-relative** `getBoundingClientRect().top/left` with a
`EPS = 1px` threshold.

On mobile, any mid-render viewport shift (the iOS URL bar showing/hiding
on scroll) moves the box `> 1px` on each commit, feeding an unbounded
`render → measure → setState` loop until React aborts at the
nested-update limit and the dashboard white-screens.

This is the **same crash family** as the `ChartTooltipPortal` loop fixed
in #657, but a **distinct root cause inside recharts** (that first-party
loop is already fixed and covered by its own test; both are present on
this branch).

### The fix
Upgrade **recharts 3.7.0 → 3.9.2**. 3.9.0 rewrote `useElementOffset` to
drive measurement off a **`ResizeObserver` with a stable callback ref**
(deps no longer include the last bounding box). `ResizeObserver` fires
on element **size** changes, not on scroll/viewport-position — so the
iOS URL-bar shift no longer re-triggers measurement. This removes the
loop at its source; it does not merely mask it.

## Commits
1. `fix(chart): upgrade recharts to 3.9.2 to stop the dashboard render
loop` — the fix (bun.lock + a code comment on `ResponsiveContainer`
recording the `>= 3.9.0` requirement).
2. `fix(chart): raise recharts floor to ^3.9.0 so the crash fix can't
regress` — review follow-up: `package.json` was still `^3.7.0` (permits
the crashing versions); bumped to `^3.9.0` and regenerated
`package-lock.json` so both `bun install --frozen-lockfile` and `npm ci`
enforce the floor. Also resolves a pre-existing
bun.lock/package-lock.json divergence.
3. `test(chart): guard recharts >= 3.9.0 against a downgrade regression`
— asserts the installed recharts is `>= 3.9.0` (the honest test for a
"depend on the patched version" fix; a jsdom behavioral repro is
impossible — zero-size rects, no ResizeObserver — and would pass on the
buggy version too).

## Review (two agents, before this PR)
Both reviewed against the actual `node_modules/recharts@3.9.2` source,
not just release notes.

- **No material regression risk.** The `ChartTooltipPortal`'s two hard
dependencies still hold in 3.9.2: `.recharts-wrapper` is still emitted
and is still the default tooltip portal target, and `Tooltip` still
passes `coordinate` as `{ x, y }`. `ResponsiveContainer`'s
`initialDimension` is still supported. No API we use was
removed/renamed.
- **Types & tests:** `bun run types` produces zero recharts-attributable
errors; the chart-related JS tests (tooltip portal/position,
budget-spending-chart, stacked-bar custom-shape path) pass on 3.9.2.

### Lockfile note
The production bundle is built by `bun run build` off **bun.lock**
(pinned to 3.9.2). `package-lock.json` is consumed **only** by
`release.yml`'s `npm ci`, which runs `release-it`
(versioning/changelog/tag) and does **not** build the shipped bundle —
so its large regen diff is mechanical drift-repair and cannot affect
users. Follow-up worth considering (out of scope): consolidate on bun
and drop the vestigial `package-lock.json`.

## Why draft (not auto-merged)
The root-cause fix is high-confidence, but this is a **charting-library
bump touching all chart surfaces** and the original bug is
**mobile-only**, which I can't visually QA here. Please spot-check
before merge:

- [ ] **Mobile dashboard** (ideally iOS Safari + Opera Touch): charts
render, no white-screen, scroll is smooth, tooltips position correctly.
- [ ] **Animations**: recharts 3.9 rewrote the animation system — glance
that Area/Bar/Line still animate acceptably on data refresh (esp.
`account-balance-chart`, `budget-spending-chart`).
- [ ] **New Sentry noise**: watch for `"ResizeObserver loop completed
with undelivered notifications"` — that's a benign warning, not the loop
returning; suppress rather than treat as a regression.

Refs PHP-LARAVEL-47
2026-07-08 12:12:20 +00:00
Víctor Falcón 5b3400909e
fix(chart): stop tooltip render loop crashing the dashboard (PHP-LARAVEL-3B) (#657)
## Problem

Sentry **PHP-LARAVEL-3B** — React `Maximum update depth exceeded`
crashing `/dashboard`. Regressed, 5 occurrences / 4 users (mobile
Chrome/Android and Safari), last seen today.

## Root cause

`ChartTooltipPortal` in `resources/js/components/ui/chart.tsx`
positioned the portaled tooltip in a `useLayoutEffect` with **no
dependency array**, so it ran after every render and called `setPos`. An
equality guard was the only thing preventing a `render → effect → setPos
→ render` feedback loop, and it failed once the computed position
oscillated by a sub-pixel (fractional `getBoundingClientRect` / integer
`offsetWidth`). React then aborted after 50 nested updates, taking down
the dashboard.

## Fix

1. **`651c7d72`** — Depend on the primitive `coordinate.x/.y` (and
`offset`) so the effect runs only when the cursor moves, never as a
result of its own `setPos`. `coordinate` itself is excluded on purpose:
Recharts hands a fresh object every render, so depending on the object
would reopen the loop. Extract the flip/clamp math into a pure
`computeTooltipPosition` helper (`resources/js/lib/`) rounded to whole
pixels, and unit-test it.
2. **`1c1602db`** — Component regression test: asserts the positioning
effect does **not** re-run on a re-render with an unchanged coordinate,
and **does** on a coordinate change. Verified it fails against the
pre-fix dependency-less effect.
3. **`0354a031`** — Strengthen the helper test to assert sub-pixel
positions collapse to one pixel, and correct the doc note (the
dependency array removes the loop; rounding only narrows the
oscillation).

## Review

Two independent review agents (architecture/duplication/tests +
user-facing correctness) examined the diff:
- **Correctness**: no must-fix issues. First paint is correct (measured
at `-9999`/opacity 0 before paint, capped by a viewport-relative
`max-w`, so size is accurate and there is no flash). The loop cannot
recur through the `setHidden` effect or a repeated identical coordinate.
No mobile/edge regression — the flip/clamp logic is the extracted
original.
- **Architecture**: helper placement/naming/types match conventions; no
duplicate positioning logic elsewhere to fold in; both agents' top
request (regression-test the actual fix mechanism) is addressed by
commit 2. `ChartTooltipPortal` is exported for the test, matching how
`StackedBarShape` is exported for its own test.

## Known minor residual (acceptable)

If the tooltip's **content size** changes while the cursor stays on the
exact same point *and* it sits at a viewport edge, the flip/clamp won't
recompute until the next mouse move. In practice content size changes
together with the coordinate (Recharts hover). Worst case is a one-frame
cosmetic overflow that self-corrects — strictly better than the crash it
replaces.

## Testing

`chart-tooltip-position.test.ts` (5) + `chart-tooltip-portal.test.tsx`
(2) pass. Note: the infinite loop itself can't be reproduced in jsdom
(zero-size rects), so the component test guards the mechanism (effect
does not self-retrigger) rather than the throw.

Fixes PHP-LARAVEL-3B
2026-07-07 18:39:09 +00:00
Víctor Falcón 7d750fa1a8
fix(encryption): never email or delete users who are still being billed (#656)
## What

The `encryption:delete-accounts` and `encryption:notify-removal`
commands target users who still hold legacy client-side encrypted data.
They should **never** warn or delete an account that is still being
billed.

## Changes

- Add `excludeBilledUsers()` to the shared
`FindsUsersWithLegacyEncryption` trait. It drops any user where
`hasActiveSubscriptionOrTrial()` is true — a valid subscription outside
its grace period, or an active generic trial. This reuses the existing
domain method (whose docblock already states such users must cancel
before deletion) instead of reimplementing Cashier's subscription-status
logic in SQL.
- Both commands apply the filter right after fetching their candidates.
- Eager-load `subscriptions` in the base query to avoid an N+1 when the
filter runs.

## Tests

- `it never deletes a subscribed user` (delete command)
- `it never warns a subscribed user` (notify command)

Both new tests plus the existing suite pass (8/8). Pint clean.
2026-07-07 16:04:27 +00:00
Víctor Falcón d504e70309
fix(ai): don't report expected transient provider overloads (PHP-LARAVEL-44) (#655)
## Issue (Sentry PHP-LARAVEL-44 — low volume: 0 users, 1 event)

`Laravel\Ai\Exceptions\ProviderOverloadedException: AI provider [gemini]
is overloaded` (underlying Gemini HTTP 503 "high demand"), thrown from
the AI rule-suggestion generator. This is an expected, transient,
self-healing provider overload — but
`LaravelAiRuleSuggestionGenerator::generate()` called `report()` on
every failed batch, so a transient hiccup surfaced in Sentry as an
error.

## Fix

Split the per-batch catch so a `FailoverableException` (overload /
rate-limit / insufficient-credits) is logged at `warning` **without**
reporting, while every other `Throwable` is still `report()`ed. This
mirrors the existing, test-enforced handling in the sibling
`CategorizeTransactions` service.

Unchanged (deliberately preserved):
- **Partial tolerance** — suggestions from batches that succeeded are
still kept.
- **Rethrow-when-all-fail** — a genuine total failure still rethrows, so
the run is marked `Failed` (the onboarding "Try again / Skip" UI) and is
reported **once** at the run level. Non-transient batch errors are still
reported immediately.

## What I deliberately did NOT do

Two review agents converged that adding a **retry backoff** (the other
candidate fix) is not worth it here and carries real risk:
- The run **already self-heals**: failed/empty runs don't count toward
the throttle, so the user's "Try again" button re-runs immediately at
zero cost.
- A synchronous per-batch `sleep` on the single-worker `database` queue,
across up to ~10 batches, could push the run past its **120s job timeout
(failing more often, not less)** and starve other queued jobs during a
provider brownout.

So this PR is scoped to the safe, high-signal change: stop reporting an
expected transient as an error. Genuine misconfigurations (bad
model/key/quota) throw non-`FailoverableException` types and remain
fully reported.

## Testing

- New: an expected transient overload returns the successful batches'
suggestions and is **not** reported
(`Exceptions::assertNothingReported()`); an unexpected batch failure
**is** reported (`assertReported(RuntimeException::class)`).
- Full `tests/Feature/Ai` suite green (121 tests); Pint and Larastan
clean.

Fixes PHP-LARAVEL-44

---
🤖 Opened by the autonomous Sentry-triage loop. Auto-merge enabled:
low-risk, mirrors an existing tested pattern, keeps genuine failures
visible.
2026-07-07 10:55:02 +00:00
Víctor Falcón 9b7632f585
fix(banking): recover from EnableBanking 422 wrong-period instead of crashing the sync (PHP-LARAVEL-42) (#653)
## Problem (Sentry PHP-LARAVEL-42)

EnableBanking's `GET /accounts/{id}/transactions` returns **HTTP 422
"Wrong transactions period requested"** when the requested date range is
wider than the bank is willing to serve. The catch-ladder in
`EnableBankingProvider::getTransactions()` only matched
401/EXPIRED_SESSION, 400/AccountNotAccessible and 400/ASPSP_ERROR, so
the 422 rethrew a **raw `RequestException`**. That:

1. escaped the per-account loop in `EnableBankingSyncer::sync` (which
only skips `InaccessibleBankAccountException`), so **every remaining
account in the connection stopped syncing too**;
2. hit the job's generic `catch (\Throwable)` → retried 3×
(deterministic, always the same 422) → connection marked **Error** and
**reported to Sentry**;
3. after the scheduled-retry budget (`consecutive_sync_failures >= 3`)
the whole connection was **dropped from scheduled sync** until a manual
reconnect.

Real user impacted (active connection). The failing request was a
~92-day window on the incremental/linked path.

## Fix (3 commits)

1. **Classify the 422** — new `WrongTransactionsPeriodException`
(`ShouldntReport`) thrown from `getTransactions()` when status is 422
and the message names the period. Also stop logging handled 422s at
`error` level in the HTTP client callback.
2. **Clamp + retry** — on that exception, `TransactionSyncService::sync`
restarts the account from page 1 with a progressively narrower window
(`90 → 30 → 7` days before `date_to`), so the user keeps as much history
as the bank will serve. `strategy='longest'` is dropped on the narrowed
retry so the explicit `date_from` is honoured; re-fetched pages are
idempotent (fingerprint dedup + date-keyed daily balances).
3. **Graceful skip** — if even the narrowest window is refused, the
syncer skips just that account (like an inaccessible account) and keeps
the connection Active, instead of failing the whole sync.

## Why draft (needs a human call, per two review agents)

The crash fix itself is well-covered and safe. What needs sign-off is
the **product tradeoff** the clamp introduces:

- **First-sync history truncation.** First sync requests
`now()->subYear()`. If the bank refuses a year, we narrow to ≤90 days
and there is **no back-fill path** (incremental syncs only move
`date_from` forward), so the skipped history is lost. This is bounded
and logged, but it is a deliberate behaviour change.
- **Incremental catch-up gap.** If the watermark is older than the
bank's servable window, the days between the watermark and the clamp are
never fetched (silent, but logged).
- **Heuristics worth a human eye:** the ladder values `[90, 30, 7]`,
dropping `strategy='longest'` on retry, and detecting the error by
`status 422 + message contains "period"` (ideally confirmed against
EnableBanking docs / a stable error code).

Low-risk per review: duplicate imports (fingerprint + `(account_id,
dedup_fingerprint)` unique index are robust to overlapping windows) and
balances (truncated, not corrupted).

## Testing

- Provider: 422 wrong-period → `WrongTransactionsPeriodException`;
unrelated 422 stays a raw `RequestException`.
- Service: clamps + retries once (asserts the clamped date and the
`strategy` drop); gives up after the ladder is exhausted; does not retry
an already-narrow window.
- Syncer: a refused account is skipped, connection stays
Active/unreported, siblings still sync.
- Full `tests/Feature/OpenBanking` + `tests/Feature/Sync` green (315
tests); Pint and Larastan clean.

Fixes PHP-LARAVEL-42

---
🤖 Opened by the autonomous Sentry-triage loop. Draft on purpose — the
data-truncation tradeoff above is a product decision for a human.
2026-07-07 08:57:23 +02:00
Víctor Falcón a38dc5dd95
fix(sync): don't crash when IndexedDB is unavailable (PHP-LARAVEL-43) (#654)
## Problem (Sentry PHP-LARAVEL-43)

`ReferenceError: Can't find variable: indexedDB` — an unhandled promise
rejection reported from production. Some browsers do not expose the
global `indexedDB` at all (Chrome on iOS in certain webviews,
private/locked-down modes). The app uses Dexie (IndexedDB) as an offline
cache for transactions, and any Dexie operation in that context throws.

Reproduced entry point: on `/register`, submitting the form runs
`transactionSyncService.clearAll()` → `db.transactions.clear()` → Dexie
references the missing global → throws. The same class of crash can fire
from every other cache touch point (`sync`, `getAll`, `getById`,
`getByAccountId`, and the cache eviction in `delete`).

IndexedDB here is only a cache — the API/Inertia is the source of truth
— so a missing store must degrade to "no local cache", never crash.

## Fix (3 commits)

1. **Guard helper** — `isIndexedDbAvailable()` (checked via `globalThis`
so referencing the global never throws) and `withDb(operation,
fallback)` in `dexie-db.ts`. `withDb` returns the fallback when
IndexedDB is missing or the op fails, and defers `db` access into a
closure so the lazy Dexie proxy never initializes on unsupported
browsers.
2. **Sync manager** — every Dexie read/write goes through `withDb`
(reads → empty, writes/`clearAll` → no-op); `sync()` early-returns a
skipped result and avoids the pointless server round-trip.
3. **Delete eviction** — only the best-effort local cache eviction in
`delete()` is guarded; the authoritative `axios.delete` stays outside
the guard so no API mutation is ever swallowed.

## Safety / complexity

- **No behavior change for browsers with IndexedDB** — `withDb` runs the
operation exactly as before (covered by tests).
- **Graceful degradation otherwise** — no crash; the app runs
online-only.
- **No swallowed mutations** — the guard wraps only Dexie calls; all
`axios` calls stay outside it.
- Reviewed by two agents: the crash does not actually block registration
(Inertia does not await `onBefore`), so this is a recurring unhandled
rejection rather than a hard block, and the fix has no user-facing
downside. Low complexity, high confidence → auto-merge.

## Testing

- New `resources/js/lib/sync-manager.test.ts` and
`resources/js/services/transaction-sync.test.ts` (7 tests): IndexedDB
missing → clearAll/sync/reads/delete-eviction no-op and never throw;
IndexedDB present → unchanged behavior.
- `bun run lint` / `bun run format` clean on the changed files. (The
repo's `tsc --noEmit` has pre-existing errors that CI intentionally does
not gate on; no new ones introduced.)

Fixes PHP-LARAVEL-43

---
🤖 Opened by the autonomous Sentry-triage loop. Auto-merge enabled:
additive guard, no behavior change for supported browsers, no data-loss
surface.
2026-07-07 04:59:10 +00:00
Víctor Falcón 3741f11584
ci(release): schedule weekly release every Monday at 10:00 Madrid (#649)
Adds a `schedule` trigger to the existing Release workflow so it runs
automatically.

- **Cron**: `0 8 * * 1` — Mondays 08:00 UTC (= 10:00 Madrid in
summer/CEST, 09:00 in winter/CET).
- **Increment**: defaults to `patch` on scheduled runs
(`workflow_dispatch` still lets you pick patch/minor/major).
- **Empty-week guard**: skips the run when there are no commits since
the last tag, avoiding empty releases.

Note: the release PR is still opened by `github-actions[bot]` with
`GITHUB_TOKEN`, which does not trigger required checks — merging it
stays a manual step unless a PAT/App token is wired in.
2026-07-06 10:02:12 +00: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 84b688b7b7
chore: release v0.2.6 (#647)
Patch release `v0.2.6` (bump + changelog) generated with release-it.

After merge, the `v0.2.6` tag and GitHub release are created on `main`.
2026-07-06 08:45:54 +00:00
Víctor Falcón 465eb38dae
fix(appearance): support MediaQueryList change events on legacy Safari (PHP-LARAVEL-41) (#646)
## What & why

Fixes **PHP-LARAVEL-41** — `TypeError: addEventListener is not a
function` on Safari 13.1.2 / macOS 10.13.

### Root cause
`MediaQueryList.addEventListener` does not exist on Safari <14 (and
other legacy browsers) — the property is `undefined`; those engines only
expose the deprecated `addListener`. `initializeTheme()` runs at app
boot (`resources/js/app.tsx`) and called:

```ts
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
```

The `?.` guarded a **null** query but not the **missing method**, so the
call threw during module evaluation, aborting the rest of boot
(`initializeChartColorScheme`, `createInertiaApp`) → the whole React app
failed to mount (white screen) for those users. `use-mobile.tsx` had the
same latent crash for any component using `useIsMobile`.

## Changes (one concern per commit)
1. **fix(appearance): support MediaQueryList change events on legacy
Safari** — add `resources/js/lib/media-query.ts`
(`addMediaQueryListener` / `removeMediaQueryListener`) that fall back to
`addListener` / `removeListener` when the modern API is absent, and
route the `prefers-color-scheme` (`use-appearance`) and
mobile-breakpoint (`use-mobile`) subscriptions through them. Colocated
Vitest test covers both the modern and legacy-fallback branches for add
and remove.
2. **harden: no-op when neither API is present** — guard the deprecated
fallback with a `typeof … === 'function'` check so a hypothetical
`MediaQueryList` exposing neither API silently no-ops instead of
re-introducing the crash. (Reviewer recommendation.)

Modern-browser behavior is byte-for-byte unchanged (the modern path is
taken whenever `addEventListener` exists); the only added cost is a
`typeof` check.

## Verification
- `vendor/bin/pint`-equivalent for JS: Prettier + ESLint clean on all
changed files.
- Vitest: `resources/js/lib/media-query.test.ts` covers modern + legacy
+ neither-API branches. (CI runs `bun run test`.)
- Reviewed by two independent agents (architecture + product): both
concluded **ship it**, no must-fix. Confirmed no other
`matchMedia(...).addEventListener('change')` call sites were missed
(`account-balance-card.tsx` and `welcome.tsx` only read `.matches`),
correct add/remove pairing preserved, and no modern-browser regression.

## Follow-up (out of scope, flagged for awareness)
- The Vite build sets no explicit `build.target`/browserslist, so the
default baseline is Safari 14. This PR stops the reported **boot**
crash, but lazily-loaded page chunks could still `SyntaxError` on
navigation for Safari 13 users. Full Safari-13 support would need an
explicit lower build target — a broader change with its own risk, left
as a separate follow-up.
2026-07-05 17:03:35 +00:00
Víctor Falcón 05d4bae0af
fix(queue): raise retry_after above the longest job timeout (PHP-LARAVEL-2D) (#645)
## What & why

Fixes **PHP-LARAVEL-2D** — `MaxAttemptsExceededException:
CategorizeUncategorizedTransactionsJob has been attempted too many
times.` (recurring, still firing as of yesterday, ~18 events).

### Root cause
The `database` queue connection used `retry_after = 90s`, but several
jobs declare a `$timeout` far above it (up to **600s**). `retry_after`
is **per-connection**. When a job outlives its 90s reservation, the DB
queue driver hands it to a second worker. For a `tries = 1` job that
immediately raises `MaxAttemptsExceededException` — and, worse, the body
can execute twice. `CategorizeUncategorizedTransactionsJob` runs on the
`ai` queue, which the deploy runs with **two concurrent workers**
(`docker/supervisor/supervisord.conf`), so the double-run re-bills
Gemini and resets progress — the exact harm its `tries = 1` was meant to
prevent.

At `retry_after = 90` **8 jobs** violated the invariant (600s rule jobs,
300s AI jobs, 120s sync jobs).

## Changes (one concern per commit)
1. **fix(queue): raise `retry_after` above the longest job timeout** —
default `90 → 900s` (env-overridable via `DB_QUEUE_RETRY_AFTER`),
clearing the 600s longest job with margin. Adds `QueueConfigTest`
asserting every `app/Jobs` `$timeout` stays `< retry_after` so a new
long job can't silently re-break it.
2. **fix(ai): de-duplicate the backfill per user** — `retry_after` stops
one dispatch being re-reserved, but not a *duplicate* dispatch (double
"Enable AI" click / re-enable mid-run) on the 2-worker `ai` queue.
Implement `ShouldBeUnique` keyed on user id, mirroring the sibling
`RetryTransientAiCategorizationJob`.
3. **test(queue): widen the guard** to also scan queued Mailables
(`app/Mail`) and Notifications (`app/Notifications`), which share the
connection and honor `$timeout`.
4. **docs(queue): correct the `retry_after` comment** — per-job
`$timeout` takes precedence over the worker `--timeout` (which is
currently unset), so the earlier "must sit between" phrasing was
inaccurate.

## Verification
- `QueueConfigTest` fails at the old `retry_after=90` (8 offenders) and
passes at `900`.
- `vendor/bin/pint --test` clean; 131 tests across `tests/Feature/Ai`,
AI-consent flow, and queue config pass.
- Confirmed against the real deploy: 2 workers on the `ai` queue,
`pcntl` installed (SIGALRM enforced), workers set no `--timeout` so each
job's own `$timeout` bounds execution below 900s.

## Reviewed by two independent agents (architecture + product)
Both concluded **ship it**. The double-dispatch gap they flagged is
closed by change #2. Neither found a correctness regression.

## Operational notes (no code change needed, flagged for awareness)
- **Reclaim window:** `retry_after` is per-connection, so
`emails`/`default` also inherit the 90→900s window. A job orphaned by a
*hard* worker crash (OOM/SIGKILL) is now reclaimed after up to ~15 min
instead of ~90s. Graceful deploys (SIGTERM + `stopwaitsecs=3600`) don't
orphan jobs, so this only bites on hard crashes — an acceptable trade. A
dedicated connection for the long AI queue would remove it; left as a
possible follow-up.
- **Worker `--timeout`:** the fix relies on `pcntl` staying installed
and long jobs keeping a `$timeout`. Setting an explicit `--timeout` (<
900) on the supervisord `queue:work` commands would make it
self-enforcing; not done here to avoid changing short-job hang
detection.
- If prod sets `DB_QUEUE_RETRY_AFTER` explicitly, ensure it's above the
longest job timeout (≥ 600, ideally 900).
2026-07-05 09:31:23 +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 26875bbfff
refactor: consolidate duplicated financial calculations (#643)
## Summary

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

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

## Changes (per commit)

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

## Test plan

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

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

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

## Reviewer findings

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

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

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

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

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

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

## Changes (by commit)

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

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

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

## Test plan

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

## Reviewer findings — addressed vs deferred

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

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

Product-bug reviewer verdict: no user-facing regressions. Hiding the 5
fields does not affect Cashier (raw attribute access),
`EncryptionController`, notifications, or any API/JSON path; the
`currency_code` dedup is runtime-identical; the cleanup timing change is
client-absorbed.
2026-07-04 18:57:58 +00:00
Víctor Falcón eccfaa5a7a
refactor(drip): extract base mailable and job for the drip email family (#641)
## Why

The drip email family was near copy-paste. Each of the eight mailables
repeated the same constructor, `$tries`/`$backoff`, `drip_from`
envelope, `markdown` + `userName` content, and `RateLimited` middleware
— differing only in subject and template (plus a reply-to on one, an
extra view var on another). Each of the eight jobs repeated the
`canReceiveEmails` / `hasReceivedEmail` guards, the `Mail::send` call,
and the `UserMailLog::create` block — differing only in the email type,
the mailable, and a handful of per-email eligibility checks.

## What

Two abstract bases, so each subclass declares only what varies:

- **`DripMail`** — owns the shared mailable shape. Subclasses declare
`dripSubject()` and `template()`; optional `contentData()` (PromoCode's
`FOUNDER` var) and `repliesToSender()` (PaywallFollowUp) hooks. The
hooks are named `dripSubject()`/`template()` deliberately to avoid
colliding with `Illuminate\Mail\Mailable`'s existing public
`subject()`/`markdown()` methods.
- **`SendDripEmailJob`** — owns the shared `handle()` flow (shared
guards → send → `UserMailLog`). Subclasses declare `emailType()` and
`buildMail()`; an optional `shouldSend()` hook carries the per-email
eligibility rules (pro-plan, onboarding, transactions, bank connections,
AI consent).

Each mailable drops from ~68 to ~13 lines and each job from ~48 to ~18.
Net **−459 lines**, with the send/log logic and envelope construction
now in one place.

## Behavior

No functional change. Subjects, `from`/`reply-to`, queue name, rate
limiting, templates, view data, and every per-job eligibility guard are
preserved. Notably, passing `replyTo: []` for the seven non-reply mails
is identical to the previous omission — `Envelope`'s `replyTo` defaults
to `[]`. Verified: `pest --exclude-testsuite=Browser` **1872 passing, 0
failing**; `pint --test`, `phpstan` (level 5), `format`, `lint` clean.

## Review-driven follow-up commits

Two review agents (architecture/duplication/coverage, and
behavior/regressions) ran against the first commit. The behavior agent
verified all eight jobs' guard logic invert correctly (order +
negations) and that `replyTo: []` ≡ omitted — no regressions. The
coverage agent found the two hook-driven behaviors were untested;
applied, each as its own commit:

- Extended the drip-sender parity test to all eight mailables and
asserted `PaywallFollowUpEmail`'s reply-to (and that a default drip mail
sets none).
- Asserted `PromoCodeEmail` injects the `FOUNDER` promo code into its
view data.

## Deliberately out of scope

- Non-drip mailables (`Waitlist*`, `Banking*`, `UpdateEmail`, …) share
the same `RateLimited('emails')->releaseAfter(1)` middleware and could
later share a queued-mail base too — separate concern.
- **Pre-existing** (not introduced here, noted by review): the job sends
the mail before writing `UserMailLog`, so a failure between the two can
re-enqueue a duplicate; there is no lock/unique constraint on `(user_id,
email_type)`. Worth a separate hardening pass.
2026-07-04 20:51:38 +02:00
Víctor Falcón 845f51abb5
refactor(open-banking): extract shared connect-controller flow into a base class (#639)
## Why

The five API-key OpenBanking "connect" controllers — Binance, Bitpanda,
Coinbase, Indexa Capital and Interactive Brokers — each carried a
~60-line `store()` that was near-identical: subscription gate →
credential validation → `Bank::firstOrCreate` → create the
`BankingConnection` (Pending) → update it to `AwaitingMapping` with
pending accounts → auto-map during onboarding or redirect to mapping.
Any change to that flow, or a bug in it, had to be made five times.

## What

Extract the shared flow into a small class hierarchy, so each controller
declares only what actually varies per provider.

- **`OpenBankingConnectController`** (abstract) owns the whole flow in a
`connect()` template method, with per-provider extension points:
`provider()`, `providerName()`, `bankLogo()`, `aspspCountry()`,
`fetchProviderData()`, `credentialErrorMessage()`,
`buildPendingAccounts()`, and an optional `emptyProviderDataMessage()`
guard.
- **`CryptoPortfolioConnectController`** (abstract, extends the above)
implements the two hooks the crypto providers share: `aspspCountry()`
(from the request) and the single "Crypto Portfolio"
`buildPendingAccounts()` (uid derived from the provider enum value, so
the generated uids are unchanged).
- The five controllers shrink to their genuine differences (client,
names, logo, error copy, and — for IB — the Flex error mapping and
empty-statement guard).

Net **−110 lines** across the five `store()` methods, and the connection
lifecycle now lives in one place.

## Behavior

No functional change. The credential-failure warning log is now a single
structured message with a `provider` key instead of five free-text
variants (the only observable difference; HTTP responses are
byte-for-byte identical). Verified by the full suite: **1869 passing, 0
failing** (`--exclude-testsuite=Browser`); `pint --test`, `format` and
`lint` clean.

## Review-driven follow-up commits

Two review agents (architecture/duplication/coverage, and
behavior/regressions) ran against the first commit. Applied, each as its
own commit:

- Finished the crypto dedup via the intermediate base (the reviewers
flagged the still-duplicated crypto hooks).
- Dropped the over-engineered per-provider
`validationFailureLogMessage()` hook in favor of a structured base log.
- Added the missing test for the Interactive Brokers empty-statement
guard (the only conditional hook, previously unexercised): asserts 422 +
NAV-section message, no connection row, and no warning logged.

## Deliberately out of scope

- **Wise** is left as-is: it builds pending accounts mid-flow (needs the
client), creates the connection in one step, and has its own
empty-portfolio guard — it does not fit this template.
- **Pre-existing** (not introduced here, noted by review):
`Bank::firstOrCreate` returns an existing bank without refreshing its
logo, so `aspsp_logo` can copy a stale/null value. Worth a separate fix.
2026-07-04 20:24:54 +02:00
Víctor Falcón 79b8d27ece
docs(claude): add laravel/ai package and ai-sdk-development skill to Boost guidelines (#638)
Adds the `laravel/ai` (v0) package and the `ai-sdk-development` skill to
the Laravel Boost guidelines section of CLAUDE.md, reflecting the
current installed ecosystem.
2026-07-04 17:34:11 +00:00
Víctor Falcón 3972007844
fix(discord): show old → new plan on plan change notification (#637)
## Problem

The Discord **Plan changed** notification only showed the *new* plan
value in the `Changed` field, so it was impossible to tell what actually
changed:

```
Changed
Plan: €3.99 / month
```

Was it a price change? An interval change? From which plan? No way to
know.

## Fix

Compute the previous plan from Stripe's `previous_attributes` and render
it as `old → new`, matching the existing status-change behaviour:

```
Changed
Plan: €9.99 / month → €3.99 / month
```

`planLabel()` was generalised to read both the modern
`items.data[].price` shape and the legacy top-level `plan` shape Stripe
still includes in the diff, so the same helper works on both the current
object and the `previous_attributes` diff. Falls back to the old `Plan:
X` output when no previous value is detectable.

## Test

Added a feature test asserting the `old → new` output for a
`customer.subscription.updated` plan change. All 11 tests in the file
pass.
2026-07-04 15:49:09 +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 c159e8782e
fix(ai): surface learned-rule toast in edit modal and guard weak description keys (#635)
## Why

Two issues surfaced from a real mis-categorization corrected via the
**Edit Transaction** modal (not the table quick-edit).

### 1. The modal never told the user the AI learned

Both the table quick-edit and the edit modal hit the same backend
(`PATCH /transactions/{id}` → `CategoryOverrideHandler` →
`AiRuleLearner`), so both learn a forward rule from a correction. But
the modal **discarded** the update response and never read
`learned_rule`:

- No *"Learned: similar transactions will be categorized automatically"*
confirmation.
- No **Undo**.
- It still showed the generic *"Automatize"* prompt — offering to create
a rule that already exists.

The modal now reads `learned_rule` and mirrors the table's toast + undo,
skipping the automatize prompt when a rule was learned. Same i18n
strings as the table (no new keys).

### 2. The learner could key a rule on a single weak token

A correction on `"Pago en MADRID SUC 04 MADRID ES"` produced a rule
keyed on `suc` alone. The tokenizer drops noise by **per-user** document
frequency, so `madrid` (20% of this user's descriptions) is correctly
dropped — but `suc` (0.14%) survives, even though it is a generic
banking abbreviation that matches broadly as a substring. The per-user
filter can't see that `suc` is generic in general.

`AiRuleLearner::descriptionClause()` now refuses a single distinctive
token shorter than 5 characters. It learns only when there are **two or
more** distinctive tokens, or a single token of **at least 5
characters**. Merchant-keyed rules are unaffected.

## Tests

- `does not learn a description rule from a single short token`
- `learns a description rule from a single sufficiently long token`
- `learns a description rule from two short tokens`

All `AiRuleLearnerTest` pass (13/13).

## Not in this PR

The one already-learned prod rule still carries the stale `suc`
clause/title (low impact, matches ~2 txns). Cleaning that data is a
separate manual step.
2026-07-04 11:00:15 +00:00
Víctor Falcón 477e4d50e2
feat(encryption): commands to warn and remove inactive encrypted-data accounts (#633)
## What

Two artisan commands to retire the remaining legacy client-side
encrypted data in production (transactions with
`description_iv`/`notes_iv`, accounts with `name_iv`).

- **`encryption:notify-removal`** — queues a re-engagement/deletion
warning to every user who still has an encrypted transaction or account.
Reuses `SendUpdateEmailJob` (queue `emails`, dedup via `UserMailLog`,
50/day pacing). The email is framed as an **inactivity** cleanup (no
mention of encryption) and pitches recent additions (AI insights, bank
integrations, etc.).
- **`encryption:delete-accounts`** — soft-deletes and anonymizes
(`User::markAsDeleted()`) those users **only** if they have not signed
in within the grace window (`--days`, default 7, via `last_active_at`).
Anyone who came back is spared, and the demo account is always excluded.

Both support `--dry-run` and `--force`, and share the target query via
the `FindsUsersWithLegacyEncryption` concern so the warning and the
deletion always target the same set.

## Intended manual run

`notify-removal --dry-run` → run for real → wait 7 days →
`delete-accounts --dry-run` → run for real.

## Notes / scope

- Deletion is a **soft-delete + email anonymization** (accounts become
unusable); it does not hard-purge rows, and does not cancel Stripe
subscriptions or revoke bank connections (unlike `user:delete`). Legacy
inactive accounts are unlikely to have either; handle separately if
needed.
- Email copy is plain English (matches the existing `mail/updates/*`
convention), no new `__()` keys.

## Tests

`tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php` and
`DeleteEncryptedDataAccountsCommandTest.php` — 6 passing (targeting,
queue, dry-run, grace window, demo exclusion).
2026-07-03 15:57:04 +00:00
Víctor Falcón afa80b60fe
fix(accounts): remove double-skeleton flash on account show (#634)
## Problem

Since #632 the account show page loads its transaction ledger via a
**deferred** Inertia prop. On first load of a transactional account the
user saw **two different loading skeletons back-to-back**, then the
table — a visible shape jump:

1. During the deferred network round-trip, `<Deferred>` rendered an
8-plain-bar fallback.
2. Once data arrived, `<TransactionList>` mounted with `isLoading`
initialized to `true` and rendered its **own, differently-shaped**
internal table skeleton for ~1 frame.
3. Then the real table.

Sequence: `plain-bars skeleton → table-shaped skeleton (~1 frame) →
table`.

## Fix

- Extract the internal table skeleton into a shared, exported
`TransactionListSkeleton`.
- Use it for **both** the `<Deferred>` fallback (`Accounts/Show.tsx`)
**and** `TransactionList`'s own `isLoading` branch — one consistent
skeleton, zero duplication.
- Initialize `isLoading` from `providedTransactions === undefined`, so
the redundant loading frame is dropped when data is already present at
mount.

### Before → After

| Phase | Before | After |
|-------|--------|-------|
| Deferred fetch | 8 plain bars | table-shaped skeleton |
| TransactionList mount | table-shaped skeleton (~1 frame) | (none —
data already present) |
| Loaded | table | table |

Result: a single, consistent, table-shaped loading state — no shape
jump.

## Shared-component safety

`TransactionList` is also used by the **budgets** show page
(`budgets/show.tsx`), which always passes a resolved `transactions`
array behind its own loading gate — so the `isLoading` init change keeps
that path rendering the table immediately. The transactions **index**
page has its own table and does not use `TransactionList`. Accounts with
zero transactions still render an empty table (unchanged).

## Tests

- New `transaction-list.test.tsx` asserts `TransactionListSkeleton`
renders the table-shaped skeleton (bordered container + many
placeholders), i.e. not the old plain-bars shape.
- `Accounts/Show.test.tsx` mock updated to export
`TransactionListSkeleton`; existing tests stay green.
- `bun run format`, `bun run lint` clean. No new TypeScript errors
introduced (pre-existing count unchanged).
2026-07-03 15:56:49 +00:00
Víctor Falcón 9326d8fd2f
perf(accounts): defer the account ledger prop on show (#632)
## Problem

`AccountController@show` (added in #631) loaded the **full transaction
ledger** for an account into the initial Inertia payload on every visit
— `transactions()->with(['category','labels'])->get()`. For accounts
with years of movements that's several MB, all on the critical render
path, blocking first paint. The `ponytail:` comment flagged the
deferred/cursor move as a follow-up; this PR does it.

## Fix

Wrap the `transactions` prop in `Inertia::defer(...)` so the page shell
(header, chart, actions) paints immediately and the ledger arrives in a
follow-up request behind a skeleton. Mirrors how `index()` already
defers `accountMetrics` and how `dashboard.tsx` consumes `<Deferred>`.

The ledger **stays the whole set on purpose** — search and filtering run
client-side over *decrypted* rows (privacy-first design), so the client
needs every row. Cursor/offset pagination isn't viable without breaking
search over encrypted fields, so deferred is the right lever: it moves
the cost off the critical path rather than reducing bytes.

Non-transactional account types
(`loan`/`investment`/`retirement`/`real_estate`, i.e.
`!hasTransactionLedger()`) return a plain `[]` and never render
`<Deferred>`, so they take no extra round-trip.

## Testing

- `AccountControllerTest`: the "includes transactions" case migrated
from `->has('transactions', 2)` to
`->missing('transactions')->loadDeferredProps(...)`, proving the prop is
deferred yet resolves.
- `Show.test.tsx`: added coverage that creating a transaction reloads
**only** the deferred prop (`router.reload({ only: ['transactions'] })`)
— this refresh became load-bearing on deferred-prop semantics once the
remount `key` was dropped in #631.
- `php artisan test` (AccountController + PageQueryCount): 45 passed.
Vitest Show: 3 passed. Pint + ESLint clean.

## Reviewer notes

- **Known minor (not fixed):** brief loading-skeleton shape change on
first load — the `<Deferred>` fallback (plain bars) differs from
`TransactionList`'s own internal table skeleton, so there's a ~1-frame
visual jump. Aligning them would mean either duplicating the table
skeleton or adding a prop to the shared component; judged not worth it
for a sub-frame cosmetic nit.
- **Out of scope (pre-existing, from #631, flagged during review):**
since #631 removed the remount `key`, an active filter/search now
persists across a create-reload (a new non-matching row can look "not
created"); and in-memory search/sort currently operates on
still-encrypted rows pending the plaintext migration. Neither is
introduced or worsened here.
2026-07-03 17:40:08 +02:00
Anshul Nitin 4fdb7eebd9
fix: address remaining security audit findings (round 2) (#628)
## Summary

Follow-up to #623 — addresses security findings **not covered** by the
maintainer's draft PR #627. The original scope was larger; out-of-scope
changes were reverted (see `revert: drop out-of-scope changes from
security round 2`) and are **no longer part of this PR** (details under
_Reverted / not included_).

## Changes

### Rate limiting
- `routes/api.php`: authenticated API group throttled at `300,1` (per
user). An SPA with offline sync + multi-widget dashboards fans out many
requests per interaction, so a tighter cap throttled legitimate bursts.
- `routes/web.php`: unauthenticated open-banking callback throttled at
`30,1` (per IP), enough headroom for browser retries and the iOS PWA →
Safari hand-off that can fire the callback twice.
- `use-decrypt-transactions.ts`: the legacy decrypt migration loops
without backoff (~3 requests per 100 encrypted transactions), so a large
account could hit the API throttle and abort the whole migration
silently. It now honours Laravel's `Retry-After` header on 429 and
retries the same request instead of bailing.

### State token persistence on failure
- `AuthorizationController::callback`: clears `state_token` on the
connection when EnableBanking session creation fails, preventing a stale
token from being replayed.

### Trust proxies hardening
- `bootstrap/app.php`: replaced `trustProxies(at: '*')` with an
env-based `TRUSTED_PROXIES` config. When unset, no proxy is trusted
(Laravel default) instead of trusting every IP.
- ⚠️ **Deploy requirement:** production runs behind a reverse proxy /
TLS terminator (`Dockerfile.production` + nginx serve HTTP on :80). With
`TRUSTED_PROXIES` unset, Laravel stops trusting
`X-Forwarded-For`/`X-Forwarded-Proto` → client IPs collapse to the proxy
IP (contaminating logs and per-IP throttling) and HTTPS detection
breaks. `TRUSTED_PROXIES` **must** be set in the production environment
before merge, and should be added to `.env.production.example`.

### XSS-safe Blade-to-JS injection
- `app.blade.php`: replaced `{{ }}` Blade syntax inside `<script>` with
`@json()` to prevent JS injection via variable content.

### TOCTOU defense-in-depth
- `Api/TransactionController::bulkUpdate`: added `->where('user_id',
$userId)` to the per-row UPDATE as belt-and-suspenders behind the
existing `abort(403)` ownership pre-check.

## Reverted / not included

The following were in the original description but were reverted and are
**not** in the current diff:
- `block-demo` middleware on additional route groups (already present
upstream on `routes/settings.php`).
- `->limit(500)` / `has_more` / `since` validation on
`TransactionSyncController`.

## Notes
- `vite.config.ts` appears in the diff only because of an older
merge-base; it is identical to `main` and nets to no change (will vanish
on rebase).
- No tests yet for the `state_token` clearing on `createSession` failure
or for the throttles — worth adding before merge (the callback throttle
is the most exposed surface).

---------

Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-07-03 15:04:03 +00:00
Víctor Falcón 021cb66643
feat(transactions): serve import dedup and account ledger from the backend (#631)
## Summary

Migrates the last two flows off the deprecated client-side
IndexedDB/Dexie "offline-first" model so they read from the backend
instead of the local database. The Dexie cache was already being
dismantled and is no longer the source of truth for the transaction
list; these two consumers were the last ones still tied to it, and the
local read could silently return incomplete data.

**Duplicate detection on import** and **the account detail transaction
list** now both come from the server.

### What changed

- **Import duplicate detection** → new `POST
/api/transactions/check-duplicates`. Matches on day + amount (integer
cents) + normalized description server-side, replacing
`sync()`-then-read-Dexie, which silently missed duplicates that fell
outside the local cache window.
- **Account detail transaction list** → served as an Inertia prop from
`AccountController@show` (eager-loading `category` + `labels`) instead
of fetching every transaction from `/api/sync/transactions` and
filtering client-side (which could show an incomplete list for accounts
with long history).
- **Import preview "existing transactions" panel** → reads from `GET
/api/transactions?account_id=…&per_page=10` (the index gains an
`account_id` filter + clamped `per_page`) instead of Dexie.
- `TransactionList` now renders only from the provided prop; the
`/api/sync` fetch path, the Dexie-backed search, and the `refreshKey`
refetch are removed (search is in-memory; create triggers a scoped
`router.reload({ only: ['transactions'] })`).

### Commits

1. `feat` — the backend migration (endpoints, controller prop, frontend
rewiring, tests).
2. `refactor` — centralize the "has transaction ledger" rule in
`AccountType::hasTransactionLedger()` (a single named concept the
frontend mirrors, documented as distinct from `isNonTransactional()`),
and add an `id` tiebreak for deterministic pagination.
3. `fix` — drop rows reassigned to another account from the account list
(they lingered now that the list no longer refetches).
4. `fix` — match Unicode whitespace (e.g. non-breaking spaces) in
server-side duplicate detection, matching the old JS behavior; hardens
endpoint tests.

## Review notes

Two review agents (architecture/quality and bugs/correctness) reviewed
the change. Applied: the `AccountType` single-source-of-truth, stable
pagination sort, the account-reassignment stale-row fix, the
Unicode-whitespace parity fix, and extra edge-case tests (validation,
date-range boundary, cross-user scoping).

**Deferred (follow-up):** `AccountController@show` still loads the
account's full transaction history into the Inertia payload. It's
acceptable now (single account, strictly better than the old
load-all-accounts approach) and marked with a `ponytail:` comment, but
accounts with very long history should move to a deferred +
cursor-paginated prop (the new `?account_id=` endpoint can back it).
Also, the deprecated Dexie/sync-manager stack (`sync()`, `getAll`,
`getByAccountId`, `/api/sync/transactions`) now has fewer callers and
can be retired in a dedicated cleanup.

## Test plan

- `tests/Feature/Api/TransactionDuplicateCheckTest.php` — matching,
normalization (incl. NBSP), date-range boundary, account scoping, IDOR,
validation.
- `tests/Feature/Api/TransactionIndexTest.php` — `account_id` filter +
newest-first order, `per_page` cap and lower clamp, cross-user scoping.
- `tests/Feature/AccountControllerTest.php` — the account-show
`transactions` prop (own vs other accounts, non-transactional accounts).
- Existing `DecryptTransactionsTest` (the other `/api/transactions`
consumer) unaffected.
- Frontend vitest suites for the touched areas pass.

Draft while the deferred payload/cleanup items are discussed.
2026-07-03 16:49:59 +02:00
Víctor Falcón c370abcd6d
chore(sentry): migrate Vite source map upload from Bugsink to Sentry (#630)
## What

Migrate the Vite source map upload from the decommissioned Bugsink
instance to Sentry. Bugsink is no longer used error tracking is fully on
Sentry.

- Point `sentryVitePlugin` at the Sentry EU region
(`https://de.sentry.io/`), org `whisper-money`, project `php-laravel`.
- Read the auth token from `process.env.SENTRY_AUTH_TOKEN` instead of
hardcoding it.
- Remove the hardcoded Bugsink URL, org, project, and auth token.

Source maps go to `php-laravel` (not `frontend`) because frontend JS
errors are captured there app.tsx initializes Sentry with
`SENTRY_LARAVEL_DSN`, and the live JS issues (e.g. `PHP-LARAVEL-2Y`,
platform `javascript`, `assets/app-*.js` frames) confirm it.

## Source map leak fix

A single flag (`SENTRY_AUTH_TOKEN` present) now gates **both** map
generation and upload, so the "maps generated but never deleted" leak
into `public/build/assets/` can no longer happen:

- **With token** (production CI): `sourcemap: 'hidden'` → maps are
emitted, uploaded, then deleted. `'hidden'` omits the `sourceMappingURL`
comment, so browsers never fetch them even in the window before
deletion.
- **Without token** (local/dev/CI): `sourcemap: false` → no maps
emitted. Nothing to leak, nothing to upload.

## Follow-ups (outside this file)

- [ ] **Rotate the old Bugsink token** (`b0f46d1b…a2e8a`) it is removed
here but remains in git history.
- [ ] **Provide `SENTRY_AUTH_TOKEN` to the production build** so uploads
actually run. `Dockerfile.production` runs `bun run build:ssr` without
it today; inject it via a BuildKit `--mount=type=secret` (not an `ARG`,
which persists in image history). Until then uploads are skipped safely,
but frontend stacks stay minified.
2026-07-03 13:36:13 +00:00
Víctor Falcón 4615d7a880
fix(worktree): remove double slash in storage/keys copy path (#629)
Fixes a typo in `worktree.sh` where the source path had a double slash
(`$ROOT_PATH//storage/keys`) when copying signing keys into a new
worktree.
2026-07-03 13:23:45 +00:00
Víctor Falcón d55c3f41df
fix(security): scope job-status endpoints to owner + feature-area fixes (#627)
## Summary

Starts from a security finding on the background-job status endpoints
and folds in the most important issues surfaced by a follow-up review of
the same feature area (categorization backfill, bulk rule re-evaluation,
automation-rule apply).

Each change is its own commit.

## Changes

### 1. `fix(security)` — scope job-status cache keys to the owning user
The categorization, bulk re-evaluation, and apply status endpoints
looked jobs up by a bare job UUID with no user scoping. Any
authenticated user who obtained another user's job id could poll its
progress payload. Cache keys now include the owning user's id, so a
status request keyed by the polling user's id resolves only that user's
own jobs — a mismatched owner falls through to the existing 404. No
ownership store or extra lookup. Cross-user isolation tests added for
all three endpoints.

### 2. `fix(automation-rules)` — re-check `only_uncategorized` at apply
time
The apply flow cached a match snapshot for up to 15 min (keyed only by
`rule.updated_at`) and applied the rule's category to every id in it
without re-checking eligibility. A transaction categorized *after* the
snapshot (by the user, a sibling rule, or a concurrent AI backfill) was
silently re-categorized and stamped `category_source=Rule`. Now
re-filtered through `shouldSkipForOnlyUncategorized()` at apply time;
skipped rows are no longer counted as changed.

### 3. `perf(automation-rules)` — memoize rule set per user in
`applyRules()`
Bulk re-evaluation calls `applyRules()` once per transaction, and each
call re-queried the user's whole rule set + labels — an N+1 scaling with
transaction count (the sibling apply job already loaded rules once).
Memoized per user for the service instance's lifetime (resolved fresh
per job, so rules created mid-run are intentionally not seen).
Query-count test asserts one `automation_rules` query regardless of
transaction count.

### 4. `fix(transactions)` — stop polling when the job never starts
The re-evaluate and apply pollers rescheduled on any non-terminal
status, including `pending`. If the queue worker is down the job never
runs, `failed()` never fires, and the client polled for the full
hour-long TTL with a stuck spinner. Now gives up after 30 consecutive
`pending` ticks (~30s), mirroring the guard the AI-categorization poller
already has. Long `processing` runs are unaffected.

### 5. `test(automation-rules)` — cover apply job execution and failure
branches
`ApplySingleAutomationRuleJob` was only asserted to be *pushed*; its
`handle()` body and `failed()` branch had no coverage, and
`ReEvaluateTransactionRulesJob::failed()` was untested too. Added direct
`handle()`/`failed()` tests pinning the progress-cache payloads.

## Deferred follow-ups (surfaced by review, not in this PR)

- **Consolidate the three frontend pollers onto `usePollJobStatus`** —
they hand-roll `setTimeout` loops and don't tear down on unmount (can
`setState`/`onClose` after the dialog closes). The hook already exists
and is unmount-safe; routing all three through it would also make the
`pending` cap unit-testable. The cap in change #4 currently mirrors the
already-shipped AI poller and is not separately unit-tested.
- **`noteAlreadyPresent()` uses a substring match**
(`AutomationRuleService`) — a rule note that is a substring of an
existing note is silently not appended. Should compare note lines
exactly.
- **No dedup/lock on concurrent apply/re-evaluate jobs** — note appends
are a non-atomic read-then-write, so two concurrent jobs can duplicate a
note. Category/label writes are already idempotent.
- **Transient 404 → false "failed"** — a mid-run cache
eviction/TTL-expiry (or the deploy window of change #1, where an
in-flight job's pre-deploy key is orphaned) makes the poller surface a
false failure. Retry a few times on transient errors before giving up.
- **`matches()` pagination** advances `next_offset` by fetched rows, not
page-window size — deleted ids can stall infinite scroll before reaching
`total`.
- **"Apply to N" count / encrypted-skip counts** can be stale or
under-report vs what's actually processed.
- **Job-trait convention drift** between the three jobs (modern
`Queueable` vs legacy trait stack).

## Testing
- `vendor/bin/pint --test` — pass
- `bun run format` / `bun run lint` — pass (one pre-existing unrelated
warning in `chart.tsx`)
- Affected Pest suites (apply, re-evaluate, evaluation, rule,
categorization, apply-rule-suggestions) — 94 tests pass
2026-07-03 14:49:32 +02:00
Víctor Falcón eb31455e60
fix: skip demo reset when demo account is disabled (#626)
## What

`demo:reset` now bails out early with an info message when
`config('app.demo.enabled')` is falsy, instead of rebuilding the demo
account regardless of the `DEMO_ENABLED` flag.

## Why

The `DEMO_ENABLED` config existed but the command ignored it, so a
scheduled reset would recreate demo data on environments where the demo
account is turned off.
2026-07-03 07:27:43 +00:00
Víctor Falcón a8d28bfcc1
test(drip): deflake AI consent onboarding-grace boundary test (#625)
## Problem

`SendAiConsentFollowUpEmailsCommandTest > it treats consent exactly
three days after signup as onboarding` fails intermittently on `main`
(more likely under parallel load):

```
The unexpected [App\Jobs\Drip\SendAiConsentFollowUpEmailJob] job was dispatched.
```

## Root cause

The `userWithConsent()` helper builds two timestamps from separate
`now()` calls:

```php
User::factory()->create(['created_at' => now()->subDays($signedUpDaysAgo)]); // now() at T1
$user->aiConsents()->create(['accepted_at' => now()->subDays($acceptedDaysAgo)]); // now() at T2 > T1
```

The command treats consent given more than `ONBOARDING_GRACE_DAYS` (3)
after signup as a post-onboarding opt-in:

```php
if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(3))) {
    continue; // skip
}
```

For the boundary case (`signedUpDaysAgo: 5, acceptedDaysAgo: 2`),
`created_at + 3 days = T1 - 2 days` and `accepted_at = T2 - 2 days`.
Because `T2 > T1` by microseconds, `accepted_at` lands just *after* the
grace boundary, the `<=` returns `false`, and the job is dispatched —
failing the assertion.

## Fix

Freeze time for the test file so all day-relative timestamps land on
exact boundaries. The command logic is correct and unchanged.

## Testing

```
php artisan test tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php
# 9 passed
```
2026-07-03 07:06:20 +00:00
Víctor Falcón 3d3f6daa77
perf(ai): reduce N+1 in bulk category updates (PHP-LARAVEL-40, partial) (#624)
> **Draft on purpose — a partial fix + a design for the full one.**
These commits are safe, behavior-preserving reductions of the N+1, but
they do **not** fully resolve PHP-LARAVEL-40. The complete fix is a
batch-aware refactor of the AI rule-learning path, which is delicate
(getting it wrong silently mis-categorizes users' transactions). I've
written the design below for a human to implement and diff against the
current per-transaction behavior. Current user impact is low (~170 ms
request, 1 event), so there's no urgency to rush the risky part.

## What

Sentry **PHP-LARAVEL-40** — N+1 in `TransactionController@bulkUpdate`
(PATCH `/transactions/bulk`). A bulk category update calls
`CategoryOverrideHandler::record()` once per transaction, and each call
runs the full AI rule-learning pipeline
(`AiRuleLearner::forgetFromAiRules()` + `learnFromCorrection()`):
loading all the user's AI rules, plucking every description, running
matcher `count(*)` probes, and inserting a `category_corrections` row.
For a 112-transaction batch that's ~112× each. The offending span is the
per-transaction `category_corrections` insert.

## What this branch does (safe, partial)

Two behavior-preserving reductions, each validated by the existing
learning tests:

1. `perf(transactions): resolve the override handler once for bulk
updates` — `bulkUpdate()` re-resolved `CategoryOverrideHandler` from the
container every iteration. Resolve it once (also required for #2 to take
effect).
2. `perf(ai): memoize the description corpus per user in AiRuleLearner`
— `learnFromCorrection` reloaded and re-tokenized every one of the
user's descriptions on each transaction. The corpus is immutable while
only categories change, so memoize the document-frequency map + count
per user. Removes one `SELECT` (and its tokenization) per transaction.
3. `test(ai): assert batch corrections learn correctly through the
memoized corpus` — proves the second, memoized-corpus learning still
yields a correct, distinct clause (not just that the query count
dropped).
4. `test(ai): guard AiRuleLearner against a singleton binding` — the
cache has no invalidation and is only safe while the learner is resolved
fresh per request; a test now fails if it is ever bound singleton.

## What it does NOT do

It does **not** resolve the flagged N+1. Still per-transaction: the
`category_corrections` insert, `forgetFromAiRules`' reload of all AI
rules, the matcher `total()`/`countMatchingAll()` overbroad probes,
`releaseClauseFromOtherCorrectionRules`, and `existingCorrectionRule`. A
learnable transaction still costs ~6–10 queries. Treat PHP-LARAVEL-40 as
**reduced, not closed** (hence no `Fixes` keyword).

## Reviewed by two independent agents (architecture +
product/correctness)

Both verified the two perf commits are **behavior-preserving and safe**:
the memo cannot go stale (nothing in the correction path mutates
descriptions; the bulk `update()` only writes
`category_id`/`category_source`/`ai_confidence`/`categorized_by_rule_id`,
and runs after the loop), no cross-user leak (keyed by user_id; learner
is transient, one user per request, no Octane), and the handler is
stateless. The existing 34 learning tests exercise the corpus→rule
computation on fresh loads.

## Proposed full fix (for a human to implement)

Exploit that a bulk update sends **all transactions to the same category
for one user.** Add `CategoryOverrideHandler::recordBulk(Collection,
?string)` backed by `AiRuleLearner::learnFromCorrections(array,
string)`; load user-level data once, mutate in memory, write once:

1. **Batch-resolve** `categorized_by_rule_id` via one `whereIn` instead
of per-txn `find()`; apply the **identical** per-txn learnable test
against the map.
2. **Batch-insert** corrections: snapshot each AI-driven txn's *old*
`from_category_id`/`source`/`confidence` during the loop, collect rows,
one insert. (Note: raw `insert()` skips model events/UUID/timestamps —
verify `CategoryCorrection` has no `creating` hook, else chunked
`create` in one transaction.)
3. **Forget once**: union all learnable txns' merchant tokens, load AI
rules once, apply the **whole union** to each rule *before* the
delete-vs-save decision, save/delete each once.
4. **Learn once**: compute each clause (merchant or memoized-corpus
tokens), dedupe within the batch and against the target rule, run
`releaseClauseFromOtherCorrectionRules` for the union once, load/create
the single target correction rule once, append all, save once.
5. Wrap 2–4 in one `DB::transaction`, and route the single-txn path
through the same method (one-element collection) so the two can't drift.

**Correctness invariants to preserve (call these out in review):**
- Apply-all-then-decide for both `forget` and `releaseClause` deletes —
an incremental forget/save/forget would delete a rule a later txn's
clause should have kept.
- Corrections must read each txn's **old** category/source/confidence —
run before the bulk `update()`, as today.
- Clause dedup must match `appendClause`'s loose `==` array comparison.
- Validate with a golden-set test diffing learned/forgotten rules
before/after against the current per-txn path over a mixed batch
(merchant txns, description txns, encrypted/no-merchant skips,
re-corrections that move a key between categories).

## Testing

- `tests/Feature/Ai/` + `BulkUpdateTransactionsTest` + IDOR/decrypt:
153/153 green. `pint` clean.

Refs PHP-LARAVEL-40
2026-07-03 07:52:19 +02:00
Víctor Falcón ad46e465be
perf(db): index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) (#622)
## What

Addresses Sentry **PHP-LARAVEL-3X** — a slow DB query in
`App\Jobs\SendDailyBankTransactionsSyncedEmailJob::handle()`.

The daily "transactions synced" email filters transactions by:

```php
Transaction::query()
    ->where('user_id', $this->user->id)
    ->where('source', TransactionSource::EnableBanking)
    ->when($lastSentMailLog?->sent_at, fn ($q, $at) => $q->where('created_at', '>', $at))
    ->whereHas('account.bankingConnection', ...) // EXISTS on PKs
    ->get();
```

The only `user_id`-leading index is `idx_transactions_budget_lookup
(user_id, transaction_date, category_id)` — its second column is
`transaction_date`, **not** `created_at`, so it can't serve
`source`/`created_at`.

## How

Add a composite index matching the predicate — two equalities then the
range:

```
idx_transactions_user_source_created (user_id, source, created_at)
```

The `whereHas` compiles to correlated `EXISTS` subqueries that join on
primary keys (`accounts.id`, `banking_connections.id`), so no extra
index on those tables is needed — the `transactions` index alone gives
the optimizer the selective driving path it currently lacks.

## Production verification (via read-only prod queries)

Confirmed the diagnosis and de-risked the deploy against the live
database:

- **The query is genuinely slow.** `EXPLAIN ANALYZE` for the heaviest
user (~6.1k EnableBanking transactions) runs in **~6 s**. The optimizer,
lacking a selective path, drives from a **full table scan of all ~2,500
accounts** and examines ~108k transaction rows (334 account loops × ~323
rows), filtering `user_id`/`source` only afterwards.
- **The index fixes it.** `(user_id, source, created_at)` lets the
optimizer drive from transactions — a seek to `(user_id,
'enablebanking')` (~6.1k rows) plus primary-key semi-joins — far cheaper
than the current ~108k-row plan, so it will be chosen.
- **The deploy is low-risk.** `transactions` holds **~297k rows** (not
millions). Adding a secondary index on InnoDB/MySQL 8 is online
(`ALGORITHM=INPLACE, LOCK=NONE`, no table rebuild), so at this size the
build is seconds and does not block the sync write path.

## Commits

1. `perf(db): index transactions for the daily synced-email query` — the
migration + a test asserting the index columns/order.
2. `test(db): assert the daily-email index name, not just its columns` —
lock the explicit index name (review feedback).

## Reviewed by two independent agents (architecture +
product/operational risk)

Both rated the change correct and shippable: column order right
(equalities before the range), migration reversible, index non-redundant
(an existing index can't be widened without breaking budget queries),
and no behavior/result change (a secondary btree index never alters
result sets; the job has no `ORDER BY`).

## Impact / risk

- **User impact:** indirect — a background email job (Sentry reports 0
interactive users), but the ~6 s query wastes queue-worker time and IO
on every run.
- **Complexity:** low — one additive, reversible index migration; no
application logic changed.
- **Write path:** one more index maintained per transaction insert (10th
on the table). Marginal, consistent with the existing UUID-leading index
cost profile.

Fixes PHP-LARAVEL-3X
2026-07-02 15:51:15 +02:00
Víctor Falcón 84bad76316
perf(banking): kill per-transaction dedup N+1 in bank sync (PHP-LARAVEL-3Y) (#621)
## What

Fixes the N+1 flagged by Sentry **PHP-LARAVEL-3Y** in
`App\Jobs\SyncBankingConnectionJob`.

`TransactionSyncService::importTransaction()` ran one `exists()` probe
**per incoming bank transaction** to detect duplicates:

```sql
select exists(
  select * from transactions
  where account_id = ?
    and (dedup_fingerprint = ? or external_transaction_id = ?)
) as exists
```

A sync importing N transactions issued N dedup `SELECT`s. As transaction
volume per sync grows, so does the query count.

## How

Preload the account's existing dedup keys **once** at the start of
`sync()` and match against in-memory sets:

- `loadExistingDedupKeys()` streams (`cursor()`) the two dedup columns
for the account — including soft-deleted rows (`withTrashed()`) — into
two keyed sets. One query instead of N.
- `importTransaction()` checks membership in memory (`isset()`), an
exact mirror of the old `fingerprint OR external_id` predicate.
- Keys from successful inserts are folded back into the sets, so
duplicates **within the same sync** (e.g. the same transaction repeated
across pages) are still caught in memory.
- The `(account_id, dedup_fingerprint)` unique index +
`UniqueConstraintViolationException` catch still backstop concurrent
syncs (`SyncBankingConnectionJob` is already `ShouldBeUnique` per
connection).

## Commits

1. `perf(banking): preload dedup keys to kill per-transaction N+1` — the
core fix + a query-count regression test.
2. `perf(banking): stream dedup key preload with a cursor` — avoids
buffering every historical row into a Collection on top of the sets
(memory guard for very large accounts).
3. `fix(banking): match external ids case-insensitively in dedup` — the
old query compared `external_transaction_id` under the production
`utf8mb4_unicode_ci` collation; the in-memory `isset()` is byte-exact.
With no unique index on `external_transaction_id`, a legacy id stored as
`ABC` vs an incoming `abc` would have silently double-imported.
Normalized with `mb_strtolower()` on both sides + a test.
4. `test(banking): cover intra-run cross-page dedup` — new coverage for
the same transaction appearing on two pages of one sync.

## Testing

- 303/303 in `tests/Feature/OpenBanking/` green; full backend suite
green except one **pre-existing, unrelated** failure on `main`
(`DashboardTest` returns 409 locally — an Inertia asset-version artifact
that resolves once `bun run build` runs in CI).
- New tests: dedup runs as a single preload SELECT regardless of batch
size; case-insensitive external-id dedup; cross-page intra-run dedup.
- `pint` clean; `larastan` clean on the changed file.

## Reviewed by two independent agents (architecture +
product/correctness)

Both rated the change behavior-preserving and shippable. Follow-ups they
surfaced, intentionally **not** bundled here to keep this fix focused
and low-risk:

- **`WiseTransactionSyncService` has the identical latent N+1**
(`:149-158`). It is not currently firing in Sentry (lower volume). Worth
a separate PR — possibly extracting a shared dedup-set helper once both
call sites need it.
- **Residual collation divergence**: accent/width folding from
`utf8mb4_unicode_ci` is not replicated in PHP. Irrelevant for real bank
reference ids (ASCII), accepted.
- The test comment referencing a "cleanup command" that repoints orphan
fingerprinted rows describes a command that does not exist in the repo —
pre-existing, worth correcting separately.

Fixes PHP-LARAVEL-3Y
2026-07-02 13:34:29 +00:00
Víctor Falcón 0f8eca50d0
fix: stop double-dispatching transaction listeners (N+1 insert into jobs) (#620)
## What & why

Fixes the Sentry N+1 **PHP-LARAVEL-3V** — `N+1 insert into jobs` on
`POST /transactions`.

### Root cause
Laravel's **event discovery is on by default** (the base
`EventServiceProvider` sets `$shouldDiscoverEvents = true`, and this app
has no subclass to turn it off), so every listener in `app/Listeners` is
already wired to its event by its `handle()` type-hint.
`AppServiceProvider::boot()` was **also** registering six of them with
`Event::listen(...)`, so each ended up registered **twice** — every
queued listener published its job twice per event.

Concretely, creating a transaction dispatched
`AssignTransactionToBudget` and `CategorizeTransactionWithAi` twice each
→ repeated `insert into jobs` in one request. (The same bug made
`PostStripeEventToDiscord` post to Discord twice per webhook,
`ApplyAutomationRules` run twice, etc.)

## Changes
1. **`AppServiceProvider`** — drop the redundant explicit
`Event::listen` calls and rely on discovery, which already wires every
listener exactly once (`ScheduleDripEmailsListener` and
`SyncUserToResendListener` were discovery-only already). Verified with
`php artisan event:list`: each event now has a single listener, and the
synchronous `ApplyAutomationRules` still runs first on
`TransactionCreated`.
2. **`CategorizeTransactionWithAi`** — add `shouldQueue()` so the job
isn't even published when it would immediately no-op (already
categorized by a rule, encrypted, or user not AI-eligible — the default,
since AI is opt-in). The same guard still runs in `handle()` because
state can change while the job waits.

Together these remove the second `insert into jobs` on transaction
creation for the common (non-AI) path.

## Tests
- New `tests/Feature/Ai/CategorizeTransactionWithAiListenerTest.php`:
the AI job is queued **once** for an eligible uncategorized transaction,
and **not at all** when the user has no AI consent or the transaction is
already categorized.
- Full suite: **1828 passing**. The one failure (`DashboardTest` → 409)
is **pre-existing on `main`** (Inertia asset-version mismatch from a
local build manifest) and reproduces with these changes reverted —
unrelated to this PR.

Fixes PHP-LARAVEL-3V
2026-07-02 13:26:45 +00:00
Víctor Falcón 4120e12861
refactor(ai): remove AiConsentSettings feature flag (#619)
## What

Removes the `AiConsentSettings` Laravel Pennant feature flag. The AI
consent management UI — the toggle in **Billing settings** and the
banner in **Transactions** — is now available to all users
automatically, no longer gated behind the flag.

The underlying AI consent functionality (model, controller, routes,
`User` consent methods) is unchanged; only the gate that hid its UI was
removed.

## Changes

- Delete `app/Features/AiConsentSettings.php`.
- `HandleInertiaRequests`: drop the `aiConsentSettings` shared prop and
its resolution.
- Frontend: render `AiConsentSection` and the transactions consent
banner unconditionally; drop the now-unused `aiConsentSettings` type and
`features` destructuring.
- Tests: remove the two flag-specific assertions in
`AiConsentSettingsTest` (consent-state coverage kept), update
`InertiaSharedDataTest`, and switch `FeatureEnableCommandTest` to
`CalculateBalancesOnImport` as its example feature.

## Testing

- `php artisan test` on the affected suites — 13 passed.
- `vendor/bin/pint`, `bun run format`, `bun run lint` — clean.
2026-07-01 09:47:55 +02: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 e631cbba69
fix(settings): vertically center rows in automation rules and labels tables (#615)
## What

Row contents in the **Automation rules** and **Labels** settings tables
were not vertically centered — the title, badges and the actions ("…")
button sat slightly above the row's vertical center.

## Why

The shared `TableCell` defaults to `align-top`, while the actions button
is centered. That mismatch pushed the text/badges up relative to the
menu. Overriding the data cells with `align-middle` in both tables makes
every element line up.

## Changes

- `resources/js/pages/settings/automation-rules.tsx` — `align-middle` on
body cells
- `resources/js/pages/settings/labels.tsx` — `align-middle` on body
cells

## Before / After

**Automation rules**

![Automation rules row
alignment](https://raw.githubusercontent.com/whisper-money/whisper-money/automations-table/.github/screenshots/automation-rules-row-alignment.png)

**Labels**

![Labels row
alignment](https://raw.githubusercontent.com/whisper-money/whisper-money/automations-table/.github/screenshots/labels-row-alignment.png)
2026-06-30 10:37:20 +00:00
Víctor Falcón a37481fb71
fix(charts): improve contrast for chart colors 9-10 (#614)
## What

Adjust the highest-index chart color variables so adjacent segments stay
distinguishable:

- **Light mode:** `--chart-9`/`--chart-10` use darker zinc shades
(`zinc-700`/`zinc-600`) instead of the near-white `zinc-100`/`zinc-50`,
which were invisible against the light background.
- **Dark mode:** `--chart-7`/`--chart-8` use lighter zinc shades
(`zinc-200`/`zinc-300`) instead of the near-black `zinc-800`/`zinc-900`.

## Why

Charts with many categories were rendering low-index and high-index
segments at nearly the same value as the background, making them
unreadable.
2026-06-30 10:28:03 +00: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 986f43705a
fix(analysis): respect category types like the cashflow screen (#612)
## Why

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

## What changed

Three commits:

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

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

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

## Tests

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

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

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

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

## How

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

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

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

## Notes

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

## Tests

- `NewTransactionsMarkerTest` (feature): null marker on first visit +
stores newest served; later visit sees previous marker + advances
forward; marker never moves backward.
- `new-transactions.test.ts` (`isNewSince`), Pint, ESLint, Prettier all
pass.
2026-06-29 19:11:37 +02:00
Víctor Falcón 884038c13b
feat(transactions): highlight new transactions since last visit (#609)
## What

Between visits, highlight the transactions that arrived since the user
last opened the list.

- The newest `created_at` from the initial page load is stored in
`localStorage` (`transactions-last-visit`), frozen at mount.
- Every transaction whose `created_at` is newer than that marker gets a
subtle accent on the row (left border + faint tint).

## Why per-row instead of a single divider line

The first cut drew one "Last visit" divider with new rows above it. A
code review found this is structurally wrong: the list is sorted by
`transaction_date` (when the money moved), but "new" is defined by
`created_at` (when the row was inserted). The two don't correlate — a
bank sync that imports a batch dated across several days scatters the
new rows throughout the list, so a single boundary lands at the first
older row and **hides every new row below it**, the exact miss the
feature was meant to prevent.

Per-row marking is correct regardless of sort order: no new transaction
can be hidden.

## Notes / limitations

- The marker is written **once at mount** from the initial payload. Rows
that arrive mid-visit (load-more, a sync/refresh, AI categorization) do
not advance it, so they are never recorded as "seen" before the user has
seen them. The trade-off is they may re-appear as new next visit (errs
on showing, never hiding).
- Purely client-side (localStorage), so the marker is per-device.

## Tests

- Unit tests for `isNewSince` and `newestCreatedAt`.
- `LocalizationTest`, ESLint, Prettier all pass.
2026-06-29 16:08:14 +00:00
Víctor Falcón 5ef3e01c89
fix(transactions): let date column size to its content (#610)
## What

Remove the fixed `max-w-[90px]` cap (and `pr-1`) on the transaction date
cell so the date column sizes to its content instead of wrapping
awkwardly.

## Why

The 90px cap forced the formatted date to wrap onto two lines in the
transactions table.
2026-06-29 15:51:51 +00:00
Víctor Falcón 300756e553
feat(stats): add --no-discord to the remaining report commands (#607)
## What

Follow-up to the `--no-discord` flag added to `stats:experiment-funnel`.
Extends the same flag to the **other stats report commands that post to
Discord**, so any of them can be run on demand (e.g. inside the prod
container) and just printed to the console without posting:

```bash
php artisan stats:subscription-funnel --no-discord
php artisan stats:ai-cohort-report   --no-discord
php artisan stats:stuck-cohort-report --no-discord
php artisan stats:daily-report       --no-discord
```

## How

- The two **funnel** commands (`subscription-funnel`) already printed
their table to the console, so `--no-discord` just short-circuits the
`->send()`.
- The **cohort/daily** commands only ever built a Discord embed. A small
`App\Console\Commands\Concerns\RendersReportToConsole` trait renders an
embed (title / description / fields) as plain text, so `--no-discord`
prints a readable report to the console instead of posting.
- Default behaviour is unchanged: without the flag (and on the scheduled
weekly/daily runs) they post to Discord exactly as before.

## Tests

A `--no-discord` test added to each command (`Http::assertNothingSent()`
+ the command succeeds). Pint + Larastan + all four command suites
green.

> Note: the commands query the default DB connection, so for
**production** numbers run them inside the prod container (Coolify
terminal).
2026-06-29 15:32:31 +02:00
Víctor Falcón 1db2871398
feat(stats): add --no-discord flag to stats:experiment-funnel (#606)
## What

Adds a `--no-discord` flag to `stats:experiment-funnel` so the
per-variant report can be run **on demand (e.g. in production) and only
printed to the console**, without posting to the Discord channel.

```bash
php artisan stats:experiment-funnel --no-discord
```

## Why

Handy for ad-hoc checks of the live experiment without spamming the
ops/Stripe Discord channel. The scheduled weekly run (and any run
without the flag) still posts to Discord exactly as before — default
behaviour is unchanged.

## Notes
- The console table already prints before the Discord step; the flag
just short-circuits the `->send()` and prints `Skipped Discord
(--no-discord).`
- Test added: with `--no-discord`, the command succeeds, prints the
table, and `Http::assertNothingSent()`.
- Pint + Larastan + the command's Pest suite green.

> Tip: the command queries the default DB connection, so to see
**production** numbers run it inside the prod container (e.g. via the
Coolify terminal).
2026-06-29 12:31:29 +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 8bbff05b26
fix(banking): only log sync failures once the connection gives up (#603)
## Why

Production log dashboards were flooded with paired `Banking sync failed`
+ `EnableBanking API error` warnings for a handful of users, recurring
on every 6-hour `banking:sync` cycle for days. Investigation (prod
`BankingSyncLog` + connection state) showed the affected connections are
**healthy and syncing daily** (`last_synced_at` = today,
`consecutive_sync_failures = 0`): the first attempt hits a transient
`ASPSP_ERROR` / `429`, and the job's retry recovers it.

So the warnings are **noise, not breakage** — but
`SyncBankingConnectionJob` logged `Banking sync failed` inside the catch
block on *every* attempt, including non-final ones that later succeed.
That's one warning per cycle for connections that sync fine, which reads
as a failure and causes alert fatigue.

## What

Gate the `Log::log(...)` call so it only fires when the connection
actually gives up:

- the **final attempt** (`attempts() >= tries`), or
- a **permanent auth error** (`isAuthError`).

Transient errors recovered by the retry are no longer logged.
Per-attempt traceability is unchanged: `BankingSyncLog` still records
every attempt (Success / Failed) in the database.

## Tests

`tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`:
- non-final attempt → `Log::log` is **not** called
- final attempt → `Log::log('error', 'Banking sync failed', ...)` **is**
called once

Full file: 24/24 passing.
2026-06-27 16:04:36 +00:00
Víctor Falcón bc57eae5c3
fix(open-banking): stop storing the XXX no-currency placeholder on accounts (#602)
## Why

Prod warning logs were flooded with `Exchange rate not found, returning
unconverted amount` (≈500 lines from a single dashboard load). The
`ExchangeRateService` was behaving correctly — the source currency was
the ISO 4217 placeholder **`XXX`** ("no currency"), which has no
exchange rate, so conversions silently fell back to an **unconverted
(wrong) amount**.

Root cause: when importing accounts from a banking connection (Enable
Banking can report `currency: "XXX"`), the code did
`$accountData['currency'] ?? 'EUR'`. The `??` only catches
`null`/missing, not the literal `"XXX"`, so the placeholder was
persisted as `currency_code`.

Prod data confirmed `XXX` is the **only** account currency not covered
by the rates table (342 currencies, incl. BTC/VES/exotics, are all
present): 18 accounts (16 from bank links, 2 manual) across 14 users,
plus **5 users** whose base currency had itself become `XXX` (a
first-account `XXX` propagates to the user via `syncFromFirstAccount`).

## What

1. **Fix the source** —
`AccountUserCurrencyService::resolveImportedCurrency()` resolves a
bank-reported currency to: bank value → user base currency → app default
(`cashier.currency`), treating `XXX`/empty/missing as "no currency".
Wired into both creation paths (`CreatesAccountsFromPending`,
`AccountMappingController`); also covers Interactive Brokers imports.
2. **Backfill** — migration fixes existing rows in order: `XXX` users →
app default first, then `XXX` accounts → their owner's currency.

## Tests

- `AccountUserCurrencyServiceTest` — resolver chain (valid / uppercasing
/ XXX·empty·null → user / both missing → default).
- `AccountMappingTest` — mapping flow falls back to the user currency
when the bank reports `XXX`.
- `BackfillXxxAccountCurrenciesTest` — migration resolves both XXX
owners and XXX accounts end-to-end.

23 tests green; Pint and Larastan clean.
2026-06-27 16:01:21 +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 756b4816a6
feat(stats): add weekly subscription funnel report (#599)
## What

Adds a weekly **registration → subscription → paid** funnel so we can
baseline subscription conversion and, later, measure A/B tests against
it.

- `stats:subscription-funnel` artisan command — prints the cohort table
and posts it to Discord (same webhook as the other weekly reports).
- `SubscriptionFunnelCollector` — builds the age-normalized weekly
cohort series.
- Scheduled weekly, Mondays 09:15, next to the existing cohort reports.

## How the funnel is defined

Every stage is measured from each user's **own signup**, so weekly
cohorts are compared at the same age regardless of the calendar.

- **Registered** — signups that week.
- **Subscribed** — started a `default` plan ≤30d after signup (entered
checkout/trial).
- **Paid** — that plan billed past the 15d trial: currently `active`, or
`canceled` only after outliving the trial. `trial_ends_at` is cleared on
cancel, so subscription lifespan is the reliable "did it ever bill"
signal.

Both stages are bounded by the same subscription-creation window, which
guarantees the funnel invariant **registered ≥ subscribed ≥ paid** (a
test enforces it — I caught and fixed a window bug where `paid` could
exceed `subscribed`). Cohorts too young to score show `pend`; signup
surges (launch/marketing waves) are flagged with .

## Current baseline (prod, 2026-06-27)

| Stage | Count | Rate |
|---|---|---|
| Registered | 1,289 | — |
| Subscribed | 158 | 12.3% of registered |
| Trial resolved | 128 | conversion denominator |
| Paid after trial | 91 | **71%** of resolved · 7.1% of registered |
| Currently active | 75 | 82% of payers retained |

⚠️ ~940 of the 1,289 signups arrived in the last ~5 weeks (a
late-May/June surge); their trials haven't fully resolved, so
cohort-level conversion for that wave isn't measurable yet. The 71% is
status-based — a clean cohort baseline lands in ~4–6 weeks.

## Tests

5 Pest tests covering cohort counting, the attribution window, the
funnel invariant, maturity gating, and Discord delivery. All green, Pint
clean.
2026-06-27 15:40:11 +02: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 4038e60fbc
fix(ai): handle transient AI provider overloads — stop the Sentry noise and retry the dropped work (#595)
## Why

Sentry issue **PHP-LARAVEL-3S** (`ProviderOverloadedException: AI
provider [gemini] is overloaded`) recurs whenever Gemini returns 503/429
under high demand — 7 events in 3h during the last surge, 0 users
impacted.

Two problems behind it:

1. **Sentry noise.** `CategorizeTransactions::resolveChunkWithRetry`
retries, `laravel/ai` fails over providers, and a still-failing chunk is
deliberately dropped so the rest of the backfill proceeds. But the catch
reported **every** dropped chunk via `report()`, so an expected,
self-healing transient condition floods Sentry and buries real bugs.
2. **Silently lost work.** A dropped chunk leaves those transactions
uncategorized (`category_id NULL`) with nothing to re-trigger them: the
backfill jobs are one-shot (`tries = 1`), there is no scheduled
backfill, and the real-time listener only handles *new* transactions.
They stay uncategorized until someone manually re-runs a backfill.

## What

**1. Stop reporting transient failures.** Catch `FailoverableException`
(the marker interface for `ProviderOverloadedException` /
`RateLimitedException`) separately and log a warning instead of
reporting it. Everything else still goes to `report()` unchanged, so
real failures (malformed responses, insufficient credits, …) keep
surfacing.

**2. Retry the dropped work.** On a transient failure, schedule a
deferred, per-user `RetryTransientAiCategorizationJob` that re-reads the
user's still-pending transactions once the provider has had time to
recover (`ai_categorization.retry_delay`, default 10 min).
- `ShouldBeUnique` per user collapses a surge of dropped chunks into a
**single** retry.
- The unique lock is held through processing, so a retry that overloads
again **cannot chain another** — exactly one deferred attempt per
failure, no infinite loop. Failed 503s aren't billed.
- Wired in `resolve()`, so it covers every entry point (backfill,
onboarding, real-time listener, admin command).
- Model cost is negligible (per config), so the retry re-runs the
existing backfill rather than tracking which exact chunks failed.

## Tests

- Transient overload → chunk dropped, nothing reported, retry scheduled
for the user.
- Unexpected failure → reported, **no** retry scheduled.
- Retry job → categorizes still-pending transactions for a consenting
user; no-op without consent (agent never prompted).

Full `tests/Feature/Ai` + listeners suite green (111 tests); Pint clean.

## Existing prod backlog

The transactions already dropped before this ships stay `pending`; an
`ai:categorize-backfill <user>` recovers them on demand.

Fixes PHP-LARAVEL-3S
2026-06-26 20:07:06 +02:00
Víctor Falcón d6ec9830df
feat(transactions): refine new transaction form layout and balance toggle (#597)
## What

Three tweaks to the **Add Transaction** form:

1. **Account selector moved to the top** of the create form, above the
date field.
2. **"Update account balance" checkbox hidden for connected accounts**
(accounts with a `banking_connection_id`). Their balance is kept in sync
by the banking connection, so the submit also skips the balance update
for them.
3. **Amount placeholder** now shows a negative example `-25.00` instead
of `0.00`.

## Testing

- Added a unit test covering that the checkbox is hidden for a connected
account.
- `bun run test edit-transaction-dialog` — 5 passed.
- `bun run format` / `bun run lint` clean (only a pre-existing warning
in `chart.tsx`).
2026-06-26 17:59:44 +00:00
Víctor Falcón 934d834ab3
feat(email): follow up after post-onboarding AI consent (#596)
## What

Adds a lifecycle email sent **2 days after a user records AI consent**,
but only when that consent was given **outside of onboarding** — i.e.
more than 3 days after sign-up. This targets users who deliberately
opted into AI later (e.g. via the billing toggle / transactions banner
gated by the `AiConsentSettings` flag), not those who consented during
initial setup.

## How

- **`email:ai-consent-follow-up`** scheduled command (daily, 10:15
Europe/Madrid) selects active consents accepted exactly 2 days ago,
skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and
queues a job per user.
- **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a
`UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the
existing drip system — **no migration**.
- Email is localised automatically via the user's `HasLocalePreference`,
like every other drip email.

## Design notes

- **Scheduled command, not a 2-day delayed job** — survives worker
restarts and respects consent revoked in the meantime.
- **Exact "2 days ago" window**, matching the existing paywall drip. If
the scheduler misses a day that cohort isn't retried; kept consistent
with the other drips on purpose.
- Copy is a first draft — open to wording tweaks.

## Tests

`tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests
covering the happy path, onboarding exclusion (incl. the exact-3-days
boundary), timing window, revoked consent, the disabled-drip gate,
single-send + mail log, no-resend, and locale rendering. All green, Pint
clean.
2026-06-26 17:56:06 +00:00
Víctor Falcón 291cfbe261
feat(ai): record the model behind each AI categorization (#594)
## Why

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

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

## What

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

## Testing

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

## Follow-ups (not in this PR)

- Pin `AI_CATEGORIZATION_MODEL` to an explicit cheaper tier (mirror the
existing `AI_SUGGESTIONS_MODEL=gemini-2.5-flash-lite`) and kill the
floating alias in the config defaults.
- Capture token usage from the SDK response for cost tracking.
2026-06-26 12:53:55 +00:00
Víctor Falcón 094ff4d5ac
feat(banking): enable Interactive Brokers for all users (#593)
## What

Removes the `InteractiveBrokers` Pennant feature flag. The integration
is now always available to every user.

## Changes

- Delete the `App\Features\InteractiveBrokers` flag class.
- Drop the per-user `abort_unless(...->active(...))` gate from
`InteractiveBrokersController`.
- Stop sharing the `interactiveBrokers` flag in Inertia props
(`HandleInertiaRequests`) and remove it from the `Features` type.
- Remove the now-unused `feature` gating mechanism from the
connect-provider registry and `useConnectFlow` — Interactive Brokers was
its only consumer.
- Update tests: drop the "blocked when flag off" case and all flag
activations; the connect dialog now asserts IB is always offered.

The Interactive Brokers integration itself (provider enum, client,
syncer) is untouched.

## Testing

- `php artisan test InteractiveBrokersControllerTest
InertiaSharedDataTest` — 17 passed
- `vitest connect-account-dialog` — 7 passed
- `bun run lint` / `bun run format` — clean
2026-06-26 11:03:21 +02:00
Víctor Falcón f72e2a64ca
feat(features): support percentage rollouts in feature:enable (#592)
## What

`feature:enable <feature> <target>` now accepts a percentage like `25%`
as the target, in addition to a user email or `all`.

```bash
php artisan feature:enable AiConsentSettings 25%
```

This activates the feature for a random `ceil(total * N/100)` sample of
the **current** users, persisted in Pennant's `features` table — no need
to edit the feature class and redeploy for a percentage-based rollout.

## Notes

- It targets the current user base. New users keep the feature's default
(`false`) unless the command is run again.
- Percentage is validated to be between 1 and 100.
- `feature:disable` was left unchanged — disabling for a random subset
has no clear use case.

## Test plan

- [x] `feature:enable AiConsentSettings 40%` enables the feature for
exactly 4 of 10 users
- [x] An out-of-range percentage (`0%`) fails
2026-06-25 10:46:30 +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 578a9b44d8
fix(banking): keep the native green Wise logo, not the aggregator's (#590)
## Context

Follow-up to #589. In production, the deduped Wise entry rendered with
the **Enable Banking blue wordmark** instead of our own green "W" mark,
even though clicking it correctly starts our native (personal-token)
integration.

## Cause

#589 set the native Wise provider's `logo` to the Enable Banking brand
URL (`https://enablebanking.com/brands/BE/Wise/`) and deleted our local
asset — based on the mistaken belief that the green mark came from the
aggregator. It was the other way around: our asset
(`public/images/banks/logos/wise.png`, added in #525) **was** the green
mark; the Enable Banking URL serves the old blue wordmark.

## Fix

- Restore the green Wise asset.
- Point the native provider's `logo` back at
`/images/banks/logos/wise.png`.
- Update the regression test's expected logo accordingly.

Only the `logo` field was ever wrong — the dedup and unique-key fixes
from #589 are unaffected, so a single Wise entry still surfaces our
native integration.
2026-06-24 09:34:37 +02:00
Víctor Falcón ed5aac0c4a
fix(banking): stop Wise appearing multiple times in the connect list (#589)
## Problem

In production, searching for a bank like **Wise** in the *Connect Bank
Account* picker showed it many times, and **the count grew every time
the search box changed** (4 → 6 → …). Only one entry — the real native
integration — should appear.

## Root cause

Two bugs stacked on top of each other:

1. **Non-unique React key.** The list rendered each institution with
`key={institution.name}`. Wise is offered both natively (our own
API-token integration) and by the Enable Banking aggregator, so two rows
shared the key `"Wise"`. On every re-render of the filtered list, React
couldn't reconcile the duplicate keys and **leaked orphaned DOM nodes**
instead of replacing them — so the entry multiplied with each keystroke
(and rendered out of alphabetical order). React even warned: *"two
children with the same key, Wise … may cause children to be
duplicated"*.
2. **No dedup against native integrations.** Even without the growth,
Wise showed twice (aggregator + native). It should only surface through
our own integration.

The bank list is built from the Enable Banking API merged with our
native providers — it does **not** read from the `banks` table, which
was a red herring (only 2 non-duplicated Wise rows there).

## Changes

- Unique key (`name-country-index`) in both the dialog and inline
pickers.
- Drop aggregator institutions we already integrate natively, so Wise
only surfaces through its own integration (general — also covers
Binance, Coinbase, etc.).
- Point the native Wise logo at the official brand mark; remove the
now-unused local asset.

## Tests

Added regression tests covering dedup and repeated filtering. Reproduced
the bug first (counts grew `[3,4,5,6,7]`), then confirmed the fix holds
it at `1`. Full JS suite green (242 tests).
2026-06-23 16:20:59 +02:00
Víctor Falcón 619ed0f1db
feat(banking): let Wise credentials be updated (#588)
## What

Makes **Wise** connections support credential updates, like every other
API-key provider.

Wise was the only API-key provider not wired into the credential-update
path:
`ConnectionController::validateProviderCredentials()` had no Wise arm,
so it
fell to the `Unsupported provider` default, and the update dialog
(driven by the
provider registry's `updatable` flag) rendered nothing. A Wise
connection in an
auth-error state therefore showed an **Update Credentials** button that
opened
an empty, unusable dialog.

## Fix

- Add the Wise validation arm in `validateProviderCredentials`
  (`WiseClient::getProfiles()`). The validation rules and the
`api_token` → column mapping already come from `BankingProvider`'s
credential
  registry, so nothing else on the backend changes.
- Drop the now-unused `updatable` flag from the connect-providers
registry —
every connect provider is updatable — and simplify
`update-credentials-dialog`
  to render fields for any registry provider.

## Tests

- Updating Wise credentials with a valid token succeeds (status back to
active,
  token stored, sync dispatched).
- `BankingProviderTest`: every API-key provider declares credential
fields,
  guarding against adding a provider without an update path again.

Follow-up to #581 (this branch is off the updated `main`).
2026-06-23 09:54:31 +00: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 8e3871370a
fix(transactions): pad Category column when Date column is hidden (#585)
## Problem

On `/transactions`, hiding the Date column left the Category column
flush against the table edge (`pl-0`), despite #582/#583 trying to fix
exactly this.

## Root cause

The padding fix (commit `0424c737`) drove the Category left padding from
`isDateHidden`, but only wired it into `transaction-list.tsx` (used by
Accounts and budgets). The `/transactions` page builds its **own** table
and called `createTransactionColumns` without `isDateHidden`, so it
always defaulted to `false` → `pl-0`.

## Fix

Pass `isDateHidden: columnVisibility.transaction_date === false` (and
add `columnVisibility` to the `useMemo` deps) on the `/transactions`
page, mirroring `transaction-list.tsx`.

## Tests

`transaction-columns.test.tsx` already covers the `pl-0`/`pl-2` branch
driven by `isDateHidden`; the bug was missing wiring, not logic.
Verified manually on `/transactions` with the Date column hidden.
2026-06-22 17:21:15 +00:00
Víctor Falcón d2806b5887
fix(transactions): pad Category column when Date column is hidden (#584)
Closes #583

When the Date column is toggled off, the Category column becomes the
leftmost data column. It carried a static `pl-0` (to sit tight after
Date), so it ended up flush against the table edge with no left padding
— visible at `< md` widths where the `select` checkbox column is hidden.

#582 tried a CSS `first:pl-2`, but the `select` cell (`hidden
md:table-cell`) is `display:none` yet still the DOM `:first-child`, so
`first:` never matches Category. This drives the padding from the Date
column's visibility instead: keep `pl-0` while Date is shown, restore
`pl-2` when it is hidden (replacing the inert `first:pl-2`).

## Test
`createTransactionColumns` unit test asserting the Category
`cellClassName` flips `pl-0` ↔ `pl-2` with `isDateHidden`.
2026-06-22 16:51:50 +00:00
Víctor Falcón ae6f869611
feat(transactions): release transaction analysis to all users (#579)
## Summary

Removes the `TransactionAnalysis` Pennant feature flag and all its
gating. After this PR the transaction analysis feature (analysis drawer
+ saved filters) is available to every user.

## Changes

- Delete `app/Features/TransactionAnalysis.php`.
- `TransactionAnalysisController` — drop the `abort_unless(...403)` flag
gate and Pennant imports.
- `HandleInertiaRequests` — stop sharing the `transactionAnalysis` flag
in Inertia props.
- `Features` TS type — remove the `transactionAnalysis` field.
- `transaction-actions-menu.tsx` / `transaction-filters.tsx` — remove
the `features.transactionAnalysis &&` gates so the Analysis button, the
analysis drawer, and saved filters always render.
- Tests updated: dropped the endpoint-gating test and the "hidden when
flag off" UI test; adjusted shared-flag expectations.

## Verification

- `./vendor/bin/pest` — 23 passed (affected suites)
- Vitest — affected component/page tests pass
- Pint, Prettier, ESLint clean

## Notes

- The orphaned value left in Pennant's `features` DB table is harmless
and not addressed here.
2026-06-22 16:09:09 +02:00
Víctor Falcón d11aa2dfe5
fix(transactions): restore left padding when category is first column (#582)
## What

The category column in the transactions table drops its left padding
(`pl-0`) so it aligns flush when preceded by other columns. But when the
user hides the columns to its left (select, date), the category becomes
the first column and loses the table's default left padding, sitting
flush against the table edge.

## Fix

Add a `first:pl-2` Tailwind variant to the category cell. When the cell
is `:first-child` (no column to its left), it restores the default
`pl-2` padding; otherwise it keeps `pl-0`. The `:first-child`
pseudo-class gives it higher specificity than `pl-0`, so it wins only in
that case.

Columns hidden via view options are removed from the DOM (TanStack), so
the category is `:first-child` exactly when it genuinely has no column
to its left. Applies to both the header and body cells, since both
consume `meta.cellClassName`.
2026-06-22 14:03:44 +00:00
Víctor Falcón a346566fd0
feat(demo): gate demo account access behind a config flag (#580)
## What

Adds a `DEMO_ENABLED` env var (`config('app.demo.enabled')`, default
`true`) to fully toggle the demo account. Setting `DEMO_ENABLED=false`
in production blocks it without code changes.

When disabled:
- **Login is blocked** — `Fortify::authenticateUsing` rejects the demo
account with a generic credentials error (doesn't reveal the demo is
off). Regular users and 2FA are unaffected.
- **Landing link hidden** — the "Check Demo" button on the landing page
is removed (shared `demoEnabled` prop).
- **No credential prefill** — `demoCredentials` is only shared when
enabled, so `/login?demo=1` no longer autofills.

## Why

The demo account is publicly shared and gets abused (e.g. duplicate
votes on integration requests). This gives us a kill switch.

## Tests

Added to `DemoAccountRestrictionsTest`:
- demo account cannot log in when disabled
- demo account can log in when enabled
- regular user can still log in when demo is disabled

Existing auth + 2FA tests still pass.
2026-06-22 11:01:27 +00:00
Víctor Falcón 5db6cdc6d2
fix(accounts): stop second long-press haptic on drag handle (#578)
## What

Reordering accounts (dashboard + accounts page) buzzed **twice** when
you tap-and-hold the drag handle on mobile: once immediately, then a
second time after a short delay.

The immediate buzz is ours — `trigger('selection')` on the handle's
`pointerdown` (added in #576). The delayed second buzz is **Android
Chrome's native long-press haptic** (~500ms): pressing and holding any
interactive element fires the system long-press/context-menu feedback.

It can't be the drag itself — the `PointerSensor` activates by
**distance** (8px), not time, so a delayed buzz on a *stationary* hold
can only come from the platform's long-press gesture. `touch-action:
none` stops scrolling/panning but not the long-press menu.

## Changes

- `SortableGrid`: add `select-none` and a prevented `onContextMenu` to
the drag handle, opting it out of the native long-press gesture (and its
haptic).

## Testing

- Added `sortable-grid.test.tsx`: asserts the selection haptic fires
exactly once on `pointerdown`, and that the handle prevents the
`contextmenu` default.
- The native long-press haptic itself can't run in jsdom — needs a
manual check on a real Android device: tap-and-hold the handle should
vibrate once (on touch), with no second buzz.
2026-06-22 11:20:57 +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 c75e834b89
fix(accounts): fire drag haptic on first tap, not after dragging (#576)
## What

On mobile, reordering accounts (dashboard + accounts page) triggered the
selection haptic only **after** the drag actually started — i.e. after
the `PointerSensor`'s 8px activation distance was met. This felt like
you had to long-press/drag before the vibration kicked in.

This moves the haptic to the drag handle's `pointerdown`, so it fires
the moment the handle is touched. dnd-kit's own `onPointerDown` is
chained afterwards so dragging keeps working.

## Changes

- `SortableGrid`: drop `onDragStart` haptic on `DndContext`.
- `SortableItem`: accept an `onActivate` callback and fire it on the
handle's `onPointerDown`, then delegate to dnd-kit's listener.

## Testing

Haptics rely on `web-haptics` (`navigator.vibrate`), which doesn't run
in jsdom, so this needs a manual check on a real mobile device: tapping
the drag handle should vibrate immediately.
2026-06-22 07:09:46 +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 0ea30a3ce8
ci: remove production deploy job (#574)
Removes the `deploy` job from CI. Deployment is no longer triggered from
GitHub Actions.

The job handled the Coolify webhook trigger and Sentry release tracking
on pushes to `main`. Build/test/lint/static-analysis and the image build
jobs are untouched.
2026-06-20 18:17:14 +00:00
Víctor Falcón 57747ef34c
ci: simplify Coolify deploy trigger to a single curl (#573)
## What

Replace the ~60-line bash retry loop in the deploy step with native curl
flags.

## Why

The old block reimplemented in shell what curl does natively (timeouts,
retries, error handling), plus manual body/timing parsing with a marker
separator.

- `--max-time 15` caps each attempt so the job can't hang for ~20 min on
an unreachable box.
- `--retry-all-errors` turns a timed-out attempt into the next retry (a
plain `--retry` doesn't always treat exit 28 as retryable).
- `--fail` keeps a webhook error (4xx/5xx) from passing the step
silently.

## Notes

- Now uses the `COOLIFY_WEBHOOK_URL` secret instead of
`DEPLOYMENT_TOKEN` + hardcoded uuid URL. **This secret must exist in
GitHub Actions before merge or the deploy breaks.**
- Drops the 10–20 min retry waits that gave a prior rebuild time to
finish. Fine if the webhook queues the deploy; add back if it rejects
requests during an in-progress rebuild.
2026-06-20 17:40:09 +00:00
Víctor Falcón 0f3cdd41aa
feat(open-banking): enable manage bank accounts for everyone (#572)
## Summary

Removes the `ManageBankAccounts` Pennant feature flag that gated the
per-connection "Manage Accounts" surface to the admin user, making the
flow available to all users.

## Changes

- Delete the `App\Features\ManageBankAccounts` feature class.
- `ConnectionAccountController`: drop the `ensureFeatureEnabled()` gate
(3 call sites + method) and the `Feature`/`ManageBankAccounts` imports.
Access stays guarded by `authorizeConnection()` (connection ownership).
- `HandleInertiaRequests`: stop sharing the `manageBankAccounts` Inertia
flag.
- Frontend: remove the flag from the `Features` type, the
`features.manageBankAccounts` conditional in `connections.tsx` (now
gated solely by `canManageAccounts`), and the now-unused `features`
destructure.
- Tests: update `InertiaSharedDataTest` and the `connections.test.tsx`
mock; simplify the `adminUser()` helper to `onboardedUser()` in
`ConnectionAccountTest` and remove the "forbidden when feature disabled"
test.

## Testing

- `vendor/bin/pint --dirty` — pass
- `php artisan test tests/Feature/OpenBanking/ConnectionAccountTest.php
tests/Feature/InertiaSharedDataTest.php` — 17 passed
- `bunx vitest run resources/js/pages/settings/connections.test.tsx` —
pass
- `bun run lint` / `bun run format` — clean
2026-06-20 17:35:43 +00:00
Víctor Falcón 83a5e9657e
fix(ui): make input borders visible in dark mode (#571)
## What

Bump the dark-mode `--input` color from `oklch(0.269)` to `oklch(0.41)`.

## Why

In dark mode the `--input` value sat almost on top of the background
(`oklch(0.225)`), so the shared `border-input` was nearly invisible on
every form control. Checkboxes were especially hard to spot.

Since inputs, checkboxes, radios, selects and textareas all share
`border-input`, this single variable raises their border contrast at
once (Δ luminosity vs background goes from 0.044 → 0.185) while staying
below the focus ring (`0.439`), so focus still stands out and every
control keeps a consistent design.

As a nice side effect, the subtle `bg-input/30` and `bg-input/50` fills
(radio, outline button) also read a touch more clearly.
2026-06-20 17:30:19 +00: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
Víctor Falcón 7fb190683c
refactor(encryption): strip client-side transaction encryption (#514)
## Why

Transactions and account names used to be stored encrypted client-side.
They now live **unencrypted in the backend**, so the browser no longer
needs to encrypt, decrypt, or mask them. A handful of legacy accounts
may still have encrypted transactions, but the auto-run
decrypt-migration handles those — so this PR strips the now-dead
encryption machinery while keeping the migration path fully intact.

Net: **−1213 / +176** lines across 25 files.

## Removed (dead machinery)

- `setup-encryption` page, `POST /api/encryption/setup`,
`SetupEncryptionRequest`, and `encrypt()` / `generateSalt()` — new data
is never encrypted again
- `EncryptedText` and the decrypt-on-render description component →
replaced by a plaintext `TransactionDescription` that keeps the
privacy-mode fake labels
- All `isKeySet`-gated client decryption: transaction list (load /
search / sort), categorize flow, import dedup
- `isKeySet` masking / locking on amount display, app logo, import
button, add-transaction button, account page, and filters
- The unused `?encrypted=false` API filter and `isKeyPersistent()`

## Kept (legacy decrypt-migration)

- The two migration hooks, the unlock dialog, and the dashboard/login
unlock prompt
- `decrypt` / `importKey` + key derivation, key-storage,
`EncryptedMessage`, salt, `GET /api/encryption/message`,
`?encrypted=true`, and the middleware salt-cleanup
- `rule-engine` / edit / import-drawer account-name decryption —
plaintext-first (`if (!account.encrypted) return name`) legacy fallback
on the kept primitives; intentionally untouched

## Testing

- Pint  · ESLint  · Prettier 
- 184 vitest tests 
- 1425 PHP Feature/Unit tests  (Browser suite not run locally — needs
`bun run build`; no Browser test references the encryption UI)

## Follow-up (out of scope)

`DecryptedTransaction.decryptedDescription` / `decryptedNotes` are now
just plaintext aliases. Collapsing that type touches 13+ files (rule
engine, categorizer, edit dialog) and was left as a separate cleanup.
2026-06-20 16:13:26 +00:00
Víctor Falcón 14c4598cda
fix(open-banking): only block re-adding a bank when a live connection exists (#569)
## What

When creating a new connection / connected account, the bank list
filtered out any bank the user already had a connection to. The check
was too broad:

- It blocked a bank whenever a **non-pending** connection existed, so
**expired, errored and revoked-but-not-deleted** connections kept
blocking re-connection.
- The inline variant was even broader — it blocked on **stale pending**
attempts too.

## Fix

A bank is now treated as "already connected" only when a connection is
genuinely **live**, applying three checks:

- **Right provider** — only `enablebanking` connections gate
EnableBanking banks; the exact provider gates
Binance/Bitpanda/Coinbase/Indexa Capital.
- **Active** — only `active` or `awaiting_mapping` statuses block.
`expired`, `error`, `revoked` and `pending` no longer block, so the user
can start a fresh connection.
- **Not deleted** — soft-deleted connections never reach the frontend
(no query uses `withTrashed`), so a deleted connection never blocks.

The two divergent filter implementations (`connect-account-dialog.tsx`
and `connect-account-inline.tsx`) are unified into a single helper in
`resources/js/lib/banking-connections.ts`.

### Covered scenarios

- Manual BBVA account → not a `BankingConnection`, never blocked → can
connect BBVA. ✓
- Deleted BBVA connection → soft-deleted, absent from the frontend → can
connect Enable Banking + BBVA. ✓

## Tests

- New `resources/js/lib/banking-connections.test.ts` (replaces the old
`connect-account-dialog.test.tsx`).

## Note

`awaiting_mapping` is treated as live (bank just authorized, accounts
not yet mapped) to avoid duplicate connections. If only `active` should
block, drop it from `LIVE_STATUSES`.
2026-06-20 13:09:04 +02:00
Víctor Falcón 52708f940d
fix(sentry): drop browser-extension noise before sending events (#568)
## What

Adds an `isBrowserExtensionNoise` predicate to the frontend Sentry
`beforeSend` chain that drops errors originating from injected
browser-extension scripts.

## Why

Triaging Sentry surfaced recurring, unactionable issues caused entirely
by users' browser extensions, not our code:

- **PHP-LARAVEL-21** — `i: Failed to connect to MetaMask`
(`chrome-extension://…/inpage.js`)
- **PHP-LARAVEL-3M** — `Cannot read properties of undefined (reading
'sendMessage')` (`chrome-extension://…/injectedScript.bundle.js`)

These recur for every user who has the offending extension installed, so
resolving-and-waiting just re-pages us forever. This follows the
existing pattern of named noise predicates with vitest coverage
(`isChunkLoadErrorEvent`, `isSafariCashbackExtensionNoise`, …).

## How

The predicate inspects the **crashing (innermost) frame** of each
exception and drops the event only when that frame's URL uses a
browser-extension scheme (`chrome-extension://`, `moz-extension://`,
`safari-web-extension://`, `safari-extension://`,
`ms-browser-extension://`).

Matching only the crashing frame — not *any* frame — avoids
over-filtering legitimate app errors that merely pass through an
extension-wrapped handler.

### Not filtered on purpose

`AxiosError: Network Error` (PHP-LARAVEL-28 / -38) is left untouched: it
has no extension frames and a full backend outage produces the same
error, so we want it to keep alerting.

## Tests

`resources/js/lib/sentry.test.ts` — 4 new cases (drops the two real
extension shapes; keeps the network error and an app error where the
extension frame is not the crash site). `vitest run` green (12/12).
2026-06-20 12:47:05 +02: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 b76a0de074
feat(transactions): default balance toggle on and apply it server-side (#566)
## What

In the add-transaction modal, the **Update account balance** checkbox
now defaults to **on**. An explicit opt-out is still remembered in
`localStorage`, so users who turned it off keep their preference.

Balance updates also move from the client to the backend, mirroring the
existing delete flow
(`ManualBalanceAdjuster::reverseDeletedTransaction`).

## How the balance is applied

When the toggle is on, `TransactionController::store` calls the new
`ManualBalanceAdjuster::applyCreatedTransaction`, which adjusts the
balance on the **transaction's own date**:

- A balance already on that date → increased by the transaction amount.
- No balance on that date but an earlier one exists → a new balance for
that date is created from the **closest earlier balance** plus the
amount.
- No prior balances at all (first transaction on the account) → the
balance is set to the transaction amount.

Connected accounts are skipped, since their balances come from bank
sync.

## Why

The previous client-side flow fetched all balances, computed from the
**latest balance overall** (not the transaction's date), and stored via
a separate request. This replaces those three round-trips with one
atomic server-side write and fixes the wrong base balance.

## Tests

- Feature tests for all three balance cases plus the not-requested and
connected-account guards (`tests/Feature/TransactionTest.php`).
- Frontend test asserting the toggle is checked by default in create
mode.

Full suite green locally: Pest (1690 passed), vitest (221 passed), Pint,
ESLint, Prettier.
2026-06-19 15:01:04 +00:00
Víctor Falcón 50dba4334a
style: pulse the AI categorize sparkle icon (#565)
## What

Replace the static `opacity-50` on the AI categorize sparkle icon with
`animate-pulse`, so the affordance gently pulses to draw attention.
Hover still fades to full opacity.

## Why

Makes the "let AI categorize your transactions" entry point more
discoverable in the transactions table.

## Testing

Visual-only Tailwind class change; no logic affected.
2026-06-19 14:29:54 +00:00
Víctor Falcón 29d13ceed1
feat(paywall): require a plan when the user has accepted AI (#564)
## What

Forces users who have accepted AI consent to choose a plan, the same way
connecting a bank already does.

## Why

AI suggestions are a paid-plan feature (`PlanFeature::AiSuggestions`),
and the onboarding notice tells the user *"AI suggestions are a Standard
Plan feature. You'll choose a plan at the end of the onboarding."* But
that was never enforced: `EnsureUserIsSubscribed` only gated on bank
connections, never on AI consent. A user who accepted AI without
connecting a bank saw the paywall once, got `paywall_seen_at` marked,
and then fell through to **free** access — making the notice a false
promise.

## Change

- `EnsureUserIsSubscribed`: a non-Pro user with an **active AI consent**
is now kept on the paywall on every request, exactly like a
bank-connected user (added `&& ! $user->hasActiveAiConsent()` to the
free-access branch).
- `SubscriptionController::index`: `canUseFreePlan` is now `false` when
the user has an active AI consent, so the paywall stops offering the
free option to these users (keeps page and middleware consistent).
- Revoking AI consent (`hasActiveAiConsent()` → false) restores
free-plan access — a clean escape hatch.

Onboarding itself is unaffected: the onboarding / AI-consent /
rule-suggestion routes live in the `auth,verified` group **without** the
`subscribed` middleware, so consenting mid-onboarding does not lock the
user out of finishing. The paywall only kicks in afterward, when
accessing the app — which is the intended behavior and matches the
notice.

## Tests

4 new tests in `SubscriptionTest` (forced to paywall even after seeing
it; `canUseFreePlan` false; subscribed users with consent still get
access; revoking consent restores free access). Full `SubscriptionTest`
green (38 passed). `pint` clean.

## ⚠️ Behavior change for existing users

This applies retroactively. From prod, **34 users currently have an
active AI consent**; any of them on the free plan today (no bank,
`paywall_seen_at` set) will be redirected to the paywall after deploy
and must subscribe or revoke consent. If we want to grandfather existing
consenters and only enforce this going forward, that needs an extra
condition (e.g. consent recorded after a cutoff date) — flag me and I'll
add it.
2026-06-19 14:18:49 +00:00
Víctor Falcón 57f8c93e28
feat(stats): weekly paywall stuck-cohort report to Discord (#563)
## What

Adds a weekly `stats:stuck-cohort-report` command that measures the
percentage of users "stuck" on the paywall, compares it to the previous
week, and posts the result to Discord.

## Definition of "stuck"

A user with a non-deleted banking connection and **no** valid
subscription:

- has a banking connection (`bankingConnections()` — SoftDeletes already
excludes deleted)
- has no subscription with `stripe_status` in `active` / `trialing` /
`past_due`, nor a `canceled` one still in grace (`ends_at > now()`)

Built with Eloquent (`whereHas` / `whereDoesntHave`), no raw SQL.

## Metric

`stuck_pct = stuck / onboarded users` (`onboarded_at` not null — the
population that reached the paywall). The Discord message also shows the
raw counts, not just the percentage.

## Week-over-week

Each run snapshots `date`, `onboarded_count`, `stuck_count`, `stuck_pct`
into a new `stuck_cohort_snapshots` table (`updateOrCreate` per day,
idempotent), then compares against the most recent prior snapshot to
report the delta in percentage points and stuck count. First run has no
prior → reports current only.

## Discord

Reuses the `SendAiCohortReportCommand` pattern: posts an embed via
`DiscordWebhook` to `config('services.discord.ai_cohort_webhook_url')`
(fallback `webhook_url`). No direct `env()`.

## Schedule


`Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid')`
in `routes/console.php`.

## Tests

7 tests (percentage calc across subscription statuses incl. canceled
in/out of grace, soft-deleted connection, non-onboarded excluded from
denominator, snapshot persistence, delta vs previous, single upsert per
day, Discord POST asserted via `Http::fake`). `pint` clean.
2026-06-19 14:12:51 +00:00
Víctor Falcón 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 6cb8d11563
feat(connections): create a new account from the manage-accounts selector (#560)
## What

In the per-connection **Manage accounts** screen, the discovered-account
card now uses a single selector to choose where a newly detected bank
account syncs. The selector lists **"Create new account"** first,
followed by the compatible existing manual accounts — replacing the
previous separate "Create account" / "Link to existing" buttons.

The **"Add accounts"** header now stacks vertically on mobile
(title/description, then a full-width "Load accounts" button) and stays
a row on `sm+`.

## Why

Consolidating *create* and *link* into the same control matches how
users think about the action ("where should this account go?") and
removes the two side-by-side buttons that overflowed horizontally on
small screens.

## Notes

- **Backend unchanged**: `ConnectionAccountController@map` already
supported both `action: create` and `action: link`. This only re-routes
the same calls through the selector (`existingAccountId === null` →
create). Covered by existing tests in
`tests/Feature/OpenBanking/ConnectionAccountTest.php` (9 passing).
- **No new i18n keys**: reuses the existing `Create new account` and
`Select an account` translations.

## Screens

The "Manage accounts" link lives in the per-connection "…" menu and is
gated behind the `ManageBankAccounts` feature flag (admin-only during
rollout).
2026-06-19 12:49:03 +02:00
Víctor Falcón 46568700b2
fix(banking): skip inaccessible EnableBanking accounts instead of failing the connection (#559)
## Problem

Sentry issue
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
started escalating again — but with a **different** error than the 401
expired-session fixed in #557. Sentry groups both under the same
`getTransactions` culprit:

```
RequestException: HTTP request returned status code 400:
{"code":400,"message":"Account not found","detail":{"message":"Account not found","error_name":"AccountNotAccessibleException"}}
```

When EnableBanking returns `400 AccountNotAccessibleException` for
**one** account (closed, or no longer covered by the consent), the raw
`RequestException` was re-thrown. This:

1. **Aborted the whole connection's account loop** in
`EnableBankingSyncer` — accounts after the dead one never synced.
2. Marked the **entire connection** `Error` (`friendlyErrorMessage`
default), even though its other accounts were fine.
3. **Reported to Sentry** on every scheduled retry (escalating noise).

Confirmed in production (`agent:db --prod`): connection `019ea143-…` is
`active` with `valid_until` in the future (2026-09-05) and **6
accounts**; one (`08bbcdf9-…`) returns the 400, and the connection is
now stuck in `error` with the generic "try again later" message. 2 users
affected.

## Fix

A single account the bank no longer exposes must not break the rest of
the connection.

- New domain exception `InaccessibleBankAccountException` (implements
`ShouldntReport`, like `TransientBankingProviderException` /
`ExpiredBankingSessionException`).
- `EnableBankingProvider::getTransactions()` / `getBalances()` wrap a
`400 AccountNotAccessibleException` in it (keyed on `detail.error_name`)
instead of re-throwing the raw `RequestException`.
- `EnableBankingSyncer` catches it **per account**, logs a warning, and
`continue`s — the remaining accounts sync and the connection stays
`Active`.

Connections currently stuck in `error` from this self-heal on the next
scheduled sync (they're `Error` + under the retry cap + `valid_until`
future, so the scheduler re-dispatches them; the dead account is now
skipped).

Composes with #558: a user can permanently *stop syncing* such an
account from the manage-accounts screen. This PR only stops one dead
account from breaking automated syncs in the meantime.

## Tests

- `EnableBankingProviderTest`: `getTransactions` / `getBalances` wrap a
`400 AccountNotAccessibleException` as a non-reportable
`InaccessibleBankAccountException`.
- `SyncRetryAndLoggingTest`: with one inaccessible and one good account,
the good one still syncs, the connection stays `Active`, and nothing is
thrown.

All three failed before the fix. Full `tests/Feature/OpenBanking` suite
passes (276), pint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-19 08:59:05 +02: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 c36df98d32
fix(banking): handle EnableBanking expired sessions as reconnect, not error (#557)
## Problem

Sentry issue
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
— `RequestException: HTTP 401 {"error":"EXPIRED_SESSION"}` in
`EnableBankingProvider::getTransactions` — was escalating (26 events, 2
users).

When an EnableBanking **session** expires *before* its 90-day consent
window (`valid_until`), the sync hit a `401 EXPIRED_SESSION` that was
not classified as transient or ASPSP, so it was re-thrown as a raw
`RequestException`. Consequences:

1. **Sentry noise** — reported as an error on every scheduled retry
until the failure cap.
2. **Silent breakage for the user** — EnableBanking's syncer returns
`notifiesOnAuthFailure() === false`, so the 401 went through the generic
permanent-error path: connection set to `Error` (not `Expired`) with
**no reconnect email**.

Confirmed in production: **10 of 17** EnableBanking connections in
`error` status are actually expired sessions (`valid_until` in the
future + "Authentication failed" message), all with no notification
sent.

## Fix

Treat a `401 EXPIRED_SESSION` as the expected lifecycle event it is:

- New domain exception `ExpiredBankingSessionException` (implements
`ShouldntReport`, like `TransientBankingProviderException`).
- `EnableBankingProvider::getTransactions()` / `getBalances()` wrap the
`401 EXPIRED_SESSION` in it instead of re-throwing the raw
`RequestException`.
- `SyncBankingConnectionJob` catches it and routes through the existing
expiry handling (extracted to `markExpired()`): marks the connection
`Expired`, sends `BankingConnectionExpiredEmail`, logs a skipped
attempt, returns **without throwing**.
- The provider's HTTP error logger downgrades the expected expiry from
`error` → `warning`.

`Expired` connections are not re-dispatched by
`SyncAllBankingConnectionsJob`, so retries stop and the user is prompted
to reconnect.

## Tests

- `EnableBankingProviderTest`: `getTransactions` / `getBalances` wrap a
`401 EXPIRED_SESSION` as a non-reportable
`ExpiredBankingSessionException`.
- `SyncRetryAndLoggingTest`: an expired session marks the connection
`Expired`, queues the reconnect email, and does not throw.

All three failed before the fix. Full `tests/Feature/OpenBanking` suite
passes (264 tests).

## Follow-up (not in this PR)

The 10 connections already stuck in `Error` from this bug are past the
retry cap and won't self-heal. A one-off command to reclassify them to
`Expired` and send the reconnect email would recover those users — worth
a separate, reviewed change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-18 13:48:13 +00: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 89c1ab1ca8
fix(integration-requests): freeze votes on not-doable requests (#555)
## What
Prevent adding or modifying votes on requests marked as **not-doable**.

- **Backend** (`removeVote`): a vote cast before a request was marked
not-doable could still be removed, mutating a frozen tally. It now
returns `404`, mirroring `vote()` which already rejected not-doable
requests.
- **Frontend**: hide the unvote (chevron-down) button for not-doable
requests; the vote button was already disabled for them.

## Test
Added a feature test: vote → request becomes not-doable → `DELETE` vote
returns `404` and the tally is unchanged.
2026-06-18 10:54:56 +00: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 7e9122eaf4
feat(integration-requests): let the admin bypass limits and auto-approve (#551)
## What

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

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

## How

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

## Tests

- Added a feature test covering the admin bypassing the monthly limit
and auto-approval. All 16 \`IntegrationRequestTest\` tests pass.
2026-06-17 14:12:51 +00:00
Víctor Falcón 5e8f227fbd
feat(integration-requests): community board to request & vote bank integrations (#550)
## What

A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.

## How it works

- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
  - Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).

## Endpoints

- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.

## Tests

- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.

## Notes / follow-ups

- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
2026-06-17 12:50:51 +00:00
Víctor Falcón 9a20335c6a
fix(transactions): only auto-select account on account pages (#549)
## What

When the add/edit transaction dialog resets in **create** mode, it no
longer auto-selects the first transactional account. The account field
starts empty so the user must pick one deliberately.

The only exception is the account detail page (`accounts/{uuid}`), which
passes `initialAccountId`; there the dialog still pre-selects that
account.

## Why

Auto-selecting the first account made it easy to accidentally book a
transaction to the wrong account. Forcing a deliberate pick (except
where the account is unambiguous from context) avoids that.

## How

- `edit-transaction-dialog.tsx`: dropped the `availableAccounts[0]`
fallback. The field now resolves to `initialAccount?.id ?? ''`.
- The existing submit guard (`Account is required`) already covers the
empty case.
- `Accounts/Show.tsx` already passes `initialAccountId={account.id}`, so
the account-page behavior is unchanged; the transactions list passes
nothing, so it now starts empty.

## Tests

Added two unit tests to `edit-transaction-dialog.test.tsx`:
- no `initialAccountId` → account value stays empty (no auto-select)
- matching `initialAccountId` → account is pre-selected
2026-06-17 11:28:31 +00:00
Víctor Falcón 9328cd3e1b
feat(ai): persist AI categorization suggestions below the label bar (#547)
## Contexto

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

## Cambio

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

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

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

## Para qué sirve

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

## Tests

- `CategorizeTransactionsTest`: el caso sub-umbral ahora verifica que la
sugerencia se persiste; el caso aplicado verifica que además se guardan
los campos de sugerencia.
- Suite de IA + tests que tocan transacciones en verde (111 tests).
2026-06-17 08:24:30 +00:00
Víctor Falcón f191d74031
fix(queue): add supervisor worker for the ai queue (#546)
## Problema

Los jobs de auto-categorización con IA (\`CategorizeTransactionWithAi\`)
se encolan en la cola dedicada \`ai\` (\`config/ai_categorization.php\`
→ \`'queue' => 'ai'\`), pero **ningún programa de supervisor consumía
esa cola en producción**.

Resultado observado en prod:
- **10.878 jobs pendientes** en la cola \`ai\`, todos
\`CategorizeTransactionWithAi\`, el más antiguo del 2026-06-15 (cuando
se activó el flag).
- **0 transacciones** con \`category_source = 'ai'\` en toda la base de
datos. La IA nunca categorizó nada en producción.
- Ningún \`failed_job\` en la cola \`ai\`: los jobs no fallaban,
simplemente nunca se ejecutaban.

## Fix

Añade un programa \`queue-worker-ai\` en
\`docker/supervisor/supervisord.conf\`, replicando el patrón de los
workers \`emails\` y \`default\`. Se mantiene la cola \`ai\` aislada
(worker propio) a propósito, para que su backlog no retrase los syncs
bancarios.

Tras el deploy el worker arranca solo (\`autostart=true\`) y drena el
backlog acumulado.
2026-06-17 07:08:25 +00:00
Víctor Falcón 220b1e11f1
refactor(banking): scalable per-provider sync via a syncer factory (#545)
## Why

Syncing was a growing `if/elseif` chain in
`SyncBankingConnectionJob::handle()` plus one private method per
provider. Every new provider meant editing the job, and the file mixed
provider-specific logic with cross-cutting orchestration. This does not
scale to dozens/hundreds of providers.

## What

Introduces a **factory + strategy** pattern:

- **`App\Contracts\BankingConnectionSyncer`** — interface: `sync()`,
`expires()`, `notifiesOnAuthFailure()`.
- **`AbstractBankingConnectionSyncer`** — defaults for the common case
(API-key provider: never expires, notifies on auth failure). A new
provider usually only implements `sync()`.
- **`BankingConnectionSyncerFactory`** — maps `connection.provider` to
its syncer (resolved from the container, so dependencies are injected).
- **Six syncers** — `IndexaCapitalSyncer`, `BinanceSyncer`,
`WiseSyncer`, `BitpandaSyncer`, `CoinbaseSyncer`, `EnableBankingSyncer`.
Each fully owns its provider behavior, including EnableBanking's daily
email and first-sync cutoff.

`SyncBankingConnectionJob` drops from ~520 to ~190 lines and now only
orchestrates: deleted-user/expiry/rate-limit checks, error handling,
retries, logging, and status updates. It asks the syncer `expires()` /
`notifiesOnAuthFailure()` instead of hardcoding provider lists.

### Adding a provider now

1. Create `XSyncer extends AbstractBankingConnectionSyncer` implementing
`sync()`.
2. Add one line to the factory map.

No changes to the job.

## Tests

- Existing job tests (`SyncBankingConnectionJobTest`,
`SyncRetryAndLoggingTest`) migrated through a shared `runSync()` helper
in `tests/Pest.php`; all original assertions preserved.
- New `BankingConnectionSyncerFactoryTest`: provider→syncer resolution,
unknown-provider exception, and the capability flags.
- Full suite green: **1604 passed**, 0 failures. PHPStan clean, Pint
applied.

## Docs

Adds `docs/adding-a-banking-provider.md` — a step-by-step developer
guide (usable by a human or an AI agent) covering the sync provider and
the broader end-to-end integration touchpoints.
2026-06-16 16:25:29 +02:00
Víctor Falcón 3d0fd9cc70
ci: remove automerge label workflow in favor of GitHub native auto-merge (#544)
## Summary

Removes the custom `automerge.yml` workflow and its `automerge` label
flow. GitHub now provides native auto-merge that merges a PR
automatically once required CI checks pass, making the custom workflow
redundant.

## Changes

- Delete `.github/workflows/automerge.yml`

## Follow-up (manual, outside this PR)

- Enable **Settings → General → Allow auto-merge**.
- Add a branch protection rule requiring the CI check so native
auto-merge waits for it.
- Remove the now-unused `MERGE_TOKEN` secret if nothing else uses it.
- Use **Enable auto-merge** per PR (or `gh pr merge --auto --squash`).
2026-06-16 13:49:20 +00:00
Víctor Falcón da0c8c58fc
refactor: centralize duplicated provider & locale keys into enums (#543)
## What

Unifies keys that were repeated across the codebase into two PHP enums
as the single source of truth. Two independent commits:

### `refactor(banking)` — `BankingProvider` enum
- New `App\Enums\BankingProvider` (`indexacapital`, `binance`,
`bitpanda`, `coinbase`, `wise`, `enablebanking`).
- `BankingConnection`'s `provider` column is cast to the enum → magic
strings gone from the model, controllers (`match`), requests, jobs,
commands, factory and `where('provider', ...)`.
- Logic that was duplicated, now on the enum:
  - `usesApiKey()` — API-key auth (everything except EnableBanking).
- `defaultAccountType()` — provider → account type (removes the
duplicated ternary in `CreatesAccountsFromPending` and
`AccountMappingController`).

### `refactor(i18n)` — `Locale` enum
- New `App\Enums\Locale` (`en`/`es`/`fr`).
- Unifies the `['en','es','fr']` list (validation in `SetLocale` and
`ProfileUpdateRequest` via `Rule::enum`) and the `Accept-Language`
detection (`Locale::detectFromHeader`), previously duplicated in
`SetLocale` and `CreateNewUser`.

## Interaction with Wise (PR #525)

Rebasing onto Wise surfaced two issues this PR fixes:
1. **`isWise()` was broken** once `provider` is cast (it compared `===
'wise'` against an enum → always `false`).
2. **Account type**: Wise uses an API key (✓ it gets the auth-failed
email) but is a **checking** account, not an investment one. Account
type now flows through `defaultAccountType()` (Wise/EnableBanking →
Checking; indexa/binance/bitpanda/coinbase → Investment).

## Behavior change (minor)

`CreateNewUser` now detects `fr` at registration (previously only
`es`/`en`), consistent with the existing French support.

## Out of scope

- Frontend (`['indexacapital','binance',...]`, `provider: string`):
syncing TS with the PHP enums would need typegen. It still receives the
string via `->value`.
- `TransactionSource::Wise/EnableBanking`: a separate enum (transaction
origin), not a duplicated check.

## Tests

- New: `tests/Unit/Enums/BankingProviderTest.php`,
`tests/Unit/Enums/LocaleTest.php`.
- `vendor/bin/pint --test` ✓ · Larastan ✓ · full suite (excluding
Browser) **1615/1616** ✓.
2026-06-16 13:43:14 +00:00
Toni Grunwald 1c5a76a3a4
feat: add Wise open banking integration with balance sync (#525)
## Summary
Adds **Wise** as an API-token banking provider — connect flow,
transaction sync, and per-wallet balance sync — and fixes two bugs that
kept business/extra profiles and balances from working.

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-06-16 13:23:25 +00:00
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
Víctor Falcón 7693e4813f
fix(discord): skip zero-amount payment stats messages (#540)
## Qué

Una suscripción en periodo de prueba genera una factura
`invoice.payment_succeeded` de **0 €** en Stripe. El listener la
convertía en un mensaje "💰 Payment succeeded" en Discord, duplicando
información con el mensaje de la suscripción y sin aportar datos de pago
útiles.

Ahora, si el importe de la factura es 0, no se envía ningún embed. El
mensaje de la suscripción sigue enviándose de forma independiente.

## Cómo

- `PostStripeEventToDiscord::invoiceEmbed` devuelve `null` cuando el
importe es 0. `handle()` ya corta en `$embed === null`, así que no se
publica nada.
- Aplica tanto a pagos exitosos como fallidos de 0 € (una factura de 0
no aporta info de pago en ningún caso).

## Tests

- Nuevo test `skips a zero-amount payment so only the subscription
message is posted` (`assertNothingSent`).
- Suite completa del listener en verde (10 tests).
2026-06-15 19:21:47 +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 d27e1622b8
chore: release v0.2.5 (#539)
Patch release **v0.2.5** (0.2.4 → 0.2.5).

### Cambios
- Bump de versión + `CHANGELOG.md` generados con release-it
(conventional-changelog).
- **Autoría de PRs en el changelog**: cada entry muestra `by [@handle]`,
resuelto por número de PR vía la API de GitHub.
- `scripts/enrich-changelog.js`: enriquece solo la sección de release
más reciente; idempotente y no-fatal si `gh` no está disponible.
- Hook `after:bump` en `.release-it.json` para que se aplique
automáticamente en cada release futura.

Tras el merge: creo el tag `v0.2.5` y el GitHub release sobre `main`.
2026-06-15 16:48:25 +00:00
Víctor Falcón 6671d89ea1
fix(layout): keep bottom padding while floating nav is visible (#537)
## Problem

On mobile we pad the bottom of pages so content clears the floating nav
bar.

That bar isn't mobile-only though. It stays visible up to the `md`
breakpoint (`md:hidden`, so below 768px). The padding stopped earlier,
at `sm` (`sm:pb-0`, 640px and up). Between 640 and 768px the bar showed
with nothing behind it and overlapped page content.

## Fix

Move the padding breakpoint from `sm` to `md`, so the padding lasts
exactly as long as the bar does:

```diff
- className="pt-safe overflow-x-hidden pb-[90px] sm:pb-0"
+ className="pt-safe overflow-x-hidden pb-[90px] md:pb-0"
```

Both flip at `md` now.

## Testing

Only a Tailwind class swap, no JS changed. Resize the window between 640
and 768px: the nav no longer covers content.
2026-06-15 18:23:26 +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 e065d4ab65
feat(ai): defer per-transaction categorization until onboarding completes (#536)
## What

During onboarding, the per-transaction AI categorization listener no
longer runs. The AI automation rules generated in the suggestions step
cover the bulk of the imported transactions (~70%). On completion, a
single batch pass categorizes whatever is still uncategorized.

## Why

Running per-transaction categorization on every transaction created
during onboarding is wasteful: most are about to be covered by the AI
rules generated at the end of the flow. Defer the AI spend to one batch
over the remainder.

## How

- **`CategorizeTransactionWithAi` listener** — skips when `!
$user->isOnboarded()`. Covers every txn-creation path, since it hangs
off the model `created` event.
- **`CategorizeOnboardingTransactionsJob`** (new) — dispatched from
`OnboardingController::complete` after `onboarded_at` is set. Snapshots
`whereNull('category_id')->whereNull('description_iv')`, so transactions
already categorized by the AI rules (or manually in the categorize step)
are excluded. Chunks by `group_batch_size` and runs `AiCategorizer` per
chunk. Runs on the dedicated `ai` queue.
- Gated by `AiCategorizationGate::allows()` (rollout flag) — automatic
system behavior, gated like the real-time tier, not the explicit
backfill.

## Flow

1. `syncing` → transactions created, **no** per-transaction AI
2. `ai-suggestions` → AI rules cover the bulk
3. `categorize-transactions` → user manually categorizes a few
4. `complete` → batch-categorize the rest, skipping anything already
labeled

## Tests

- Listener skips transactions created while onboarding; still
categorizes post-onboarding.
- Job categorizes the remainder, leaves rule/manual categories
untouched, no-ops for ineligible users.
- `complete` marks the user onboarded and queues the batch job.

Full Ai + Onboarding suites green (112/112). Pint clean.
2026-06-15 17:33:26 +02: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 1d4bcd5082
fix(cashflow): bound trend window to prevent request timeout (#534)
## Sentry
Fixes
**[PHP-LARAVEL-3A](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3A)**
— `FatalError: Maximum execution time of 30 seconds exceeded` on `GET
/api/cashflow/trend`. Top frame in Carbon date math (`getUTCUnit`).

## Root cause
`trend()` caps the `months` param at 24, but the `from`/`to` branch
validated only `date` — **no span limit**:

```php
if (isset($validated['from'], $validated['to'])) {
    $start = Carbon::parse($validated['from'])->startOfMonth();
    $end   = Carbon::parse($validated['to'])->endOfMonth();
}
```

The series is then built by `while ($current->lte($end)) { ...;
$current->addMonth(); }`. A request with a huge range (e.g.
`from=0001-01-01&to=9999-12-31`) runs that loop hundreds of thousands of
times — each iteration doing Carbon `format()`/`addMonth()` — and
exhausts the 30s timeout. (Sentry strips query strings, which is why the
captured URL looked param-less.)

The frontend uses the capped `?months=12&to=...` path for the month
view, but the uncapped `?from=...&to=...` path for other period types.

## Fix
Clamp the computed `start` to at most `MAX_TREND_MONTHS` (24) months
before `end`, regardless of branch. Bounds both the month loop and the
transaction query window. 24 matches the existing `months` ceiling.

## Test
`cashflow trend caps the window for unbounded date ranges` — requests
`from=0001-01-01&to=9999-12-31` and asserts exactly 24 data points
returned, in ~0.02s. Without the clamp this loops ~96k months and hangs.
2026-06-15 12:47:27 +02:00
Víctor Falcón cd323bbe52
fix(budgets): make period generation idempotent (#533)
## Sentry
Fixes
**[PHP-LARAVEL-39](https://whisper-money.sentry.io/issues/PHP-LARAVEL-39)**
— `UniqueConstraintViolationException` (1062) on
`budget_periods_budget_id_start_date_unique`. 7 events over 6 days, 0
users (background command), regressed.

## Root cause
`BudgetPeriodService::generatePeriod()` did a blind
`BudgetPeriod::create()`. The scheduled `budgets:generate-periods`
command reaches `generatePeriod()` from three paths (`handle()` +
`closePeriod()`), each computing the next start date as `max(end_date) +
1 day`. Across **overlapping or repeated runs** two paths compute the
same `start_date`, and the second insert collides with the unique key:

```
insert into budget_periods (budget_id, start_date, ...) values (019ea713..., 2026-06-30 00:00:00, ...)
-> 1062 Duplicate entry '019ea713...-2026-06-30'
```

## Fix
Make creation idempotent on the `(budget_id, start_date)` unique key via
`firstOrCreate()`. It delegates to Laravel's `createOrFirst()`, which
catches the unique violation and re-queries — so the call is
**concurrency-safe** and the command is safe to re-run. Period dates are
normalized to start-of-day so the lookup matches the stored date key.

## Test
`generatePeriod is idempotent when a period already exists for the start
date` — reproduces the 1062 (verified failing against the pre-fix code
with the exact `BudgetPeriodService.php:26` stack), passes after the
fix. Returns the existing period, no duplicate row.

## Notes
Behavior change: when the next period already exists, `generatePeriod()`
now returns it unchanged instead of throwing. `closePeriod()` still
updates `carried_over_amount` on the returned period.
2026-06-15 12:44:44 +02:00
Víctor Falcón 2bfb569a22
fix(account): block deletion while subscription or trial is active (#531)
## Problem

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

## Fix

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

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

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

## Tests

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

All passing locally, alongside Pint / Prettier / ESLint.
2026-06-14 20:46:44 +02:00
Víctor Falcón 906e3cc2b4
feat(ai): add weekly AI-suggestions cohort report (#530)
## What

Adds `stats:ai-cohort-report` — a monthly scheduled command that posts a
**weekly per-cohort** retention/conversion time series for the
onboarding AI suggestions feature to Discord.

## Design (pre/post, directional — not causal)

We measure whether shipping AI suggestions moves retention/conversion
among the users who can actually receive it. This is an **observational
pre/post** readout, deliberately chosen over a randomized A/B holdout
(low signup volume; we don't want to withhold the feature). It reports a
**correlation**, never a cause.

- **Cohort (ITT):** users who imported **≥50 transactions in their first
7 days** (a pre-treatment trait). We do *not* condition on who accepted
AI — that would reintroduce self-selection.
- **Release anchor `R`:** `MIN(ai_consents.accepted_at)`, planted by
self-accepting on deploy.
- **Metrics, all fixed-horizon from each user's own signup:**
  - Retention — active ≥14d (`last_active_at ≥ signup+14d`)
  - Trial — subscribed ≤14d
  - Paid — `active` subscription ≤30d
  - AI-acceptance — funnel-health line (descriptive, not causal)
- **Right-censoring:** too-young cohorts render as `pend`, never `0`.
- **Surge flagging:** weeks with outlier eligible volume (>2.5× median)
are flagged  so a one-time acquisition spike (e.g. the launch/YouTube
surge) isn't misread as an organic trend.

Staff/test accounts (incl. the one planting the anchor consent) are
excluded via `config('ai_suggestions.report.excluded_emails')`. Output
goes to `DISCORD_AI_COHORT_WEBHOOK_URL`, falling back to the shared
`DISCORD_WEBHOOK_URL`.

## Honest caveat (baked into every report footer)

A one-time launch+YouTube signup surge sits right before release and
there's no acquisition-source tracking, so cross-release comparisons mix
channels. Read **organic-vs-organic cohort trends over a quarter**, not
a month-one before/after diff.

## Deploy steps

1. Self-accept the AI consent in prod to plant `R`.
2. Add your email (+ staff) to `AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS`.
3. Set `DISCORD_AI_COHORT_WEBHOOK_URL` (or rely on the existing admin
webhook).

## Tests

`tests/Feature/SendAiCohortReportCommandTest.php` — 7 tests / 25
assertions covering eligibility, retention, trial/paid windows, release
anchor + pre/post split, maturity censoring, surge flagging, and Discord
delivery (incl. webhook fallback). Pint clean.
2026-06-13 23:23:34 +02:00
Víctor Falcón 8056ede636
feat(ai): suggest automation rules during onboarding (#523)
Suggests transaction categorization rules during onboarding.

After a sync or import, it groups the uncategorized transactions, asks
Gemini (via laravel/ai) to map the common merchants to categories, and
shows the results for review. The user edits or drops any and creates
the ones they want. During onboarding the accepted rules also categorize
existing transactions right away.

Off by default: it needs the `AiRuleSuggestions` Pennant flag and a
per-user AI consent. The model and thresholds are config-driven.
`ai:suggest-rules {user}` prints what a user would get.

The settings-page surface and monthly regeneration are a follow-up.
2026-06-13 22:51:15 +02:00
Víctor Falcón 0f48ffbbef
feat(welcome): add Albert G. testimonial (#529)
## What

Adds a new landing-page testimonial from **Albert G.**, sourced from a
user email.

- `welcome.tsx` — new entry (English source string) inserted before the
co-owner entry.
- `lang/es.json` — matching Spanish translation.

## Notes

- `gravatar` is the md5 of the sender's email.
- Adapted the message to fit: kept the praise (intuitive, functional,
daily-finance help, generous free tier), dropped the feature-request
portion (advanced auto-categorization, richer dashboards) since that's
roadmap feedback, not testimonial copy.

## Verification

- `LocalizationTest` passes (es.json key present, valid JSON).
- Prettier + ESLint clean.
2026-06-13 19:23:09 +02:00
Víctor Falcón e526f861b2
fix(automation-rules): hint amount sign for expenses vs income (#524)
## Problem

A user reported automation rules not applying. The rule matched a
description **and** `Valor es igual a 21,99`, but never fired.

Root cause: the matching engine evaluates the **signed** transaction
amount (`amount / 100`), and expenses are stored negative. So an expense
of `-21,99` is compared as `-21.99 == 21.99` → false. The rule editor
showed a plain number input with no hint about the sign convention, so
entering a positive `21,99` to match an expense silently builds a rule
that can never match.

This is a common trap — likely affecting many users with amount-based
rules.

## Change

Add a small helper note under the numeric `amount`/`Valor` value input:

> Usa un valor negativo para gastos (ej. -21,99) y un valor positivo
para ingresos.

- Renders only for the numeric `amount` field; text conditions are
unaffected.
- No change to how rules are stored or evaluated — existing rules keep
working.
- Added the Spanish translation key (`es.json` is CI-enforced).

## Testing

- `bun run format` and `eslint` clean on the component.
- `LocalizationTest` passes (translation key resolves, no missing keys).

## Follow-up (not in this PR)

The hint guides new rules but doesn't retroactively fix rules already
saved wrong. A future enhancement could warn when saving a
positive-value amount condition, since most finance rules target
expenses.
2026-06-12 19:04:43 +02:00
Víctor Falcón d2a4412118
feat(console): add agent:db command for querying local and prod DB (#522)
## What

Adds an `agent:db` artisan command so agents (and humans) can run read
queries against the local or production database from the CLI.

```bash
php artisan agent:db "select id, email from users limit 5"        # local, JSON (default)
php artisan agent:db --format=table "select count(*) from transactions"  # console table
php artisan agent:db --prod "select count(*) from users"          # production
```

### Options
- `--format=json` (default) — pretty-printed JSON
- `--format=table` — classic console table
- `--prod` — target the production connection (new `prod` connection
backed by `PROD_DB_URL`)

## Notes
- Read-only: uses `DB::select()`, so it won't run
`INSERT`/`UPDATE`/`DELETE`. Query errors are caught and reported.
- Adds a `querying-the-database` skill documenting the command
(auto-activates on prod/DB questions).
- De-duplicates the skills tree: `.agents/skills` is now the canonical
directory and `.claude/skills` is a symlink to it (previously both were
tracked as identical copies).

## Test plan
- [x] `php artisan test --filter=AgentDatabaseCommand` (4 passing: json,
table, invalid format, query error)
- [x] `vendor/bin/pint`
2026-06-12 18:35:14 +02:00
Víctor Falcón 08bb42979a
chore: update Laravel Boost skills and guidelines (#521)
## Summary

Output of running `php artisan boost:update`.

- Add `cashier-stripe-development` skill
- Rename `developing-with-fortify` → `fortify-development`
- Refresh skill files, `AGENTS.md`, and `CLAUDE.md` guidelines
- Update `boost.json` skill list

## Test plan

No code changes — this only updates Boost-managed agent guideline files
(`.agents/`, `.claude/`, `.github/` skills, `AGENTS.md`, `CLAUDE.md`,
`boost.json`).
2026-06-12 18:20:30 +02:00
Víctor Falcón 3223824edb
fix(transactions): improve mobile analysis button affordance (#520)
## Summary

Improves the transaction actions menu on mobile:

- **Tap-to-show tooltip instead of a modal** — when the Analysis button
is tapped with no filter applied, it now shows the same tooltip used on
desktop ("Apply a filter to enable this button") via a controlled Radix
tooltip, instead of opening a separate modal dialog.
- **Always show the "Analysis" label on mobile** — previously the mobile
button was icon-only, which made it hard to understand. It now shows the
icon + label like desktop.
- **Move "Add transaction" into the more-actions dropdown on mobile** —
frees up horizontal space so the Analysis label fits. On desktop the
standalone Add transaction button is unchanged.

## Notes

- Reuses the existing `Add transaction` translation key (already in
`lang/es.json`).
- Removed the now-unused `Dialog` import/markup.

## Test plan

- [ ] On mobile, tap Analysis with no filter → tooltip appears on tap,
dismisses on tap outside.
- [ ] On mobile, the Analysis button shows its label.
- [ ] On mobile, "Add transaction" appears in the more-actions (⌄)
dropdown.
- [ ] On desktop, behavior is unchanged (hover tooltip, standalone Add
transaction button).
2026-06-12 16:21:23 +02:00
Víctor Falcón 9c3c4d573e
feat(analysis): per-category 12-month spending drawer (#519)
## Summary

Adds a **category-analysis drawer** so you can see how much you spend on
a single category, month by month, over the last 12 months — answering
"am I spending more or less on food delivery?".

Reachable via an **Analyze** button on three cards:
- Dashboard → Top spending categories
- Cashflow → Income Sources
- Cashflow → Expense Categories

Each button opens a shared drawer with a full-width category chooser
(all categories) and a 12-month stacked bar chart.

## Chart semantics
- X = last 12 rolling months (gaps filled with zero). Y = user display
currency.
- Stacked segments = the chosen category's immediate children
(grandchildren rolled in) + a **Direct** segment (spend on the parent
itself) + an **Other** segment (children beyond the top 6, ranked by
total).
- A leaf category renders a single series named after the category.
- Amounts convert to the user currency and **net per month**, oriented
by category type so the expected direction reads as a positive bar; an
against-grain month (e.g. refunds > spend) dips below zero.
- Colors use the theme-aware chart palette (same as the net-worth
chart).

## Summary stats
Two glowing cards above the chart: **Monthly average** and **Trend**
(recent 6-month average vs earlier 6-month average; null when there's no
earlier baseline).

## Persistence
Each widget remembers its own chosen category in `localStorage`
(category only), prefilling the first category of its list otherwise; a
remembered-but-deleted category falls back gracefully.

## Tests
- 11 Pest feature tests (rollup, Direct/Other, net-with-refunds,
12-month window, currency conversion, income orientation, summary
average + trend).
- 5 vitest tests for the prefill/persistence precedence.
- es.json keys added.
2026-06-11 09:52:53 +02:00
Víctor Falcón 49acc8a884
fix(analysis): truncate long breakdown names so amounts stay in widget (#518)
## What

Long category/label names in the shared bar-list breakdowns (cash-flow
top expenses/income, dashboard top categories, analysis drawer
categories/tags/accounts) spilled the amount out past the widget's right
edge.

## Why

The name span already had `truncate min-w-0 flex-1`, but its `grow` row
wrapper (the `<Link>`/`<div>`) had no `min-w-0`. Flex items default to
`min-width: auto`, so the wrapper refused to shrink below its content —
the inner `truncate` never engaged.

## Change

Add `min-w-0` to both `grow` wrappers so the row can shrink and the
existing `truncate` clamps the name with an ellipsis, keeping the amount
inside the widget.

Visual-only CSS class change.
2026-06-10 21:58:36 +02:00
Víctor Falcón bcd025f1b1
feat(analysis): shared bar-list breakdowns in transaction drawer (#517)
## What

Unifies the "top categories"-style bar lists behind one reusable
component and applies it to the transaction **analysis drawer**.

### Shared component
New `resources/js/components/shared/category-breakdown-list.tsx` —
generic `CategoryBreakdownRow<T>` + adapter (leading marker, truncated
name, optional trend + %, amount, proportional bar, expand/collapse
recursion). Now used by:
- Dashboard **Top spending categories** (was a local `CategoryRow`)
- Cash-flow **Income Sources / Expense Categories** (was a local
`BreakdownRow`)
- Analysis drawer breakdowns

### Analysis drawer
- **Top categories** — pie+list → bar list, now with **expand/collapse**
of subcategories (children already in the payload, served through
`useExpandableCategories` with no extra fetch).
- **Top tags** — recharts bar → bar list, colour-dot leading marker.
- **Top accounts** — plain list → bar list, with the **bank/account
logo** as the leading marker instead of a category icon.

### Largest expenses table
Long category names pushed the amount onto two lines (minus sign split
from the digits). Category + description columns are now capped to the
same width, the chip truncates, and the amount cell is
`whitespace-nowrap`.

### Backend
`icon` added to the category breakdown payload (parent, child,
Uncategorized) so the drawer can render the icon circle.

## Tests
- Drawer: category expand/collapse + tag/account render tests.
- Backend: asserts `color`/`icon` on the category breakdown.
- Full JS suite (191) + analysis feature tests green; pint / eslint /
prettier clean.

## Note
Dashboard card has no test file — refactor preserves its public
behaviour; worth a visual check.
2026-06-10 12:36:20 +02:00
Víctor Falcón fcf2d3d1ad
feat(users): track last login and last active timestamps (#516)
## What

Adds two timestamp columns to `users`:

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

## Why

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

## Tests

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

Migrations not yet run on environments.
2026-06-10 11:01:30 +02:00
Víctor Falcón 21f8f3b277
fix(analysis): align summary card amounts to a common baseline (#515)
## What

In the transaction **analysis drawer**, the summary cards' amounts
weren't lining up horizontally: the **Avg / day** card's label row holds
the day-editor button (a 24px icon button), making that row taller than
a plain text label, so its amount sat lower than **Total spent**.

## Fix

Give every summary card's label row a fixed `h-6` (matching the
icon-button height), so all label rows are the same height and the
amounts share a common baseline. Amounts were already the same size
(`text-lg`); this just equalizes the vertical space above them.

Applies to all summary tiles — expense mode (Total spent / Avg per day)
and income mode (Income / Expenses / Net / Margin).

## Before / After
- Before: Avg/day amount offset below Total spent.
- After: both amounts aligned on the same row.

Frontend tests (9) pass; eslint + prettier clean.
2026-06-09 16:09:07 +02:00
Víctor Falcón 7cc49a5368
feat(analysis): project-aware transaction analysis (#513)
## What

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

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

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

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

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

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

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

## Notes
- Adds migration `add_analysis_mode_to_saved_filters_table`.
2026-06-09 15:32:07 +02:00
Víctor Falcón 1530544b8b
fix(header): keep mobile logo on one line, compact auth buttons (#512)
## What

Two mobile-header fixes:

- **Logo no longer wraps/clips.** The brand row had no `shrink-0`, so
flexbox squeezed it and "Whisper Money" wrapped to two lines or got cut
off by the buttons. Now pinned with `shrink-0` + `whitespace-nowrap`,
and the bird icon is bumped `size-4` → `size-5`.
- **Auth buttons fit when logged out.** Log in + Register both as text
buttons overflowed the narrow pill. Log in is now an icon-only ghost
button (`LogIn` icon, `aria-label` for a11y), so Register keeps its full
primary-CTA pill.

Desktop header is untouched.

## Test plan

- [ ] Logged-out mobile: logo on one line, login icon + Register both
visible, no overflow
- [ ] Logged-in mobile: Dashboard button unaffected
- [ ] Desktop unchanged
2026-06-09 14:44:35 +02:00
Víctor Falcón e9ba70b315
chore: ignore .playwright-mcp directory (#511)
Add `/.playwright-mcp` to `.gitignore` so the Playwright MCP working
directory isn't tracked.
2026-06-09 12:05:21 +02:00
Víctor Falcón c4987b7f05
test(open-banking): e2e coverage for Enable Banking connection flows (#509)
## Why

Bank connection via Enable Banking is a critical flow that must keep
working in **both onboarding and settings**, including reconnecting an
expired consent. This adds regression coverage so it never silently
breaks.

## What

Two complementary layers:

### 1. CI regression — mocked Pest browser tests
`tests/Browser/BankConnectionFlowTest.php` drives the full UI →
`authorize` → `callback` → account mapping → sync flow for all three
scenarios, with the banking provider faked
(`tests/Support/FakeBankingProvider.php`). Deterministic, no external
calls.

-  connect from onboarding (accounts auto-created)
-  connect from settings (+ account mapping)
-  reconnect an expired connection (re-matches accounts by IBAN)

**Runs on CI** in the existing `browser-tests` job (distributed across
shards automatically as a new test; `shards.json` timing entry will be
added next time `update-browser-shards` runs).

### 2. Live-sandbox verification — standalone Playwright script
`tests/Browser/live/connect-bank.mjs` runs the same three flows against
the **real Enable Banking sandbox** (Banco de Sabadell + BBVA) via the
dev server, for on-demand / nightly checks. Backed by a local-only
helper command `app/Console/Commands/E2eBankingFixtureCommand.php`.

**Does not run on CI** (needs the dev server, live sandbox, and
secrets). See `tests/Browser/live/README.md`.

#### Why the live flow can't be a normal Pest test
Enable Banking only redirects to the fixed registered URI
(`https://whisper.money.local/open-banking/callback`). A
`RefreshDatabase` Pest test runs an ephemeral server on a random port
with a transactional DB the external redirect can never reach. The
script instead drives the persistent dev server, captures the
`code`+`state` the bank redirects with, and replays the callback against
the dev server (the callback is stateless — keyed by `state_token`).

## Verification

- Mocked Pest tests: **3 passing**.
- Live script: **all 3 scenarios PASS** against the real sandbox
(Sabadell → 5 accounts + 328 tx synced; BBVA onboarding → 7 accounts;
expired → reconnect refreshes `valid_until` and retains accounts).
- `pint --test`, eslint, prettier clean.

> Note: while verifying locally, the dev DB was missing the
`state_token` migration (`2026_06_05_185212`), which made
`/open-banking/authorize` 500. Worth confirming it's applied in other
environments.
2026-06-09 11:58:50 +02:00
Víctor Falcón 6486908716
fix(transactions): tidy filter bar into two balanced rows (#510)
## What

Tidies the transactions filter bar so it lays out as **two balanced
rows** — a control on the left and on the right of each — instead of
wrapping awkwardly once **Transaction analysis** is enabled (the
saved-filters button was crowding the row).

- **Row 1:** `Filters` (left) · search input (grows, middle) ·
saved-filters bookmark (right)
- **Row 2:** `Analysis / Categorize / + Transaction / ▾` (left) ·
`Columns` (right)

Actions row dropped `flex-wrap` + `sm:justify-end` in favour of a clean
`justify-between`; the saved-filters/clear group is now grouped to the
right with `ml-auto`.

## Screenshots

**Desktop**


![Desktop](https://github.com/whisper-money/whisper-money/blob/assets/transaction-filters-screenshots/screenshots/transactions-desktop.png?raw=true)

**Mobile**


![Mobile](https://github.com/whisper-money/whisper-money/blob/assets/transaction-filters-screenshots/screenshots/transactions-mobile.png?raw=true)

## Notes

- Screenshots are hosted on a throwaway
`assets/transaction-filters-screenshots` branch so they render here
without landing in `main`. Safe to delete after merge.
2026-06-09 11:04:32 +02:00
Víctor Falcón c944a2c37e
feat(analysis): break down spending by sub-category (#508)
## What

Clicking **Analysis** on the transactions page opens a chart of spending
by category. Two improvements:

### Sub-category breakdown
Previously every expense rolled up to its top-level category. Now each
parent shows its total with the sub-categories that carry spending
nested beneath it.

- New `CategoryTree::spendingBreakdown()` builds a two-level tree: each
root with its rolled-up total + immediate sub-categories. Grand-children
fold into their level-2 ancestor. Spend booked directly on a parent that
is *also* split across sub-categories surfaces as a **Direct** child, so
the children always sum to the parent total.
- `by_category` slices now carry a `children[]` array.
- The legend renders each parent row, then indented sub-category rows
(name, % of parent, amount). The donut stays at parent level.

### Clearer colors
Monochrome schemes (blue, pink, neutral) run darkest→lightest, so
adjacent slices were nearly identical. A new `alternateContrast()` zips
the two palette halves (`chart-1,5,2,6,3,7,4,8`) so neighbouring slices
alternate between dark and light ends. Sub-category dots reuse the
parent color at reduced opacity.

## Tests
Added coverage for sub-category nesting, the Direct child, grand-child
folding, and the flat (no-children) case. All analysis + CategoryTree
tests pass.
2026-06-08 14:53:36 +02:00
Víctor Falcón 8375fd490e
feat(transactions): filtered analysis dashboard (#507)
## Summary

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

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

## Behavior

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

## Backend

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

## Tests

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

## Notes

- Data is always sourced from the backend.
- The flag is still off by default — enable `TransactionAnalysis`
per-user to try it.
2026-06-08 14:04:00 +02:00
Víctor Falcón c53c40cf0b
feat(landing): add go now button to redirect modal (#506)
## Summary

On the landing page, logged-in users see a modal with a three-second
progress bar that redirects them to the dashboard. This adds a primary
**Go now** button so users can skip the wait.

- Move **Cancel** button to the left, add primary **Go now** button on
the right (`sm:justify-between`)
- `Go now` calls `router.visit(dashboard())` immediately
- Added `Go now` Spanish translation (CI enforces `es.json`)

## Testing

- Added test: redirects immediately when clicking go now
- All 4 dialog tests pass
2026-06-08 09:51:35 +02:00
Víctor Falcón 346e21ad4e
feat(cashflow): enlarge and color-code net cashflow and saved cards (#505)
## What

Enlarge and color-code the headline numbers in the first two cash-flow
cards so users can spot a weak savings net or low allocation at a
glance.

- **Net Cashflow**: headline number bumped to `4xl`; green when net ≥ 0,
red when negative.
- **Saved & Invested**: headline number bumped to `4xl`; colored by
allocation rate — green ≥50%, yellow ≥25%, orange ≥0%, red below.

## Why

The numbers were uniformly black, making it hard to tell at a glance
when net cashflow is negative or when only a little is being set aside.

## Testing

- `vitest run` on `net-cashflow-card.test.tsx` 
- `bun run format` / `bun run lint` clean
2026-06-08 09:23:57 +02:00
Víctor Falcón 899ea6a939
feat(currency): add NZD (New Zealand Dollar) (#504)
## What
Add New Zealand Dollar (NZD) as a supported currency.

## Compatibility
Conversion provider `@fawazahmed0/currency-api` covers NZD (standard ISO
4217). Conversion service lowercases codes and fetches `nzd.min.json` —
same path AUD 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` — NZD entry (`allows_primary` +
`allows_account`)
- `resources/js/utils/currency.ts` — `NZ$` symbol
- `lang/es.json` — Spanish translation (name passes through `__()`)

## Tests
Currency + translation suites pass.
2026-06-08 09:18:17 +02:00
Víctor Falcón 4b90bcfc96
fix(currency): make rate fetching resilient to slow CDN (#502)
## What

Hardens `CurrencyConversionService` against a slow/unreachable external
FX-rate CDN, which was crashing `/api/cashflow/trend`.

## Sentry

- Fixes PHP-LARAVEL-2X — `RuntimeException: Failed to fetch currency
rates ... cURL error 28: Operation timed out`
- Fixes PHP-LARAVEL-31 — `FatalError: Maximum execution time of 30
seconds exceeded` (Guzzle CurlFactory)

## Root cause

For a historical date the service walked up to 8 lookback dates × 2 URLs
at a **10s timeout each** (~160s worst case → 30s PHP fatal), and a
single network timeout threw a `RuntimeException` that 500'd the whole
request.

## Changes

- **Bounded timeouts**: 3s connect / 5s total per request.
- **Abort on unreachable source**: a connection/timeout error stops the
date walk (after trying the fallback host) instead of repeating the same
timeout for every candidate date. 404s still walk back to earlier
releases.
- **Graceful degradation**: failures return an empty rate map instead of
throwing — conversions already fall back to `0.0` on missing rates, so
the trend endpoint no longer 500s.
- **Persistent cache**: historical releases cached 30d (immutable),
`latest` 6h, unavailable results 10m (dampens retries during an outage).

## Tests

- Updated the former "throws" test to assert graceful degradation to
`0.0`.
- Added: timeout aborts the date walk after 2 attempts; rates cache
across service instances.
- All 12 tests pass.
2026-06-08 09:10:38 +02:00
Víctor Falcón f4bbbfd767
fix(auth): prevent FormData crash on successful login (#503)
## What

Removes `resetOnSuccess={['password']}` from the login form.

## Sentry

- Fixes PHP-LARAVEL-2Y — `TypeError: Failed to construct 'FormData':
parameter 1 is not of type 'HTMLFormElement'.` (6 users, on `/login`)

## Root cause

On a successful login the server redirects to the dashboard, which
**unmounts the login form**. `resetOnSuccess` then fires a form reset,
and Inertia's reset handler constructs `new FormData(ref)` from the form
ref — now stale/null — which throws.

The reset serves no purpose on login (the user navigates away
regardless), so removing it eliminates the crash.

## Tests

- Added `resources/js/pages/auth/login.test.tsx`: renders the login page
and asserts the `<Form>` is not configured to reset on success.
2026-06-08 09:03:15 +02:00
Víctor Falcón a69c602366
fix(transactions): prevent crash when sorting by nullable column (#501)
## Problem
`GET /transactions` throws `InvalidArgumentException: Illegal operator
and value combination` (Sentry
[PHP-LARAVEL-34](https://whisper-money.sentry.io/issues/PHP-LARAVEL-34)
— escalating, 76 occurrences, 2 users).

## Root cause
Laravel's `cursorPaginate()` builds `where(orderColumn, '>',
cursorValue)` for the next page. When sorting by a **nullable** column
(`creditor_name` / `debtor_name`) and the boundary row's value is
`null`, the cursor encodes `null` → `prepareValueAndOperator` rejects
`where(col, '>', null)`. Default sort (`transaction_date`, NOT NULL)
never hits it.

## Fix
When sorting by a nullable column, select `COALESCE(col, '') as
col_sort` and order by the alias. Cursor reads the coalesced
(never-null) value; Laravel rewrites the WHERE to the `COALESCE`
expression. Alias is hidden from serialization.

## Tests
- Reproduces cross-page cursor pagination with null values in the sort
column (500 → 200).
- Asserts the `*_sort` alias is not leaked to the frontend.

Fixes PHP-LARAVEL-34
2026-06-06 17:23:21 +02:00
Víctor Falcón a7dde5fbc5
feat(open-banking): finalize bank connection without a session via state token (#498)
## Why

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

## What

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

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

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

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

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

## Flow

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

## Tests

36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
2026-06-06 12:12:07 +02:00
Víctor Falcón cb728ce176
fix(perf): batch feature flag resolution in shared Inertia data (#500)
## What

Collapse the two Pennant feature-flag checks in
`HandleInertiaRequests::resolveFeatureFlags()` into a single
`Feature::for($user)->values([...])` call.

## Why

The `transactionAnalysis` flag added in #496 introduced a **second**
Pennant DB lookup that runs on **every** page. That pushed the account
show page from 18 → 19 queries, breaking the `performance-tests` job on
`main`:

```
Account Show: Expected at most 18 queries, but 19 were executed.
```

Batching keeps shared data at a single query regardless of how many
flags we add, so this fixes the regression without bumping the
threshold.

## Testing

- `./vendor/bin/pest --testsuite=Performance` — 25 passed
- `tests/Feature/InertiaSharedDataTest.php` — 8 passed
- Pint clean
2026-06-06 12:00:12 +02:00
Víctor Falcón 58254fcede
feat(auth): show/hide toggle on password fields (#499)
## What

Add an eye icon to every password field that toggles between masked and
plaintext, so users can verify what they typed.

New reusable `PasswordInput` component
(`components/ui/password-input.tsx`) wrapping the base `Input` with an
`Eye`/`EyeOff` toggle.

## Where

- **Auth**: login, register (password + confirm), reset password
(password + confirm)
- **Settings**: password form & account form (current + new + confirm)
- **Dialogs**: delete-account confirmation, encryption unlock dialog

## Details

- Toggle is keyboard-skipped (`tabIndex={-1}`), mirrors the input's
`disabled` state.
- Accessible: `aria-pressed` + `aria-label` (`Show password` / `Hide
password`), translated and added to `lang/es.json`.
- Dark-mode aware via existing `text-muted-foreground` /
`hover:text-foreground` tokens.

## Tests

- New vitest suite for `PasswordInput` (mask default, toggle, ref
forwarding, disabled state) — 4 passing.
- `LocalizationTest` passing (new translation keys present).
- eslint + prettier clean.

## Not included

Left out (can add on request): `confirm-password.tsx`,
`setup-encryption.tsx`, and the open-banking API key/secret fields.
2026-06-06 11:42:20 +02:00
Víctor Falcón 91929477ab
feat(banking): add command to disconnect connections by id (#497)
## What

Adds a `banking:disconnect` artisan command to disconnect (soft-delete)
one or more banking connections by ID, for ops use on prod.

```bash
php artisan banking:disconnect <id1>,<id2>,<id3>
php artisan banking:disconnect <id1>,<id2> --delete-accounts
```

## How

- Parses comma-separated IDs (trims spaces, dedups).
- Reuses the existing `DisconnectBankingConnection` action, which:
- Revokes the session on Enable Banking's side (`DELETE /sessions/{id}`)
when the connection is an active Enable Banking one.
  - Sets status to `Revoked` and soft-deletes the connection.
- Default behavior unlinks linked accounts (kept as manual accounts).
`--delete-accounts` hard-deletes accounts, transactions and balances.
- Warns on unknown IDs, reports per-connection results, and exits with
failure if any ID was missing or any disconnect threw.
- A provider-side revoke failure does not block the soft-delete (action
catches + logs a warning), matching the existing
`banking:cancel-free-enablebanking` behavior.

## Tests

`tests/Feature/DisconnectBankingConnectionsCommandTest.php` — covers
multi-ID disconnect + session revoke, `--delete-accounts`, missing IDs,
and no-match. All pass.
2026-06-06 11:16:01 +02:00
Víctor Falcón 8df44c2ef4
feat(transactions): save and reuse transaction filters (#496)
## What

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

## Why

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

## Changes

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

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

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

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

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

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

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

## Fix

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

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

## Tests

Added `category and label filters combine with OR` to
`TransactionFilterTest`. All 34 filter + bulk-update tests pass; pint
clean.
2026-06-05 17:11:02 +02:00
Víctor Falcón 744d874464
fix(import): honor selected date format for CSV imports (#494)
## Problem

Importing a CSV with `DD/MM/YYYY` dates (e.g. `02/06/2026`) parsed them
as `MM/DD/YYYY`, and the **Date Format** selector either didn't appear
or had no effect — the preview showed the same wrong date regardless of
the chosen format.

## Root cause

The `xlsx` parser coerced CSV date-like strings into Excel **serial
numbers** at read time, guessing the format itself. `parseDate` then hit
its numeric short-circuit and **ignored the selected format entirely**,
so the Date Format radio never changed the preview.

On top of that, ambiguous dates (valid as both DD/MM and MM/DD) were
resolved silently by browser locale, and a saved import config force-hid
the selector — so a wrong guess couldn't be corrected.

## Changes

- **`parseFile` reads with `raw: true`** — CSV cells stay as their
original strings; `parseDate` applies the chosen format. Native
`.xls/.xlsx` dates still arrive as numbers and use the serial path.
- **`detectDateFormat()`** now returns `{ format, ambiguous }`. When
multiple formats parse the sample equally, the importer keeps the Date
Format selector visible (defaulting to the locale/saved best guess) —
even when a saved config exists — so a previously-saved wrong format can
be fixed. `autoDetectDateFormat()` kept as a thin wrapper.
- Applied to both the transactions and account-balances import drawers.
- **UI:** moved the Date Format selector above the preview and switched
the mapping form from broken Tailwind v4 `space-y`/`space-x` to
`flex`/`gap`.

## Tests

- New `parseFile` regression test: `02/06/2026` stays a string and
parses to June (DD-MM) vs February (MM-DD).
- New `detectDateFormat` tests for the `ambiguous` flag.
- All 39 `file-parser` tests pass; lint/format clean.
2026-06-05 15:10:48 +02:00
Víctor Falcón 300188f135
feat(landing): real testimonials with gravatar/facehash avatars (#493)
## Summary
- Replaces the placeholder landing testimonials with **11 real ones**
sourced from welcome-email replies and the Discord community.
- All testimonials shown in **English** with **Spanish translations**
added to `lang/es.json`.
- Adds a testimonial from Víctor Falcón (co-owner).

## Avatars
- Uses **Gravatar** with `d=404`; on miss, Radix `Avatar` falls back to
**`<Facehash>`** (same config as `user-info.tsx`).
- Privacy: raw emails are **not** stored in the repo — only precomputed
MD5 hashes.

## Layout
- Testimonials render in **two marquee rows**, same direction but with
different speeds + a phase offset so cards don't line up into a grid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-05 14:37:49 +02:00
Víctor Falcón e62f1d6aac
refactor(api): standardize serialization via model $hidden (#492)
## What

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

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

## Why

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

## How

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

## Notes for reviewers

- Per-commit breakdown: each model standardized in its own commit for
easy review.
- Tests added/updated for each model to assert the serialized shape
(hidden columns absent, relations present).
- Full suite: 1382 passed / 1 skipped / 0 failed. `pint` clean.
2026-06-05 13:57:34 +02:00
Víctor Falcón 45e25e018d
feat: optionally update manual account balance on transaction delete (#491)
## What

When deleting a transaction that belongs to a **manual**
(non-bank-connected) account, the user can now opt to update the
account's **current balance** for today, so it stays accurate without
re-syncing.

- Deleting an **expense** (amount < 0) → balance **increases** by the
amount.
- Deleting **income** (amount > 0) → balance **decreases** by the
amount.
- Formula: `new_balance = current_balance − transaction.amount`. If
today has no balance row, it's seeded from the latest known balance.
- Available for **single delete** and **bulk delete** (a checkbox in the
confirmation dialog).
- Connected accounts are never touched — their balances come from bank
sync.
- On the account page, the balance display refreshes automatically after
the delete (no page reload).

## How

- New `ManualBalanceAdjuster` service; `TransactionController@destroy`
calls it when the request carries `update_balance` and the account is
manual.
- Frontend `transactionSyncService.delete/deleteMany` accept an
`updateBalance` option; both delete dialogs show the checkbox only when
a manual account is affected.
- `TransactionList` gains an `onBalanceUpdated` callback;
`Accounts/Show` uses it to bump the balance chart's refresh key.
- Added `banking_connection_id` to the accounts payload (transactions,
budgets, and the shared Inertia props feeding accounts/show) so the
frontend can identify manual accounts.

<img width="896" height="400" alt="image"
src="https://github.com/user-attachments/assets/e7f35ccb-13a1-40a1-bf04-615752bddf1a"
/>
2026-06-05 11:30:31 +02:00
Víctor Falcón 14b79557d7
fix: verify email via signed link without requiring login (#490)
## Problem

When the verification email link opens in a browser where the user is
not logged in (common on phones, where the link escapes the app), the
`verification.verify` route's `auth` middleware redirects to login and
the email is never verified.

## Fix

The signed verification URL already self-secures via `id` + `hash` +
signature + expiry, so the `auth` requirement was redundant. This adds a
public, signed-only verify route and points the verification email at
it.

- `Auth/VerifyEmailController` — resolves the user by `id`, validates
`hash` with `hash_equals(sha1(email))`, marks verified and fires
`Verified`. Guests → `login` with a success status; the same logged-in
user → `dashboard?verified=1`.
- New route `GET /verify-email/{id}/{hash}` with `signed` + `throttle`
(name `verification.verify.public`). Distinct URI/name so it doesn't
clash with Fortify's existing auth-protected route, which stays intact.
- `VerifyEmailNotification::verificationUrl()` overridden to sign the
public route.

Now the link verifies regardless of login state, and the user can return
to the app and sign in.

## Tests

- 6 new cases in `EmailVerificationTest` (logged-out verify, logged-in
verify, bad hash, unsigned request, already-verified no refire).
- 1 new case in `VerificationNotificationTest` asserting the email links
the public signed route.
- All pass; pint clean.
2026-06-05 10:01:32 +02:00
Víctor Falcón 2a0d6c8258
ci: deploy webhook 3 attempts with 10/20 min backoff (#489)
Reduce deploy webhook retries to 3 attempts with longer waits so a slow
Coolify rebuild can finish before giving up.

- Attempt 1 → fail → wait 10 min
- Attempt 2 → fail → wait 20 min
- Attempt 3 → fail → give up

Total window ~30 min (was 10 attempts / ~7 min). Connection-level
timeouts (curl exit 28) were exhausting retries before the box came
back.
2026-06-04 14:14:22 +02:00
Víctor Falcón f8cc8816a8
feat: enable category tree for all users, remove flag (#488)
## Summary
Removes the `CategoryTree` Pennant feature flag so the parent/child
category tree UI is active for all existing and new users.

## Changes
- Delete `app/Features/CategoryTree.php` (flag resolved `false`).
- `CategoryController` — drop `Feature`/`CategoryTreeFeature` imports
and the `categoryTreeEnabled` Inertia prop.
- `parent-category-field.tsx` — remove the `usePage` flag read and `if
(!enabled) return null` gate; the parent field now always renders.

## Testing
- `vendor/bin/pint --dirty` — pass
- `bun run lint` — clean (1 pre-existing unrelated warning in
`chart.tsx`)
- `php artisan test tests/Feature/Settings/CategoryTreeTest.php` — 11
passed
- `php artisan test tests/Feature/Settings/CategoryTest.php` — 32 passed
2026-06-04 13:44:57 +02:00
Víctor Falcón 721cbef024
feat: single-open Sankey expand, fit overflowing children (#487)
## What

- Expanding a category on the Sankey chart now **collapses any other
open category**, so only one subcategory column is shown at a time.
- Grow the SVG \`viewBox\` to the real content bounds so tall child
stacks no longer get **clipped above or below** the canvas.

## Why

Multiple simultaneously-expanded categories cluttered the chart, and a
parent with 3+ children produced a stack taller than the canvas — the
top entries were cut off (e.g. "Traveling €2,011" rendered half-hidden).

## Tests

- Added a test asserting opening a second parent collapses the first.
- Existing expand/collapse tests still pass (5/5).
2026-06-04 11:35:26 +02:00
Víctor Falcón 53d051800b
feat: expand parent categories inline in breakdowns (#486)
## What

Expand/collapse child categories inline in three places:
- Dashboard → **Top spending categories**
- Cashflow → **Income sources**
- Cashflow → **Expense categories**

## How it looks

<img width="1225" height="928" alt="image"
src="https://github.com/user-attachments/assets/aadce904-bfdd-46eb-b919-8695577845d7"
/>

## Implementation

**Frontend**
- `useExpandableCategories` hook: tracks expanded set, lazy-fetches
children once, caches, resets on period change.
- `AnimatedCollapse` component for the height animation.
- Recursive rows in `BreakdownCard` and `TopCategoriesCard`.

**Backend**
- Extracted `rollUpByTree`/`displayNodeFor` out of
`CashflowAnalyticsController` into `CategoryTree::rollUp` (now shared by
cashflow + dashboard).
- Dashboard `top-categories` emits `has_children`/`is_direct` and
accepts `?parent` to drill into a category's children (children + a
direct "Parent" node), mirroring the existing cashflow breakdown drill.
- Added Spanish translations for the new toggle labels.

## Tests
- Backend: `has_children` true/false + parent-drill split (children +
direct node) for dashboard top categories.
- Component: chevron expands, lazily fetches `parent=…`, renders the
child, flips to "Hide subcategories".
- Full suite green; pint / eslint / prettier clean.

> Note: children fetch lazily on first expand (same pattern as the
Sankey inline expand).
2026-06-04 11:19:21 +02:00
Víctor Falcón 679c8d7bff
feat: expand Sankey subcategories inline (#485)
## What

Clicking a parent category in the cashflow **Money Flow** Sankey now
expands its children inline as an extra column branching off the parent
— expenses grow to the right, income to the left — instead of swapping
the whole chart for a drilled-in view.

<img width="782" height="392" alt="image"
src="https://github.com/user-attachments/assets/6ce7d925-19d2-4744-a5da-6884a9583a4d"
/>

## Behaviour

- Toggle: click a parent to expand, click again to collapse. Multiple
parents can be open at once.
- One level deep (a child with its own children is not further
expandable).
- The parent stays as an intermediate node and its flow splits into the
children + a node for transactions booked directly on the parent
(labelled **Parent**).
- Changing the period resets expansions.

## Implementation

- `sankey-chart.tsx`: dynamic column layout (was fixed 3-column); lazily
fetches children via the existing `/api/cashflow/sankey?parent=`
endpoint and caches them; label + amount now render in a `foreignObject`
so flexbox handles spacing, vertical centering, and CSS-ellipsis
truncation (full name kept in a `title`); expand affordance is a
`ChevronsRight` icon.
- Child nodes use the same `MIN_NODE_HEIGHT` and gap as top-level nodes,
centered on the parent so links fan out cleanly.
- Removed the old drill-replace + breadcrumb flow (and the now-unused
`sankeyParent` hook option).
- Backend: the direct-transactions node is renamed from `"<name>
(direct)"` to `"Parent"` (+ `es` translation).
2026-06-04 10:31:40 +02:00
Víctor Falcón e3cbbb2e0d
ci: make deploy webhook curl robust and observable (#484)
## Problem

The deploy step intermittently fails on CI with only:

```
Response:
HTTP Status: 000
Deployment request failed with status 000
```

`000` means curl never completed a connection — the Coolify box (which
rebuilds the image from git on deploy) has its API unreachable while a
prior build runs. But the step gave no way to confirm that: `-s`
silenced curl's error message and `|| true` swallowed the exit code.

## Changes

- **Surface the real cause** — `-sS` plus `--write-out
%{exitcode}`/`%{errormsg}`/timing, so failures print the curl exit code
(7 refused, 28 timeout, …) and message instead of a blind `000`.
- **Robust parsing** — body/metadata split on an explicit
`===CURL_META===` marker instead of fragile `tail`/`sed` line counting.
- **No false timeouts** — `--max-time` raised 120→300s so a
slow-but-alive box doesn't abort mid-trigger and report a phantom `000`.
- **Wider retry window** — 10 attempts with backoff capped at 60s (~8
min) instead of 5 with a 240s dead sleep, to ride out a multi-minute
rebuild.
- **Fail fast on 4xx** — a bad token/uuid is not retryable, so don't
waste the window on it.

## Note

This makes the trigger robust and observable; the structural fix (let
Coolify pull the prebuilt ghcr image instead of rebuilding on the box)
is out of scope here.
2026-06-04 08:46:18 +02:00
Víctor Falcón 1cc10566a3
feat: parent/child category tree (#474)
## Summary

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

## Backend

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

## Frontend

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

## Tests

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

## Rollout

Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
2026-06-03 19:30:12 +02:00
Víctor Falcón faccbc3c5a
refactor(js): use InputError for inline form errors (#483)
## What

15 hand-rolled `{errors.x && <p className="text-sm
text-red-500">...</p>}` blocks across 6 components (label/category
create+edit dialogs, automation-rule form, rule builder) replaced with
the existing `InputError` component.

Bonus: `InputError` uses `text-red-600 dark:text-red-400` — the inline
copies had **no dark-mode variant** (CLAUDE.md requires dark-mode
support).

## Stats

- **-63 / +21 lines**

## Checks

- `bun run test` — 154 passed
- `bun run lint` / `format` — clean (1 pre-existing warning)
- `tsc --noEmit` — 59 pre-existing errors, none new

Part of duplication-removal series (#475–#482).
2026-06-03 19:01:21 +02:00
Víctor Falcón d27905ec3d
refactor(requests): share account detail validation rules (#481)
## What

Real-estate detail rule block (~10 fields) was duplicated in **4
requests**; loan detail block in **2**. Variants differ only by
`sometimes` on `property_type` and presence of `revaluation_percentage`.

New `ValidatesAccountDetailRules` trait:

- `realEstateDetailRules(propertyTypeSometimes:, withRevaluation:)`
- `loanDetailRules()`

## Stats

- **-115 / +83 lines**; one source of truth for these field rules

## Checks

- `php artisan test --filter="Account|RealEstate|Loan"` — 355 passed
(1744 assertions)
- `vendor/bin/pint --dirty` — pass

Part of duplication-removal series (#475–#480).
2026-06-03 19:01:03 +02:00
Víctor Falcón 4028e5dfa3
refactor(js): use shared formatMonthFromYearMonth in dashboard (#482)
## What

`use-dashboard-data.ts` had a private `formatMonth()` duplicating
`utils/date.ts` `formatMonthFromYearMonth()`.

Removed the local copy; dashboard now uses the shared util.

**Tiny visual change:** non-current-year labels on dashboard net-worth
chart render `Jan '26` instead of `Jan 26` — now consistent with
cashflow-trend and account-balance charts which already use the shared
util.

## Stats

- **-14 / +2 lines**

## Checks

- `bun run test` — 154 passed
- `bun run lint` — clean (1 pre-existing warning)

Part of duplication-removal series (#475–#481).
2026-06-03 19:00:46 +02:00
Víctor Falcón 7784f0d4fb
refactor(banks): add Bank::availableForUser scope (#480)
## What

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

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

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

## Stats

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

## Checks

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

Part of duplication-removal series (#475–#479).
2026-06-03 17:59:24 +02:00
Víctor Falcón fe5e22bcfe
refactor(policies): extract HandlesUserOwnership trait (#478)
## What

`CategoryPolicy`, `LabelPolicy`, `TransactionPolicy`,
`AutomationRulePolicy` were structurally identical: `update`/`delete`
check `$user->id === $model->user_id`, everything else returns `false`.
`AccountPolicy` same + ownership `view`.

New `app/Policies/Concerns/HandlesUserOwnership` trait; the 5 policies
now use it (Account keeps its `view` override).

`BudgetPolicy` and `RealEstateDetailPolicy` untouched — different
semantics (all-allowed / ownership via parent account).

## Stats

- **-252 / +112 lines**

## Checks

- `php artisan test
--filter="Categor|Label|Transaction|AutomationRule|Account|Polic"` — 607
passed (2887 assertions), 1 skipped
- `vendor/bin/pint --dirty` — pass

Part of duplication-removal series (#475, #476, #477).
2026-06-03 17:43:30 +02:00
Víctor Falcón fe692e37c3
refactor(requests): share category cashflow direction resolution (#479)
## What

`StoreCategoryRequest` and `UpdateCategoryRequest` had identical 24-line
`prepareForValidation()` deriving `cashflow_direction` from category
type.

Moved to `app/Http/Requests/Concerns/ResolvesCategoryCashflowDirection`
trait.

## Stats

- **-48 / +43 lines** (net small, removes a whole duplicated logic block
that must stay in sync)

## Checks

- `php artisan test --filter=Categor` — 104 passed (565 assertions)
- `vendor/bin/pint --dirty` — pass

Part of duplication-removal series (#475–#478).
2026-06-03 17:39:40 +02:00
Víctor Falcón cc63a86a1c
refactor(requests): extract user-owned exists rules to trait (#477)
## What

The `Rule::exists('x', 'id')->where(fn => user_id)` ownership closure
was repeated **19×** across 10 Form Requests (categories, labels,
accounts, typed accounts).

New `app/Http/Requests/Concerns/ValidatesUserOwnedResources` trait:

- `$this->userOwned('categories' | 'labels' | 'accounts')`
- `$this->userOwnedAccountOfType(AccountType::Loan | RealEstate)`

`UpdateAutomationRuleRequest` intentionally untouched — #476 makes it
extend Store.

## Stats

- **-110 / +63 lines**

## Checks

- `php artisan test
--filter="Transaction|Budget|Account|AutomationRule|RealEstate|Loan"` —
641 passed (3052 assertions), 1 skipped
- `vendor/bin/pint --dirty` — pass

Part of duplication-removal series (#475, #476).
2026-06-03 17:33:54 +02:00
Víctor Falcón 65acab4512
refactor(requests): dedupe UpdateAutomationRuleRequest (#476)
## What

`UpdateAutomationRuleRequest` was **byte-identical** to
`StoreAutomationRuleRequest` (verified with `diff`): same `rules()`,
`passedValidation()`, `withValidator()`.

Now it simply extends the Store request.

## Stats

- **-74 / +1 lines**

## Checks

- `php artisan test --filter=AutomationRule` — 63 passed (211
assertions)
- `vendor/bin/pint --dirty` — applied

Part of duplication-removal series (#475 was first).
2026-06-03 17:29:23 +02:00
Víctor Falcón aa3b811027
refactor(js): extract shared getCsrfToken util (#475)
## What

Same XSRF-TOKEN cookie-parsing snippet was duplicated inline in **12
files** (some as local `getCsrfToken()` copies, some inline
`decodeURIComponent(...)` blocks).

Extracted to single util: `resources/js/lib/csrf.ts`, with vitest
coverage.

## Why

Part of duplication-removal series — PRs that mostly delete code.

## Stats

- App code: **-89 / +23 lines**
- 12 files now import one shared util
- New: `lib/csrf.ts` (8 lines) + `lib/csrf.test.ts`

## Checks

- `bun run test` — 154 pass (3 new)
- `bun run lint` — clean (1 pre-existing warning in chart.tsx)
- `bun run format` — applied
- `tsc --noEmit` — 59 pre-existing errors on main, same 59 on branch
(none introduced)
2026-06-03 17:26:09 +02:00
Víctor Falcón e178f1b1bd
feat(settings): let users disable bank transactions email (#472)
## Summary

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

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

## Testing

- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
2026-06-02 12:24:39 +02:00
Víctor Falcón e5b493329a
feat(currencies): add Colombian and Dominican peso (#471)
## Summary

Adds **COP** (Colombian Peso) and **DOP** (Dominican Peso) to the
supported currency list for both accounts and user primary currency.

## Compatibility

Both are supported by the conversion API (fawazahmed0 currency-api):
- 1 USD = 3686.83 COP
- 1 USD = 58.74 DOP

No migration needed — `currency_code` columns are plain strings and
validation reads the whitelist from `config/currencies.php`.

## Changes

- `config/currencies.php` — add COP + DOP (primary + account)
- `resources/js/utils/currency.ts` — add `RD$` symbol for DOP
- `tests/Feature/Settings/AccountTest.php` — acceptance tests for both

## Testing

`php artisan test --filter="Colombian|Dominican"` → passing
2026-06-01 18:14:16 +02:00
Víctor Falcón f9d1303d98
feat(importer): support YYYYMMDD date format (#470)
## Summary
Adds a compact `YYYYMMDD` (no separators) date format to the
transactions and balance importers.

- New `DateFormat.YearMonthDayCompact = 'YYYYMMDD'` enum value
- `parseDate` parses the 8-digit form (`20241231` → `2024-12-31`)
- `autoDetectDateFormat` recognizes it (unambiguous, no separators)
- Both mapping UIs (transactions + balances) expose it as a selectable
option

## Testing
- `bunx vitest run resources/js/lib/file-parser.test.ts` — 34 passed
- Added tests: compact auto-detection + compact conversion
- `bun run format`, `bun run lint` clean
2026-06-01 16:44:46 +02:00
Víctor Falcón d68fee6c2d
fix(sentry): only report errors in production (#467)
## What

Gate Sentry error reporting on the production environment so nothing is
sent from local, staging, or testing.

- **Backend** (`config/sentry.php`): DSN resolves to `null` unless
`APP_ENV=production`. A null DSN disables the Laravel SDK entirely.
- **Frontend** (`resources/js/app.tsx`): `enabled` now requires
`import.meta.env.MODE === 'production'` (plus a DSN).
2026-06-01 12:41:31 +02:00
Víctor Falcón e3d77ce933
chore: release v0.2.4 (#468)
Patch release `v0.2.4`. Bumps version + updates CHANGELOG via
release-it.

Tag `v0.2.4` + GitHub Release created after merge (tag must point at the
merged commit).
2026-06-01 12:40:29 +02:00
Víctor Falcón 71dd6e2b7f
feat(budgets): track multiple categories and labels per budget (#466)
## Summary

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

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

## Changes

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

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

## Testing
- Updated all budget/transaction/listener/browser tests for the pivot
model; added cases for multi-category matching, mixed category+label
pooling, and store validation (empty selection + foreign-ownership).
- Added the new `__()` strings to `lang/es.json`.
- Local `pint`, `lint`, `format` pass. Relying on CI for the full suite.
2026-06-01 12:32:23 +02:00
Víctor Falcón 5ce439f463
feat(cashflow): rework summary cards into net + saved/invested (#465)
## What

Reworks the two top summary cards on the **Cashflow** page so they no
longer both show net flow.

### Net Cashflow (card 1)
- Shows the net amount **and** its share of income (%).
- Compares **both** the amount and the percentage with the previous
period.

### Saved & Invested (card 2, replaces "Savings Rate")
- Headline = money moved to savings + investment categories (amount).
- Percentage = that total as a share of **net cashflow** (income −
expenses).
- Compares amount and percentage vs. the previous period.
- Keeps the per-category Saved / Invested breakdown at the bottom.
- Adds a **click-triggered help popover** (shadcn `Popover`, works on
touch + desktop, dismisses on outside click / Escape) explaining the
numbers come from transactions using a **"saving"** or **"investment"**
category type.

### i18n
- The card's "Saved" label uses a dedicated key so it renders
**"Ahorrado"** on the cashflow card while form buttons keep
**"Guardado"**. A minimal `lang/en.json` keeps the English label as
"Saved".
- Spanish strings added for the new card + popover copy.

## Testing
- `vitest` — net-cashflow + saved-invested card specs (amount/percentage
comparisons, click-to-reveal popover)
- Full `pest` suite (excl. Browser) — green; localization key-coverage
test passes
- `pint --test`, `prettier --check`, `eslint` — clean
- production `bun run build` — succeeds
2026-06-01 11:37:28 +02:00
Víctor Falcón 26cf79d88b
chore: install laravel/pao dev dependency (#464)
Installs `laravel/pao` as a dev dependency via `composer require
laravel/pao --dev`.

## Changes
- Add `laravel/pao ^1.1` to `require-dev`
- Update `composer.lock`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-01 10:01:38 +02:00
Víctor Falcón 448bb2e64a
feat(posthog): route analytics through reverse proxy (#463)
## What

Route PostHog through the managed reverse proxy at `t.whisper.money` to
dodge ad blockers and keep analytics first-party.

We use the **JS library method** (`posthog-js` in
`resources/js/lib/posthog.ts`), so only that init needed changes — no
snippet.

## Changes
- `api_host` default → `https://t.whisper.money` (proxy domain)
- Add `ui_host` (default `https://eu.posthog.com`) so dashboard links
resolve back to PostHog correctly
- Both stay env-overridable: `VITE_POSTHOG_HOST`, new
`VITE_POSTHOG_UI_HOST`
- Update `.env.example`

## Test
- `posthog.test.ts` passes
- format + lint clean (one pre-existing unrelated warning)
2026-06-01 09:29:30 +02:00
Víctor Falcón 96ee311299
fix(register): don't block signup on unrecognized browser timezone (#462)
## Problem

Some users reported they couldn't register: they filled the form
correctly, pressed the button, saw a loading spinner, then got bounced
back to the registration page with **no error message**. Meanwhile 20-30
users register fine every day, and the issue wasn't reproducible by the
team.

## Root cause

The register form sends a **hidden, browser-auto-detected `timezone`**
field (`Intl.DateTimeFormat().resolvedOptions().timeZone`). The backend
validated it with Laravel's `timezone` rule:

```php
'timezone' => ['nullable', 'string', 'timezone'],
```

That rule rejects **legacy IANA aliases** that many browsers/OSes still
emit — e.g. `Asia/Calcutta`, `Asia/Saigon`, `Asia/Rangoon`,
`US/Pacific`. Proof on PHP 8.4:

```
in_array("Asia/Calcutta", DateTimeZone::listIdentifiers())  →  false
```

When validation failed, Fortify redirected back with a `timezone` error
— but the form had **no error display** for timezone (every other field
has one). Result: spinner → silent bounce → no message → and the user
can't fix a hidden field anyway. The team couldn't reproduce because
their browsers send canonical zones that pass. Production tzdata may
reject even more zones.

## Fix

- Stop validating the auto-detected hidden field against the strict
timezone list.
- Add `normalizeTimezone()`: keep recognized zones (including BC aliases
via `DateTimeZone::ALL_WITH_BC`), drop anything unrecognized to `null`.
Registration can never be blocked by it again.
- Surface `errors.timezone` in the form as a safety net so a
hidden-field failure can never be silent in the future.

## Tests

- Legacy alias `Asia/Calcutta` → registers, stored as-is.
- Unrecognized zone → registers, stored `null`.
- All existing registration tests pass (13 passed).
2026-06-01 09:03:15 +02:00
Víctor Falcón a71626a350
feat(currency): add Saudi Riyal (SAR) (#461)
## What

Adds Saudi Riyal (SAR) as a supported currency for both user primary
currency and account currency.

## Why

SAR is fully supported by the `@fawazahmed0/currency-api` conversion
backend (verified rates are returned), so it works end-to-end with the
existing conversion system.

## Changes

- `config/currencies.php`: add SAR with `allows_primary: true`,
`allows_account: true`
- Tests mirroring the BRL cases in `AccountTest` and `ProfileUpdateTest`

## Testing

- `php artisan test` on both settings test files — 32 passed
- `vendor/bin/pint --dirty` — clean
2026-06-01 08:52:21 +02:00
Víctor Falcón f9bf0ea5ff
feat(discord): enrich Stripe event messages and dedupe deliveries (#460)
## Why

The Stripe subscription events posted to the Discord admin feed weren't
useful (only status + raw `cus_123` id) and sometimes arrived duplicated
— including two cancellation messages for the same subscription.

## What changed

**Richer messages** (`PostStripeEventToDiscord`)
- Customer name + email (resolved via the Stripe API), plan + interval
(`€19.99 / month`), status, subscription id.
- A `Changed` field built from Stripe's `previous_attributes` diff (e.g.
`Status: trialing → active`).
- Cancellation reason, trial-end and period-end dates where relevant.
- Invoices now show invoice number + subscription id.

**Deduplication (two causes fixed)**
- *Webhook/queue retries*: guard on the Stripe event id via
`Cache::add(..., 24h)` — first delivery wins, retries skipped.
- *Double cancellation*: Stripe fires `subscription.updated` (cancel
scheduled) **then** `subscription.deleted`. We now label the scheduled
one `🗓️ Cancellation scheduled` distinctly from `👋 Subscription
cancelled`, and only post `subscription.updated` when something
meaningful changed (status, plan, or cancellation), dropping trivial
churn.

**New** `StripeCustomerResolver` service — resolves `Name (email)` from
a customer id, falls back to the id on any error. Injectable so it's
stubbed in tests (no network).

## Tests

9 cases covering rich fields, dedup, scheduled-vs-deleted cancellation,
status-change diff, trivial-update skip, deletion reason, and invoice
amounts. All passing.

Note: this adds one Stripe API call per subscription/invoice event, made
inside the queued listener (no webhook latency).
2026-05-31 12:50:37 +02:00
Víctor Falcón 85ea3cc714
feat(accounts): show 50 transactions per page and link to full list (#459)
## What

- Bump the account page transaction list page size from **10 → 50**
(`Accounts/Show.tsx`).
- Add a **View in Transactions** button next to *Load more* (only on the
account page) that opens the Transactions page pre-filtered to the
current account via `account_ids`.
- Add Spanish translation `View in Transactions` → `Ver en
Transacciones`.

## Notes

- The account list is synced client-side (IndexedDB); `pageSize`
controls how many rows render before *Load more*, so no backend change
needed.
- The Transactions index already defaults to `per_page = 50` and reads
`account_ids` from the query string.

## Testing

- `vitest` Accounts/Show tests pass.
- `bun run format`, `bun run lint`, `bun run build` pass.
2026-05-30 18:48:21 +02:00
Víctor Falcón 0b528b7902
feat: add Discord admin feed for daily stats and Stripe events (#458)
## What

Adds a private Discord admin feed with two flows, both posting through
one webhook (`DISCORD_WEBHOOK_URL`):

### 1. Daily stats — `stats:daily-report`
Scheduled **09:00 Europe/Madrid**. Posts an embed with:
- New users created **yesterday** (Madrid calendar day, converted to UTC
for the query)
- Total users
- Active/trialing counts + current & projected **MRR / ARR** per
currency

Reuses the #457 Stripe stats logic, extracted into
`SubscriptionStatsCollector` so the existing `stripe:subscription-stats`
command and this report share one source of truth.

### 2. Stripe events — `PostStripeEventToDiscord`
Queued listener on Cashier's `WebhookReceived`. Posts on:
- `customer.subscription.created` / `updated` / `deleted`
- `invoice.payment_succeeded` / `payment_failed`

## Setup
- `DISCORD_WEBHOOK_URL` — added to prod  (set locally to test)
- Stripe dashboard must send the 5 event types above to the existing
Cashier `/stripe/webhook` endpoint
- A queue worker must be running (listener is `ShouldQueue`)

## Tests
13 passing: Discord client (3), daily report (2), Stripe event listener
(4), plus the existing stats command (4) still green after the refactor.
2026-05-30 18:14:46 +02:00
Víctor Falcón 670a0a65c7
feat: add Stripe subscription stats command (#457)
## What

Adds a `stripe:subscription-stats` Artisan command that prints
subscription stats to the CLI.

Shows, per currency:
- **Active subs** count + their MRR
- **Trialing subs** count + their MRR
- **Current MRR & ARR** (active subscriptions only)
- **Projected MRR & ARR** (if trialing subs convert to active)

## How

- Queries Stripe directly via `Cashier::stripe()`, auto-paginating
`active` and `trialing` subscriptions.
- MRR per subscription sums all items, normalizing each price to a
monthly value (handles day/week/month/year intervals, `interval_count`,
and quantity).
- Groups results per currency (EUR, BRL, …) since aggregating across
currencies would be incorrect.

```bash
php artisan stripe:subscription-stats
```

## Tests

4 Pest feature tests covering empty state, MRR/ARR math, per-currency
grouping, and quantity handling. Mocks the Stripe client.
2026-05-30 17:37:17 +02:00
Víctor Falcón 144d919c0b
fix(import): correct balance for same-day, zero and negative values (#456)
## Problem

CSV imports only carry a date (no timestamp). Rows are newest-on-top and
we import top-to-bottom. Three balance bugs:

1. **Same-day balance wrong.** The store loop did `Map.set(date,
balance)` per row, so when a day had 2+ transactions the **last**
(bottom = oldest) row overwrote the newest. The correct daily balance is
the **first** (newest) row.
2. **Zero balance dropped.** The parser guard `row[mapping.balance]`
treated numeric `0` as falsy, so a real `0` balance became `null`. `0`
is valid.
3. **Negative balance lost its sign.** `parseAmount` stripped `-` via
`replace(/[^\d]/g,'')`, so negative balances (and amounts) came out
positive.

## Fix

- `import-transactions-drawer.tsx`: keep the **first** occurrence per
date (`!balancesToImport.has(date)`).
- `file-parser.ts`: guard balance on null/undefined/empty-string only,
so `0` is kept.
- `file-parser.ts`: `parseAmount` now detects a leading `-` (or `(...)`)
and applies the sign — fixes negative balances and negative amounts.

## Tests

Added 4 cases to `file-parser.test.ts`: zero (number), zero (string),
negative, empty→null. Full suite: **144 passed**.

> Note: the same-day "keep first" logic lives in the React component and
relies on `newTransactions` staying in CSV order (top = newest), which
holds in current code.
2026-05-30 16:19:27 +02:00
Víctor Falcón 65175e184a
fix(accounts): translate update button in edit account modal (#455)
## Problem
Update button in edit account modal showed hardcoded English (`Update` /
`Updating...`) instead of translated text.

## Fix
Wrap button label in `__()` so it resolves the existing `es.json` keys
(`Actualizar` / `Actualizando...`).

## Test
`edit-account-dialog.test.tsx` passes.
2026-05-30 16:09:30 +02:00
Víctor Falcón 05ee8dc442
feat(categorize): show debtor and creditor names (#454)
## What
Show the debtor and creditor names (when present) on the categorization
page (`/transactions/categorize`).

## Changes
- `TransactionController@categorize`: select `creditor_name` and
`debtor_name` — they were never sent to the frontend.
- `categorizer-card.tsx`: render Creditor/Debtor rows below the account
when present, using the same i18n labels as the transaction columns.
- `TransactionTest`: assert both names are exposed on the categorize
page.

## Testing
- `php artisan test --filter="debtor and creditor"` — passes.
- pint, format, lint clean.
2026-05-30 12:27:59 +02:00
Víctor Falcón 4dec0ab7ca
feat: add BRL currency support (#453)
## Summary
- Add BRL (Brazilian Real) to supported currency list
(`config/currencies.php`), allowed as both primary and account currency.

## API compatibility
- Verified `@fawazahmed0/currency-api` supports BRL in both directions
(e.g. USD→BRL ≈ 5.05). No conversion changes needed.

## Tests
- Added BRL account-creation test (`AccountTest.php`).
- Added BRL primary-currency test (`ProfileUpdateTest.php`).
- Both pass.
2026-05-29 16:33:21 +02:00
Víctor Falcón cbc2f4f85f
chore: update Discord invite link (#452)
Swap Discord invite link `discord.gg/2WZmDW9QZ8` →
`discord.gg/m8hUhx6D9D` across all files (README, header, user menu,
connections page, welcome email, test).
2026-05-29 15:56:48 +02:00
Víctor Falcón 741dc49d53
fix(logging): keep laravel.log writable across container UIDs (#451)
Fixes the largest production noise cluster — **8 duplicate issues**, all
`UnexpectedValueException: The stream or file
"/app/storage/logs/laravel.log" could not be opened in append mode:
Failed to open stream: Permission denied`:

`PHP-LARAVEL-G`, `PHP-LARAVEL-Z`, `PHP-LARAVEL-12`, `PHP-LARAVEL-2P`,
`PHP-LARAVEL-2Q`, `PHP-LARAVEL-2R`, `PHP-LARAVEL-2S`, `PHP-LARAVEL-2T`.

## Root cause

`docker/entrypoint.sh` runs `migrate` / `config:cache` / etc. **as
root** before supervisor starts. With the default `umask 022`, the first
log line written during boot creates `laravel.log` as `root:root 0644`.
The persisted `whisper-storage` named volume
(`docker-compose.production.yml`) keeps that stale, non-group-writable
file across deploys — so the `www-data` php-fpm and queue workers can't
append to it and every code path that logs throws. 8 code paths → 8
Sentry issues.

## Fix

- **entrypoint**: `umask 0002`, pre-create `laravel.log` group-writable
before any artisan command, and normalize it to `0664` in the
post-artisan re-apply step.
- **config/logging.php**: `permission => 0664` on the `single` and
`daily` channels, so files Laravel creates itself (including daily
rotation) stay group-writable.

Net effect: whoever writes first (root at boot or www-data at runtime),
the file is owned `www-data` and group-writable `0664` — no more
`EACCES`.

## Tests

- `single` and `daily` channels expose `permission => 0664`.
- `bash -n docker/entrypoint.sh` passes.

## Follow-up

Once deployed and confirmed quiet, the 8 issues should be merged into
one canonical group in Sentry.

Fixes PHP-LARAVEL-G, PHP-LARAVEL-Z, PHP-LARAVEL-12, PHP-LARAVEL-2P,
PHP-LARAVEL-2Q, PHP-LARAVEL-2R, PHP-LARAVEL-2S, PHP-LARAVEL-2T.
2026-05-29 15:10:50 +02:00
Víctor Falcón 64b78e3680
fix(banking): handle balance-fetch timeouts and silence handled retries (#450)
Fixes two linked production banking-sync issues.

## PHP-LARAVEL-W — `cURL error 28: timed out` in
`EnableBankingProvider::getBalances` (8 events, 4 users, High)

`getBalances` called `$response->throw()` raw, so a connection timeout
(or ASPSP error) escaped as an **unhandled**
`ConnectionException`/`RequestException` and crashed the sync.
`getTransactions` already wraps these in
`TransientBankingProviderException` (which `implements ShouldntReport`
and is handled as a transient, retryable error in
`SyncBankingConnectionJob`).

→ `getBalances` now follows the exact same pattern. Genuine validation
errors (non-ASPSP 4xx) stay reportable.

## PHP-LARAVEL-2D — `SyncBankingConnectionJob has been attempted too
many times` (High, regressed)

The hanging balance call above pushed the job past its 120s `timeout`,
the worker was killed mid-job, and the retry tripped a
`MaxAttemptsExceededException`. That exception is thrown by the queue
worker (not catchable in `handle()`), and the job's `failed()` handler
**already** records the terminal `Error` state on the connection — so
the Sentry report is redundant operational noise.

→ Fixing W removes the main cause of the timeout. Additionally,
`MaxAttemptsExceededException` is no longer reported **for this job
only** (scoped via `dontReportWhen` on `$e->job?->resolveName()`); other
jobs still report it.

## Tests
- `getBalances` wraps connection failures and ASPSP errors as
non-reportable transient errors; keeps non-ASPSP client errors
reportable.
- `MaxAttemptsExceededException` is not reported for
`SyncBankingConnectionJob`, but still reported for other jobs.

Fixes PHP-LARAVEL-W, PHP-LARAVEL-2D.
2026-05-29 14:58:38 +02:00
Víctor Falcón bef657c2ed
fix(currency): degrade gracefully when rates return 404 (#449)
## Problem

`PHP-LARAVEL-2M` (High, escalating, 38 events) — `RuntimeException:
Failed to fetch currency rates for xxx on 2025-12-31: HTTP 404` crashing
`/dashboard`.

A user holds an account in currency `xxx` (ISO 4217 "no/unknown
currency"). No rate file exists for it, so every candidate URL returns
404. `CurrencyConversionService::fetchRates` threw an unhandled
`RuntimeException` that bubbled past the graceful-degradation logic in
`ExchangeRateService::convert` and broke the whole dashboard render.

## Fix

Distinguish **permanent** 404s from **transient** failures:
- All sources return 404 → log a warning and return `[]`. Callers
already handle empty rates (return unconverted / zero amount).
- Timeouts / 5xx / connection errors → still throw, so real outages stay
visible.

## Tests

- New: unknown base currency with all-404 sources returns `0.0` instead
of throwing.
- Existing "throws when both primary and fallback fail" (500s) still
passes — transient errors still surface.

Fixes PHP-LARAVEL-2M.
2026-05-29 14:44:56 +02:00
Víctor Falcón 5119528149
fix(categories): expose cashflow setting on create (#448)
## Summary
- Always show a simplified Cashflow and charts explanation in the
category form
- Explain how each category type affects cashflow, income/spending
charts, top spending categories, savings, and investments
- For transfer categories, keep a cashflow chart visibility selector so
users can choose hidden/inflow/outflow
- Show savings and investment categories as outflows in the cashflow
chart and store default/new categories as outflow
- Add a new migration to update existing production savings/investment
categories to outflow
- Avoid user-facing Sankey terminology and update Spanish copy to use
dinero/saldo instead of efectivo
- Add missing Spanish translations and browser/feature coverage

## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
- php artisan test --compact
tests/Feature/Console/ResetUserCategoriesCommandTest.php
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter="migration updates existing default saving and investment
categories|migration sets saving and investment categories to cashflow
outflow|users can create savings and investment categories|default
categories are created when user registers"
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter="sankey includes savings and investment categories on the
expense side|sankey includes outflow transfer categories on the expense
side"
- php artisan test --compact tests/Browser/CategoriesTest.php
--filter="explains savings and investment category cashflow impact in
the create dialog|can create a new transfer category with a cashflow
analytics direction"
- php artisan test --compact tests/Browser/CategoriesTest.php
- ./vendor/bin/pest --testsuite=Performance --filter="account show page
does not exceed query threshold"
- vendor/bin/pint --dirty --format agent
- npx prettier --write
resources/js/components/categories/category-cashflow-direction-fields.tsx
- npm run build (assets emitted; Sentry sourcemap upload reports local
SSL error)

## Video
- screenshots/category-create-cashflow-setting.webm

## Notes
- npm run types still fails on existing unrelated TypeScript errors.
2026-05-29 11:05:04 +02:00
Víctor Falcón 0b9406714e
fix: filter Safari cashback extension errors (#447)
## Sentry
- Issue: PHP-LARAVEL-2K
- URL: https://whisper-money.sentry.io/issues/123306290/

## Root cause
Sentry is collecting unhandled errors from a Safari/iOS browser
extension content script. Recent production events all came from
`webkit-masked-url://hidden/`, function `onResponse`, with message
`response.cashbackReminder`, on public app routes. No matching
application code exists.

## Fix
- Add a narrow client-side Sentry `beforeSend` filter for this Safari
cashback extension signature.
- Keep similarly named errors from application bundles.
- Add Vitest regression coverage.

## Verification
- `npx prettier --write resources/js/app.tsx resources/js/lib/sentry.ts
resources/js/lib/sentry.test.ts`
- `npx eslint --fix resources/js/app.tsx resources/js/lib/sentry.ts
resources/js/lib/sentry.test.ts`
- `npm test -- resources/js/lib/sentry.test.ts`
- `npm run types` (fails on existing unrelated TypeScript errors outside
touched files)
2026-05-28 15:10:49 +02:00
Víctor Falcón edb9f1c852
chore: update lead invitation schedule (#446)
## Summary
- increase scheduled lead invitation batch limit from 100 to 150
- keep Spanish translations sorted with new copy keys

## Tests
- vendor/bin/pint --dirty --format agent
- php -r 'json_decode(file_get_contents("lang/es.json"), true, 512,
JSON_THROW_ON_ERROR); echo "lang/es.json ok\n";'
- php artisan schedule:list --no-interaction
- php artisan test --compact
tests/Feature/Commands/SendUserLeadInvitationsTest.php
tests/Feature/Commands/SendUserLeadReInvitationsTest.php
2026-05-28 14:00:16 +02:00
Víctor Falcón cfa61fd23c
feat: add PKR currency support (#443)
## Summary
- Add Pakistani Rupee (PKR) to supported primary and account currencies
- Cover PKR account creation and profile currency selection

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Settings/AccountTest.php
tests/Feature/Settings/ProfileUpdateTest.php
2026-05-28 09:46:09 +02:00
Víctor Falcón 2fa822e6d9
fix(categories): allow recreate after delete (#444)
## Summary
- allow users to recreate category names after soft delete
- update category unique validation to ignore trashed rows
- add active-row unique index for category names

## Tests
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
2026-05-28 09:46:02 +02:00
Víctor Falcón 4f46ae3e2d
fix: move community link to user menu (#442)
## Summary
- Move Community link from sidebar footer into user dropdown above
Feedback.
- Hide empty sidebar footer nav group.
- Add tests for dropdown order and footer removal.

## Tests
- `npm test -- --run resources/js/components/user-menu-content.test.tsx
resources/js/providers/menu-item-provider.test.ts`

Note: `npm run types` currently fails on unrelated existing TypeScript
errors.
2026-05-27 17:39:26 +02:00
Víctor Falcón 10da06ed84
feat(transactions): add counterparty fields (#440)
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns

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

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

## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
2026-05-27 16:20:55 +02:00
Víctor Falcón 6caadad1db
fix: net category refunds in cashflow (#441)
## Summary
- net mixed-sign category transactions before cashflow analytics display
- align dashboard, cashflow trend, breakdown, and sankey behavior with
top categories
- add feature tests for refund-style expense netting

## Tests
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
tests/Feature/DashboardAnalyticsTest.php
- vendor/bin/pint --dirty --format agent
2026-05-27 15:16:50 +02:00
Víctor Falcón d5d262e9fd
fix(chart): mask stacked bar edges (#439)
## Summary
- mask stacked bar rounded edges with the card background
- clamp bar radius for tiny stacked segments
- add regression tests for shape stroke and radius

## Tests
- npx vitest run resources/js/components/ui/stacked-bar-chart.test.tsx
--reporter=verbose
- npx eslint resources/js/components/ui/stacked-bar-chart.tsx
resources/js/components/ui/stacked-bar-chart.test.tsx
2026-05-26 17:03:06 +02:00
Víctor Falcón 534a14790e
feat(accounts): add transaction action (#438)
## Summary
- add Add transaction action to disconnected transactional account
detail pages
- keep balance actions grouped as Update balance | Import balances |
menu
- center empty data-table messages vertically

## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/AccountControllerTest.php
- npm test -- resources/js/pages/Accounts/Show.test.tsx
- npx eslint resources/js/pages/Accounts/Show.tsx
resources/js/pages/Accounts/Show.test.tsx
resources/js/components/ui/data-table.tsx

Note: npm run types still fails due existing unrelated TypeScript
errors.
2026-05-26 15:57:23 +02:00
Víctor Falcón 235911b960
fix(budgets): explain locked edit fields (#437)
## Summary
- add disclaimer for locked period and carry-over budget fields
- cover disclaimer rendering with Vitest

## Tests
- npm run test --
resources/js/components/budgets/edit-budget-dialog.test.tsx
2026-05-26 15:49:53 +02:00
Víctor Falcón 0250fdc268
fix(cashflow): clarify period comparisons (#436)
## Summary
- Remove misleading primary net cashflow trend arrow
- Add previous-period comparisons for income, expenses, saved, and
invested values
- Move savings-rate comparison below the main percentage

## Tests
- npm test --
resources/js/components/cashflow/net-cashflow-card.test.tsx
resources/js/components/cashflow/savings-rate-card.test.tsx
2026-05-26 10:52:01 +02:00
Víctor Falcón 606093d311
fix: batch automation rule application (#435)
## Sentry
- Issue: PHP-LARAVEL-2C
- URL: https://whisper-money.sentry.io/issues/122336983/

## Root cause
Applying an automation rule to multiple transactions used
per-transaction `saveQuietly()` and `syncWithoutDetaching()`. Sentry
detected repeated transaction updates and pivot lookups/inserts on
`/settings/automation-rules/{automationRule}/apply`.

## Fix
- Added batched automation rule application for transaction collections.
- Bulk updates category changes in one query.
- Bulk inserts missing label pivots with `insertOrIgnore`.
- Reused batching from sync controller path and queued job chunks.

## Verification
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php`
2026-05-26 10:45:37 +02:00
Víctor Falcón d5d22b9d57
feat(leads): schedule invitation emails (#434)
## Summary
- run lead invitation emails daily at 9:00 AM
- run lead re-invitation emails daily at 9:00 AM
- use --force so scheduler runs non-interactively

## Verification
- php artisan schedule:list --no-interaction | rg
"leads:send-(re-)?invitations"
2026-05-26 08:47:28 +02:00
Víctor Falcón 7b03d7cf23
feat(leads): add user lead re-invite campaign (#432)
## Summary
- add re-invite tracking columns for user leads
- add queued re-invitation mailable and command
- default re-invite eligibility to leads invited at least 3 days ago
- localize re-invite email in Spanish for `es` leads
- add stats command output for conversion rate

## Tests
- vendor/bin/pint --dirty --format agent
- vendor/bin/phpstan analyse --memory-limit=2G
- php artisan test --compact
tests/Feature/Commands/SendUserLeadReInvitationsTest.php
tests/Feature/Mail/UserLeadReInvitationTest.php
tests/Feature/Commands/SendUserLeadInvitationsTest.php
tests/Feature/Mail/UserLeadInvitationTest.php
2026-05-26 08:35:31 +02:00
Víctor Falcón 9772cfc37c
fix(automation): avoid skipping rule matches (#433)
## Summary
- replace ordered automation match scanning from chunkById to chunk so
sorted rows are not skipped
- make regression fixture deterministic across chunk boundaries

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php --filter='matches
endpoint avoids repeated relationship queries'
- php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php
2026-05-26 08:26:20 +02:00
Víctor Falcón fd67cf7c72
fix(automation): avoid rule preview n+1 (#431)
## Sentry issue
- PHP-LARAVEL-2F: https://whisper-money.sentry.io/issues/122581787/

## Root cause
- Automation rule match preview eagerly loaded account, bank, category,
and labels for every 500-transaction chunk, even for rules that only
inspect description fields.
- Sentry flagged repeated account/bank eager-load queries across chunks
as an N+1 pattern on
`/settings/automation-rules/{automationRule}/matches`.

## Fix
- Detect variables used by an automation rule and eager load only
relationships required for evaluation.
- Keep label eager loading only when label-only skip logic needs it.
- Avoid lazy loading unused relationships while preserving full data
shape for rule evaluation.
- Update the Sentry prompt to use the `sentry` CLI workflow.

## Verification
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php`
- `php artisan test --compact
tests/Feature/AutomationRuleEvaluationTest.php
tests/Feature/AutomationRuleApplicationTest.php`
2026-05-26 08:02:46 +02:00
Víctor Falcón 0d4c68361a
fix(accounts): sync currency from first account (#430)
## Summary
- sync user currency from the first account, manual or connected
- reuse one service across manual creation and open banking
mapping/auto-create flows
- keep later accounts from overwriting user currency
- add browser signup + onboarding account creation coverage

## Prod check
- users with any account: 210
- first account currency mismatches user currency: 64
- USD user + EUR first account: 36
- first account manual users: 189
- manual-first mismatches: 61

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php
tests/Feature/OpenBanking/AuthorizationControllerTest.php
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php
tests/Feature/OpenBanking/BinanceControllerTest.php
tests/Feature/OpenBanking/BitpandaControllerTest.php
tests/Feature/OpenBanking/CoinbaseControllerTest.php
tests/Feature/Settings/AccountTest.php
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter='syncs user currency from first onboarding account after
signup'
2026-05-25 20:55:24 +02:00
Víctor Falcón 4f42de74a1
feat(landing): redirect signed-in users (#429)
## Summary
- Show signed-in landing visitors a 3-second dashboard redirect modal.
- Add cancel action and animated progress bar.
- Keep dashboard link visible when signup/auth buttons are hidden.
- Add Spanish translations.

## Tests
- `npm test --
resources/js/components/landing/authenticated-redirect-dialog.test.tsx
resources/js/components/partials/header.test.tsx`
- `npx eslint
resources/js/components/landing/authenticated-redirect-dialog.tsx
resources/js/components/landing/authenticated-redirect-dialog.test.tsx`

## Screenshots
<img width="1207" height="797" alt="image"
src="https://github.com/user-attachments/assets/5954d083-54be-4820-b3f3-2c8454480a21"
/>
2026-05-25 20:28:45 +02:00
Víctor Falcón 1278a2b972
fix(cashflow): defer period label translation (#427)
## Summary
- translate cashflow period type labels during render
- keep Spanish labels as Mes / Trimestre / Año
- add regression coverage for period label translation

## Tests
- npx eslint resources/js/components/cashflow/period-navigation.tsx
resources/js/components/cashflow/period-navigation.test.tsx
- npx vitest run
resources/js/components/cashflow/period-navigation.test.tsx
2026-05-25 17:00:23 +02:00
Víctor Falcón 949ca110fa
fix(cashflow): stack mobile header controls (#426)
## Summary
- stack cashflow header on mobile
- make period type and period selector rows full width on mobile

## Tests
- npx eslint resources/js/pages/cashflow/index.tsx
resources/js/components/cashflow/period-navigation.tsx
2026-05-25 16:49:10 +02:00
Víctor Falcón ed737db7b2
feat(cashflow): add savings and period views (#424)
## Summary
- add savings and investment category types and migrate default EN/ES
categories
- show saved and invested amounts on cashflow summary
- add month, quarter, and year cashflow period views

## Validation
- vendor/bin/pint --dirty --format agent
- php -l changed PHP files
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter='migration updates existing default saving and investment
categories'
- npm run test -- --run resources/js/lib/chart-calculations.test.ts

## Notes
<img width="1245" height="464" alt="image"
src="https://github.com/user-attachments/assets/7456e018-fb16-4387-8989-aa0b104a42d0"
/>

- Broadened migration after read-only prod check found legacy default
categories still using expense/hidden.
2026-05-25 16:41:00 +02:00
Víctor Falcón af661f72f2
chore: release v0.2.3 (#423)
Automated release PR for **v0.2.3**.\n\nTag `v0.2.3` and GitHub release
published. Merge this PR to land version bump and CHANGELOG on `main`.
2026-05-25 12:23:52 +02:00
Víctor Falcón d8bb78e5e0
feat(mail): use AWS SES for email delivery (#422)
## Summary
- add AWS SDK required by Laravel SES mail transport
- default mail delivery to SES when MAIL_MAILER is unset
- document SES env vars and keep Resend marked as contact sync only
- add config coverage for SES mail setup

## Tests
- php artisan test --compact tests/Feature/MailConfigurationTest.php
2026-05-25 10:59:57 +02:00
Víctor Falcón faf046b3c4
fix: preview categorizer rule application (#421)
## Summary
- show the existing transaction preview only when the user explicitly
chooses `Apply to existing transactions` from an automation rule row
menu
- keep create/update rule save prompt behavior
- stop opening the apply modal just because the categorizer/onboarding
rules dialog closed

## Videos
### Apply existing rule — categorizer

https://github.com/user-attachments/assets/18806421-340d-4337-998f-308ebe60d11f

### Apply existing rule — onboarding

https://github.com/user-attachments/assets/ad615365-5927-4b2d-b9e5-5f4e20e80dce

### Create new rule — categorizer

https://github.com/user-attachments/assets/55ed2dea-5cec-4e8c-9c5f-d760194bc661

### Create new rule — onboarding

https://github.com/user-attachments/assets/cb045683-e5ce-4b93-9849-5722324848da

## Tests
- `npm test -- --run
resources/js/components/automation-rules/apply-automation-rule-flow.test.ts
resources/js/components/automation-rules/post-save-apply-rule-prompt.test.ts`
- `npx eslint resources/js/hooks/use-categorize-transactions.ts
resources/js/pages/transactions/categorize.tsx
resources/js/components/onboarding/step-categorize-transactions.tsx
resources/js/components/automation-rules/automation-rules-dialog.tsx`
- CI passing
2026-05-25 10:24:21 +02:00
Víctor Falcón 364c72db72
fix: build arm64 images on native runners (#419)
## Summary
- build arm64 Docker images on native GitHub arm runners instead of QEMU
- create Sentry release in deploy job so deploy no longer races registry
publishing

## Verification
- php artisan test --compact tests/Feature/SentryConfigTest.php
- vendor/bin/pint --dirty --format agent
- yq parsed .github/workflows/ci.yml
- git diff --check
2026-05-22 16:05:48 +01:00
Víctor Falcón 44116010de
fix: publish arm64 docker images asynchronously (#418)
## Summary
- publish arm64 Docker images after amd64 image build
- keep deploy independent from Docker image publishing
- publish multi-platform manifests for development and production tags

Closes #412

## Verification
- yq parsed .github/workflows/ci.yml
- git diff --check
2026-05-22 15:18:22 +01:00
Víctor Falcón eaba315196
fix: allow managing canceled connections (#417)
## Summary
- add paywall settings CTA for onboarded users with ended canceled
subscriptions and bank connections
- keep onboarding and normal unpaid connected users out of this escape
path
- cover paywall props and connection settings access

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

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

Note: npm run types still fails on pre-existing unrelated TypeScript
errors.
2026-05-22 10:19:11 +01:00
Víctor Falcón a00c73ac1d
ci: enforce conventional PR titles (#415)
## Summary
- add PR title workflow using semantic pull request action
- allow conventional commit types used by release-it changelog
generation

## Verification
- parsed workflow YAML locally with Ruby
2026-05-22 08:02:23 +01:00
Víctor Falcón 91b375296d
chore: release v0.2.2 (#414)
## Summary\n- bump package version to 0.2.2 with release-it\n- update
changelog for v0.2.2\n\n## Tests\n- npm test
2026-05-22 08:51:26 +02:00
Víctor Falcón 9d7a91dcd0
Apply automation rules to existing transactions (#413)
## Summary
- add apply-to-existing-transactions flow for automation rules
- preview matching transactions with uncategorized filter and infinite
scroll
- apply rule actions sync or via queued job with progress polling
- fix duplicate preview rows and repeat apply prompt when labels change

## Tests
- vendor/bin/pint --dirty --format agent
- npm test -- --run
resources/js/components/automation-rules/post-save-apply-rule-prompt.test.ts
resources/js/components/automation-rules/apply-automation-rule-flow.test.ts
- php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php

## Video

https://github.com/user-attachments/assets/e74f5e58-e582-4fb6-b6d4-2702398804b7
2026-05-22 08:36:18 +02:00
Víctor Falcón 933dfdeb1b
fix(budget): show today marker (#411)
## Summary
- fix budget spending chart today marker on first period
- use local date keys for budget chart dates
- add today marker tests

## Tests
- npm test --
resources/js/components/budgets/budget-spending-chart.test.ts
- npm run types (fails: existing unrelated TypeScript errors)
2026-05-20 16:52:00 +01:00
Víctor Falcón 8e113b1aa2
Move account delete into edit modal (#410)
## Summary
- remove account delete action from account detail submenu
- add ghost destructive delete button to edit account modal
- keep existing DELETE confirmation flow before destroy

## Tests
- npm test --
resources/js/components/accounts/edit-account-dialog.test.tsx
- npm run types -- --pretty false (project has pre-existing unrelated
type errors; no edit-account-dialog errors)
2026-05-20 16:46:13 +01:00
Víctor Falcón d73cc9b01f
Fix failed reconnect deleting connections (#409)
## Summary
- keep existing banking connections visible when reconnect callback
fails
- mark failed reconnects as error instead of soft-deleting them
- add regression test for failed ING-style reconnect

## Test
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php
2026-05-20 14:31:00 +01:00
Víctor Falcón c01f2b60e6
fix open banking reconnect callback (#408) 2026-05-20 14:03:02 +01:00
Víctor Falcón d2e00f14e5
fix(connections): show expired reconnect (#407)
## Summary
- show primary Reconnect action for expired EnableBanking connections
- keep dropdown reconnect available for expired connections
- add React test covering multiple expired connections

## Tests
- npm test -- resources/js/pages/settings/connections.test.tsx
- npx eslint resources/js/pages/settings/connections.tsx
resources/js/pages/settings/connections.test.tsx

## Notes
- npm run types -- --pretty false still fails on pre-existing unrelated
TypeScript errors
2026-05-20 13:13:13 +01:00
Víctor Falcón 11f989d03a
fix: keep lead invite command aliases (#406)
## Sentry issue
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1H

## Root cause
Production invoked old/typoed Artisan command names (`leads:send-invite`
/ `leads:send-invites`) after the command was named
`leads:send-invitations`, causing Symfony CommandNotFoundException
before command logic ran.

## Fix
- Add compatibility aliases for previous invite command names.
- Add regression coverage that both aliases execute successfully.

## Verification
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/Commands/SendUserLeadInvitationsTest.php`
2026-05-20 12:26:21 +02:00
Víctor Falcón 66ff427481
feat(import): calculate balances from transactions (#403)
## Why

Some banks return transactions without a balance column. The CSV/Excel
importer happily creates the transactions but leaves the account without
any balance history, making the account look incomplete.

## What

Opt-in flow that derives per-day balances from the transactions
themselves, using a single reference point (the balance on the date of
the latest transaction).

### Mapping step
- New checkbox **"Calculate balances from transactions"** next to the
  Balance column select.
- Available only when no balance column is mapped (disabled + visually
  faded otherwise).
- When checked, a balance input appears labeled with the latest
  transaction date using a relative format:
  - `Today`
  - `Yesterday`
  - `Monday, 3 of Jun`
- If an `AccountBalance` already exists for that date, it is pre-filled
  automatically (no need to ask) with a small hint.
- The reference balance is **mandatory** when the checkbox is checked;
  the Next button is disabled until it is provided.

### Preview step
- A new `Balance` column shows the balance about to be created for each
  date, regardless of whether it came from the file or was calculated.

### Persistence
- Balances are computed by walking backwards across distinct transaction
  dates and subtracting each day's net movement.
- They flow through the existing import path that POSTs to
  `AccountBalanceController@store`, so no new endpoint was needed.

## Feature flag

Hidden behind a new Pennant feature, **off by default**:

```bash
php artisan feature:enable CalculateBalancesOnImport user@example.com
# or
php artisan feature:enable CalculateBalancesOnImport all
```

The flag is exposed to the frontend via Inertia shared props
(`features.calculateBalancesOnImport`).

## Tests

- New vitest cases for `getLatestTransactionDate` and
  `calculateBalancesFromTransactions` (sparse dates, reference date
  without txns, empty list).
- `LocalizationTest` passes (new ES strings added).
- Full vitest suite green (106 tests).
2026-05-20 10:29:14 +01:00
Víctor Falcón b66f9e29bc
ci: cap Docker image build time (#405)
## Summary
- cap Docker image publish job and build steps
- stop multi-arch QEMU builds; publish amd64 only
- cache production Docker dependency layers before app copy
- add tests that guard CI timeout/platform behavior

## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/CiDockerBuildTest.php
2026-05-20 09:45:12 +01:00
Víctor Falcón 0f527d0f26
Notify users about expired bank connections (#404)
## Summary
- mark expired EnableBanking connections during scheduled sync and queue
reconnect email
- add reconnect email/button route for expired connections
- surface expired connections in shared Inertia props and show
persistent reconnect toast

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php
--filter='expired|scheduled sync includes active enablebanking'
- php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php
--filter='reauthorize|reconnect link'
- php artisan test --compact tests/Feature/InertiaSharedDataTest.php
--filter='expired banking'
- npm run types (fails on existing unrelated TypeScript errors; no
app.tsx/expiredBanking errors after Wayfinder generation)
2026-05-20 09:20:13 +02:00
Mouad Jaouhari 5c74292448
fix: publish and use production Docker image (#393)
## Summary
  - publish a GHCR production image built from `Dockerfile.production`
- point production Docker Compose and Coolify template to `:production`
- add `.dockerignore` so host `.env`, `vendor`, and `node_modules` do
not leak into image builds
  - clarify Docker self-hosting docs and Compose log command

  ## Verification
- `docker build -f Dockerfile.production -t
whisper-money:production-test .`
  - `docker compose -f docker-compose.production.yml config --images`
  - `vendor/bin/pint --dirty --format agent`
  - `vendor/bin/pest tests/Unit/DockerProductionImageTest.php`

---------

Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-05-20 07:19:31 +00:00
Víctor Falcón a1648fc4c5
Fix Vite asset preload recovery (#399)
## Summary
- detect Vite CSS preload failures as chunk load errors
- reuse existing one-shot reload recovery for stale asset failures

Fixes PHP-LARAVEL-29
Fixes PHP-LARAVEL-27

## Tests
- npm run test -- resources/js/lib/chunk-load-recovery.test.ts
- npm run types *(fails: existing unrelated TypeScript errors in
accounts, charts, transactions, crypto, etc.)*
2026-05-14 15:59:08 +02:00
Víctor Falcón 9b4f0ab422
Filter Facebook webview bridge errors (#401)
## Summary
- drop Facebook Android in-app browser Java bridge shutdown errors
- require the iabjs navigation performance logger frame so app errors
still report

Fixes PHP-LARAVEL-20
Fixes PHP-LARAVEL-1Y

## Tests
- npm run test -- resources/js/lib/sentry.test.ts
- npm run lint -- resources/js/app.tsx resources/js/lib/sentry.ts
resources/js/lib/sentry.test.ts *(passes with existing chart hook
warning)*
2026-05-14 15:58:20 +02:00
Víctor Falcón af424b0442
Harden browser storage and PostHog recording (#402)
## Summary
- guard early chart color localStorage access for restricted browser
contexts
- disable PostHog session recording by default; opt in with
VITE_POSTHOG_SESSION_RECORDING_ENABLED=1

Fixes PHP-LARAVEL-1W
Fixes PHP-LARAVEL-22

## Tests
- vendor/bin/pint --dirty --format agent
- npm run test -- resources/js/lib/posthog.test.ts
- php artisan test --compact tests/Feature/PwaTest.php
- npm run lint -- resources/js/lib/posthog.ts
resources/js/lib/posthog.test.ts *(passes with existing chart hook
warning)*
2026-05-14 15:57:48 +02:00
Víctor Falcón 4ba130f310
Enable Coinbase for all users (#398)
## Summary
- remove CoinbaseIntegration Pennant feature flag
- always show Coinbase in open banking institution picker
- drop shared Coinbase feature flag type

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/InertiaSharedDataTest.php
- php artisan test --compact
tests/Feature/OpenBanking/CoinbaseControllerTest.php
- npx eslint
resources/js/components/open-banking/connect-account-inline.tsx
resources/js/components/open-banking/connect-account-dialog.tsx
resources/js/types/index.d.ts

Note: npm run types still fails on existing project-wide Wayfinder/type
issues.
2026-05-14 11:47:47 +01:00
Víctor Falcón fc419c6cae
Fix transaction importer account preselect (#396)
## Summary
- only auto-select the sole import account during onboarding
- hide the upload-step Back button after onboarding auto-select
- add component coverage for account selection and hidden back button

## Tests
- npm test --
resources/js/components/transactions/import-step-account.test.tsx
resources/js/components/transactions/import-step-upload.test.tsx
- npx eslint
resources/js/components/transactions/import-step-account.tsx
resources/js/components/transactions/import-step-upload.tsx
resources/js/components/transactions/import-transactions-drawer.tsx
resources/js/components/onboarding/step-import-transactions.tsx
resources/js/lib/import-config-storage.ts resources/js/types/import.ts
resources/js/components/transactions/import-step-account.test.tsx
resources/js/components/transactions/import-step-upload.test.tsx

## Notes
- npm run types still fails on existing unrelated project-wide
TypeScript errors.
2026-05-14 12:38:44 +02:00
Víctor Falcón 4f55ced837
Fix Spanish translations in category and delete flows (#397)
## Summary
- translate category type dropdown labels
- translate bulk delete confirmation text
- add focused i18n tests

## Tests
- npm run test -- resources/js/types/category.test.ts
resources/js/lib/transaction-delete-confirmation.test.ts
- npx eslint resources/js/types/category.ts
resources/js/types/category.test.ts
resources/js/components/categories/create-category-dialog.tsx
resources/js/components/categories/edit-category-dialog.tsx
resources/js/lib/transaction-delete-confirmation.ts
resources/js/lib/transaction-delete-confirmation.test.ts
resources/js/pages/transactions/index.tsx
2026-05-14 11:36:53 +01:00
Víctor Falcón 81c0fd4a81
Backfill Coinbase monthly history (#395)
## Summary
- backfill Coinbase portfolio balances for 12 previous months plus today
- retry missing historical backfill on later syncs
- add Coinbase candle pricing with USD fallback and mark Coinbase
accounts as investments

## Tests
- php artisan test --compact
tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php
tests/Feature/OpenBanking/AccountMappingTest.php
tests/Feature/OpenBanking/CoinbaseControllerTest.php
2026-05-14 10:52:46 +01:00
Víctor Falcón b8e04478df
Fix imported transaction date shifts (#394)
## Summary
- format imported dates from local date parts instead of UTC ISO strings
- add regression coverage for Europe/Madrid date imports

## Tests
- npm test -- resources/js/lib/file-parser.test.ts
2026-05-14 10:28:39 +01:00
Víctor Falcón aa36bb8a86
Prompt automation after categorizing transactions (#378)
## Summary
- show Automatize toast after category assignment in categorizer,
transactions table, and edit modal
- add modal flow for creating a new automation rule or updating an
existing one
- extract shared automation rule form and add rule helper tests

## Verification
- npm run test -- resources/js/lib/rule-builder-utils.test.ts
- npx eslint changed frontend files
- npm run build (app compiled; Sentry sourcemap upload failed with TLS
error)


https://github.com/user-attachments/assets/1f7d90e8-520a-4eaf-9fc4-4124adb848a0
2026-05-14 09:17:12 +02:00
Víctor Falcón e71a743a0a
feat: Coinbase banking integration (#388)
## Summary

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

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

## Account model

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

## Backend

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

## Frontend

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

## Tests

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

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

## Follow-ups (not in this PR)

- Upload `storage/banks/logos/coinbase.png` to production storage (URL
referenced in seeder + frontend).
- Invested-amount calc from transaction history (deferred).
- Historical balance reconstruction (deferred — Coinbase has no daily
snapshot endpoint).
2026-05-13 19:53:30 +02:00
Víctor Falcón a09339d3c0
chore(dev): copy portless URL on start (#392)
## Summary
- copy existing Portless dev URL to clipboard before starting dev stack
- print copied URL when available

## Verification
- composer validate --strict --no-check-publish
2026-05-13 12:09:54 +01:00
Víctor Falcón d9204bb3d6
fix(banking): dedup EnableBanking transactions by deterministic fingerprint (#390)
## Problem

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

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

## Root cause

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

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

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

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

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

## Fix

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

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

## Rollout plan

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

## Tradeoff

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

## Tests

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

## Files

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

## Out of scope

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

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

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

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

## Fix

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

Other channels (database, etc.) keep working.

## Test

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

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

Fixes PHP-LARAVEL-1Z
2026-05-13 09:47:50 +01:00
Víctor Falcón 06e7eed4e2
fix(banking): treat Indexa Capital performance 404 as empty data (#386)
## Problem

Sentry:
[PHP-LARAVEL-1X](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1X)
— 6 events, 2 users.

`IndexaCapitalClient::getPerformance()` throws `RequestException` when
Indexa Capital returns 404 (HTML page) for an account without
performance data — e.g. brand-new or inactive accounts. This aborts
`SyncBankingConnectionJob::syncIndexaCapital` and pages us via Sentry.

## Fix

Catch the 404 inside the client, log at info level, and return an empty
array. The downstream `IndexaCapitalBalanceSyncService` already
short-circuits when `portfolios` is empty, so the sync no-ops cleanly.
Other HTTP errors (auth, 5xx) keep throwing.

## Test

Added `returns empty performance when indexa capital responds 404` in
`IndexaCapitalBalanceSyncTest`.

```
php artisan test --filter=IndexaCapitalBalanceSyncTest
Tests:  12 passed
```

Fixes PHP-LARAVEL-1X
2026-05-13 09:34:28 +01:00
Víctor Falcón 31b9198775
chore: release v0.2.1 (#385)
Patch release v0.2.1

### Features
* Add yearly budget period (#384)
* Add labels to automation rules (#379)

### Bug Fixes
* Fix exchange rate cache race PHP-LARAVEL-1V (#383)
* Fix cashflow null category rows (#382)
* Fix browser translation crash PHP-LARAVEL-1S (#381)
* Fix cashflow multi-currency totals (#380)
* Fix service worker registration rejection (#376)
* Recover from stale Vite chunks (#374)
* sentry: ignore postMessage clone noise (#373)
* Fix Sentry transaction and dashboard crashes (#372)
* Fix Sentry release commit detection in image build (#371)
* Prevent cached cashflow analytics responses (#368)
* Fix duplicate category name validation (#364)

### Chores
* Add sentry issue slash command (#375)
* Update worktree script (#366)
* Speed up PR CI browser path (#365)
2026-05-12 13:54:41 +02:00
Víctor Falcón f8f3b06073
Add yearly budget period (#384)
## Summary
- Add yearly budget period option on backend and frontend
- Generate yearly budget periods for Jan 1 through Dec 31
- Add feature coverage for yearly budget creation and period generation

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/BudgetPeriodServiceTest.php
tests/Feature/BudgetTest.php

## Notes
- npm run types fails on existing unrelated TypeScript errors: missing
generated Wayfinder modules and pre-existing type mismatches
2026-05-12 13:35:25 +02:00
Víctor Falcón c3dcbb48fd
Fix PHP-LARAVEL-1V exchange rate cache race (#383)
## Sentry
- Issue: PHP-LARAVEL-1V
- URL: https://whisper-money.sentry.io/issues/PHP-LARAVEL-1V

## Root cause
`ExchangeRateService::getRates()` skipped the database lookup after
`preloadRates()` marked a miss, then used `create()` to cache fetched
rates. If another request inserted the same `base_currency` + `date`
meanwhile, the unique index threw a duplicate-key exception.

## Fix
Use atomic Eloquent `upsert()` for exchange-rate cache writes so
concurrent requests converge on one row. Added regression coverage for
the preload-miss race path.

## Verification
- `php artisan test --compact tests/Feature/ExchangeRateServiceTest.php`
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact --filter="getRates tolerates another
request storing rates after preload miss"`
2026-05-12 12:45:45 +02:00
Víctor Falcón 30cc4da6c6
Fix cashflow null category rows (#382)
## Sentry
- Issue: PHP-LARAVEL-1T
- URL: https://whisper-money.sentry.io/issues/PHP-LARAVEL-1T

## Root cause
Cashflow breakdown UI assumed every API row has a category object.
Production returned a breakdown row with `category: null`, so rendering
read `item.category.icon` and crashed.

## Fix
- Allow breakdown rows to carry nullable category/category_id.
- Render null categories as Uncategorized with HelpCircle/gray fallback.
- Add focused Vitest regression.
- Enable Wayfinder Vite plugin in Vitest so components importing
generated actions resolve in tests.

## Verification
- `npm test -- resources/js/components/cashflow/breakdown-card.test.tsx`
- `npx prettier --write
resources/js/components/cashflow/breakdown-card.tsx
resources/js/components/cashflow/breakdown-card.test.tsx
resources/js/hooks/use-cashflow-data.ts vitest.config.ts`
- `npx eslint resources/js/components/cashflow/breakdown-card.tsx
resources/js/components/cashflow/breakdown-card.test.tsx
resources/js/hooks/use-cashflow-data.ts vitest.config.ts --fix`

## Notes
- `npm run types` currently fails on existing unrelated TypeScript
errors across account, transaction, chart, auth, crypto, and dexie
files.
2026-05-11 18:54:26 +02:00
Víctor Falcón e635fdad5c
Fix PHP-LARAVEL-1S browser translation crash (#381)
## Sentry issue
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1S

## Root cause
- Browser/page translation mutated React-managed DOM on /onboarding by
injecting font wrappers. React later attempted to unmount a node that
translation had moved, causing NotFoundError removeChild.

## Fix
- Mark the Inertia root document as not translatable with
translate="no", notranslate class, and google notranslate meta tag.
- Add regression coverage for the root template markers.

## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
2026-05-11 17:06:47 +01:00
Víctor Falcón 4e03996289
Fix cashflow multi-currency totals (#380)
## Summary
- convert cashflow transaction amounts to user currency
- apply conversion to summary, trend, sankey, and breakdown endpoints
- add regression coverage for mixed USD/EUR cashflow analytics

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
2026-05-11 17:49:29 +02:00
Víctor Falcón 5b8e7e86d7
Add labels to automation rules (#379)
## Summary
- add label selection to automation rule create/edit dialogs
- allow rules with category, labels, or both
- display category, labels, and note actions in rule tables

## Verification
- php artisan test --compact tests/Feature/AutomationRuleTest.php
tests/Feature/AutomationRuleEvaluationTest.php
- npx eslint
resources/js/components/automation-rules/automation-rule-action-badges.tsx
resources/js/components/automation-rules/create-automation-rule-dialog.tsx
resources/js/components/automation-rules/edit-automation-rule-dialog.tsx
resources/js/components/automation-rules/automation-rules-dialog.tsx
resources/js/pages/settings/automation-rules.tsx
- npm run build (exit 0; Sentry sourcemap upload logged SSL error)
2026-05-11 14:56:25 +02:00
Víctor Falcón a20eeff378
Invite leads by email (#377)
## Summary
- add --email option to leads:send-invitations
- target one pending queued lead by email
- cover targeted invite and missing pending lead cases

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/Commands/SendUserLeadInvitationsTest.php
2026-05-11 11:28:23 +01:00
Víctor Falcón 3526e5fd12
Fix service worker registration rejection (#376)
## Sentry
- Fixes PHP-LARAVEL-1Q
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1Q

## Root cause
- Service worker registration promise rejected in production and had no
rejection handler.
- Browser reported unhandled promise rejection: Error: Rejected.

## Fix
- Add a catch handler to service worker registration so
unsupported/intercepted registration failures do not become unhandled
global errors.
- Add regression coverage asserting registration has rejection handling.

## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
2026-05-10 17:45:47 +01:00
Víctor Falcón c929c1f7a5
chore: Add sentry issue slash command (#375) 2026-05-10 17:43:39 +01:00
Víctor Falcón 69610c57b3
Recover from stale Vite chunks (#374)
## Summary
- reload once when a stale Vite dynamic import chunk fails
- suppress recoverable chunk load failures from Sentry
- add Vitest coverage for recovery and Sentry filtering

Fixes PHP-LARAVEL-1P

## Tests
- npm run test -- resources/js/lib/chunk-load-recovery.test.ts
resources/js/lib/sentry.test.ts
- npm run types *(fails: existing TypeScript errors outside this
change)*
2026-05-10 17:20:24 +02:00
Víctor Falcón 6335287765
fix(sentry): ignore postMessage clone noise (#373)
## Summary
- filter browser postMessage DataCloneError noise before Sentry send
- add focused classifier tests

## Tests
- npm test -- resources/js/lib/sentry.test.ts
- npm run types *(fails: existing repo-wide TypeScript errors, including
missing Wayfinder generated modules)*

Fixes PHP-LARAVEL-1M
2026-05-10 11:16:38 +01:00
Víctor Falcón 718cfa9e8f
Fix Sentry transaction and dashboard crashes (#372)
## Summary
- guard transaction list Inertia callbacks against non-paginated
transaction props
- exclude soft-deleted categories from dashboard category aggregates
- add dashboard category and cursor pagination tests

Fixes PHP-LARAVEL-1K
Fixes PHP-LARAVEL-1J

## Tests
- npm run test -- cursor-pagination.test.ts
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
--filter='top categories'
- vendor/bin/pint --dirty --format agent

Note: npm run types still fails on existing unrelated TypeScript errors.
2026-05-10 11:10:31 +01:00
Víctor Falcón f4ab4a1989
Fix Sentry release commit detection in image build (#371)
## Summary
- add --ignore-missing to Sentry set-commits so build-image does not
fail when previous release SHA is unavailable
- cover the workflow command in Sentry config test

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SentryConfigTest.php
2026-05-09 16:51:51 +01:00
Víctor Falcón 6132a0fa92
Init browser Sentry from env DSN (#370)
## Summary
- read browser Sentry DSN from `SENTRY_LARAVEL_DSN`
- expose `SENTRY_LARAVEL_DSN` through Vite env
- enable default PII and disable client Sentry when DSN missing
- removed temporary welcome test button

## Tests
- `npx eslint resources/js/pages/welcome.tsx resources/js/app.tsx
resources/js/types/vite-env.d.ts vite.config.ts`
- `php artisan test --compact tests/Feature/SentryConfigTest.php`
- `npm run build` (build completed; existing Sentry sourcemap upload
reports SSL error against Bugsink)
2026-05-09 12:07:11 +02:00
Víctor Falcón 97df0597f8
Prevent cached cashflow analytics responses (#368)
## Summary
- mark cashflow analytics JSON responses as no-store/private
- add coverage for cache-control header

## Why
Browser tests reuse the same cashflow API URLs across authenticated
users. Cached user-specific analytics can leak stale breakdown data
between sessions and hide the income category.

## Tests
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
2026-05-08 16:51:27 +02:00
Víctor Falcón 360a38a880
Update worktree script (#366)
## Summary\n- update worktree script\n\n## Tests\n- not run (script-only
change)
2026-05-07 20:43:58 +01:00
Víctor Falcón e36d6f3e16
Speed up PR CI browser path (#365)
## Summary
- rebalance Browser tests with explicit class-filter shards
- build PR browser assets inside shards, removing the build-assets
dependency gate
- run Browser shards with Pest parallelism ()

## Autoresearch metric
- baseline modeled PR critical path: 406.50s
- final modeled PR critical path: 234.27s
- modeled improvement: 172.23s (42.4%)

## Verification
- ran METRIC ci_total_s=234.270
METRIC actual_recent_pr_total_s=422.000
METRIC build_assets_s=0.000
METRIC tests_s=170.000
METRIC browser_matrix_s=232.270
METRIC linter_s=60.000
METRIC static_analysis_s=26.500
METRIC performance_tests_s=63.000
METRIC job_count=13.000
METRIC browser_shards=6.000 for each experiment
- coverage guard in autoresearch script checks Browser filters cover all
recent Browser classes exactly once

## Notes
- wall-clock faster; runner minutes likely higher due extra shards and
duplicate asset builds
- real CI should validate Browser parallelism flake risk
2026-05-07 20:40:13 +01:00
Víctor Falcón e3c2d2fd82
Fix duplicate category name validation (#364)
## Summary
- validate category names against the existing per-user unique index
- return validation errors for create/update duplicate races instead of
500s
- cover create, update, soft-delete, cross-user, and DB race cases

Fixes PHP-LARAVEL-1E
Fixes PHP-LARAVEL-1F

## Tests
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
- vendor/bin/pint --dirty --format agent
- git diff --check
2026-05-07 12:55:35 +01:00
Víctor Falcón 5784e25f0a chore: release v0.2.0 2026-05-07 11:56:58 +02:00
Víctor Falcón 164235f6d3
Fix label creation dropdown refresh (#363)
## Summary
- keep newly created labels available in the combobox without reload
- propagate created labels through transaction forms and bulk actions
- add browser coverage for creating a label from transaction label
dropdown

## Tests
- php artisan test --compact tests/Browser/TransactionsTest.php
--filter='newly created labels'
- vendor/bin/pint --dirty --format agent
- npm run build

Note: `npm run types` still fails on pre-existing TypeScript errors
outside this change.
2026-05-07 10:26:43 +01:00
Víctor Falcón caae0e8918
Preload exchange rates (#362)
## Summary
- preload dashboard exchange-rate ranges in one query
- memoize exchange-rate lookups per request
- cover Sentry N+1 regression

Fixes PHP-LARAVEL-1G

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/ExchangeRateServiceTest.php
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
- php artisan test --compact tests/Performance/ApiQueryCountTest.php
--filter='net worth evolution API'
- php artisan test --compact tests/Performance/PageQueryCountTest.php
--filter='dashboard'
2026-05-06 16:35:49 +01:00
Víctor Falcón 17d952435b
Hide stale onboarding plan warning (#360)
## Summary
- hide connected-account plan warning and price after the user chooses
connected setup once
- seed onboarding state from existing connected accounts
- add hook unit coverage and browser coverage for warning/price
visibility

## Tests
- bun run test -- resources/js/hooks/use-onboarding-state.test.tsx
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter="hides the connected plan warning"
- npm run types -- --pretty false 2>&1 | rg
"resources/js/(components/onboarding/step-create-account|hooks/use-onboarding-state|pages/onboarding/index)"
(no onboarding errors; repo has unrelated existing TS errors)
2026-05-06 15:10:32 +01:00
Víctor Falcón 0f1ce81b4e
Fix onboarding return after bank auth errors (#361)
## Summary
- Keep non-onboarded users on the accounts step after bank authorization
errors
- Add feature and browser coverage for the callback error path

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter='returns to the accounts step'
- php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php
--filter='callback with error'
2026-05-06 15:50:02 +02:00
Víctor Falcón 6cd465bb78
Fix bank connection FAQ (#359)
## Summary
- update landing FAQ to confirm secure Open Banking connections
- clarify bank connections are Pro-only and free users import via
CSV/Excel
- add regression coverage for FAQ copy and Spanish translation

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Unit/WelcomeFaqTest.php
- git diff --check
2026-05-06 11:28:26 +01:00
Víctor Falcón 917a9a655f
Handle transient EnableBanking sync failures (#358)
## Problem

Sentry showed two related production issues in the same sync path:

- `PHP-LARAVEL-13`: EnableBanking returned HTTP `400` from `GET
/accounts/{accountId}/transactions` with body `ASPSP_ERROR` / `Error
interacting with ASPSP`. This is an upstream bank/provider failure, but
the app threw an unhandled `RequestException`.
- `PHP-LARAVEL-14`: the same transactions endpoint timed out after 20s,
throwing an unhandled `ConnectionException`.

Both originated from `EnableBankingProvider::getTransactions()` and
bubbled through `SyncBankingConnectionJob`, creating Sentry issues for
expected transient provider/bank outages.

## New behavior

- Wrap EnableBanking `400 ASPSP_ERROR` responses in
`TransientBankingProviderException`.
- Wrap EnableBanking transaction connection failures / timeouts in the
same transient exception.
- Mark that exception as `ShouldntReport`, so these expected upstream
failures stop creating Sentry issues.
- Keep queue retry behavior intact. The job still retries and only marks
the connection as `Error` after normal retry exhaustion.
- Log transient sync failures as warnings instead of errors.
- Show users a retry-later message when retries are exhausted: the bank
provider is temporarily unavailable.
- Leave other `400` responses reportable. Validation / app-side request
bugs still throw `RequestException`.
- Leave auth failures and rate-limit handling unchanged.

Fixes PHP-LARAVEL-13
Fixes PHP-LARAVEL-14

## Testing

- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/OpenBanking/EnableBankingProviderTest.php
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`
2026-05-06 09:24:05 +02:00
Víctor Falcón 1024122e57
chore: add MCP SDK dev dependency (#357)
## Summary
- Add @modelcontextprotocol/sdk as a dev dependency via Bun
- Update bun.lock

## Verification
- bun install --frozen-lockfile
2026-05-05 15:28:35 +01:00
Víctor Falcón 0fe7bf7fc6
Batch historical balance writes (#356)
## Summary
- Batch calculated historical account balances with one upsert
- Keep existing balance dates untouched
- Add regression coverage for one account_balances write query

Fixes PHP-LARAVEL-1C

## Tests
- php artisan test --compact
tests/Feature/OpenBanking/BalanceSyncServiceTest.php
- vendor/bin/pint --dirty --format agent
2026-05-05 15:26:28 +01:00
Víctor Falcón 22043ced29
fix(budgets): remove Custom period type to fix duplicate-key crash (#355)
## Why merge this

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

## Root cause

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

Concrete trace from the Sentry event:

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

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

## Fix

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

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

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

## Manual prod cleanup (separate, after merge)

The single buggy budget in prod will be deleted manually:

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

## Tests

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

## Risk

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

Fixes PHP-LARAVEL-1B
2026-05-05 16:07:16 +02:00
Víctor Falcón 3fea230d58
chore: Don't run `demo:reset` command daily (#353)
Removes daily schedule for `demo:reset` command.
2026-05-05 09:59:54 +01:00
Víctor Falcón e387c038ca
perf(resend): default sync-leads to last 24h window (#354)
## Why

`resend:sync-leads` was syncing every verified lead on each run, taking
4+ minutes.

## Change

- New `--since=N` option (hours). Default `24`.
- `--since=0` syncs all leads (manual backfill).
- Scheduler unchanged: runs daily at 03:00 → 24h window covers it.

## Tests

- Default 24h window excludes older leads.
- `--since=0` syncs everything.
2026-05-05 09:57:25 +01:00
Víctor Falcón b1709b714e
fix(onboarding): guard window access in SSR (#351)
## Problem

Production SSR crashes repeatedly when rendering the Onboarding page:

```
ReferenceError: window is not defined
  at Onboarding (/app/bootstrap/ssr/assets/index-BEtdcHXd.js:2295)
```

The `useMemo` reading `?step=` from the URL accessed
`window.location.search` directly, which fails during Inertia SSR.

Observed ~7+ times in production logs between 18:35–21:37 UTC.

## Fix

Add `typeof window === 'undefined'` guard before reading the URL.
Returns `undefined` on the server so the step initializer falls back to
default behavior; client hydration re-runs and reads the URL.

## Related logs

- `Inertia\Ssr\SsrException` — Onboarding page
- Other SSR-touching files (`welcome.tsx`, `auth/login.tsx`) verified
safe (window inside `useEffect` or already guarded).
2026-05-05 08:45:44 +01:00
Víctor Falcón f800847591
feat(banking): back off scheduler when EnableBanking returns 429 (#352)
## Problem

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

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

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

## Fix

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

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

## Tests

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

`php artisan test --compact
--filter="SyncBankingConnectionJobTest|SyncRetryAndLoggingTest|SyncBankingConnectionsCommandTest"`
→ 70 passed.
2026-05-05 09:39:32 +02:00
Víctor Falcón 21b5692174
fix: include production Dockerfile in deploy filter (#350)
## Summary
- include Dockerfile.* in CI changes filter
- ensure Dockerfile.production-only commits build and deploy

## Verification
- python3 assertion confirms Dockerfile.* filter exists and matches
Dockerfile.production
2026-05-04 15:36:56 +01:00
Víctor Falcón 23977f74f5
Fix Bun permissions for SSR worker (#349)
## Summary
- install Bun under /usr/local/bun instead of /root/.bun
- expose Bun via /usr/local/bin for www-data supervisor workers

## Test
- docker build -f Dockerfile.production --check .
2026-05-04 14:15:33 +01:00
Víctor Falcón 049486093a
Add Sentry user context (#348)
## Summary
- identify authenticated users on Sentry web requests with id and email
- add user and banking connection context to banking sync jobs
- cover Sentry context behavior with feature tests

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SentryUserMiddlewareTest.php
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
2026-05-04 13:26:50 +01:00
Víctor Falcón cd2462d451
Fix default category creation N+1 (#347)
## Summary
- replace default category firstOrCreate loop with one lookup plus bulk
insert
- add regression coverage for repeated category lookups

Fixes PHP-LARAVEL-19

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter='default categories'
2026-05-04 13:22:55 +01:00
Víctor Falcón 70f3897b55
fix: unblock onboarding after sync failure (#346)
## Summary
- mark failed banking sync jobs as error so onboarding can continue
- add EnableBanking HTTP timeouts to avoid worker hard timeouts
- add regression coverage for failed active sync jobs

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
--filter='failed sync job marks active connection as error'
- php artisan test --compact
tests/Feature/Onboarding/OnboardingSyncStatusTest.php --filter='returns
pending false when unsynced connection has an error status'
2026-05-04 12:52:21 +01:00
Víctor Falcón 592fb20059
Fix Stripe checkout promotion code conflict (#345)
## Summary
- avoid sending both allow_promotion_codes and discounts to Stripe
Checkout
- keep manual promo entry enabled only when no lead promotion code
exists
- add checkout regression test

Fixes PHP-LARAVEL-1A

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SubscriptionTest.php
--filter='checkout'
2026-05-04 12:31:04 +01:00
Víctor Falcón fe3b32395c
Fix missing historical currency rates (#344)
## Summary
- fall back to previous historical currency rate dates when exact API
release is missing
- keep latest rate lookups unchanged
- add regression coverage for missing historical release

Fixes PHP-LARAVEL-18

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CurrencyConversionServiceTest.php
2026-05-04 11:26:45 +01:00
Víctor Falcón f6c20576b5
fix(banking): clamp linkedDateFrom to today on EnableBanking sync (#343)
## Problem

`EnableBankingProvider::getTransactions` returned 422
`DATE_FROM_IN_FUTURE` when an account had a future-dated last
transaction (e.g. pending/scheduled). `linkedDateFrom` was set from
`lastTransaction->transaction_date` without bounding, producing
`dateFrom > dateTo`.

Sentry:
[PHP-LARAVEL-15](https://whisper-money.sentry.io/issues/PHP-LARAVEL-15)

## Fix

Clamp `linkedDateFrom` to `dateTo` (today) in
`SyncBankingConnectionJob::syncEnableBanking`.

## Tests

Added test covering future-dated last transaction case.

Fixes PHP-LARAVEL-15
2026-05-03 17:26:27 +00:00
Víctor Falcón 0815548ac9
ci: tier 1 + tier 2 pipeline speedups (#342)
Continues the work merged in #341 with two focused commits.

## Baseline vs result (real numbers from runs on this PR)

| Job | Before (last run on #341) | After | Notes |
|---|---|---|---|
| build-assets | 70s (gates 3 jobs) | 74s | unchanged |
| tests | 215s | 243s | now starts at t=0 (no gate) |
| performance-tests | 72s | 64s | now starts at t=0 (no gate) |
| browser-tests longest shard | 341s | 323s | -18s |
| linter | 61s | 63s | same |
| static-analysis | 29s | 23s | -6s |
| **Wall (critical path)** | **~7m** | **~6.6m** | **-25s on the
critical path** |

The critical path is still `build-assets → browser-tests-matrix`. Real
wall-time wins for that path require either smarter shard balancing or a
Playwright Docker container — both deliberately deferred (see below).

## Tier 1 — wall-time wins (commit 1)

- **Pest:** `withoutVite()` is now applied globally in the `Feature` and
`Performance` suites via `pest()->beforeEach(...)->in(...)`. Those
suites never assert on the JS bundle, so they no longer need a compiled
Vite manifest.
- **Drop `needs: build-assets`** from `tests` and `performance-tests`.
Both now start at t=0 instead of waiting ~70s for the artifact. Browser
tests still gate on it (real Playwright session needs the manifest).
- **Playwright:** install only `chromium` (no firefox/webkit) since
that's all pest-plugin-browser uses. Saves ~15s of system-dep install
per shard.
- **Drop `actions/setup-node`** from browser-tests-matrix;
`oven-sh/setup-bun` already provides node for `npx playwright`.

I also tried bumping browser shards 4 → 6 but reverted (commit 3):
pest-plugin-browser's `--shard=N/M` distributes very unevenly at M=6 in
this codebase — shard 6 ended up running the entire 106-test suite
(13m26s) while other shards ran 23 tests each. Reverted to 4 shards for
predictable balance. Better balancing needs a test-time-aware split
(follow-up).

## Tier 2 — caching + setup dedup (commit 2)

- New composite actions:
- `.github/actions/setup-php-deps`: PHP + composer cache + `vendor/`
cache. On a `vendor/` cache hit, `composer install` is skipped entirely.
- `.github/actions/setup-bun-deps`: Bun + bun cache + `node_modules/`
cache. On a hit, `bun install` is skipped entirely.
- All 6 jobs refactored to use the composite actions. Removes ~150 lines
of duplicated YAML.
- **Bug fix: Pint cache key** dropped `${{ github.sha }}` from the
primary key so it actually reuses across runs (was effectively 0%
reuse).

## Deliberately not included

- **Pest `--parallel`**: paratest's per-process DB suffix conflicts with
Testcontainers user grants (see revert in 82e9a77 on the parent branch).
Worth revisiting in a follow-up that either widens grants or overrides
`ParallelTesting::setUpProcess`.
- **Playwright Docker container** (`mcr.microsoft.com/playwright`):
would eliminate the 34s system-deps install entirely but couples PHP
setup to a custom container; risky for one PR.
- **Test-time-aware browser shard split**: only real way to actually
shrink the critical path further given pest-plugin-browser's current
sharding behavior.

## Verification

- Final CI run on this PR is green (one flaky `Create Category` browser
timeout that passed on rerun — pre-existing flake unrelated to these
changes).
- `php artisan test --filter=InertiaSharedDataTest` and
`--filter=BudgetPeriodDateTest` pass with `public/build` deleted,
confirming the global `withoutVite()` covers feature tests that
previously rendered Inertia pages.
- All YAML files parse cleanly.
- `vendor/bin/pint --dirty` passes.
2026-05-01 19:07:04 +02:00
Víctor Falcón b94a285aad
ci: speed up pipeline (#341)
## Why

Last CI run on a PR took **~16m 36s** wall (browser-tests dominated).
Total CPU ~34 min. Several easy wins identified.

## Baseline (run 25169327223)

| Job | Wall | Critical step |
|---|---|---|
| browser-tests | 16m 36s | Browser Tests 718s, Build 166s, Playwright
50s |
| tests | 9m 38s | Tests 334s, Build 173s |
| performance-tests | 4m 19s | Build 173s |
| linter | 2m 54s | Pint 109s |
| static-analysis | 36s | — |

## Changes (one commit each)

**A. Shard browser tests 4×** — `pest --shard=N/4` matrix. ~12 min → ~3
min critical path.

**B. Build assets once, share via artifact** — new `build-assets` job
uploads `public/build`; tests/browser-tests/performance-tests download
it. Saves ~340s of duplicated CPU.

**C. Disable xdebug in tests job** — `coverage: xdebug` was set but
`--coverage` never passed. Silent ~30-50% slowdown.

**D. Parallel Pest with Testcontainers** — `pest --parallel
--processes=4`, `TESTCONTAINERS=true` so each worker gets isolated
MySQL. Drops job-level mysql service.

**E. Cache composer + bun deps** — `actions/cache` for composer cache
dir and `~/.bun/install/cache` keyed on lockfiles. Saves 30-60s/job
warm.

**F. Cache Playwright browsers** — cache `~/.cache/ms-playwright` keyed
on bun.lock; on hit only runs `install-deps`. Saves ~50s.

**G. Pint parallel + cache** — `--parallel --cache-file=.pint.cache`
with persisted cache. ~109s → ~5-10s warm.

## Expected wall time after

Browser shards become critical path: **~6-7 min** (down from 16-17 min).

## Risks

- **D**: 4 paratest workers each spawn a MySQL container — watch
RAM/runner load. Drop to `--processes=2` if flaky.
- **A**: more concurrent runners. Adjust shard count if MySQL service
init becomes the new bottleneck per shard.
- **B**: `build-assets` is now a hard dep for
tests/browser-tests/performance-tests.

## Test plan

CI itself is the test. Watch this PR run, compare timings to baseline.
2026-04-30 14:52:33 +00:00
Víctor Falcón ab3d6e9fca
feat(leads): cohort-based launch invitations with per-user Stripe coupons (#333)
## Summary

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

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

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

## What's added

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

## Tests (Pest)

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

Full suite green (1262 passed).

## Launch checklist

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

## Notes / non-goals

- No `User → UserLead` foreign key — checkout matches by email per
request.
- Existing `FOUNDER` promo flow is untouched and still kicks in if a
user has no lead-specific code.
- `founder_referrer` cohort is empty in current prod data; logic is in
place for when founders start referring.
2026-04-30 15:10:28 +01:00
Víctor Falcón 8f42496a5f
fix(dashboard): avoid month overflow in real estate projection (#340)
## Problem

`DashboardAnalyticsTest > real estate balance evolution appends
projected mortgage balance from linked loan` fails on certain calendar
dates (e.g. when run on 2026-04-30) with:

```
Failed asserting that 17324874 is less than 17324874.
```

## Root cause

Carbon's `addMonths()` defaults to overflow mode, so adding months to a
day that doesn't exist in the target month rolls forward into the next
month:

- `2026-04-30 + 10 months` → `2027-02-30` → overflow → `2027-03-02`
- `2026-04-30 + 11 months` → `2027-03-30`

Both end up in the same `Y-m` (`2027-03`). The dashboard
balance-evolution endpoint and
`LoanAmortizationService::projectFromBalance` use `addMonths` to build
month-keyed projections and to label projected chart points. When two
iterations collapse onto the same `Y-m`:

1. `generateProjection` overwrites the earlier balance with the later
one.
2. The controller emits two consecutive projected points labeled with
that month and assigns the same `mortgage_balance` to both, breaking the
strictly-decreasing assertion.

## Fix

Switch every `addMonths()` call in the projection paths to
`addMonthsNoOverflow()` so each iteration lands on a distinct month.

- `app/Services/LoanAmortizationService.php` — `projectFromBalance`,
`projectFromOriginal`
- `app/Http/Controllers/Api/DashboardAnalyticsController.php` — real
estate projected loop

## Verification

```
php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
Tests: 34 passed (220 assertions)
```

Previously failing on 2026-04-30, now green.
2026-04-30 13:53:01 +00:00
Víctor Falcón 0f2300bf3e
feat(accounts): show projection on real estate chart (#338)
Real estate account chart now appends 12 months of projected data when
the property has a revaluation percentage and/or a linked loan:

- Market value projected forward via the configured revaluation rate
  (compounded monthly).
- Mortgage balance projected via LoanAmortizationService on the linked
  loan, so the mortgage decline is visible alongside the property
  revaluation in the same chart.

Backend: DashboardAnalyticsController appends projected points with
display-currency variants. Frontend: chart adds dashed projection lines
for value and mortgage_balance plus a Today reference line. Tooltip and
currency-mode swap propagate the new projected_mortgage_balance field.
2026-04-27 08:08:12 +01:00
Víctor Falcón d697857041
typo: Add missing space (#339) 2026-04-27 07:59:45 +01:00
Víctor Falcón 13f741aaed
fix(real-estate): compound annual revaluation monthly (#337)
Previous formula divided annual % by 12 linearly, which over-applied
the configured rate when compounded over 12 months (e.g. 12% annual
grew balances ~12.68%/yr).

Use (1 + p/100)^(1/12) - 1 so 12 monthly applications equal the
configured annual percentage exactly. Works for negatives too.
2026-04-27 07:35:51 +01:00
Víctor Falcón af7ba727d5
ci: drop tests/** from code paths filter (#336) 2026-04-26 10:48:25 +02:00
Víctor Falcón 405e6bfc29
ci: skip build and deploy when no code changes (#335)
## Why

Pushes to `main` that only modify workflows, README, or docs were
triggering production deploys.

## What

- Adds a `changes` job using `dorny/paths-filter@v3` to detect whether
the push touched application code.
- Gates `build-image` and `deploy` on `needs.changes.outputs.code ==
'true'`.

## Code paths considered

`app/`, `bootstrap/`, `config/`, `database/`, `public/`, `resources/`,
`routes/`, `tests/`, `storage/`, `artisan`, `composer.{json,lock}`,
`package*.json`, `bun.lock*`, `vite.config.*`, `tsconfig*.json`,
`eslint.config.*`, `.prettierrc*`, `phpstan.neon*`, `phpunit.xml*`,
`pint.json`, `Dockerfile`, `docker/**`, `.dockerignore`, `.env.example`.

Anything else (e.g. `.github/**`, `*.md`, `docs/**`) is treated as
non-deployable.
2026-04-26 09:15:06 +01:00
Víctor Falcón b2d73ef5c3
ci: add manual release workflow (#334)
Adds a manually-triggered release workflow using `release-it`.

## How it works

1. Go to **Actions → Release → Run workflow**
2. Pick `patch` / `minor` / `major`
3. Workflow:
   - checks out `main`, creates `release/run-<id>` branch
- runs `release-it --ci <increment>`: bumps `package.json`, updates
`CHANGELOG.md`, commits, tags `vX.Y.Z`, pushes branch + tag, creates the
GitHub release
   - opens a PR back to `main` with the version bump + changelog

No direct push to `main` required. Tag and release are published
immediately; the PR just lands the version bump.

## Changes

- `.github/workflows/release.yml` — new workflow
- `.release-it.json` — allow non-main branches, ensure branch push

## Caveats

- Squash-merging the release PR leaves the tag on a dangling commit (tag
still valid). Use merge or rebase to keep the tag reachable from `main`.
2026-04-26 08:44:26 +01:00
Víctor Falcón 00c412a837 chore: release v0.1.20 2026-04-24 19:23:07 +02:00
Víctor Falcón 0eca002856
fix(docker): ensure www-data owns storage after artisan commands (#329)
## Problem

Sentry reporting permission-denied errors on `/open-banking/callback`:

> UnexpectedValueException: The stream or file
"/app/storage/logs/laravel.log" could not be opened in append mode:
Failed to open stream: Permission denied

4 issues, 14 events (PHP-LARAVEL-Z, 10, 11, 12).

## Root cause

Entrypoint runs as root. After the initial `chown www-data`, artisan
commands (`config:cache`, `route:cache`, `view:cache`, `event:cache`,
`migrate`) execute and can create/touch `storage/logs/laravel.log` as
root. Later, php-fpm (www-data) tries to append and fails.

Queue workers + inertia-ssr also run as root via supervisor — same
failure mode from worker side.

## Fix

- `docker/entrypoint.sh`: re-chown `/app/storage` and
`/app/bootstrap/cache` to www-data **after** artisan cache warm-up, just
before `exec supervisord`.
- `docker/supervisor/supervisord.conf`: add `user=www-data` to
`queue-worker`, `queue-worker-emails`, `inertia-ssr`. Nginx and php-fpm
master keep root (need port 80 / pool forking).

## Verification

Deploy and confirm Sentry issues stop firing on next
`/open-banking/callback` hits.
2026-04-24 18:21:15 +01:00
Víctor Falcón 79075dbcdf
fix(i18n): translate Unknown Income/Expense and other missing ES strings (#331)
## Summary

Fixes untranslated strings surfaced in the Spanish UI (e.g. `Unknown
Income`, `Unknown Expense` on the cashflow breakdown).

## Root cause

`CashflowAnalyticsController` pushed literal English names into the API
response for uncategorized totals, bypassing `__()`. Additional `__()`
calls across the sync flow and validation had no matching keys in
`lang/es.json`. Several frontend toasts/errors were also hardcoded.

## Changes

**Controller (P0)**
- `app/Http/Controllers/Api/CashflowAnalyticsController.php` — wrap
`Unknown Income`/`Unknown Expense` with `__()` (lines 250, 323).

**Translations (P0 + P1 + P2)**
- `lang/es.json` — added 23 keys (1730 → 1753):
  - `Unknown Income`, `Unknown Expense`, `Unknown Bank`, `Unknown error`
- Sync/banking messages: `Invalid credentials…`, `Rate limit exceeded…`,
`Failed to sync with the provider…`, `The provider is experiencing
issues…`, `An unexpected error occurred during sync…`, `Credentials
updated. Sync started.`, `Action required: :provider connection needs
attention`, `:count new transactions synced on Whisper Money`
- Validation: `The selected property cannot be linked.`, `The selected
property is already linked to a loan.`, `The verification link is
invalid.`, `This field is required.`
- Misc: `Confirm your waitlist spot - Whisper Money`, `Log in to your
bank`
- Frontend toasts: `Failed to re-evaluate rules. Please try again.`,
`Failed to load import data`, `All transactions failed to import`,
`Failed to update transactions`, `Failed to update transactions with
labels`

**Frontend hardcoded strings wrapped in `__()` (P2)**
- `resources/js/components/transactions/import-transactions-drawer.tsx`
- `resources/js/components/accounts/import-balances-drawer.tsx`
- `resources/js/lib/sync-manager.ts` (added `@/utils/i18n` import)
- `resources/js/components/transactions/transaction-list.tsx`
- `resources/js/components/transactions/import-transactions-button.tsx`
- `resources/js/pages/transactions/index.tsx`

## Verification

- `vendor/bin/pint --dirty` → pass
- `php artisan test --filter=CashflowAnalytics` → 24 passed
- `php artisan test --filter=Sync` → 166 passed
- `npm run build` → OK
- Post-fix audit: 0 real missing `__()` keys remaining
2026-04-24 17:58:26 +01:00
Víctor Falcón 7028400050
chore: remove release workflow (#330)
Direct pushes to `main` are blocked by branch ruleset, causing the
release workflow to fail. Removing it for now.
2026-04-24 16:49:39 +00:00
Copilot 25e14fa4db
Use Bun in manual release workflow (#327)
Manual `Release` workflow failed before `release-it` ran because it
installed dependencies with `npm ci` while repository dependency state
is maintained with Bun. This change aligns the release job with the
repo’s actual package manager so manual releases can install
dependencies from the committed lockfile.

- **Problem**
- `release.yml` used `npm ci`, which requires `package.json` and
`package-lock.json` to be perfectly in sync.
- Repo changes had moved dependency resolution forward via Bun, causing
the manual release job to fail during install instead of reaching
release creation.

- **Workflow change**
  - Keep Node setup for `release-it`.
  - Add Bun setup in the release job.
  - Replace `npm ci` with `bun install --frozen-lockfile`.

- **Result**
- Manual release job now follows same package manager path as rest of
repo/workflows.
- Dependency installation uses committed Bun lock state, avoiding
`package-lock.json` drift as release blocker.

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: '20'

- uses: oven-sh/setup-bun@v2

- name: Install dependencies
  run: bun install --frozen-lockfile
```

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: victor-falcon <238766+victor-falcon@users.noreply.github.com>
2026-04-24 18:24:45 +02:00
Víctor Falcón 267bdec405
ci: add manual release workflow (#326)
Adds a manually-triggered workflow to cut releases from the GitHub
Actions UI.

## What

New workflow: \`.github/workflows/release.yml\`

- Trigger: \`workflow_dispatch\` with a \`bump\` input (\`patch\` /
\`minor\` / \`major\`, default \`patch\`)
- Runs \`npx release-it <bump> --ci\` → reuses existing
\`.release-it.json\`:
  - angular conventional-changelog preset
  - updates \`CHANGELOG.md\`
  - commits \`chore: release vX.Y.Z\`
  - tags \`vX.Y.Z\`
  - creates GitHub release with grouped Features / Bug Fixes notes

## Auth

Uses the default \`GITHUB_TOKEN\` (no PAT). Requires:

- \`permissions: contents: write\` (push commit/tag, create release)
- Repo setting: Settings → Actions → General → Workflow permissions →
**Read and write permissions**

If \`main\` has branch protection blocking direct pushes, the bot token
must be allowed to bypass, otherwise the push step will fail and a PAT
will be needed instead.

## How to run

Actions tab → Release → Run workflow → pick bump type.
2026-04-24 14:39:26 +01:00
Víctor Falcón 5b1d059e02
feat(loans): backfill historical balances on loan creation (#322)
## Summary

Mirror the real-estate historical-balance pattern for loan accounts.
When a loan is created with a current balance, linearly interpolate
monthly `account_balances` rows between the loan start date
(`original_amount`) and today (current balance).

## Approach

Since we don't know how the user actually paid down the loan, use simple
linear regression between two known points:
- `(start_date, original_amount)` from `loan_details`
- `(today, current_balance)` from the latest `account_balances` row

Rows are placed on the start date, the 1st of each intermediate month,
and today.

## Changes

- **`LoanBalanceGeneratorService`** — linear interpolation + upsert on
`(account_id, balance_date)` so existing rows aren't duplicated.
- **`GenerateHistoricalLoanBalancesJob`** — `ShouldQueue`, 3 tries, 10s
backoff. Takes account, original amount, start date, current balance,
from, to.
- **`Settings/AccountController::store`** (loan branch) — after creating
`loanDetail`, if a starting balance was provided: generate the last 12
months synchronously, dispatch older window to the queue when
`start_date` predates it.
- **`LoanDetailController::update`** — when the detail is first created
for an existing account, use the most recent `AccountBalance` as the
current value and apply the same sync/async split.

## Tests

- Service: interpolation, single-balance edge, future start_date, upsert
dedupe, monthly 1st placement, from/to range.
- Job: implements `ShouldQueue`, respects from/to range.

```
Tests:    8 passed (62 assertions)
```

No dependency or directory-structure changes.
2026-04-24 13:09:34 +01:00
Víctor Falcón 74cbdd42ef
feat(billing): apply Stripe tax rates to subscriptions (#325)
## Summary
Every subscription now gets Stripe tax rates attached automatically via
Cashier.

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

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

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

## Notes
Using hard-coded tax rate IDs (not Stripe Tax auto-calc). Switch to
`Cashier::calculateTaxes()` later if desired.
2026-04-24 14:07:58 +02:00
Víctor Falcón b399aaaa0d
feat(subscriptions): add configurable trial period to paid plans (#324)
## Summary

Adds a 15-day trial to the monthly and yearly plans. Configurable per
plan (or disabled) via config.

## Changes

- `config/subscriptions.php` — new `trial_days` key per plan (defaults:
monthly=15, yearly=15). Env overrides: `STRIPE_PRO_MONTHLY_TRIAL_DAYS`,
`STRIPE_PRO_YEARLY_TRIAL_DAYS`. Set to `0` to disable.
- `SubscriptionController::checkout` — applies `trialDays()` on the
Cashier subscription builder when `trial_days > 0`.
- Tests — assert `trial_days` surfaced in pricing props; assert
`trialDays(15)` applied on checkout; assert skipped when `0`.

## Notes

Stripe Checkout enforces a **minimum 2-day trial**. Values of `1` will
fail at Stripe. `0` disables cleanly.

## Test plan

```
php artisan test --compact tests/Feature/SubscriptionTest.php
```
2026-04-24 14:07:46 +02:00
Víctor Falcón 169ebf22a5
worktree: Update script (#323) 2026-04-24 13:00:40 +01:00
Víctor Falcón 4b145e230b
feat(accounts): add today marker on projected balance chart (#321)
## Summary

Adds a dashed 'Today' vertical reference line on the account balance
chart when a loan amortization projection is rendered. Makes it obvious
which months on the chart are historical (carry-forward of the last
recorded balance) and which are future projections.

## Context

Loan accounts extend their balance-evolution series with 12 months of
projected amortization values (see
`DashboardAnalyticsController::accountBalanceEvolution`). Without a
marker, past carry-forward months and future projected months looked
like a single continuous history.

## Changes

- `resources/js/components/accounts/account-balance-chart.tsx`
  - Import `ReferenceLine` from recharts.
  - Compute `todayMarker` (`yyyy-MM` monthly, `yyyy-MM-dd` daily).
- Render `<ReferenceLine>` inside the projected `ComposedChart` branch.
- Styling matches the budget spending chart (1px dashed, foreground
stroke, 'Today' label on top).

## Screenshots

_Visual change only — renders a dashed vertical line at the current date
on loan account charts with projection._
2026-04-24 12:45:51 +01:00
Víctor Falcón 38e1976270
fix(chart): hide tooltip on scroll with opacity fade (#320)
## Summary

Follow-up to #317. Portaled tooltip uses `position: fixed`; on scroll it
stayed anchored to stale viewport coords.

- Listen to `scroll` (capture) and `resize`, flip a `hidden` flag to
hide the tooltip.
- Reset flag whenever Recharts reports a new `coordinate` (pointer back
on a bar).
- Replace `visibility` binary toggle with `opacity` + 120ms transition
so the whole tooltip (bg, border, text) fades as one layer instead of
revealing staggered unmount order.

## Test plan
- Hover a bar → tooltip fades in at cursor.
- Scroll the page while hovering → tooltip fades out smoothly, whole
element at once.
- Hover another bar after scrolling → tooltip reappears.
2026-04-24 08:28:14 +01:00
Víctor Falcón 2b4e0e0984
chore: copy .pi dir in worktree setup (#319) 2026-04-23 08:43:06 +01:00
Víctor Falcón 753002f930
fix(dashboard): dismiss account card tooltip when tapping outside (#318)
## Problem

On mobile, the sparkline tooltip on the dashboard account cards covered
almost the entire card, making it very hard to dismiss — you had to tap
very specific spots inside the card to clear it.

## Fix

Listen for `pointerdown` events outside the chart wrapper and unmount
the Recharts `<Tooltip>` to dismiss it. Because only the Tooltip is
toggled (not `<Line>`), there's no redraw animation.

Gated behind `matchMedia('(hover: hover)')` so desktop hover behavior is
left alone — tooltip still appears on hover without any click needed.

Works across multiple cards: tapping one dismisses tooltips on all
others.
2026-04-23 08:35:21 +01:00
Víctor Falcón e4d2ade92f
fix(chart): tooltip escapes overflow, truncates long labels (#317)
## Summary

- Portal chart tooltip to `document.body` to escape ancestor
`overflow-hidden` (CardContent, scroll container). Positioned via
Recharts' `coordinate` prop + `.recharts-wrapper` viewport rect, with
viewport-edge flipping.
- Cap tooltip width and truncate long account/liability names with
ellipsis; values stay nowrap.
- Force `grid-cols-[minmax(0,1fr)]` on tooltip grid tracks so `truncate`
actually engages (grid items default to min-width auto).

## Test plan
- Hover net-worth chart: tooltip no longer clipped by container.
- Position follows cursor; flips near viewport edges.
- Long liability/account names show ellipsis within tooltip bounds.
2026-04-23 08:17:38 +01:00
Víctor Falcón 2604f1158c
Support soft-deleted users with reusable emails (#316)
## Summary
- soft-delete users by adding `deleted_at` to `users`
- rename deleted user emails with a timestamp prefix so original email
can be reused
- block email sends and banking follow-up work for deleted users while
preserving data

## Testing
- php artisan test --compact tests/Feature/DeleteUserCommandTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php
tests/Feature/Console/SendUpdateEmailCommandTest.php
- vendor/bin/pint --dirty --format agent
2026-04-22 11:41:41 +01:00
Víctor Falcón c7cfa10117
fix: expose pi mcp extension as mcps.ts (#315)
## Summary
- add project-local `mcps.ts` extension entry for Pi auto-discovery
- move MCP bridge implementation into `.pi/extensions/mcps/main.ts`
- ignore nested `node_modules/` directories so extension package
installs do not dirty repo

## Testing
- `node -e "const
createJiti=require('/opt/homebrew/lib/node_modules/@mariozechner/pi-coding-agent/node_modules/@mariozechner/jiti/lib/jiti.cjs');
const jiti=createJiti(process.cwd()); const
mod=jiti('./.pi/extensions/mcps.ts'); if (typeof mod.default !==
"function") process.exit(1); console.log("ok");"`
2026-04-22 09:51:20 +01:00
Víctor Falcón 9e6920dbc7
chore: add PR check watch note to AGENTS (#314)
## Summary
- add instruction to watch PR checks after PR creation
- include fail-fast command example for CI monitoring

## Testing
- not run (AGENTS.md change only)
2026-04-21 11:14:06 +01:00
Víctor Falcón 905edeb4a2
fix: route new PWA guests to signup (#313)
## Summary
- redirect guest access to protected routes based on returning-user
cookie
- send new PWA guests to registration and returning guests to login
- persist returning-user cookie after register, login, and 2FA login
- keep explicit login links working via `force=1`

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Auth/AuthenticationTest.php
tests/Feature/DashboardTest.php tests/Feature/Auth/RegistrationTest.php
2026-04-21 10:53:05 +01:00
Víctor Falcón 240fcf1703
feat(landing): add signed auth links (#312)
## Summary
- add signed landing links that unlock auth buttons while
HIDE_AUTH_BUTTONS is enabled
- persist the unlock in a secure cookie so desktop and installed PWA
users can still sign up
- add artisan command to generate signed landing auth links and test
coverage for the flow

## Testing
- php artisan test --compact tests/Feature/LandingAuthOverrideTest.php
tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php
tests/Feature/Auth/RegistrationTest.php
- php artisan test --compact tests/Feature/WelcomeBanksOrderingTest.php
tests/Feature/SubscriptionTest.php
- vendor/bin/pint --dirty --format agent
2026-04-21 08:28:59 +01:00
Víctor Falcón 69665c3c58
feat(stripe): add promo code generator (#311)
## Summary
- add artisan command to generate N Stripe promotion codes
- default coupon to 0E5fAsXG and enforce single redemption
- add feature test coverage for success and validation

## Testing
- php artisan test --compact
tests/Feature/GenerateStripePromotionCodesCommandTest.php
- vendor/bin/pint --dirty --format agent
2026-04-20 18:15:28 +01:00
Víctor Falcón 16675f6518
fix(open-banking): suppress first sync email (#310)
## Summary
- save the first-sync email cutoff after the initial import finishes so
onboarding transactions are not counted as later sync activity
- add a regression test that reproduces onboarding imports and verifies
the daily sync email stays silent afterward

## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent
2026-04-20 13:38:51 +01:00
Víctor Falcón bfe1af3c83
fix(ci): order sentry deploy after build (#309)
## Summary
- make the `deploy` job wait for `build-image` so Sentry release
creation finishes before deploy tracking runs
- keep the Sentry deploy marker on the same release name while removing
the race that caused `Release not found`
- add a focused regression test that asserts the workflow keeps this
dependency in place

## Testing
- php artisan test --compact tests/Feature/SentryConfigTest.php
2026-04-20 13:35:07 +01:00
Víctor Falcón 3500eaa469
refactor(real-estate): remove Pennant gating (#308)
## Summary
- remove the `real-estate` Pennant flag definition, request gating,
local auto-activation middleware, and frontend shared flag plumbing
- make real-estate account creation and onboarding availability the
default path for all users
- update feature and browser tests to cover default availability instead
of flag enable/disable behavior

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/RealEstateTest.php
- php artisan test --compact
tests/Feature/RealEstateAvailabilityTest.php
- php artisan test --compact tests/Browser/RealEstateAccountTest.php
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
- php artisan test --compact --filter=\"can create a real estate account
linked to an existing loan\" tests/Browser/BankAccountsTest.php

## Notes
- `npm run build` completed the client build, but the Sentry sourcemap
upload step failed with an SSL/TLS error from the configured plugin
- deploy follow-up: run `php artisan pennant:purge real-estate` to
remove stale stored flag values
2026-04-20 13:31:49 +01:00
Víctor Falcón 75736f3e59
fix(auth): allow forced registration (#307)
## Summary
- allow `/register?force=1` to bypass the hidden-auth redirect and
render the registration page
- preserve the `force=1` query on form submit so hidden signup still
works end-to-end
- enforce hidden-signup blocking in the Fortify user creation path and
cover the forced/unforced flows with tests
2026-04-20 10:00:52 +01:00
Víctor Falcón fbffdd3f3c
fix(open-banking): respect local email hours (#306)
## Summary
- avoid sending bank transaction synced emails during 23:00-08:00 in the
user's local timezone by re-releasing the job until the next local 08:00
- deduplicate the daily bank transaction email by the user's local date
instead of the UTC date
- add feature coverage for quiet hours, first allowed local send time,
and local-day deduplication

## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent

## Notes
- existing 6-hour sync slots still leave at least one valid send slot
for every timezone
2026-04-19 16:44:40 +01:00
Víctor Falcón 827acb8c15
chore: Use caveman on AGENTS.md (#305)
## Summary
- add concise caveman-mode communication rules at top of `AGENTS.md`
- keep existing agent guidance intact while making terse-response
behavior explicit
2026-04-19 16:18:31 +01:00
Víctor Falcón 45e311e17b
fix(budgets): retry assignment deadlocks (#304)
## Summary
- lock the transaction row before reconciling `budget_transactions` so
concurrent assignment work serializes per transaction
- retry the assignment transaction on deadlocks using Laravel's built-in
transaction attempts
- add regression coverage that asserts the deadlock retry path remains
configured for `PHP-LARAVEL-D`

## Testing
- php artisan test --compact
tests/Feature/BudgetTransactionServiceTest.php
tests/Feature/Listeners/AssignTransactionToBudgetTest.php
2026-04-19 11:21:27 +01:00
Víctor Falcón b1ceda61f9
fix(budgets): make budget assignment idempotent (#303)
## Summary
- replace wipe-and-reinsert budget assignment with idempotent
reconciliation in `BudgetTransactionService`
- keep the fix scoped to the service layer instead of relying on queued
listener uniqueness or event metadata
- add regression coverage for duplicate reruns, stale assignment
cleanup, historical reruns, and label-only listener updates

## Testing
- php artisan test --compact
tests/Feature/BudgetTransactionServiceTest.php
tests/Feature/Listeners/AssignTransactionToBudgetTest.php
tests/Feature/TransactionTest.php
tests/Feature/BulkUpdateTransactionsTest.php
2026-04-18 18:39:00 +01:00
Víctor Falcón 22952c4e75
fix(cashflow): read period from server props instead of window (#302)
## Summary
- Fixes
[PHP-LARAVEL-9](https://whisper-money.sentry.io/issues/PHP-LARAVEL-9):
`ReferenceError: window is not defined` during Inertia SSR of
`CashflowPage`.
- `CashflowController` now reads + validates the `period` query param
(`YYYY-MM`) and passes it as an Inertia prop.
- Client `useState` initializer reads from `usePage().props.period`
instead of `window.location.search`, making SSR safe and avoiding
hydration mismatch.
- `useEffect` URL sync now compares against the prop, not
`window.location.search`.

## Why (c) not a `typeof window` guard
Server-provided params avoid hydration mismatch and keep URL as single
source of truth.

## Tests
New `tests/Feature/CashflowPageTest.php`:
- guest redirect
- no query param → `period` prop `null`
- valid `?period=2025-03` → prop `'2025-03'`
- invalid value → `null`
- malformed format (`2025-3`) → `null`

All 5 pass (50 assertions).

## Scope
Cashflow only. Other pages with similar `window.location.search` in
`useState` initializers (`onboarding/index.tsx`, `welcome.tsx`,
`auth/login.tsx`) left for follow-up.

Fixes PHP-LARAVEL-9
2026-04-17 10:27:49 +01:00
Víctor Falcón cfa54a2d9d
fix(demo-reset): use renamed 'ING Direct' bank (#301)
## Summary
- `demo:reset` was looking up the `ING` bank, but it was renamed to `ING
Direct` in production.
- The miss fell through to `Bank::factory()->create(...)`, which fails
in prod because Faker's `fake()` helper isn't available (dev-only
autoload).

## Fix
Update the name lookup in `ResetDemoAccountCommand` to `ING Direct`.

## Sentry
- Fixes PHP-LARAVEL-7 (root cause)
- Fixes PHP-LARAVEL-4 (scheduler wrapper)
2026-04-17 09:43:15 +01:00
Víctor Falcón d79fceff9a
chore: add sentry mcp (#300) 2026-04-17 10:42:34 +02:00
Víctor Falcón 244344e953
feat(open-banking): remove feature flag gating (#297)
## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows

## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`

## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)
2026-04-17 10:20:05 +02:00
Víctor Falcón 63e472e8a4
test(schedule): remove stale horizon snapshot (#298)
- remove random and silly test
2026-04-16 15:14:16 +01:00
Víctor Falcón fde5405777
fix(user): persist detected timezones (#296)
## Summary
- store browser-detected IANA timezones on users during registration and
share the value to the frontend
- silently backfill missing timezones for authenticated users without
overwriting existing saved values
- add focused feature coverage for registration, shared props, and the
timezone backfill endpoint

## Testing
- php artisan test --compact tests/Feature/Auth/RegistrationTest.php
tests/Feature/InertiaSharedDataTest.php
tests/Feature/Settings/TimezoneTest.php
- npm test -- resources/js/utils/currency.test.ts
2026-04-16 11:36:57 +01:00
Víctor Falcón 473ac03088
fix(open-banking): skip silent sync emails (#295)
## Summary
- add a per-connection cutoff timestamp so silent first/full sync
imports are excluded from later daily bank sync emails
- keep the existing one-email-per-day user cap while still reporting
transactions created after the silent sync cutoff
- add regression coverage for silent first sync, full sync, and
post-cutoff reporting behavior

## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent
2026-04-16 08:28:44 +01:00
Víctor Falcón 64ec047769
chore: track Sentry releases in CI (#294)
## Summary
- create and finalize Sentry releases in CI using
`whisper-money@<git-sha>`
- bake the release into the production image so Laravel tags backend
events with the deployed version
- mark production deploys in Sentry and add a focused test covering the
release binding

## Testing
- php artisan test --compact tests/Feature/SentryConfigTest.php
2026-04-16 08:04:59 +01:00
Víctor Falcón b438a1c73b
fix(schedule): remove stale horizon snapshot (#293)
## Summary
- remove the stale `horizon:snapshot` scheduled command that still runs
in production
- add a regression test to ensure scheduled commands do not include
Horizon snapshot
- keep production aligned with the current `queue:work` worker setup
2026-04-16 07:35:33 +01:00
Víctor Falcón c90e8166bf
fix(open-banking): sort bank sync email data (#292)
## Summary
- sort bank names before building the daily synced-transactions email
payload
- make the queued mailable payload deterministic so the open banking job
test stops failing intermittently
- keep the email bank list order stable for users

## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
2026-04-15 16:42:06 +01:00
Víctor Falcón 37498111d6
chore: configure production Sentry integration (#291)
## Summary
- wire Laravel exception handling into Sentry via `bootstrap/app.php`
- add the `sentry_logs` logging channel and document production Sentry
env defaults
- keep local/example defaults disabled while enabling the production
example for logs, traces, and profiles

## Verification
- `vendor/bin/pint --dirty --format agent`
- `php artisan sentry:test` could not run locally after disabling the
local DSN, which is expected
- `php artisan test --compact tests/Feature/ExampleTest.php` currently
fails because the local Vite manifest is missing at
`public/build/manifest.json`
2026-04-15 16:00:47 +01:00
Víctor Falcón 552aa59aaf
fix: limit bank sync emails to one per day (#290)
## Summary
- move bank transaction sync emails from per-connection inline sends to
a unique per-user daily job
- send at most one bank sync email per user per day while still
including all unreported enable-banking transactions since last reported
mail
- keep first-ever connection sync silent and add coverage for same-day
suppression and next-day catch-up emails

## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
2026-04-15 16:39:28 +02:00
Víctor Falcón 2f583c0113
Cancel Enable Banking connections for free users (#289)
## Summary
- add month-end command to revoke old Enable Banking sessions for users
without a paid plan while keeping their linked accounts as manual
accounts
- extract banking disconnect flow into a reusable action so manual
disconnects and scheduled cleanup share same revoke and detach behavior
- add feature coverage for free-user cleanup, paid-user skips,
under-6-hour skips, non-Enable Banking skips, and revoke failure
fallback

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php
tests/Feature/OpenBanking/ConnectionControllerTest.php
2026-04-15 16:23:03 +02:00
Víctor Falcón 319ca758e1
fix(pricing): update final release prices (#288)
## Summary
- update subscription pricing to the final release amounts for monthly
and annual billing
- keep billing UI formatting aligned with the configured currency so
1.99 €/month displays correctly
- refresh subscription and Stripe sync tests to lock the new values in
place
2026-04-15 14:49:02 +01:00
Víctor Falcón 094fb1b744
feat(real-estate): auto-calculate revaluation % and generate historical balances (#253)
## Summary

- **Auto-calculates Annual Revaluation %** (CAGR) on the frontend when
purchase price, purchase date, and current market value are all provided
— pre-fills the revaluation field while still allowing manual override
- **Generates historical monthly balance records** via linear
interpolation from purchase date to today when creating a real estate
account with complete purchase data
- New `RealEstateBalanceGeneratorService` handles balance generation
with dates on the 1st of each month (matching the existing
`ApplyRealEstateRevaluationCommand` convention)

## Changes

### Backend
- **`app/Services/RealEstateBalanceGeneratorService.php`** (new) —
Linear interpolation service that builds balance dates (purchase date,
1st of each intermediate month, today) and creates `AccountBalance`
records using `updateOrCreate`
- **`app/Http/Controllers/Settings/AccountController.php`** — Calls the
service after real estate detail creation when all three inputs are
present

### Frontend
- **`resources/js/components/accounts/account-form.tsx`** — Added CAGR
auto-calculation via `useEffect` with a `useRef` flag
(`isRevaluationManuallySet`) to avoid overwriting manual user input

### Tests
- **`tests/Feature/Services/RealEstateBalanceGeneratorServiceTest.php`**
(new) — 7 unit tests covering interpolation, edge cases (same-day
purchase, flat values, single month spans)
- **`tests/Feature/RealEstateTest.php`** — 6 new feature tests covering
historical balance generation through the controller (with/without
purchase data, same-day, flat values)

All 38 tests in `RealEstateTest.php` and all 7 service tests pass.
2026-04-15 12:18:33 +00:00
Víctor Falcón 5b78509588
feat: resend verification emails to unverified leads (#287)
## Summary

- Adds `leads:resend-verification-emails` artisan command that
dispatches `VerifyUserLeadEmailNotification` to all leads where
`email_verified_at IS NULL`
- Supports `--dry-run` flag to preview the count without sending
- Follows the same pattern as `leads:retry-failed-jobs` (progress bar,
summary table)

## Test plan

- [ ] `--dry-run` shows correct count without dispatching
- [ ] Command dispatches exactly one notification per unverified lead
- [ ] Verified leads are skipped
- [ ] Early exit when no unverified leads exist
2026-04-15 09:13:27 +01:00
Víctor Falcón f408dbe4c8
feat: selective retry of failed lead email jobs (#286)
## Summary

- Adds `leads:retry-failed-jobs` artisan command to selectively retry
failed `emails`-queue jobs after a Resend rate-limit incident
- Retries jobs for verified leads and `VerifyUserLeadEmailNotification`
for unverified leads; forgets jobs for deleted leads (DDoS cleanup) or
unverified leads receiving waitlist emails
- Adds `deleteWhenMissingModels = true` to all lead mail/notification
classes so future jobs for deleted leads are silently discarded instead
of failing

## Usage

```bash
# Preview (no changes)
php artisan leads:retry-failed-jobs --dry-run

# Execute
php artisan leads:retry-failed-jobs
```

## Test plan

- [x] `leads:retry-failed-jobs` forgets jobs for deleted leads
- [x] `leads:retry-failed-jobs` retries jobs for verified leads
- [x] `leads:retry-failed-jobs` forgets waitlist jobs for unverified
leads
- [x] `leads:retry-failed-jobs` retries
`VerifyUserLeadEmailNotification` for unverified leads
- [x] `leads:retry-failed-jobs` handles mixed job types correctly
- [x] `--dry-run` does not modify `failed_jobs`
2026-04-15 08:00:29 +01:00
Víctor Falcón d0aab3d11b
feat: verify waitlist leads (#285)
## Summary
- gate waitlist signup behind email confirmation so only verified leads
receive a queue position and referral code
- add signed lead verification routes, a check-email page, and a
dedicated verification email for the waitlist flow
- update lead syncing and feature tests so only verified leads are
exported to Resend and referral movement happens after verification

## Testing
- php artisan test --compact tests/Feature/UserLeadTest.php
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- php artisan test --compact tests/Feature/MailSenderTest.php
- vendor/bin/pint --dirty --format agent
2026-04-14 11:26:01 +01:00
Víctor Falcón 1f9c0cf030
chore: remove hardcoded resend segment ID (#284)
## Summary
- remove the hardcoded Resend leads segment ID from tracked config
- keep `RESEND_LEADS_SEGMENT_ID` environment-driven for production use
- replace leaked test UUID references with a test-only placeholder

## Testing
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- vendor/bin/pint --dirty --format agent
2026-04-13 20:27:00 +01:00
Víctor Falcón dc0695c2ca
feat: sync user leads to resend (#283)
## Summary
- add a `resend:sync-leads` command that syncs all `user_leads` into the
Resend leads segment
- make lead sync idempotent by creating contacts with the segment and
falling back to adding existing contacts to the segment
- schedule the command daily at `03:00` UTC and cover the
command/fallback behavior with Pest tests

## Testing
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
2026-04-13 19:56:08 +01:00
Víctor Falcón ea9956f21d
fix: keep iOS popovers below the notch (#282)
## Summary
- add safe-area CSS variables for the viewport top and bottom insets
- apply safe-area-aware collision padding to the shared popover
primitive so iOS overlays stay below the notch
- add a focused regression test for the popover safe-area guard

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PopoverSafeAreaTest.php
2026-04-13 15:19:56 +01:00
Víctor Falcón 80b666836c
fix: avoid iOS PWA status bar overlap (#281)
## Summary
- switch the iOS standalone PWA status bar style from
`black-translucent` to `default`
- keep installed iOS app content below the status bar and notch area
- extend the PWA template test to guard against regressing to
translucent overlap

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
2026-04-13 14:38:48 +01:00
Víctor Falcón b505d68ef0
fix: keep iOS content below the notch (#280)
## Summary
- move the top safe-area inset from the mobile header to the shared
authenticated content shell
- keep scrolling content on iOS sidebar pages from sliding under the
notch
- add a focused regression test for the shared layout safe-area
placement

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/AuthenticatedLayoutSafeAreaTest.php
2026-04-13 13:59:05 +01:00
Víctor Falcón db359a0572
test: expand browser coverage for account types and transactions (#279)
## Summary
- add browser coverage for manual credit card, investment, retirement,
and other account creation flows
- replace placeholder transaction search coverage with real filter
assertions and add edit/delete transaction browser tests
- keep the new coverage focused on the existing account and transaction
browser test files

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Browser/BankAccountsTest.php
tests/Browser/TransactionsTest.php
2026-04-13 14:02:21 +02:00
Víctor Falcón a7c1bd35ef
feat: link loans to existing properties (#275)
## Summary
- add an optional linked property selector when creating loan accounts
in both the main create dialog and onboarding
- validate that only the current user's unlinked real-estate accounts
can be selected and persist the reciprocal link after loan creation
- expose linked property state in shared account props and cover the new
flow with focused loan feature tests

## Testing
- php artisan test --compact tests/Feature/LoanTest.php
- vendor/bin/pint --dirty --format agent
2026-04-13 09:51:13 +01:00
Víctor Falcón dc41c5f6e0
test: expand browser coverage for accounts and linked loans (#277)
## Summary
- add browser coverage for manual loan account creation with balance and
loan details
- add browser coverage for creating real-estate accounts linked to
existing loan accounts
- add browser coverage for updating balances and linking or unlinking
loans from account details

## Testing
- php artisan test --compact tests/Browser/BankAccountsTest.php
tests/Browser/AccountsPageTest.php
2026-04-13 09:33:18 +01:00
Víctor Falcón d91d9d3b3e
fix: default to standard onboarding option (#276)
## Summary
- default the onboarding account setup choice to the connected Standard
option when open banking is available
- change the Standard onboarding card and connected-bank step accents
from blue to emerald for consistent plan styling
- update the onboarding browser test comment to reflect the new default
selection

## Verification
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php` *(3
unrelated existing failures in browser assertions)*
2026-04-13 08:54:12 +01:00
Víctor Falcón dafc58f49f
fix: clarify account creation modal copy (#274)
## Summary
- update the create account modal copy to explicitly mention loans and
property accounts
- clarify the manual option so users understand it also covers manually
created real-estate accounts
- update the browser test assertion to match the revised dialog copy
2026-04-12 16:38:00 +01:00
Víctor Falcón 80274e03a8
fix: align onboarding account types with current asset support (#273)
## Summary
- add real estate to the onboarding account type explainer and update
investment copy to mention crypto and cold wallets
- align onboarding manual account creation with the real-estate feature
flag and real-estate payload requirements
- cover the onboarding copy and real-estate creation flow with browser
tests

## Testing
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
2026-04-12 16:22:38 +01:00
Víctor Falcón 62ab1b38db
fix: clarify mobile settings navigation (#272)
## Summary
- add a hamburger icon to the mobile settings dropdown trigger so it
reads more clearly as navigation
- reduce the mobile spacing between the settings subtitle and the
dropdown trigger
- keep the desktop settings sidebar unchanged

## Testing
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
2026-04-10 17:59:52 +01:00
Víctor Falcón 3acb277fb5
feat: add appearance shortcut to user menu (#269)
## Summary
- add an Appearance shortcut to the user dropdown between Privacy mode
and Settings
- use the existing appearance route so users can jump straight to
`/settings/appearance`
- add a focused frontend test covering the new menu item and its order
2026-04-10 14:59:16 +01:00
Víctor Falcón 38cf672c8e
fix: default account charts to user currency (#271)
## Summary
- default account charts to the user's currency when a currency toggle
is available
- reverse the account chart currency toggle order so the user currency
appears first
- add a focused frontend test covering the toggle order and selected
state

## Testing
- npm test -- chart-currency-toggle
2026-04-10 14:41:36 +01:00
Víctor Falcón 0735ee6d69
fix: preserve cents in chart amounts (#270)
## Summary
- keep two decimal places in net worth and account chart amounts instead
of rounding to whole units
- update shared chart tooltip, trend tooltip, and MoM tooltip currency
formatting so related chart values stay consistent
- add targeted formatter coverage for chart-style balances with decimals

## Testing
- npm test -- resources/js/utils/currency.test.ts
2026-04-10 15:41:24 +02:00
Víctor Falcón 1b76729154
chore: Change mobile menu translation on spanish (#268) 2026-04-10 13:46:26 +01:00
Víctor Falcón 1e2036110f
fix: prioritize exact bank search matches (#267)
## Summary
- remove the bank search result cap so exact matches are no longer
pushed out of the response
- rank exact name matches first, then prefix matches, then broader
substring matches
- add feature coverage for search ranking, full result sets, and
user-specific visibility
2026-04-10 13:32:56 +01:00
Víctor Falcón fec93734c0
fix: reorder signed names in mail templates (#266)
## Summary
- update mail signatures to show Álvaro before Víctor across the Blade
mail templates
- add a focused mail test that guards the signature order so the old
ordering does not reappear
- verified with `php artisan test --compact
tests/Feature/MailSenderTest.php`
2026-04-10 13:32:32 +01:00
Víctor Falcón 7be0fe0120
fix: make transaction sync email use default sender (#265)
## Summary
- explicitly set the transaction sync mailable sender to the configured
default transactional address
- add a regression test so the transaction sync email cannot fall back
to an unintended sender

## Testing
- php artisan test --compact tests/Feature/MailSenderTest.php
2026-04-07 13:50:42 +01:00
Víctor Falcón 12db64ad60
chore: Generate random port on composer run dev (#264) 2026-04-06 11:02:45 +00:00
Víctor Falcón c3ff4c684a
feat: store invested_amount in user currency instead of account currency (#262)
## Summary

- **Store `invested_amount` in user's currency** (e.g. EUR) instead of
the account's currency (e.g. BTC), since it represents "how much of my
money did I put in" — a concept tied to the user's home currency.
- **Flip `display_invested_amount`** in API responses: now converts from
user currency → account currency (previously account → user), for the
chart's account-currency toggle mode.
- **Remove redundant conversions** in `AccountMetricsService` since
invested amounts are already in user currency after sync.

## Changes

### Backend (5 files)
- **BinanceBalanceSyncService** — convert to
`$account->user->currency_code` instead of `$account->currency_code`
- **BitpandaBalanceSyncService** — same currency target change
- **IndexaCapitalBalanceSyncService** — inject
`CurrencyConversionService`, convert invested amount float to user
currency before storing as cents
- **DashboardAnalyticsController** — flip `display_invested_amount`
conversion direction (user→account) in both monthly and daily endpoints
- **AccountMetricsService** — remove 4 now-redundant invested_amount
conversion spots (values already in user currency)

### Frontend (6 files)
- **update-balance-dialog.tsx** — invested amount input uses user
currency
- **balances-modal.tsx** — invested amount display and edit uses user
currency
- **account-balance-chart.tsx** — currency toggle: user-currency mode
keeps invested_amount as-is; account-currency mode swaps to
`display_invested_amount`
- **import-balances-drawer.tsx** — passes `investedAmountCurrencyCode`
(user currency) to sub-components
- **import-balance-step-mapping.tsx** /
**import-balance-step-preview.tsx** — format invested amounts in user
currency

### Tests (2 files)
- **IndexaCapitalBalanceSyncTest** — use `app()` instead of `new`
(constructor now has DI), pin account currency to USD for deterministic
assertions
- **DashboardAnalyticsTest** — add EUR-based exchange rate, flip
assertion from `/ 0.90` to `* 0.90`
2026-04-06 12:48:13 +02:00
Víctor Falcón ce5692cb30
fix: split drip and default email senders (#263)
## Summary
- route drip mailables through `Álvaro and Víctor <hi@whisper.money>`
and send the rest from `Whisper Money <no-reply@whisper.money>`
- remove legacy per-mailable `Victor` sender overrides so non-drip mail
falls back to the default sender consistently
- add focused sender coverage for drip, non-drip, and verification mail
paths

## Testing
- `php artisan test --compact tests/Feature/MailSenderTest.php
tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php`
2026-04-06 12:16:47 +02:00
Víctor Falcón 3990472249
Make bank selection optional when creating or updating accounts (#261)
## Summary

- Makes `bank_id` nullable for all account types in both
`StoreAccountRequest` and `UpdateAccountRequest` (previously it was only
nullable for real estate)
- Shows the bank combobox field for all account types in the
`AccountForm` component, including real estate
- Removes the `required` attribute from the hidden bank input and the
logic that cleared bank selection when switching to real estate type

## Test changes

- Updated "validates required fields" test to no longer expect `bank_id`
as a required field
- Added "can create a new account without a bank" test in `AccountTest`
- Updated `RealEstateTest` to verify non-real-estate accounts can also
be created without a bank (was previously asserting the opposite)
2026-04-04 16:33:26 +01:00
Víctor Falcón 6ce5b123ce
fix: add missing port to frontend Bugsink DSN (#260)
## Summary
- Adds the missing `:8000` port to the frontend Sentry/Bugsink DSN in
`app.tsx`, so errors are sent to the correct endpoint at
`bugsink.whisper.money:8000`.
2026-04-04 15:10:14 +00:00
Víctor Falcón 7e958284e3
fix(loans): project monthly balances from actual entries instead of original params (#259)
## Summary

- **Bug**: The `loans:generate-balances` command called
`getBalanceAtDate()` which always recalculated the remaining balance
from the **original loan parameters** (`original_amount`,
`annual_interest_rate`, `loan_term_months`, `start_date`), completely
ignoring existing actual balance entries. When the real balance was
lower than the theoretical amortization schedule (e.g. user made extra
payments), the auto-generated balance would **jump up** instead of
continuing to decrease.
- **Example**: Account `019d0b33-d3c5-7110-9033-51155ac93219` had a real
balance of ~4,489,670 (March 2026), but the command generated 5,700,356
(April 2026) — a jump **up** of ~1.2M cents instead of the expected ~16K
decrease.
- **Fix**: `getBalanceAtDate()` now checks for the latest
`AccountBalance` entry on or before the target date and projects forward
from it, falling back to the theoretical formula only when no entries
exist. This matches the method's own documented behavior and aligns with
how `generateProjection()` already works.

## Testing

Added 4 new tests:
- Projects from existing balance entries instead of original loan params
- Returns existing balance when target date is in the same month
- Generates correct monthly balance when existing entries differ from
theoretical
- All 44 existing loan tests continue to pass
2026-04-04 15:57:29 +01:00
Víctor Falcón 3d5823728a
feat(settings): centralize currency options and split profile/account support (#256)
## Summary

- **Bug fix:** Dashboard and accounts index cards displayed the
account's original currency code (e.g. `BTC`) even though balances were
already converted to the user's currency (e.g. `EUR`). Now passes
`displayCurrencyCode` to card components so the label matches the
converted amount.
- **Feature:** Added a currency toggle on the account detail chart to
switch between the account's native currency and the user's main
currency. Extends the balance evolution APIs with `display_*` fields
when conversion applies.
- **First-account restriction:** Restricts first-account creation to
primary (fiat) currencies only, ensuring the user's base currency is
always widely supported.

## Changes

### Bug fix — currency label on cards
- `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode`
prop; all amount renders now use it instead of `account.currency_code`
- `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as
`displayCurrencyCode`
- `Accounts/Index.tsx`: Passes `auth.user.currency_code` as
`displayCurrencyCode`

### Backend — API extension
- `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and
`accountDailyBalanceEvolution()` now return `display_value`,
`display_invested_amount`, `display_mortgage_balance` per data point and
a top-level `display_currency_code` when the account currency differs
from the user's

### Frontend — currency toggle
- New `ChartCurrencyToggle` component with `ToggleGroup` showing
currency code labels (e.g. `BTC` / `EUR`)
- `ChartSettingsPopover`: Extended with optional `currencyToggle` prop
for mobile
- `AccountBalanceChart`: Full integration — all amounts, trends,
tooltips, MoM chart, and equity swap to `display_*` values when toggle
is set to user currency

### First-account currency restriction
- `StoreAccountRequest`: First account limited to primary currency codes
- `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass
`usePrimaryCurrenciesOnly` when applicable

### Tests
- 5 new Pest tests for `display_*` fields in balance evolution endpoints
- 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty
data, invested amounts)
- 1 new Pest test for first-account BTC rejection
2026-04-02 19:23:10 +02:00
Víctor Falcón 259a9a9712
chore: replace Caddy with Portless for local HTTPS proxy (#258)
## Summary

- Replace Caddy reverse proxy with [Portless](https://portless.sh) for
local HTTPS, eliminating manual cert generation and `/etc/hosts` editing
- Two URL strategies coexist: `composer run dev` uses worktree-aware
URLs (`https://<branch>.dev.whisper.money.localhost`), `whispermoney
start` uses a fixed alias (`https://whisper.money.localhost`)
- Remove Caddyfile, `docker/caddy/` directory, caddy service from
`compose.yaml`, and cert-related `.gitignore` entries
- Simplify `vite.config.ts` by removing caddy cert detection block (Vite
stays on plain HTTP since browsers treat localhost as secure context)
- Overhaul `public/setup.sh` to use `portless trust`, `portless proxy
start`, and `portless alias` instead of SSL cert generation and hosts
file editing
2026-04-02 16:39:44 +01:00
Víctor Falcón 83f7e83a13
fix(cashflow): net transfer categories in sankey (#257)
## Summary
- net transfer categories in the cashflow Sankey on their configured
side instead of showing gross signed flows
- keep the existing mixed-sign behavior for non-transfer categories so
regular income and expense categories can still appear on both sides
- add regression coverage for zero-net inflow transfers and partial
inflow/outflow transfer netting

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter=sankey

## Notes
- running the full `CashflowAnalyticsTest` file in this workspace still
hits two unrelated page tests because `public/build/manifest.json` is
missing locally
2026-04-01 14:48:38 +01:00
Víctor Falcón c42a48a952
chore: Remove account-mapping feature flag (#252)
## Summary

- Removes the `account-mapping` Pennant feature flag entirely, making
the account mapping flow (pending accounts data + map-accounts page) the
default and only code path
- Removes the old direct-creation branches from all banking controllers
(Authorization, Bitpanda, Binance, IndexaCapital)
- Extracts a shared `CreatesAccountsFromPending` trait for the
onboarding auto-create logic
- Updates all controller tests to reflect the always-on mapping behavior

## Changes

### Backend
- **AppServiceProvider** — removed `account-mapping` flag definition
- **AuthorizationController** — removed flag check +
`createAccountsFromSession()` dead code; always stores
`pending_accounts_data` and redirects to mapping
- **BitpandaController / BinanceController / IndexaCapitalController** —
removed flag checks, old inline account creation, and unused imports
- **HandleInertiaRequests / ActivateDevelopmentFeatures /
ResolvesFeatures** — removed `account-mapping` from flag arrays
- **New `CreatesAccountsFromPending` trait** — shared auto-create logic
for onboarding path (to be consumed next)

### Frontend
- Removed `'account-mapping'` from the TypeScript `Features` interface

### Tests
- Merged flag-specific test variants into single always-on tests
- Removed redundant tests that tested the old disabled-flag code path
- Updated assertions to check `pending_accounts_data` instead of direct
account creation
2026-04-01 12:09:22 +02:00
Víctor Falcón ca189a565b
test(budgets): stabilize budget rename browser test (#255)
## Summary
- replace the budget rename test's fixed post-submit wait with stable UI
signals tied to the save lifecycle
- wait for the edit dialog to close, the budget show page to settle, and
the updated budget name to appear in the page content and title
- verify the change with `php artisan test --compact
tests/Browser/BudgetCrudTest.php`
2026-04-01 10:30:49 +01:00
Víctor Falcón f3b5929ecc
fix(banking): retry failed sync connections and log every sync attempt (#251)
## Summary

- **Fixes the broken retry mechanism** — after the first failed attempt
set status to `Error`, subsequent retry attempts bailed out because
`isActive()` returns `false` for `Error`. Now both `Active` and `Error`
statuses are syncable.
- **Adds auto-retry across scheduled runs** —
`SyncAllBankingConnectionsJob` and `banking:sync` command now include
`Error` connections where `consecutive_sync_failures < 3` (configurable
via `MAX_SCHEDULED_RETRIES`). After 3 full dispatch cycles, manual
intervention is required.
- **Logs every sync attempt to DB** — new `banking_sync_logs` table
records status (Success/Failed/Skipped), attempt number, error details,
duration, and metadata for each sync.

## Changes

### Core logic (`SyncBankingConnectionJob`)
- `isSyncableStatus()` allows both `Active` and `Error` through the gate
- Temporary errors: status only set to `Error` on the final attempt
(attempt 3); earlier attempts re-throw without changing status
- Permanent auth errors (401/403): `$this->fail()` called immediately,
`consecutive_sync_failures` set beyond the cap
- Rate limit (429): handled silently (existing behavior preserved)
- Every attempt is logged to `banking_sync_logs`

### Query updates
- `SyncAllBankingConnectionsJob`: includes `Error` connections under
retry cap
- `SyncBankingConnections` command: same query update for
`--user`/`--connection` filtered runs

### Controller updates
- `ConnectionController::sync()` and `updateCredentials()` reset
`consecutive_sync_failures` to 0

### New files
- `BankingSyncLogStatus` enum (Success, Failed, Skipped)
- `BankingSyncLog` model
- Two migrations: `add_consecutive_sync_failures` column,
`create_banking_sync_logs` table

### Tests
- Updated 3 existing auth error tests in `SyncBankingConnectionJobTest`
(24 pass)
- Added 17 new tests in `SyncRetryAndLoggingTest` covering retry
behavior, sync logging, scheduled retry inclusion/exclusion, and manual
retry reset
- All 10 `SyncBankingConnectionsCommandTest` tests still pass
2026-03-31 11:34:35 +01:00
Víctor Falcón 755452d6ce
Fix net worth chart config UX and color scheme issues (#250)
## Summary

- **Clickable toggle rows**: The full row in the chart settings popover
now toggles the checkbox, not just the checkbox itself.
- **Chart stacking order**: When toggling "Include loans" or "Include
real estate", the chart now re-mounts so accounts appear in the correct
sorted position (biggest at bottom) instead of being appended on top.
- **Scheme-aware liability dot**: The red liability indicator dot in the
chart tooltip now respects the user's chart color scheme (neutral, blue,
pink) instead of always using the destructive/red color.
2026-03-31 11:19:35 +01:00
Víctor Falcón 0a535fbf47 fix(i18n): add missing Spanish translations for mortgage UI strings 2026-03-27 09:38:05 +01:00
Víctor Falcón 50a80bf8ee refactor(dashboard): move mortgage subtitle under property name and always show account type icon 2026-03-27 09:09:22 +01:00
Víctor Falcón 752176e80d feat(dashboard): merge real estate accounts with linked mortgages on dashboard
Extend the dashboard to hide linked loan accounts and display merged
real estate cards showing equity as the primary balance, a dual-line
sparkline (solid market value, dashed mortgage owed), 'Mortgage at'
subtitle with bank logo, and a rich tooltip with Market Value,
Mortgage Owed, and color-coded Equity.
2026-03-26 21:04:13 +01:00
Víctor Falcón 6e976354ba feat(accounts): merge real estate accounts with linked mortgages in UI (#248)
Combine real estate and linked loan accounts into a unified card on the
accounts list, showing equity as the primary balance with a dual-line
sparkline (solid for market value, dashed for mortgage owed). Hide linked
loans from the loan group and display mortgage bank info in the subtitle.

On the detail page, render LoanDetailsCard for real estate accounts with
linked loans and add header actions for editing loan details and updating
owed amounts via dialogs.
2026-03-26 20:54:12 +01:00
Víctor Falcón 8b71115afc
fix(accounts): use chart color scheme for real estate sparkline and balance charts (#247)
## Summary

- **Market Value**, **Mortgage Owed**, and **Equity** sparkline cards
and the main real estate balance chart now respect the user's selected
chart color scheme (neutral, blue, pink, colorful) instead of hardcoding
colorful-only colors (amber-500, emerald-500).
- Added `mortgageLineColor` and `equityLineColor` to the
`useChartColors()` hook so all real estate chart components derive
colors from a single source of truth.
- Updated `RealEstateTooltipContent` and the `ComposedChart` in
`AccountBalanceChart` to use the hook colors as well.
2026-03-26 15:22:48 +00:00
Víctor Falcón 6daa0d7345 ci: increase deploy retry delay to exponential backoff 2026-03-26 15:29:00 +01:00
Víctor Falcón bb65bdc16e
feat(accounts): add loan amortization projections for loan accounts (#246)
## Summary

- Adds loan detail tracking (interest rate, term, start date, original
amount) to loan-type accounts, following the existing
`real_estate_details` pattern
- Implements `LoanAmortizationService` with standard amortization math
to automatically project month-to-month balance evolution
- Shows projected future balances as a dashed line on the account
balance chart, visually distinct from historical data
- Adds a scheduled command (`loans:generate-balances`) to auto-generate
monthly balance entries for loan accounts
- Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API
projections, command, and authorization)

## Changes

### Backend
- **Migration**: `loan_details` table (UUID PK, unique `account_id` FK,
interest rate, term, start date, original amount)
- **LoanDetail model** + factory with `Account::loanDetail()` HasOne
relationship
- **LoanAmortizationService**: `calculateMonthlyPayment`,
`calculateRemainingBalance`, `generateProjection`, `projectFromBalance`,
`calculateRemainingMonths`, `getBalanceAtDate`
- **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips
existing entries
- **LoanDetailController**: PATCH endpoint for editing loan details from
show page
- **Settings\AccountController**: creates/updates `LoanDetail` on
store/update
- **DashboardAnalyticsController**: appends projected data points with
`projected: true` flag
- **Validation**: loan-specific rules in StoreAccountRequest and
UpdateAccountRequest

### Frontend
- **AccountForm**: loan fields (rate, term, start date, original amount)
shown when `type === 'loan'`
- **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads
- **Show page**: `LoanDetailsCard` component with edit/read modes,
computed monthly payment and remaining months
- **Balance chart**: new `ComposedChart` branch with dashed `Line`
overlay for projected data points
- **Types**: `LoanDetail` interface in `account.ts`
2026-03-26 15:06:09 +01:00
Víctor Falcón fa11dc78e0
feat(accounts): add market value and annual revaluation to real estate accounts (#245)
## Summary

- Allow setting the **current market value** (balance) when creating a
real estate account — the balance field existed for other account types
but was hidden for real estate
- Add an **annual revaluation percentage** field to real estate
accounts, editable from both the creation form and the property details
card on the account show page
- Create a scheduled command (`real-estate:apply-revaluation`) that runs
monthly on the 1st to automatically adjust property values by
`revaluation_percentage / 12 / 100`

## Details

### Backend
- Migration adds `decimal('revaluation_percentage', 5, 2)` nullable
column to `real_estate_details`
- Validation: nullable numeric, range -100 to +100 (negative for
depreciation)
- `ApplyRealEstateRevaluationCommand` finds all accounts with a
non-null/non-zero percentage, gets the latest balance, and upserts the
new balance for today
- Scheduled `->monthlyOn(1, '00:00')` in `routes/console.php`

### Frontend
- Added `real_estate` to `BALANCE_ACCOUNT_TYPES` so the Market Value
field appears during creation
- Added Annual Revaluation (%) input to both the creation form and the
property details card
- Read-mode displays formatted percentage with sign (e.g.,
"+3.50%/year")

### Tests
- 6 new tests in `RealEstateTest.php` covering creation with balance,
revaluation %, negative %, validation, PATCH update, and clearing to
null
- 8 tests in `ApplyRealEstateRevaluationTest.php` covering
positive/negative revaluation, skip conditions, latest balance usage,
multiple accounts, and upsert behavior
2026-03-26 11:02:20 +01:00
Víctor Falcón 1880333b1c
chore: upgrade Laravel 12 to 13 (#242)
## Summary

- Upgrade `laravel/framework` from v12 to **v13.1.1** and update all 52
dependencies to their latest versions
- Bump `laravel/tinker` from v2 to **v3.0** (required for Laravel 13
compatibility)
- Address Laravel 13 breaking change: add `serializable_classes =>
false` to `config/cache.php`
- Fix cached `Collection` in `routes/web.php` — converted to plain array
via `->toArray()` for serialization safety

## Changes

| File | What changed |
|------|-------------|
| `composer.json` | Bumped `php ^8.3`, `laravel/framework ^13.0`,
`laravel/tinker ^3.0` |
| `composer.lock` | 52 packages updated, 1 removed
(`symfony/polyfill-php83`) |
| `config/cache.php` | Added `serializable_classes => false` |
| `routes/web.php` | Cached query result uses `->toArray()`, fallback
changed from `collect()` to `[]` |

## Testing

Full test suite passing: **919 tests, 3792 assertions, 0 failures**
2026-03-25 12:56:33 +00:00
Víctor Falcón 973243277a
feat(accounts): show mortgage data and equity on real estate account page (#243)
## Summary

- Add mortgage balance as a dashed line overlay on the property value
chart when a real estate account has a linked loan
- Display equity (market value - mortgage owed) in the chart header and
as a 3-column summary strip (Market Value | Mortgage Owed | Equity) in
the property details card
- Extend balance evolution API endpoints to include `mortgage_balance`
data points for linked loan accounts
- Add 6 new tests covering mortgage/equity display in account show page
and balance evolution endpoints
2026-03-24 15:56:23 +00:00
Víctor Falcón 8ac6ed4d83
fix: batch Pennant feature flag queries to avoid N+1 selects (#244)
## Summary

- Batch-load all Pennant feature flags via `Feature::load()` in
`HandleInertiaRequests`, reducing 3 individual SELECT+INSERT pairs (6
queries) to 1 batched SELECT + 1 bulk INSERT (2 queries)
- Batch `activate()` calls in `ActivateDevelopmentFeatures` middleware
using array syntax for a single UPSERT instead of 3

## Context

PR #241 (real estate feature) added a new Pennant feature flag check to
the shared Inertia data, pushing the top categories API endpoint from 12
to 13 queries and breaking the performance test threshold.

## Query impact

| Scenario | Before | After |
|---|---|---|
| Feature flag queries per request | 6 (3 SELECT + 3 INSERT) | 2 (1
SELECT + 1 INSERT) |
| Top categories API total | 13 | 9 |
| Future-proof | +2 queries per new flag | Constant regardless of flag
count |
2026-03-24 15:47:40 +00:00
Víctor Falcón 395c4ad2c3
feat(accounts): add real estate asset tracking (#241)
## Summary

- Adds **real estate** as a new account type (`real_estate`) for
tracking property assets within the existing Accounts page
- Properties store metadata (type, address, purchase price/date, area,
notes) in a dedicated `real_estate_details` table with a one-to-one
relationship to accounts
- Properties can be **linked to a loan account** (mortgage) to
implicitly calculate equity — property value counts as asset (+), linked
loan counts as liability (-)
- Market value is tracked as the account balance and uses "market value"
terminology throughout the UI

## What's included

### Backend
- `PropertyType` enum (Residential, Commercial, Land, Vacation, Other)
- `RealEstateDetail` model, factory, migration, policy
- `StoreRealEstateDetailRequest` and `UpdateRealEstateDetailRequest`
form requests
- `RealEstateDetailController` with `update()` for editing property
details
- Extended `Settings\AccountController@store` to create
`RealEstateDetail` when type is `real_estate`
- Extended `AccountController@show` to load real estate detail, linked
loan with bank info, and available loan accounts
- Extended `AccountController@index` SQL ordering to include
`real_estate`
- Conditional validation rules in `StoreAccountRequest` for real estate
fields

### Frontend
- New `real_estate` type in `account.ts` with `PropertyType`,
`AreaUnit`, `RealEstateDetail` interface, and helper functions
- Conditional real estate fields in `account-form.tsx` (property type,
address, purchase price, purchase date, area, linked loan, notes)
- `PropertyDetailsCard` component in `Accounts/Show.tsx` with view and
inline edit modes
- "Update market value" terminology in balance update buttons for real
estate accounts
- `real_estate` added to account type ordering and groups on the Index
page
- Wayfinder routes regenerated

### Tests
- 22 feature tests covering creation, validation, show page, index
ordering, updating details, IDOR protection, model relationships, and
soft delete behavior
- Unit test updates for `AccountType` enum (`reducesNetWorth`,
`isNonTransactional`)

### Translations
- 32 new Spanish translation strings for all real estate UI

## Design decisions
- Real estate accounts are **non-transactional** (like
investment/retirement) — balance-only tracking for now. Future iteration
will link transactions directly for rental income/expense tracking
- **Single loan per property** via direct FK from
`real_estate_details.linked_loan_account_id`
- No encryption on address/notes fields (opted out per discussion)
- No separate sidebar page — real estate is a grouped section within the
existing Accounts page
2026-03-24 10:21:32 +00:00
Víctor Falcón ff62532a55
chore: rework neutral light scheme colors (#240)
## Summary

- Rework the neutral (default) chart color scheme for light mode to
improve contrast distribution and visual variety
- Adjusts `--chart-1` through `--chart-9` zinc shade assignments for
better differentiation between chart segments
2026-03-20 11:17:55 +00:00
Víctor Falcón 7a056213cf
feat(accounts): allow setting initial balance when creating balance-tracking accounts (#239)
## Summary

- Adds an optional balance input field to the account creation modal for
**investment**, **loan**, **retirement**, and **savings** account types.
- When a balance is provided, an `AccountBalance` record is created for
today's date upon account creation.
- The balance label adapts to the account type (e.g., "Owed Amount" for
loans, "Balance" for others).

## Changes

- **`account-form.tsx`** — Shows `AmountInput` when a balance-tracking
type is selected and currency is set.
- **`create-account-dialog.tsx`** — Passes `balance` in the POST payload
when present.
- **`StoreAccountRequest.php`** — Adds optional `balance` (nullable
integer) validation.
- **`AccountController::store`** — Creates an `AccountBalance` record
when balance is provided.
- **`AccountTest.php`** — 3 new tests covering creation with balance,
without balance, and invalid balance validation.

## Screenshots
<img width="1017" height="649" alt="image"
src="https://github.com/user-attachments/assets/4bf1530e-0faf-45a4-a3d1-d0ec49d5b412"
/>
2026-03-20 10:51:42 +00:00
Víctor Falcón f140b5df7f
fix(dashboard): treat loans as debt in net worth (#238)
## Summary
- fix dashboard net worth math so loan balances reduce totals and trends
instead of showing as positive assets
- add a per-user toggle in the net worth chart settings to include or
exclude loans from the dashboard net worth card
- cover the new preference and liability handling with feature and
frontend tests

## Screenshots
<img width="1121" height="509" alt="image"
src="https://github.com/user-attachments/assets/ab6a7cde-1052-4dab-aa14-34f6d9528829"
/>
<img width="1122" height="506" alt="image"
src="https://github.com/user-attachments/assets/e48a0369-16c4-4294-ae00-4eba56480264"
/>
2026-03-20 09:55:53 +00:00
Víctor Falcón 6dda5f56ad
feat(cashflow): show tracked transfers in Sankey diagram (#237)
## Summary

Transfer categories with `cashflow_direction` set to `outflow` or
`inflow` now appear in the **Sankey diagram** instead of the 12-month
trend chart. This gives a clearer picture of money flow (e.g.
investments, savings transfers) without inflating the income/expense
trend lines. Hidden transfers remain excluded from everything.

## Changes

- **Sankey endpoint**: Updated join to include tracked transfer
categories on the matching flow side (outflow → expense side, inflow →
income side)
- **Trend endpoint**: Removed `transfer_inflow` and `transfer_outflow`
columns from the SQL query and API response
- **Trend chart UI**: Removed tracked inflow/outflow bars, tooltip rows,
legend items, and related config
- **TypeScript types**: Removed `transfer_inflow`/`transfer_outflow`
from `TrendDataPoint`
- **Category form**: Updated direction field description to reference
"Sankey chart" instead of "monthly trend"
- **Tests**: Updated 4 existing tests and added 2 new tests covering
tracked transfers in Sankey breakdown

## Design decision

Tracked transfers appear **only in the Sankey diagram** — they do not
affect summary totals, savings rate, or breakdown cards. This keeps the
high-level numbers accurate while still visualizing where money flows
internally.
2026-03-19 11:49:54 +01:00
Víctor Falcón 272dac14b8
feat(cashflow): track transfer categories in trends (#236)
## Summary
- add a transfer-only cashflow reporting setting so categories like
investments can appear in the monthly cashflow trend without counting as
income or expense
- extend the cashflow trend API and chart to show tracked transfer
inflows and outflows while keeping summary cards and sankey accounting
unchanged
- expose the new setting in category management and add feature coverage
for tracked transfer analytics and category persistence

## Screenshots
<img width="808" height="164" alt="image"
src="https://github.com/user-attachments/assets/336d27d4-eacb-4a40-ba60-ecee84d65340"
/>
<img width="1098" height="474" alt="image"
src="https://github.com/user-attachments/assets/ca98883a-525d-4f71-aa2f-e8d6083e4564"
/>
<img width="1114" height="713" alt="image"
src="https://github.com/user-attachments/assets/353461b6-e5c0-42cb-a6ed-87629d7b4575"
/>
2026-03-18 14:02:47 +00:00
Víctor Falcón debb47f6af
fix(cashflow): exclude transfer categories from sankey (#235)
## Summary
- exclude transfer-type categories from the cashflow Sankey category
breakdown
- add a regression test to ensure transfer transactions never appear on
either side of the chart
- keep cashflow totals limited to real income and expense categories for
the Sankey response
2026-03-18 11:09:45 +00:00
Víctor Falcón b36197e76b fix(ci): skip outdated production deploys 2026-03-17 12:19:26 +01:00
Víctor Falcón c53106289d chore: release v0.1.19 2026-03-17 12:02:31 +01:00
Víctor Falcón ec245655b8
feat(cashflow): make income/expense category rows clickable to transactions (#234)
## Summary

- Makes Income Sources and Expense Categories rows on the Cashflow page
clickable, navigating to the Transactions page pre-filtered by category
and date range
- Follows the exact same pattern as the dashboard's Top Spending
Categories card (`top-categories-card.tsx`)
- Fixes progress bar hover visibility by adding the `group` Tailwind
class to the link wrapper

## Changes

- **`resources/js/components/cashflow/breakdown-card.tsx`**: Added
optional `period` prop; each category row renders as an Inertia `<Link>`
with `category_ids`, `date_from`, and `date_to` query params when period
is provided; falls back to plain `div` otherwise
- **`resources/js/pages/cashflow/index.tsx`**: Passes `period` to both
`BreakdownCard` instances (income and expense)
- **`tests/Browser/CashflowCategoryNavigationTest.php`**: Browser tests
verifying clicking a category navigates to `/transactions` with the
correct query params
2026-03-17 10:57:40 +00:00
Víctor Falcón cd40bc75d9
fix(ci): allow deploy retry loop to survive curl timeout (#233)
## Summary

- Adds `|| true` after the `curl` command so `bash -e` does not kill the
script on curl exit code 28 (timeout) before the retry loop can continue
- Guards the HTTP status integer comparison against an empty value,
which occurs when curl times out and produces no output
2026-03-17 09:57:38 +00:00
Víctor Falcón 9e2a9cadfe
fix(cashflow): only count sign-matching transactions in Sankey category breakdown (#232)
## Problem

When an income category contained both incoming and outgoing
transactions (e.g. an \"Income from Rents\" category that also holds
property-related expense payments), the Sankey endpoint was summing
**all** amounts together and applying `abs()` to the net result.

Real example reported:

| Date | Description | Amount |
|------|-------------|--------|
| Mar 13 | BIZUM sent — Lavadora | -€124.03 |
| Mar 10 | BIZUM received — luz febrero | +€38.04 |
| Mar 9 | Endesa energy payment | -€38.04 |
| Mar 4 | BIZUM received — agua Febrero | +€41.34 |
| Mar 2 | Comunidad de Propietarios | -€105.92 |

- Net: **-€188.61** → `abs()` → **€188.61 shown as income** 
- Correct: sum of positive flows only → **€79.38** 

## Fix

Added a sign filter to `getCategoryBreakdown()` before the `GROUP BY`
aggregation:
- Income categories: `WHERE transactions.amount > 0`
- Expense categories: `WHERE transactions.amount < 0`

This ensures the Sankey shows the **actual gross flow** on each side
rather than a misleading abs-of-net.

## Test

Added a regression test in `CashflowAnalyticsTest` that reproduces the
exact five transactions from the reported scenario and asserts the
Sankey returns `7938` cents (€79.38) instead of the buggy `18861`
(€188.61).
2026-03-17 09:29:36 +00:00
Víctor Falcón 6525c31fff
chore: remove unused LEAD_REDIRECT_URL env var (#231)
## Summary

- Removes `LEAD_REDIRECT_URL` from `.env.example` and
`config/landing.php` as it was never referenced anywhere in the
codebase.
2026-03-16 13:38:02 +00:00
Víctor Falcón e5fcaee8f8
fix(settings): restore budgets settings redirect (#228)
## Summary
- restore the legacy `/settings/budgets` route as a redirect to
`/budgets` so old settings links no longer trigger a missing Inertia
page
- add a feature test covering the redirect to prevent the broken route
from resurfacing

## Testing
- php artisan test --compact
tests/Feature/Settings/BudgetSettingsRedirectTest.php
2026-03-16 13:04:08 +01:00
Víctor Falcón a60fd6f452
fix: prevent account label combobox crash (#230)
## Summary
- guard the shared label combobox against missing `value` and `labels`
props so account transaction tools cannot crash while data is still
loading
- wire account transaction bulk label actions to pass label data through
consistently and keep optimistic updates in sync
- add regression coverage for the combobox normalization helper and the
account show payload shared props
2026-03-16 13:03:11 +01:00
Víctor Falcón f600524c2b
fix(haptics): use a local WebHaptics wrapper (#225)
## Summary
- replace direct `web-haptics/react` imports with a local hook that
manages the `WebHaptics` instance safely in-app
- keep existing haptic triggers working across the sidebar, mobile back
button, connect account flow, and transaction categorizer
- add focused Vitest coverage for the wrapper lifecycle and trigger
proxying

## Testing
- npm run test -- resources/js/hooks/use-web-haptics.test.tsx
- npm run lint -- resources/js/hooks/use-web-haptics.ts
resources/js/hooks/use-web-haptics.test.tsx
resources/js/components/app-sidebar.tsx
resources/js/components/nav-main.tsx
resources/js/components/mobile-back-button.tsx
resources/js/components/open-banking/connect-account-inline.tsx
resources/js/pages/transactions/categorize.tsx
- npm run build
2026-03-16 11:26:04 +00:00
Víctor Falcón 5b9ae2a525
fix(banking): treat 429 rate limit as transient, skip error status on sync (#224)
## Summary

- A `429 ASPSP_RATE_LIMIT_EXCEEDED` response from the bank's API was
incorrectly marking connections as `status=error`, blocking all future
syncs.
- Rate limit errors are transient — the connection is still valid and
should be retried on the next scheduled sync.
- Added `isRateLimitError()` check in the `catch` block of
`SyncBankingConnectionJob`: on 429, the job returns early without
updating the connection status or error message.
2026-03-16 10:59:46 +00:00
1115 changed files with 102342 additions and 13342 deletions

View File

@ -0,0 +1,436 @@
---
name: ai-sdk-development
description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly.
license: MIT
metadata:
author: laravel
---
# Developing with the Laravel AI SDK
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers.
## Searching the Documentation
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth.
- Use broad, simple queries that match the documentation section headings below.
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`.
- Run multiple queries at once — the most relevant results are returned first.
### Documentation Sections
Use these section headings as query terms for accurate results:
- Introduction, Installation, Configuration, Provider Support
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration
- Images
- Audio (TTS)
- Transcription (STT)
- Embeddings: Querying Embeddings, Caching Embeddings
- Reranking
- Files
- Vector Stores: Adding Files to Stores
- Failover
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores
- Events
## Decision Workflow
Determine the right entry point before writing code:
Text generation or chat? → Agent class with `Promptable` trait
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic)
Structured JSON output? → Agent + `HasStructuredOutput` interface
Image generation? → `Image::of()->generate()`
Audio synthesis? → `Audio::of()->generate()`
Transcription? → `Transcription::fromPath()->generate()`
Embeddings? → `Embeddings::for()->generate()`
Reranking? → `Reranking::of()->rerank()`
File storage? → `Document::fromPath()->put()`
Vector stores? → `Stores::create()`
## Basic Usage Examples
### Agents
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent
{
use Promptable;
public function instructions(): string
{
return 'You are a sales coach.';
}
}
// Prompting
$response = (new SalesCoach)->prompt('Analyze this transcript...');
echo $response->text;
// Container resolution with dependency injection
$agent = SalesCoach::make(user: $user);
// Override provider, model, or timeout per-prompt
$response = (new SalesCoach)->prompt(
'Analyze this transcript...',
provider: Lab::Anthropic,
model: 'claude-haiku-4-5-20251001',
timeout: 120,
);
// Streaming (returns SSE response from a route)
return (new SalesCoach)->stream('Analyze this transcript...');
// Queueing
(new SalesCoach)->queue('Analyze this transcript...')
->then(fn ($response) => /* ... */);
// Anonymous agents
use function Laravel\Ai\{agent};
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello');
```
### Conversation Context
Manual conversation history via the `Conversational` interface:
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable;
public function __construct(public User $user) {}
public function instructions(): string { return 'You are a sales coach.'; }
public function messages(): iterable
{
return History::where('user_id', $this->user->id)
->latest()->limit(50)->get()->reverse()
->map(fn ($m) => new Message($m->role, $m->content))
->all();
}
}
```
Automatic conversation persistence via the `RemembersConversations` trait:
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable, RemembersConversations;
public function instructions(): string { return 'You are a sales coach.'; }
}
// Start a new conversation
$response = (new SalesCoach)->forUser($user)->prompt('Hello!');
$conversationId = $response->conversationId;
// Continue an existing conversation
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.');
```
### Structured Output
```php
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class Reviewer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string { return 'Review and score content.'; }
public function schema(JsonSchema $schema): array
{
return [
'feedback' => $schema->string()->required(),
'score' => $schema->integer()->min(1)->max(10)->required(),
];
}
}
$response = (new Reviewer)->prompt('Review this...');
echo $response['score']; // Access like an array
```
### Images
```php
use Laravel\Ai\Image;
$image = Image::of('A sunset over mountains')
->landscape()
->quality('high')
->generate();
$path = $image->store(); // Store to default disk
```
### Audio
```php
use Laravel\Ai\Audio;
$audio = Audio::of('Hello from Laravel.')
->female()
->instructions('Speak warmly')
->generate();
$path = $audio->store();
```
### Transcription
```php
use Laravel\Ai\Transcription;
$transcript = Transcription::fromStorage('audio.mp3')
->diarize()
->generate();
echo (string) $transcript;
```
### Embeddings
```php
use Laravel\Ai\Embeddings;
use Illuminate\Support\Str;
$response = Embeddings::for(['Text one', 'Text two'])
->dimensions(1536)
->cache()
->generate();
// Single string via Stringable
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings();
```
### Reranking
```php
use Laravel\Ai\Reranking;
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.'])
->limit(5)
->rerank('PHP frameworks');
$response->first()->document; // "Laravel is PHP."
```
### Files and Vector Stores
```php
use Laravel\Ai\Files\Document;
use Laravel\Ai\Stores;
// Store a file with the provider
$file = Document::fromPath('/path/to/doc.pdf')->put();
// Create a vector store and add files
$store = Stores::create('Knowledge Base');
$store->add($file->id);
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step
```
## Agent Configuration
### PHP Attributes
```php
use Laravel\Ai\Attributes\{Provider, Model, MaxSteps, MaxTokens, Temperature, Timeout};
use Laravel\Ai\Enums\Lab;
#[Provider(Lab::Anthropic)]
#[Model('claude-haiku-4-5-20251001')]
#[MaxSteps(10)]
#[MaxTokens(4096)]
#[Temperature(0.7)]
#[Timeout(120)]
class MyAgent implements Agent
{
use Promptable;
// ...
}
```
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection.
### Tools
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`:
```php
use Laravel\Ai\Contracts\HasTools;
class MyAgent implements Agent, HasTools
{
use Promptable;
public function tools(): iterable
{
return [new MyCustomTool];
}
}
```
### Provider Tools
```php
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch};
public function tools(): iterable
{
return [
(new WebSearch)->max(5)->allow(['laravel.com']),
new WebFetch,
new FileSearch(stores: ['store_id']),
];
}
```
### Conversation Memory
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Conversational;
class ChatBot implements Agent, Conversational
{
use Promptable, RemembersConversations;
// ...
}
$response = (new ChatBot)->forUser($user)->prompt('Hello!');
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...');
```
### Failover
```php
$response = (new MyAgent)->prompt('Hello', provider: [Lab::OpenAI, Lab::Anthropic]);
```
## Testing and Faking
Each capability supports `fake()` with assertions:
```php
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores};
// Agents
SalesCoach::fake(['Response 1', 'Response 2']);
SalesCoach::assertPrompted('query');
SalesCoach::assertNotPrompted('query');
SalesCoach::assertNeverPrompted();
SalesCoach::fake()->preventStrayPrompts();
// Images
Image::fake();
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset'));
Image::assertNothingGenerated();
// Audio
Audio::fake();
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello'));
// Transcription
Transcription::fake(['Transcribed text.']);
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized());
// Embeddings
Embeddings::fake();
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel'));
// Reranking
Reranking::fake();
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP'));
// Files
Files::fake();
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain');
// Stores
Stores::fake();
Stores::assertCreated('Knowledge Base');
$store = Stores::get('id');
$store->assertAdded('file_id');
```
## Key Patterns
- Namespace: `Laravel\Ai\`
- Package: `composer require laravel/ai`
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational`
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores`
- Provider enum: `Laravel\Ai\Enums\Lab` (prefer over plain strings)
- Artisan commands: `php artisan make:agent`, `php artisan make:tool`
- Global helper: `agent()` for anonymous agents
## Common Pitfalls
### Wrong Namespace
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`.
```php
// Correct
use Laravel\Ai\Image;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
// Wrong — these do not exist
use Illuminate\Ai\Image;
use Laravel\AI\Agent;
```
### Unsupported Provider Capability
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below.
## Provider Support
| Feature | Providers |
| ---------- | --------------------------------------------------------------- |
| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama |
| Images | OpenAI, Gemini, xAI |
| TTS | OpenAI, ElevenLabs |
| STT | OpenAI, ElevenLabs, Mistral |
| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI |
| Reranking | Cohere, Jina |
| Files | OpenAI, Anthropic, Gemini |
Use the `Laravel\Ai\Enums\Lab` enum to reference providers in code instead of plain strings:
```php
use Laravel\Ai\Enums\Lab;
Lab::Anthropic;
Lab::OpenAI;
Lab::Gemini;
// ...
```

View File

@ -0,0 +1,108 @@
---
name: cashier-stripe-development
description: "Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues."
license: MIT
metadata:
author: laravel
---
# Cashier Stripe Development
## When to Apply
Activate this skill when:
- Installing or configuring Laravel Cashier Stripe
- Setting up subscriptions, trials, quantities, or plan swapping
- Handling webhooks or SCA/3DS payment failures
- Working with Stripe Checkout, invoices, or charges
- Testing billing scenarios with Stripe test cards or tokens
## Documentation
Use `search-docs` for detailed Cashier patterns and documentation covering subscriptions, webhooks, Stripe Checkout, invoices, payment methods, and testing.
For deeper guidance on specific topics, read the relevant reference file before implementing:
- `references/subscriptions.md` covers subscription creation, status checks, swapping, trials, quantities, and multiple products
- `references/webhooks.md` covers webhook setup, custom handlers, CSRF exclusion, and local development with the Stripe CLI
- `references/testing.md` covers Stripe test cards, payment method tokens, and feature test patterns
## Basic Usage
### Installation
```bash
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
php artisan vendor:publish --tag="cashier-config"
```
### Environment Variables
```
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en_US
```
### Billable Model
<!-- Add Billable Trait -->
```php
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
```
For a non-User model, register it in a service provider:
<!-- Custom Billable Model -->
```php
// In AppServiceProvider::boot()
Cashier::useCustomerModel(Team::class);
```
### Creating a Subscription
<!-- Create Subscription -->
```php
use Laravel\Cashier\Exceptions\IncompletePayment;
try {
$user->newSubscription('default', 'price_xxxx')->create($paymentMethodId);
} catch (IncompletePayment $e) {
return redirect()->route('cashier.payment', [$e->payment->id, 'redirect' => route('home')]);
}
```
Always wrap subscription creation in a try/catch for `IncompletePayment`. When a card requires 3DS authentication, Cashier throws this exception. The `cashier.payment` route is auto-registered and handles the confirmation flow.
## Verification
1. Run migrations and confirm `stripe_id`, `pm_type`, `pm_last_four`, and `trial_ends_at` columns exist on the billable model table
2. Test the webhook endpoint with `stripe listen --forward-to localhost/stripe/webhook` if you use the default path, or swap `stripe` for your configured `CASHIER_PATH`
3. Confirm `$user->subscribed('default')` returns the expected value for active and incomplete subscriptions
## Common Pitfalls
- The migration publish tag is `cashier-migrations`, not `cashier`. Running `migrate` before publishing results in missing columns and tables.
- `CASHIER_CURRENCY` must be set explicitly. It defaults to USD, which silently breaks non-US apps.
- The Stripe CLI generates its own webhook signing secret. It is different from the Dashboard endpoint secret. Using the wrong one causes signature verification failures.
- The webhook route must be excluded from CSRF verification using your configured `cashier.path`. If you change `CASHIER_PATH` from `stripe` to `billing`, exclude `billing/*`, not `stripe/*`.
- `canceled()` returns true as soon as `cancel()` is called, but the user still has access during the grace period. Use `ended()` to confirm access is fully revoked.
- `subscribed()` returns true during the grace period even though the subscription is canceled.
- `subscribed()` returns false for `incomplete` and `past_due` subscriptions by default.
- Prices cannot be swapped and quantity cannot be updated while a subscription has an incomplete payment.
- When extending `WebhookController`, call `Cashier::ignoreRoutes()` in a service provider and re-register both `cashier.payment` and `cashier.webhook` under the configured `cashier.path`.
- Use `Cashier::useCustomerModel()` in a service provider to set a custom billable model. There is no `CASHIER_MODEL` env var.
- `trial_ends_at` is a local database column synced via webhooks. It will be stale if webhooks are not configured in production.
- In MySQL, the `stripe_id` column must use `utf8_bin` collation to avoid case-sensitivity issues.
- `noProrate()` has no effect when combined with `swapAndInvoice()`. That method always prorates.
- Methods like `withPromotionCode()` require the Stripe API ID such as `promo_xxxx`, not the customer-facing code. Use `findPromotionCode()` to resolve a code to its ID.
- Always use `search-docs` for the latest Cashier documentation rather than relying on this skill alone.

View File

@ -0,0 +1,108 @@
# Subscriptions Reference
Use `search-docs` for authoritative documentation on subscriptions.
## Status Checks
| Method | Returns true when |
|---|---|
| `$user->subscribed('default')` | Active or on grace period |
| `->onTrial()` | Trial period active |
| `->onGracePeriod()` | Canceled, period not yet ended |
| `->canceled()` | `ends_at` is set, may still have access |
| `->ended()` | Canceled and grace period expired |
| `->incomplete()` | Awaiting SCA/3DS confirmation |
| `->pastDue()` | Payment overdue |
| `->recurring()` | Active and not on trial |
Check by product or price:
```php
$user->subscribedToProduct('prod_premium', 'default');
$user->subscribedToPrice('price_monthly', 'default');
```
## Swapping Plans
```php
$user->subscription('default')->swap('price_new');
$user->subscription('default')->noProrate()->swap('price_new');
$user->subscription('default')->swapAndInvoice('price_new');
$user->subscription('default')->skipTrial()->swap('price_new');
```
## Quantity
```php
$user->subscription('default')->incrementQuantity();
$user->subscription('default')->decrementQuantity();
$user->subscription('default')->updateQuantity(10);
$user->subscription('default')->noProrate()->updateQuantity(10);
```
## Trials
```php
$user->newSubscription('default', 'price_xxxx')
->trialDays(14)
->create($paymentMethodId);
$subscription->extendTrial(now()->addDays(7));
```
## Multiple Products on One Subscription
```php
$user->newSubscription('default', ['price_monthly', 'price_chat'])
->quantity(5, 'price_chat')
->create($paymentMethod);
$user->subscription('default')->addPrice('price_chat');
$user->subscription('default')->removePrice('price_chat');
$user->subscription('default')->swap(['price_pro', 'price_chat']);
```
## Multiple Subscriptions
```php
$user->newSubscription('swimming', 'price_swimming_monthly')->create($pm);
$user->newSubscription('gym', 'price_gym_monthly')->create($pm);
$user->subscription('swimming')->swap('price_swimming_yearly');
$user->subscription('gym')->cancel();
```
## Cancellation and Resumption
```php
$user->subscription('default')->cancel(); // At end of billing period
$user->subscription('default')->cancelNow(); // Immediately
$user->subscription('default')->resume(); // During grace period only
```
## Incomplete Payment Handling
```php
if ($user->hasIncompletePayment('default')) {
$paymentId = $user->subscription('default')->latestPayment()->id;
return redirect()->route('cashier.payment', $paymentId);
}
```
Opt out of default deactivation behavior:
```php
Cashier::keepPastDueSubscriptionsActive();
Cashier::keepIncompleteSubscriptionsActive();
```
## Metered / Usage-Based Billing
```php
$user->newSubscription('default')
->meteredPrice('price_metered')
->create($paymentMethodId);
$user->reportMeterEvent('emails-sent');
$user->reportMeterEvent('emails-sent', quantity: 15);
```

View File

@ -0,0 +1,52 @@
# Testing Reference
Use `search-docs` for authoritative documentation on testing Cashier integrations.
## Test Cards and Tokens
Use card numbers for browser-based flows (Stripe.js / Checkout). Use `pm_card_*` tokens directly in feature tests that call the Stripe API.
| Card Number | Token | Behavior |
|---|---|---|
| `4242 4242 4242 4242` | `pm_card_visa` | Succeeds immediately |
| `4000 0025 0000 3155` | `pm_card_threeDSecure2Required` | Requires SCA/3DS |
| `4000 0027 6000 3184` | `pm_card_authenticationRequired` | Requires authentication |
| `4000 0000 0000 9995` | `pm_card_chargeDeclinedInsufficientFunds` | Declined, insufficient funds |
| `4000 0000 0000 0002` | `pm_card_chargeDeclined` | Declined |
Use expiry `12/34`, any CVC, any ZIP for card number inputs.
## Feature Test Example
Feature tests that hit the real Stripe test API use `pm_card_*` tokens:
```php
public function test_user_can_subscribe(): void
{
$user = User::factory()->create();
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_visa');
$this->assertTrue($user->subscribed('default'));
}
public function test_incomplete_payment_is_handled(): void
{
$user = User::factory()->create();
try {
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_threeDSecure2Required');
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
$this->assertTrue($user->subscription('default')->incomplete());
}
}
```
## Setup Notes
- Use Stripe test mode keys (`sk_test_...`, `pk_test_...`) in your test environment
- Cashier does not ship a global `fake()` helper. Tests hit the real Stripe test API by default.
- Refer to `tests/Feature/` in the Cashier package itself for integration test patterns covering subscription creation, payment methods, and webhook handling
- Use `search-docs` for current guidance on mocking Stripe HTTP calls or using Stripe's test clock feature for time-sensitive scenarios

View File

@ -0,0 +1,132 @@
# Webhooks Reference
Use `search-docs` for authoritative documentation on webhooks.
## Auto-Registered Routes
Cashier registers two routes automatically under the `cashier.path` prefix (`config('cashier.path')`, default `stripe`):
- `POST /{cashier.path}/webhook` named `cashier.webhook`
- `GET /{cashier.path}/payment/{id}` named `cashier.payment`
With the default config these are `/stripe/webhook` and `/stripe/payment/{id}`. If you set `CASHIER_PATH=billing`, they become `/billing/webhook` and `/billing/payment/{id}`.
## CSRF Exclusion
Use the same path prefix you configured for Cashier here. If `CASHIER_PATH=billing`, exclude `billing/*` instead of `stripe/*`.
**Laravel 11+ (`bootstrap/app.php`, default path example):**
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/*']);
})
```
**Laravel 10 (`app/Http/Middleware/VerifyCsrfToken.php`, default path example):**
```php
protected $except = [
'stripe/*',
];
```
## Local Development with Stripe CLI
If you changed `cashier.path`, forward Stripe CLI events to that URL instead of `/stripe/webhook`.
```bash
stripe login
stripe listen --forward-to your-app.test/stripe/webhook
stripe trigger invoice.payment_succeeded
```
The CLI outputs a `whsec_...` signing secret specific to that session. Set it as `STRIPE_WEBHOOK_SECRET` locally. It is not the same as the Dashboard endpoint secret.
## Registering Events in the Stripe Dashboard
Use the Artisan command to create the endpoint automatically with all required events:
```bash
php artisan cashier:webhook
```
Cashier's `cashier:webhook` command registers these events by default:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.updated` / `customer.deleted`
- `invoice.payment_action_required`
- `invoice.payment_succeeded`
- `payment_method.automatically_updated`
Cashier's `WebhookController` has built-in handlers for all of the above except `invoice.payment_succeeded`. For renewal hooks, prefer `WebhookReceived` / `WebhookHandled` listeners unless you intentionally add your own controller method.
## Custom Handlers: Extending WebhookController
Method name pattern: `handle` + StudlyCase of event type with dots replaced by underscores.
`customer.subscription.created` becomes `handleCustomerSubscriptionCreated`.
```php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class StripeWebhookController extends CashierController
{
public function handleCustomerSubscriptionCreated(array $payload)
{
$response = parent::handleCustomerSubscriptionCreated($payload);
// your logic after Cashier syncs the subscription
return $response;
}
}
```
If you add a method for an event Cashier does not handle internally, such as `invoice.payment_succeeded`, do not call `parent::handle...()` unless the base controller actually defines that method.
In a service provider, disable auto-registration and re-register both Cashier routes so the incomplete-payment flow and `cashier:webhook` command keep working:
```php
Cashier::ignoreRoutes();
```
```php
// routes/web.php
use App\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route;
use Laravel\Cashier\Http\Controllers\PaymentController;
Route::prefix(config('cashier.path'))
->name('cashier.')
->group(function () {
Route::get('payment/{id}', [PaymentController::class, 'show'])->name('payment');
Route::post('webhook', [StripeWebhookController::class, 'handleWebhook'])->name('webhook');
});
```
Keep the `cashier.webhook` route name unless you plan to pass `--url` explicitly to `php artisan cashier:webhook`.
## Custom Handlers: Listening to Events
The simpler option when you do not need to replace Cashier's internal logic, or when you want to react to events such as `invoice.payment_succeeded` that Cashier does not process itself:
```php
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Cashier\Events\WebhookHandled;
// WebhookReceived fires for every event before Cashier processes it
// WebhookHandled fires after Cashier processes it
Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
if ($event->payload['type'] === 'invoice.payment_succeeded') {
// handle renewal
}
});
```
## Signature Verification
`VerifyWebhookSignature` middleware is applied automatically when `cashier.webhook.secret` is set. No extra wiring is needed.

View File

@ -1,5 +1,5 @@
---
name: developing-with-fortify
name: fortify-development
description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
---

View File

@ -15,7 +15,7 @@ Activate this skill when:
- Creating or modifying React page components for Inertia
- Working with forms in React (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, or polling
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Building React-specific features with the Inertia protocol
## Documentation
@ -295,14 +295,21 @@ export default function UsersIndex({ users }) {
### Polling
Use the `usePoll` hook to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
Automatically refresh data at intervals:
<!-- Basic Polling -->
<!-- Polling Example -->
```react
import { usePoll } from '@inertiajs/react'
import { router } from '@inertiajs/react'
import { useEffect } from 'react'
export default function Dashboard({ stats }) {
usePoll(5000)
useEffect(() => {
const interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [])
return (
<div>
@ -313,65 +320,38 @@ export default function Dashboard({ stats }) {
}
```
<!-- Polling With Request Options and Manual Control -->
```react
import { usePoll } from '@inertiajs/react'
### WhenVisible
export default function Dashboard({ stats }) {
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
return (
<div>
<h1>Dashboard</h1>
<div>Active Users: {stats.activeUsers}</div>
<button onClick={start}>Start Polling</button>
<button onClick={stop}>Stop Polling</button>
</div>
)
}
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<!-- Infinite Scroll with WhenVisible -->
<!-- WhenVisible Example -->
```react
import { WhenVisible } from '@inertiajs/react'
export default function UsersList({ users }) {
export default function Dashboard({ stats }) {
return (
<div>
{users.data.map(user => (
<div key={user.id}>{user.name}</div>
))}
<h1>Dashboard</h1>
{users.next_page_url && (
<WhenVisible
data="users"
params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>}
/>
)}
{/* stats prop is loaded only when this section scrolls into view */}
<WhenVisible data="stats" buffer={200} fallback={<div className="animate-pulse">Loading stats...</div>}>
{({ fetching }) => (
<div>
<p>Total Users: {stats.total_users}</p>
<p>Revenue: {stats.revenue}</p>
{fetching && <span>Refreshing...</span>}
</div>
)}
</WhenVisible>
</div>
)
}
```
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)

View File

@ -1,6 +1,6 @@
---
name: pennant-development
description: "Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features."
description: "Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems."
license: MIT
metadata:
author: laravel

View File

@ -1,6 +1,6 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
@ -8,16 +8,6 @@ metadata:
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.

View File

@ -0,0 +1,54 @@
---
name: querying-the-database
description: "Query the local or production database from the CLI via the `agent:db` artisan command. Activates when the user asks to inspect, count, look up, or run a query against the database; asks 'how many X', 'what's in prod', 'check the prod DB', 'query the database'; or mentions production data, the prod database, or running SQL."
metadata:
author: whisper-money
---
# Querying the Database
## When to Apply
Activate this skill whenever you need to read data from the database to answer a
question — especially anything about **production** data. Prefer this command over
the `tinker`, `database-query`, or `database-schema` Boost tools when the user asks
about prod, since those default to the local connection.
## The `agent:db` command
Runs a query (`SELECT`, `SHOW`, `DESCRIBE`, `DELETE`, etc.) and prints the result.
```bash
php artisan agent:db "<query>"
```
### Options
- `--format=json` (default) — pretty-printed JSON, best for parsing the result yourself.
- `--format=table` — classic console table, best when showing the result to the user.
- `--prod` — run against the **production** database (the `prod` connection backed by
`PROD_DB_URL`). Without this flag the query runs against the local DB.
### Examples
```bash
# Local, JSON (default)
php artisan agent:db "select id, email from users limit 5"
# Local, human-readable table
php artisan agent:db --format=table "select count(*) as total from transactions"
# Production
php artisan agent:db --prod "select count(*) from users"
php artisan agent:db --prod --format=table "select status, count(*) from subscriptions group by status"
```
## Guidelines
- **Be careful with `--prod`**: this is live customer data. Only run prod queries the
user explicitly asked for, keep them scoped (add `LIMIT`, filter by id), and never
dump large or sensitive datasets unprompted. This app is privacy-first.
- Use `--format=json` when you need to read the values to continue working; use
`--format=table` when presenting results back to the user.
- Inspect schema first with `database-schema` (local) when you're unsure of column
names before writing a query.

View File

@ -1,6 +1,6 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
@ -8,16 +8,6 @@ metadata:
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.

View File

@ -8,13 +8,6 @@ metadata:
# Wayfinder Development
## When to Apply
Activate whenever referencing backend routes in frontend components:
- Importing from `@/actions/` or `@/routes/`
- Calling Laravel routes from TypeScript/JavaScript
- Creating links or navigation to backend endpoints
## Documentation
Use `search-docs` for detailed Wayfinder patterns and documentation.

1
.claude/skills Symbolic link
View File

@ -0,0 +1 @@
../.agents/skills

View File

@ -1,381 +0,0 @@
---
name: inertia-react-development
description: "Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation."
license: MIT
metadata:
author: laravel
---
# Inertia React Development
## When to Apply
Activate this skill when:
- Creating or modifying React page components for Inertia
- Working with forms in React (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, or polling
- Building React-specific features with the Inertia protocol
## Documentation
Use `search-docs` for detailed Inertia v2 React patterns and documentation.
## Basic Usage
### Page Components Location
React page components should be placed in the `resources/js/pages` directory.
### Page Component Structure
<!-- Basic React Page Component -->
```react
export default function UsersIndex({ users }) {
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
</div>
)
}
```
## Client-Side Navigation
### Basic Link Component
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
<!-- Inertia React Navigation -->
```react
import { Link, router } from '@inertiajs/react'
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
<Link href={`/users/${user.id}`}>View User</Link>
```
### Link with Method
<!-- Link with POST Method -->
```react
import { Link } from '@inertiajs/react'
<Link href="/logout" method="post" as="button">
Logout
</Link>
```
### Prefetching
Prefetch pages to improve perceived performance:
<!-- Prefetch on Hover -->
```react
import { Link } from '@inertiajs/react'
<Link href="/users" prefetch>
Users
</Link>
```
### Programmatic Navigation
<!-- Router Visit -->
```react
import { router } from '@inertiajs/react'
function handleClick() {
router.visit('/users')
}
// Or with options
router.visit('/users', {
method: 'post',
data: { name: 'John' },
onSuccess: () => console.log('Success!'),
})
```
## Form Handling
### Form Component (Recommended)
The recommended way to build forms is with the `<Form>` component:
<!-- Form Component Example -->
```react
import { Form } from '@inertiajs/react'
export default function CreateUser() {
return (
<Form action="/users" method="post">
{({ errors, processing, wasSuccessful }) => (
<>
<input type="text" name="name" />
{errors.name && <div>{errors.name}</div>}
<input type="email" name="email" />
{errors.email && <div>{errors.email}</div>}
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create User'}
</button>
{wasSuccessful && <div>User created!</div>}
</>
)}
</Form>
)
}
```
### Form Component With All Props
<!-- Form Component Full Example -->
```react
import { Form } from '@inertiajs/react'
<Form action="/users" method="post">
{({
errors,
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
clearErrors,
resetAndClearErrors,
defaults,
isDirty,
reset,
submit
}) => (
<>
<input type="text" name="name" defaultValue={defaults.name} />
{errors.name && <div>{errors.name}</div>}
<button type="submit" disabled={processing}>
{processing ? 'Saving...' : 'Save'}
</button>
{progress && (
<progress value={progress.percentage} max="100">
{progress.percentage}%
</progress>
)}
{wasSuccessful && <div>Saved!</div>}
</>
)}
</Form>
```
### Form Component Reset Props
The `<Form>` component supports automatic resetting:
- `resetOnError` - Reset form data when the request fails
- `resetOnSuccess` - Reset form data when the request succeeds
- `setDefaultsOnSuccess` - Update default values on success
Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
<!-- Form with Reset Props -->
```react
import { Form } from '@inertiajs/react'
<Form
action="/users"
method="post"
resetOnSuccess
setDefaultsOnSuccess
>
{({ errors, processing, wasSuccessful }) => (
<>
<input type="text" name="name" />
{errors.name && <div>{errors.name}</div>}
<button type="submit" disabled={processing}>
Submit
</button>
</>
)}
</Form>
```
Forms can also be built using the `useForm` helper for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
### `useForm` Hook
For more programmatic control or to follow existing conventions, use the `useForm` hook:
<!-- useForm Hook Example -->
```react
import { useForm } from '@inertiajs/react'
export default function CreateUser() {
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
password: '',
})
function submit(e) {
e.preventDefault()
post('/users', {
onSuccess: () => reset('password'),
})
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div>{errors.name}</div>}
<input
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && <div>{errors.email}</div>}
<input
type="password"
value={data.password}
onChange={e => setData('password', e.target.value)}
/>
{errors.password && <div>{errors.password}</div>}
<button type="submit" disabled={processing}>
Create User
</button>
</form>
)
}
```
## Inertia v2 Features
### Deferred Props
Use deferred props to load data after initial page render:
<!-- Deferred Props with Empty State -->
```react
export default function UsersIndex({ users }) {
// users will be undefined initially, then populated
return (
<div>
<h1>Users</h1>
{!users ? (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
) : (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
}
```
### Polling
Use the `usePoll` hook to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Basic Polling -->
```react
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
usePoll(5000)
return (
<div>
<h1>Dashboard</h1>
<div>Active Users: {stats.activeUsers}</div>
</div>
)
}
```
<!-- Polling With Request Options and Manual Control -->
```react
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
return (
<div>
<h1>Dashboard</h1>
<div>Active Users: {stats.activeUsers}</div>
<button onClick={start}>Start Polling</button>
<button onClick={stop}>Stop Polling</button>
</div>
)
}
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<!-- Infinite Scroll with WhenVisible -->
```react
import { WhenVisible } from '@inertiajs/react'
export default function UsersList({ users }) {
return (
<div>
{users.data.map(user => (
<div key={user.id}>{user.name}</div>
))}
{users.next_page_url && (
<WhenVisible
data="users"
params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>}
/>
)}
</div>
)
}
```
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
- Forgetting to add loading states (skeleton screens) when using deferred props
- Not handling the `undefined` state of deferred props before data loads
- Using `<form>` without preventing default submission (use `<Form>` component or `e.preventDefault()`)
- Forgetting to check if `<Form>` component is available in your Inertia version

View File

@ -1,77 +0,0 @@
---
name: pennant-development
description: "Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features."
license: MIT
metadata:
author: laravel
---
# Pennant Features
## When to Apply
Activate this skill when:
- Creating or checking feature flags
- Managing feature rollouts
- Implementing A/B testing
## Documentation
Use `search-docs` for detailed Pennant patterns and documentation.
## Basic Usage
### Defining Features
<!-- Defining Features -->
```php
use Laravel\Pennant\Feature;
Feature::define('new-dashboard', function (User $user) {
return $user->isAdmin();
});
```
### Checking Features
<!-- Checking Features -->
```php
if (Feature::active('new-dashboard')) {
// Feature is active
}
// With scope
if (Feature::for($user)->active('new-dashboard')) {
// Feature is active for this user
}
```
### Blade Directive
<!-- Blade Directive -->
```blade
@feature('new-dashboard')
<x-new-dashboard />
@else
<x-old-dashboard />
@endfeature
```
### Activating / Deactivating
<!-- Activating Features -->
```php
Feature::activate('new-dashboard');
Feature::for($user)->activate('new-dashboard');
```
## Verification
1. Check feature flag is defined
2. Test with different scopes/users
## Common Pitfalls
- Forgetting to scope features for specific users/entities
- Not following existing naming conventions

View File

@ -1,167 +0,0 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
license: MIT
metadata:
author: laravel
---
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
<!-- Basic Pest Test Example -->
```php
it('is true', function () {
expect(true)->toBeTrue();
});
```
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<!-- Pest Response Assertion -->
```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
```
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 4 Features
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Test Sharding
Split tests across parallel processes for faster CI runs.
### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
```
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests

View File

@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View File

@ -1,87 +0,0 @@
---
name: wayfinder-development
description: "Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions."
license: MIT
metadata:
author: laravel
---
# Wayfinder Development
## When to Apply
Activate whenever referencing backend routes in frontend components:
- Importing from `@/actions/` or `@/routes/`
- Calling Laravel routes from TypeScript/JavaScript
- Creating links or navigation to backend endpoints
## Documentation
Use `search-docs` for detailed Wayfinder patterns and documentation.
## Quick Reference
### Generate Routes
Run after route changes if Vite plugin isn't installed:
```bash
php artisan wayfinder:generate --no-interaction
```
For form helpers, use `--with-form` flag:
```bash
php artisan wayfinder:generate --with-form --no-interaction
```
### Import Patterns
<!-- Controller Action Imports -->
```typescript
// Named imports for tree-shaking (preferred)...
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
// Named route imports...
import { show as postShow } from '@/routes/post'
```
### Common Methods
<!-- Wayfinder Methods -->
```typescript
// Get route object...
show(1) // { url: "/posts/1", method: "get" }
// Get URL string...
show.url(1) // "/posts/1"
// Specific HTTP methods...
show.get(1)
store.post()
update.patch(1)
destroy.delete(1)
// Form attributes for HTML forms...
store.form() // { action: "/posts", method: "post" }
// Query parameters...
show(1, { query: { page: 1 } }) // "/posts/1?page=1"
```
## Wayfinder + Inertia
Use Wayfinder with the `<Form>` component:
<!-- Wayfinder Form (React) -->
```typescript
<Form {...store.form()}><input name="title" /></Form>
```
## Verification
1. Run `php artisan wayfinder:generate` to regenerate routes if Vite plugin isn't installed
2. Check TypeScript imports resolve correctly
3. Verify route URLs match expected paths
## Common Pitfalls
- Using default imports instead of named imports (breaks tree-shaking)
- Forgetting to regenerate after route changes
- Not using type-safe parameter objects for route model binding

View File

@ -6,6 +6,9 @@
"artisan",
"boost:mcp"
]
},
"sentry": {
"url": "https://mcp.sentry.dev/mcp"
}
}
}

32
.dockerignore Normal file
View File

@ -0,0 +1,32 @@
.git
.github
.agents
.claude
.cursor
.opencode
.pi
.env
.env.*
!.env.example
!.env.production.example
node_modules
vendor
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
!storage/app/.gitignore
!storage/framework/cache/.gitignore
!storage/framework/sessions/.gitignore
!storage/framework/views/.gitignore
!storage/logs/.gitignore
Dockerfile
Dockerfile.*
docker-compose*.yml
screenshots

View File

@ -2,12 +2,18 @@ APP_NAME="Whisper Money"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://whisper.money.local
APP_URL=https://whisper.money.localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Passport OAuth signing keys for the MCP OAuth server. Leave blank to use the
# keys generated by `php artisan passport:keys` under storage/. Set both (PEM
# contents) to share one key pair across multiple app instances.
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
@ -51,12 +57,15 @@ MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hi@whisper.money"
MAIL_FROM_ADDRESS="no-reply@whisper.money"
MAIL_FROM_NAME="Whisper Money"
MAIL_DRIP_FROM_ADDRESS="hi@whisper.money"
MAIL_DRIP_FROM_NAME="Álvaro and Víctor"
ADMIN_EMAIL=
# Resend Email Service (set MAIL_MAILER=resend to use in production)
# Resend contact sync (optional, not used for sending email)
RESEND_API_KEY=
RESEND_LEADS_SEGMENT_ID=
# Drip Emails (welcome, onboarding, promo codes, etc.)
DRIP_EMAILS_ENABLED=true
@ -64,13 +73,14 @@ DRIP_EMAILS_ENABLED=true
# Email Verification (disable in local to auto-verify new users)
EMAIL_VERIFICATION_ENABLED=false
# Amazon SES Email Service (set MAIL_MAILER=ses to use in production)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_SESSION_TOKEN=
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
LEAD_REDIRECT_URL=https://google.es
HIDE_AUTH_BUTTONS=false
VITE_APP_NAME="${APP_NAME}"
@ -78,7 +88,8 @@ VITE_APP_NAME="${APP_NAME}"
# PostHog Analytics
VITE_POSTHOG_ENABLED=false
VITE_POSTHOG_API_KEY=
VITE_POSTHOG_HOST=https://eu.i.posthog.com
VITE_POSTHOG_HOST=https://t.whisper.money
VITE_POSTHOG_UI_HOST=https://eu.posthog.com
# Stripe Configuration
STRIPE_KEY=
@ -96,6 +107,7 @@ STRIPE_PRO_YEARLY_LOOKUP_KEY=whisper_pro_yearly
SENTRY_LARAVEL_DSN=
SENTRY_TRACES_SAMPLE_RATE=1.0
SENTRY_PROFILES_SAMPLE_RATE=0
SENTRY_ENABLE_LOGS=false
# Docker Service Ports (forwarded to host)
FORWARD_DB_PORT=3307
@ -115,3 +127,31 @@ ENABLEBANKING_REDIRECT_URL="${APP_URL}/open-banking/callback"
DEMO_EMAIL=demo@whisper.money
DEMO_PASSWORD=demo
DEMO_ENCRYPTION_KEY=demo
# AI rule suggestions (powered by laravel/ai + Gemini)
GEMINI_API_KEY=
GEMINI_BASE_URL=
# Model is overridable without a deploy; defaults to a Flash-tier model.
# GEMINI_BASE_URL is optional — leave empty to use Google AI Studio
# (https://generativelanguage.googleapis.com/v1beta). Only set it for a
# proxy or Vertex AI gateway.
AI_SUGGESTIONS_MODEL=gemini-flash-latest
AI_SUGGESTIONS_MIN_GROUP_COUNT=2
AI_SUGGESTIONS_MAX_GROUPS=40
AI_SUGGESTIONS_CONFIDENCE_FLOOR=0.3
AI_SUGGESTIONS_AUTO_SELECT=0.6
AI_SUGGESTIONS_OVERBROAD_FRACTION=0.4
AI_SUGGESTIONS_MIN_TRANSACTIONS=50
AI_SUGGESTIONS_THROTTLE_DAYS=30
AI_SUGGESTIONS_CONSENT_VERSION=1
# AI Suggestions cohort report (stats:ai-cohort-report)
AI_SUGGESTIONS_REPORT_WEEKS=16
# Comma-separated staff/test emails to exclude from cohort metrics (e.g. the
# account that plants the release-anchor consent on deploy).
AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS=
# Discord webhooks
DISCORD_WEBHOOK_URL=
# Optional dedicated channel for the AI cohort report; falls back to DISCORD_WEBHOOK_URL.
DISCORD_AI_COHORT_WEBHOOK_URL=

View File

@ -14,9 +14,18 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Trusted Proxies
# Comma-separated proxy IPs/CIDRs Laravel trusts for X-Forwarded-* headers (no spaces).
# When unset the app trusts all proxies (*), which keeps small private self-hosted
# setups working behind any proxy out of the box. On a publicly exposed deployment set
# this to the actual proxy range: otherwise clients can spoof X-Forwarded-For to forge
# their IP (defeating per-IP rate limiting and poisoning logs). The private ranges below
# cover the Docker network Coolify/Traefik connects from.
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_STACK=single,sentry_logs
LOG_LEVEL=error
# Database (MySQL)
@ -36,6 +45,13 @@ SESSION_ENCRYPT=true
# Queue
QUEUE_CONNECTION=database
# Sentry Error Tracking
SENTRY_LARAVEL_DSN=
# SENTRY_RELEASE is injected by CI as whisper-money@<git-sha> during production builds.
SENTRY_TRACES_SAMPLE_RATE=1.0
SENTRY_PROFILES_SAMPLE_RATE=1.0
SENTRY_ENABLE_LOGS=true
# Redis (internal to container - no configuration needed)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
@ -43,8 +59,11 @@ REDIS_PORT=6379
# Email - Resend (Recommended for production)
MAIL_MAILER=resend
RESEND_API_KEY=your-resend-api-key
MAIL_FROM_ADDRESS=hi@your-domain.com
RESEND_LEADS_SEGMENT_ID=your-segment-id
MAIL_FROM_ADDRESS=no-reply@your-domain.com
MAIL_FROM_NAME="Whisper Money"
MAIL_DRIP_FROM_ADDRESS=hi@your-domain.com
MAIL_DRIP_FROM_NAME="Álvaro and Víctor"
# Stripe (Optional - for subscriptions)
STRIPE_KEY=

View File

@ -0,0 +1,30 @@
name: Setup Bun & Node Deps
description: |
Sets up Bun and installs Node dependencies, caching both the global
Bun install cache and `node_modules/`. On a `node_modules/` cache hit
`bun install` is skipped entirely.
runs:
using: composite
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Cache bun global cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- name: Install Node Dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
shell: bash
run: bun install --frozen-lockfile

View File

@ -0,0 +1,57 @@
name: Setup PHP & Composer Deps
description: |
Sets up PHP 8.4 and installs Composer dependencies with two layers of cache:
the global Composer cache directory and the resolved `vendor/` directory
itself. On a `vendor/` cache hit we skip `composer install` entirely.
inputs:
php-version:
description: PHP version to install
required: false
default: '8.4'
tools:
description: Tools to install via shivammathur/setup-php
required: false
default: 'composer:v2'
coverage:
description: Coverage driver (xdebug, pcov, none)
required: false
default: 'none'
composer-args:
description: Extra args for `composer install` when running
required: false
default: '--no-interaction --prefer-dist --optimize-autoloader'
runs:
using: composite
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ inputs.php-version }}
tools: ${{ inputs.tools }}
coverage: ${{ inputs.coverage }}
- name: Get composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache composer global cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ runner.os }}-php${{ inputs.php-version }}-${{ hashFiles('composer.lock') }}
restore-keys: composer-${{ runner.os }}-php${{ inputs.php-version }}-
- name: Cache vendor/
id: vendor-cache
uses: actions/cache@v4
with:
path: vendor
key: vendor-${{ runner.os }}-php${{ inputs.php-version }}-${{ hashFiles('composer.lock') }}
- name: Install PHP Dependencies
if: steps.vendor-cache.outputs.cache-hit != 'true'
shell: bash
run: composer install ${{ inputs.composer-args }}

View File

@ -0,0 +1,108 @@
---
name: cashier-stripe-development
description: "Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues."
license: MIT
metadata:
author: laravel
---
# Cashier Stripe Development
## When to Apply
Activate this skill when:
- Installing or configuring Laravel Cashier Stripe
- Setting up subscriptions, trials, quantities, or plan swapping
- Handling webhooks or SCA/3DS payment failures
- Working with Stripe Checkout, invoices, or charges
- Testing billing scenarios with Stripe test cards or tokens
## Documentation
Use `search-docs` for detailed Cashier patterns and documentation covering subscriptions, webhooks, Stripe Checkout, invoices, payment methods, and testing.
For deeper guidance on specific topics, read the relevant reference file before implementing:
- `references/subscriptions.md` covers subscription creation, status checks, swapping, trials, quantities, and multiple products
- `references/webhooks.md` covers webhook setup, custom handlers, CSRF exclusion, and local development with the Stripe CLI
- `references/testing.md` covers Stripe test cards, payment method tokens, and feature test patterns
## Basic Usage
### Installation
```bash
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
php artisan vendor:publish --tag="cashier-config"
```
### Environment Variables
```
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en_US
```
### Billable Model
<!-- Add Billable Trait -->
```php
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
```
For a non-User model, register it in a service provider:
<!-- Custom Billable Model -->
```php
// In AppServiceProvider::boot()
Cashier::useCustomerModel(Team::class);
```
### Creating a Subscription
<!-- Create Subscription -->
```php
use Laravel\Cashier\Exceptions\IncompletePayment;
try {
$user->newSubscription('default', 'price_xxxx')->create($paymentMethodId);
} catch (IncompletePayment $e) {
return redirect()->route('cashier.payment', [$e->payment->id, 'redirect' => route('home')]);
}
```
Always wrap subscription creation in a try/catch for `IncompletePayment`. When a card requires 3DS authentication, Cashier throws this exception. The `cashier.payment` route is auto-registered and handles the confirmation flow.
## Verification
1. Run migrations and confirm `stripe_id`, `pm_type`, `pm_last_four`, and `trial_ends_at` columns exist on the billable model table
2. Test the webhook endpoint with `stripe listen --forward-to localhost/stripe/webhook` if you use the default path, or swap `stripe` for your configured `CASHIER_PATH`
3. Confirm `$user->subscribed('default')` returns the expected value for active and incomplete subscriptions
## Common Pitfalls
- The migration publish tag is `cashier-migrations`, not `cashier`. Running `migrate` before publishing results in missing columns and tables.
- `CASHIER_CURRENCY` must be set explicitly. It defaults to USD, which silently breaks non-US apps.
- The Stripe CLI generates its own webhook signing secret. It is different from the Dashboard endpoint secret. Using the wrong one causes signature verification failures.
- The webhook route must be excluded from CSRF verification using your configured `cashier.path`. If you change `CASHIER_PATH` from `stripe` to `billing`, exclude `billing/*`, not `stripe/*`.
- `canceled()` returns true as soon as `cancel()` is called, but the user still has access during the grace period. Use `ended()` to confirm access is fully revoked.
- `subscribed()` returns true during the grace period even though the subscription is canceled.
- `subscribed()` returns false for `incomplete` and `past_due` subscriptions by default.
- Prices cannot be swapped and quantity cannot be updated while a subscription has an incomplete payment.
- When extending `WebhookController`, call `Cashier::ignoreRoutes()` in a service provider and re-register both `cashier.payment` and `cashier.webhook` under the configured `cashier.path`.
- Use `Cashier::useCustomerModel()` in a service provider to set a custom billable model. There is no `CASHIER_MODEL` env var.
- `trial_ends_at` is a local database column synced via webhooks. It will be stale if webhooks are not configured in production.
- In MySQL, the `stripe_id` column must use `utf8_bin` collation to avoid case-sensitivity issues.
- `noProrate()` has no effect when combined with `swapAndInvoice()`. That method always prorates.
- Methods like `withPromotionCode()` require the Stripe API ID such as `promo_xxxx`, not the customer-facing code. Use `findPromotionCode()` to resolve a code to its ID.
- Always use `search-docs` for the latest Cashier documentation rather than relying on this skill alone.

View File

@ -0,0 +1,108 @@
# Subscriptions Reference
Use `search-docs` for authoritative documentation on subscriptions.
## Status Checks
| Method | Returns true when |
|---|---|
| `$user->subscribed('default')` | Active or on grace period |
| `->onTrial()` | Trial period active |
| `->onGracePeriod()` | Canceled, period not yet ended |
| `->canceled()` | `ends_at` is set, may still have access |
| `->ended()` | Canceled and grace period expired |
| `->incomplete()` | Awaiting SCA/3DS confirmation |
| `->pastDue()` | Payment overdue |
| `->recurring()` | Active and not on trial |
Check by product or price:
```php
$user->subscribedToProduct('prod_premium', 'default');
$user->subscribedToPrice('price_monthly', 'default');
```
## Swapping Plans
```php
$user->subscription('default')->swap('price_new');
$user->subscription('default')->noProrate()->swap('price_new');
$user->subscription('default')->swapAndInvoice('price_new');
$user->subscription('default')->skipTrial()->swap('price_new');
```
## Quantity
```php
$user->subscription('default')->incrementQuantity();
$user->subscription('default')->decrementQuantity();
$user->subscription('default')->updateQuantity(10);
$user->subscription('default')->noProrate()->updateQuantity(10);
```
## Trials
```php
$user->newSubscription('default', 'price_xxxx')
->trialDays(14)
->create($paymentMethodId);
$subscription->extendTrial(now()->addDays(7));
```
## Multiple Products on One Subscription
```php
$user->newSubscription('default', ['price_monthly', 'price_chat'])
->quantity(5, 'price_chat')
->create($paymentMethod);
$user->subscription('default')->addPrice('price_chat');
$user->subscription('default')->removePrice('price_chat');
$user->subscription('default')->swap(['price_pro', 'price_chat']);
```
## Multiple Subscriptions
```php
$user->newSubscription('swimming', 'price_swimming_monthly')->create($pm);
$user->newSubscription('gym', 'price_gym_monthly')->create($pm);
$user->subscription('swimming')->swap('price_swimming_yearly');
$user->subscription('gym')->cancel();
```
## Cancellation and Resumption
```php
$user->subscription('default')->cancel(); // At end of billing period
$user->subscription('default')->cancelNow(); // Immediately
$user->subscription('default')->resume(); // During grace period only
```
## Incomplete Payment Handling
```php
if ($user->hasIncompletePayment('default')) {
$paymentId = $user->subscription('default')->latestPayment()->id;
return redirect()->route('cashier.payment', $paymentId);
}
```
Opt out of default deactivation behavior:
```php
Cashier::keepPastDueSubscriptionsActive();
Cashier::keepIncompleteSubscriptionsActive();
```
## Metered / Usage-Based Billing
```php
$user->newSubscription('default')
->meteredPrice('price_metered')
->create($paymentMethodId);
$user->reportMeterEvent('emails-sent');
$user->reportMeterEvent('emails-sent', quantity: 15);
```

View File

@ -0,0 +1,52 @@
# Testing Reference
Use `search-docs` for authoritative documentation on testing Cashier integrations.
## Test Cards and Tokens
Use card numbers for browser-based flows (Stripe.js / Checkout). Use `pm_card_*` tokens directly in feature tests that call the Stripe API.
| Card Number | Token | Behavior |
|---|---|---|
| `4242 4242 4242 4242` | `pm_card_visa` | Succeeds immediately |
| `4000 0025 0000 3155` | `pm_card_threeDSecure2Required` | Requires SCA/3DS |
| `4000 0027 6000 3184` | `pm_card_authenticationRequired` | Requires authentication |
| `4000 0000 0000 9995` | `pm_card_chargeDeclinedInsufficientFunds` | Declined, insufficient funds |
| `4000 0000 0000 0002` | `pm_card_chargeDeclined` | Declined |
Use expiry `12/34`, any CVC, any ZIP for card number inputs.
## Feature Test Example
Feature tests that hit the real Stripe test API use `pm_card_*` tokens:
```php
public function test_user_can_subscribe(): void
{
$user = User::factory()->create();
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_visa');
$this->assertTrue($user->subscribed('default'));
}
public function test_incomplete_payment_is_handled(): void
{
$user = User::factory()->create();
try {
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_threeDSecure2Required');
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
$this->assertTrue($user->subscription('default')->incomplete());
}
}
```
## Setup Notes
- Use Stripe test mode keys (`sk_test_...`, `pk_test_...`) in your test environment
- Cashier does not ship a global `fake()` helper. Tests hit the real Stripe test API by default.
- Refer to `tests/Feature/` in the Cashier package itself for integration test patterns covering subscription creation, payment methods, and webhook handling
- Use `search-docs` for current guidance on mocking Stripe HTTP calls or using Stripe's test clock feature for time-sensitive scenarios

View File

@ -0,0 +1,132 @@
# Webhooks Reference
Use `search-docs` for authoritative documentation on webhooks.
## Auto-Registered Routes
Cashier registers two routes automatically under the `cashier.path` prefix (`config('cashier.path')`, default `stripe`):
- `POST /{cashier.path}/webhook` named `cashier.webhook`
- `GET /{cashier.path}/payment/{id}` named `cashier.payment`
With the default config these are `/stripe/webhook` and `/stripe/payment/{id}`. If you set `CASHIER_PATH=billing`, they become `/billing/webhook` and `/billing/payment/{id}`.
## CSRF Exclusion
Use the same path prefix you configured for Cashier here. If `CASHIER_PATH=billing`, exclude `billing/*` instead of `stripe/*`.
**Laravel 11+ (`bootstrap/app.php`, default path example):**
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/*']);
})
```
**Laravel 10 (`app/Http/Middleware/VerifyCsrfToken.php`, default path example):**
```php
protected $except = [
'stripe/*',
];
```
## Local Development with Stripe CLI
If you changed `cashier.path`, forward Stripe CLI events to that URL instead of `/stripe/webhook`.
```bash
stripe login
stripe listen --forward-to your-app.test/stripe/webhook
stripe trigger invoice.payment_succeeded
```
The CLI outputs a `whsec_...` signing secret specific to that session. Set it as `STRIPE_WEBHOOK_SECRET` locally. It is not the same as the Dashboard endpoint secret.
## Registering Events in the Stripe Dashboard
Use the Artisan command to create the endpoint automatically with all required events:
```bash
php artisan cashier:webhook
```
Cashier's `cashier:webhook` command registers these events by default:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.updated` / `customer.deleted`
- `invoice.payment_action_required`
- `invoice.payment_succeeded`
- `payment_method.automatically_updated`
Cashier's `WebhookController` has built-in handlers for all of the above except `invoice.payment_succeeded`. For renewal hooks, prefer `WebhookReceived` / `WebhookHandled` listeners unless you intentionally add your own controller method.
## Custom Handlers: Extending WebhookController
Method name pattern: `handle` + StudlyCase of event type with dots replaced by underscores.
`customer.subscription.created` becomes `handleCustomerSubscriptionCreated`.
```php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class StripeWebhookController extends CashierController
{
public function handleCustomerSubscriptionCreated(array $payload)
{
$response = parent::handleCustomerSubscriptionCreated($payload);
// your logic after Cashier syncs the subscription
return $response;
}
}
```
If you add a method for an event Cashier does not handle internally, such as `invoice.payment_succeeded`, do not call `parent::handle...()` unless the base controller actually defines that method.
In a service provider, disable auto-registration and re-register both Cashier routes so the incomplete-payment flow and `cashier:webhook` command keep working:
```php
Cashier::ignoreRoutes();
```
```php
// routes/web.php
use App\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route;
use Laravel\Cashier\Http\Controllers\PaymentController;
Route::prefix(config('cashier.path'))
->name('cashier.')
->group(function () {
Route::get('payment/{id}', [PaymentController::class, 'show'])->name('payment');
Route::post('webhook', [StripeWebhookController::class, 'handleWebhook'])->name('webhook');
});
```
Keep the `cashier.webhook` route name unless you plan to pass `--url` explicitly to `php artisan cashier:webhook`.
## Custom Handlers: Listening to Events
The simpler option when you do not need to replace Cashier's internal logic, or when you want to react to events such as `invoice.payment_succeeded` that Cashier does not process itself:
```php
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Cashier\Events\WebhookHandled;
// WebhookReceived fires for every event before Cashier processes it
// WebhookHandled fires after Cashier processes it
Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
if ($event->payload['type'] === 'invoice.payment_succeeded') {
// handle renewal
}
});
```
## Signature Verification
`VerifyWebhookSignature` middleware is applied automatically when `cashier.webhook.secret` is set. No extra wiring is needed.

View File

@ -1,128 +0,0 @@
---
name: developing-with-fortify
description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
---
# Laravel Fortify Development
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
## Documentation
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
## Usage
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
## Available Features
Enable in `config/fortify.php` features array:
- `Features::registration()` - User registration
- `Features::resetPasswords()` - Password reset via email
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
- `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
> Use `search-docs` for feature configuration options and customization patterns.
## Setup Workflows
### Two-Factor Authentication Setup
```
- [ ] Add TwoFactorAuthenticatable trait to User model
- [ ] Enable feature in config/fortify.php
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Set up view callbacks in FortifyServiceProvider
- [ ] Create 2FA management UI
- [ ] Test QR code and recovery codes
```
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Email Verification Setup
```
- [ ] Enable emailVerification feature in config
- [ ] Implement MustVerifyEmail interface on User model
- [ ] Set up verifyEmailView callback
- [ ] Add verified middleware to protected routes
- [ ] Test verification email flow
```
> Use `search-docs` for MustVerifyEmail implementation patterns.
### Password Reset Setup
```
- [ ] Enable resetPasswords feature in config
- [ ] Set up requestPasswordResetLinkView callback
- [ ] Set up resetPasswordView callback
- [ ] Define password.reset named route (if views disabled)
- [ ] Test reset email and link flow
```
> Use `search-docs` for custom password reset flow patterns.
### SPA Authentication Setup
```
- [ ] Set 'views' => false in config/fortify.php
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
- [ ] Set up CSRF token handling
- [ ] Test XHR authentication flows
```
> Use `search-docs` for integration and SPA authentication patterns.
#### Two-Factor Authentication in SPA Mode
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
```json
{
"two_factor": true
}
```
## Best Practices
### Custom Authentication Logic
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
### Registration Customization
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
### Rate Limiting
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
## Key Endpoints
| Feature | Method | Endpoint |
|------------------------|----------|---------------------------------------------|
| Login | POST | `/login` |
| Logout | POST | `/logout` |
| Register | POST | `/register` |
| Password Reset Request | POST | `/forgot-password` |
| Password Reset | POST | `/reset-password` |
| Email Verify Notice | GET | `/email/verify` |
| Resend Verification | POST | `/email/verification-notification` |
| Password Confirm | POST | `/user/confirm-password` |
| Enable 2FA | POST | `/user/two-factor-authentication` |
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
| 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |

View File

@ -1,5 +1,5 @@
---
name: developing-with-fortify
name: fortify-development
description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
---

View File

@ -15,7 +15,7 @@ Activate this skill when:
- Creating or modifying React page components for Inertia
- Working with forms in React (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, or polling
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Building React-specific features with the Inertia protocol
## Documentation
@ -295,14 +295,21 @@ export default function UsersIndex({ users }) {
### Polling
Use the `usePoll` hook to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
Automatically refresh data at intervals:
<!-- Basic Polling -->
<!-- Polling Example -->
```react
import { usePoll } from '@inertiajs/react'
import { router } from '@inertiajs/react'
import { useEffect } from 'react'
export default function Dashboard({ stats }) {
usePoll(5000)
useEffect(() => {
const interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [])
return (
<div>
@ -313,65 +320,38 @@ export default function Dashboard({ stats }) {
}
```
<!-- Polling With Request Options and Manual Control -->
```react
import { usePoll } from '@inertiajs/react'
### WhenVisible
export default function Dashboard({ stats }) {
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
return (
<div>
<h1>Dashboard</h1>
<div>Active Users: {stats.activeUsers}</div>
<button onClick={start}>Start Polling</button>
<button onClick={stop}>Stop Polling</button>
</div>
)
}
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<!-- Infinite Scroll with WhenVisible -->
<!-- WhenVisible Example -->
```react
import { WhenVisible } from '@inertiajs/react'
export default function UsersList({ users }) {
export default function Dashboard({ stats }) {
return (
<div>
{users.data.map(user => (
<div key={user.id}>{user.name}</div>
))}
<h1>Dashboard</h1>
{users.next_page_url && (
<WhenVisible
data="users"
params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>}
/>
)}
{/* stats prop is loaded only when this section scrolls into view */}
<WhenVisible data="stats" buffer={200} fallback={<div className="animate-pulse">Loading stats...</div>}>
{({ fetching }) => (
<div>
<p>Total Users: {stats.total_users}</p>
<p>Revenue: {stats.revenue}</p>
{fetching && <span>Refreshing...</span>}
</div>
)}
</WhenVisible>
</div>
)
}
```
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)

View File

@ -1,6 +1,6 @@
---
name: pennant-development
description: "Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features."
description: "Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems."
license: MIT
metadata:
author: laravel

View File

@ -1,6 +1,6 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
@ -8,16 +8,6 @@ metadata:
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.

View File

@ -1,6 +1,6 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
@ -8,16 +8,6 @@ metadata:
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.

View File

@ -8,13 +8,6 @@ metadata:
# Wayfinder Development
## When to Apply
Activate whenever referencing backend routes in frontend components:
- Importing from `@/actions/` or `@/routes/`
- Calling Laravel routes from TypeScript/JavaScript
- Creating links or navigation to backend endpoints
## Documentation
Use `search-docs` for detailed Wayfinder patterns and documentation.

View File

@ -1,57 +0,0 @@
name: Automerge
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
automerge:
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Merge PR with Automerge label
env:
GH_TOKEN: ${{ secrets.MERGE_TOKEN }}
REPO: ${{ github.repository }}
run: |
HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}"
PR_NUMBER=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open --json number --jq '.[0].number')
if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then
echo "No open PR found for branch $HEAD_BRANCH"
exit 0
fi
HAS_LABEL=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | map(select(. == "Automerge")) | length')
if [ "$HAS_LABEL" -eq 0 ]; then
echo "PR #$PR_NUMBER does not have Automerge label, skipping"
exit 0
fi
# Ensure the CI run matches the PR's latest commit to avoid merging stale results
CI_SHA="${{ github.event.workflow_run.head_sha }}"
PR_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
if [ "$CI_SHA" != "$PR_SHA" ]; then
echo "CI ran on $CI_SHA but PR head is $PR_SHA, skipping"
exit 0
fi
# Ensure no checks have failed on the PR
FAILED=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json state --jq '[.[] | select(.state == "FAILURE")] | length')
if [ "$FAILED" -gt 0 ]; then
echo "PR #$PR_NUMBER has $FAILED failed check(s), skipping merge"
exit 0
fi
echo "PR #$PR_NUMBER has Automerge label and all checks passed. Merging..."
gh pr merge "$PR_NUMBER" --squash --repo "$REPO" --delete-branch

View File

@ -17,6 +17,29 @@ on:
default: true
jobs:
build-assets:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
- name: Setup Bun & Node Deps
uses: ./.github/actions/setup-bun-deps
- name: Build Assets
run: bun run build
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: build-assets
path: public/build
retention-days: 1
tests:
runs-on: ubuntu-latest
services:
@ -32,24 +55,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Node Dependencies
run: bun install --frozen-lockfile
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Build Assets
run: bun run build
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
- name: Copy Environment File
run: cp .env.example .env
@ -57,8 +64,16 @@ jobs:
- name: Generate Application Key
run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Tests
run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance
# --parallel splits the suite across workers via paratest. Each
# worker creates its own "testing_test_<N>" database; the mysql
# service runs as root which has CREATE on *.* so this Just Works
# without extra grants. Performance suite is excluded because
# concurrent load skews its timing-based assertions.
run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance --parallel --processes=4
env:
TESTCONTAINERS: false
DB_CONNECTION: mysql
@ -69,8 +84,86 @@ jobs:
DB_PASSWORD: password
browser-tests:
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs: browser-tests-matrix
steps:
- name: Aggregate shard results
run: |
if [ "${{ needs.browser-tests-matrix.result }}" != "success" ]; then
echo "One or more browser-tests shards failed: ${{ needs.browser-tests-matrix.result }}"
exit 1
fi
echo "All browser-tests shards passed."
browser-tests-matrix:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
- name: Setup Bun & Node Deps
uses: ./.github/actions/setup-bun-deps
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: playwright-${{ runner.os }}-
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright system deps
run: npx playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
- name: Build Assets
run: bun run build
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Browser Tests
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6
env:
TESTCONTAINERS: false
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
update-browser-shards:
if: github.event_name == 'workflow_dispatch' && inputs.build_only == false
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
@ -84,28 +177,27 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
- name: Setup Bun & Node Deps
uses: ./.github/actions/setup-bun-deps
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
php-version: 8.4
tools: composer:v2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Node Dependencies
run: bun install --frozen-lockfile
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: playwright-${{ runner.os }}-
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install PHP Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install Playwright system deps
run: npx playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
- name: Build Assets
run: bun run build
@ -116,8 +208,8 @@ jobs:
- name: Generate Application Key
run: php artisan key:generate
- name: Browser Tests
run: ./vendor/bin/pest --testsuite=Browser --ci
- name: Update Browser Shards
run: ./vendor/bin/pest --testsuite=Browser --ci --update-shards
env:
TESTCONTAINERS: false
DB_CONNECTION: mysql
@ -127,42 +219,51 @@ jobs:
DB_USERNAME: root
DB_PASSWORD: password
- name: Upload Browser Shards
uses: actions/upload-artifact@v4
with:
name: browser-shards
path: tests/.pest/shards.json
retention-days: 1
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
with:
php-version: '8.4'
- name: Install PHP Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
composer-args: '-q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist'
- name: Run PHPStan
run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress
linter:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
with:
php-version: '8.4'
composer-args: '-q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Bun & Node Deps
uses: ./.github/actions/setup-bun-deps
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
bun install --frozen-lockfile
- name: Cache Pint results
uses: actions/cache@v4
with:
path: .pint.cache
key: pint-${{ runner.os }}-${{ hashFiles('composer.lock', 'pint.json') }}
restore-keys: pint-${{ runner.os }}-
- name: Run Pint
run: vendor/bin/pint --test
run: vendor/bin/pint --test --parallel --cache-file=.pint.cache
- name: Format Frontend
run: bun run format
@ -170,9 +271,44 @@ jobs:
- name: Lint Frontend
run: bun run lint
# Advisory for now: the codebase carries a backlog of pre-existing
# `tsc --noEmit` errors that predate this step, so it must not gate
# merges yet. It still surfaces type regressions in the CI log. Flip
# `continue-on-error` to false once the existing backlog is cleared.
- name: Type Check Frontend
run: bun run types
continue-on-error: true
- name: Frontend Tests
run: bun run test
# Gates merges on a Conventional-Commit PR title. Lives in the linter
# job (a required check) so a bad title blocks the merge, unlike the old
# standalone workflow whose check wasn't required. Skipped on push since
# there's no PR title to validate then.
- name: Conventional PR title
if: github.event_name == 'pull_request'
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
chore
docs
style
refactor
perf
test
build
ci
revert
requireScope: false
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
Subject must not start with uppercase. Use: feat: add checkout, fix(billing): handle retry.
performance-tests:
runs-on: ubuntu-latest
services:
@ -188,23 +324,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Node Dependencies
run: bun install --frozen-lockfile
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Build Assets
run: bun run build
- name: Setup PHP & Composer Deps
uses: ./.github/actions/setup-php-deps
- name: Copy Environment File
run: cp .env.example .env
@ -223,19 +344,85 @@ jobs:
DB_USERNAME: root
DB_PASSWORD: password
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'app/**'
- 'bootstrap/**'
- 'config/**'
- 'database/**'
- 'public/**'
- 'resources/**'
- 'routes/**'
- 'storage/**'
- 'artisan'
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
- 'bun.lock'
- 'bun.lockb'
- 'vite.config.*'
- 'tsconfig*.json'
- 'eslint.config.*'
- '.prettierrc*'
- 'phpstan.neon*'
- 'phpunit.xml*'
- 'pint.json'
- 'Dockerfile'
- 'Dockerfile.*'
- 'docker/**'
- '.dockerignore'
- '.env.example'
- '.env.production.example'
- 'docker-compose.production.yml'
- 'docker-compose.production.local.yml'
- 'templates/coolify/**'
- '.github/workflows/ci.yml'
build-image:
runs-on: ubuntu-latest
needs: [tests, linter, static-analysis, performance-tests]
if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch'
timeout-minutes: 45
needs: [tests, linter, static-analysis, performance-tests, changes]
if: ((github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch') && needs.changes.outputs.code == 'true'
outputs:
development-digest: ${{ steps.development-image.outputs.digest }}
production-digest: ${{ steps.production-image.outputs.digest }}
package-version: ${{ steps.package-version.outputs.version }}
env:
SENTRY_RELEASE: whisper-money@${{ github.sha }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Install Sentry CLI
run: curl -sL https://sentry.io/get-cli/ | bash
- name: Create Sentry release
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: |
if ! sentry-cli releases info "$SENTRY_RELEASE" >/dev/null 2>&1; then
sentry-cli releases new "$SENTRY_RELEASE"
fi
sentry-cli releases set-commits "$SENTRY_RELEASE" --auto --ignore-missing
sentry-cli releases finalize "$SENTRY_RELEASE"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -256,57 +443,153 @@ jobs:
type=sha,prefix=
type=raw,value=latest
- name: Build and push
- name: Extract package version
id: package-version
run: node -e "console.log('version=' + require('./package.json').version)" >> "$GITHUB_OUTPUT"
- name: Extract production metadata
id: production-meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,suffix=-production
type=raw,value=production
type=raw,value=v${{ steps.package-version.outputs.version }}-production
- name: Build and push development image
id: development-image
uses: docker/build-push-action@v6
timeout-minutes: 20
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: [tests, linter, static-analysis, performance-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
- name: Build and push production image
id: production-image
uses: docker/build-push-action@v6
timeout-minutes: 25
with:
context: .
file: Dockerfile.production
platforms: linux/amd64
push: true
tags: ${{ steps.production-meta.outputs.tags }}
labels: ${{ steps.production-meta.outputs.labels }}
build-args: |
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
cache-from: type=gha,scope=production
cache-to: type=gha,mode=max,scope=production
build-arm64-images:
runs-on: ubuntu-24.04-arm
timeout-minutes: 75
needs: build-image
if: needs.build-image.result == 'success'
env:
SENTRY_RELEASE: whisper-money@${{ github.sha }}
permissions:
contents: read
packages: write
steps:
- name: Trigger deployment
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Extract production metadata
id: production-meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,suffix=-production
type=raw,value=production
type=raw,value=v${{ needs.build-image.outputs.package-version }}-production
- name: Build and push arm64 development image by digest
id: development-arm64
uses: docker/build-push-action@v6
timeout-minutes: 30
with:
context: .
platforms: linux/arm64
tags: ghcr.io/${{ github.repository }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
cache-from: type=gha,scope=arm64
cache-to: type=gha,mode=max,scope=arm64
- name: Publish multi-platform development manifests
env:
AMD64_DIGEST: ${{ needs.build-image.outputs.development-digest }}
ARM64_DIGEST: ${{ steps.development-arm64.outputs.digest }}
IMAGE: ghcr.io/${{ github.repository }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
max_attempts=5
attempt=1
while IFS= read -r tag; do
[ -n "$tag" ] || continue
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts..."
docker buildx imagetools create \
--tag "$tag" \
"$IMAGE@$AMD64_DIGEST" \
"$IMAGE@$ARM64_DIGEST"
done <<< "$TAGS"
response=$(curl -s -w "\n%{http_code}" \
--connect-timeout 30 \
--max-time 120 \
-H "Authorization: Bearer ${{ secrets.DEPLOYMENT_TOKEN }}" \
"http://147.93.126.54:8000/api/v1/deploy?uuid=ww00sswosco8w80k08c0occ8&force=false")
- name: Build and push arm64 production image by digest
id: production-arm64
uses: docker/build-push-action@v6
timeout-minutes: 40
with:
context: .
file: Dockerfile.production
platforms: linux/arm64
tags: ghcr.io/${{ github.repository }}
labels: ${{ steps.production-meta.outputs.labels }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
cache-from: type=gha,scope=production-arm64
cache-to: type=gha,mode=max,scope=production-arm64
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
- name: Publish multi-platform production manifests
env:
AMD64_DIGEST: ${{ needs.build-image.outputs.production-digest }}
ARM64_DIGEST: ${{ steps.production-arm64.outputs.digest }}
IMAGE: ghcr.io/${{ github.repository }}
TAGS: ${{ steps.production-meta.outputs.tags }}
run: |
while IFS= read -r tag; do
[ -n "$tag" ] || continue
echo "Response: $body"
echo "HTTP Status: $http_code"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
echo "Deployment triggered successfully!"
exit 0
fi
echo "Deployment request failed with status $http_code"
if [ $attempt -lt $max_attempts ]; then
delay=$((attempt * 10))
echo "Retrying in ${delay}s..."
sleep $delay
fi
attempt=$((attempt + 1))
done
echo "All $max_attempts attempts failed. Giving up."
exit 1
docker buildx imagetools create \
--tag "$tag" \
"$IMAGE@$AMD64_DIGEST" \
"$IMAGE@$ARM64_DIGEST"
done <<< "$TAGS"

86
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,86 @@
name: Release
on:
schedule:
- cron: "0 8 * * 1" # Mondays 08:00 UTC = 10:00 Madrid (CEST) / 09:00 (CET)
workflow_dispatch:
inputs:
increment:
description: "Version increment"
required: true
default: patch
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Check for releasable commits
id: guard
run: |
LAST=$(git describe --tags --abbrev=0)
if [ -z "$(git log "$LAST"..HEAD --oneline)" ]; then
echo "No commits since $LAST; skipping release."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Create release branch
id: branch
if: steps.guard.outputs.skip != 'true'
run: |
BRANCH="release/run-${{ github.run_id }}"
git checkout -b "$BRANCH"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Run release-it
if: steps.guard.outputs.skip != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx release-it "${{ inputs.increment || 'patch' }}" --ci
- name: Read new version
id: version
if: steps.guard.outputs.skip != 'true'
run: echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Open pull request
if: steps.guard.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr create \
--base main \
--head "${{ steps.branch.outputs.name }}" \
--title "chore: release v${{ steps.version.outputs.value }}" \
--body "Automated release PR for **v${{ steps.version.outputs.value }}**.\n\nTag \`v${{ steps.version.outputs.value }}\` and the GitHub release have already been published. Merge this PR to land the version bump and CHANGELOG on \`main\`."

5
.gitignore vendored
View File

@ -1,6 +1,7 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
node_modules/
/public/build
/public/hot
/public/storage
@ -28,7 +29,7 @@ yarn-error.log
/.vscode
/.zed
.php-cs-fixer.cache
/docker/caddy/certs/*.pem
/docker/caddy/certs/*-key.pem
.claude/settings.local.json
.php-version
.pint.cache
/.playwright-mcp

View File

@ -6,6 +6,10 @@
"artisan",
"boost:mcp"
]
},
"sentry": {
"type": "http",
"url": "https://mcp.sentry.dev/mcp"
}
}
}

1
.pi/extensions/mcps.ts Normal file
View File

@ -0,0 +1 @@
export { default } from './mcps/main.ts';

1136
.pi/extensions/mcps/main.ts Normal file

File diff suppressed because it is too large Load Diff

1145
.pi/extensions/mcps/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
{
"name": "pi-mcp-bridge",
"private": true,
"description": "Project-local Pi extension that loads MCP servers from .mcp.json or opencode.json",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@sinclair/typebox": "^0.34.49",
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,68 @@
---
description: Pick or fix a Sentry issue end-to-end, then PR and watch CI
argument-hint: "[issue-id-or-url]"
---
Run Sentry issue repair workflow for this project only.
Input: `$ARGUMENTS`
Goal:
1. Use provided Sentry issue id or URL. If none provided, choose the highest-impact unresolved issue using frequency, affected users, recency, and production impact.
2. Create/switch to git branch named exactly after the Sentry short issue id.
3. Investigate root cause, implement fix, and add/update tests.
4. Open PR and watch CI until green.
Rules:
- Protect local work. Start with `git status --short`. If uncommitted changes exist, stop and ask before branching.
- Never expose secrets. Do not print tokens, `.env`, Sentry auth, DB URLs, or PII.
- Use the Sentry CLI (`sentry`) first. Prefer stored OAuth login over env build tokens; if `SENTRY_AUTH_TOKEN` is invalid or too narrow, run CLI commands as `env -u SENTRY_AUTH_TOKEN sentry ...` unless `SENTRY_FORCE_ENV_TOKEN=1` is intentionally set.
- Never print raw Sentry JSON that may contain PII. Redact emails, user IDs when summarizing. Keep secrets out of output.
- For Laravel ecosystem changes, use `application-info` and `search-docs` before code changes.
- Activate/read relevant project skills when touched: Pest tests, Inertia React, Wayfinder, Tailwind, Fortify, Pennant.
- Every code change needs programmatic verification. Add or update a focused Pest/test when feasible. Run minimum affected tests. Run `vendor/bin/pint --dirty --format agent` after PHP edits.
- Prefer small surgical fix. No dependency changes without approval.
Workflow:
1. Identify issue:
- Confirm auth and org/project access with `sentry auth status`, `sentry org list --json`, and `sentry project list <org> --json` when needed.
- If `$ARGUMENTS` is a Sentry URL, extract `/issues/<numeric-id>/` or the visible short issue id, then fetch it with `sentry issue view <issue> --json --fields id,shortId,title,culprit,permalink,level,status,substatus,count,userCount,firstSeen,lastSeen,project,metadata,priority,platform,isUnhandled`.
- If `$ARGUMENTS` is a bare issue id or short id, fetch it with `sentry issue view <issue> --json --fields id,shortId,title,culprit,permalink,level,status,substatus,count,userCount,firstSeen,lastSeen,project,metadata,priority,platform,isUnhandled`.
- If no args, list unresolved production issues with both frequency and user sorting, then compare top results:
- `sentry issue list <org>/<project> --query 'is:unresolved environment:production' --sort freq --limit 25 --json --fields id,shortId,title,culprit,count,userCount,lastSeen,permalink,priority,metadata,project`
- `sentry issue list <org>/<project> --query 'is:unresolved environment:production' --sort user --limit 25 --json --fields id,shortId,title,culprit,count,userCount,lastSeen,permalink,priority,metadata,project`
- Pick best impact score: high event count, high user count, recent lastSeen, production environment, clear actionable stack.
- Record chosen short issue id and Sentry URL in notes.
2. Branch:
- Derive branch name from Sentry short issue id only, e.g. `WHISPER-MONEY-123`.
- Run `git switch -c <issue-id>`; if branch exists, `git switch <issue-id>`.
3. Investigate:
- Fetch latest issue details and spans with `sentry issue view <issue> --spans all --json`.
- Fetch recent events with `sentry issue events <issue> --limit 10 --full --json`.
- Extract stack trace, breadcrumbs, environment, release, tags, URLs/routes, affected users count, trace/span data, and suspect queries. Redact PII in notes.
- For event details if needed, use `sentry event view <event-id> --json` or `sentry api <endpoint> --json`.
- Reproduce locally using tests or focused command. Inspect app logs/browser logs if relevant.
- If root cause not obvious after issue/event data, run Seer with `sentry issue explain <issue> --json` and/or `sentry issue plan <issue> --json`.
4. Fix:
- Read nearby code and conventions first.
- Implement minimal fix.
- Add regression test covering Sentry failure path.
5. Verify:
- Run targeted test command, e.g. `php artisan test --compact --filter=<test-or-class>`.
- Run lint/format commands required by touched files.
- If failures occur, fix and rerun until pass.
6. PR:
- Commit changes with concise conventional commit.
- Push branch.
- Create PR with `gh pr create`, including Sentry issue link, root cause, fix summary, and verification commands.
- Get PR number from `gh pr view --json number --jq .number` if needed.
- Watch CI every 10 seconds with `gh pr checks <number> --watch --fail-fast --interval 10`.
- If CI fails, inspect logs, fix, commit/push, and watch again until green.
Output when done:
- Issue id + Sentry URL
- Branch name
- Root cause
- Fix summary
- Tests/commands run
- PR URL
- CI status

View File

@ -1,7 +1,13 @@
{
"hooks": {
"after:bump": "node scripts/enrich-changelog.js && git add CHANGELOG.md"
},
"git": {
"commitMessage": "chore: release v${version}",
"tagName": "v${version}"
"tagName": "v${version}",
"requireBranch": false,
"requireCleanWorkingDir": true,
"push": true
},
"github": {
"release": true,

View File

@ -1,3 +1,12 @@
Terse like caveman. Technical substance exact. Only fluff die.
Drop: articles, filler (just/really/basically), pleasantries, hedging.
Fragments OK. Short synonyms. Code unchanged.
Pattern: [thing] [action] [reason]. [next step].
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift.
Code/commits/PRs: normal. Off: "stop caveman" / "normal mode".
- After creating a PR use `gh pr checks 123 --watch --fail-fast` to check CI every 10 sec, if something fails, fix it, and update the pr.
<laravel-boost-guidelines>
=== foundation rules ===
@ -9,14 +18,15 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.17
- php - 8.4
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/framework (LARAVEL) - v13
- laravel/pennant (PENNANT) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/wayfinder (WAYFINDER) - v0
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
@ -35,12 +45,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pennant-development` — Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features.
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems.
- `wayfinder-development` — Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions.
- `pest-testing`Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using &lt;Link&gt;, &lt;Form&gt;, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
- `tailwindcss-development`Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
- `pest-testing`Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
- `tailwindcss-development`Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `fortify-development` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
## Conventions
@ -75,19 +86,23 @@ This project has domain-specific skills available. You MUST activate the relevan
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
## Artisan Commands
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`).
- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
## Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly.
- To read configuration values, read the config files directly or run `php artisan config:show [key]`.
- To inspect routes, run `php artisan route:list` directly.
- To check environment variables, read the `.env` file directly.
## Reading Browser Logs With the `browser-logs` Tool
@ -153,7 +168,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== inertia-laravel/v2 rules ===
=== inertia-laravel/core rules ===
# Inertia
@ -165,14 +180,14 @@ protected function isAccessible(User $user, ?string $path = null): bool
# Inertia v2
- Use all Inertia features from v1 and v2. Check the documentation before making changes to ensure the correct approach.
- New features: deferred props, infinite scrolling (merging props + `WhenVisible`), lazy loading on scroll, polling, prefetching.
- New features: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
- When using deferred props, add an empty state with a pulsing or animated skeleton.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
@ -186,7 +201,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
### APIs & Eloquent Resources
@ -223,39 +238,6 @@ protected function isAccessible(User $user, ?string $path = null): bool
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pennant/core rules ===
# Laravel Pennant
- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
- IMPORTANT: Always use `search-docs` tool for version-specific Pennant documentation and updated code examples.
- IMPORTANT: Activate `pennant-development` every time you're working with a Pennant or feature-flag-related task.
=== wayfinder/core rules ===
# Laravel Wayfinder
@ -282,8 +264,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== inertia-react/core rules ===
@ -291,14 +271,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- IMPORTANT: Activate `inertia-react-development` when working with Inertia React client-side patterns.
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
=== laravel/fortify rules ===
# Laravel Fortify

View File

@ -1,5 +1,400 @@
# Changelog
## [0.2.6](https://github.com/whisper-money/whisper-money/compare/v0.2.5...v0.2.6) (2026-07-06)
### Bug Fixes
* **accounts:** fire drag haptic on first tap, not after dragging ([#576](https://github.com/whisper-money/whisper-money/issues/576)) ([c75e834](https://github.com/whisper-money/whisper-money/commit/c75e834b89b58f19fba0021e423ef5d7fe8ae827))
* **accounts:** remove double-skeleton flash on account show ([#634](https://github.com/whisper-money/whisper-money/issues/634)) ([afa80b6](https://github.com/whisper-money/whisper-money/commit/afa80b60fe97ecc504847ef9c4a1e8c9a9c2c9f1)), closes [#632](https://github.com/whisper-money/whisper-money/issues/632)
* **accounts:** stop second long-press haptic on drag handle ([#578](https://github.com/whisper-money/whisper-money/issues/578)) ([5db6cdc](https://github.com/whisper-money/whisper-money/commit/5db6cdc6d2fcc03d3d89d16d058834cb89107999)), closes [#576](https://github.com/whisper-money/whisper-money/issues/576)
* address remaining security audit findings (round 2) ([#628](https://github.com/whisper-money/whisper-money/issues/628)) ([4fdb7ee](https://github.com/whisper-money/whisper-money/commit/4fdb7eebd916f81bfde7dc4c6adbc8387d6699ca)), closes [#623](https://github.com/whisper-money/whisper-money/issues/623)
* **ai:** handle transient AI provider overloads — stop the Sentry noise and retry the dropped work ([#595](https://github.com/whisper-money/whisper-money/issues/595)) ([4038e60](https://github.com/whisper-money/whisper-money/commit/4038e60fbcd9891ceecabe5f66c34dde1abcc6cd))
* **ai:** surface learned-rule toast in edit modal and guard weak description keys ([#635](https://github.com/whisper-money/whisper-money/issues/635)) ([c159e87](https://github.com/whisper-money/whisper-money/commit/c159e8782efa5808e1f707eb7309966de3144f9b))
* **analysis:** respect category types like the cashflow screen ([#612](https://github.com/whisper-money/whisper-money/issues/612)) ([986f437](https://github.com/whisper-money/whisper-money/commit/986f43705a35fb0e3cd49c2056c2a46e769a91aa))
* **appearance:** support MediaQueryList change events on legacy Safari (PHP-LARAVEL-41) ([#646](https://github.com/whisper-money/whisper-money/issues/646)) ([465eb38](https://github.com/whisper-money/whisper-money/commit/465eb38dae1e856f0017f7f0e33bbad31b1d2c71))
* **banking:** handle EnableBanking expired sessions as reconnect, not error ([#557](https://github.com/whisper-money/whisper-money/issues/557)) ([c36df98](https://github.com/whisper-money/whisper-money/commit/c36df98d326336c27cf63039eb28518dae3af43e))
* **banking:** keep the native green Wise logo, not the aggregator's ([#590](https://github.com/whisper-money/whisper-money/issues/590)) ([578a9b4](https://github.com/whisper-money/whisper-money/commit/578a9b44d8f01d4d22558397af2f31a9e5bf80b8)), closes [#589](https://github.com/whisper-money/whisper-money/issues/589) [#525](https://github.com/whisper-money/whisper-money/issues/525) [#589](https://github.com/whisper-money/whisper-money/issues/589)
* **banking:** only log sync failures once the connection gives up ([#603](https://github.com/whisper-money/whisper-money/issues/603)) ([8bbff05](https://github.com/whisper-money/whisper-money/commit/8bbff05b2693cef3edbc8fc4c9350f6ba7ab99d6))
* **banking:** skip inaccessible EnableBanking accounts instead of failing the connection ([#559](https://github.com/whisper-money/whisper-money/issues/559)) ([4656870](https://github.com/whisper-money/whisper-money/commit/46568700b2c0610566a7a35efe54810ea51e3925))
* **banking:** stop Wise appearing multiple times in the connect list ([#589](https://github.com/whisper-money/whisper-money/issues/589)) ([ed5aac0](https://github.com/whisper-money/whisper-money/commit/ed5aac0c4a0713e5bf2b0f2a82b9258abdcdb986))
* **charts:** improve contrast for chart colors 9-10 ([#614](https://github.com/whisper-money/whisper-money/issues/614)) ([a37481f](https://github.com/whisper-money/whisper-money/commit/a37481fb71b935f0f42b282fabe6db3c664be5c6)), closes [hi#index](https://github.com/hi/issues/index)
* **discord:** show old → new plan on plan change notification ([#637](https://github.com/whisper-money/whisper-money/issues/637)) ([3972007](https://github.com/whisper-money/whisper-money/commit/39720078447f9b17dacbfd15e9986f978b87b56a))
* **discord:** skip zero-amount payment stats messages ([#540](https://github.com/whisper-money/whisper-money/issues/540)) ([7693e48](https://github.com/whisper-money/whisper-money/commit/7693e4813f810c21836b292770590ec511224109))
* **i18n:** keep Discord brand name untranslated in French ([#541](https://github.com/whisper-money/whisper-money/issues/541)) ([06effb5](https://github.com/whisper-money/whisper-money/commit/06effb5e6ef2311657c5beaf9ec57918739eb38f))
* **integration-requests:** freeze votes on not-doable requests ([#555](https://github.com/whisper-money/whisper-money/issues/555)) ([89c1ab1](https://github.com/whisper-money/whisper-money/commit/89c1ab1ca8edf4afcab183e11d0b6fc7ab095952))
* **open-banking:** only block re-adding a bank when a live connection exists ([#569](https://github.com/whisper-money/whisper-money/issues/569)) ([14c4598](https://github.com/whisper-money/whisper-money/commit/14c4598cda6989017af9dd8551791d5a2c456a9d))
* **open-banking:** stop storing the XXX no-currency placeholder on accounts ([#602](https://github.com/whisper-money/whisper-money/issues/602)) ([bc57eae](https://github.com/whisper-money/whisper-money/commit/bc57eae5c34cdf37beb7a82b5569fb50d12e3ab7))
* **queue:** add supervisor worker for the ai queue ([#546](https://github.com/whisper-money/whisper-money/issues/546)) ([f191d74](https://github.com/whisper-money/whisper-money/commit/f191d740314836ae12b897b6e9f5151ecc39e15f))
* **queue:** raise retry_after above the longest job timeout (PHP-LARAVEL-2D) ([#645](https://github.com/whisper-money/whisper-money/issues/645)) ([05d4bae](https://github.com/whisper-money/whisper-money/commit/05d4bae0af1fece9196fabde61a624cf19472da9))
* **security:** scope job-status endpoints to owner + feature-area fixes ([#627](https://github.com/whisper-money/whisper-money/issues/627)) ([d55c3f4](https://github.com/whisper-money/whisper-money/commit/d55c3f41df9705fe533d13f3fa9da0cf7790cb4a)), closes [#4](https://github.com/whisper-money/whisper-money/issues/4) [#1](https://github.com/whisper-money/whisper-money/issues/1)
* **sentry:** drop browser-extension noise before sending events ([#568](https://github.com/whisper-money/whisper-money/issues/568)) ([52708f9](https://github.com/whisper-money/whisper-money/commit/52708f940df2a86bc1bf47afe72af08abd4e345f))
* **settings:** vertically center rows in automation rules and labels tables ([#615](https://github.com/whisper-money/whisper-money/issues/615)) ([e631cbb](https://github.com/whisper-money/whisper-money/commit/e631cbba69d8184f5efbb4c65d198a836a9ab883))
* skip demo reset when demo account is disabled ([#626](https://github.com/whisper-money/whisper-money/issues/626)) ([eb31455](https://github.com/whisper-money/whisper-money/commit/eb31455e606f8e0b965b6b3cd83d4e2c9de34df5))
* stop double-dispatching transaction listeners (N+1 insert into jobs) ([#620](https://github.com/whisper-money/whisper-money/issues/620)) ([0f8eca5](https://github.com/whisper-money/whisper-money/commit/0f8eca50d036aca21142fac39ca5ed385d2dbada))
* **transactions:** let date column size to its content ([#610](https://github.com/whisper-money/whisper-money/issues/610)) ([5ef3e01](https://github.com/whisper-money/whisper-money/commit/5ef3e01c8905795ca29600bab3f62578ffe0673d))
* **transactions:** only auto-select account on account pages ([#549](https://github.com/whisper-money/whisper-money/issues/549)) ([9a20335](https://github.com/whisper-money/whisper-money/commit/9a20335c6a4a7fa814f1460afbf8384888ee2e88))
* **transactions:** pad Category column when Date column is hidden ([#584](https://github.com/whisper-money/whisper-money/issues/584)) ([d2806b5](https://github.com/whisper-money/whisper-money/commit/d2806b5887753aca2d7ccce8b2360107541890bc)), closes [#583](https://github.com/whisper-money/whisper-money/issues/583) [#582](https://github.com/whisper-money/whisper-money/issues/582)
* **transactions:** pad Category column when Date column is hidden ([#585](https://github.com/whisper-money/whisper-money/issues/585)) ([8e38713](https://github.com/whisper-money/whisper-money/commit/8e3871370ae17e2b582effdb72218ba2ed65b40a)), closes [582/#583](https://github.com/whisper-money/whisper-money/issues/583)
* **transactions:** restore left padding when category is first column ([#582](https://github.com/whisper-money/whisper-money/issues/582)) ([d11aa2d](https://github.com/whisper-money/whisper-money/commit/d11aa2dfe579e0a9af27909d51ac776f975b3073))
* **ui:** make input borders visible in dark mode ([#571](https://github.com/whisper-money/whisper-money/issues/571)) ([83a5e96](https://github.com/whisper-money/whisper-money/commit/83a5e9657e679d0e849c9f5f532a021dce9807ff))
* **worktree:** remove double slash in storage/keys copy path ([#629](https://github.com/whisper-money/whisper-money/issues/629)) ([4615d7a](https://github.com/whisper-money/whisper-money/commit/4615d7a88002f2b3c7df847a27f783a710a69cc6))
### Features
* **accounts:** reorder accounts with drag-and-drop ([#575](https://github.com/whisper-money/whisper-money/issues/575)) ([cd3080e](https://github.com/whisper-money/whisper-money/commit/cd3080ec52fd2586f9920794559c7b500cbef6bf))
* add Wise open banking integration with balance sync ([#525](https://github.com/whisper-money/whisper-money/issues/525)) ([1c5a76a](https://github.com/whisper-money/whisper-money/commit/1c5a76a3a4cc9173d7ba2a7f8e3f67fc303e30d4))
* **ai:** dismissable AI consent banner that stops after the first decision ([#617](https://github.com/whisper-money/whisper-money/issues/617)) ([7e36bba](https://github.com/whisper-money/whisper-money/commit/7e36bbafef8faa076b25dc70451b7165b000349a))
* **ai:** learn from category corrections so the AI stops repeating the same mistake ([#608](https://github.com/whisper-money/whisper-money/issues/608)) ([6727a9c](https://github.com/whisper-money/whisper-money/commit/6727a9c64a7083ac962e4d489a36c61f4c4e590f))
* **ai:** manage AI consent outside onboarding with live backfill ([#591](https://github.com/whisper-money/whisper-money/issues/591)) ([9a458b1](https://github.com/whisper-money/whisper-money/commit/9a458b103131a0b848bdc17b5d015c923a30d71e))
* **ai:** persist AI categorization suggestions below the label bar ([#547](https://github.com/whisper-money/whisper-money/issues/547)) ([9328cd3](https://github.com/whisper-money/whisper-money/commit/9328cd3e1ba53218b521d6501d8750bb483a2ea6))
* **ai:** record the model behind each AI categorization ([#594](https://github.com/whisper-money/whisper-money/issues/594)) ([291cfbe](https://github.com/whisper-money/whisper-money/commit/291cfbe2612a3682f913216a64ebc76c699e55a2))
* **banking:** add Interactive Brokers sync via Flex Web Service ([#581](https://github.com/whisper-money/whisper-money/issues/581)) ([f60e6d7](https://github.com/whisper-money/whisper-money/commit/f60e6d70355d77b05ff4cad690cea65c1eb5792d))
* **banking:** enable Interactive Brokers for all users ([#593](https://github.com/whisper-money/whisper-money/issues/593)) ([094ff4d](https://github.com/whisper-money/whisper-money/commit/094ff4d5ac1e4ebab787056b8794654ac49f0cd8))
* **banking:** let Wise credentials be updated ([#588](https://github.com/whisper-money/whisper-money/issues/588)) ([619ed0f](https://github.com/whisper-money/whisper-money/commit/619ed0f1db44f004b8e4704e2a4e1bbef0e56a74)), closes [#581](https://github.com/whisper-money/whisper-money/issues/581)
* **connections:** create a new account from the manage-accounts selector ([#560](https://github.com/whisper-money/whisper-money/issues/560)) ([6cb8d11](https://github.com/whisper-money/whisper-money/commit/6cb8d115631d41d01f0dee025092decc4c31e01c))
* **connections:** manage which accounts a bank connection syncs ([#558](https://github.com/whisper-money/whisper-money/issues/558)) ([a9b90a2](https://github.com/whisper-money/whisper-money/commit/a9b90a200efc66d85e20405872a857db829255cf))
* **currencies:** add Nigerian Naira (NGN) ([#642](https://github.com/whisper-money/whisper-money/issues/642)) ([6ff7edf](https://github.com/whisper-money/whisper-money/commit/6ff7edf193bfb9baf51b4907a9fe0da49a95f533))
* **currency:** add GHS (Ghanaian Cedi) ([#644](https://github.com/whisper-money/whisper-money/issues/644)) ([2aebe45](https://github.com/whisper-money/whisper-money/commit/2aebe45d1f5a481760fd2c36ec98436b29d1c510)), closes [#567](https://github.com/whisper-money/whisper-money/issues/567)
* **currency:** add RSD (Serbian Dinar) ([#567](https://github.com/whisper-money/whisper-money/issues/567)) ([934e16c](https://github.com/whisper-money/whisper-money/commit/934e16c0fa2659a7e701d6cfcc23cbaad67d0c13))
* **dashboard:** add accounts manager dialog with visibility toggle and reorder ([#604](https://github.com/whisper-money/whisper-money/issues/604)) ([777dfc0](https://github.com/whisper-money/whisper-money/commit/777dfc07b281a5df522fc93154b73e6d7c72d2ef))
* **demo:** gate demo account access behind a config flag ([#580](https://github.com/whisper-money/whisper-money/issues/580)) ([a346566](https://github.com/whisper-money/whisper-money/commit/a346566fd0a6398bb7e9b146617f88d0d218a35f))
* **drip:** email users stuck on the paywall a day after onboarding ([#562](https://github.com/whisper-money/whisper-money/issues/562)) ([ce6bfc9](https://github.com/whisper-money/whisper-money/commit/ce6bfc9c562195c7dc89028fe3a920a814ff1b7a))
* **email:** follow up after post-onboarding AI consent ([#596](https://github.com/whisper-money/whisper-money/issues/596)) ([934d834](https://github.com/whisper-money/whisper-money/commit/934d834ab3dd19d66525fbd883d30de1b3d77982))
* **encryption:** commands to warn and remove inactive encrypted-data accounts ([#633](https://github.com/whisper-money/whisper-money/issues/633)) ([477e4d5](https://github.com/whisper-money/whisper-money/commit/477e4d50e26760d387dee1784db7093c05ce593e))
* **features:** support percentage rollouts in feature:enable ([#592](https://github.com/whisper-money/whisper-money/issues/592)) ([f72e2a6](https://github.com/whisper-money/whisper-money/commit/f72e2a64ca1101a04ddc3c3384f5f7c75aed8e5d))
* **i18n:** add French translation support ([#532](https://github.com/whisper-money/whisper-money/issues/532)) ([a38ed69](https://github.com/whisper-money/whisper-money/commit/a38ed69d2e134651ceddae5082fd2d70493b83e0))
* **integration-requests:** add done status and fix review command crash on orphaned author ([#601](https://github.com/whisper-money/whisper-money/issues/601)) ([e4be39b](https://github.com/whisper-money/whisper-money/commit/e4be39be1201b54894097e9580958d7cd23c4740))
* **integration-requests:** add not-doable status with a public comment ([#552](https://github.com/whisper-money/whisper-money/issues/552)) ([801f6c7](https://github.com/whisper-money/whisper-money/commit/801f6c7cd4834eeecbe77a078994c89845003a70))
* **integration-requests:** community board to request & vote bank integrations ([#550](https://github.com/whisper-money/whisper-money/issues/550)) ([5e8f227](https://github.com/whisper-money/whisper-money/commit/5e8f227fbdabbd15fd752645e7a1405803f032d9))
* **integration-requests:** let the admin bypass limits and auto-approve ([#551](https://github.com/whisper-money/whisper-money/issues/551)) ([7e9122e](https://github.com/whisper-money/whisper-money/commit/7e9122eaf442f0ea4cd2f2eecf1dfea1c8e7f7fd))
* **integration-requests:** markdown comments and in-progress status ([#553](https://github.com/whisper-money/whisper-money/issues/553)) ([da88adb](https://github.com/whisper-money/whisper-money/commit/da88adbee36c899fa24342d0b62c0cf1063df689))
* **integration-requests:** multi-vote, per-plan quota and undo ([#554](https://github.com/whisper-money/whisper-money/issues/554)) ([0ea54fa](https://github.com/whisper-money/whisper-money/commit/0ea54fa0d75dd268c7cb5e59f1da95a9a22976ee))
* **landing:** clarify AI framing and add testimonials ([#613](https://github.com/whisper-money/whisper-money/issues/613)) ([d55e15b](https://github.com/whisper-money/whisper-money/commit/d55e15bb4faabf34e4f1f92022db4a17fe085dec))
* **onboarding:** auto-enable AI for connected banks, ask the rest ([#618](https://github.com/whisper-money/whisper-money/issues/618)) ([10442c1](https://github.com/whisper-money/whisper-money/commit/10442c1e3293270dcc75a299414b793c5db14610))
* **onboarding:** clarify the "categorize at least 5" goal in the categorizer ([#616](https://github.com/whisper-money/whisper-money/issues/616)) ([af64f56](https://github.com/whisper-money/whisper-money/commit/af64f563991897723c69749ac69bf724b162f44c))
* **open-banking:** allow re-connecting a bank behind a replace warning ([#570](https://github.com/whisper-money/whisper-money/issues/570)) ([64827fa](https://github.com/whisper-money/whisper-money/commit/64827fabae82cadd98febdd457dcf0106934cc18)), closes [#569](https://github.com/whisper-money/whisper-money/issues/569)
* **open-banking:** disable already-connected banks in the connect picker ([#556](https://github.com/whisper-money/whisper-money/issues/556)) ([6e6433c](https://github.com/whisper-money/whisper-money/commit/6e6433c6ad8c6673edc5f263a76096bc4377da96))
* **open-banking:** enable manage bank accounts for everyone ([#572](https://github.com/whisper-money/whisper-money/issues/572)) ([0f3cdd4](https://github.com/whisper-money/whisper-money/commit/0f3cdd41aaa72a9544202a3d5add2515cf5ac6ab))
* **paywall:** require a plan when the user has accepted AI ([#564](https://github.com/whisper-money/whisper-money/issues/564)) ([29d13ce](https://github.com/whisper-money/whisper-money/commit/29d13ceed103860399b271ffed3cb17810112001))
* **stats:** add --no-discord flag to stats:experiment-funnel ([#606](https://github.com/whisper-money/whisper-money/issues/606)) ([1db2871](https://github.com/whisper-money/whisper-money/commit/1db2871398bd86deb0a3c48ba1f1881822e016cc))
* **stats:** add --no-discord to the remaining report commands ([#607](https://github.com/whisper-money/whisper-money/issues/607)) ([300756e](https://github.com/whisper-money/whisper-money/commit/300756e55341b84245efbbd37a038ae7edb7217b))
* **stats:** add weekly subscription funnel report ([#599](https://github.com/whisper-money/whisper-money/issues/599)) ([756b481](https://github.com/whisper-money/whisper-money/commit/756b4816a6d67922a15cf6b69e9933f8f1bea2c8))
* **stats:** weekly paywall stuck-cohort report to Discord ([#563](https://github.com/whisper-money/whisper-money/issues/563)) ([57f8c93](https://github.com/whisper-money/whisper-money/commit/57f8c93e2818ad53abcd8d1ad656aeb1f1edda4d))
* **subscriptions:** reframe pay_now paywall copy around try-and-refund ([#605](https://github.com/whisper-money/whisper-money/issues/605)) ([09d6e8e](https://github.com/whisper-money/whisper-money/commit/09d6e8ee6cfe6247439bfb3bfd475ab1a092e722))
* **subscriptions:** trial/pricing A/B/C experiment ([#600](https://github.com/whisper-money/whisper-money/issues/600)) ([e5350ff](https://github.com/whisper-money/whisper-money/commit/e5350ff1a6061ee3bdb9596e3b6e925618e39e3e))
* **support:** add support link with community-first help modal ([#542](https://github.com/whisper-money/whisper-money/issues/542)) ([4a891a5](https://github.com/whisper-money/whisper-money/commit/4a891a5906d57f17bbf0aeeec957656e7e67f937))
* **transactions:** default balance toggle on and apply it server-side ([#566](https://github.com/whisper-money/whisper-money/issues/566)) ([b76a0de](https://github.com/whisper-money/whisper-money/commit/b76a0de0746e334835c84e7aef96140e8ef7d00f))
* **transactions:** highlight new transactions since last visit ([#609](https://github.com/whisper-money/whisper-money/issues/609)) ([884038c](https://github.com/whisper-money/whisper-money/commit/884038c13bd093ca5ec1f6a656af40453f085efc))
* **transactions:** make new-transaction marker cross-device ([#611](https://github.com/whisper-money/whisper-money/issues/611)) ([ee69c51](https://github.com/whisper-money/whisper-money/commit/ee69c51a846a944632e6c23e098014f768229310)), closes [#609](https://github.com/whisper-money/whisper-money/issues/609)
* **transactions:** refine new transaction form layout and balance toggle ([#597](https://github.com/whisper-money/whisper-money/issues/597)) ([d6ec983](https://github.com/whisper-money/whisper-money/commit/d6ec9830dff95b1fa869b57604ccc892e97113bb))
* **transactions:** release transaction analysis to all users ([#579](https://github.com/whisper-money/whisper-money/issues/579)) ([ae6f869](https://github.com/whisper-money/whisper-money/commit/ae6f8696118cc9d4808a8ee9270de2b25850dd70))
* **transactions:** reorder filters and switch accounts to a logo dropdown ([#598](https://github.com/whisper-money/whisper-money/issues/598)) ([d7bc4e6](https://github.com/whisper-money/whisper-money/commit/d7bc4e6707a802044b5f931c54cefca23dcbeebc))
* **transactions:** serve import dedup and account ledger from the backend ([#631](https://github.com/whisper-money/whisper-money/issues/631)) ([021cb66](https://github.com/whisper-money/whisper-money/commit/021cb6664311ec475e87b628ca7a88baf8de4907))
* **welcome:** add Francisco Montes testimonial ([#636](https://github.com/whisper-money/whisper-money/issues/636)) ([02087ab](https://github.com/whisper-money/whisper-money/commit/02087abcc7c7e9f7c210ae922c7813206db6093e))
* **welcome:** add Haru testimonial with Discord avatar ([#577](https://github.com/whisper-money/whisper-money/issues/577)) ([b0e74fa](https://github.com/whisper-money/whisper-money/commit/b0e74fac2c629078d4d88b9a2365d343571cc0e6))
### Performance Improvements
* **accounts:** defer the account ledger prop on show ([#632](https://github.com/whisper-money/whisper-money/issues/632)) ([9326d8f](https://github.com/whisper-money/whisper-money/commit/9326d8fd2fd3c9739cffa7d0f2f384fa49a623c1)), closes [#631](https://github.com/whisper-money/whisper-money/issues/631) [#631](https://github.com/whisper-money/whisper-money/issues/631) [#631](https://github.com/whisper-money/whisper-money/issues/631)
* **ai:** reduce N+1 in bulk category updates (PHP-LARAVEL-40, partial) ([#624](https://github.com/whisper-money/whisper-money/issues/624)) ([3d3f6da](https://github.com/whisper-money/whisper-money/commit/3d3f6daa77c130e5a91547d9426189a1e820cad5)), closes [#2](https://github.com/whisper-money/whisper-money/issues/2)
* **banking:** kill per-transaction dedup N+1 in bank sync (PHP-LARAVEL-3Y) ([#621](https://github.com/whisper-money/whisper-money/issues/621)) ([84bad76](https://github.com/whisper-money/whisper-money/commit/84bad76316425616899f03157fa8246144635288))
* **db:** index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) ([#622](https://github.com/whisper-money/whisper-money/issues/622)) ([ad46e46](https://github.com/whisper-money/whisper-money/commit/ad46e465be3cb2d3a29726a7c4cc2f822ecf67a3))
## [0.2.5](https://github.com/whisper-money/whisper-money/compare/v0.2.4...v0.2.5) (2026-06-15)
### Bug Fixes
* **account:** block deletion while subscription or trial is active ([#531](https://github.com/whisper-money/whisper-money/issues/531)) ([2bfb569](https://github.com/whisper-money/whisper-money/commit/2bfb569a2226df44b71363e731889ab07a2a41c4)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** align summary card amounts to a common baseline ([#515](https://github.com/whisper-money/whisper-money/issues/515)) ([21f8f3b](https://github.com/whisper-money/whisper-money/commit/21f8f3b27719f3ff47b4769e5fdda00c323fe22d)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** truncate long breakdown names so amounts stay in widget ([#518](https://github.com/whisper-money/whisper-money/issues/518)) ([49acc8a](https://github.com/whisper-money/whisper-money/commit/49acc8a884617fc8a97e7b82e7dcedb5931a20b4)) by [@victor-falcon](https://github.com/victor-falcon)
* **auth:** prevent FormData crash on successful login ([#503](https://github.com/whisper-money/whisper-money/issues/503)) ([f4bbbfd](https://github.com/whisper-money/whisper-money/commit/f4bbbfd767388642b856b53814187b9c85e4ac22)) by [@victor-falcon](https://github.com/victor-falcon)
* **automation-rules:** hint amount sign for expenses vs income ([#524](https://github.com/whisper-money/whisper-money/issues/524)) ([e526f86](https://github.com/whisper-money/whisper-money/commit/e526f861b2a7f46a073273482f10111f06e44f84)) by [@victor-falcon](https://github.com/victor-falcon)
* **budgets:** make period generation idempotent ([#533](https://github.com/whisper-money/whisper-money/issues/533)) ([cd323bb](https://github.com/whisper-money/whisper-money/commit/cd323bbe529678e68bca23e84a9cdb0d78c33373)) by [@victor-falcon](https://github.com/victor-falcon)
* **cashflow:** bound trend window to prevent request timeout ([#534](https://github.com/whisper-money/whisper-money/issues/534)) ([1d4bcd5](https://github.com/whisper-money/whisper-money/commit/1d4bcd5082413071ddcc2764ab866e23ea73ab5a)) by [@victor-falcon](https://github.com/victor-falcon)
* **currency:** make rate fetching resilient to slow CDN ([#502](https://github.com/whisper-money/whisper-money/issues/502)) ([4b90bcf](https://github.com/whisper-money/whisper-money/commit/4b90bcfc96b4dd6340981d53f2e665d02872288f)) by [@victor-falcon](https://github.com/victor-falcon)
* **header:** keep mobile logo on one line, compact auth buttons ([#512](https://github.com/whisper-money/whisper-money/issues/512)) ([1530544](https://github.com/whisper-money/whisper-money/commit/1530544b8b3322f90829991df3d41d06e43e54a8)) by [@victor-falcon](https://github.com/victor-falcon)
* **import:** honor selected date format for CSV imports ([#494](https://github.com/whisper-money/whisper-money/issues/494)) ([744d874](https://github.com/whisper-money/whisper-money/commit/744d874464b803b4f9a187347cb833c9440a62d1)) by [@victor-falcon](https://github.com/victor-falcon)
* **layout:** keep bottom padding while floating nav is visible ([#537](https://github.com/whisper-money/whisper-money/issues/537)) ([6671d89](https://github.com/whisper-money/whisper-money/commit/6671d89ea10638983a5ba10a718ea16dde982697)) by [@victor-falcon](https://github.com/victor-falcon)
* **perf:** batch feature flag resolution in shared Inertia data ([#500](https://github.com/whisper-money/whisper-money/issues/500)) ([cb728ce](https://github.com/whisper-money/whisper-money/commit/cb728ce176fbac50984c183abae8af19acf1c8e7)) by [@victor-falcon](https://github.com/victor-falcon), closes [#496](https://github.com/whisper-money/whisper-money/issues/496)
* **sentry:** only report errors in production ([#467](https://github.com/whisper-money/whisper-money/issues/467)) ([d68fee6](https://github.com/whisper-money/whisper-money/commit/d68fee6c2dd54f923cb0a57c1dd483d5fa4e1ed5)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** combine category and label filters with OR ([#495](https://github.com/whisper-money/whisper-money/issues/495)) ([af87ac7](https://github.com/whisper-money/whisper-money/commit/af87ac75600060939b7c27e99548bace462a0d73)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** improve mobile analysis button affordance ([#520](https://github.com/whisper-money/whisper-money/issues/520)) ([3223824](https://github.com/whisper-money/whisper-money/commit/3223824edb03d3f5657af55dc8b01a8e79259f06)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** prevent crash when sorting by nullable column ([#501](https://github.com/whisper-money/whisper-money/issues/501)) ([a69c602](https://github.com/whisper-money/whisper-money/commit/a69c602366c5a2c8e18ad106827fc30693ba8471)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** tidy filter bar into two balanced rows ([#510](https://github.com/whisper-money/whisper-money/issues/510)) ([6486908](https://github.com/whisper-money/whisper-money/commit/6486908716e563b3b8970b9ec7b73989d7ee72be)) by [@victor-falcon](https://github.com/victor-falcon)
* verify email via signed link without requiring login ([#490](https://github.com/whisper-money/whisper-money/issues/490)) ([14b7955](https://github.com/whisper-money/whisper-money/commit/14b79557d79dd1c5c5876b6c6b1a480baedd536e)) by [@victor-falcon](https://github.com/victor-falcon)
### Features
* add catch-all budgets ([#527](https://github.com/whisper-money/whisper-money/issues/527)) ([dbec1c4](https://github.com/whisper-money/whisper-money/commit/dbec1c4c134a257b95c3c1402590a6727026a4d4)) by [@tonigruni](https://github.com/tonigruni)
* **ai:** add weekly AI-suggestions cohort report ([#530](https://github.com/whisper-money/whisper-money/issues/530)) ([906e3cc](https://github.com/whisper-money/whisper-money/commit/906e3cc2b466428e7efa4c4c66cb56a068475451)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** auto-categorize transactions with AI (behind flag) ([#535](https://github.com/whisper-money/whisper-money/issues/535)) ([8013a0b](https://github.com/whisper-money/whisper-money/commit/8013a0b6f2d53c6df64bcf6019592dd1f8e477d1)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** defer per-transaction categorization until onboarding completes ([#536](https://github.com/whisper-money/whisper-money/issues/536)) ([e065d4a](https://github.com/whisper-money/whisper-money/commit/e065d4ab65f603f5396cafcf4634e544c2eead2f)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** share AI sparkle icon and mark AI-generated rules ([#538](https://github.com/whisper-money/whisper-money/issues/538)) ([7dde67c](https://github.com/whisper-money/whisper-money/commit/7dde67c606fa0f8603f7603c0a6769df6755028c)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** suggest automation rules during onboarding ([#523](https://github.com/whisper-money/whisper-money/issues/523)) ([8056ede](https://github.com/whisper-money/whisper-money/commit/8056ede63644a4fac2afa88fcc9d89d7fb3bcea1)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** break down spending by sub-category ([#508](https://github.com/whisper-money/whisper-money/issues/508)) ([c944a2c](https://github.com/whisper-money/whisper-money/commit/c944a2c37ecfab3c483a6c0ffa3596bdd8b73f01)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** per-category 12-month spending drawer ([#519](https://github.com/whisper-money/whisper-money/issues/519)) ([9c3c4d5](https://github.com/whisper-money/whisper-money/commit/9c3c4d573ee8a04155d56da39bc213b052a138a4)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** project-aware transaction analysis ([#513](https://github.com/whisper-money/whisper-money/issues/513)) ([7cc49a5](https://github.com/whisper-money/whisper-money/commit/7cc49a53689d95855ca254a4df8ebd6fbad5720c)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** shared bar-list breakdowns in transaction drawer ([#517](https://github.com/whisper-money/whisper-money/issues/517)) ([bcd025f](https://github.com/whisper-money/whisper-money/commit/bcd025f1b16490baf871718b24db71997895e07e)) by [@victor-falcon](https://github.com/victor-falcon)
* **auth:** show/hide toggle on password fields ([#499](https://github.com/whisper-money/whisper-money/issues/499)) ([58254fc](https://github.com/whisper-money/whisper-money/commit/58254fcedeb76ff99b88f5b5fafe00f807510f91)) by [@victor-falcon](https://github.com/victor-falcon)
* **banking:** add command to disconnect connections by id ([#497](https://github.com/whisper-money/whisper-money/issues/497)) ([9192947](https://github.com/whisper-money/whisper-money/commit/91929477ab94cd721bc8b6c3a7c9a28dcdfb31cf)) by [@victor-falcon](https://github.com/victor-falcon)
* **cashflow:** enlarge and color-code net cashflow and saved cards ([#505](https://github.com/whisper-money/whisper-money/issues/505)) ([346e21a](https://github.com/whisper-money/whisper-money/commit/346e21ad4eec3b9dd7369abd6503b66fd7262c92)) by [@victor-falcon](https://github.com/victor-falcon)
* **console:** add agent:db command for querying local and prod DB ([#522](https://github.com/whisper-money/whisper-money/issues/522)) ([d2a4412](https://github.com/whisper-money/whisper-money/commit/d2a44121189624ab5e5c8e1ac1baac4ffd17ec21)) by [@victor-falcon](https://github.com/victor-falcon)
* **currencies:** add Colombian and Dominican peso ([#471](https://github.com/whisper-money/whisper-money/issues/471)) ([e5b4933](https://github.com/whisper-money/whisper-money/commit/e5b493329a6b5b8e6fdf04f873e591546c42441d)) by [@victor-falcon](https://github.com/victor-falcon)
* **currency:** add NZD (New Zealand Dollar) ([#504](https://github.com/whisper-money/whisper-money/issues/504)) ([899ea6a](https://github.com/whisper-money/whisper-money/commit/899ea6a939787e567a41566aeb6fbeee6885600d)) by [@victor-falcon](https://github.com/victor-falcon)
* enable category tree for all users, remove flag ([#488](https://github.com/whisper-money/whisper-money/issues/488)) ([f8cc881](https://github.com/whisper-money/whisper-money/commit/f8cc8816a898514a558667a2938f6255363b2ed7)) by [@victor-falcon](https://github.com/victor-falcon)
* expand parent categories inline in breakdowns ([#486](https://github.com/whisper-money/whisper-money/issues/486)) ([53d0518](https://github.com/whisper-money/whisper-money/commit/53d051800bb1e676563bbc1038e607efc186bebd)) by [@victor-falcon](https://github.com/victor-falcon)
* expand Sankey subcategories inline ([#485](https://github.com/whisper-money/whisper-money/issues/485)) ([679c8d7](https://github.com/whisper-money/whisper-money/commit/679c8d7bff3b69a5b70c55ae90fd1d8236af99ee)) by [@victor-falcon](https://github.com/victor-falcon)
* **importer:** support YYYYMMDD date format ([#470](https://github.com/whisper-money/whisper-money/issues/470)) ([f9d1303](https://github.com/whisper-money/whisper-money/commit/f9d1303d986aa2cee9ee6dbf92d6a3fb708f8f05)) by [@victor-falcon](https://github.com/victor-falcon)
* **landing:** add go now button to redirect modal ([#506](https://github.com/whisper-money/whisper-money/issues/506)) ([c53c40c](https://github.com/whisper-money/whisper-money/commit/c53c40cf0b54a94c163f28ebd504dd720ff03339)) by [@victor-falcon](https://github.com/victor-falcon)
* **landing:** real testimonials with gravatar/facehash avatars ([#493](https://github.com/whisper-money/whisper-money/issues/493)) ([300188f](https://github.com/whisper-money/whisper-money/commit/300188f135a598a4ae2a5bb6f119af1d13cae7eb)) by [@victor-falcon](https://github.com/victor-falcon)
* **open-banking:** finalize bank connection without a session via state token ([#498](https://github.com/whisper-money/whisper-money/issues/498)) ([a7dde5f](https://github.com/whisper-money/whisper-money/commit/a7dde5fbc5e1a09d55d64ff101e5ea4061335fd0)) by [@victor-falcon](https://github.com/victor-falcon)
* optionally update manual account balance on transaction delete ([#491](https://github.com/whisper-money/whisper-money/issues/491)) ([45e25e0](https://github.com/whisper-money/whisper-money/commit/45e25e018de377622197ae0c3c39c9cab6053220)) by [@victor-falcon](https://github.com/victor-falcon)
* parent/child category tree ([#474](https://github.com/whisper-money/whisper-money/issues/474)) ([1cc1056](https://github.com/whisper-money/whisper-money/commit/1cc10566a36562fa37ac7ecb9592da03284f5f51)) by [@victor-falcon](https://github.com/victor-falcon)
* **settings:** let users disable bank transactions email ([#472](https://github.com/whisper-money/whisper-money/issues/472)) ([e178f1b](https://github.com/whisper-money/whisper-money/commit/e178f1b1bdb57a778ae2124e651078d1904f71c4)) by [@victor-falcon](https://github.com/victor-falcon)
* single-open Sankey expand, fit overflowing children ([#487](https://github.com/whisper-money/whisper-money/issues/487)) ([721cbef](https://github.com/whisper-money/whisper-money/commit/721cbef02464acb40ab1428c769b65a042b4d96a)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** filtered analysis dashboard ([#507](https://github.com/whisper-money/whisper-money/issues/507)) ([8375fd4](https://github.com/whisper-money/whisper-money/commit/8375fd490ecc473bbcb7450830ba5b55e4b6bb26)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** save and reuse transaction filters ([#496](https://github.com/whisper-money/whisper-money/issues/496)) ([8df44c2](https://github.com/whisper-money/whisper-money/commit/8df44c2ef492abe4a9f5d385c9eb5ca0835c7675)) by [@victor-falcon](https://github.com/victor-falcon)
* **users:** track last login and last active timestamps ([#516](https://github.com/whisper-money/whisper-money/issues/516)) ([fcf2d3d](https://github.com/whisper-money/whisper-money/commit/fcf2d3d1ad5f4f85542bd92f7bdaf23ec26c8c50)) by [@victor-falcon](https://github.com/victor-falcon)
* **welcome:** add Albert G. testimonial ([#529](https://github.com/whisper-money/whisper-money/issues/529)) ([0f48ffb](https://github.com/whisper-money/whisper-money/commit/0f48ffbbeffe12321d434c9e4714d09eb2ce9b02)) by [@victor-falcon](https://github.com/victor-falcon)
## [0.2.4](https://github.com/whisper-money/whisper-money/compare/v0.2.3...v0.2.4) (2026-06-01)
### Bug Fixes
* **accounts:** sync currency from first account ([#430](https://github.com/whisper-money/whisper-money/issues/430)) ([0d4c683](https://github.com/whisper-money/whisper-money/commit/0d4c68361afc114615323418e2cfcf380e5ac13f))
* **accounts:** translate update button in edit account modal ([#455](https://github.com/whisper-money/whisper-money/issues/455)) ([65175e1](https://github.com/whisper-money/whisper-money/commit/65175e184a900404b12a5acccabfa0d700ba57ea))
* **automation:** avoid rule preview n+1 ([#431](https://github.com/whisper-money/whisper-money/issues/431)) ([fd67cf7](https://github.com/whisper-money/whisper-money/commit/fd67cf7c72148ce6e3afa97a2cb35893f3457e4b))
* **automation:** avoid skipping rule matches ([#433](https://github.com/whisper-money/whisper-money/issues/433)) ([9772cfc](https://github.com/whisper-money/whisper-money/commit/9772cfc37c19d521ab8b1cd64a5de467ae4ae5ce))
* **banking:** handle balance-fetch timeouts and silence handled retries ([#450](https://github.com/whisper-money/whisper-money/issues/450)) ([64b78e3](https://github.com/whisper-money/whisper-money/commit/64b78e36801a34321ee2fc12ef41a2938d13c572))
* batch automation rule application ([#435](https://github.com/whisper-money/whisper-money/issues/435)) ([606093d](https://github.com/whisper-money/whisper-money/commit/606093d311f307abe76e2c5ac10c7afc6f927578))
* **budgets:** explain locked edit fields ([#437](https://github.com/whisper-money/whisper-money/issues/437)) ([235911b](https://github.com/whisper-money/whisper-money/commit/235911b960c80ba1f18dc059ced6af7b43b225da))
* **cashflow:** clarify period comparisons ([#436](https://github.com/whisper-money/whisper-money/issues/436)) ([0250fdc](https://github.com/whisper-money/whisper-money/commit/0250fdc268ab27e4058a4a5bf6efa4b699133944))
* **cashflow:** defer period label translation ([#427](https://github.com/whisper-money/whisper-money/issues/427)) ([1278a2b](https://github.com/whisper-money/whisper-money/commit/1278a2b972bf85649d0f2390277765b8a3f0bc8b))
* **cashflow:** stack mobile header controls ([#426](https://github.com/whisper-money/whisper-money/issues/426)) ([949ca11](https://github.com/whisper-money/whisper-money/commit/949ca110fa2d250e316af45d5ca42a932b919343))
* **categories:** allow recreate after delete ([#444](https://github.com/whisper-money/whisper-money/issues/444)) ([2fa822e](https://github.com/whisper-money/whisper-money/commit/2fa822e6d960f73b4d168e012a1a003a86415f85))
* **categories:** expose cashflow setting on create ([#448](https://github.com/whisper-money/whisper-money/issues/448)) ([5119528](https://github.com/whisper-money/whisper-money/commit/5119528149a1ea1686e70d081418a10ec61edd68))
* **chart:** mask stacked bar edges ([#439](https://github.com/whisper-money/whisper-money/issues/439)) ([d5d262e](https://github.com/whisper-money/whisper-money/commit/d5d262e9fd5720b21bbe1c44500ef1034290cf9a))
* **currency:** degrade gracefully when rates return 404 ([#449](https://github.com/whisper-money/whisper-money/issues/449)) ([bef657c](https://github.com/whisper-money/whisper-money/commit/bef657c2ed8a7ed7be1b7c7d232dc037d76ee39a))
* filter Safari cashback extension errors ([#447](https://github.com/whisper-money/whisper-money/issues/447)) ([0b94067](https://github.com/whisper-money/whisper-money/commit/0b9406714e158244b64cb449e6b19320305ee6b9))
* **import:** correct balance for same-day, zero and negative values ([#456](https://github.com/whisper-money/whisper-money/issues/456)) ([144d919](https://github.com/whisper-money/whisper-money/commit/144d919c0b23d8231b1095e148d4ca4a58f0e955))
* **logging:** keep laravel.log writable across container UIDs ([#451](https://github.com/whisper-money/whisper-money/issues/451)) ([741dc49](https://github.com/whisper-money/whisper-money/commit/741dc49d5371b3aa867d45c78974fe0049d6b346))
* move community link to user menu ([#442](https://github.com/whisper-money/whisper-money/issues/442)) ([4f46ae3](https://github.com/whisper-money/whisper-money/commit/4f46ae3e2d9e365a1bb45355fe2f2c310cab2bb8))
* net category refunds in cashflow ([#441](https://github.com/whisper-money/whisper-money/issues/441)) ([6caadad](https://github.com/whisper-money/whisper-money/commit/6caadad1dbc267ea5d2bb58f2ce92200146349bf))
* **register:** don't block signup on unrecognized browser timezone ([#462](https://github.com/whisper-money/whisper-money/issues/462)) ([96ee311](https://github.com/whisper-money/whisper-money/commit/96ee311299b0fb6d8234fd17cab44caa3c886488))
### Features
* **accounts:** add transaction action ([#438](https://github.com/whisper-money/whisper-money/issues/438)) ([534a147](https://github.com/whisper-money/whisper-money/commit/534a14790e777f3bd3d993fe6083fef40a9727ab))
* **accounts:** show 50 transactions per page and link to full list ([#459](https://github.com/whisper-money/whisper-money/issues/459)) ([85ea3cc](https://github.com/whisper-money/whisper-money/commit/85ea3cc71416d446fdc8858fbd8a562a02334182))
* add BRL currency support ([#453](https://github.com/whisper-money/whisper-money/issues/453)) ([4dec0ab](https://github.com/whisper-money/whisper-money/commit/4dec0ab7ca14e3100ee2b584a22b39f8cdf8e110))
* add Discord admin feed for daily stats and Stripe events ([#458](https://github.com/whisper-money/whisper-money/issues/458)) ([0b528b7](https://github.com/whisper-money/whisper-money/commit/0b528b79025314294db53a61a07231199b2b864c)), closes [#457](https://github.com/whisper-money/whisper-money/issues/457)
* add PKR currency support ([#443](https://github.com/whisper-money/whisper-money/issues/443)) ([cfa61fd](https://github.com/whisper-money/whisper-money/commit/cfa61fd23cc9b7888755684d24ede3f6a35863cb))
* add Stripe subscription stats command ([#457](https://github.com/whisper-money/whisper-money/issues/457)) ([670a0a6](https://github.com/whisper-money/whisper-money/commit/670a0a65c73d19da091f95ea9a381d7dcab1b240))
* **budgets:** track multiple categories and labels per budget ([#466](https://github.com/whisper-money/whisper-money/issues/466)) ([71dd6e2](https://github.com/whisper-money/whisper-money/commit/71dd6e2b7fcf44d889641842a13e19091a47d186))
* **cashflow:** add savings and period views ([#424](https://github.com/whisper-money/whisper-money/issues/424)) ([ed737db](https://github.com/whisper-money/whisper-money/commit/ed737db7b28442ea3674290eb7eb3f15744dd5b2))
* **cashflow:** rework summary cards into net + saved/invested ([#465](https://github.com/whisper-money/whisper-money/issues/465)) ([5ce439f](https://github.com/whisper-money/whisper-money/commit/5ce439f463bfdac7ebb3aa5c0396d098999fdad6))
* **categorize:** show debtor and creditor names ([#454](https://github.com/whisper-money/whisper-money/issues/454)) ([05ee8dc](https://github.com/whisper-money/whisper-money/commit/05ee8dc44258e7c9e6dcb6e77afedeb92f3329a2))
* **currency:** add Saudi Riyal (SAR) ([#461](https://github.com/whisper-money/whisper-money/issues/461)) ([a71626a](https://github.com/whisper-money/whisper-money/commit/a71626a350a568fc0591847aba6b0faac0d484fb))
* **discord:** enrich Stripe event messages and dedupe deliveries ([#460](https://github.com/whisper-money/whisper-money/issues/460)) ([f9bf0ea](https://github.com/whisper-money/whisper-money/commit/f9bf0ea5fff32f26af8d05dc5645a302524b3a95))
* **landing:** redirect signed-in users ([#429](https://github.com/whisper-money/whisper-money/issues/429)) ([4f42de7](https://github.com/whisper-money/whisper-money/commit/4f42de74a1beda4b7032a6e2fe6d9d1eec4edd4c))
* **leads:** add user lead re-invite campaign ([#432](https://github.com/whisper-money/whisper-money/issues/432)) ([7b03d7c](https://github.com/whisper-money/whisper-money/commit/7b03d7cf230ef0cea3fe4ce6ea2215e3348910f9))
* **leads:** schedule invitation emails ([#434](https://github.com/whisper-money/whisper-money/issues/434)) ([d5d22b9](https://github.com/whisper-money/whisper-money/commit/d5d22b9d5724cfb7363ead047946ff587772d9ea))
* **posthog:** route analytics through reverse proxy ([#463](https://github.com/whisper-money/whisper-money/issues/463)) ([448bb2e](https://github.com/whisper-money/whisper-money/commit/448bb2e64af8ebb8b2a28700d793b97fba75bf6d))
* **transactions:** add counterparty fields ([#440](https://github.com/whisper-money/whisper-money/issues/440)) ([10da06e](https://github.com/whisper-money/whisper-money/commit/10da06ed849c019fcd041d5889d5864a6c26844f))
## [0.2.3](https://github.com/whisper-money/whisper-money/compare/v0.2.2...v0.2.3) (2026-05-25)
### Bug Fixes
* allow managing canceled connections ([#417](https://github.com/whisper-money/whisper-money/issues/417)) ([eaba315](https://github.com/whisper-money/whisper-money/commit/eaba3151967a942044cffbd33c0dcee8d00cb457))
* build arm64 images on native runners ([#419](https://github.com/whisper-money/whisper-money/issues/419)) ([364c72d](https://github.com/whisper-money/whisper-money/commit/364c72db72812e337a08813019bfd8d6c84ce1a3))
* preview categorizer rule application ([#421](https://github.com/whisper-money/whisper-money/issues/421)) ([faf046b](https://github.com/whisper-money/whisper-money/commit/faf046b3c4213417190504cd0b7491012e7fcffd))
* publish arm64 docker images asynchronously ([#418](https://github.com/whisper-money/whisper-money/issues/418)) ([4411601](https://github.com/whisper-money/whisper-money/commit/44116010dee35eb93b6826e4c955d0c662aa5927)), closes [#412](https://github.com/whisper-money/whisper-money/issues/412)
### Features
* keep past due subscriptions active ([#416](https://github.com/whisper-money/whisper-money/issues/416)) ([88faa5b](https://github.com/whisper-money/whisper-money/commit/88faa5beb60c8b9948ff11284609b21fbfebee30))
* **mail:** use AWS SES for email delivery ([#422](https://github.com/whisper-money/whisper-money/issues/422)) ([d8bb78e](https://github.com/whisper-money/whisper-money/commit/d8bb78e5e096938292d0a971637d077d76cf61a8))
## [0.2.2](https://github.com/whisper-money/whisper-money/compare/v0.2.0...v0.2.2) (2026-05-22)
### Bug Fixes
* **banking:** dedup EnableBanking transactions by deterministic fingerprint ([#390](https://github.com/whisper-money/whisper-money/issues/390)) ([d9204bb](https://github.com/whisper-money/whisper-money/commit/d9204bb3d611c382493e45691d9ec963a73bf454))
* **banking:** treat Indexa Capital performance 404 as empty data ([#386](https://github.com/whisper-money/whisper-money/issues/386)) ([06e7eed](https://github.com/whisper-money/whisper-money/commit/06e7eed4e20796accf0555659baeffb481e925bd))
* **budget:** show today marker ([#411](https://github.com/whisper-money/whisper-money/issues/411)) ([933dfde](https://github.com/whisper-money/whisper-money/commit/933dfdeb1b32ffba04850b973d9c3d4681262053))
* **connections:** show expired reconnect ([#407](https://github.com/whisper-money/whisper-money/issues/407)) ([d2e00f1](https://github.com/whisper-money/whisper-money/commit/d2e00f14e5302ccd40f620733479fc7a7b410699))
* keep lead invite command aliases ([#406](https://github.com/whisper-money/whisper-money/issues/406)) ([11f989d](https://github.com/whisper-money/whisper-money/commit/11f989d03af290f9ee32bc6e46fad327dd2c1e03))
* **notifications:** skip mail dispatch when recipient email is invalid ([#387](https://github.com/whisper-money/whisper-money/issues/387)) ([d140b4f](https://github.com/whisper-money/whisper-money/commit/d140b4fd4cd5402b464c203d619c231db9672ef7))
* **sentry:** ignore postMessage clone noise ([#373](https://github.com/whisper-money/whisper-money/issues/373)) ([6335287](https://github.com/whisper-money/whisper-money/commit/63352877654bc880e09e36d0a3efada527961d82))
### Features
* Coinbase banking integration ([#388](https://github.com/whisper-money/whisper-money/issues/388)) ([e71a743](https://github.com/whisper-money/whisper-money/commit/e71a743a0a6e3a1a6bde5c1bc29df94555f74bd1))
* **import:** calculate balances from transactions ([#403](https://github.com/whisper-money/whisper-money/issues/403)) ([66ff427](https://github.com/whisper-money/whisper-money/commit/66ff427481dfe0e00cf632e3f0f1caef33238636))
## [0.2.1](https://github.com/whisper-money/whisper-money/compare/v0.2.0...v0.2.1) (2026-05-12)
### Features
* Add yearly budget period ([#384](https://github.com/whisper-money/whisper-money/issues/384)) ([f8f3b06](https://github.com/whisper-money/whisper-money/commit/f8f3b06))
* Add labels to automation rules ([#379](https://github.com/whisper-money/whisper-money/issues/379)) ([5b8e7e8](https://github.com/whisper-money/whisper-money/commit/5b8e7e8))
### Bug Fixes
* Fix exchange rate cache race (PHP-LARAVEL-1V) ([#383](https://github.com/whisper-money/whisper-money/issues/383)) ([c3dcbb4](https://github.com/whisper-money/whisper-money/commit/c3dcbb4))
* Fix cashflow null category rows ([#382](https://github.com/whisper-money/whisper-money/issues/382)) ([30cc4da](https://github.com/whisper-money/whisper-money/commit/30cc4da))
* Fix browser translation crash (PHP-LARAVEL-1S) ([#381](https://github.com/whisper-money/whisper-money/issues/381)) ([e635fda](https://github.com/whisper-money/whisper-money/commit/e635fda))
* Fix cashflow multi-currency totals ([#380](https://github.com/whisper-money/whisper-money/issues/380)) ([4e03996](https://github.com/whisper-money/whisper-money/commit/4e03996))
* Fix service worker registration rejection ([#376](https://github.com/whisper-money/whisper-money/issues/376)) ([3526e5f](https://github.com/whisper-money/whisper-money/commit/3526e5f))
* Recover from stale Vite chunks ([#374](https://github.com/whisper-money/whisper-money/issues/374)) ([69610c5](https://github.com/whisper-money/whisper-money/commit/69610c5))
* **sentry:** ignore postMessage clone noise ([#373](https://github.com/whisper-money/whisper-money/issues/373)) ([6335287](https://github.com/whisper-money/whisper-money/commit/6335287))
* Fix Sentry transaction and dashboard crashes ([#372](https://github.com/whisper-money/whisper-money/issues/372)) ([718cfa9](https://github.com/whisper-money/whisper-money/commit/718cfa9))
* Fix Sentry release commit detection in image build ([#371](https://github.com/whisper-money/whisper-money/issues/371)) ([f4ab4a1](https://github.com/whisper-money/whisper-money/commit/f4ab4a1))
* Prevent cached cashflow analytics responses ([#368](https://github.com/whisper-money/whisper-money/issues/368)) ([97df059](https://github.com/whisper-money/whisper-money/commit/97df059))
* Fix duplicate category name validation ([#364](https://github.com/whisper-money/whisper-money/issues/364)) ([e3c2d2f](https://github.com/whisper-money/whisper-money/commit/e3c2d2f))
### Chores
* Add sentry issue slash command ([#375](https://github.com/whisper-money/whisper-money/issues/375)) ([c929c1f](https://github.com/whisper-money/whisper-money/commit/c929c1f))
* Update worktree script ([#366](https://github.com/whisper-money/whisper-money/issues/366)) ([360a38a](https://github.com/whisper-money/whisper-money/commit/360a38a))
* Speed up PR CI browser path ([#365](https://github.com/whisper-money/whisper-money/issues/365)) ([e36d6f3](https://github.com/whisper-money/whisper-money/commit/e36d6f3))
# [0.2.0](https://github.com/whisper-money/whisper-money/compare/v0.1.20...v0.2.0) (2026-05-07)
### Bug Fixes
* **banking:** clamp linkedDateFrom to today on EnableBanking sync ([#343](https://github.com/whisper-money/whisper-money/issues/343)) ([f6c2057](https://github.com/whisper-money/whisper-money/commit/f6c20576b5dd6a98cb69c860825459fe010e2164))
* **budgets:** remove Custom period type to fix duplicate-key crash ([#355](https://github.com/whisper-money/whisper-money/issues/355)) ([22043ce](https://github.com/whisper-money/whisper-money/commit/22043ced29e80486bcc3bb025952fda0f0b1f537))
* **dashboard:** avoid month overflow in real estate projection ([#340](https://github.com/whisper-money/whisper-money/issues/340)) ([8f42496](https://github.com/whisper-money/whisper-money/commit/8f42496a5f6cd655828df7c49f358ad61d7e8002))
* include production Dockerfile in deploy filter ([#350](https://github.com/whisper-money/whisper-money/issues/350)) ([21b5692](https://github.com/whisper-money/whisper-money/commit/21b5692174f2cf23d44a93e26f7b39d21edfe383))
* **onboarding:** guard window access in SSR ([#351](https://github.com/whisper-money/whisper-money/issues/351)) ([b1709b7](https://github.com/whisper-money/whisper-money/commit/b1709b714e5e5d591351db51f7d2b31fb201fe74))
* **real-estate:** compound annual revaluation monthly ([#337](https://github.com/whisper-money/whisper-money/issues/337)) ([13f741a](https://github.com/whisper-money/whisper-money/commit/13f741aaed38681571c5950da844f44309306858))
* unblock onboarding after sync failure ([#346](https://github.com/whisper-money/whisper-money/issues/346)) ([70f3897](https://github.com/whisper-money/whisper-money/commit/70f3897b5534940c4be1dfdce3b4ce8978a882b9))
### Features
* **accounts:** show projection on real estate chart ([#338](https://github.com/whisper-money/whisper-money/issues/338)) ([0f2300b](https://github.com/whisper-money/whisper-money/commit/0f2300bf3e420576893758117ed5583b39f656d7))
* **banking:** back off scheduler when EnableBanking returns 429 ([#352](https://github.com/whisper-money/whisper-money/issues/352)) ([f800847](https://github.com/whisper-money/whisper-money/commit/f80084759133a5e00fc997602266575d3806dfaa))
* **leads:** cohort-based launch invitations with per-user Stripe coupons ([#333](https://github.com/whisper-money/whisper-money/issues/333)) ([ab3d6e9](https://github.com/whisper-money/whisper-money/commit/ab3d6e9fcaeccf3b57027c26904460e788c8df3e))
### Performance Improvements
* **resend:** default sync-leads to last 24h window ([#354](https://github.com/whisper-money/whisper-money/issues/354)) ([e387c03](https://github.com/whisper-money/whisper-money/commit/e387c038ca6e5e0ea3f757e28c52125ea20ba198))
## [0.1.20](https://github.com/whisper-money/whisper-money/compare/v0.1.19...v0.1.20) (2026-04-24)
### Bug Fixes
* **accounts:** use chart color scheme for real estate sparkline and balance charts ([#247](https://github.com/whisper-money/whisper-money/issues/247)) ([8b71115](https://github.com/whisper-money/whisper-money/commit/8b71115afc0f46ec1867e7030bffc87cad481a10))
* add missing port to frontend Bugsink DSN ([#260](https://github.com/whisper-money/whisper-money/issues/260)) ([6ce5b12](https://github.com/whisper-money/whisper-money/commit/6ce5b123ce9b58ae7ec660d8cbcd005fb1748e35))
* align onboarding account types with current asset support ([#273](https://github.com/whisper-money/whisper-money/issues/273)) ([80274e0](https://github.com/whisper-money/whisper-money/commit/80274e03a8e697509ddbd0ec3e7a4e9d5d752d10))
* **auth:** allow forced registration ([#307](https://github.com/whisper-money/whisper-money/issues/307)) ([75736f3](https://github.com/whisper-money/whisper-money/commit/75736f3e59966e6821f436d4aac7f45e4111e5da))
* avoid iOS PWA status bar overlap ([#281](https://github.com/whisper-money/whisper-money/issues/281)) ([80b6668](https://github.com/whisper-money/whisper-money/commit/80b666836c9ad106c526eb45c82046af953c0342))
* **banking:** retry failed sync connections and log every sync attempt ([#251](https://github.com/whisper-money/whisper-money/issues/251)) ([f3b5929](https://github.com/whisper-money/whisper-money/commit/f3b5929ecc2ca4d093e645ff996fc47b63440e17))
* batch Pennant feature flag queries to avoid N+1 selects ([#244](https://github.com/whisper-money/whisper-money/issues/244)) ([8ac6ed4](https://github.com/whisper-money/whisper-money/commit/8ac6ed4d83e14eaab9fe8215247e091fab8258c3)), closes [#241](https://github.com/whisper-money/whisper-money/issues/241)
* **budgets:** make budget assignment idempotent ([#303](https://github.com/whisper-money/whisper-money/issues/303)) ([b1ceda6](https://github.com/whisper-money/whisper-money/commit/b1ceda61f93d1bb385060b7ffee35fb56fd41962))
* **budgets:** retry assignment deadlocks ([#304](https://github.com/whisper-money/whisper-money/issues/304)) ([45e311e](https://github.com/whisper-money/whisper-money/commit/45e311e17baaa510a4309724937c5b18ded42631))
* **cashflow:** exclude transfer categories from sankey ([#235](https://github.com/whisper-money/whisper-money/issues/235)) ([debb47f](https://github.com/whisper-money/whisper-money/commit/debb47f6af2808669a319a696d9a81036ca7b961))
* **cashflow:** net transfer categories in sankey ([#257](https://github.com/whisper-money/whisper-money/issues/257)) ([83f7e83](https://github.com/whisper-money/whisper-money/commit/83f7e83a134db2fe98f4b3ba75f173b7e0f44e44))
* **cashflow:** read period from server props instead of window ([#302](https://github.com/whisper-money/whisper-money/issues/302)) ([22952c4](https://github.com/whisper-money/whisper-money/commit/22952c4e75cfbe933b42c91da826ff0e33e472e3))
* **chart:** hide tooltip on scroll with opacity fade ([#320](https://github.com/whisper-money/whisper-money/issues/320)) ([38e1976](https://github.com/whisper-money/whisper-money/commit/38e1976270b3afafac93d02a5586c508762e25af))
* **chart:** tooltip escapes overflow, truncates long labels ([#317](https://github.com/whisper-money/whisper-money/issues/317)) ([e4d2ade](https://github.com/whisper-money/whisper-money/commit/e4d2ade92f4c532fa040a9b98e2fcee2ba5cc3b9))
* **ci:** order sentry deploy after build ([#309](https://github.com/whisper-money/whisper-money/issues/309)) ([bfe1af3](https://github.com/whisper-money/whisper-money/commit/bfe1af3c839e3370d5b6132efdaaad5a6b9983a3))
* **ci:** skip outdated production deploys ([b36197e](https://github.com/whisper-money/whisper-money/commit/b36197e76bca7b73cc50f4f53775974326cae264))
* clarify account creation modal copy ([#274](https://github.com/whisper-money/whisper-money/issues/274)) ([dafc58f](https://github.com/whisper-money/whisper-money/commit/dafc58f49f0a832a45bbd3f02fd39340e575a4d7))
* clarify mobile settings navigation ([#272](https://github.com/whisper-money/whisper-money/issues/272)) ([62ab1b3](https://github.com/whisper-money/whisper-money/commit/62ab1b38db8fc03e4e3172cc31676442b850deaf))
* **dashboard:** dismiss account card tooltip when tapping outside ([#318](https://github.com/whisper-money/whisper-money/issues/318)) ([753002f](https://github.com/whisper-money/whisper-money/commit/753002f930f4abe8c8025bac7f28609d1694152c))
* **dashboard:** treat loans as debt in net worth ([#238](https://github.com/whisper-money/whisper-money/issues/238)) ([f140b5d](https://github.com/whisper-money/whisper-money/commit/f140b5df7f2188dde8d278eca47a4e8eaa431f86))
* default account charts to user currency ([#271](https://github.com/whisper-money/whisper-money/issues/271)) ([38cf672](https://github.com/whisper-money/whisper-money/commit/38cf672c8e9ba24e8f8f956e2b19a2c05c98064a))
* default to standard onboarding option ([#276](https://github.com/whisper-money/whisper-money/issues/276)) ([d91d9d3](https://github.com/whisper-money/whisper-money/commit/d91d9d3b3eb2ac7c6c9deed2ef2454835daf5d5a))
* **demo-reset:** use renamed 'ING Direct' bank ([#301](https://github.com/whisper-money/whisper-money/issues/301)) ([cfa54a2](https://github.com/whisper-money/whisper-money/commit/cfa54a2d9dc9b8031d18528b51bde933ed501729))
* **docker:** ensure www-data owns storage after artisan commands ([#329](https://github.com/whisper-money/whisper-money/issues/329)) ([0eca002](https://github.com/whisper-money/whisper-money/commit/0eca00285699ca67dccd6c7ab8ec5af853a951fc))
* expose pi mcp extension as mcps.ts ([#315](https://github.com/whisper-money/whisper-money/issues/315)) ([c7cfa10](https://github.com/whisper-money/whisper-money/commit/c7cfa1011764be700687da5e499de1fde3445e65))
* **i18n:** add missing Spanish translations for mortgage UI strings ([0a535fb](https://github.com/whisper-money/whisper-money/commit/0a535fbf4729afc4bf0c791faddbfc71397c01ef))
* **i18n:** translate Unknown Income/Expense and other missing ES strings ([#331](https://github.com/whisper-money/whisper-money/issues/331)) ([79075db](https://github.com/whisper-money/whisper-money/commit/79075dbcdf2003373483afd396d2b4cb4b415f6a))
* keep iOS content below the notch ([#280](https://github.com/whisper-money/whisper-money/issues/280)) ([b505d68](https://github.com/whisper-money/whisper-money/commit/b505d68ef0ac4d52ee94a85b4e6b113c9d8d35c9))
* keep iOS popovers below the notch ([#282](https://github.com/whisper-money/whisper-money/issues/282)) ([ea9956f](https://github.com/whisper-money/whisper-money/commit/ea9956f21da3f7498bf947f539c6b31fa844fe96))
* limit bank sync emails to one per day ([#290](https://github.com/whisper-money/whisper-money/issues/290)) ([552aa59](https://github.com/whisper-money/whisper-money/commit/552aa59aaf5e476c81d81483ca9118f872730d2e))
* **loans:** project monthly balances from actual entries instead of original params ([#259](https://github.com/whisper-money/whisper-money/issues/259)) ([7e95828](https://github.com/whisper-money/whisper-money/commit/7e958284e3944a9bf3dfae08524f81afbca4a7da))
* make transaction sync email use default sender ([#265](https://github.com/whisper-money/whisper-money/issues/265)) ([7be0fe0](https://github.com/whisper-money/whisper-money/commit/7be0fe012041283df651c1fbce7b3f69102a500f))
* **open-banking:** respect local email hours ([#306](https://github.com/whisper-money/whisper-money/issues/306)) ([fbffdd3](https://github.com/whisper-money/whisper-money/commit/fbffdd3f3c16ae075bb2d779e22d1b4e82a792e9))
* **open-banking:** skip silent sync emails ([#295](https://github.com/whisper-money/whisper-money/issues/295)) ([473ac03](https://github.com/whisper-money/whisper-money/commit/473ac03088b3ad6e09c32344e0b4ca5f1db489ea))
* **open-banking:** sort bank sync email data ([#292](https://github.com/whisper-money/whisper-money/issues/292)) ([c90e816](https://github.com/whisper-money/whisper-money/commit/c90e8166bfc94f1af7aab2197edd87d68eb9e1b9))
* **open-banking:** suppress first sync email ([#310](https://github.com/whisper-money/whisper-money/issues/310)) ([16675f6](https://github.com/whisper-money/whisper-money/commit/16675f6518ec2e652b711c483d28d4b22792abd6))
* preserve cents in chart amounts ([#270](https://github.com/whisper-money/whisper-money/issues/270)) ([0735ee6](https://github.com/whisper-money/whisper-money/commit/0735ee6d697bd8d46044a223bc1061b8742f035e))
* **pricing:** update final release prices ([#288](https://github.com/whisper-money/whisper-money/issues/288)) ([319ca75](https://github.com/whisper-money/whisper-money/commit/319ca758e1e9869445512e9311b3d26a4197291f))
* prioritize exact bank search matches ([#267](https://github.com/whisper-money/whisper-money/issues/267)) ([1e20361](https://github.com/whisper-money/whisper-money/commit/1e2036110fe05d564069bcc57ffadec4fb8a8147))
* reorder signed names in mail templates ([#266](https://github.com/whisper-money/whisper-money/issues/266)) ([fec9373](https://github.com/whisper-money/whisper-money/commit/fec93734c0dd2d618c00e99247506d314b9b10e7))
* route new PWA guests to signup ([#313](https://github.com/whisper-money/whisper-money/issues/313)) ([905edeb](https://github.com/whisper-money/whisper-money/commit/905edeb4a249cf71a9fccd7815f14fbadc20c884))
* **schedule:** remove stale horizon snapshot ([#293](https://github.com/whisper-money/whisper-money/issues/293)) ([b438a1c](https://github.com/whisper-money/whisper-money/commit/b438a1c73bfb388c784764dbe08b2274c40126ed))
* split drip and default email senders ([#263](https://github.com/whisper-money/whisper-money/issues/263)) ([ce5692c](https://github.com/whisper-money/whisper-money/commit/ce5692cb3036ec47c4f82ae57aaadfd58e6c14a4))
* **user:** persist detected timezones ([#296](https://github.com/whisper-money/whisper-money/issues/296)) ([fde5405](https://github.com/whisper-money/whisper-money/commit/fde5405777250f71cdcc1b45fae73fdb64cd7496))
### Features
* **accounts:** add loan amortization projections for loan accounts ([#246](https://github.com/whisper-money/whisper-money/issues/246)) ([bb65bdc](https://github.com/whisper-money/whisper-money/commit/bb65bdc16e2f0952ec7508dbce418d0155715077))
* **accounts:** add market value and annual revaluation to real estate accounts ([#245](https://github.com/whisper-money/whisper-money/issues/245)) ([fa11dc7](https://github.com/whisper-money/whisper-money/commit/fa11dc78e0c60e310a13708edfd35926f1435a0b))
* **accounts:** add real estate asset tracking ([#241](https://github.com/whisper-money/whisper-money/issues/241)) ([395c4ad](https://github.com/whisper-money/whisper-money/commit/395c4ad2c34b43b341a675ce5526edf2a3d03cd0))
* **accounts:** add today marker on projected balance chart ([#321](https://github.com/whisper-money/whisper-money/issues/321)) ([4b145e2](https://github.com/whisper-money/whisper-money/commit/4b145e230b5a19a25b585260b05f2cc2c19fe066))
* **accounts:** allow setting initial balance when creating balance-tracking accounts ([#239](https://github.com/whisper-money/whisper-money/issues/239)) ([7a05621](https://github.com/whisper-money/whisper-money/commit/7a056213cf6a29eab0b2416f69fd7dfa9ab1061d))
* **accounts:** merge real estate accounts with linked mortgages in UI ([#248](https://github.com/whisper-money/whisper-money/issues/248)) ([6e97635](https://github.com/whisper-money/whisper-money/commit/6e976354ba2e673d5b183bacc3e9a896937ee54f))
* **accounts:** show mortgage data and equity on real estate account page ([#243](https://github.com/whisper-money/whisper-money/issues/243)) ([9732432](https://github.com/whisper-money/whisper-money/commit/973243277a512b40f46d48cae557d240924fe2cb))
* add appearance shortcut to user menu ([#269](https://github.com/whisper-money/whisper-money/issues/269)) ([3acb277](https://github.com/whisper-money/whisper-money/commit/3acb277fb5838c8538b989aa0ba7a8e209ac917f))
* **billing:** apply Stripe tax rates to subscriptions ([#325](https://github.com/whisper-money/whisper-money/issues/325)) ([74cbdd4](https://github.com/whisper-money/whisper-money/commit/74cbdd42efea0e8884639b049a5de7138489fad2))
* **cashflow:** show tracked transfers in Sankey diagram ([#237](https://github.com/whisper-money/whisper-money/issues/237)) ([6dda5f5](https://github.com/whisper-money/whisper-money/commit/6dda5f56ade8d669b9c0843d4980c2d76c9dc614)), closes [hi#level](https://github.com/hi/issues/level)
* **cashflow:** track transfer categories in trends ([#236](https://github.com/whisper-money/whisper-money/issues/236)) ([272dac1](https://github.com/whisper-money/whisper-money/commit/272dac14b82b6863af6eddf88dc54e0fb408c9f1))
* **dashboard:** merge real estate accounts with linked mortgages on dashboard ([752176e](https://github.com/whisper-money/whisper-money/commit/752176e80d67241ab4566d3ced0a7abe8a987b69))
* **landing:** add signed auth links ([#312](https://github.com/whisper-money/whisper-money/issues/312)) ([240fcf1](https://github.com/whisper-money/whisper-money/commit/240fcf17030c605ed5daaa3fffa77018e20968c5))
* link loans to existing properties ([#275](https://github.com/whisper-money/whisper-money/issues/275)) ([a7c1bd3](https://github.com/whisper-money/whisper-money/commit/a7c1bd35ef058f6ef468cdd96a3b9e3a9be89de1))
* **loans:** backfill historical balances on loan creation ([#322](https://github.com/whisper-money/whisper-money/issues/322)) ([5b1d059](https://github.com/whisper-money/whisper-money/commit/5b1d059e020f7aa12c3502b51b174d0615a820e1))
* **open-banking:** remove feature flag gating ([#297](https://github.com/whisper-money/whisper-money/issues/297)) ([244344e](https://github.com/whisper-money/whisper-money/commit/244344e953033b12a948c5f9d85d7db4639bba1d))
* **real-estate:** auto-calculate revaluation % and generate historical balances ([#253](https://github.com/whisper-money/whisper-money/issues/253)) ([094fb1b](https://github.com/whisper-money/whisper-money/commit/094fb1b7446ca57e32e00018b78ddd645eeea3a3))
* resend verification emails to unverified leads ([#287](https://github.com/whisper-money/whisper-money/issues/287)) ([5b78509](https://github.com/whisper-money/whisper-money/commit/5b7850958882988d80080eaa456e599007b974c8))
* selective retry of failed lead email jobs ([#286](https://github.com/whisper-money/whisper-money/issues/286)) ([f408dbe](https://github.com/whisper-money/whisper-money/commit/f408dbe4c8a8ccbd7368ac675525a85b70c9abdf))
* **settings:** centralize currency options and split profile/account support ([#256](https://github.com/whisper-money/whisper-money/issues/256)) ([3d58237](https://github.com/whisper-money/whisper-money/commit/3d5823728a18a146e9c420e7f924014bd66bd3c8))
* store invested_amount in user currency instead of account currency ([#262](https://github.com/whisper-money/whisper-money/issues/262)) ([c3ff4c6](https://github.com/whisper-money/whisper-money/commit/c3ff4c684a50eb1b506d59954442f2ba7a41b04d))
* **stripe:** add promo code generator ([#311](https://github.com/whisper-money/whisper-money/issues/311)) ([69665c3](https://github.com/whisper-money/whisper-money/commit/69665c3c588ad0b4d27594d2b55fdb185553483a))
* **subscriptions:** add configurable trial period to paid plans ([#324](https://github.com/whisper-money/whisper-money/issues/324)) ([b399aaa](https://github.com/whisper-money/whisper-money/commit/b399aaaa0dcafc27f9f9665209f9aceecf0b70e7))
* sync user leads to resend ([#283](https://github.com/whisper-money/whisper-money/issues/283)) ([dc0695c](https://github.com/whisper-money/whisper-money/commit/dc0695c2ca55d3447b814b44bd8f13848922f92a))
* verify waitlist leads ([#285](https://github.com/whisper-money/whisper-money/issues/285)) ([d0aab3d](https://github.com/whisper-money/whisper-money/commit/d0aab3d11bad80fefb35fa01055347ce1413d18b))
## [0.1.19](https://github.com/whisper-money/whisper-money/compare/v0.1.18...v0.1.19) (2026-03-17)
### Bug Fixes
* **banking:** treat 429 rate limit as transient, skip error status on sync ([#224](https://github.com/whisper-money/whisper-money/issues/224)) ([5b9ae2a](https://github.com/whisper-money/whisper-money/commit/5b9ae2a5259ecf1e55e4074295c52dcc0429ef71))
* **cashflow:** only count sign-matching transactions in Sankey category breakdown ([#232](https://github.com/whisper-money/whisper-money/issues/232)) ([9e2a9ca](https://github.com/whisper-money/whisper-money/commit/9e2a9cadfe0210e0f2a45da8dbcaab1552dc0844))
* **ci:** allow deploy retry loop to survive curl timeout ([#233](https://github.com/whisper-money/whisper-money/issues/233)) ([cd40bc7](https://github.com/whisper-money/whisper-money/commit/cd40bc75d9b60acede4fc519f3f8f66ad8f560c3))
* **haptics:** use a local WebHaptics wrapper ([#225](https://github.com/whisper-money/whisper-money/issues/225)) ([f600524](https://github.com/whisper-money/whisper-money/commit/f600524c2b834b9322fda1ca7a6881b43c5d5194))
* prevent account label combobox crash ([#230](https://github.com/whisper-money/whisper-money/issues/230)) ([a60fd6f](https://github.com/whisper-money/whisper-money/commit/a60fd6f452b58d8ba9e4033dffc27a4f0c0fff15))
* **settings:** restore budgets settings redirect ([#228](https://github.com/whisper-money/whisper-money/issues/228)) ([e5fcaee](https://github.com/whisper-money/whisper-money/commit/e5fcaee8f8a0c9badf0450fb209ff7cd7e4c0d2e))
### Features
* **cashflow:** make income/expense category rows clickable to transactions ([#234](https://github.com/whisper-money/whisper-money/issues/234)) ([ec24565](https://github.com/whisper-money/whisper-money/commit/ec245655b8f5541a6bafec92edede97bf75573aa))
## [0.1.18](https://github.com/whisper-money/whisper-money/compare/v0.1.17...v0.1.18) (2026-03-12)

View File

@ -15,6 +15,14 @@ composer run dev # Start full dev environment (PHP server, queue, Vite,
bun run dev # Vite dev server only
```
### Running the server for QA
When a command or skill needs the user to open the app in Chrome to review a change:
1. **First run in a worktree?** Prepare it: `./worktree.sh /path/to/main/repo` (copies `.env` + `storage/keys`, installs `bun` and `composer` deps). Skip if already set up.
2. **Start the server** if it isn't running: `composer run dev`.
3. **Get the URL** — it's dynamic per worktree, so don't guess it. On startup `composer run dev` prints and copies it to the clipboard (`✓ <URL> copied to clipboard`); read it from the `server` pane output. Ask the user to open that URL in Chrome to QA.
### Build & Quality
```bash
@ -105,14 +113,16 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.17
- php - 8.4
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/ai (AI) - v0
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/framework (LARAVEL) - v13
- laravel/pennant (PENNANT) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/wayfinder (WAYFINDER) - v0
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
@ -131,12 +141,14 @@ This application is a Laravel application and its main Laravel ecosystems packag
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pennant-development` — Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features.
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems.
- `wayfinder-development` — Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions.
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using &lt;Link&gt;, &lt;Form&gt;, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `ai-sdk-development` — TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly.
- `fortify-development` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
## Conventions
@ -171,19 +183,23 @@ This project has domain-specific skills available. You MUST activate the relevan
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
## Artisan Commands
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`).
- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
## Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly.
- To read configuration values, read the config files directly or run `php artisan config:show [key]`.
- To inspect routes, run `php artisan route:list` directly.
- To check environment variables, read the `.env` file directly.
## Reading Browser Logs With the `browser-logs` Tool
@ -249,7 +265,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== inertia-laravel/v2 rules ===
=== inertia-laravel/core rules ===
# Inertia
@ -261,14 +277,14 @@ protected function isAccessible(User $user, ?string $path = null): bool
# Inertia v2
- Use all Inertia features from v1 and v2. Check the documentation before making changes to ensure the correct approach.
- New features: deferred props, infinite scrolling (merging props + `WhenVisible`), lazy loading on scroll, polling, prefetching.
- New features: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
- When using deferred props, add an empty state with a pulsing or animated skeleton.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
@ -282,7 +298,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
### APIs & Eloquent Resources
@ -319,39 +335,6 @@ protected function isAccessible(User $user, ?string $path = null): bool
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pennant/core rules ===
# Laravel Pennant
- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
- IMPORTANT: Always use `search-docs` tool for version-specific Pennant documentation and updated code examples.
- IMPORTANT: Activate `pennant-development` every time you're working with a Pennant or feature-flag-related task.
=== wayfinder/core rules ===
# Laravel Wayfinder
@ -378,8 +361,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== inertia-react/core rules ===
@ -387,14 +368,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- IMPORTANT: Activate `inertia-react-development` when working with Inertia React client-side patterns.
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
=== laravel/fortify rules ===
# Laravel Fortify

View File

@ -1,16 +0,0 @@
whisper.money.local {
# Use mkcert certificates (generated by setup script)
# These certificates are automatically trusted by browsers
tls /etc/caddy/certs/whisper.money.local.pem /etc/caddy/certs/whisper.money.local-key.pem
reverse_proxy host.docker.internal:8000
header {
-Server
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
}
encode zstd gzip
}

View File

@ -1,6 +1,8 @@
# Use the official PHP 8.4 FPM image
FROM php:8.4-fpm
ARG SENTRY_RELEASE
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
@ -30,9 +32,12 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
# Install Bun
RUN curl -fsSL https://bun.com/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# Install Bun in a non-root path so supervisor workers running as www-data can execute it
ENV BUN_INSTALL="/usr/local/bun"
RUN curl -fsSL https://bun.com/install | bash \
&& ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun \
&& chmod -R a+rx /usr/local/bun
ENV PATH="/usr/local/bun/bin:${PATH}"
# Install memcached and redis servers
RUN apt-get update && apt-get install -y memcached redis-server && rm -rf /var/lib/apt/lists/*
@ -40,18 +45,23 @@ RUN apt-get update && apt-get install -y memcached redis-server && rm -rf /var/l
# Set working directory
WORKDIR /app
ENV SENTRY_RELEASE=${SENTRY_RELEASE}
# Copy dependency manifests first so Docker can reuse install layers across code changes
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader
COPY package.json bun.lock ./
COPY scripts ./scripts
RUN bun install --frozen-lockfile
# Copy application files
COPY . /app/.
RUN composer dump-autoload --no-dev --optimize
# Create necessary directories
RUN mkdir -p /var/log/nginx && mkdir -p /var/cache/nginx
# Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader
# Install Node.js dependencies
RUN bun install --frozen-lockfile
# Build assets with HIDE_AUTH_BUTTONS=false so Wayfinder generates all routes
# (HIDE_AUTH_BUTTONS will be set to true at runtime via .env)
RUN HIDE_AUTH_BUTTONS=false bun run build:ssr

55
ONBOARDING.md Normal file
View File

@ -0,0 +1,55 @@
# Welcome to Whisper Money
## How We Use Claude
Based on Víctor Falcón's usage over the last 30 days:
Work Type Breakdown:
Debug Fix ████████████████████ 100%
Top Skills & Commands:
/sentry-cli ████████████████████ 1x/month
Top MCP Servers:
_None used in this window_
## Your Setup Checklist
### Codebases
- [ ] whisper-money — https://github.com/whisper-money/whisper-money
### MCP Servers to Activate
- [ ] _None required yet_ — the team hasn't leaned on MCP servers in this window. Sentry access is via the `/sentry-cli` skill (see below).
### Skills to Know About
- [ ] `/sentry-cli` — drives Sentry from the command line (view issues, events, projects, orgs, make API calls). The team uses it when triaging and fixing flagged errors before opening a PR.
## Team Tips
_TODO_
## Get Started
_TODO_
<!-- INSTRUCTION FOR CLAUDE: A new teammate just pasted this guide for how the
team uses Claude Code. You're their onboarding buddy — warm, conversational,
not lecture-y.
Open with a warm welcome — include the team name from the title. Then: "Your
teammate uses Claude Code for [list all the work types]. Let's get you started."
Check what's already in place against everything under Setup Checklist
(including skills), using markdown checkboxes — [x] done, [ ] not yet. Lead
with what they already have. One sentence per item, all in one message.
Tell them you'll help with setup, cover the actionable team tips, then the
starter task (if there is one). Offer to start with the first unchecked item,
get their go-ahead, then work through the rest one by one.
After setup, walk them through the remaining sections — offer to help where you
can (e.g. link to channels), and just surface the purely informational bits.
Don't invent sections or summaries that aren't in the guide. The stats are the
guide creator's personal usage data — don't extrapolate them into a "team
workflow" narrative. -->

View File

@ -22,7 +22,7 @@ Whisper Money is a privacy-first personal finance application that helps you tra
> 🎮 **Try the Demo:** Experience Whisper Money with our [demo account](https://whisper.money/login?demo=1) - no registration required!
> 💬 **Join our Community:** Whether you're a user looking for help or a developer wanting to contribute, we'd love to have you in our [Discord server](https://discord.gg/2WZmDW9QZ8)! Share feedback, ask questions, discuss new features, or just hang out with fellow privacy enthusiasts.
> 💬 **Join our Community:** Whether you're a user looking for help or a developer wanting to contribute, we'd love to have you in our [Discord server](https://discord.gg/m8hUhx6D9D)! Share feedback, ask questions, discuss new features, or just hang out with fellow privacy enthusiasts.
## Features
@ -51,7 +51,7 @@ The easiest way to get started is using our automated setup script:
bash <(curl -fsSL https://whisper.money/setup.sh)
```
After installation, just visit **<https://whisper.money.local>** in your browser.
After installation, just visit **<https://whisper.money.localhost>** in your browser.
### Manual Setup
@ -100,12 +100,12 @@ composer run dev
This will concurrently start:
- PHP development server
- PHP development server (via [Portless](https://portless.sh) HTTPS proxy)
- Queue worker
- Log viewer (Pail)
- Vite dev server
The application will be available at **<https://whisper.money.local>** or **<http://localhost:8000>** (direct PHP server).
The application will be available at **<https://dev.whisper.money.localhost>**. In git worktrees, the branch name is automatically prepended (e.g. `https://fix-ui.dev.whisper.money.localhost`).
## Running with Docker (Production Image)
@ -171,7 +171,7 @@ The template includes:
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=whisper-money/whisper-money&type=date&legend=top-left)](https://www.star-history.com/#whisper-money/whisper-money&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/chart?repos=whisper-money/whisper-money&type=date&legend=top-left&sealed_token=aCH1_aXcn3SWzmvWphdcfCvsm_6KewnWP3tJmVYtXio1ulzirYZDej6gnPuguhtXgvs-urR1q_t6r4-5PWBz8ecaY2G18_a75NOMb5iJVO91OGyBtst20bXRD_tv2p7dpvTDbv3HybWdqnve-rp14PvHDqxoZR-47g4IZusfNCz-5O8Gqv0TItLJBkaA)](https://www.star-history.com/?type=date&legend=top-left&repos=whisper-money%2Fwhisper-money)
## License

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions\Ai;
use App\Jobs\CategorizeUncategorizedTransactionsJob;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class StartCategorizationBackfill
{
public function __construct(private readonly AiCategorizationGate $gate) {}
/**
* Dispatch a categorization backfill when the user is eligible and has
* something to categorize, seeding the progress cache the client polls.
*
* @return array{job_id: string, total: int}|null
*/
public function handle(User $user): ?array
{
if (! $this->gate->allows($user)) {
return null;
}
$total = Transaction::query()
->where('user_id', $user->id)
->pendingAiCategorization()
->count();
if ($total === 0) {
return null;
}
$jobId = (string) Str::uuid();
Cache::put(
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($user->id, $jobId),
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0],
now()->addHour(),
);
CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId);
return ['job_id' => $jobId, 'total' => $total];
}
}

View File

@ -2,22 +2,67 @@
namespace App\Actions;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class CreateDefaultCategories
{
/**
* Create default categories for a newly registered user.
* Create default categories for a newly registered user, nesting child
* categories under their configured parent. Categories live in a space, so
* seeding targets the given space (defaulting to the user's active one).
*/
public function handle(User $user): void
public function handle(User $user, ?Space $space = null): void
{
$space ??= $user->activeSpace();
$locale = $user->locale ?? app()->getLocale();
$defaultCategories = self::getDefaultCategories($locale);
foreach ($defaultCategories as $category) {
$user->categories()->firstOrCreate(
['name' => $category['name']],
$category
$existingCategories = $space->categories()
->whereIn('name', array_column($defaultCategories, 'name'))
->pluck('id', 'name');
$now = now();
$pending = collect($defaultCategories)
->reject(fn (array $category): bool => $existingCategories->has($category['name']))
->map(fn (array $category): array => [
...$category,
'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value,
'id' => (string) Str::uuid(),
'user_id' => $user->id,
'space_id' => $space->id,
'created_at' => $now,
'updated_at' => $now,
]);
if ($pending->isEmpty()) {
return;
}
$idsByName = $existingCategories->merge($pending->pluck('id', 'name'));
[$children, $roots] = $pending->partition(fn (array $category): bool => isset($category['parent']));
if ($roots->isNotEmpty()) {
Category::query()->insert(
$roots->map(fn (array $category): array => Arr::except($category, 'parent'))->values()->all()
);
}
if ($children->isNotEmpty()) {
Category::query()->insert(
$children
->map(fn (array $category): array => [
...Arr::except($category, 'parent'),
'parent_id' => $idsByName[$category['parent']] ?? null,
])
->values()
->all()
);
}
}
@ -25,7 +70,7 @@ class CreateDefaultCategories
/**
* Get the default categories configuration for a given locale.
*
* @return array<int, array{name: string, icon: string, color: string, type: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
*/
public static function getDefaultCategories(string $locale = 'en'): array
{
@ -37,6 +82,10 @@ class CreateDefaultCategories
return array_map(function (array $category) use ($translations) {
$category['name'] = $translations[$category['name']] ?? $category['name'];
if (isset($category['parent'])) {
$category['parent'] = $translations[$category['parent']] ?? $category['parent'];
}
return $category;
}, $categories);
}
@ -45,9 +94,10 @@ class CreateDefaultCategories
}
/**
* Get the base (English) categories configuration.
* Get the base (English) categories configuration. A `parent` entry nests
* the category under the default category with that (English) name.
*
* @return array<int, array{name: string, icon: string, color: string, type: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
*/
private static function getBaseCategories(): array
{
@ -60,30 +110,35 @@ class CreateDefaultCategories
],
[
'name' => 'Cafes, restaurants, bars',
'parent' => 'Food',
'icon' => 'Wine',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Groceries',
'parent' => 'Food',
'icon' => 'ShoppingBasket',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Tobacco and alcohol',
'parent' => 'Food',
'icon' => 'Cigarette',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Other groceries',
'parent' => 'Food',
'icon' => 'ShoppingBasket',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Food delivery',
'parent' => 'Food',
'icon' => 'Pizza',
'color' => 'red',
'type' => 'expense',
@ -96,42 +151,49 @@ class CreateDefaultCategories
],
[
'name' => 'Electricity',
'parent' => 'Utility services',
'icon' => 'Bolt',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Natural gas',
'parent' => 'Utility services',
'icon' => 'Flame',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Rent and maintanence',
'parent' => 'Utility services',
'icon' => 'Wrench',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Telephone, internet, TV, computer',
'parent' => 'Utility services',
'icon' => 'Wifi',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Water',
'parent' => 'Utility services',
'icon' => 'Droplets',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Other utility expenses',
'parent' => 'Utility services',
'icon' => 'Receipt',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Household goods',
'parent' => 'Utility services',
'icon' => 'Home',
'color' => 'orange',
'type' => 'expense',
@ -144,24 +206,28 @@ class CreateDefaultCategories
],
[
'name' => 'Parking',
'parent' => 'Transportation',
'icon' => 'ParkingMeter',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Fuel',
'parent' => 'Transportation',
'icon' => 'Fuel',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Transportation expenses',
'parent' => 'Transportation',
'icon' => 'Ticket',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Vehicle purchase, maintenance',
'parent' => 'Transportation',
'icon' => 'Car',
'color' => 'amber',
'type' => 'expense',
@ -180,36 +246,42 @@ class CreateDefaultCategories
],
[
'name' => 'Gifts',
'parent' => 'Leisure activities, traveling',
'icon' => 'Gift',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Books, newspapers, magazines',
'parent' => 'Leisure activities, traveling',
'icon' => 'BookOpen',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Accommodation, travel expenses',
'parent' => 'Leisure activities, traveling',
'icon' => 'Hotel',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Sport and sports goods',
'parent' => 'Leisure activities, traveling',
'icon' => 'Dumbbell',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Theatre, music, cinema',
'parent' => 'Leisure activities, traveling',
'icon' => 'Clapperboard',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Hobbies and other leisure time activites',
'parent' => 'Leisure activities, traveling',
'icon' => 'Puzzle',
'color' => 'violet',
'type' => 'expense',
@ -222,18 +294,21 @@ class CreateDefaultCategories
],
[
'name' => 'Education and courses',
'parent' => 'Education, health and beauty',
'icon' => 'GraduationCap',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Beauty, cosmetics',
'parent' => 'Education, health and beauty',
'icon' => 'Sparkles',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Health and pharmaceuticals',
'parent' => 'Education, health and beauty',
'icon' => 'HeartPulse',
'color' => 'rose',
'type' => 'expense',
@ -246,6 +321,7 @@ class CreateDefaultCategories
],
[
'name' => 'Online services',
'parent' => 'Online transactions',
'icon' => 'Server',
'color' => 'fuchsia',
'type' => 'expense',
@ -260,19 +336,23 @@ class CreateDefaultCategories
'name' => 'Investments',
'icon' => 'LineChart',
'color' => 'lime',
'type' => 'expense',
'type' => CategoryType::Investment->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => 'expense',
'type' => CategoryType::Savings->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Other investments',
'parent' => 'Investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => 'expense',
'type' => CategoryType::Investment->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Financial services and commission',
@ -312,6 +392,7 @@ class CreateDefaultCategories
],
[
'name' => 'Lottery',
'parent' => 'Gambling',
'icon' => 'TicketPercent',
'color' => 'purple',
'type' => 'expense',
@ -336,6 +417,7 @@ class CreateDefaultCategories
],
[
'name' => 'Other personal transfers',
'parent' => 'Personal transfers',
'icon' => 'ArrowLeftRight',
'color' => 'cyan',
'type' => 'transfer',
@ -411,6 +493,7 @@ class CreateDefaultCategories
'icon' => 'Users',
'color' => 'blue',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Inflow->value,
],
[
'name' => 'Returned payments',

View File

@ -2,7 +2,9 @@
namespace App\Actions\Fortify;
use App\Enums\Locale;
use App\Models\User;
use App\Services\LandingAuthOverrideService;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
@ -11,6 +13,8 @@ class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
public function __construct(private LandingAuthOverrideService $landingAuthOverrideService) {}
/**
* Validate and create a newly registered user.
*
@ -18,6 +22,10 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
if ($this->landingAuthOverrideService->authButtonsHidden(request())) {
abort(404);
}
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
@ -28,13 +36,15 @@ class CreateNewUser implements CreatesNewUsers
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
'timezone' => ['nullable', 'string', 'max:255'],
])->validate();
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
'locale' => $this->detectLocaleFromRequest(),
'locale' => Locale::detectFromHeader(request()->header('Accept-Language'))->value,
'timezone' => $this->normalizeTimezone($input['timezone'] ?? null),
]);
if (! config('mail.email_verification_enabled')) {
@ -45,17 +55,19 @@ class CreateNewUser implements CreatesNewUsers
}
/**
* Detect locale from Accept-Language header.
* Normalize a browser-detected timezone, discarding identifiers PHP does
* not recognize so a hidden auto-detected field can never block registration.
*/
protected function detectLocaleFromRequest(): string
protected function normalizeTimezone(?string $timezone): ?string
{
$acceptLanguage = request()->header('Accept-Language', '');
// Check if Spanish is preferred
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
return 'es';
if ($timezone === null || $timezone === '') {
return null;
}
return 'en';
if (! in_array($timezone, timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC), true)) {
return null;
}
return $timezone;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@ -9,7 +10,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{

View File

@ -0,0 +1,44 @@
<?php
namespace App\Actions\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use Illuminate\Support\Facades\Log;
class DisconnectBankingConnection
{
public function __construct(private BankingProviderInterface $provider) {}
public function handle(BankingConnection $connection, bool $deleteAccounts = false): void
{
if ($connection->isEnableBanking() && $connection->session_id && $connection->isActive()) {
try {
$this->provider->revokeSession($connection->session_id);
} catch (\Throwable $e) {
Log::warning('Failed to revoke EnableBanking session', [
'connection_id' => $connection->id,
'session_id' => $connection->session_id,
'error' => $e->getMessage(),
]);
}
}
if ($deleteAccounts) {
$connection->accounts->each(function ($account): void {
$account->transactions()->delete();
$account->balances()->delete();
$account->delete();
});
} else {
$connection->accounts()->update([
'banking_connection_id' => null,
'external_account_id' => null,
]);
}
$connection->update(['status' => BankingConnectionStatus::Revoked]);
$connection->delete();
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Actions\Subscription;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Log;
/**
* Self-service "money-back guarantee" for the pay_now experiment variant:
* refund the upfront charge, cancel the subscription immediately, and revoke
* the user's bank connections (keeping the data they already imported).
*
* Eligibility is enforced by the caller via ExperimentOffer::canSelfRefund().
* The refund is stamped before the cancel/disconnect steps run so that a
* failure in those steps can never leave a refunded-but-active subscription
* that could be refunded a second time; the cleanup is best-effort and logged.
*/
class RefundSelfServe
{
public function __construct(private DisconnectBankingConnection $disconnect) {}
public function handle(User $user): void
{
$subscription = $user->subscription('default');
if ($subscription === null || $subscription->refunded_at !== null) {
return;
}
$payment = $subscription->latestPayment();
if ($payment !== null) {
$user->refund($payment->asStripePaymentIntent()->id);
}
$subscription->forceFill(['refunded_at' => now()])->save();
try {
$subscription->cancelNow();
$user->bankingConnections()->get()->each(function (BankingConnection $connection): void {
$this->disconnect->handle($connection, deleteAccounts: false);
});
} catch (\Throwable $exception) {
Log::error('Self-serve refund issued but post-refund cleanup failed', [
'user_id' => $user->getKey(),
'subscription_id' => $subscription->getKey(),
'error' => $exception->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
* Maps pre-aggregated transaction groups to categorization rules. The agent
* only ever sees merchant/description signals and the user's own category list
* never full account context and returns a strictly-typed suggestion set.
*/
class RuleSuggestionAgent implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You help a personal-finance app suggest automation rules that auto-categorize
a user's transactions. You are given a JSON object with:
- "transaction_groups": recurring groups of uncategorized transactions. Each has
a "field" (description, creditor_name or debtor_name), a "key", an example
"samples" list, an occurrence "count", an "avg_amount" and a "direction"
(outflow = money spent, inflow = money received).
- "categories": the user's existing categories, each with an "id", a "path"
(parent > child), a "type" and a "direction". Prefer leaf categories.
For each group that clearly belongs to a single category, return one suggestion:
- "group_key": echo the group's "key".
- "match_field": which field to match on (use the group's field).
- "match_operator": "contains" for free-text descriptions, "equals" for clean
counterparty names.
- "match_token": a short, lowercase, DISTINCTIVE substring that appears VERBATIM
in the group's samples (e.g. "mercadona", "netflix"). Never invent text that is
not present. Never use a token so generic it would match unrelated transactions
(avoid words like "compra", "payment", "card").
- "category_id": the id of the best-fitting existing category. An outflow group
must map to a spending category, an inflow group to an income category.
- If, and only if, no existing category fits, leave "category_id" empty and instead
propose "new_category_name" and "new_category_direction" (inflow or outflow).
- "confidence": 0.01.0, how sure you are.
Aim for broad coverage: return a suggestion for EVERY group you can map to a
category with reasonable confidence. Only skip a group when it is genuinely
ambiguous for example internal transfers between the user's own accounts, or a
catch-all group mixing unrelated transactions. Never invent a category you are
unsure about; instead let "confidence" honestly reflect your certainty (the app
filters out low-confidence suggestions itself).
PROMPT;
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'suggestions' => $schema->array()->items(
$schema->object(fn (JsonSchema $schema): array => [
'group_key' => $schema->string()->required(),
'match_field' => $schema->string()->enum(['description', 'creditor_name', 'debtor_name'])->required(),
'match_operator' => $schema->string()->enum(['contains', 'equals'])->required(),
'match_token' => $schema->string()->required(),
'category_id' => $schema->string(),
'new_category_name' => $schema->string(),
'new_category_direction' => $schema->string()->enum(['inflow', 'outflow']),
'confidence' => $schema->number()->required(),
])
)->required(),
];
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
* Assigns each given transaction to one of the user's existing leaf categories.
* The agent only ever sees merchant/description signals and the user's own
* category list never full account context. Categories are referenced by a
* numeric "index" (not their id) so the model cannot hallucinate an identifier,
* and the caller maps the index back to a real category.
*/
class TransactionCategorizationAgent implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You categorize a personal-finance app user's bank transactions. You are given a
JSON object with:
- "transactions": the transactions to categorize. Each has a "ref" (echo it back
verbatim), a "text" (the bank description), an "amount", a "direction"
(outflow = money spent, inflow = money received) and optional "creditor_name"
/ "debtor_name" counterparties.
- "categories": the user's existing LEAF categories. Each has an "index", a "path"
(parent > child), a "type" and a "direction". You may ONLY choose from these.
For each transaction you can confidently place, return one result:
- "ref": echo the transaction's "ref".
- "category_index": the "index" of the best-fitting category. An outflow MUST map
to a spending category, an inflow to an income category. If no category fits,
OMIT this field (do not guess).
- "confidence": 0.01.0, how sure you are of this category for THIS transaction.
- "merchant_unambiguous": true only if the counterparty/merchant reliably maps to
this ONE category for every future transaction (e.g. "Netflix" Subscriptions,
"Mercadona" Groceries). false when the right category depends on the specific
purchase (e.g. "Amazon", "PayPal", a generic bank transfer, an ATM withdrawal).
Let "confidence" honestly reflect your certainty the app filters out low-confidence
results itself. Never invent a category that is not in the provided list.
PROMPT;
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'results' => $schema->array()->items(
$schema->object(fn (JsonSchema $schema): array => [
'ref' => $schema->string()->required(),
'category_index' => $schema->integer(),
'confidence' => $schema->number()->required(),
'merchant_unambiguous' => $schema->boolean()->required(),
])
)->required(),
];
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
class AgentDatabaseCommand extends Command
{
protected $signature = 'agent:db
{query : The SQL query to run}
{--format=json : Output format: "json" or "table"}
{--prod : Run against the production database connection}';
protected $description = 'Run a database query and return the result in the selected format';
public function handle(): int
{
$format = strtolower((string) $this->option('format'));
if (! in_array($format, ['json', 'table'], true)) {
$this->error('Invalid format. Use "json" or "table".');
return self::FAILURE;
}
$connection = $this->option('prod') ? 'prod' : config('database.default');
try {
$rows = array_map(
static fn (object $row): array => (array) $row,
DB::connection($connection)->select($this->argument('query')),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
if ($format === 'json') {
$this->line((string) json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
if ($rows === []) {
$this->info('Empty result set.');
return self::SUCCESS;
}
$this->table(array_keys($rows[0]), $rows);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Models\RealEstateDetail;
use Illuminate\Console\Command;
class ApplyRealEstateRevaluationCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'real-estate:apply-revaluation';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Apply monthly revaluation to real estate accounts based on their annual revaluation percentage';
/**
* Execute the console command.
*/
public function handle(): int
{
$details = RealEstateDetail::query()
->whereNotNull('revaluation_percentage')
->where('revaluation_percentage', '!=', 0)
->with('account')
->get();
$applied = 0;
$skipped = 0;
foreach ($details as $detail) {
$account = $detail->account;
if (! $account) {
$skipped++;
continue;
}
$latestBalance = $account->balances()
->orderByDesc('balance_date')
->first();
if (! $latestBalance) {
$skipped++;
continue;
}
$annualRate = (float) $detail->revaluation_percentage / 100;
$monthlyMultiplier = (1 + $annualRate) ** (1 / 12);
$newBalance = (int) round($latestBalance->balance * $monthlyMultiplier);
$account->balances()->updateOrCreate(
['balance_date' => now()->toDateString()],
['balance' => $newBalance],
);
$applied++;
}
$this->info("Revaluation applied to {$applied} account(s), skipped {$skipped}.");
return self::SUCCESS;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingProvider;
use App\Models\Account;
use App\Models\User;
use Illuminate\Console\Command;
@ -32,7 +33,7 @@ class BackfillAccountIbans extends Command
->whereNull('iban')
->whereNotNull('external_account_id')
->whereNotNull('banking_connection_id')
->whereHas('bankingConnection', fn ($q) => $q->where('provider', 'enablebanking'));
->whereHas('bankingConnection', fn ($q) => $q->where('provider', BankingProvider::EnableBanking));
if ($connectionId) {
$query->where('banking_connection_id', $connectionId);

View File

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\BankingConnection;
use App\Models\Budget;
use App\Models\Category;
use App\Models\Label;
use App\Models\SavedFilter;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class BackfillSpaces extends Command
{
protected $signature = 'spaces:backfill {--chunk=500 : Users processed per batch}';
protected $description = 'Provision a personal space for every user and stamp their existing rows with it';
/**
* Owned tables to backfill. Each row inherits the personal space of its
* user_id. Idempotent: only rows with a null space_id are touched.
*
* @var list<class-string<Model>>
*/
private array $models = [
Account::class,
BankingConnection::class,
Transaction::class,
Category::class,
Label::class,
Budget::class,
AutomationRule::class,
SavedFilter::class,
];
public function handle(): int
{
$chunk = (int) $this->option('chunk');
// Soft-deleted users are included: their accounts/transactions still
// exist and must be stamped too, so a restored account keeps its data
// and the column can eventually go NOT NULL.
$this->info('Provisioning personal spaces…');
User::withTrashed()->whereNull('current_space_id')->chunkById($chunk, function ($users): void {
foreach ($users as $user) {
$user->provisionPersonalSpace();
}
});
$this->info('Backfilling space_id on owned rows…');
Space::query()->where('personal', true)->chunkById($chunk, function ($spaces): void {
foreach ($spaces as $space) {
foreach ($this->models as $model) {
$this->stamp($model, $space->owner_id, $space->id);
}
}
});
$this->info('Done.');
return self::SUCCESS;
}
/**
* @param class-string<Model> $model
*/
private function stamp(string $model, string $ownerId, string $spaceId): void
{
// Go through the query builder rather than Eloquent so soft-deleted rows
// are stamped too (no soft-delete global scope to work around).
DB::table((new $model)->getTable())
->whereNull('space_id')
->where('user_id', $ownerId)
->update(['space_id' => $spaceId]);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Mail\EnableBankingConnectionsCancelledEmail;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class CancelFreeEnableBankingConnectionsCommand extends Command
{
protected $signature = 'banking:cancel-free-enablebanking';
protected $description = 'Close Enable Banking connections for free users at month end';
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
{
$cutoff = now()->subHours(6);
$connections = BankingConnection::query()
->with(['user', 'accounts'])
->whereHas('user')
->where('provider', BankingProvider::EnableBanking)
->where('status', '!=', BankingConnectionStatus::Revoked)
->where('created_at', '<=', $cutoff)
->get();
$count = $connections->count();
if ($count === 0) {
$this->info('No eligible Enable Banking connections found for free users.');
return Command::SUCCESS;
}
$revoked = 0;
$skipped = 0;
$connections
->groupBy('user_id')
->each(function ($userConnections) use ($disconnectBankingConnection, &$revoked, &$skipped): void {
$user = $userConnections->first()?->user;
if (! $user) {
return;
}
if ($user->hasProPlan()) {
$skipped += $userConnections->count();
return;
}
foreach ($userConnections as $connection) {
$disconnectBankingConnection->handle($connection);
$revoked++;
}
if ($user->canReceiveEmails()) {
Mail::to($user)->send(new EnableBankingConnectionsCancelledEmail(
$user,
$userConnections->count(),
));
}
});
$this->info("Revoked {$revoked} Enable Banking connection(s). Skipped paid users: {$skipped}.");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
class CategorizeBackfillCommand extends Command
{
protected $signature = 'ai:categorize-backfill {user : User id or email}';
protected $description = 'Categorize a user\'s existing uncategorized transactions with AI (explicit, opt-in backfill)';
public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): int
{
$user = $this->resolveUser((string) $this->argument('user'));
if ($user === null) {
$this->error('User not found.');
return self::FAILURE;
}
if (! $gate->allows($user)) {
$this->warn('User is not eligible (needs the feature enabled, a pro plan and active AI consent).');
return self::FAILURE;
}
$pendingIds = Transaction::query()
->where('user_id', $user->id)
->whereNull('category_id')
->whereNull('description_iv')
->pluck('id');
if ($pendingIds->isEmpty()) {
$this->info('No uncategorized transactions to backfill.');
return self::SUCCESS;
}
$rulesBefore = $this->aiRuleCount($user);
$applied = 0;
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
// Chunk a fixed snapshot of ids so transactions left blank (below the
// confidence bar) are never re-processed on a later iteration.
$this->withProgressBar(
$pendingIds->chunk($batchSize),
function ($chunkIds) use ($user, $categorizer, &$applied): void {
$chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get();
$outcomes = $categorizer->run($user, new Collection($chunk->all()));
$applied += count(array_filter($outcomes, fn ($outcome): bool => $outcome->applied));
},
);
$this->newLine(2);
$this->components->info(sprintf(
'Backfill complete: %d transaction(s) categorized, %d AI rule(s) learned.',
$applied,
$this->aiRuleCount($user) - $rulesBefore,
));
return self::SUCCESS;
}
private function aiRuleCount(User $user): int
{
return AutomationRule::query()
->where('user_id', $user->id)
->origin(RuleOrigin::Ai)
->count();
}
private function resolveUser(string $identifier): ?User
{
return User::query()->where('email', $identifier)->first()
?? User::query()->find($identifier);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands\Concerns;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
trait FindsUsersWithLegacyEncryption
{
/**
* Users who still have at least one client-side encrypted transaction or account
* (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag).
*
* Subscriptions are eager-loaded so callers can filter with {@see excludeBilledUsers()}
* without an N+1 query.
*
* @return Builder<User>
*/
protected function usersWithLegacyEncryption(): Builder
{
return User::query()->with('subscriptions')->where(function (Builder $query): void {
$query->whereHas('transactions', function (Builder $transactions): void {
$transactions->whereNotNull('description_iv')
->orWhereNotNull('notes_iv');
})->orWhereHas('accounts', function (Builder $accounts): void {
$accounts->whereNotNull('name_iv');
});
});
}
/**
* Drop users who are still being billed. These accounts must never be emailed a
* deletion warning nor deleted while a subscription or trial is active.
*
* @param Collection<int, User> $users
* @return Collection<int, User>
*/
protected function excludeBilledUsers(Collection $users): Collection
{
return $users->reject(fn (User $user): bool => $user->hasActiveSubscriptionOrTrial())->values();
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands\Concerns;
use Illuminate\Console\Command;
/**
* Render Discord report embeds as plain console output, for the --no-discord
* flag on the stats:* report commands.
*
* @mixin Command
*/
trait RendersReportToConsole
{
/**
* @param array<int, array<string, mixed>> $embeds
*/
protected function printEmbeds(array $embeds): void
{
foreach ($embeds as $embed) {
if (! empty($embed['title'])) {
$this->newLine();
$this->line('<options=bold>'.$this->plainText($embed['title']).'</>');
}
if (! empty($embed['description'])) {
$this->line($this->plainText($embed['description']));
}
foreach ($embed['fields'] ?? [] as $field) {
$this->newLine();
$this->line('<options=bold>'.$this->plainText($field['name']).'</>');
$this->line($this->plainText($field['value']));
}
}
}
/**
* Strip Discord markdown (code fences, bold) for readable console output.
*/
private function plainText(string $text): string
{
return trim(str_replace(['```', '**'], '', $text));
}
}

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array
{
return ['open-banking', 'account-mapping'];
return [];
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class DeleteEncryptedDataAccountsCommand extends Command
{
use FindsUsersWithLegacyEncryption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'encryption:delete-accounts
{--days=7 : Spare users active within the last N days}
{--dry-run : List the accounts that would be deleted without touching them}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Soft-delete and anonymize users who still have legacy encrypted data and did not sign in within the grace window';
/**
* Execute the console command.
*/
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$users = $this->excludeBilledUsers(
$this->usersWithLegacyEncryption()
->where('email', '!=', config('app.demo.email'))
->where(function (Builder $query) use ($cutoff): void {
$query->whereNull('last_active_at')
->orWhere('last_active_at', '<', $cutoff);
})
->get()
);
if ($users->isEmpty()) {
$this->info('No accounts to delete.');
return self::SUCCESS;
}
$this->info("Found {$users->count()} account(s) with encrypted data and no activity in the last {$days} day(s).");
if ($this->option('dry-run')) {
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
$user->email,
$user->last_active_at?->toDateTimeString() ?? 'never',
])->all());
$this->info('[dry-run] No accounts deleted.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Soft-delete and anonymize {$users->count()} account(s)?", false)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
$progressBar = $this->output->createProgressBar($users->count());
$progressBar->start();
foreach ($users as $user) {
$user->markAsDeleted();
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Deleted {$users->count()} account(s).");
return self::SUCCESS;
}
}

View File

@ -2,9 +2,11 @@
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingProvider;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Subscription;
class DeleteUserCommand extends Command
{
@ -20,16 +22,16 @@ class DeleteUserCommand extends Command
*
* @var string
*/
protected $description = 'Delete a user and all their associated data';
protected $description = 'Mark a user as deleted while preserving their data';
/**
* Execute the console command.
*/
public function handle(): int
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
{
$email = $this->argument('email');
$user = User::query()->where('email', $email)->first();
$user = User::withTrashed()->where('email', $email)->first();
if (! $user) {
$this->error("User with email '{$email}' not found.");
@ -37,48 +39,75 @@ class DeleteUserCommand extends Command
return self::FAILURE;
}
// Check for active subscriptions
if ($user->subscribed('default')) {
$this->error('Cannot delete user with an active subscription. Please cancel the subscription first.');
if ($user->trashed()) {
$this->info("User '{$email}' is already marked as deleted.");
return self::FAILURE;
return self::SUCCESS;
}
if (! $this->confirm("Are you sure you want to delete user '{$user->name}' ({$user->email}) and all their data?")) {
if (! $this->confirm("Are you sure you want to mark user '{$user->name}' ({$user->email}) as deleted? Their data will be preserved.")) {
$this->info('Deletion cancelled.');
return self::SUCCESS;
}
DB::transaction(function () use ($user) {
// Delete account balances through accounts
foreach ($user->accounts as $account) {
$account->balances()->delete();
}
$subscription = $this->activeSubscription($user);
$enableBankingConnections = $user->bankingConnections()
->with('accounts')
->where('provider', BankingProvider::EnableBanking)
->get();
// Delete all related data
$user->encryptedMessage()->delete();
$user->transactions()->delete();
$user->accounts()->delete();
$user->categories()->delete();
$user->automationRules()->delete();
$user->labels()->delete();
$user->mailLogs()->delete();
if ($subscription && ! $this->confirm("User '{$user->email}' has an active Stripe subscription. Cancel it before deleting the user?")) {
$this->info('Deletion cancelled.');
// Delete user's banks
DB::table('banks')->where('user_id', $user->id)->delete();
return self::SUCCESS;
}
// Delete Cashier subscription data if exists
if ($user->subscriptions()->exists()) {
$user->subscriptions()->delete();
}
if ($enableBankingConnections->isNotEmpty() && ! $this->confirm("User '{$user->email}' has {$enableBankingConnections->count()} Enable Banking connection(s). Revoke them and keep linked accounts as manual accounts?")) {
$this->info('Deletion cancelled.');
// Delete the user
$user->delete();
});
return self::SUCCESS;
}
$this->info("User '{$email}' and all associated data have been deleted successfully.");
if ($subscription) {
$this->cancelSubscription($user, $subscription);
$this->info("Cancelled active Stripe subscription for '{$user->email}'.");
}
foreach ($enableBankingConnections as $connection) {
$disconnectBankingConnection->handle($connection, deleteAccounts: false);
}
if ($enableBankingConnections->isNotEmpty()) {
$this->info("Revoked {$enableBankingConnections->count()} Enable Banking connection(s) for '{$user->email}'.");
}
$user->markAsDeleted();
$this->info("User '{$email}' has been marked as deleted. Their data remains in the database.");
return self::SUCCESS;
}
private function activeSubscription(User $user): ?Subscription
{
$subscription = $user->subscription('default');
if (! $subscription || ! $subscription->valid()) {
return null;
}
return $subscription;
}
private function cancelSubscription(User $user, Subscription $subscription): void
{
if ($user->hasStripeId()) {
$subscription->cancelNow();
return;
}
$subscription->markAsCanceled();
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
class DisconnectBankingConnectionsCommand extends Command
{
protected $signature = 'banking:disconnect
{ids : One or more banking connection IDs, comma-separated}
{--delete-accounts : Also delete linked accounts, transactions and balances}';
protected $description = 'Disconnect (soft-delete) banking connections and revoke their session on the provider';
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
{
$ids = collect(explode(',', (string) $this->argument('ids')))
->map(fn (string $id): string => trim($id))
->filter()
->unique()
->values();
if ($ids->isEmpty()) {
$this->error('No connection IDs provided.');
return Command::FAILURE;
}
$deleteAccounts = $this->option('delete-accounts');
$connections = BankingConnection::query()
->with('accounts')
->whereIn('id', $ids)
->get();
$missing = $ids->diff($connections->pluck('id'));
foreach ($missing as $id) {
$this->warn("Connection not found: {$id}");
}
if ($connections->isEmpty()) {
$this->error('No matching banking connections found.');
return Command::FAILURE;
}
$disconnected = 0;
foreach ($connections as $connection) {
try {
$disconnectBankingConnection->handle($connection, $deleteAccounts);
$this->info("Disconnected connection {$connection->id} ({$connection->aspsp_name}).");
$disconnected++;
} catch (\Throwable $e) {
$this->error("Failed to disconnect {$connection->id}: {$e->getMessage()}");
}
}
$this->info("Disconnected {$disconnected} of {$connections->count()} connection(s).");
return $missing->isEmpty() && $disconnected === $connections->count()
? Command::SUCCESS
: Command::FAILURE;
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Console\Commands;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
/**
* Support command for the live Enable Banking e2e script
* (tests/Browser/live/connect-bank.mjs). It seeds deterministic users, inspects the
* resulting connection, and can force a connection into the expired state so the
* reconnect flow can be exercised. Output is JSON so the script can parse it.
*
* Restricted to local/testing environments it creates passwordful users and fake
* subscriptions and must never run against production data.
*/
class E2eBankingFixtureCommand extends Command
{
protected $signature = 'e2e:banking-fixture {action : seed|inspect|expire} {email? : target user email for inspect/expire}';
protected $description = 'Seed/inspect fixtures for the live Enable Banking e2e script (local only).';
private const ONBOARDING_EMAIL = 'e2e-onboarding@example.test';
private const SETTINGS_EMAIL = 'e2e-settings@example.test';
private const PASSWORD = 'password';
public function handle(): int
{
if (! app()->environment('local', 'testing')) {
$this->error('e2e:banking-fixture is only available in local/testing environments.');
return self::FAILURE;
}
return match ($this->argument('action')) {
'seed' => $this->seed(),
'inspect' => $this->inspect(),
'expire' => $this->expire(),
default => $this->invalidAction(),
};
}
private function seed(): int
{
$onboarding = $this->resetUser(self::ONBOARDING_EMAIL, onboarded: false, pro: false);
$settings = $this->resetUser(self::SETTINGS_EMAIL, onboarded: true, pro: true);
$this->line((string) json_encode([
'onboarding' => ['email' => $onboarding->email, 'password' => self::PASSWORD],
'settings' => ['email' => $settings->email, 'password' => self::PASSWORD],
], JSON_PRETTY_PRINT));
return self::SUCCESS;
}
private function inspect(): int
{
$connection = $this->latestConnection();
if (! $connection) {
$this->line((string) json_encode(['connection' => null]));
return self::SUCCESS;
}
$accountIds = $connection->accounts()->pluck('id');
$this->line((string) json_encode([
'connection' => [
'id' => $connection->id,
'status' => $connection->status->value,
'aspsp_name' => $connection->aspsp_name,
'session_id' => $connection->session_id !== null,
'valid_until' => $connection->valid_until?->toIso8601String(),
'accounts_count' => $accountIds->count(),
'accounts_without_external_id' => $connection->accounts()->whereNull('external_account_id')->count(),
'transactions_count' => Transaction::whereIn('account_id', $accountIds)->count(),
],
], JSON_PRETTY_PRINT));
return self::SUCCESS;
}
private function expire(): int
{
$connection = $this->latestConnection();
if (! $connection) {
$this->error('No connection found for '.$this->argument('email'));
return self::FAILURE;
}
$connection->update([
'status' => BankingConnectionStatus::Expired,
'valid_until' => now()->subDay(),
]);
$this->line((string) json_encode(['expired' => $connection->id]));
return self::SUCCESS;
}
private function resetUser(string $email, bool $onboarded, bool $pro): User
{
// Reuse the user across runs (the model soft-deletes, so recreating the same
// email would collide). Clear everything the flows produce so each run starts
// from a clean slate.
$user = User::withTrashed()->firstWhere('email', $email)
?? User::factory()->create(['email' => $email]);
$user->accounts()->forceDelete();
$user->bankingConnections()->forceDelete();
$user->subscriptions()->delete();
$user->restore();
$user->forceFill([
'email_verified_at' => now(),
'password' => Hash::make(self::PASSWORD),
'onboarded_at' => $onboarded ? now() : null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
if ($pro) {
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_e2e_'.$user->id,
'stripe_status' => 'active',
'stripe_price' => 'price_e2e',
'quantity' => 1,
]);
}
return $user;
}
private function latestConnection(): ?BankingConnection
{
$email = $this->argument('email');
$user = User::where('email', $email)->first();
if (! $user) {
return null;
}
return $user->bankingConnections()->latest()->first();
}
private function invalidAction(): int
{
$this->error('Unknown action. Use one of: seed, inspect, expire.');
return self::FAILURE;
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Stripe\Coupon;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\InvalidRequestException;
class EnsureLaunchCouponsCommand extends Command
{
public const COUPON_FOUNDER_FOREVER = 'wm_founder_forever';
public const COUPON_EARLYBIRD_MONTHLY = 'wm_earlybird_monthly';
public const COUPON_EARLYBIRD_YEARLY = 'wm_earlybird_yearly';
protected $signature = 'stripe:ensure-launch-coupons {--dry-run : Show what would happen without writing to Stripe}';
protected $description = 'Idempotently create the launch invitation coupons in Stripe';
public function handle(): int
{
$stripe = Cashier::stripe();
$dryRun = (bool) $this->option('dry-run');
$monthlyPriceId = $this->resolvePriceId(config('subscriptions.plans.monthly.stripe_lookup_key'));
$yearlyPriceId = $this->resolvePriceId(config('subscriptions.plans.yearly.stripe_lookup_key'));
if ($monthlyPriceId === null || $yearlyPriceId === null) {
$this->error('Unable to resolve monthly/yearly Stripe price IDs. Run `php artisan stripe:sync-prices` first.');
return self::FAILURE;
}
$monthlyProductId = $stripe->prices->retrieve($monthlyPriceId)->product;
$yearlyProductId = $stripe->prices->retrieve($yearlyPriceId)->product;
$coupons = [
[
'id' => self::COUPON_FOUNDER_FOREVER,
'name' => 'WM Founder 100% off forever',
'percent_off' => 100,
'duration' => 'forever',
'applies_to' => null,
],
[
'id' => self::COUPON_EARLYBIRD_MONTHLY,
'name' => 'WM Early Bird 2 months free',
'percent_off' => 100,
'duration' => 'repeating',
'duration_in_months' => 2,
'applies_to' => ['products' => [$monthlyProductId]],
],
[
'id' => self::COUPON_EARLYBIRD_YEARLY,
'name' => 'WM Early Bird 25% off year 1',
'percent_off' => 25,
'duration' => 'once',
'applies_to' => ['products' => [$yearlyProductId]],
],
];
foreach ($coupons as $config) {
$existing = $this->findCoupon($config['id']);
if ($existing !== null) {
$this->info("✔ Coupon `{$config['id']}` already exists.");
continue;
}
if ($dryRun) {
$this->line("[dry-run] Would create coupon `{$config['id']}`.");
continue;
}
$payload = array_filter([
'id' => $config['id'],
'name' => $config['name'],
'percent_off' => $config['percent_off'],
'duration' => $config['duration'],
'duration_in_months' => $config['duration_in_months'] ?? null,
'applies_to' => $config['applies_to'] ?? null,
], fn ($value): bool => $value !== null);
try {
$stripe->coupons->create($payload);
} catch (ApiErrorException $exception) {
$this->error("Failed creating `{$config['id']}`: {$exception->getMessage()}");
return self::FAILURE;
}
$this->info("+ Created coupon `{$config['id']}`.");
}
return self::SUCCESS;
}
private function findCoupon(string $id): ?Coupon
{
try {
return Cashier::stripe()->coupons->retrieve($id);
} catch (InvalidRequestException) {
return null;
}
}
private function resolvePriceId(?string $lookupKey): ?string
{
if ($lookupKey === null || $lookupKey === '') {
return null;
}
$prices = Cashier::stripe()->prices->all([
'lookup_keys' => [$lookupKey],
'limit' => 1,
'expand' => ['data.product'],
]);
return $prices->data[0]->id ?? null;
}
}

View File

@ -11,7 +11,7 @@ class FeatureEnableCommand extends Command
{
use ResolvesFeatures;
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}';
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email, "all" for everyone, or a percentage like "25%" for a random rollout}';
protected $description = 'Enable a feature for a specific user or all users';
@ -36,6 +36,10 @@ class FeatureEnableCommand extends Command
return self::SUCCESS;
}
if (preg_match('/^(\d{1,3})%$/', $target, $matches)) {
return $this->enableForPercentage($featureClass, $featureName, (int) $matches[1]);
}
$user = User::where('email', $target)->first();
if (! $user) {
@ -49,4 +53,23 @@ class FeatureEnableCommand extends Command
return self::SUCCESS;
}
private function enableForPercentage(string $featureClass, string $featureName, int $percentage): int
{
if ($percentage < 1 || $percentage > 100) {
$this->error('Percentage must be between 1 and 100.');
return self::FAILURE;
}
$count = (int) ceil(User::count() * $percentage / 100);
User::inRandomOrder()
->take($count)
->each(fn (User $user) => Feature::for($user)->activate($featureClass));
$this->info("Feature '{$featureName}' enabled for {$count} users (~{$percentage}% of current users).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\LandingAuthOverrideService;
use Illuminate\Console\Command;
class GenerateLandingAuthLinkCommand extends Command
{
public function __construct(private LandingAuthOverrideService $landingAuthOverrideService)
{
parent::__construct();
}
protected $signature = 'landing:auth-link
{--days=7 : Number of days before the link expires}';
protected $description = 'Generate a signed landing page link that unlocks authentication';
public function handle(): int
{
$days = filter_var($this->option('days'), FILTER_VALIDATE_INT);
if ($days === false || $days < 1) {
$this->error('Days must be a positive integer.');
return self::FAILURE;
}
$url = $this->landingAuthOverrideService->generateSignedUrl($days);
$this->line($url);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Services\LoanAmortizationService;
use Illuminate\Console\Command;
class GenerateMonthlyLoanBalances extends Command
{
protected $signature = 'loans:generate-balances';
protected $description = 'Generate monthly balance entries for loan accounts with amortization details';
public function __construct(protected LoanAmortizationService $amortizationService)
{
parent::__construct();
}
public function handle(): int
{
$this->info('Generating monthly loan balances...');
$accounts = Account::query()
->where('type', AccountType::Loan)
->whereHas('loanDetail')
->with('loanDetail')
->get();
$generatedCount = 0;
$skippedCount = 0;
$balanceDate = now()->startOfMonth()->toDateString();
foreach ($accounts as $account) {
$loanDetail = $account->loanDetail;
$existingBalance = AccountBalance::query()
->where('account_id', $account->id)
->where('balance_date', $balanceDate)
->exists();
if ($existingBalance) {
$skippedCount++;
continue;
}
$projectedBalance = $this->amortizationService->getBalanceAtDate(
$loanDetail,
now(),
);
AccountBalance::create([
'account_id' => $account->id,
'balance_date' => $balanceDate,
'balance' => $projectedBalance,
]);
$generatedCount++;
$this->info("Generated balance for: {$account->name} ({$projectedBalance} cents)");
}
$this->info("Generated {$generatedCount} balance entries, skipped {$skippedCount} (already exist)");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Cashier\Cashier;
use RuntimeException;
use Stripe\Exception\ApiErrorException;
use Stripe\PromotionCode;
class GenerateStripePromotionCodesCommand extends Command
{
private const DEFAULT_COUPON_ID = '0E5fAsXG';
protected $signature = 'stripe:generate-promotion-codes
{count : Number of promotion codes to generate}
{--coupon='.self::DEFAULT_COUPON_ID.' : Stripe coupon ID to attach to each code}';
protected $description = 'Generate single-use Stripe promotion codes';
public function handle(): int
{
$count = filter_var($this->argument('count'), FILTER_VALIDATE_INT);
if ($count === false || $count < 1) {
$this->error('Count must be a positive integer.');
return self::FAILURE;
}
$couponId = (string) $this->option('coupon');
$generatedCodes = [];
$rows = [];
$this->info("Generating {$count} single-use promotion codes for coupon {$couponId}...");
for ($index = 1; $index <= $count; $index++) {
try {
$promotionCode = $this->createPromotionCode($couponId, $generatedCodes);
} catch (ApiErrorException $exception) {
$this->error("Failed generating promotion code {$index}: {$exception->getMessage()}");
return self::FAILURE;
}
$generatedCodes[] = $promotionCode->code;
$rows[] = [$index, $promotionCode->id, $promotionCode->code];
}
$this->newLine();
$this->table(['#', 'Stripe ID', 'Code'], $rows);
$this->newLine();
$this->info('Generated '.$count.' promotion code'.($count === 1 ? '' : 's').'.');
return self::SUCCESS;
}
/**
* @param list<string> $generatedCodes
*/
private function createPromotionCode(string $couponId, array $generatedCodes): PromotionCode
{
for ($attempt = 1; $attempt <= 5; $attempt++) {
$code = $this->makeCode($generatedCodes);
try {
return Cashier::stripe()->promotionCodes->create([
'coupon' => $couponId,
'code' => $code,
'max_redemptions' => 1,
]);
} catch (ApiErrorException $exception) {
if ($attempt < 5 && $this->isDuplicateCodeError($exception)) {
continue;
}
throw $exception;
}
}
throw new RuntimeException('Unable to generate a unique promotion code after 5 attempts.');
}
/**
* @param list<string> $generatedCodes
*/
private function makeCode(array $generatedCodes): string
{
do {
$code = 'WM-'.Str::upper(Str::random(10));
} while (in_array($code, $generatedCodes, true));
return $code;
}
private function isDuplicateCodeError(ApiErrorException $exception): bool
{
$message = Str::lower($exception->getMessage());
return str_contains($message, 'code') && str_contains($message, 'exist');
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
use App\Jobs\SendUpdateEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class NotifyEncryptedDataRemovalCommand extends Command
{
use FindsUsersWithLegacyEncryption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'encryption:notify-removal
{--dry-run : List affected users without sending anything}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Warn users with legacy encrypted data that their account will be deleted unless they sign in within 7 days';
private const VIEW = 'encrypted-data-removal';
private const IDENTIFIER = 'encrypted-data-removal';
private const SUBJECT = 'Action required: sign in to keep your Whisper Money account';
/**
* Execute the console command.
*/
public function handle(): int
{
$encryptedUsers = $this->usersWithLegacyEncryption()->get();
// Always report the total scope of legacy encrypted data (soft-deleted users are
// excluded by the model's default scope). This is the signal for deciding whether
// the browser-side encryption code can finally be removed, independent of who we
// actually email below.
$this->info("{$encryptedUsers->count()} non-deleted user(s) still have encrypted data.");
$users = $this->excludeBilledUsers($encryptedUsers);
if ($users->isEmpty()) {
$this->info('No users to warn.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->renderTable($users);
$this->info('[dry-run] No emails sent.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Send the deletion warning to {$users->count()} user(s)?", true)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
$progressBar = $this->output->createProgressBar($users->count());
$progressBar->start();
foreach ($users->values() as $index => $user) {
// Spread over the 'emails' queue at 50/day to match the existing bulk-email convention.
SendUpdateEmailJob::dispatch($user, self::VIEW, self::IDENTIFIER, self::SUBJECT)
->delay(now()->addDays((int) floor($index / 50)));
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Queued {$users->count()} warning email(s) to the 'emails' queue (50/day).");
return self::SUCCESS;
}
/**
* @param Collection<int, User> $users
*/
private function renderTable($users): void
{
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
$user->email,
$user->last_active_at?->toDateTimeString() ?? 'never',
])->all());
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Models\UserLead;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('leads:resend-verification-emails {--dry-run : Show what would happen without dispatching emails}')]
#[Description('Resend verification emails to leads that have not yet verified their email address')]
class ResendLeadVerificationEmailsCommand extends Command
{
public function handle(): int
{
$isDryRun = $this->option('dry-run');
if ($isDryRun) {
$this->warn('DRY RUN — no emails will be dispatched.');
$this->newLine();
}
$leads = UserLead::query()->whereNull('email_verified_at')->get();
if ($leads->isEmpty()) {
$this->info('No unverified leads found.');
return self::SUCCESS;
}
$dispatched = 0;
$progressBar = $this->output->createProgressBar($leads->count());
$progressBar->start();
foreach ($leads as $lead) {
if (! $isDryRun) {
$lead->sendEmailVerificationNotification();
}
$dispatched++;
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$this->table(
['Action', 'Count'],
[
['dispatched'.($isDryRun ? ' (dry run)' : ''), $dispatched],
]
);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Models\UserLead;
use App\Services\ResendService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('resend:sync-leads {--since=24 : Sync leads created within the last N hours. Use 0 to sync all.}')]
#[Description('Sync user leads to the Resend leads segment (last 24h by default)')]
class ResendSyncLeadsCommand extends Command
{
public function handle(ResendService $resendService): int
{
$since = (int) $this->option('since');
if (! config('services.resend.key')) {
$this->error('Resend API key not configured.');
return self::FAILURE;
}
if (! config('services.resend.leads_segment_id')) {
$this->error('Resend leads segment ID not configured.');
return self::FAILURE;
}
$leads = UserLead::query()
->whereNotNull('email_verified_at')
->when($since > 0, fn ($query) => $query->where('created_at', '>=', now()->subHours($since)))
->get();
if ($leads->isEmpty()) {
$this->info('No user leads to sync.');
return self::SUCCESS;
}
$this->info("Syncing {$leads->count()} user leads to Resend...");
$progressBar = $this->output->createProgressBar($leads->count());
$progressBar->start();
$failed = 0;
foreach ($leads as $lead) {
try {
$resendService->syncLead($lead);
} catch (\Exception $exception) {
$failed++;
$this->newLine();
$this->warn("Failed to sync {$lead->email}: {$exception->getMessage()}");
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$synced = $leads->count() - $failed;
$this->info("Synced {$synced} user leads to Resend.");
if ($failed > 0) {
$this->warn("Failed to sync {$failed} user leads.");
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@ -40,6 +40,14 @@ class ResetDemoAccountCommand extends Command
public function handle(): int
{
$demoEnabled = config('app.demo.enabled');
if (! $demoEnabled) {
$this->info('Demo account is not enabled');
return self::SUCCESS;
}
$demoEmail = config('app.demo.email');
$demoPassword = config('app.demo.password');
@ -148,7 +156,7 @@ class ResetDemoAccountCommand extends Command
{
$bbvaBank = Bank::query()->whereNull('user_id')->where('name', 'BBVA')->first()
?? Bank::factory()->create(['user_id' => null]);
$ingBank = Bank::query()->whereNull('user_id')->where('name', 'ING')->first()
$ingBank = Bank::query()->whereNull('user_id')->where('name', 'ING Direct')->first()
?? Bank::factory()->create(['user_id' => null]);
$indexaCapitalBank = Bank::query()->whereNull('user_id')->where('name', 'Indexa Capital')->first()
?? Bank::factory()->create(['user_id' => null]);
@ -468,8 +476,6 @@ class ResetDemoAccountCommand extends Command
$currentDate->addDay();
}
break;
case BudgetPeriodType::Custom:
break;
}
$iteration++;

View File

@ -0,0 +1,134 @@
<?php
namespace App\Console\Commands;
use App\Models\UserLead;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('leads:retry-failed-jobs {--dry-run : Show what would happen without making changes}')]
#[Description('Selectively retry failed lead email jobs, forgetting stale ones whose lead was deleted or is not yet verified')]
class RetryLeadFailedJobsCommand extends Command
{
public function handle(): int
{
$isDryRun = $this->option('dry-run');
if ($isDryRun) {
$this->warn('DRY RUN — no changes will be made.');
$this->newLine();
}
$failedJobs = DB::table('failed_jobs')
->where('queue', 'emails')
->get();
if ($failedJobs->isEmpty()) {
$this->info('No failed email jobs found.');
return self::SUCCESS;
}
$retried = 0;
$forgotten = 0;
$skipped = 0;
$progressBar = $this->output->createProgressBar($failedJobs->count());
$progressBar->start();
foreach ($failedJobs as $job) {
$payload = json_decode($job->payload, true);
$displayName = $payload['displayName'] ?? '';
$command = $payload['data']['command'] ?? '';
$leadId = $this->extractLeadId($command);
if ($leadId === null) {
$skipped++;
$progressBar->advance();
continue;
}
$lead = UserLead::find($leadId);
$action = $this->determineAction($lead, $displayName);
if ($action === 'retry') {
$retried++;
if (! $isDryRun) {
$this->callSilently('queue:retry', ['id' => [$job->uuid]]);
}
} else {
$forgotten++;
if (! $isDryRun) {
$this->callSilently('queue:forget', ['id' => [$job->uuid]]);
}
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$this->table(
['Action', 'Count'],
[
['retried'.($isDryRun ? ' (dry run)' : ''), $retried],
['forgotten'.($isDryRun ? ' (dry run)' : ''), $forgotten],
['skipped (no lead ID found)', $skipped],
]
);
return self::SUCCESS;
}
/**
* Extract the UserLead UUID from a serialized job payload command string.
*
* Handles both queued mailables (id stored as a single string) and
* queued notifications (id stored as a single-element array).
*/
private function extractLeadId(string $command): ?string
{
// Mail jobs store the lead as a single string ID
if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) {
return $matches[1];
}
// Notification jobs store notifiables as an array of IDs
if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";a:\d+:\{i:0;s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) {
return $matches[1];
}
return null;
}
/**
* Decide whether to retry or forget a failed job based on lead state.
*
* Rules:
* - Lead deleted forget (DDoS cleanup or similar)
* - Lead verified retry (Resend rate limit was the issue)
* - Lead unverified + VerifyUserLeadEmailNotification retry (still needs the verification email)
* - Lead unverified + anything else forget (waitlist emails to unverified leads are meaningless)
*/
private function determineAction(?UserLead $lead, string $displayName): string
{
if ($lead === null) {
return 'forget';
}
if ($lead->hasVerifiedEmail()) {
return 'retry';
}
if (str_contains($displayName, 'VerifyUserLeadEmailNotification')) {
return 'retry';
}
return 'forget';
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace App\Console\Commands;
use App\Enums\IntegrationRequestStatus;
use App\Models\IntegrationRequest;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class ReviewIntegrationRequestsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'integration-requests:review {--all : Pick any request from the full list and change its status}';
/**
* @var string
*/
protected $description = 'Review integration requests and change their status';
public function handle(): int
{
return $this->option('all') ? $this->reviewAny() : $this->reviewPending();
}
private function reviewPending(): int
{
$pending = $this->load(IntegrationRequest::query()->where('status', IntegrationRequestStatus::Pending));
if ($pending->isEmpty()) {
$this->info('No pending integration requests.');
return self::SUCCESS;
}
$this->printTable($pending);
$changed = 0;
foreach ($pending as $request) {
$decision = $this->choice(
"Review \"{$request->name}\" ({$request->url})",
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
'skip',
);
if ($decision !== 'skip' && $this->apply($request, $decision)) {
$changed++;
}
}
$this->info("Done. Updated {$changed} of {$pending->count()} requests.");
return self::SUCCESS;
}
private function reviewAny(): int
{
$requests = $this->load(IntegrationRequest::query());
if ($requests->isEmpty()) {
$this->info('No integration requests.');
return self::SUCCESS;
}
$this->printTable($requests, withIndex: true);
$request = $requests->get((int) $this->ask('Which request do you want to update? (number)') - 1);
if ($request === null) {
$this->error('That number is not on the list.');
return self::FAILURE;
}
$this->apply($request, $this->choice(
"New status for \"{$request->name}\"",
['approve', 'in progress', 'reject', 'not doable', 'done'],
));
$this->info("\"{$request->name}\" is now {$request->status->label()}.");
return self::SUCCESS;
}
private function apply(IntegrationRequest $request, string $decision): bool
{
$status = match ($decision) {
'approve' => IntegrationRequestStatus::Approved,
'in progress' => IntegrationRequestStatus::InProgress,
'reject' => IntegrationRequestStatus::Rejected,
'not doable' => IntegrationRequestStatus::NotDoable,
'done' => IntegrationRequestStatus::Done,
default => null,
};
if ($status === null) {
return false;
}
$request->update([
'status' => $status,
'comment' => $this->commentFor($status),
]);
return true;
}
private function commentFor(IntegrationRequestStatus $status): ?string
{
if ($status === IntegrationRequestStatus::NotDoable) {
$comment = $this->ask('Why is this integration not doable? (shown to users)');
while (blank($comment)) {
$comment = $this->ask('A comment is required to mark an integration as not doable');
}
return $comment;
}
if ($status === IntegrationRequestStatus::InProgress) {
return $this->ask('Add a comment for this request (optional, shown to users)') ?: null;
}
// Approving, rejecting, re-queuing or marking done drops any stale public comment.
return null;
}
/**
* @param Builder<IntegrationRequest> $query
* @return Collection<int, IntegrationRequest>
*/
private function load(Builder $query): Collection
{
return $query
->withCount('votes')
// Authors who deleted their account are soft-deleted, but their requests
// linger; load them trashed so the table still shows who submitted each one.
->with(['user' => fn ($user) => $user->withTrashed()->select('id', 'email')])
->orderBy('created_at')
->get();
}
/**
* @param Collection<int, IntegrationRequest> $requests
*/
private function printTable(Collection $requests, bool $withIndex = false): void
{
$headers = ['Name', 'URL', 'Status', 'Submitted by', 'Votes', 'Created'];
if ($withIndex) {
array_unshift($headers, '#');
}
$rows = $requests->values()->map(function (IntegrationRequest $request, int $index) use ($withIndex): array {
$row = [
$request->name,
$request->url,
$request->status->label(),
$request->user->email,
$request->votes_count,
$request->created_at?->format('Y-m-d') ?? '—',
];
if ($withIndex) {
array_unshift($row, $index + 1);
}
return $row;
})->all();
$this->table($headers, $rows);
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Services\Ai\AiCohortReportCollector;
use App\Services\Discord\DiscordWebhook;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
class SendAiCohortReportCommand extends Command
{
use RendersReportToConsole;
protected $signature = 'stats:ai-cohort-report {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post the weekly AI-suggestions cohort retention/conversion report to Discord';
public function __construct(private AiCohortReportCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
$report = $this->collector->collect($weeks);
$embed = $this->buildEmbed($report);
if ($this->option('no-discord')) {
$this->printEmbeds([$embed]);
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$embed]);
$this->info('AI cohort report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{releaseAt: ?CarbonImmutable, releaseWeek: ?string, weeks: list<array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$lines = [sprintf('%-9s %5s %6s %6s %6s %5s', 'Week', 'Elig', 'Ret', 'Trial', 'Paid', 'AI')];
foreach ($report['weeks'] as $row) {
$flags = '';
if ($report['releaseWeek'] !== null && $row['week'] === $report['releaseWeek']) {
$flags .= ' 🚀';
}
if ($row['surge']) {
$flags .= ' ⚡';
}
$lines[] = sprintf(
'%-9s %5d %6s %6s %6s %5s%s',
$row['week'],
$row['eligible'],
$this->rateCell($row['retainedRate'], $row['retentionMature'], $row['eligible']),
$this->rateCell($row['trialRate'], $row['retentionMature'], $row['eligible']),
$this->rateCell($row['paidRate'], $row['paidMature'], $row['eligible']),
$this->aiCell($row['aiAcceptedRate'], $row['eligible']),
$flags,
);
}
$release = $report['releaseAt'] !== null
? 'First AI consent (release anchor): '.$report['releaseAt']->format('D, d M Y').' · week '.$report['releaseWeek'].' 🚀'
: 'No AI consent recorded yet — feature not live in production.';
return [
'title' => '🤖 AI Suggestions — Weekly Cohort Report',
'description' => "```\n".implode("\n", $lines)."\n```",
'color' => 0x5865F2,
'fields' => [
[
'name' => 'Release anchor',
'value' => $release,
'inline' => false,
],
[
'name' => 'Legend',
'value' => 'Elig = users with ≥50 transactions in their first 7 days · Ret = active ≥14d after signup · Trial = subscribed ≤14d · Paid = active subscription ≤30d · AI = accepted AI consent · `pend` = cohort too young to score · ⚡ = signup surge',
'inline' => false,
],
[
'name' => '⚠️ Directional only',
'value' => 'Pre/post comparison, not a randomised test. Cohorts are compared at equal age. Surge weeks (⚡, e.g. launch/YouTube) differ in acquisition channel and are not controlled — compare organic weeks like-for-like. Confidence builds over a quarter, not a single month.',
'inline' => false,
],
],
];
}
private function rateCell(?float $rate, bool $mature, int $eligible): string
{
if ($eligible === 0) {
return '—';
}
if (! $mature || $rate === null) {
return 'pend';
}
return ((int) round($rate * 100)).'%';
}
private function aiCell(?float $rate, int $eligible): string
{
if ($eligible === 0 || $rate === null) {
return '—';
}
return ((int) round($rate * 100)).'%';
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
use App\Models\AiConsent;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
class SendAiConsentFollowUpEmailsCommand extends Command
{
protected $signature = 'email:ai-consent-follow-up';
protected $description = 'Queue the AI consent follow-up email for users who opted into AI two days ago, outside of onboarding';
/**
* Consent given more than this many days after sign-up is treated as a
* deliberate, post-onboarding opt-in (the audience for this email).
*/
private const ONBOARDING_GRACE_DAYS = 3;
public function handle(): int
{
if (! config('mail.drip_emails_enabled')) {
$this->info('Drip emails are disabled. Nothing to do.');
return self::SUCCESS;
}
$queued = 0;
AiConsent::query()
->active()
->whereDate('accepted_at', today()->subDays(2))
->with('user')
->chunkById(100, function (Collection $consents) use (&$queued): void {
foreach ($consents as $consent) {
$user = $consent->user;
if ($user === null) {
continue;
}
if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(self::ONBOARDING_GRACE_DAYS))) {
continue;
}
SendAiConsentFollowUpEmailJob::dispatch($user);
$queued++;
}
});
$this->info("Queued {$queued} AI consent follow-up email(s).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Models\User;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Stripe\Exception\ApiErrorException;
class SendDailyStatsReportCommand extends Command
{
use RendersReportToConsole;
protected $signature = 'stats:daily-report {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post yesterday\'s user and Stripe subscription stats to the Discord admin channel';
private const TIMEZONE = 'Europe/Madrid';
public function __construct(
private SubscriptionStatsCollector $collector,
private DiscordWebhook $discord,
) {
parent::__construct();
}
public function handle(): int
{
try {
$stats = $this->collector->collect();
} catch (ApiErrorException $exception) {
$this->error("Stripe API error: {$exception->getMessage()}");
return self::FAILURE;
}
$yesterdayStart = Carbon::now(self::TIMEZONE)->subDay()->startOfDay();
$todayStart = Carbon::now(self::TIMEZONE)->startOfDay();
$newUsers = User::query()
->whereBetween('created_at', [$yesterdayStart->copy()->utc(), $todayStart->copy()->utc()])
->count();
$totalUsers = User::query()
->where('created_at', '<', $todayStart->copy()->utc())
->count();
$embed = $this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart);
if ($this->option('no-discord')) {
$this->printEmbeds([$embed]);
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$this->discord->send('', [$embed]);
$this->info('Daily stats report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{active: array<string, array{count: int, mrr: float}>, trialing: array<string, array{count: int, mrr: float}>} $stats
* @return array<string, mixed>
*/
private function buildEmbed(array $stats, int $newUsers, int $totalUsers, Carbon $day): array
{
$fields = [
[
'name' => '👥 Users',
'value' => "New yesterday: **{$newUsers}**\nTotal: **{$totalUsers}**",
'inline' => false,
],
];
$currencies = array_unique(array_merge(array_keys($stats['active']), array_keys($stats['trialing'])));
sort($currencies);
foreach ($currencies as $currency) {
$active = $stats['active'][$currency] ?? ['count' => 0, 'mrr' => 0.0];
$trialing = $stats['trialing'][$currency] ?? ['count' => 0, 'mrr' => 0.0];
$currentMrr = $active['mrr'];
$projectedMrr = $active['mrr'] + $trialing['mrr'];
$fields[] = [
'name' => '💳 '.strtoupper($currency),
'value' => implode("\n", [
"Active: **{$active['count']}** ({$this->money($currentMrr, $currency)} MRR)",
"Trialing: **{$trialing['count']}** ({$this->money($trialing['mrr'], $currency)} MRR)",
"Current MRR/ARR: **{$this->money($currentMrr, $currency)}** / **{$this->money($currentMrr * 12, $currency)}**",
"Projected MRR/ARR: **{$this->money($projectedMrr, $currency)}** / **{$this->money($projectedMrr * 12, $currency)}**",
]),
'inline' => false,
];
}
if ($currencies === []) {
$fields[] = [
'name' => '💳 Subscriptions',
'value' => 'No active or trialing subscriptions.',
'inline' => false,
];
}
return [
'title' => '📊 Daily Stats — '.$day->format('D, d M Y'),
'color' => 0x5865F2,
'fields' => $fields,
];
}
/**
* MRR figures are held in major units (e.g. euros), so convert to cents for
* the shared formatter.
*/
private function money(float $amount, string $currency): string
{
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -0,0 +1,212 @@
<?php
namespace App\Console\Commands;
use App\Features\SubscriptionExperiment;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\BinomialProportion;
use App\Services\Stats\ExperimentFunnelCollector;
use App\Services\Stats\ProportionSignificance;
use App\Support\Money;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
class SendExperimentFunnelReportCommand extends Command
{
protected $signature = 'stats:experiment-funnel
{--no-discord : Print the report to the console only, without posting to Discord}
{--cost-per-connection=0.4 : Estimated cost (in the Cashier currency) per bank connection, used for the Cost/Burn/CM columns}';
protected $description = 'Post the trial/pricing experiment funnel (per variant) to Discord';
private const LABELS = [
SubscriptionExperiment::CONTROL => 'control',
SubscriptionExperiment::REDUCED_TRIAL => 'reduced',
SubscriptionExperiment::PAY_NOW => 'pay_now',
];
public function __construct(
private ExperimentFunnelCollector $collector,
private ProportionSignificance $significance,
) {
parent::__construct();
}
public function handle(): int
{
$costPerConnectionCents = (int) round(((float) $this->option('cost-per-connection')) * 100);
$report = $this->collector->collect($costPerConnectionCents);
if ($report['startedAt'] === null) {
$this->warn('Experiment not started — set SUBSCRIPTION_EXPERIMENT_STARTED_AT to begin.');
return self::SUCCESS;
}
foreach ($this->tableLines($report) as $line) {
$this->line($line);
}
foreach ($this->significanceLines($report) as $line) {
$this->line($line);
}
if ($this->option('no-discord')) {
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Experiment funnel report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$revenue = $report['revenueAvailable'];
$currency = $report['currency'];
$lines = [sprintf(
'%-8s %5s %5s %5s %5s %5s %6s %7s %7s %7s %7s %7s',
'Variant', 'Assg', 'Actd', 'Card', 'MatU', 'Conv', 'Conv%', 'ARPU', 'MRR', 'Cost', 'Burn', 'CM',
)];
foreach (self::LABELS as $key => $label) {
$row = $report['variants'][$key];
$mature = $row['assignedMature'] > 0;
$showMoney = $revenue && $mature;
$lines[] = sprintf(
'%-8s %5d %5d %5d %5d %5d %6s %7s %7s %7s %7s %7s',
$label,
$row['assigned'],
$row['activated'],
$row['subscribed'],
$row['assignedMature'],
$row['convertedMature'],
$mature ? ((int) round($row['conversionRate'] * 100)).'%' : 'pend',
$showMoney && $row['arpuCents'] !== null ? Money::format($row['arpuCents'], $currency) : '—',
$showMoney ? Money::format($row['mrrCents'], $currency) : '—',
$mature ? Money::format($row['costCents'], $currency) : '—',
$mature ? Money::format($row['wastedCostCents'], $currency) : '—',
$showMoney ? Money::format($row['contributionMarginCents'], $currency) : '—',
);
}
return $lines;
}
/**
* Per-variant conversion-rate uncertainty (95% Wilson interval) plus the
* leader-vs-runner-up verdict from {@see ProportionSignificance} a Fisher
* exact test and a Newcombe difference interval, Bonferroni-corrected so
* "check significance before calling a winner" has the numbers behind it.
*
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return list<string>
*/
private function significanceLines(array $report): array
{
$lines = ['', 'Significance (95% Wilson CI on Conv%, n = MatU):'];
$arms = [];
foreach (self::LABELS as $key => $label) {
$row = $report['variants'][$key];
$n = (int) $row['assignedMature'];
$k = (int) $row['convertedMature'];
if ($n <= 0) {
$lines[] = sprintf(' %-8s pend (n=0)', $label);
continue;
}
[$low, $high] = $this->significance->wilsonInterval($k, $n);
$lines[] = sprintf(' %-8s %6s [%6s %6s] (n=%d)', $label, $this->percent($k / $n), $this->percent($low), $this->percent($high), $n);
$arms[] = new BinomialProportion($label, $k, $n);
}
if (count($arms) < 2) {
$lines[] = 'Not enough matured variants to compare yet.';
return $lines;
}
usort($arms, fn (BinomialProportion $a, BinomialProportion $b): int => $b->rate() <=> $a->rate());
[$leader, $runnerUp] = [$arms[0], $arms[1]];
$result = $this->significance->compare($leader, $runnerUp);
$lines[] = sprintf(
'Leader %s vs %s: Δ %+.1f pts (95%% CI %+.1f … %+.1f pts, Newcombe).',
$leader->label, $runnerUp->label,
($leader->rate() - $runnerUp->rate()) * 100, $result['diffLow'] * 100, $result['diffHigh'] * 100,
);
$lines[] = sprintf(
'Fisher exact p=%.3f %s α=%.3f (Bonferroni×3) -> %s.%s',
$result['fisherP'], $result['significant'] ? '<' : '≥', $result['alpha'],
$result['significant'] ? 'significant' : 'not significant',
$result['significant'] ? '' : ' Keep running.',
);
if ($result['minExpectedCount'] < 5.0) {
$lines[] = sprintf(
'(Small sample: min expected conversions %.1f < 5, so the normal-approx z=%.2f overstates — exact test used.)',
$result['minExpectedCount'], $result['z'],
);
}
return $lines;
}
private function percent(float $rate): string
{
return number_format($rate * 100, 1).'%';
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
return [
'title' => '🧪 Trial/Pricing Experiment — Funnel by Variant',
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
'color' => 0xFEE75C,
'fields' => [
[
'name' => 'Started',
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
'inline' => false,
],
[
'name' => '📊 Significance',
'value' => "```\n".implode("\n", $this->significanceLines($report))."\n```",
'inline' => false,
],
[
'name' => 'Legend',
'value' => sprintf(
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Conv = matured users who ever converted (were charged, net of refund) — time-invariant, so it does not shrink as an older cohort has longer to churn · Conv%% = Conv ÷ MatU (always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of *currently* paying subs (yearly ÷ 12); Conv above MRR is churn · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR Cost · `pend`/`—` = no matured data yet.',
Money::format($report['costPerConnectionCents'], $report['currency']),
),
'inline' => false,
],
[
'name' => '⚠️ How to read it',
'value' => 'Each variant matures on its own decision window (control 15d, reduced 7d, pay_now 3d, +3d settle), so at any moment MatU differs a lot between variants (pay_now matures first). **Compare variants on Conv% and ARPU — normalized per matured user — not on the absolute MRR/Cost/Burn/CM totals, which scale with MatU and so mechanically favour whichever variant has matured more.** Assg/Actd/Card are lifetime counts; everything from MatU rightward covers the matured cohort only, so the raw Actd→Card→Conv funnel mixes cohorts (immature carded users can\'t have matured yet) — read it for volume. Conv counts anyone ever charged (net of refund), so it is not depressed for older cohorts the way a live-active snapshot would be. Per-user CM is sub-cent at current volume, so treat CM as directional context, not the decision. Check significance (sample size = MatU) before calling a winner. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
'inline' => false,
],
],
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendPaywallFollowUpEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
class SendPaywallFollowUpEmailsCommand extends Command
{
protected $signature = 'email:paywall-follow-up';
protected $description = 'Queue the paywall follow-up email for users who completed onboarding yesterday but are stuck on the paywall';
public function handle(): int
{
if (! config('mail.drip_emails_enabled')) {
$this->info('Drip emails are disabled. Nothing to do.');
return self::SUCCESS;
}
$query = User::query()
->whereDate('onboarded_at', today()->subDay())
->whereHas('bankingConnections')
->whereDoesntHave('mailLogs', function ($query): void {
$query->where('email_type', DripEmailType::PaywallFollowUp);
});
$queued = 0;
$query->chunkById(100, function ($users) use (&$queued): void {
foreach ($users as $user) {
if ($user->hasProPlan()) {
continue;
}
SendPaywallFollowUpEmailJob::dispatch($user);
$queued++;
}
});
$this->info("Queued {$queued} paywall follow-up email(s).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Models\StuckCohortSnapshot;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\StuckCohortReportCollector;
use Illuminate\Console\Command;
class SendStuckCohortReportCommand extends Command
{
use RendersReportToConsole;
protected $signature = 'stats:stuck-cohort-report {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post the weekly paywall stuck-cohort report (banked users without a valid subscription) to Discord';
public function __construct(private StuckCohortReportCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$report = $this->collector->collect();
$embed = $this->buildEmbed($report);
if ($this->option('no-discord')) {
$this->printEmbeds([$embed]);
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$embed]);
$this->info('Stuck cohort report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{snapshot: StuckCohortSnapshot, previous: ?StuckCohortSnapshot, pctDelta: ?float, stuckDelta: ?int} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$snapshot = $report['snapshot'];
$lines = [
sprintf('Stuck %d', $snapshot->stuck_count),
sprintf('Onboarded %d', $snapshot->onboarded_count),
sprintf('Stuck rate %s%%', $this->formatPct((float) $snapshot->stuck_pct)),
];
if ($report['previous'] !== null) {
$lines[] = '';
$lines[] = sprintf(
'vs %s: %s pp · %s stuck',
$report['previous']->date->format('d M'),
$this->formatSignedPct((float) $report['pctDelta']),
$this->formatSignedInt((int) $report['stuckDelta']),
);
} else {
$lines[] = '';
$lines[] = 'First snapshot — no previous week to compare.';
}
return [
'title' => '🪤 Paywall — Weekly Stuck Cohort',
'description' => "```\n".implode("\n", $lines)."\n```",
'color' => 0xED4245,
'fields' => [
[
'name' => 'Definition',
'value' => 'Stuck = onboarded users with a non-deleted banking connection but no valid subscription (active/trialing/past_due, or canceled but still within the grace period).',
'inline' => false,
],
[
'name' => 'Denominator',
'value' => 'Stuck rate = stuck / onboarded users (`onboarded_at` not null) — the population that has reached the paywall.',
'inline' => false,
],
],
];
}
private function formatPct(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function formatSignedPct(float $value): string
{
$sign = $value > 0 ? '+' : '';
return $sign.$this->formatPct($value);
}
private function formatSignedInt(int $value): string
{
return ($value > 0 ? '+' : '').$value;
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Console\Commands;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\SubscriptionFunnelCollector;
use Illuminate\Console\Command;
class SendSubscriptionFunnelReportCommand extends Command
{
protected $signature = 'stats:subscription-funnel {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post the weekly registration -> subscription -> paid funnel to Discord';
public function __construct(private SubscriptionFunnelCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
$report = $this->collector->collect($weeks);
foreach ($this->tableLines($report) as $line) {
$this->line($line);
}
if ($this->option('no-discord')) {
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Subscription funnel report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$lines = [sprintf('%-9s %5s %5s %5s %5s %5s %5s', 'Week', 'Reg', 'Sub', 'Sub%', 'Paid', 'Pd%', 'T2P')];
foreach ($report['weeks'] as $row) {
$lines[] = sprintf(
'%-9s %5d %5d %5s %5d %5s %5s%s',
$row['week'],
$row['registered'],
$row['subscribed'],
$this->rateCell($row['subscribedRate'], $row['subscribedMature'], $row['registered']),
$row['paid'],
$this->rateCell($row['paidRate'], $row['paidMature'], $row['registered']),
$this->rateCell($row['trialToPaidRate'], $row['paidMature'], $row['subscribed']),
$row['surge'] ? ' ⚡' : '',
);
}
return $lines;
}
/**
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$mature = array_values(array_filter($report['weeks'], fn (array $row): bool => $row['paidMature']));
$registered = array_sum(array_column($mature, 'registered'));
$subscribed = array_sum(array_column($mature, 'subscribed'));
$paid = array_sum(array_column($mature, 'paid'));
$totals = $registered > 0
? sprintf(
"Registered %d\nSubscribed %d (%s%%)\nPaid %d (%s%% of reg · %s%% of subs)",
$registered,
$subscribed,
$this->pct($subscribed / $registered),
$paid,
$this->pct($paid / $registered),
$subscribed > 0 ? $this->pct($paid / $subscribed) : '—',
)
: 'No mature cohorts yet.';
return [
'title' => '💸 Subscription Funnel — Weekly Cohorts',
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
'color' => 0x57F287,
'fields' => [
[
'name' => 'Mature cohorts (baseline)',
'value' => "```\n".$totals."\n```",
'inline' => false,
],
[
'name' => 'Legend',
'value' => 'Reg = signups · Sub = started a plan ≤30d after signup · Paid = that plan billed past the '.$report['trialDays'].'d trial (active, or canceled only after billing) · Sub%/Pd% of signups · T2P = paid ÷ subscribed · `pend` = cohort too young to score · ⚡ = signup surge',
'inline' => false,
],
[
'name' => '⚠️ Directional only',
'value' => 'Cohorts compared at equal age. Surge weeks (⚡, e.g. launch/marketing) differ in acquisition channel and are not controlled — compare organic weeks like-for-like. This is the pre-A/B baseline, not a randomised test.',
'inline' => false,
],
],
];
}
private function rateCell(?float $rate, bool $mature, int $denominator): string
{
if ($denominator === 0) {
return '—';
}
if (! $mature || $rate === null) {
return 'pend';
}
return $this->pct($rate).'%';
}
private function pct(float $rate): string
{
return (string) ((int) round($rate * 100));
}
}

View File

@ -2,67 +2,191 @@
namespace App\Console\Commands;
use App\Enums\LeadCohort;
use App\Mail\UserLeadInvitation;
use App\Models\UserLead;
use App\Services\LeadCohortResolver;
use App\Services\LeadPromoCodeAllocator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Throwable;
class SendUserLeadInvitations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'leads:send-invitations {--force : Skip confirmation prompt}';
protected $signature = 'leads:send-invitations
{--limit=50 : Maximum number of leads to invite in this batch}
{--email= : Invite a specific pending lead by email address}
{--cohort= : Restrict to a single cohort (founder, founder_referrer, early_bird, waitlist)}
{--dry-run : Show what would happen without sending or creating Stripe codes}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send invitation emails to all UserLeads with FOUNDER promo code';
protected $description = 'Send launch invitation emails to the next batch of waitlist leads';
/**
* Execute the console command.
*/
public function handle(): int
protected $aliases = [
'leads:send-invite',
'leads:send-invites',
];
public function handle(LeadCohortResolver $resolver, LeadPromoCodeAllocator $allocator): int
{
$leads = UserLead::query()->get();
$limit = (int) $this->option('limit');
if ($limit < 1) {
$this->error('Limit must be a positive integer.');
if ($leads->isEmpty()) {
$this->info('No user leads found in the database.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$emailFilter = $this->resolveEmailFilter();
$cohortFilter = $this->resolveCohortFilter();
if ($emailFilter === false || $cohortFilter === false) {
return self::FAILURE;
}
$candidates = UserLead::query()
->whereNotNull('position')
->where('position', '>', 0)
->whereNull('invitation_sent_at')
->when(
$emailFilter !== null,
fn ($query) => $query->where('email', $emailFilter),
fn ($query) => $query->orderBy('position')->limit($limit * 5), // over-fetch when filtering by cohort
)
->get();
if ($candidates->isEmpty()) {
if ($emailFilter !== null) {
$this->error("No pending lead found for {$emailFilter}.");
return self::FAILURE;
}
$this->info('No pending leads found.');
return self::SUCCESS;
}
$this->info("Found {$leads->count()} user lead(s).");
$plan = [];
foreach ($candidates as $lead) {
$cohort = $resolver->resolve($lead);
if ($cohort === null) {
continue;
}
if ($cohortFilter !== null && $cohort !== $cohortFilter) {
continue;
}
$plan[] = [$lead, $cohort];
if (count($plan) >= $limit) {
break;
}
}
if ($plan === []) {
$this->info('No leads matched the requested filters.');
return self::SUCCESS;
}
$this->table(
['#', 'Position', 'Email', 'Cohort'],
array_map(
fn (array $row, int $i): array => [
$i + 1,
$row[0]->position,
$row[0]->email,
$row[1]->value,
],
$plan,
array_keys($plan),
),
);
if ($dryRun) {
$this->info('[dry-run] No emails sent.');
return self::SUCCESS;
}
if (! $this->option('force')) {
if (! $this->confirm("Do you want to send invitation emails to {$leads->count()} lead(s)?", true)) {
if (! $this->confirm('Send these invitation emails?', true)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
}
$this->info('Sending invitation emails...');
$progressBar = $this->output->createProgressBar($leads->count());
$sent = 0;
$failed = 0;
$progressBar = $this->output->createProgressBar(count($plan));
$progressBar->start();
$sent = 0;
foreach ($leads as $lead) {
Mail::to($lead->email)->send(new UserLeadInvitation($lead));
$sent++;
foreach ($plan as [$lead, $cohort]) {
try {
/** @var UserLead $lead */
/** @var LeadCohort $cohort */
$lead->cohort = $cohort;
$allocator->ensureCodes($lead, $cohort);
Mail::to($lead->email)->send(new UserLeadInvitation($lead->fresh(), $cohort));
$lead->forceFill(['invitation_sent_at' => now()])->save();
$sent++;
} catch (Throwable $exception) {
$failed++;
$this->error("Failed for {$lead->email}: {$exception->getMessage()}");
report($exception);
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Successfully queued {$sent} invitation email(s)!");
$this->info("Queued {$sent} invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.');
return self::SUCCESS;
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function resolveEmailFilter(): string|false|null
{
$email = $this->option('email');
if ($email === null || $email === '') {
return null;
}
$email = strtolower(trim((string) $email));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Invalid email `{$email}`.");
return false;
}
return $email;
}
private function resolveCohortFilter(): LeadCohort|false|null
{
$cohort = $this->option('cohort');
if ($cohort === null || $cohort === '') {
return null;
}
$resolved = LeadCohort::tryFrom((string) $cohort);
if ($resolved === null) {
$this->error("Unknown cohort `{$cohort}`. Valid values: founder, founder_referrer, early_bird, waitlist.");
return false;
}
return $resolved;
}
}

View File

@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Mail\UserLeadReInvitation;
use App\Models\UserLead;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Throwable;
#[Signature('leads:send-re-invitations
{--limit=50 : Maximum number of leads to re-invite in this batch}
{--email= : Re-invite a specific invited lead by email address}
{--min-days-since-invite=3 : Only include leads invited at least this many days ago}
{--again : Include leads that were already re-invited}
{--stats : Show re-invitation signup stats instead of sending emails}
{--dry-run : Show what would happen without sending emails}
{--force : Skip confirmation prompt}')]
#[Description('Send follow-up invitation emails to invited leads who have not signed up')]
class SendUserLeadReInvitations extends Command
{
public function handle(): int
{
if ((bool) $this->option('stats')) {
$this->displayStats();
return self::SUCCESS;
}
$limit = (int) $this->option('limit');
if ($limit < 1) {
$this->error('Limit must be a positive integer.');
return self::FAILURE;
}
$minDaysSinceInvite = max(0, (int) $this->option('min-days-since-invite'));
$emailFilter = $this->resolveEmailFilter();
if ($emailFilter === false) {
return self::FAILURE;
}
$leads = $this->pendingReInvitationQuery($minDaysSinceInvite, (bool) $this->option('again'))
->when(
$emailFilter !== null,
fn (Builder $query) => $query->where('email', $emailFilter),
fn (Builder $query) => $query->orderBy('invitation_sent_at')->limit($limit),
)
->get();
if ($leads->isEmpty()) {
if ($emailFilter !== null) {
$this->error("No invited lead pending re-invitation found for {$emailFilter}.");
return self::FAILURE;
}
$this->info('No invited leads pending re-invitation found.');
return self::SUCCESS;
}
$this->table(
['#', 'Email', 'Invited at', 'Re-invited at', 'Re-invites'],
$leads->values()->map(fn (UserLead $lead, int $index): array => [
$index + 1,
$lead->email,
$lead->invitation_sent_at?->toDateTimeString(),
$lead->re_invitation_sent_at?->toDateTimeString() ?? '-',
$lead->re_invitation_count ?? 0,
])->all(),
);
if ((bool) $this->option('dry-run')) {
$this->info('[dry-run] No re-invitation emails sent.');
return self::SUCCESS;
}
if (! $this->option('force')) {
if (! $this->confirm('Send these re-invitation emails?', true)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
}
$sent = 0;
$failed = 0;
$progressBar = $this->output->createProgressBar($leads->count());
$progressBar->start();
foreach ($leads as $lead) {
try {
Mail::to($lead->email)->send(new UserLeadReInvitation($lead));
$lead->forceFill([
're_invitation_sent_at' => now(),
're_invitation_count' => ((int) $lead->re_invitation_count) + 1,
])->save();
$sent++;
} catch (Throwable $exception) {
$failed++;
$this->error("Failed for {$lead->email}: {$exception->getMessage()}");
report($exception);
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Queued {$sent} re-invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.');
$this->displayStats();
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
/** @return Builder<UserLead> */
private function pendingReInvitationQuery(int $minDaysSinceInvite, bool $includeAlreadyReInvited): Builder
{
return UserLead::query()
->whereNotNull('invitation_sent_at')
->whereDoesntHave('signedUpUser')
->when(! $includeAlreadyReInvited, fn (Builder $query) => $query->whereNull('re_invitation_sent_at'))
->when(
$minDaysSinceInvite > 0,
fn (Builder $query) => $query->where('invitation_sent_at', '<=', now()->subDays($minDaysSinceInvite)),
);
}
private function displayStats(): void
{
$reInvited = UserLead::query()
->whereNotNull('re_invitation_sent_at')
->count();
$signedUpAfterReInvite = UserLead::query()
->whereNotNull('re_invitation_sent_at')
->whereHas('signedUpUser', fn (Builder $query) => $query->whereColumn('users.created_at', '>=', 'user_leads.re_invitation_sent_at'))
->count();
$rate = $reInvited > 0 ? round(($signedUpAfterReInvite / $reInvited) * 100, 2) : 0.0;
$this->table(['Metric', 'Value'], [
['Re-invited leads', $reInvited],
['Re-invited leads signed up', $signedUpAfterReInvite],
['Success rate', $rate.'%'],
]);
}
private function resolveEmailFilter(): string|false|null
{
$email = $this->option('email');
if ($email === null || $email === '') {
return null;
}
$email = strtolower(trim((string) $email));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Invalid email `{$email}`.");
return false;
}
return $email;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Stripe\Exception\ApiErrorException;
class StripeSubscriptionStatsCommand extends Command
{
protected $signature = 'stripe:subscription-stats';
protected $description = 'Show Stripe subscription stats: active/trialing counts and current/projected MRR & ARR';
/**
* @var array<string, array{count: int, mrr: float}>
*/
private array $active = [];
/**
* @var array<string, array{count: int, mrr: float}>
*/
private array $trialing = [];
public function __construct(private SubscriptionStatsCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
try {
$stats = $this->collector->collect();
} catch (ApiErrorException $exception) {
$this->error("Stripe API error: {$exception->getMessage()}");
return self::FAILURE;
}
$this->active = $stats['active'];
$this->trialing = $stats['trialing'];
$this->render();
return self::SUCCESS;
}
private function render(): void
{
$currencies = array_unique(array_merge(array_keys($this->active), array_keys($this->trialing)));
sort($currencies);
if ($currencies === []) {
$this->warn('No active or trialing subscriptions found.');
return;
}
foreach ($currencies as $currency) {
$active = $this->active[$currency] ?? ['count' => 0, 'mrr' => 0.0];
$trialing = $this->trialing[$currency] ?? ['count' => 0, 'mrr' => 0.0];
$currentMrr = $active['mrr'];
$projectedMrr = $active['mrr'] + $trialing['mrr'];
$this->newLine();
$this->line('<options=bold>'.strtoupper($currency).'</>');
$this->line(" Active subs: <fg=green>{$active['count']}</> ({$this->format($active['mrr'], $currency)} MRR)");
$this->line(" Trialing subs: <fg=yellow>{$trialing['count']}</> ({$this->format($trialing['mrr'], $currency)} MRR)");
$this->newLine();
$this->line(" Current MRR: <fg=cyan>{$this->format($currentMrr, $currency)}</>");
$this->line(" Current ARR: <fg=cyan>{$this->format($currentMrr * 12, $currency)}</>");
$this->line(" Projected MRR: <fg=cyan>{$this->format($projectedMrr, $currency)}</> (if trialing convert)");
$this->line(" Projected ARR: <fg=cyan>{$this->format($projectedMrr * 12, $currency)}</>");
}
$this->newLine();
}
/**
* MRR figures are held in major units (e.g. euros), so convert to cents for
* the shared formatter.
*/
private function format(float $amount, string $currency): string
{
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -0,0 +1,216 @@
<?php
namespace App\Console\Commands;
use App\Enums\SuggestionRunStatus;
use App\Models\RuleSuggestion;
use App\Models\User;
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
use App\Services\Ai\GenerateRuleSuggestions;
use App\Services\Ai\RuleSuggestionAggregator;
use App\Services\Ai\RuleSuggestionAvailability;
use App\Services\Ai\RuleSuggestionGuard;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Throwable;
class SuggestRulesCommand extends Command
{
protected $signature = 'ai:suggest-rules
{user : User id or email}
{--persist : Run through the real pipeline and store a SuggestionRun instead of a dry run}';
protected $description = 'Generate AI rule suggestions for a user and print what each stage produces';
public function handle(
RuleSuggestionAggregator $aggregator,
RuleSuggestionGenerator $generator,
RuleSuggestionGuard $guard,
RuleSuggestionAvailability $availability,
): int {
$user = $this->resolveUser((string) $this->argument('user'));
if ($user === null) {
$this->error('User not found.');
return self::FAILURE;
}
$this->line("User: <info>{$user->email}</info> ({$user->id})");
$this->line(sprintf(
'Transactions: %d · eligible: %s · throttled: %s',
$availability->transactionCount($user),
$availability->isEligible($user) ? 'yes' : 'no',
$availability->isThrottled($user) ? 'yes' : 'no',
));
$this->newLine();
if ($this->option('persist')) {
return $this->runPersisted($user);
}
return $this->runDryRun($user, $aggregator, $generator, $guard);
}
private function runDryRun(
User $user,
RuleSuggestionAggregator $aggregator,
RuleSuggestionGenerator $generator,
RuleSuggestionGuard $guard,
): int {
$groups = $aggregator->groupsFor($user);
if ($groups === []) {
$this->warn('No transaction groups to suggest from (need uncategorized, server-readable transactions).');
return self::SUCCESS;
}
$this->components->info(count($groups).' transaction group(s) sent to the model');
$this->table(
['Key', 'Field', 'Count', 'Direction', 'Avg', 'Samples'],
array_map(fn (array $group): array => [
$group['key'],
$group['field'],
$group['count'],
$group['direction'],
$group['avg_amount'],
Str::limit(implode(' | ', $group['samples']), 50),
], $groups),
);
$categories = $aggregator->categoryOptions($user);
try {
$raw = $generator->generate($groups, $categories);
} catch (Throwable $exception) {
$this->error('Model call failed: '.$exception->getMessage());
return self::FAILURE;
}
$this->components->info(count($raw).' raw suggestion(s) returned by the model');
$this->table(
['Group', 'Field', 'Op', 'Token', 'Category', 'Confidence'],
array_map(fn (array $suggestion): array => [
$suggestion['group_key'] ?? '',
$suggestion['match_field'] ?? '',
$suggestion['match_operator'] ?? '',
$suggestion['match_token'] ?? '',
$this->describeRawCategory($suggestion, $categories),
$suggestion['confidence'] ?? '',
], $raw),
);
$validated = $guard->validate($user, $raw, $categories);
$this->components->info(count($validated).' suggestion(s) survived the guards');
$this->table(
['Field', 'Op', 'Token', 'Category', 'Confidence', 'Matches'],
array_map(fn (array $suggestion): array => [
$suggestion['match_field'],
$suggestion['match_operator'],
$suggestion['match_token'],
$this->describeValidatedCategory($suggestion, $categories),
$suggestion['confidence'],
$suggestion['group_size'],
], $validated),
);
$this->newLine();
$this->line('<comment>Dry run — nothing was saved. Use --persist to store a SuggestionRun.</comment>');
return self::SUCCESS;
}
private function runPersisted(User $user): int
{
$run = $user->suggestionRuns()->create(['status' => SuggestionRunStatus::Pending]);
app(GenerateRuleSuggestions::class)->run($run);
$run->refresh()->load('suggestions.proposedCategory');
$this->components->info("Run {$run->id} finished with status: {$run->status->value}");
if ($run->error) {
$this->error($run->error);
return self::FAILURE;
}
if ($run->suggestions->isEmpty()) {
$this->warn('No suggestions were produced.');
return self::SUCCESS;
}
$this->table(
['Field', 'Op', 'Token', 'Category', 'Confidence', 'Matches'],
$run->suggestions->map(fn (RuleSuggestion $suggestion): array => [
$suggestion->match_field,
$suggestion->match_operator,
$suggestion->match_token,
$suggestion->proposed_category_id !== null
? $suggestion->proposedCategory->name
: ($suggestion->new_category_name !== null
? "NEW: {$suggestion->new_category_name}"
: '—'),
$suggestion->confidence,
$suggestion->group_size,
])->all(),
);
return self::SUCCESS;
}
private function resolveUser(string $identifier): ?User
{
return User::query()->where('email', $identifier)->first()
?? User::query()->find($identifier);
}
/**
* @param array<string, mixed> $suggestion
* @param list<array<string, mixed>> $categories
*/
private function describeRawCategory(array $suggestion, array $categories): string
{
$id = $suggestion['category_id'] ?? null;
if (filled($id)) {
foreach ($categories as $category) {
if ($category['id'] === $id) {
return (string) $category['path'];
}
}
return (string) $id;
}
$name = $suggestion['new_category_name'] ?? null;
return filled($name) ? "NEW: {$name}" : '—';
}
/**
* @param array<string, mixed> $suggestion
* @param list<array<string, mixed>> $categories
*/
private function describeValidatedCategory(array $suggestion, array $categories): string
{
if (filled($suggestion['proposed_category_id'])) {
foreach ($categories as $category) {
if ($category['id'] === $suggestion['proposed_category_id']) {
return (string) $category['path'];
}
}
return (string) $suggestion['proposed_category_id'];
}
return filled($suggestion['new_category_name'])
? "NEW: {$suggestion['new_category_name']} ({$suggestion['new_category_direction']})"
: '—';
}
}

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Jobs\SyncAllBankingConnectionsJob;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
@ -41,10 +42,29 @@ class SyncBankingConnections extends Command
}
$query = BankingConnection::query()
->where('status', BankingConnectionStatus::Active)
->whereHas('user')
->where(function ($query) {
$query->whereNull('valid_until')
->orWhere('valid_until', '>', now());
$query->where(function ($query) {
$query->where(function ($query) {
$query->where('status', BankingConnectionStatus::Active)
->orWhere(function ($query) {
$query->where('status', BankingConnectionStatus::Error)
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
});
})->where(function ($query) {
$query->whereNull('valid_until')
->orWhere('valid_until', '>', now());
});
})->orWhere(function ($query) {
$query->where('provider', BankingProvider::EnableBanking)
->where('status', BankingConnectionStatus::Active)
->whereNotNull('valid_until')
->where('valid_until', '<=', now());
});
})
->where(function ($query) {
$query->whereNull('rate_limited_until')
->orWhere('rate_limited_until', '<=', now());
});
if ($connectionId) {
@ -73,9 +93,9 @@ class SyncBankingConnections extends Command
$connections->each(function (BankingConnection $connection) use ($sync, $fullSync) {
if ($sync) {
$this->info("Syncing {$connection->provider} connection {$connection->id}...");
$this->info("Syncing {$connection->provider->value} connection {$connection->id}...");
SyncBankingConnectionJob::dispatchSync($connection, $fullSync);
$this->info("Finished syncing {$connection->provider} connection {$connection->id}.");
$this->info("Finished syncing {$connection->provider->value} connection {$connection->id}.");
} else {
SyncBankingConnectionJob::dispatch($connection, $fullSync);
}

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Support\Money;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Stripe\Exception\ApiErrorException;
@ -50,6 +51,7 @@ class SyncStripePricesCommand extends Command
$amountInCents = (int) round($plan['price'] * 100);
$billingPeriod = $plan['billing_period'] ?? null;
$productId = config('subscriptions.products.pro');
$formattedAmount = Money::format($amountInCents, $currency);
$this->line(" <options=bold>{$plan['name']}</>");
@ -68,7 +70,7 @@ class SyncStripePricesCommand extends Command
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: true);
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$formattedAmount}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create new price and transfer lookup key '{$lookupKey}'");
}
@ -79,9 +81,9 @@ class SyncStripePricesCommand extends Command
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false);
$this->line(" <fg=green>✓</> Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
$this->line(" <fg=green>✓</> Created {$price->id} ({$formattedAmount}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$this->formatAmount($amountInCents, $currency)}/{$billingPeriod}");
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$formattedAmount}/{$billingPeriod}");
}
$created++;
@ -102,7 +104,7 @@ class SyncStripePricesCommand extends Command
}
/**
* @return \Stripe\Price|null
* @return Price|null
*/
private function findPriceByLookupKey(string $lookupKey): mixed
{
@ -138,7 +140,7 @@ class SyncStripePricesCommand extends Command
}
/**
* @param \Stripe\Price $price
* @param Price $price
*/
private function priceMatches(mixed $price, int $amountInCents, string $currency, ?string $billingPeriod): bool
{
@ -148,16 +150,4 @@ class SyncStripePricesCommand extends Command
return $currencyMatches && $amountMatches && $intervalMatches;
}
private function formatAmount(int $amountInCents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'jpy' => '¥',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amountInCents / 100, 2);
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\User;
use App\Services\Subscriptions\ExperimentOffer;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Laravel\Pennant\Feature;
/**
* Live sandbox check for the pay_now self-service refund the one path that
* Pest tests can only mock. It creates a real, immediately-charged subscription
* against the Stripe test environment, runs the actual RefundSelfServe action,
* and confirms via the Stripe API that the charge was refunded and the
* subscription canceled. Run before flipping SUBSCRIPTION_EXPERIMENT_STARTED_AT.
*
* Refuses to run against anything but Stripe test keys.
*/
class VerifyRefundFlowCommand extends Command
{
protected $signature = 'stripe:verify-refund';
protected $description = 'Verify the pay_now self-service refund end-to-end against the Stripe sandbox';
public function __construct(private ExperimentOffer $offer)
{
parent::__construct();
}
public function handle(): int
{
if (app()->isProduction() || ! str_starts_with((string) config('cashier.secret'), 'sk_test')) {
$this->error('Refusing to run: this command requires Stripe test keys and a non-production environment.');
return self::FAILURE;
}
config(['subscriptions.tax_rates' => []]);
$passed = true;
$check = function (string $label, bool $ok) use (&$passed): void {
$this->line(($ok ? '<fg=green>PASS</>' : '<fg=red>FAIL</>').' '.$label);
$passed = $passed && $ok;
};
$lookup = config('subscriptions.plans.monthly.stripe_lookup_key');
$prices = Cashier::stripe()->prices->all(['lookup_keys' => [$lookup], 'limit' => 1]);
$priceId = $prices->data[0]->id ?? null;
if ($priceId === null) {
$this->error("No Stripe price found for lookup key '{$lookup}'.");
return self::FAILURE;
}
$user = User::factory()->create([
'email' => 'refund-sandbox-'.uniqid().'@whisper.test',
'created_at' => now(),
]);
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW);
try {
$user->newSubscription('default', $priceId)->create('pm_card_visa');
$subscription = $user->subscription('default');
$check('subscription active after immediate charge', $subscription->active() && $subscription->stripe_status === 'active');
$check('canSelfRefund is true before refund', $this->offer->canSelfRefund($user));
$paymentIntentId = $subscription->latestPayment()?->asStripePaymentIntent()->id;
$check('latestPayment() resolves a payment intent', $paymentIntentId !== null);
app(RefundSelfServe::class)->handle($user->fresh());
$subscription = $user->subscription('default')->fresh();
$check('refunded_at is stamped', $subscription->refunded_at !== null);
$check('subscription is canceled', $subscription->canceled());
$check('canSelfRefund is false after refund', ! $this->offer->canSelfRefund($user->fresh()));
$intent = Cashier::stripe()->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]);
$charge = $intent->latest_charge;
$check('Stripe charge shows a full refund', is_object($charge) && $charge->refunded === true);
$this->line(' amount_refunded='.($charge->amount_refunded ?? 'n/a'));
} catch (\Throwable $exception) {
$this->error($exception::class.': '.$exception->getMessage());
$passed = false;
} finally {
try {
if ($user->hasStripeId()) {
Cashier::stripe()->customers->delete($user->stripe_id);
}
} catch (\Throwable) {
// best-effort sandbox cleanup
}
$user->forceDelete();
}
$this->newLine();
$this->{$passed ? 'info' : 'error'}($passed ? 'Refund flow verified.' : 'Refund flow verification FAILED.');
return $passed ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Contracts;
use App\Models\BankingConnection;
interface BankingConnectionSyncer
{
/**
* Sync every account belonging to the connection.
*
* @return array<string, mixed> Metadata to persist on the sync log.
*/
public function sync(BankingConnection $connection, bool $isFirstSync): array;
/**
* Whether the connection's consent can expire (consent-based providers).
*/
public function expires(): bool;
/**
* Whether a permanent auth failure should notify the user (API-key providers).
*/
public function notifiesOnAuthFailure(): bool;
}

Some files were not shown because too many files have changed in this diff Show More