Compare commits

...

46 Commits
v0.2.6 ... main

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
205 changed files with 13684 additions and 1920 deletions

View File

@ -8,6 +8,12 @@ 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

View File

@ -64,6 +64,9 @@ jobs:
- name: Generate Application Key
run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Tests
# --parallel splits the suite across workers via paratest. Each
# worker creates its own "testing_test_<N>" database; the mysql
@ -144,6 +147,9 @@ jobs:
- 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:
@ -235,6 +241,9 @@ jobs:
linter:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
@ -273,6 +282,33 @@ jobs:
- 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:

View File

@ -1,34 +0,0 @@
name: PR title
on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]
jobs:
semantic-pr-title:
name: Conventional PR title
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- name: Validate PR title
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.

View File

@ -1,6 +1,8 @@
name: Release
on:
schedule:
- cron: "0 8 * * 1" # Mondays 08:00 UTC = 10:00 Madrid (CEST) / 09:00 (CET)
workflow_dispatch:
inputs:
increment:
@ -42,23 +44,38 @@ jobs:
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 }} --ci
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: |

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

View File

@ -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

@ -5,6 +5,7 @@ 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;
@ -13,14 +14,16 @@ class CreateDefaultCategories
{
/**
* Create default categories for a newly registered user, nesting child
* categories under their configured parent.
* 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);
$existingCategories = $user->categories()
$existingCategories = $space->categories()
->whereIn('name', array_column($defaultCategories, 'name'))
->pluck('id', 'name');
@ -32,6 +35,7 @@ class CreateDefaultCategories
'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,
]);

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

@ -4,6 +4,7 @@ namespace App\Console\Commands\Concerns;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
trait FindsUsersWithLegacyEncryption
{
@ -11,11 +12,14 @@ 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()->where(function (Builder $query): void {
return User::query()->with('subscriptions')->where(function (Builder $query): void {
$query->whereHas('transactions', function (Builder $transactions): void {
$transactions->whereNotNull('description_iv')
->orWhereNotNull('notes_iv');
@ -24,4 +28,16 @@ trait FindsUsersWithLegacyEncryption
});
});
}
/**
* 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

@ -36,13 +36,15 @@ class DeleteEncryptedDataAccountsCommand extends Command
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$users = $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();
$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.');

View File

@ -39,16 +39,22 @@ class NotifyEncryptedDataRemovalCommand extends Command
*/
public function handle(): int
{
$users = $this->usersWithLegacyEncryption()->get();
$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 with encrypted data found.');
$this->info('No users to warn.');
return self::SUCCESS;
}
$this->info("Found {$users->count()} user(s) with encrypted data.");
if ($this->option('dry-run')) {
$this->renderTable($users);
$this->info('[dry-run] No emails sent.');

View File

@ -6,6 +6,7 @@ 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;
@ -115,17 +116,12 @@ class SendDailyStatsReportCommand extends Command
];
}
/**
* 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
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount, 2);
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -4,13 +4,18 @@ 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}';
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';
@ -20,14 +25,17 @@ class SendExperimentFunnelReportCommand extends Command
SubscriptionExperiment::PAY_NOW => 'pay_now',
];
public function __construct(private ExperimentFunnelCollector $collector)
{
public function __construct(
private ExperimentFunnelCollector $collector,
private ProportionSignificance $significance,
) {
parent::__construct();
}
public function handle(): int
{
$report = $this->collector->collect();
$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.');
@ -39,6 +47,10 @@ class SendExperimentFunnelReportCommand extends Command
$this->line($line);
}
foreach ($this->significanceLines($report) as $line) {
$this->line($line);
}
if ($this->option('no-discord')) {
$this->info('Skipped Discord (--no-discord).');
@ -56,49 +68,112 @@ class SendExperimentFunnelReportCommand extends Command
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
* @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'];
$lines = [sprintf('%-8s %5s %4s %5s %5s %5s %5s %8s %8s', 'Variant', 'Assg', 'Sub', 'Actv', 'Cncl', 'Rfnd', 'Net%', 'MRR', 'ARPU')];
$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 %4d %5d %5d %5d %5s %8s %8s',
'%-8s %5d %5d %5d %5d %5d %6s %7s %7s %7s %7s %7s',
$label,
$row['assigned'],
$row['activated'],
$row['subscribed'],
$row['active'],
$row['canceled'],
$row['refunded'],
$mature ? ((int) round($row['netActiveRate'] * 100)).'%' : 'pend',
$revenue ? $this->money($row['mrrCents'], $report['currency']) : '—',
$revenue && $mature ? $this->money((int) $row['arpuCents'], $report['currency']) : '—',
$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;
}
private function money(int $cents, string $currency): string
/**
* 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
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
default => $currency.' ',
};
$lines = ['', 'Significance (95% Wilson CI on Conv%, n = MatU):'];
$arms = [];
return $symbol.number_format($cents / 100, 2);
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, variants: array<string, array<string, mixed>>} $report
* @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
@ -113,14 +188,22 @@ class SendExperimentFunnelReportCommand extends Command
'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' => 'Assg = assigned · Sub = started a plan · Actv/Cncl = current status · Rfnd = self-service refunds (pay_now) · Net% = live, non-refunded subs ÷ assigned (mature users only) · MRR = monthly run-rate of those subs (yearly ÷ 12) · ARPU = MRR ÷ assigned · `pend`/`—` = no mature data yet.',
'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 is gated by its own decision window (control 15d, reduced 7d, pay_now 3d), so pay_now matures first — compare only once all three have mature volume, and check significance before calling a winner. **ARPU is the revenue metric to compare.** MRR is run-rate, so it does not credit pay_now\'s yearly upfront cash; true LTV also needs a churn rate the experiment is too young to have.',
'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

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Stripe\Exception\ApiErrorException;
@ -77,17 +78,12 @@ class StripeSubscriptionStatsCommand extends Command
$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
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount, 2);
return Money::format((int) round($amount * 100), $currency);
}
}

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++;
@ -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

@ -23,7 +23,17 @@ enum AccountType: string
public function reducesNetWorth(): bool
{
return in_array($this, [self::CreditCard, self::Loan], true);
return $this === self::Loan;
}
/**
* Whether this account type is part of the net worth total at all. Credit
* cards are spending accounts, not wealth, so they are excluded entirely
* (neither added nor subtracted) while still being tracked on their own.
*/
public function countsInNetWorth(): bool
{
return $this !== self::CreditCard;
}
/**

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum ImportConfigType: string
{
case Transaction = 'transaction';
case Balance = 'balance';
}

View File

@ -6,6 +6,7 @@ enum PlanFeature: string
{
case ConnectedAccounts = 'connected_accounts';
case AiSuggestions = 'ai_suggestions';
case McpAccess = 'mcp_access';
/**
* Whether access to this feature is gated behind a paid (Pro) plan.
@ -13,7 +14,7 @@ enum PlanFeature: string
public function requiresProPlan(): bool
{
return match ($this) {
self::ConnectedAccounts, self::AiSuggestions => true,
self::ConnectedAccounts, self::AiSuggestions, self::McpAccess => true,
};
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Enums;
/**
* The upsell entry point a subscription checkout was started from, used to
* attribute revenue to each upgrade prompt. The value is carried into Stripe as
* subscription metadata and persisted onto the local subscription so revenue
* can be measured per upsell point.
*
* Mirrored on the frontend by the UpsellSource union in
* resources/js/components/subscription/upgrade-dialog.tsx keep both in sync
* when adding a point (an unknown value is silently dropped by tryFrom()).
*/
enum UpsellSource: string
{
case AiCategorization = 'ai_categorization';
case Connections = 'connections';
case Accounts = 'accounts';
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Exceptions\Banking;
use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;
use Throwable;
/**
* The banking provider rejected the requested transactions date range as wider
* than the bank is willing to serve (EnableBanking HTTP 422 "Wrong transactions
* period requested"). Recoverable by retrying with a narrower window, so it is
* not reported: the sync layer clamps and retries, and only skips the account
* if even the narrowest window is refused.
*/
class WrongTransactionsPeriodException extends Exception implements ShouldntReport
{
public function __construct(
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

22
app/Features/Mcp.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* Gates the MCP access settings screen while the feature is being rolled out.
* Toggle per user / everyone with `php artisan feature:enable App\\Features\\Mcp <target>`.
*
* @api
*/
class Mcp
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ImportConfigType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\UpdateAccountImportConfigRequest;
use App\Models\Account;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AccountImportConfigController extends Controller
{
use AuthorizesRequests;
public function show(Request $request, Account $account): JsonResponse
{
$this->authorize('view', $account);
$validated = $request->validate([
'type' => ['required', Rule::enum(ImportConfigType::class)],
]);
$config = $account->importConfigs()
->where('type', $validated['type'])
->first();
return response()->json(['data' => $config?->config]);
}
public function update(UpdateAccountImportConfigRequest $request, Account $account): JsonResponse
{
$this->authorize('update', $account);
$config = $account->importConfigs()->updateOrCreate(
['type' => $request->validated('type')],
['config' => $request->validated('config')],
);
return response()->json(['data' => $config->config]);
}
}

View File

@ -482,6 +482,10 @@ class DashboardAnalyticsController extends Controller
$total = 0;
foreach ($accounts as $account) {
if (! $account->type->countsInNetWorth()) {
continue;
}
$balance = $lookup->getBalanceAt($account->id, $date);
$convertedBalance = $this->exchangeRateService->convert(

View File

@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Features\Mcp;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreMcpTokenRequest;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Laravel\Sanctum\PersonalAccessToken;
class McpTokenController extends Controller implements HasMiddleware
{
/**
* Hide the whole MCP settings surface behind the rollout feature flag.
*
* @return array<int, Closure>
*/
public static function middleware(): array
{
return [
function (Request $request, Closure $next): mixed {
abort_unless(Feature::active(Mcp::class), 404);
return $next($request);
},
];
}
/**
* Show the MCP access page: existing tokens, connection details and the
* one-time plaintext secret when a token was just created.
*/
public function index(Request $request): Response
{
return Inertia::render('settings/mcp', [
'tokens' => $this->tokensFor($request),
'serverUrl' => url('/mcp'),
'oauthUrl' => url('/mcp/oauth'),
'subscribeUrl' => route('subscribe'),
'newToken' => $request->session()->get('mcp_token'),
]);
}
/**
* Create a new MCP token. The plaintext secret is flashed once; only its
* hash is stored, so it can never be shown again. A "read" token can only
* analyse data; a "read_write" token additionally carries `mcp:write`,
* unlocking the write tools.
*/
public function store(StoreMcpTokenRequest $request): RedirectResponse
{
$abilities = $request->validated('scope') === 'read_write'
? ['mcp:read', 'mcp:write']
: ['mcp:read'];
$token = $request->user()->createToken($request->validated('name'), $abilities);
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
}
/**
* Revoke (delete) a token the user owns.
*/
public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorizeOwnership($request, $token);
$token->delete();
return to_route('mcp.index');
}
/**
* Rotate a token: revoke it and issue a fresh secret keeping the same name
* and scope, so a leaked token can be replaced without reconfiguring intent.
*/
public function rotate(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorizeOwnership($request, $token);
$fresh = $request->user()->createToken($token->name, $token->abilities);
$token->delete();
return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken);
}
/**
* Ensure the token belongs to the requesting user before mutating it.
*/
private function authorizeOwnership(Request $request, PersonalAccessToken $token): void
{
abort_unless(
$token->tokenable_id === $request->user()->getKey()
&& $token->tokenable_type === $request->user()->getMorphClass(),
403
);
}
/**
* @return list<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
*/
private function tokensFor(Request $request): array
{
return $request->user()->tokens()
->latest()
->get()
->map(fn (PersonalAccessToken $token): array => [
'id' => $token->id,
'name' => $token->name,
'scope' => in_array('mcp:write', $token->abilities ?? [], true) ? 'read_write' : 'read',
'created_at' => $token->created_at?->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
])
->all();
}
}

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers;
use App\Actions\Subscription\RefundSelfServe;
use App\Enums\UpsellSource;
use App\Features\SubscriptionExperiment;
use App\Models\AccountBalance;
use App\Models\User;
use App\Models\UserLead;
use App\Services\Discord\DiscordWebhook;
@ -52,32 +52,14 @@ class SubscriptionController extends Controller
}
/**
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int, automationRulesCount: int, balancesByCurrency: array<string, int>}
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int}
*/
private function getUserStats(User $user): array
{
$accounts = $user->accounts()->get();
$balancesByCurrency = [];
foreach ($accounts as $account) {
$latestBalance = AccountBalance::query()
->where('account_id', $account->id)
->orderBy('balance_date', 'desc')
->value('balance') ?? 0;
$currency = $account->currency_code;
if (! isset($balancesByCurrency[$currency])) {
$balancesByCurrency[$currency] = 0;
}
$balancesByCurrency[$currency] += $latestBalance;
}
return [
'accountsCount' => $accounts->count(),
'accountsCount' => $user->accounts()->count(),
'transactionsCount' => $user->transactions()->count(),
'categoriesCount' => $user->categories()->count(),
'automationRulesCount' => $user->automationRules()->count(),
'balancesByCurrency' => $balancesByCurrency,
];
}
@ -106,6 +88,14 @@ class SubscriptionController extends Controller
$subscriptionBuilder->trialDays($trialDays);
}
// Attribute revenue to the upsell point the checkout started from. The
// value rides along as Stripe subscription metadata and is persisted
// locally when the subscription webhook lands (see
// PersistUpsellSourceFromStripe).
if ($source = UpsellSource::tryFrom((string) $request->query('source', ''))) {
$subscriptionBuilder->withMetadata(['upsell_source' => $source->value]);
}
return $subscriptionBuilder->checkout([
'success_url' => route('subscribe.success'),
'cancel_url' => route('subscribe.cancel'),

View File

@ -213,7 +213,7 @@ class TransactionController extends Controller
], 201);
}
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
{
$this->authorize('update', $transaction);
@ -238,6 +238,10 @@ class TransactionController extends Controller
}
}
// Snapshot the pre-edit account/date/amount before filling, so a manual
// account balance can be moved off the old values if the edit changes them.
$originalSnapshot = clone $transaction;
// Update attributes directly without firing events yet
if (! empty($data)) {
$transaction->fill($data);
@ -260,6 +264,18 @@ class TransactionController extends Controller
$transaction->save();
}
// Move the manual account balance to match an edited amount/date/account:
// strip the pre-edit contribution (exactly as a deletion would) and apply
// the new one (exactly as a creation would), both cascading forward.
// ponytail: like create/delete, this trusts the opt-in flag and keeps no
// record of whether creation adjusted the balance, so mixing the flag
// across create and edit can drift; a transaction-derived balance would
// remove that trust. Connected accounts are skipped inside the adjuster.
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
$balanceAdjuster->reverseDeletedTransaction($originalSnapshot);
$balanceAdjuster->applyCreatedTransaction($transaction->load('account'));
}
return response()->json([
'data' => $transaction->fresh()->load('labels'),
'learned_rule' => $learnedRule === null ? null : [

View File

@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Features\CalculateBalancesOnImport;
use App\Features\Mcp;
use App\Jobs\PurgeResidualEncryptionArtifactsJob;
use App\Models\BankingConnection;
use App\Services\CurrencyOptions;
@ -179,16 +180,19 @@ class HandleInertiaRequests extends Middleware
return [
'cashflow' => true,
'calculateBalancesOnImport' => false,
'mcp' => false,
];
}
$features = Feature::for($user)->values([
CalculateBalancesOnImport::class,
Mcp::class,
]);
return [
'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'mcp' => $features[Mcp::class] !== false,
];
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Api;
use App\Enums\ImportConfigType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAccountImportConfigRequest extends FormRequest
{
/**
* Authorization is handled by the controller via the AccountPolicy.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'type' => ['required', Rule::enum(ImportConfigType::class)],
'config' => ['required', 'array'],
'config.columnMapping' => ['required', 'array'],
'config.dateFormat' => ['required', 'string', 'max:20'],
];
}
}

View File

@ -24,7 +24,10 @@ trait ValidatesAccountDetailRules
],
'address' => ['nullable', 'string', 'max:500'],
'purchase_price' => ['nullable', 'integer', 'min:0'],
'purchase_date' => ['nullable', 'date', 'before_or_equal:today'],
// Floor the date: a mistyped/ancient year would make the historical
// balance generator build a multi-century monthly series and OOM the
// queue worker (PHP-LARAVEL-49). 1900 rejects typos, not real assets.
'purchase_date' => ['nullable', 'date', 'after_or_equal:1900-01-01', 'before_or_equal:today'],
'area_value' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
'area_unit' => ['nullable', 'string', Rule::in(['sqm', 'sqft', 'acres', 'hectares'])],
'linked_loan_account_id' => [
@ -52,7 +55,9 @@ trait ValidatesAccountDetailRules
return [
'annual_interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'loan_term_months' => ['nullable', 'integer', 'min:1', 'max:600'],
'loan_start_date' => ['nullable', 'date'],
// Floor the date for the same reason as purchase_date above: an
// ancient loan start date OOMs the balance generator (PHP-LARAVEL-49).
'loan_start_date' => ['nullable', 'date', 'after_or_equal:1900-01-01'],
'original_amount' => ['nullable', 'integer', 'min:0'],
];
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreMcpTokenRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'scope' => ['required', 'in:read,read_write'],
];
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\TransactionSource;
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
use App\Models\Transaction;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTransactionRequest extends FormRequest
@ -16,7 +18,7 @@ class UpdateTransactionRequest extends FormRequest
public function rules(): array
{
return [
$rules = [
'category_id' => ['nullable', $this->userOwned('categories')],
'description' => ['sometimes', 'string'],
'description_iv' => ['nullable', 'string', 'size:16'],
@ -27,6 +29,20 @@ class UpdateTransactionRequest extends FormRequest
'label_ids' => ['nullable', 'array'],
'label_ids.*' => ['required', 'string', 'uuid', $this->userOwned('labels')],
];
// Manually created transactions can edit every field after creation.
// Imported ones keep amount, date, account and currency locked to the
// source data, so those keys are only validated (and thus persisted)
// for manual transactions.
$transaction = $this->route('transaction');
if ($transaction instanceof Transaction && $transaction->source === TransactionSource::ManuallyCreated) {
$rules['account_id'] = ['sometimes', $this->userOwned('accounts')];
$rules['transaction_date'] = ['sometimes', 'date'];
$rules['amount'] = ['sometimes', 'integer'];
$rules['currency_code'] = ['sometimes', 'string', 'size:3'];
}
return $rules;
}
public function messages(): array

View File

@ -0,0 +1,40 @@
<?php
namespace App\Listeners;
use App\Enums\UpsellSource;
use Laravel\Cashier\Events\WebhookHandled;
use Laravel\Cashier\Subscription;
/**
* Copies the upsell attribution set at checkout (Stripe subscription metadata)
* onto the local subscription row so revenue can be measured per upsell point.
*
* Listens to WebhookHandled (fired after Cashier has already upserted the local
* subscription) and only fills the column while it's empty, so a later
* subscription.updated event never overwrites the original attribution.
*/
class PersistUpsellSourceFromStripe
{
public function handle(WebhookHandled $event): void
{
$type = $event->payload['type'] ?? null;
if (! is_string($type) || ! str_starts_with($type, 'customer.subscription.')) {
return;
}
$object = $event->payload['data']['object'] ?? [];
$stripeId = $object['id'] ?? null;
$source = UpsellSource::tryFrom((string) ($object['metadata']['upsell_source'] ?? ''));
if (! is_string($stripeId) || $stripeId === '' || $source === null) {
return;
}
Subscription::query()
->where('stripe_id', $stripeId)
->whereNull('upsell_source')
->update(['upsell_source' => $source->value]);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Listeners;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stripe\StripeCustomerResolver;
use App\Support\Money;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
@ -171,7 +172,7 @@ class PostStripeEventToDiscord implements ShouldQueue
$email = $this->stringOrNull($object['customer_email'] ?? null);
$fields = [
$this->field('Amount', $this->money((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true),
$this->field('Amount', Money::format((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true),
$this->field('Customer', $email ?? $this->customers->label($this->stringOrNull($object['customer'] ?? null)), true),
];
@ -237,7 +238,7 @@ class PostStripeEventToDiscord implements ShouldQueue
return null;
}
$label = $this->money((int) $amount, (string) ($price['currency'] ?? 'usd'));
$label = Money::format((int) $amount, (string) ($price['currency'] ?? 'usd'));
$recurring = is_array($price['recurring'] ?? null) ? $price['recurring'] : $price;
if (! isset($recurring['interval'])) {
@ -260,20 +261,6 @@ class PostStripeEventToDiscord implements ShouldQueue
return is_string($reason) ? str_replace('_', ' ', $reason) : null;
}
private function money(int $amount, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount / 100, 2);
}
private function timestamp(mixed $value): ?string
{
if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) {

View File

@ -0,0 +1,88 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\CategorizeTransaction;
use App\Mcp\Tools\CreateAutomationRule;
use App\Mcp\Tools\CreateBalance;
use App\Mcp\Tools\CreateCategory;
use App\Mcp\Tools\CreateLabel;
use App\Mcp\Tools\CreateTransaction;
use App\Mcp\Tools\DeleteAutomationRule;
use App\Mcp\Tools\DeleteCategory;
use App\Mcp\Tools\DeleteLabel;
use App\Mcp\Tools\DeleteTransaction;
use App\Mcp\Tools\GetCashflow;
use App\Mcp\Tools\GetNetWorth;
use App\Mcp\Tools\LabelTransaction;
use App\Mcp\Tools\ListAccounts;
use App\Mcp\Tools\ListCategories;
use App\Mcp\Tools\ListLabels;
use App\Mcp\Tools\ListSpaces;
use App\Mcp\Tools\SearchTransactions;
use App\Mcp\Tools\SpendingByCategory;
use App\Mcp\Tools\UpdateAutomationRule;
use App\Mcp\Tools\UpdateCategory;
use App\Mcp\Tools\UpdateLabel;
use App\Mcp\Tools\UpdateTransaction;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
use Laravel\Mcp\Server\Tool;
#[Name('Whisper Money')]
#[Version('1.0.0')]
#[Instructions(<<<'MARKDOWN'
Access to the authenticated user's Whisper Money finance data, for analysing
spending, cashflow and net worth and, with write access, for editing that
data.
- All amounts are integers in minor units (cents). Divide by 100 for a display value.
- Data is organised into "spaces" (the personal space and any shared spaces).
Transaction, account, category and label tools accept an optional `space` id and
default to the personal space; call `list_spaces` to discover ids. The cashflow,
net-worth and spending tools cover the user's whole account.
- To find recurring charges (subscriptions), use `search_transactions` and group
the results by merchant and cadence yourself.
Write tools (create_transaction, update_transaction, delete_transaction,
categorize_transaction, label_transaction, create_balance and full CRUD for
categories, labels and automation rules) require a read & write token; a
read-only token can analyse data but never change it. Bank-connected accounts
and bank/imported transactions are protected: you can only create, edit or
delete manual transactions and manual-account balances, but you can categorize
and label any transaction.
MARKDOWN)]
class WhisperMoneyServer extends Server
{
/** @var array<int, class-string<Tool>> */
protected array $tools = [
// Read
SearchTransactions::class,
SpendingByCategory::class,
GetCashflow::class,
GetNetWorth::class,
ListAccounts::class,
ListCategories::class,
ListLabels::class,
ListSpaces::class,
// Write
CreateTransaction::class,
UpdateTransaction::class,
DeleteTransaction::class,
CategorizeTransaction::class,
LabelTransaction::class,
CreateBalance::class,
CreateCategory::class,
UpdateCategory::class,
DeleteCategory::class,
CreateLabel::class,
UpdateLabel::class,
DeleteLabel::class,
CreateAutomationRule::class,
UpdateAutomationRule::class,
DeleteAutomationRule::class,
];
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategorySource;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Set (or clear) the category of any transaction, including bank/imported ones. Marks the category as manually assigned. Pass category_id: null to remove the category.')]
class CategorizeTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the transaction to categorize.')->required(),
'category_id' => $schema->string()->description('Category id to assign, or null to remove the category.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
if (! $request->has('category_id')) {
return Response::error('Provide category_id to set the category (or null to remove it).');
}
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
$categoryId = $request->filled('category_id')
? $this->categoryInSpace($request, $space)->id
: null;
$transaction->category_id = $categoryId;
$transaction->category_source = $categoryId === null ? null : CategorySource::Manual;
$transaction->ai_confidence = null;
$transaction->categorized_by_rule_id = null;
$transaction->save();
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Mcp\Tools\Concerns;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
trait DecodesRulesJson
{
/**
* Decode and validate a JsonLogic condition argument, accepting either a
* JSON object or a JSON-encoded string.
*
* @return array<string, mixed>
*/
protected function rulesJson(Request $request, string $key = 'rules_json'): array
{
$rulesJson = $request->get($key);
if (is_string($rulesJson)) {
$rulesJson = json_decode($rulesJson, true);
}
if (! is_array($rulesJson) || $rulesJson === []) {
throw ValidationException::withMessages([
$key => "{$key} must be a non-empty JsonLogic object.",
]);
}
return $rulesJson;
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Mcp\Tools\Concerns;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Space;
use App\Services\CategoryTree;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
/**
* Category create/update helpers shared by the category write tools. Mirrors
* the web form's parent resolution (ownership, depth, cycle) and its
* type-driven cashflow derivation, without depending on `auth()` (which is not
* the active guard for MCP requests).
*/
trait ResolvesCategoryWrites
{
/**
* Resolve the requested parent category within the space, enforcing the
* same rules as the web form. Returns null when creating/keeping a root.
* $moving is the category being edited (null on create) so its own subtree
* depth and cycle constraints can be checked.
*/
protected function resolveParentCategory(Request $request, Space $space, ?Category $moving): ?Category
{
if (! $request->filled('parent_id')) {
return null;
}
$parentId = $request->string('parent_id')->toString();
$parent = Category::query()->forSpace($space)->whereKey($parentId)->first();
if ($parent === null) {
throw ValidationException::withMessages([
'parent_id' => "No category with id {$parentId} in space {$space->id}. Call list_categories to see valid ids.",
]);
}
$tree = new CategoryTree;
if ($moving !== null && $tree->wouldCreateCycle($moving, $parent->id)) {
throw ValidationException::withMessages([
'parent_id' => 'A category cannot be nested under itself or one of its children.',
]);
}
$subtreeDepth = $moving !== null ? $tree->subtreeDepth($moving) : 1;
if ($tree->depth($parent) + $subtreeDepth > Category::MAX_DEPTH) {
throw ValidationException::withMessages([
'parent_id' => 'Categories can only be nested up to '.Category::MAX_DEPTH.' levels deep.',
]);
}
return $parent;
}
/**
* Derive the cashflow direction the web form would compute: a child inherits
* its parent's direction; a root follows its type (savings/investment always
* count as an outflow, transfers keep the requested direction, everything
* else is hidden).
*/
protected function cashflowDirectionFor(CategoryType $type, ?Category $parent, ?string $requested): CategoryCashflowDirection
{
if ($parent !== null) {
return $parent->cashflow_direction;
}
return match ($type) {
CategoryType::Savings, CategoryType::Investment => CategoryCashflowDirection::Outflow,
CategoryType::Transfer => CategoryCashflowDirection::tryFrom((string) $requested) ?? CategoryCashflowDirection::Hidden,
default => CategoryCashflowDirection::Hidden,
};
}
/**
* Whether a sibling category (same parent) with the given name already
* exists in the space, optionally ignoring one category by id (for edits).
*/
protected function categoryNameTaken(Space $space, string $name, ?string $parentId, ?string $ignoreId = null): bool
{
return Category::query()
->forSpace($space)
->where('name', $name)
->where('parent_id', $parentId)
->when($ignoreId !== null, fn ($query) => $query->whereKeyNot($ignoreId))
->exists();
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\RuleOrigin;
use App\Mcp\Tools\Concerns\DecodesRulesJson;
use App\Models\AutomationRule;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description(<<<'TEXT'
Create an automation rule that auto-applies a category and/or labels to matching transactions. `rules_json` is a JsonLogic object evaluated against these lowercase variables: description, notes, creditor_name, debtor_name, account_name, bank_name, category, transaction_date (YYYY-MM-DD) and amount. Note: amount here is in MAJOR units (e.g. 12.50), not cents. Example: {"and":[{">":[{"var":"amount"},100]},{"in":["grocery",{"var":"description"}]}]}. At least one action (action_category_id or action_label_ids) is required.
TEXT)]
class CreateAutomationRule extends WriteTool
{
use DecodesRulesJson;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()->description('Human-readable rule name.')->required(),
'priority' => $schema->integer()->min(0)->description('Lower numbers are evaluated first.')->required(),
'rules_json' => $schema->object()->description('JsonLogic condition object.')->required(),
'action_category_id' => $schema->string()->description('Category id to assign to matching transactions.'),
'action_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach to matching transactions.'),
'action_note' => $schema->string()->description('Note to append to matching transactions.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'title' => ['required', 'string', 'max:255'],
'priority' => ['required', 'integer', 'min:0'],
'action_note' => ['sometimes', 'nullable', 'string'],
]);
$space = $this->resolveSpace($request, $user);
$rulesJson = $this->rulesJson($request);
$labels = $this->labelsInSpace($request, $space, 'action_label_ids');
$categoryId = $request->filled('action_category_id')
? $this->categoryInSpace($request, $space, 'action_category_id')->id
: null;
if ($categoryId === null && $labels->isEmpty()) {
throw ValidationException::withMessages([
'action_category_id' => 'At least one action is required: pass action_category_id and/or action_label_ids.',
]);
}
$rule = new AutomationRule([
'user_id' => $user->id,
'space_id' => $space->id,
'title' => $request->string('title')->toString(),
'priority' => $request->integer('priority'),
'origin' => RuleOrigin::User->value,
'rules_json' => $rulesJson,
'action_category_id' => $categoryId,
'action_note' => $request->filled('action_note') ? $request->string('action_note')->toString() : null,
]);
$rule->save();
if ($labels->isNotEmpty()) {
$rule->labels()->sync($labels->pluck('id')->all());
$rule->touch();
}
return $this->json(['automation_rule' => $this->presentAutomationRule($rule)]);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AccountBalance;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Record an account balance snapshot on a non-connected (manual) account. Balance is an integer in minor units (cents). Replaces any existing snapshot for that date. Connected/bank accounts are read-only.')]
class CreateBalance extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'account_id' => $schema->string()->description('Id of a non-connected (manual) account.')->required(),
'balance' => $schema->integer()->description('Balance in minor units (cents).')->required(),
'balance_date' => $schema->string()->description('Snapshot date, YYYY-MM-DD. Defaults to today.'),
'invested_amount' => $schema->integer()->description('Optional invested amount in minor units (cents), for investment accounts.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'balance' => ['required', 'integer'],
'balance_date' => ['sometimes', 'date'],
'invested_amount' => ['sometimes', 'nullable', 'integer'],
]);
$space = $this->resolveSpace($request, $user);
$account = $this->writableAccount($request, $space);
$balanceDate = $request->filled('balance_date')
? $request->string('balance_date')->toString()
: now()->toDateString();
$balance = AccountBalance::updateOrCreate(
['account_id' => $account->id, 'balance_date' => $balanceDate],
[
'balance' => $request->integer('balance'),
...$request->filled('invested_amount')
? ['invested_amount' => $request->integer('invested_amount')]
: [],
],
);
return $this->json([
'balance' => [
'id' => $balance->id,
'account_id' => $balance->account_id,
'balance_date' => $balance->balance_date->toDateString(),
'balance' => $balance->balance,
'invested_amount' => $balance->invested_amount,
],
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
use App\Models\Category;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Create a category. A child (parent_id set) inherits its parent type and cashflow direction; a root follows its own type. Categories can be nested up to 3 levels deep.')]
class CreateCategory extends WriteTool
{
use ResolvesCategoryWrites;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->description('Category name (unique among its siblings).')->required(),
'icon' => $schema->string()->description('Icon name (e.g. ShoppingBag, Home, Car).')->required(),
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('Category color.')->required(),
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('Category type. Ignored for children (they inherit the parent type).')->required(),
'parent_id' => $schema->string()->description('Optional parent category id to nest under.'),
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'icon' => ['required', 'string'],
'color' => ['required', Rule::enum(CategoryColor::class)],
'type' => ['required', Rule::enum(CategoryType::class)],
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
]);
$space = $this->resolveSpace($request, $user);
$parent = $this->resolveParentCategory($request, $space, null);
$type = $parent !== null ? $parent->type : $request->enum('type', CategoryType::class);
$cashflow = $this->cashflowDirectionFor($type, $parent, $request->string('cashflow_direction')->toString());
$name = $request->string('name')->toString();
if ($this->categoryNameTaken($space, $name, $parent?->id)) {
throw ValidationException::withMessages([
'name' => 'A category with that name already exists at this level.',
]);
}
$category = new Category([
'user_id' => $user->id,
'space_id' => $space->id,
'name' => $name,
'icon' => $request->string('icon')->toString(),
'color' => $request->string('color')->toString(),
'type' => $type->value,
'cashflow_direction' => $cashflow->value,
'parent_id' => $parent?->id,
]);
$category->save();
return $this->json(['category' => $this->presentCategory($category)]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\LabelColor;
use App\Models\Label;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Create a label. Names are unique within the space.')]
class CreateLabel extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->description('Label name (unique within the space).')->required(),
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('Label color.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', Rule::enum(LabelColor::class)],
]);
$space = $this->resolveSpace($request, $user);
$name = $request->string('name')->toString();
if (Label::query()->forSpace($space)->where('name', $name)->exists()) {
throw ValidationException::withMessages([
'name' => 'A label with that name already exists.',
]);
}
$label = new Label([
'user_id' => $user->id,
'space_id' => $space->id,
'name' => $name,
'color' => $request->string('color')->toString(),
]);
$label->save();
return $this->json(['label' => $this->presentLabel($label)]);
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategorySource;
use App\Enums\TransactionSource;
use App\Models\Transaction;
use App\Models\User;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Create a manual transaction on a non-connected (manual) account. Amount is a signed integer in minor units (cents): negative for an expense, positive for income. Connected/bank accounts are read-only.')]
class CreateTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'account_id' => $schema->string()->description('Id of a non-connected (manual) account to add the transaction to.')->required(),
'description' => $schema->string()->description('Human-readable description.')->required(),
'amount' => $schema->integer()->description('Signed amount in minor units (cents). Negative = expense, positive = income.')->required(),
'transaction_date' => $schema->string()->description('Transaction date, YYYY-MM-DD.')->required(),
'currency_code' => $schema->string()->description('ISO 4217 currency code (3 letters). Defaults to the account currency.'),
'category_id' => $schema->string()->description('Optional category id to assign.'),
'creditor_name' => $schema->string()->description('Optional creditor (payee) name.'),
'debtor_name' => $schema->string()->description('Optional debtor (payer) name.'),
'notes' => $schema->string()->description('Optional free-text notes.'),
'label_ids' => $schema->array()->items($schema->string())->description('Optional label ids to attach.'),
'update_balance' => $schema->boolean()->description('When true, shift the manual account balance snapshots by this amount. Default false.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'account_id' => ['required', 'string'],
'description' => ['required', 'string'],
'amount' => ['required', 'integer'],
'transaction_date' => ['required', 'date'],
'currency_code' => ['sometimes', 'string', 'size:3'],
'notes' => ['sometimes', 'nullable', 'string'],
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
]);
$space = $this->resolveSpace($request, $user);
$account = $this->writableAccount($request, $space);
$labels = $this->labelsInSpace($request, $space, 'label_ids');
$categoryId = $request->filled('category_id')
? $this->categoryInSpace($request, $space)->id
: null;
$transaction = new Transaction([
'user_id' => $user->id,
'space_id' => $space->id,
'account_id' => $account->id,
'category_id' => $categoryId,
'category_source' => $categoryId === null ? null : CategorySource::Manual->value,
'description' => $request->string('description')->toString(),
'transaction_date' => $request->string('transaction_date')->toString(),
'amount' => $request->integer('amount'),
'currency_code' => $request->filled('currency_code')
? mb_strtoupper($request->string('currency_code')->toString())
: $account->currency_code,
'notes' => $request->filled('notes') ? $request->string('notes')->toString() : null,
'creditor_name' => $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null,
'debtor_name' => $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null,
'source' => TransactionSource::ManuallyCreated->value,
]);
$transaction->save();
if ($labels->isNotEmpty()) {
$transaction->labels()->sync($labels->pluck('id')->all());
}
if ($request->boolean('update_balance')) {
app(ManualBalanceAdjuster::class)->applyCreatedTransaction($transaction->load('account'));
}
return $this->json(['transaction' => $this->presentTransaction($transaction)]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AutomationRule;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete an automation rule. Transactions it already categorized keep their category; the rule simply stops running on future transactions.')]
class DeleteAutomationRule extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'automation_rule_id' => $schema->string()->description('Id of the automation rule to delete.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$id = $request->string('automation_rule_id')->toString();
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
if ($rule === null) {
throw ValidationException::withMessages([
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
]);
}
$rule->delete();
return $this->json(['deleted' => true, 'id' => $rule->id]);
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategoryDeletionStrategy;
use App\Models\Category;
use App\Models\User;
use App\Services\CategoryTree;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a category. The strategy decides what happens to its child categories: "reparent" (default) lifts them to the deleted category\'s parent, "promote" turns them into roots, "cascade" deletes the whole subtree and uncategorizes affected transactions.')]
class DeleteCategory extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'category_id' => $schema->string()->description('Id of the category to delete.')->required(),
'strategy' => $schema->string()->enum(array_column(CategoryDeletionStrategy::cases(), 'value'))->description('How to handle child categories. Defaults to "reparent".'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'strategy' => ['sometimes', Rule::enum(CategoryDeletionStrategy::class)],
]);
$space = $this->resolveSpace($request, $user);
$category = $this->categoryInSpace($request, $space);
$strategy = $request->enum('strategy', CategoryDeletionStrategy::class) ?? CategoryDeletionStrategy::Reparent;
$tree = new CategoryTree;
match ($strategy) {
CategoryDeletionStrategy::Cascade => $tree->deleteSubtree($category),
CategoryDeletionStrategy::Promote => $this->detachChildrenAndDelete($category, null),
CategoryDeletionStrategy::Reparent => $this->detachChildrenAndDelete($category, $category->parent_id),
};
return $this->json(['deleted' => true, 'id' => $category->id, 'strategy' => $strategy->value]);
}
/**
* Move the category's direct children to a new parent, then delete it.
*/
private function detachChildrenAndDelete(Category $category, ?string $newParentId): void
{
try {
$category->children()->update(['parent_id' => $newParentId]);
} catch (UniqueConstraintViolationException) {
throw ValidationException::withMessages([
'strategy' => 'A category with the same name already exists at the destination level. Rename it first.',
]);
}
$category->delete();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a label. It is removed from every transaction it was attached to.')]
class DeleteLabel extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'label_id' => $schema->string()->description('Id of the label to delete.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$label = $this->labelInSpace($request, $space);
$label->delete();
return $this->json(['deleted' => true, 'id' => $label->id]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\TransactionSource;
use App\Models\User;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a manually-created transaction. Only manual transactions can be deleted; bank/imported ones cannot.')]
class DeleteTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to delete.')->required(),
'update_balance' => $schema->boolean()->description('When true, reverse this transaction from the manual account balance snapshots. Default false.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
if ($transaction->source !== TransactionSource::ManuallyCreated) {
return Response::error('Only manually-created transactions can be deleted. This one came from a bank or import.');
}
if ($request->boolean('update_balance')) {
app(ManualBalanceAdjuster::class)->reverseDeletedTransaction($transaction);
}
$transaction->delete();
return $this->json(['deleted' => true, 'id' => $transaction->id]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Mcp\Tools;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('The full cashflow picture for a date range as JSON, mirroring the app\'s cashflow screen: income/expense/savings/investment summary (current vs previous), the income-vs-expense category flow (sankey), and the monthly trend. Amounts are in minor units (cents). Covers the user\'s whole account.')]
class GetCashflow extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date'],
]);
$controller = app(CashflowAnalyticsController::class);
$range = ['from' => $request->string('from')->toString(), 'to' => $request->string('to')->toString()];
return $this->json([
'summary' => $this->callController($controller, 'summary', $user, $range),
'sankey' => $this->callController($controller, 'sankey', $user, $range),
'trend' => $this->callController($controller, 'trend', $user, $range),
]);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Mcp\Tools;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Net worth for a date range as JSON: the current total vs the previous period, plus the per-account balance evolution over time. Set granularity to "monthly" (default) or "daily". Amounts are in minor units (cents). Covers the user\'s whole account.')]
class GetNetWorth extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
'granularity' => $schema->string()->enum(['monthly', 'daily'])->description('Evolution granularity (default monthly).'),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date'],
'granularity' => ['sometimes', 'in:monthly,daily'],
]);
$controller = app(DashboardAnalyticsController::class);
$range = ['from' => $request->string('from')->toString(), 'to' => $request->string('to')->toString()];
$daily = $request->string('granularity')->toString() === 'daily';
return $this->json([
'granularity' => $daily ? 'daily' : 'monthly',
'current' => $this->callController($controller, 'netWorth', $user, $range),
'evolution' => $this->callController(
$controller,
$daily ? 'netWorthDailyEvolution' : 'netWorthEvolution',
$user,
$range,
),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Add and/or remove labels on any transaction, including bank/imported ones. Pass add_label_ids and/or remove_label_ids.')]
class LabelTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the transaction to label.')->required(),
'add_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach.'),
'remove_label_ids' => $schema->array()->items($schema->string())->description('Label ids to detach.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
$add = $this->labelsInSpace($request, $space, 'add_label_ids');
$remove = $this->labelsInSpace($request, $space, 'remove_label_ids');
if ($add->isEmpty() && $remove->isEmpty()) {
return Response::error('Provide add_label_ids and/or remove_label_ids (arrays of label ids).');
}
if ($add->isNotEmpty()) {
$transaction->labels()->syncWithoutDetaching($add->pluck('id')->all());
}
if ($remove->isNotEmpty()) {
$transaction->labels()->detach($remove->pluck('id')->all());
}
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Account;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the user\'s accounts in a space, including whether each is connected to a bank/provider (connected accounts are read-only).')]
class ListAccounts extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$accounts = Account::query()
->forSpace($space)
->with('bank:id,name')
->orderBy('name')
->get()
->map(fn (Account $account): array => [
'id' => $account->id,
'name' => $account->name,
'type' => $account->type->value,
'currency' => $account->currency_code,
'bank' => $account->bank?->name,
'is_connected' => $account->isConnected(),
]);
return $this->json([
'space_id' => $space->id,
'accounts' => $accounts,
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Category;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the user\'s categories in a space. Categories form a tree via parent_id; use the ids to filter search_transactions or spending_by_category.')]
class ListCategories extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$categories = Category::query()
->forSpace($space)
->orderBy('name')
->get()
->map(fn (Category $category): array => [
'id' => $category->id,
'name' => $category->name,
'type' => $category->type->value,
'cashflow_direction' => $category->cashflow_direction->value,
'parent_id' => $category->parent_id,
]);
return $this->json([
'space_id' => $space->id,
'categories' => $categories,
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Label;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the user\'s labels in a space. Use the ids with label_transaction or automation-rule label actions.')]
class ListLabels extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$labels = Label::query()
->forSpace($space)
->orderBy('name')
->get()
->map(fn (Label $label): array => [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
]);
return $this->json([
'space_id' => $space->id,
'labels' => $labels,
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Space;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the spaces the user can access (personal and shared). Pass a space id to the other tools\' `space` argument to query a specific one.')]
class ListSpaces extends McpTool
{
/**
* @return array<string, JsonSchema>
*/
public function schema(JsonSchema $schema): array
{
return [];
}
protected function respond(Request $request, User $user): Response
{
$spaces = $user->accessibleSpaces()
->map(fn (Space $space): array => [
'id' => $space->id,
'name' => $space->name,
'personal' => $space->personal,
'is_current' => $space->id === $user->current_space_id,
]);
return $this->json(['spaces' => $spaces]);
}
}

104
app/Mcp/Tools/McpTool.php Normal file
View File

@ -0,0 +1,104 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\PlanFeature;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
/**
* Base for every Whisper Money read tool. Enforces the Pro-plan gate on each
* call (a lapsed subscription stops working without revoking the token) and
* provides the shared space-resolution and JSON-encoding helpers.
*/
abstract class McpTool extends Tool
{
/**
* Expose snake_case tool names (search_transactions, list_spaces, ) instead
* of the framework default kebab-case, matching the documented tool catalog.
*/
public function name(): string
{
return Str::snake(class_basename($this));
}
public function handle(Request $request): Response
{
$user = $request->user();
if (! $user instanceof User) {
return Response::error('Authentication required.');
}
if (! $user->canUseFeature(PlanFeature::McpAccess)) {
return Response::error(
'A paid (Pro) plan is required to use the Whisper Money MCP. Upgrade your account at '.route('subscribe')
);
}
return $this->respond($request, $user);
}
abstract protected function respond(Request $request, User $user): Response;
/**
* Encode structured data as a JSON text response the agent can parse.
*/
protected function json(mixed $data): Response
{
return Response::text((string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* Reuse an existing analytics controller by invoking one of its actions with
* a synthesized GET request bound to the MCP user, returning its JSON body.
* Keeps the (user-scoped) dashboard maths in exactly one place.
*
* ponytail: couples to the controllers returning a JsonResponse; acceptable
* while they're stable. Extract the orchestration into a shared service if a
* controller stops returning JSON or a third tool needs the same maths.
*
* @param array<string, mixed> $query
* @return array<array-key, mixed>
*/
protected function callController(object $controller, string $method, User $user, array $query): array
{
$httpRequest = \Illuminate\Http\Request::create('/', 'GET', $query);
$httpRequest->setUserResolver(fn (): User => $user);
return $controller->{$method}($httpRequest)->getData(true);
}
/**
* The space a tool operates on: the optional `space` argument (validated
* against the spaces the user can access) or the user's personal space.
*
* Scoping is by `space_id` only, gated by membership (`accessibleSpaces`): a
* space is a shared tenant, so a member is meant to see every row in it. The
* security boundary is the membership check here, not a per-row `user_id`
* filter.
*/
protected function resolveSpace(Request $request, User $user): Space
{
$spaceId = $request->string('space')->toString();
if ($spaceId === '') {
return $user->personalSpace ?? $user->activeSpace();
}
$space = $user->accessibleSpaces()->firstWhere('id', $spaceId);
if ($space === null) {
throw ValidationException::withMessages([
'space' => "You do not have access to a space with id {$spaceId}. Call list_spaces to see valid ids.",
]);
}
return $space;
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Search and filter the user\'s transactions by text, category, account, date range and amount. Amounts are integers in minor units (cents). Use this to analyse spending or to find recurring charges by grouping results by merchant.')]
class SearchTransactions extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()->description('Free text matched against description, creditor and debtor names.'),
'account_id' => $schema->string()->description('Restrict to a single account id.'),
'category_id' => $schema->string()->description('Restrict to a single category id.'),
'from' => $schema->string()->description('Earliest transaction date, YYYY-MM-DD.'),
'to' => $schema->string()->description('Latest transaction date, YYYY-MM-DD.'),
'min_amount' => $schema->integer()->description('Minimum signed amount in minor units (cents).'),
'max_amount' => $schema->integer()->description('Maximum signed amount in minor units (cents).'),
'limit' => $schema->integer()->min(1)->max(200)->description('Max rows to return (default 50).'),
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['sometimes', 'date'],
'to' => ['sometimes', 'date'],
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
]);
$space = $this->resolveSpace($request, $user);
$transactions = Transaction::query()
->forSpace($space)
->with(['account:id,name', 'category:id,name,type'])
->when($request->string('query')->toString() !== '', function ($query) use ($request): void {
$term = '%'.$request->string('query')->toString().'%';
$query->where(function ($q) use ($term): void {
$q->where('description', 'like', $term)
->orWhere('creditor_name', 'like', $term)
->orWhere('debtor_name', 'like', $term);
});
})
->when($request->string('account_id')->toString() !== '', fn ($query) => $query->where('account_id', $request->string('account_id')->toString()))
->when($request->string('category_id')->toString() !== '', fn ($query) => $query->where('category_id', $request->string('category_id')->toString()))
->when($request->string('from')->toString() !== '', fn ($query) => $query->whereDate('transaction_date', '>=', $request->string('from')->toString()))
->when($request->string('to')->toString() !== '', fn ($query) => $query->whereDate('transaction_date', '<=', $request->string('to')->toString()))
->when($request->has('min_amount'), fn ($query) => $query->where('amount', '>=', $request->integer('min_amount')))
->when($request->has('max_amount'), fn ($query) => $query->where('amount', '<=', $request->integer('max_amount')))
->orderByDesc('transaction_date')
->limit($request->integer('limit', 50))
->get()
->map(fn (Transaction $transaction): array => [
'id' => $transaction->id,
'date' => $transaction->transaction_date->toDateString(),
'description' => $transaction->description,
'amount' => $transaction->amount,
'currency' => $transaction->currency_code,
'category' => $transaction->category?->name,
'category_id' => $transaction->category_id,
'account' => $transaction->account?->name,
'account_id' => $transaction->account_id,
'source' => $transaction->source->value,
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
]);
return $this->json([
'space_id' => $space->id,
'count' => $transactions->count(),
'transactions' => $transactions,
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use App\Services\CategorySpendingService;
use Carbon\Carbon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Expense spending rolled up by category for a date range. Without parent_category_id, root categories are returned; pass one to drill into its children. Amounts are in minor units (cents). Covers the user\'s whole account.')]
class SpendingByCategory extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
'parent_category_id' => $schema->string()->description('Drill into a parent category\'s children.'),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date'],
]);
$spending = app(CategorySpendingService::class)->forPeriod(
$user->id,
Carbon::parse($request->string('from')->toString()),
Carbon::parse($request->string('to')->toString()),
$request->string('parent_category_id')->toString() ?: null,
);
return $this->json(['categories' => $spending->values()]);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Tools\Concerns\DecodesRulesJson;
use App\Models\AutomationRule;
use App\Models\Space;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit an automation rule. Only the fields you pass are changed. The rule must always keep at least one action (a category or labels). See create_automation_rule for the rules_json format.')]
class UpdateAutomationRule extends WriteTool
{
use DecodesRulesJson;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'automation_rule_id' => $schema->string()->description('Id of the automation rule to edit.')->required(),
'title' => $schema->string()->description('New rule name.'),
'priority' => $schema->integer()->min(0)->description('New priority (lower is evaluated first).'),
'rules_json' => $schema->object()->description('New JsonLogic condition object.'),
'action_category_id' => $schema->string()->description('New category id to assign, or null to clear.'),
'action_label_ids' => $schema->array()->items($schema->string())->description('Replacement set of label ids (replaces all existing labels).'),
'action_note' => $schema->string()->description('New note to append, or null to clear.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'priority' => ['sometimes', 'integer', 'min:0'],
'action_note' => ['sometimes', 'nullable', 'string'],
]);
$space = $this->resolveSpace($request, $user);
$rule = $this->ruleInSpace($request, $space);
if ($request->has('title')) {
$rule->title = $request->string('title')->toString();
}
if ($request->has('priority')) {
$rule->priority = $request->integer('priority');
}
if ($request->has('rules_json')) {
$rule->rules_json = $this->rulesJson($request);
}
if ($request->has('action_category_id')) {
$rule->action_category_id = $request->filled('action_category_id')
? $this->categoryInSpace($request, $space, 'action_category_id')->id
: null;
}
if ($request->has('action_note')) {
$rule->action_note = $request->filled('action_note') ? $request->string('action_note')->toString() : null;
}
$newLabels = $request->has('action_label_ids')
? $this->labelsInSpace($request, $space, 'action_label_ids')
: null;
// The rule must keep at least one action. Compare against the labels it
// would have after this edit (the new set if provided, else current).
$labelCount = $newLabels !== null ? $newLabels->count() : $rule->labels()->count();
if ($rule->action_category_id === null && $labelCount === 0) {
throw ValidationException::withMessages([
'action_category_id' => 'An automation rule must keep at least one action: a category or labels.',
]);
}
$rule->save();
if ($newLabels !== null) {
$rule->labels()->sync($newLabels->pluck('id')->all());
}
$rule->touch();
return $this->json(['automation_rule' => $this->presentAutomationRule($rule->refresh())]);
}
private function ruleInSpace(Request $request, Space $space): AutomationRule
{
$id = $request->string('automation_rule_id')->toString();
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
if ($rule === null) {
throw ValidationException::withMessages([
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
]);
}
return $rule;
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
use App\Models\Category;
use App\Models\Space;
use App\Models\User;
use App\Services\CategoryTree;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit a category. Only the fields you pass are changed. Moving it under a parent (or clearing parent_id to make it a root) re-derives its type/cashflow and cascades the type to its descendants.')]
class UpdateCategory extends WriteTool
{
use ResolvesCategoryWrites;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'category_id' => $schema->string()->description('Id of the category to edit.')->required(),
'name' => $schema->string()->description('New category name.'),
'icon' => $schema->string()->description('New icon name.'),
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('New category color.'),
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('New type (ignored while the category has a parent).'),
'parent_id' => $schema->string()->description('New parent id, or null to make it a root.'),
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'icon' => ['sometimes', 'string'],
'color' => ['sometimes', Rule::enum(CategoryColor::class)],
'type' => ['sometimes', Rule::enum(CategoryType::class)],
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
]);
$space = $this->resolveSpace($request, $user);
$category = $this->categoryInSpace($request, $space);
$parent = $request->has('parent_id')
? $this->resolveParentCategory($request, $space, $category)
: $this->currentParent($category, $space);
$type = $parent !== null
? $parent->type
: ($request->has('type') ? $request->enum('type', CategoryType::class) : $category->type);
$requestedDirection = $request->filled('cashflow_direction')
? $request->string('cashflow_direction')->toString()
: $category->cashflow_direction->value;
$cashflow = $this->cashflowDirectionFor($type, $parent, $requestedDirection);
$name = $request->has('name') ? $request->string('name')->toString() : $category->name;
if ($this->categoryNameTaken($space, $name, $parent?->id, $category->id)) {
throw ValidationException::withMessages([
'name' => 'A category with that name already exists at this level.',
]);
}
$category->fill([
'name' => $name,
'icon' => $request->has('icon') ? $request->string('icon')->toString() : $category->icon,
'color' => $request->has('color') ? $request->string('color')->toString() : $category->color,
'type' => $type->value,
'cashflow_direction' => $cashflow->value,
'parent_id' => $parent?->id,
]);
$category->save();
(new CategoryTree)->syncDescendantTypes($category);
return $this->json(['category' => $this->presentCategory($category->refresh())]);
}
private function currentParent(Category $category, Space $space): ?Category
{
if ($category->parent_id === null) {
return null;
}
return Category::query()->forSpace($space)->whereKey($category->parent_id)->first();
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\LabelColor;
use App\Models\Label;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit a label. Only the fields you pass are changed.')]
class UpdateLabel extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'label_id' => $schema->string()->description('Id of the label to edit.')->required(),
'name' => $schema->string()->description('New label name (unique within the space).'),
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('New label color.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'color' => ['sometimes', Rule::enum(LabelColor::class)],
]);
$space = $this->resolveSpace($request, $user);
$label = $this->labelInSpace($request, $space);
if ($request->has('name')) {
$name = $request->string('name')->toString();
$exists = Label::query()
->forSpace($space)
->where('name', $name)
->whereKeyNot($label->id)
->exists();
if ($exists) {
throw ValidationException::withMessages([
'name' => 'A label with that name already exists.',
]);
}
$label->name = $name;
}
if ($request->has('color')) {
$label->color = $request->string('color')->toString();
}
$label->save();
return $this->json(['label' => $this->presentLabel($label)]);
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategorySource;
use App\Enums\TransactionSource;
use App\Models\User;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Carbon;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit a manually-created transaction. Only manual transactions can be edited; bank/imported ones keep their core fields locked (use categorize_transaction or label_transaction for those). Only the fields you pass are changed.')]
class UpdateTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to edit.')->required(),
'description' => $schema->string()->description('New description.'),
'amount' => $schema->integer()->description('New signed amount in minor units (cents).'),
'transaction_date' => $schema->string()->description('New transaction date, YYYY-MM-DD.'),
'currency_code' => $schema->string()->description('New ISO 4217 currency code (3 letters).'),
'account_id' => $schema->string()->description('Move the transaction to another non-connected account.'),
'category_id' => $schema->string()->description('New category id, or null to clear the category.'),
'creditor_name' => $schema->string()->description('New creditor (payee) name.'),
'debtor_name' => $schema->string()->description('New debtor (payer) name.'),
'notes' => $schema->string()->description('New free-text notes.'),
'update_balance' => $schema->boolean()->description('When true and the amount/date/account changed, move the manual account balance snapshots accordingly. Default false.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
if ($transaction->source !== TransactionSource::ManuallyCreated) {
return Response::error('Only manually-created transactions can be edited. This one came from a bank or import, so its core fields are locked. Use categorize_transaction or label_transaction instead.');
}
$request->validate([
'description' => ['sometimes', 'string'],
'amount' => ['sometimes', 'integer'],
'transaction_date' => ['sometimes', 'date'],
'currency_code' => ['sometimes', 'string', 'size:3'],
'notes' => ['sometimes', 'nullable', 'string'],
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
]);
// Snapshot the pre-edit account/date/amount so a manual balance can be
// moved off the old values if the edit changes them.
$originalSnapshot = clone $transaction;
if ($request->has('description')) {
$transaction->description = $request->string('description')->toString();
}
if ($request->has('amount')) {
$transaction->amount = $request->integer('amount');
}
if ($request->has('transaction_date')) {
$transaction->transaction_date = Carbon::parse($request->string('transaction_date')->toString());
}
if ($request->has('currency_code')) {
$transaction->currency_code = mb_strtoupper($request->string('currency_code')->toString());
}
if ($request->has('account_id')) {
$transaction->account_id = $this->writableAccount($request, $space)->id;
}
if ($request->has('notes')) {
$transaction->notes = $request->filled('notes') ? $request->string('notes')->toString() : null;
}
if ($request->has('creditor_name')) {
$transaction->creditor_name = $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null;
}
if ($request->has('debtor_name')) {
$transaction->debtor_name = $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null;
}
// A new category is always a manual assignment: reset any AI/rule
// provenance so the row is not later treated as machine-categorized.
// ponytail: unlike the web edit path this does not learn a correction
// rule — MCP writes stay predictable and side-effect free.
if ($request->has('category_id')) {
$newCategoryId = $request->filled('category_id') ? $this->categoryInSpace($request, $space)->id : null;
if ($newCategoryId !== $transaction->category_id) {
$transaction->category_id = $newCategoryId;
$transaction->category_source = $newCategoryId === null ? null : CategorySource::Manual;
$transaction->ai_confidence = null;
$transaction->categorized_by_rule_id = null;
}
}
$transaction->save();
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
$adjuster = app(ManualBalanceAdjuster::class);
$adjuster->reverseDeletedTransaction($originalSnapshot);
$adjuster->applyCreatedTransaction($transaction->load('account'));
}
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
}
}

229
app/Mcp/Tools/WriteTool.php Normal file
View File

@ -0,0 +1,229 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\Label;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
/**
* Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate
* it gates write access: OAuth connections (Claude Desktop / ChatGPT) get
* read+write, and Sanctum personal access tokens must carry the `mcp:write`
* ability, so a read-only PAT can analyse data but never change it.
*
* Each concrete write tool must additionally carry the #[IsDestructive]
* annotation. PHP attributes are not inherited, so the framework only reports
* one declared directly on the served tool class it cannot live here.
*/
abstract class WriteTool extends McpTool
{
protected function respond(Request $request, User $user): Response
{
// Write access is granted to OAuth connections (Claude Desktop /
// ChatGPT, resolved via the `api` guard — the user approves the
// connection on the consent screen) and to Sanctum personal access
// tokens carrying the mcp:write ability. A read-only Sanctum token is
// rejected. Bank-connected data stays protected for both (see the
// writableAccount / transaction helpers below).
if (Auth::getDefaultDriver() !== 'api' && ! $user->tokenCan('mcp:write')) {
return Response::error('This token is read-only. Create a read & write token to make changes.');
}
return $this->write($request, $user);
}
abstract protected function write(Request $request, User $user): Response;
/**
* Resolve an account the token may write to: it must live in the space and
* must not be connected to a bank (bank-sourced data is never touched).
*/
protected function writableAccount(Request $request, Space $space, string $key = 'account_id'): Account
{
$id = $request->string($key)->toString();
$account = Account::query()->forSpace($space)->whereKey($id)->first();
if ($account === null) {
throw ValidationException::withMessages([
$key => "No account with id {$id} in space {$space->id}. Call list_accounts to see valid ids.",
]);
}
if ($account->isConnected()) {
throw ValidationException::withMessages([
$key => 'That account is connected to a bank and is read-only. Only non-connected (manual) accounts can be written to.',
]);
}
return $account;
}
protected function transactionInSpace(Request $request, Space $space, string $key = 'transaction_id'): Transaction
{
$id = $request->string($key)->toString();
$transaction = Transaction::query()->forSpace($space)->whereKey($id)->first();
if ($transaction === null) {
throw ValidationException::withMessages([
$key => "No transaction with id {$id} in space {$space->id}. Call search_transactions to find ids.",
]);
}
return $transaction;
}
protected function categoryInSpace(Request $request, Space $space, string $key = 'category_id'): Category
{
$id = $request->string($key)->toString();
$category = Category::query()->forSpace($space)->whereKey($id)->first();
if ($category === null) {
throw ValidationException::withMessages([
$key => "No category with id {$id} in space {$space->id}. Call list_categories to see valid ids.",
]);
}
return $category;
}
protected function labelInSpace(Request $request, Space $space, string $key = 'label_id'): Label
{
$id = $request->string($key)->toString();
$label = Label::query()->forSpace($space)->whereKey($id)->first();
if ($label === null) {
throw ValidationException::withMessages([
$key => "No label with id {$id} in space {$space->id}. Call list_labels to see valid ids.",
]);
}
return $label;
}
/**
* Resolve every label id passed under $key, asserting each belongs to the
* space. Returns an empty collection when the argument is absent or empty.
*
* @return Collection<int, Label>
*/
protected function labelsInSpace(Request $request, Space $space, string $key): Collection
{
$ids = collect($request->get($key, []))
->map(fn (mixed $id): string => (string) $id)
->filter()
->unique()
->values();
if ($ids->isEmpty()) {
/** @var Collection<int, Label> $empty */
$empty = Label::query()->whereRaw('1 = 0')->get();
return $empty;
}
$labels = Label::query()->forSpace($space)->whereIn('id', $ids)->get();
if ($labels->count() !== $ids->count()) {
throw ValidationException::withMessages([
$key => "One or more label ids do not exist in space {$space->id}. Call list_labels to see valid ids.",
]);
}
return $labels;
}
/**
* The transaction shape returned by every transaction write tool, matching
* the fields search_transactions exposes so the agent sees a familiar row.
*
* @return array<string, mixed>
*/
protected function presentTransaction(Transaction $transaction): array
{
$transaction->loadMissing(['account:id,name', 'category:id,name', 'labels:id,name']);
return [
'id' => $transaction->id,
'date' => $transaction->transaction_date->toDateString(),
'description' => $transaction->description,
'amount' => $transaction->amount,
'currency' => $transaction->currency_code,
'category_id' => $transaction->category_id,
'category' => $transaction->category?->name,
'category_source' => $transaction->category_source?->value,
'account_id' => $transaction->account_id,
'account' => $transaction->account?->name,
'source' => $transaction->source->value,
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
'labels' => $transaction->labels
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
->values()
->all(),
];
}
/**
* @return array<string, mixed>
*/
protected function presentCategory(Category $category): array
{
return [
'id' => $category->id,
'name' => $category->name,
'icon' => $category->icon,
'color' => $category->color,
'type' => $category->type->value,
'cashflow_direction' => $category->cashflow_direction->value,
'parent_id' => $category->parent_id,
];
}
/**
* @return array<string, mixed>
*/
protected function presentLabel(Label $label): array
{
return [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
];
}
/**
* @return array<string, mixed>
*/
protected function presentAutomationRule(AutomationRule $rule): array
{
$rule->loadMissing('labels:id,name');
return [
'id' => $rule->id,
'title' => $rule->title,
'priority' => $rule->priority,
'rules_json' => $rule->rules_json,
'action_category_id' => $rule->action_category_id,
'action_note' => $rule->action_note,
'origin' => $rule->origin->value,
'labels' => $rule->labels
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
->values()
->all(),
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\AccountType;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\AccountFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model
{
/** @use HasFactory<AccountFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'name',
'name_iv',
'bank_id',
@ -40,6 +42,7 @@ class Account extends Model
/** @var list<string> */
protected $hidden = [
'user_id',
'space_id',
'bank_id',
'iban',
'position',
@ -101,6 +104,12 @@ class Account extends Model
return $this->hasMany(AccountBalance::class);
}
/** @return HasMany<AccountImportConfig, $this> */
public function importConfigs(): HasMany
{
return $this->hasMany(AccountImportConfig::class);
}
/** @return BelongsTo<BankingConnection, $this> */
public function bankingConnection(): BelongsTo
{

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use App\Enums\ImportConfigType;
use Database\Factories\AccountImportConfigFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AccountImportConfig extends Model
{
/** @use HasFactory<AccountImportConfigFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'account_id',
'type',
'config',
];
protected function casts(): array
{
return [
'type' => ImportConfigType::class,
'config' => 'array',
];
}
/** @return BelongsTo<Account, $this> */
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\RuleOrigin;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\AutomationRuleFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class AutomationRule extends Model
{
/** @use HasFactory<AutomationRuleFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'title',
'priority',
'origin',
@ -32,6 +34,11 @@ class AutomationRule extends Model
'action_note_iv',
];
/** @var list<string> */
protected $hidden = [
'space_id',
];
protected function casts(): array
{
return [
@ -62,6 +69,7 @@ class AutomationRule extends Model
return $this->belongsTo(Category::class, 'action_category_id');
}
/** @return BelongsToMany<Label, $this, AutomationRuleLabel, 'pivot'> */
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class, 'automation_rule_labels')

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Models\Concerns\BelongsToSpace;
use Carbon\Carbon;
use Database\Factories\BankingConnectionFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -28,10 +29,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class BankingConnection extends Model
{
/** @use HasFactory<BankingConnectionFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'provider',
'authorization_id',
'state_token',
@ -52,6 +54,7 @@ class BankingConnection extends Model
];
protected $hidden = [
'space_id',
'api_token',
'api_secret',
'pending_accounts_data',

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\BudgetPeriodType;
use App\Enums\RolloverType;
use App\Models\Concerns\BelongsToSpace;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -18,10 +19,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
*/
class Budget extends Model
{
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'name',
'period_type',
'period_start_day',
@ -32,6 +34,7 @@ class Budget extends Model
/** @var list<string> */
protected $hidden = [
'period_duration',
'space_id',
];
protected function casts(): array

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -26,7 +27,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
/**
* Maximum allowed nesting depth (a root counts as level 1).
@ -40,12 +41,14 @@ class Category extends Model
'type',
'cashflow_direction',
'user_id',
'space_id',
'parent_id',
];
/** @var list<string> */
protected $hidden = [
'user_id',
'space_id',
'created_at',
'updated_at',
'deleted_at',

View File

@ -0,0 +1,69 @@
<?php
namespace App\Models\Concerns;
use App\Models\Space;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Marks a model as belonging to a Space (the tenant). On create, `space_id` is
* filled from the row's own `user_id` (its owner's current space) unless it was
* set explicitly so factories and existing single-owner creation paths keep
* working untouched, while multi-space callers pass `space_id` themselves.
*
* @property ?string $space_id
*/
trait BelongsToSpace
{
public static function bootBelongsToSpace(): void
{
static::creating(function ($model): void {
if ($model->space_id === null) {
$model->space_id = $model->resolveDefaultSpaceId();
}
});
}
/** @return BelongsTo<Space, $this> */
public function space(): BelongsTo
{
return $this->belongsTo(Space::class);
}
/**
* Restrict a query to a single space.
*
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeForSpace(Builder $query, Space|string $space): Builder
{
return $query->where($this->getTable().'.space_id', $space instanceof Space ? $space->id : $space);
}
/**
* The space a new row defaults to when none was set explicitly: the current
* space of the user that owns the row. Models with a stronger anchor (e.g. a
* transaction inheriting its account's space) override this.
*/
protected function resolveDefaultSpaceId(): ?string
{
return $this->spaceIdFromUser();
}
/**
* The current space of the user that owns this row, if any.
*/
protected function spaceIdFromUser(): ?string
{
$userId = $this->getAttribute('user_id');
if ($userId === null) {
return null;
}
return User::query()->whereKey($userId)->value('current_space_id');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\LabelFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,12 +14,13 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Label extends Model
{
/** @use HasFactory<LabelFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'name',
'color',
'user_id',
'space_id',
];
/**
@ -29,6 +31,7 @@ class Label extends Model
*/
protected $hidden = [
'pivot',
'space_id',
];
/** @return BelongsTo<User, $this> */

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\AnalysisMode;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\SavedFilterFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -12,10 +13,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SavedFilter extends Model
{
/** @use HasFactory<SavedFilterFactory> */
use HasFactory, HasUuids;
use BelongsToSpace, HasFactory, HasUuids;
protected $fillable = [
'user_id',
'space_id',
'name',
'filters',
'analysis_days',
@ -25,6 +27,7 @@ class SavedFilter extends Model
/** @var list<string> */
protected $hidden = [
'user_id',
'space_id',
'created_at',
'updated_at',
];

134
app/Models/Space.php Normal file
View File

@ -0,0 +1,134 @@
<?php
namespace App\Models;
use Database\Factories\SpaceFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string $id
* @property string $owner_id
* @property string $name
* @property bool $personal
*/
class Space extends Model
{
/** @use HasFactory<SpaceFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'owner_id',
'name',
'personal',
];
protected function casts(): array
{
return [
'personal' => 'boolean',
];
}
/** @return BelongsTo<User, $this> */
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
/**
* Members invited into the space (excludes the owner, who is implicit).
*
* @return BelongsToMany<User, $this>
*/
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class, 'space_user')
->withPivot('role')
->withTimestamps();
}
/** @return HasMany<SpaceInvitation, $this> */
public function invitations(): HasMany
{
return $this->hasMany(SpaceInvitation::class);
}
/** @return HasMany<Account, $this> */
public function accounts(): HasMany
{
return $this->hasMany(Account::class);
}
/** @return HasMany<BankingConnection, $this> */
public function bankingConnections(): HasMany
{
return $this->hasMany(BankingConnection::class);
}
/** @return HasMany<Transaction, $this> */
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
/** @return HasMany<Category, $this> */
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
/** @return HasMany<Label, $this> */
public function labels(): HasMany
{
return $this->hasMany(Label::class);
}
/** @return HasMany<Budget, $this> */
public function budgets(): HasMany
{
return $this->hasMany(Budget::class);
}
/** @return HasMany<AutomationRule, $this> */
public function automationRules(): HasMany
{
return $this->hasMany(AutomationRule::class);
}
/** @return HasMany<SavedFilter, $this> */
public function savedFilters(): HasMany
{
return $this->hasMany(SavedFilter::class);
}
/**
* Whether the given user owns or is a member of this space. The owner check
* short-circuits without a query for the common (personal-space) case.
*/
public function hasMember(User $user): bool
{
if ($this->owner_id === $user->id) {
return true;
}
return $this->members()->whereKey($user->id)->exists();
}
/**
* The role the user holds in this space: owner, the pivot role, or null when
* they have no access.
*/
public function roleFor(User $user): ?string
{
if ($this->owner_id === $user->id) {
return 'owner';
}
return $this->members()->whereKey($user->id)->value('role');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $id
* @property string $space_id
* @property ?string $invited_by_id
* @property string $email
* @property string $role
* @property string $token
* @property ?Carbon $expires_at
* @property ?Carbon $accepted_at
*/
class SpaceInvitation extends Model
{
use HasUuids;
protected $fillable = [
'space_id',
'invited_by_id',
'email',
'role',
'token',
'expires_at',
'accepted_at',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
];
}
/** @return BelongsTo<Space, $this> */
public function space(): BelongsTo
{
return $this->belongsTo(Space::class);
}
/** @return BelongsTo<User, $this> */
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_id');
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
public function isAccepted(): bool
{
return $this->accepted_at !== null;
}
}

View File

@ -9,6 +9,7 @@ use App\Enums\TransactionSource;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
use App\Events\TransactionUpdated;
use App\Models\Concerns\BelongsToSpace;
use App\Services\CategoryTree;
use Carbon\Carbon;
use Database\Factories\TransactionFactory;
@ -25,6 +26,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property Carbon $transaction_date
* @property int|float $total_amount
* @property TransactionSource $source
* @property ?CategorySource $category_source
* @property ?float $ai_confidence
* @property ?string $categorized_by_rule_id
@ -35,7 +37,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Transaction extends Model
{
/** @use HasFactory<TransactionFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
/** @var array<string, class-string> */
protected $dispatchesEvents = [
@ -46,6 +48,7 @@ class Transaction extends Model
protected $fillable = [
'user_id',
'space_id',
'account_id',
'category_id',
'category_source',
@ -77,6 +80,7 @@ class Transaction extends Model
* @var list<string>
*/
protected $hidden = [
'space_id',
'original_description',
'external_transaction_id',
'dedup_fingerprint',
@ -111,6 +115,26 @@ class Transaction extends Model
return $this->belongsTo(Account::class);
}
/**
* A transaction always lives in its account's space (the account is the
* tenant anchor), so bank-sync inserts land in the right space regardless of
* whichever space the syncing user is currently viewing.
*/
protected function resolveDefaultSpaceId(): ?string
{
$accountId = $this->getAttribute('account_id');
if ($accountId !== null) {
$spaceId = Account::query()->whereKey($accountId)->value('space_id');
if ($spaceId !== null) {
return $spaceId;
}
}
return $this->spaceIdFromUser();
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{

View File

@ -10,8 +10,11 @@ use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -23,6 +26,7 @@ use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Pennant\Concerns\HasFeatures;
use Laravel\Sanctum\HasApiTokens;
/**
* @property ?Carbon $last_logged_in_at
@ -33,7 +37,7 @@ use Laravel\Pennant\Concerns\HasFeatures;
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
use Billable, HasApiTokens, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
@ -50,6 +54,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'currency_code',
'locale',
'timezone',
'current_space_id',
];
/**
@ -89,6 +94,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
];
}
/**
* Memoized active space for the current request lifecycle.
*/
protected ?Space $resolvedActiveSpace = null;
protected static function booted(): void
{
static::created(function (User $user): void {
$user->provisionPersonalSpace();
});
}
public function isOnboarded(): bool
{
return $this->onboarded_at !== null;
@ -139,6 +156,97 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
});
}
/** @return BelongsTo<Space, $this> */
public function currentSpace(): BelongsTo
{
return $this->belongsTo(Space::class, 'current_space_id');
}
/** @return HasMany<Space, $this> */
public function ownedSpaces(): HasMany
{
return $this->hasMany(Space::class, 'owner_id');
}
/**
* The one personal space every user owns (created on registration).
*
* @return HasOne<Space, $this>
*/
public function personalSpace(): HasOne
{
return $this->hasOne(Space::class, 'owner_id')->where('personal', true);
}
/**
* Spaces the user was invited into (excludes the ones they own).
*
* @return BelongsToMany<Space, $this>
*/
public function memberSpaces(): BelongsToMany
{
return $this->belongsToMany(Space::class, 'space_user')
->withPivot('role')
->withTimestamps();
}
/**
* Every space the user can access: the ones they own plus the ones they were
* invited into, ordered with the personal space first.
*
* @return Collection<int, Space>
*/
public function accessibleSpaces(): Collection
{
return Space::query()
->where('owner_id', $this->id)
->orWhereHas('members', fn (Builder $query) => $query->whereKey($this->id))
->orderByDesc('personal')
->orderBy('name')
->get();
}
/**
* Idempotently ensure the user has a personal space and points at it.
*/
public function provisionPersonalSpace(): Space
{
$space = $this->ownedSpaces()->firstOrCreate(
['personal' => true],
['name' => 'Personal'],
);
if ($this->current_space_id === null) {
$this->forceFill(['current_space_id' => $space->id])->saveQuietly();
}
return $space;
}
/**
* The space the user is currently working in. Falls back to (and repairs
* towards) the personal space when the pointer is missing or points at a
* space the user can no longer access e.g. after a membership is revoked or
* a Business subscription lapses.
*/
public function activeSpace(): Space
{
if ($this->resolvedActiveSpace !== null) {
return $this->resolvedActiveSpace;
}
$space = $this->current_space_id !== null
? Space::query()->find($this->current_space_id)
: null;
if ($space === null || ! $space->hasMember($this)) {
$space = $this->provisionPersonalSpace();
$this->forceFill(['current_space_id' => $space->id])->saveQuietly();
}
return $this->resolvedActiveSpace = $space;
}
/** @return HasMany<AutomationRule, $this> */
public function automationRules(): HasMany
{

View File

@ -15,6 +15,7 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
{
@ -55,5 +56,9 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('emails', function (object $job): Limit {
return Limit::perSecond(30);
});
// Render the OAuth consent screen (Claude Desktop / ChatGPT connecting
// to the MCP server) with our own on-brand Blade view.
Passport::authorizationView(fn (array $parameters) => response()->view('mcp.authorize', $parameters));
}
}

View File

@ -4,7 +4,9 @@ namespace App\Services\Ai;
use App\Ai\Agents\RuleSuggestionAgent;
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Exceptions\FailoverableException;
use Throwable;
class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator
@ -27,6 +29,17 @@ class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator
foreach ($this->generateBatchWithRetry($batch, $categoryOptions) as $suggestion) {
$suggestions[] = $suggestion;
}
} catch (FailoverableException $exception) {
// An overloaded or rate-limited provider is an expected transient
// condition, not a bug (PHP-LARAVEL-44). Count it as a failure so
// an all-transient run still surfaces, but don't report the
// per-batch noise — a genuine total failure is still reported once
// by the run-level handler. Mirrors CategorizeTransactions.
$failures++;
$lastError = $exception;
Log::warning('AI rule-suggestion batch dropped: provider transient failure.', [
'exception' => $exception->getMessage(),
]);
} catch (Throwable $exception) {
// A single batch failing must not discard the suggestions from
// the batches that did succeed (a run can span many batches).

View File

@ -6,6 +6,7 @@ use App\Contracts\BankingProviderInterface;
use App\Exceptions\Banking\ExpiredBankingSessionException;
use App\Exceptions\Banking\InaccessibleBankAccountException;
use App\Exceptions\Banking\TransientBankingProviderException;
use App\Exceptions\Banking\WrongTransactionsPeriodException;
use Firebase\JWT\JWT;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
@ -119,6 +120,22 @@ class EnableBankingProvider implements BankingProviderInterface
);
}
if ($this->isWrongPeriod($e)) {
throw new WrongTransactionsPeriodException(
'EnableBanking rejected the requested transactions period as too wide.',
previous: $e,
);
}
if ($this->isTransientServerError($e)) {
throw new TransientBankingProviderException(
'EnableBanking returned a server error while fetching account transactions.',
provider: 'enablebanking',
statusCode: $e->response->status(),
previous: $e,
);
}
if (! $this->isAspspError($e)) {
throw $e;
}
@ -170,6 +187,15 @@ class EnableBankingProvider implements BankingProviderInterface
);
}
if ($this->isTransientServerError($e)) {
throw new TransientBankingProviderException(
'EnableBanking returned a server error while fetching account balances.',
provider: 'enablebanking',
statusCode: $e->response->status(),
previous: $e,
);
}
if (! $this->isAspspError($e)) {
throw $e;
}
@ -222,6 +248,14 @@ class EnableBankingProvider implements BankingProviderInterface
&& ($body['error'] ?? null) === 'ASPSP_ERROR';
}
private function isTransientServerError(RequestException $e): bool
{
// Any upstream 5xx (EnableBanking itself or the ASPSP behind it) is a
// transient server-side failure — same class as a ConnectionException,
// so retry/self-heal rather than report it as an app error.
return $e->response->status() >= 500;
}
private function isExpiredSession(RequestException $e): bool
{
$body = $this->errorBody($e);
@ -243,6 +277,20 @@ class EnableBankingProvider implements BankingProviderInterface
&& $errorName === 'AccountNotAccessibleException';
}
private function isWrongPeriod(RequestException $e): bool
{
$message = $this->errorBody($e)['message'] ?? null;
// The bank refused the requested date range as too wide ("Wrong
// transactions period requested"). Keyed on 422 + the stable "period"
// token so genuine validation 422s (e.g. malformed dates) still surface.
// ponytail: message match; if EnableBanking adds a stable error code for
// this, key on that instead.
return $e->response->status() === 422
&& is_string($message)
&& str_contains(strtolower($message), 'period');
}
/**
* @return array<string, mixed>
*/
@ -264,7 +312,8 @@ class EnableBankingProvider implements BankingProviderInterface
$body = $response->json();
$error = is_array($body) ? ($body['error'] ?? null) : null;
$isExpected = ($response->status() === 400 && $error === 'ASPSP_ERROR')
|| ($response->status() === 401 && $error === 'EXPIRED_SESSION');
|| ($response->status() === 401 && $error === 'EXPIRED_SESSION')
|| $response->status() === 422;
Log::log($isExpected ? 'warning' : 'error', 'EnableBanking API error', [
'status' => $response->status(),

View File

@ -3,6 +3,7 @@
namespace App\Services\Banking\Sync;
use App\Exceptions\Banking\InaccessibleBankAccountException;
use App\Exceptions\Banking\WrongTransactionsPeriodException;
use App\Jobs\SendDailyBankTransactionsSyncedEmailJob;
use App\Models\BankingConnection;
use App\Services\Banking\BalanceSyncService;
@ -63,13 +64,15 @@ class EnableBankingSyncer extends AbstractBankingConnectionSyncer
$this->balanceSync->calculateHistoricalBalances($account);
}
}
} catch (InaccessibleBankAccountException) {
// A single account the bank no longer exposes must not break the
} catch (InaccessibleBankAccountException|WrongTransactionsPeriodException $e) {
// A single account the bank no longer exposes, or whose history
// window it refuses even after narrowing, must not break the
// whole connection sync. Skip it; the user can stop syncing it
// from the manage-accounts screen.
Log::warning('Skipping inaccessible EnableBanking account during sync', [
Log::warning('Skipping unsyncable EnableBanking account during sync', [
'connection_id' => $connection->id,
'account_id' => $account->id,
'reason' => $e::class,
]);
continue;

View File

@ -5,10 +5,7 @@ namespace App\Services\Banking;
/**
* Builds a deterministic fingerprint for an EnableBanking transaction
* payload so we can dedup even when the upstream bank omits a stable
* id (transaction_id / entry_reference).
*
* Shared between the live sync path and the cleanup command so they
* stay in lock-step.
* id (transaction_id / entry_reference), consumed by TransactionSyncService.
*/
class TransactionFingerprint
{
@ -21,8 +18,24 @@ class TransactionFingerprint
return self::hash(['transaction_id', $data['transaction_id']]);
}
if (($data['entry_reference'] ?? null) !== null) {
return self::hash(['entry_reference', $data['entry_reference']]);
$entryReference = $data['entry_reference'] ?? null;
// Some ASPSPs emit a positional `{booking_date}.{index}` entry_reference
// that is absent the day a transaction first appears and only populated
// on a later sync. Keying on it fingerprints the same transaction
// differently across syncs, so it slips past dedup and imports twice.
// Treat that positional form as "no stable id" and fall through to the
// content hash, which is identical on both syncs.
//
// Trade-off: the index is also the only field that would tell apart two
// genuinely distinct same-day transactions with byte-identical content
// (e.g. two identical tolls). Dropping it collapses them to one
// fingerprint, so only the first is kept. We accept that here — a rare
// silent under-count over the systematic duplication it fixes. Fixing
// both needs occurrence-aware dedup in the consumer (a schema change),
// tracked as a follow-up.
if ($entryReference !== null && ! self::isPositionalReference($entryReference)) {
return self::hash(['entry_reference', $entryReference]);
}
return self::hash([
@ -43,6 +56,11 @@ class TransactionFingerprint
]);
}
private static function isPositionalReference(string $reference): bool
{
return preg_match('/^\d{4}-\d{2}-\d{2}\.\d+$/D', $reference) === 1;
}
/**
* @param array<int, string>|string $remittance
*/

View File

@ -4,12 +4,24 @@ namespace App\Services\Banking;
use App\Contracts\BankingProviderInterface;
use App\Enums\TransactionSource;
use App\Exceptions\Banking\WrongTransactionsPeriodException;
use App\Models\Account;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class TransactionSyncService
{
/**
* Fallback lookback windows (in days back from date_to) tried in order when
* the bank rejects the requested transactions period as too wide. Ordered
* widest-first so the user keeps as much history as the bank will serve;
* the last step is the floor before the account is skipped.
*
* @var list<int>
*/
private const array WRONG_PERIOD_LOOKBACK_DAYS = [90, 30, 7];
public function __construct(
private BankingProviderInterface $provider,
private TransactionDescriptionFormatter $descriptionFormatter,
@ -40,27 +52,59 @@ class TransactionSyncService
// ever dwarfs its sync window, narrow this to the incoming batch's keys.
[$knownFingerprints, $knownExternalIds] = $this->loadExistingDedupKeys($account);
do {
$result = $this->provider->getTransactions(
$account->external_account_id,
$dateFrom,
$dateTo,
$continuationKey,
$strategy,
);
// The bank can reject the requested window as too wide (HTTP 422). When
// that happens, restart the account from the first page with a
// progressively narrower window so the user still gets the history the
// bank is willing to serve, instead of crashing the whole connection
// sync. Re-fetched pages are idempotent (dedup skips already-imported
// rows; daily balances are keyed by date), and strategy is dropped on
// the narrowed retry so the explicit date_from is honoured rather than
// overridden by "longest".
while (true) {
try {
$continuationKey = null;
foreach ($result['transactions'] as $transaction) {
if ($this->importTransaction($account, $transaction, $bankName, $knownFingerprints, $knownExternalIds)) {
$created++;
do {
$result = $this->provider->getTransactions(
$account->external_account_id,
$dateFrom,
$dateTo,
$continuationKey,
$strategy,
);
foreach ($result['transactions'] as $transaction) {
if ($this->importTransaction($account, $transaction, $bankName, $knownFingerprints, $knownExternalIds)) {
$created++;
}
if ($saveDailyBalances) {
$this->trackDailyBalance($transaction, $dailyBalances);
}
}
$continuationKey = $result['continuation_key'];
} while ($continuationKey);
break;
} catch (WrongTransactionsPeriodException $e) {
$narrowedDateFrom = $this->nextNarrowerDateFrom($dateFrom, $dateTo);
if ($narrowedDateFrom === null) {
throw $e;
}
if ($saveDailyBalances) {
$this->trackDailyBalance($transaction, $dailyBalances);
}
Log::warning('EnableBanking rejected the transactions period; retrying with a narrower window', [
'account_id' => $account->id,
'rejected_date_from' => $dateFrom,
'retry_date_from' => $narrowedDateFrom,
'date_to' => $dateTo,
]);
$dateFrom = $narrowedDateFrom;
$strategy = null;
}
$continuationKey = $result['continuation_key'];
} while ($continuationKey);
}
if ($saveDailyBalances) {
$this->saveDailyBalances($account, $dailyBalances);
@ -76,6 +120,28 @@ class TransactionSyncService
return $created;
}
/**
* The next window start strictly narrower (later) than the current one,
* stepping down the bounded lookback ladder. Returns null when no ladder
* step narrows the current window, so the caller gives up on the account
* rather than looping forever. Candidates are always <= date_to, so the
* window never inverts. Dates are 'Y-m-d', where string order is date order.
*/
private function nextNarrowerDateFrom(string $dateFrom, string $dateTo): ?string
{
$to = Carbon::parse($dateTo);
foreach (self::WRONG_PERIOD_LOOKBACK_DAYS as $days) {
$candidate = $to->copy()->subDays($days)->toDateString();
if ($candidate > $dateFrom) {
return $candidate;
}
}
return null;
}
/**
* Import a single transaction, skipping duplicates.
*
@ -115,6 +181,7 @@ class TransactionSyncService
try {
$account->transactions()->create([
'user_id' => $account->user_id,
'space_id' => $account->space_id,
'description' => $formatted['description'],
'description_iv' => null,
'original_description' => $formatted['original_description'],

View File

@ -166,6 +166,7 @@ class WiseTransactionSyncService
try {
$account->transactions()->create([
'user_id' => $account->user_id,
'space_id' => $account->space_id,
'description' => $parsed['description'],
'description_iv' => null,
'original_description' => $parsed['description'],

View File

@ -9,6 +9,15 @@ use Illuminate\Support\Str;
class LoanBalanceGeneratorService
{
/**
* Upsert historical balances in batches of this size. An old loan start
* date (which has no lower bound in validation) can produce a very long
* monthly series; building and upserting it all at once exhausts the queue
* worker's memory in Arr::map/flatten (PHP-LARAVEL-49). Batching bounds
* peak memory.
*/
private const UPSERT_CHUNK_SIZE = 500;
/**
* Generate historical monthly balances from a loan's start date to today
* using linear interpolation between the original amount owed and the
@ -73,6 +82,25 @@ class LoanBalanceGeneratorService
'created_at' => $now,
'updated_at' => $now,
];
if (count($rows) >= self::UPSERT_CHUNK_SIZE) {
$this->upsertBalances($rows);
$rows = [];
}
}
$this->upsertBalances($rows);
}
/**
* Upsert a batch of balance rows, keyed by (account_id, balance_date).
*
* @param list<array<string, mixed>> $rows
*/
private function upsertBalances(array $rows): void
{
if ($rows === []) {
return;
}
AccountBalance::upsert($rows, ['account_id', 'balance_date'], ['balance', 'updated_at']);

View File

@ -2,18 +2,17 @@
namespace App\Services;
use App\Models\AccountBalance;
use App\Models\Account;
use App\Models\Transaction;
use Illuminate\Support\Carbon;
class ManualBalanceAdjuster
{
/**
* Reverse a deleted transaction's effect on its manual account's current balance.
* Reverse a deleted transaction's effect on its manual account's balances.
*
* Adjusts today's balance by the inverse of the transaction amount: an expense
* (negative amount) increases the balance, income (positive amount) decreases it.
* Connected accounts are skipped because their balances come from bank sync.
* Subtracts the transaction amount from its own day and every later
* snapshot, mirroring the forward shift applied on creation. Connected
* accounts are skipped because their balances come from bank sync.
*/
public function reverseDeletedTransaction(Transaction $transaction): void
{
@ -23,31 +22,20 @@ class ManualBalanceAdjuster
return;
}
$today = Carbon::now()->toDateString();
$currentBalance = $account->balances()
->where('balance_date', '<=', $today)
->orderByDesc('balance_date')
->value('balance') ?? 0;
AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $today,
],
[
'balance' => $currentBalance - $transaction->amount,
],
$this->shiftBalancesFrom(
$account,
$transaction->transaction_date->toDateString(),
-$transaction->amount,
);
}
/**
* Apply a newly created transaction to its manual account's balance.
* Apply a newly created transaction to its manual account's balances.
*
* Adjusts the balance on the transaction's own date. The base is that day's
* balance if one exists, otherwise the closest earlier balance, otherwise
* zero (the first transaction on the account). Connected accounts are
* skipped because their balances come from bank sync.
* Seeds a snapshot on the transaction's own date (from the carried-forward
* balance when none exists yet), then shifts that day and every later
* snapshot by the transaction amount. Connected accounts are skipped
* because their balances come from bank sync.
*/
public function applyCreatedTransaction(Transaction $transaction): void
{
@ -59,19 +47,36 @@ class ManualBalanceAdjuster
$transactionDate = $transaction->transaction_date->toDateString();
$baseBalance = $account->balances()
->where('balance_date', '<=', $transactionDate)
$account->balances()->firstOrCreate(
['balance_date' => $transactionDate],
['balance' => $this->carriedForwardBalance($account, $transactionDate)],
);
$this->shiftBalancesFrom($account, $transactionDate, $transaction->amount);
}
/**
* Shift every balance snapshot on or after the given date by the delta.
*
* Balances carry forward, so a retroactive change must move the
* transaction's own day and every later snapshot (such as today's current
* balance) by the same amount to keep the running balance consistent.
*/
private function shiftBalancesFrom(Account $account, string $fromDate, int $delta): void
{
$account->balances()
->where('balance_date', '>=', $fromDate)
->increment('balance', $delta);
}
/**
* The most recent balance strictly before the given date, or 0 if none.
*/
private function carriedForwardBalance(Account $account, string $date): int
{
return $account->balances()
->where('balance_date', '<', $date)
->orderByDesc('balance_date')
->value('balance') ?? 0;
AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $transactionDate,
],
[
'balance' => $baseBalance + $transaction->amount,
],
);
}
}

View File

@ -9,6 +9,14 @@ use Illuminate\Support\Str;
class RealEstateBalanceGeneratorService
{
/**
* Upsert historical balances in batches of this size. An old purchase date
* (which has no lower bound in validation) can produce a very long monthly
* series; building and upserting it all at once exhausts the queue worker's
* memory in Arr::map/flatten (PHP-LARAVEL-49). Batching bounds peak memory.
*/
private const UPSERT_CHUNK_SIZE = 500;
/**
* Generate historical monthly balances from purchase date to today
* using linear interpolation between purchase price and current value.
@ -73,6 +81,25 @@ class RealEstateBalanceGeneratorService
'created_at' => $now,
'updated_at' => $now,
];
if (count($rows) >= self::UPSERT_CHUNK_SIZE) {
$this->upsertBalances($rows);
$rows = [];
}
}
$this->upsertBalances($rows);
}
/**
* Upsert a batch of balance rows, keyed by (account_id, balance_date).
*
* @param list<array<string, mixed>> $rows
*/
private function upsertBalances(array $rows): void
{
if ($rows === []) {
return;
}
AccountBalance::upsert($rows, ['account_id', 'balance_date'], ['balance', 'updated_at']);

View File

@ -0,0 +1,22 @@
<?php
namespace App\Services\Stats;
/**
* One arm of a binomial experiment: a count of successes out of trials, with a
* label. Bundles the (successes, trials) pair that the significance methods
* would otherwise pass around as loose integers.
*/
final readonly class BinomialProportion
{
public function __construct(
public string $label,
public int $successes,
public int $trials,
) {}
public function rate(): float
{
return $this->trials > 0 ? (float) $this->successes / $this->trials : 0.0;
}
}

View File

@ -6,9 +6,9 @@ use App\Features\SubscriptionExperiment;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Subscription;
use Laravel\Pennant\Feature;
class ExperimentFunnelCollector
{
@ -20,41 +20,62 @@ class ExperimentFunnelCollector
/**
* Per-variant funnel for the trial/pricing experiment. Users are attributed
* by the variant Pennant resolved for them the same value the runtime
* served at checkout/paywall so the report can't drift from what users
* actually experienced (including any QA override or a legacy bucket that
* predates the experiment). "Net active" is a live, non-refunded
* subscription an exact, heuristic-free metric that is comparable across
* variants once each cohort clears its own decision window.
* by SubscriptionExperiment::bucket() the deterministic crc32 split that is
* the single source of truth for assignment over the in-window signups the
* query selects, so it matches the variant each user was served without being
* perturbed by the force_variant rollout hook (which pins every user to the
* winner once decided) or by Pennant store drift. "Net active" is a live,
* non-refunded subscription an exact, heuristic-free metric that is
* comparable across variants once each cohort clears its own decision window.
*
* The funnel is assigned activated carded (subscribed) net-paying:
* "activated" = the user connected a bank or enabled AI, i.e. triggered the
* paid infrastructure that costs us money, whether or not they ever paid. The
* gap activated carded (completed Checkout with a card) is where a user
* connects a bank and walks away without paying the exact leak the flat
* per-connection cost estimate quantifies.
*
* Revenue is the monthly-recurring run-rate (MRR) of the mature, net-active
* subscriptions, with yearly plans normalised to a monthly equivalent, plus
* ARPU = MRR ÷ assigned (mature) the per-signup revenue each variant earns.
* This is run-rate, not realised cash, so it does not credit pay_now's yearly
* upfront payment any differently from a monthly one. Full LTV needs a churn
* rate the experiment is too young to have; ARPU is the proxy until then.
* ARPU = MRR ÷ assigned (mature). Cost is a flat estimate: connections of the
* mature cohort × `$costPerConnectionCents`; "wasted" cost is the same for
* mature users who did not convert (money burned). Contribution margin =
* MRR cost, the decision metric once every variant has mature volume. This
* is run-rate, not realised cash, so it does not credit pay_now's yearly
* upfront payment any differently from a monthly one.
*
* @param int $costPerConnectionCents flat estimated cost per bank connection
* @return array{
* startedAt: ?CarbonImmutable,
* currency: string,
* revenueAvailable: bool,
* costPerConnectionCents: int,
* variants: array<string, array{
* assigned: int,
* activated: int,
* subscribed: int,
* trialing: int,
* trialingCanceling: int,
* active: int,
* canceled: int,
* pastDue: int,
* refunded: int,
* assignedMature: int,
* activatedMature: int,
* convertedMature: int,
* activeMature: int,
* conversionRate: ?float,
* netActiveRate: ?float,
* activationToPaidRate: ?float,
* mrrCents: int,
* arpuCents: ?int,
* costCents: int,
* wastedCostCents: int,
* contributionMarginCents: int,
* }>
* }
*/
public function collect(): array
public function collect(int $costPerConnectionCents = 40): array
{
$startedValue = config('subscriptions.experiment.started_at');
$startedAt = $startedValue !== null ? CarbonImmutable::parse($startedValue) : null;
@ -67,24 +88,40 @@ class ExperimentFunnelCollector
];
if ($startedAt === null) {
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'variants' => $variants];
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'costPerConnectionCents' => $costPerConnectionCents, 'variants' => $variants];
}
$now = CarbonImmutable::now('UTC');
$excluded = (array) config('ai_suggestions.report.excluded_emails', []);
$windows = $this->decisionWindows();
$monthlyEquiv = $this->monthlyEquivByPriceId();
$missingPrices = [];
User::query()
// Soft-deleted accounts still count: they were assigned a variant and
// their bank connections incurred real cost, and deleting the account
// is itself an experiment outcome (the strongest "connect and leave").
->withTrashed()
->where('users.created_at', '>=', $startedAt)
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
->with(['subscriptions' => fn ($query) => $query->where('type', 'default')])
->select(['id', 'created_at'])
->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv): void {
Feature::for($users)->load([SubscriptionExperiment::class]);
->withCount([
// Cost is incurred by every connection ever opened, so count
// soft-deleted (revoked) ones too — they still cost us money.
'bankingConnections as connection_count' => fn ($query) => $query->withTrashed(),
'aiConsents as ai_consent_count',
])
->chunkById(500, function ($users) use (&$variants, &$missingPrices, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void {
foreach ($users as $user) {
$variant = Feature::for($user)->value(SubscriptionExperiment::class);
// Attribute by the deterministic bucket (the single source of
// truth in SubscriptionExperiment), not the resolved Pennant
// value: the latter is short-circuited by the force_variant
// rollout hook, which would collapse every user onto one
// variant once a winner is pinned. Every queried user is
// in-window, so bucket() equals the variant they were served,
// and reading it avoids writing Pennant rows as a side effect.
$variant = SubscriptionExperiment::bucket((string) $user->id);
if (! isset($variants[$variant])) {
continue;
@ -94,14 +131,37 @@ class ExperimentFunnelCollector
$row['assigned']++;
$connections = (int) ($user->connection_count ?? 0);
$activated = $connections > 0 || (int) ($user->ai_consent_count ?? 0) > 0;
if ($activated) {
$row['activated']++;
}
/** @var Subscription|null $subscription */
$subscription = $user->subscriptions->sortByDesc('created_at')->first();
$status = $subscription?->stripe_status;
$netActive = $status === 'active' && $subscription->refunded_at === null;
// "Converted" is time-invariant: the user was ever charged and
// not refunded — currently active, or churned after the trial.
// Unlike $netActive (a live snapshot), it does not shrink as an
// older cohort has more time to cancel, so it is comparable
// across variants that matured at different times. Excludes
// trial-only cancels (ended on/before the trial → never charged).
$converted = $subscription !== null
&& $subscription->refunded_at === null
&& $status !== 'trialing'
&& (
$subscription->trial_ends_at === null
|| $subscription->ends_at === null
|| $subscription->ends_at->greaterThan($subscription->trial_ends_at)
);
if ($subscription !== null) {
$row['subscribed']++;
$row['trialing'] += $status === 'trialing' ? 1 : 0;
$row['trialingCanceling'] += ($status === 'trialing' && $subscription->ends_at !== null) ? 1 : 0;
$row['active'] += $status === 'active' ? 1 : 0;
$row['canceled'] += $status === 'canceled' ? 1 : 0;
$row['pastDue'] += $status === 'past_due' ? 1 : 0;
@ -115,9 +175,33 @@ class ExperimentFunnelCollector
if ($mature) {
$row['assignedMature']++;
$connectionCostCents = $connections * $costPerConnectionCents;
$row['costCents'] += $connectionCostCents;
if ($activated) {
$row['activatedMature']++;
}
if ($converted) {
$row['convertedMature']++;
}
if ($netActive) {
$row['activeMature']++;
$row['mrrCents'] += (int) ($monthlyEquiv[$subscription->stripe_price] ?? 0);
$priceId = (string) $subscription->stripe_price;
if ($monthlyEquiv !== [] && ! isset($monthlyEquiv[$priceId])) {
$missingPrices[$priceId] = true;
}
$row['mrrCents'] += (int) ($monthlyEquiv[$priceId] ?? 0);
} elseif ($subscription === null || $subscription->refunded_at !== null) {
// Burn = connections of matured users who never earned
// net revenue: connected a bank but never carded, or
// paid and got refunded. A user who paid and later
// churned (canceled, not refunded) did convert, so
// their connection cost is not burn.
$row['wastedCostCents'] += $connectionCostCents;
}
}
@ -125,48 +209,76 @@ class ExperimentFunnelCollector
}
});
if ($missingPrices !== []) {
Log::warning('Experiment funnel: net-active subscriptions on prices absent from the monthly-equivalent map — their MRR is undercounted as 0.', [
'price_ids' => array_keys($missingPrices),
]);
}
foreach ($variants as $key => $row) {
$variants[$key]['conversionRate'] = $row['assignedMature'] > 0
? (float) $row['convertedMature'] / $row['assignedMature']
: null;
$variants[$key]['netActiveRate'] = $row['assignedMature'] > 0
? $row['activeMature'] / $row['assignedMature']
? (float) $row['activeMature'] / $row['assignedMature']
: null;
$variants[$key]['activationToPaidRate'] = $row['activatedMature'] > 0
? (float) $row['activeMature'] / $row['activatedMature']
: null;
$variants[$key]['arpuCents'] = $row['assignedMature'] > 0
? (int) round($row['mrrCents'] / $row['assignedMature'])
: null;
$variants[$key]['contributionMarginCents'] = $row['mrrCents'] - $row['costCents'];
}
return [
'startedAt' => $startedAt,
'currency' => $currency,
'revenueAvailable' => $monthlyEquiv !== [],
'costPerConnectionCents' => $costPerConnectionCents,
'variants' => $variants,
];
}
/**
* @return array{assigned: int, subscribed: int, trialing: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activeMature: int, netActiveRate: ?float, mrrCents: int, arpuCents: ?int}
* @return array{assigned: int, activated: int, subscribed: int, trialing: int, trialingCanceling: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activatedMature: int, convertedMature: int, activeMature: int, conversionRate: ?float, netActiveRate: ?float, activationToPaidRate: ?float, mrrCents: int, arpuCents: ?int, costCents: int, wastedCostCents: int, contributionMarginCents: int}
*/
private function emptyRow(): array
{
return [
'assigned' => 0,
'activated' => 0,
'subscribed' => 0,
'trialing' => 0,
'trialingCanceling' => 0,
'active' => 0,
'canceled' => 0,
'pastDue' => 0,
'refunded' => 0,
'assignedMature' => 0,
'activatedMature' => 0,
'convertedMature' => 0,
'activeMature' => 0,
'conversionRate' => null,
'netActiveRate' => null,
'activationToPaidRate' => null,
'mrrCents' => 0,
'arpuCents' => null,
'costCents' => 0,
'wastedCostCents' => 0,
'contributionMarginCents' => 0,
];
}
/**
* Monthly-equivalent amount (in cents) for each plan price id, from Stripe.
* Yearly prices are divided by 12. Cached for an hour; returns [] (revenue
* unavailable) if Stripe can't be reached, without caching the failure.
* Yearly prices are divided by 12. Fetched by product so that archived,
* rotated price ids (Stripe mints a new id and transfers the lookup key on
* any amount change) still resolve otherwise subscriptions on an old id
* would silently contribute 0 to MRR. Falls back to the current lookup keys
* when no product is configured. Foreign-currency and one-off prices are
* skipped. Cached for an hour; returns [] (revenue unavailable) if Stripe
* can't be reached, without caching the failure.
*
* @return array<string, int>
*/
@ -178,26 +290,40 @@ class ExperimentFunnelCollector
return Cache::get($key);
}
$productId = config('subscriptions.products.pro');
$lookups = array_values(array_filter([
config('subscriptions.plans.monthly.stripe_lookup_key'),
config('subscriptions.plans.yearly.stripe_lookup_key'),
]));
if ($lookups === []) {
if ($productId === null && $lookups === []) {
return [];
}
$params = $productId !== null
? ['product' => $productId, 'limit' => 100]
: ['lookup_keys' => $lookups, 'limit' => 10];
try {
$prices = Cashier::stripe()->prices->all(['lookup_keys' => $lookups, 'limit' => 10]);
$prices = Cashier::stripe()->prices->all($params);
} catch (\Throwable) {
return [];
}
$currency = strtolower((string) config('cashier.currency', 'eur'));
$map = [];
foreach ($prices->data as $price) {
if ($price->recurring === null) {
continue;
}
if (strtolower((string) ($price->currency ?? $currency)) !== $currency) {
continue;
}
$amount = (int) ($price->unit_amount ?? 0);
$map[$price->id] = ($price->recurring->interval ?? 'month') === 'year'
? intdiv($amount, 12)
? (int) round($amount / 12)
: $amount;
}

View File

@ -0,0 +1,149 @@
<?php
namespace App\Services\Stats;
/**
* Frequentist inference on binomial proportions for the experiment funnel:
* per-arm Wilson intervals, a Newcombe interval for the difference of two
* proportions, and a Fisher exact test for the leader-vs-runner-up comparison.
*
* Fisher (exact at any sample size) is the decision test because the report's
* conversion counts are too small for the two-proportion z normal approximation
* to be valid its expected cell counts fall well below the np>=5 rule.
*/
final class ProportionSignificance
{
/** Two-sided 95% standard-normal quantile. */
private const Z_95 = 1.96;
/** @var list<float> memoised log-factorials, index i = log(i!) */
private array $logFactorials = [0.0, 0.0];
/**
* Compare the two leading arms: Newcombe difference interval, Fisher exact
* p-value, and a family-wise (Bonferroni) corrected significance flag. `z`
* and `minExpectedCount` are returned for the small-sample caveat only.
*
* @return array{alpha: float, diffLow: float, diffHigh: float, fisherP: float, significant: bool, minExpectedCount: float, z: float}
*/
public function compare(BinomialProportion $leader, BinomialProportion $runnerUp, float $familyAlpha = 0.05, int $comparisons = 3): array
{
$alpha = $familyAlpha / $comparisons;
[$diffLow, $diffHigh] = $this->newcombeDiffInterval($leader, $runnerUp);
$fisherP = $this->fisherExactTwoSided(
$leader->successes, $leader->trials - $leader->successes,
$runnerUp->successes, $runnerUp->trials - $runnerUp->successes,
);
$pooled = ($leader->successes + $runnerUp->successes) / ($leader->trials + $runnerUp->trials);
$minExpectedCount = min($leader->trials, $runnerUp->trials) * min($pooled, 1 - $pooled);
return [
'alpha' => $alpha,
'diffLow' => $diffLow,
'diffHigh' => $diffHigh,
'fisherP' => $fisherP,
'significant' => $fisherP < $alpha,
'minExpectedCount' => $minExpectedCount,
'z' => $this->twoProportionZ($leader, $runnerUp),
];
}
/**
* Wilson score interval for a binomial proportion accurate for small n
* and near 0/1, where the normal approximation misbehaves.
*
* @return array{0: float, 1: float} lower and upper bound, clamped to [0, 1]
*/
public function wilsonInterval(int $successes, int $trials, float $z = self::Z_95): array
{
$p = $successes / $trials;
$z2 = $z * $z;
$denom = 1 + $z2 / $trials;
$center = ($p + $z2 / (2 * $trials)) / $denom;
$margin = ($z / $denom) * sqrt($p * (1 - $p) / $trials + $z2 / (4 * $trials * $trials));
return [max(0.0, $center - $margin), min(1.0, $center + $margin)];
}
/**
* Newcombe (Wilson-based) 95% interval for the difference pA pB. The
* correct object for "is A better than B": overlapping marginal intervals do
* NOT imply the difference includes 0.
*
* @return array{0: float, 1: float}
*/
public function newcombeDiffInterval(BinomialProportion $a, BinomialProportion $b, float $z = self::Z_95): array
{
$pA = $a->rate();
$pB = $b->rate();
[$lA, $uA] = $this->wilsonInterval($a->successes, $a->trials, $z);
[$lB, $uB] = $this->wilsonInterval($b->successes, $b->trials, $z);
$lower = ($pA - $pB) - sqrt(($pA - $lA) ** 2 + ($uB - $pB) ** 2);
$upper = ($pA - $pB) + sqrt(($uA - $pA) ** 2 + ($pB - $lB) ** 2);
return [$lower, $upper];
}
/** Pooled two-proportion z statistic — descriptive only, not the decision test. */
public function twoProportionZ(BinomialProportion $a, BinomialProportion $b): float
{
$pooled = ($a->successes + $b->successes) / ($a->trials + $b->trials);
$se = sqrt($pooled * (1 - $pooled) * (1 / $a->trials + 1 / $b->trials));
return $se > 0.0 ? ($a->rate() - $b->rate()) / $se : 0.0;
}
/**
* Two-sided Fisher exact p-value for the 2x2 table [[a, b], [c, d]]
* (a/c = successes, b/d = failures). Sums the hypergeometric probabilities
* of every same-margin table no more likely than the observed one. Exact at
* any sample size no normal approximation.
*/
public function fisherExactTwoSided(int $a, int $b, int $c, int $d): float
{
$rowA = $a + $b;
$rowB = $c + $d;
$col = $a + $c;
$total = $rowA + $rowB;
if ($rowA === 0 || $rowB === 0 || $col === 0 || $col === $total) {
return 1.0;
}
$logProbObserved = $this->hypergeometricLogProb($a, $rowA, $rowB, $col);
$p = 0.0;
for ($x = max(0, $col - $rowB); $x <= min($col, $rowA); $x++) {
$logProb = $this->hypergeometricLogProb($x, $rowA, $rowB, $col);
if ($logProb <= $logProbObserved + 1e-7) {
$p += exp($logProb);
}
}
return min(1.0, $p);
}
private function hypergeometricLogProb(int $x, int $rowA, int $rowB, int $col): float
{
return $this->logChoose($rowA, $x) + $this->logChoose($rowB, $col - $x) - $this->logChoose($rowA + $rowB, $col);
}
private function logChoose(int $n, int $k): float
{
if ($k < 0 || $k > $n) {
return -INF;
}
return $this->logFactorial($n) - $this->logFactorial($k) - $this->logFactorial($n - $k);
}
private function logFactorial(int $n): float
{
for ($i = count($this->logFactorials); $i <= $n; $i++) {
$this->logFactorials[$i] = $this->logFactorials[$i - 1] + log($i);
}
return $this->logFactorials[$n];
}
}

27
app/Support/Money.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Support;
/**
* Formats a minor-unit amount (cents) with its currency symbol, e.g. "€3.99".
*
* Centralizes the money formatting that was duplicated across the stats report
* commands and the Discord Stripe listener. Currencies without a known symbol
* fall back to the uppercased currency code plus a trailing space ("CHF 3.99").
*/
final class Money
{
public static function format(int $cents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($cents / 100, 2);
}
}

View File

@ -16,6 +16,8 @@ use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Illuminate\Queue\MaxAttemptsExceededException;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Sentry\Laravel\Integration;
return Application::configure(basePath: dirname(__DIR__))
@ -54,6 +56,8 @@ return Application::configure(basePath: dirname(__DIR__))
'subscribed' => EnsureUserIsSubscribed::class,
'onboarded' => EnsureOnboardingComplete::class,
'block-demo' => BlockDemoAccountActions::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -51,7 +51,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"recharts": "^3.9.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@ -1248,7 +1248,7 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"immer": ["immer@11.1.11", "", {}, "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@ -1766,7 +1766,7 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recharts": ["recharts@3.7.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="],
"recharts": ["recharts@3.9.2", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
@ -1792,7 +1792,7 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
"resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
@ -2224,6 +2224,8 @@
"@reduxjs/toolkit/immer": ["immer@11.0.1", "", {}, "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA=="],
"@reduxjs/toolkit/reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],

View File

@ -19,7 +19,10 @@
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",
"laravel/framework": "^13.0",
"laravel/mcp": "^0.6.7",
"laravel/passport": "^13.0",
"laravel/pennant": "^1.18",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.9",
"resend/resend-php": "^1.1",

1013
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,11 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
],
/*

View File

@ -164,5 +164,23 @@ return [
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'SEK',
'name' => 'Swedish Krona',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'GTQ',
'name' => 'Guatemalan Quetzal',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'HKD',
'name' => 'Hong Kong Dollar',
'allows_primary' => true,
'allows_account' => true,
],
],
];

55
config/mcp.php Normal file
View File

@ -0,0 +1,55 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Redirect Domains
|--------------------------------------------------------------------------
|
| These domains are the domains that OAuth clients are permitted to use
| for redirect URIs. Each domain should be specified with its scheme
| and host. Domains not in this list will raise validation errors.
|
| An "*" may be used to allow all domains.
|
*/
'redirect_domains' => [
// Anthropic's hosted callback for Claude Desktop / Claude web connectors.
'https://claude.ai',
// OpenAI's hosted callback for ChatGPT connectors.
'https://chatgpt.com',
],
/*
|--------------------------------------------------------------------------
| Allowed Custom Schemes
|--------------------------------------------------------------------------
|
| Native desktop OAuth clients like Cursor and VS Code use private-use URI
| schemes (RFC 8252) for redirect callbacks instead of standard schemes
| like HTTPS. Here, you may list which custom schemes you will allow.
|
*/
'custom_schemes' => [
// 'claude',
// 'cursor',
// 'vscode',
],
/*
|--------------------------------------------------------------------------
| Authorization Server
|--------------------------------------------------------------------------
|
| Here you may configure the OAuth authorization server issuer identifier
| per RFC 8414. This value appears in your protected resource and auth
| server metadata endpoints. When null, this defaults to `url('/')`.
|
*/
'authorization_server' => null,
];

48
config/passport.php Normal file
View File

@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
'middleware' => [],
/*
|--------------------------------------------------------------------------
| Encryption Keys
|--------------------------------------------------------------------------
|
| Passport uses encryption keys while generating secure access tokens for
| your application. By default, the keys are stored as local files but
| can be set via environment variables when that is more convenient.
|
*/
'private_key' => env('PASSPORT_PRIVATE_KEY'),
'public_key' => env('PASSPORT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
];

87
config/sanctum.php Normal file
View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View File

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\ImportConfigType;
use App\Models\Account;
use App\Models\AccountImportConfig;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<AccountImportConfig>
*/
class AccountImportConfigFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'account_id' => Account::factory(),
'type' => ImportConfigType::Transaction,
'config' => [
'columnMapping' => [
'transaction_date' => 'Date',
'description' => 'Description',
'amount' => 'Amount',
'balance' => null,
'creditor_name' => null,
'debtor_name' => null,
],
'dateFormat' => 'YYYY-MM-DD',
],
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\Space;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Space>
*/
class SpaceFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'owner_id' => User::factory(),
'name' => fake()->company(),
'personal' => false,
];
}
public function personal(): static
{
return $this->state(fn (array $attributes): array => [
'personal' => true,
'name' => 'Personal',
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('spaces', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('owner_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->boolean('personal')->default(false);
$table->timestamps();
$table->index('owner_id');
});
}
public function down(): void
{
Schema::dropIfExists('spaces');
}
};

Some files were not shown because too many files have changed in this diff Show More