## 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
## 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.
## Summary
Wave 2 structural refactor: the same financial math was copy-pasted
across the
dashboard and the analytics API endpoints, so it could silently diverge
between
screens — a real risk in a finance app. This consolidates the duplicated
calculations into single sources of truth, kills a net-worth N+1, and
aligns
the PHP/TS rule engines and the `TransactionSource` enum. Every change
is
**behavior-preserving**; the numeric outputs of every endpoint are
unchanged and
are locked down with characterization/parity tests.
Builds on merged #640 (Wave 1); no file overlap. No dependencies
changed.
## Changes (per commit)
- **Consolidate savings-rate math into `CashflowSummaryService`** —
`savings_rate`
and `net` were byte-identical inline in `DashboardController` and
`Api/CashflowAnalyticsController`. Extracted to
`CashflowSummaryService::summarize(income, expense)`;
both controllers now spread its result (same keys, same values).
- **Move income/expense-side classification onto the `Transaction`
model** —
the income/expense side test was reimplemented in three places
(`Api/TransactionAnalysisController`, `Api/CashflowAnalyticsController`,
`DashboardController`). Now `Transaction::isIncomeSide()` /
`isExpenseSide()`.
- **Extract duplicated `getCategorySpending` into
`CategorySpendingService`** —
the tree-rollup expense-spending query was duplicated verbatim between
`DashboardController` and `Api/DashboardAnalyticsController`. Moved to
`CategorySpendingService::forPeriod()` (drill-parent parameterized).
- **Batch net-worth balance lookups to kill the per-account N+1** —
`Api/DashboardAnalyticsController::calculateNetWorthAt` ran one
`AccountBalance` query per account per compared period. Now uses the
existing
`BalanceLookup::forAccounts()` (fixed 3 queries via carry-forward seed +
in-range records), reproducing the exact "latest balance <= date, else
0"
semantics.
- **Align server rule normalization with the client and lock it with
parity
fixtures** — `AutomationRuleService::normalizeRuleJson` protected only
`['description','notes']` while `rule-engine.ts` also protected
`creditor_name`/`debtor_name`. Aligned to the superset (structurally a
no-op
since those var names are already lowercase, so no matching change) and
added
shared PHP+TS parity fixtures so the two engines can never drift
unnoticed.
- **Add missing `TransactionSource` cases to the TS type** —
`transaction.ts`
was missing `enablebanking`/`wise`; now mirrors
`App\Enums\TransactionSource`.
- **Guard `sumTransactions` against unsupported category types**
(reviewer fix) —
replaced the income/expense ternary that silently treated any non-Income
type
as expense with a `match` that throws on Savings/Investment/Transfer.
- **Document category eager-load expectation on `Transaction` side
methods**
(reviewer fix) — doc-only note to prevent a future N+1.
## Test plan
New tests:
- `tests/Unit/Services/CashflowSummaryServiceTest.php` —
net/savings-rate/rounding/div-by-zero.
- `tests/Feature/TransactionSideClassificationTest.php` — income/expense
side across signs, uncategorized, and transfer/savings/investment =
neither side.
- `tests/Feature/DashboardAnalyticsTest.php` — new net-worth test
asserts both the values (600000 / 540000) and a flat balance-query count
(<= 3) regardless of account count.
- `tests/Feature/RuleEngineParityTest.php` +
`resources/js/lib/rule-engine-parity.test.ts` +
`tests/Fixtures/rule-engine-parity.json` — one shared fixture set
driving both the PHP and TS rule engines.
Results (targeted, local):
- `--filter=Cashflow` (exclude Browser): 57/57 passed
- `--filter=DashboardAnalytics`: 41/41 passed
- `--filter=AutomationRule`: 60/60 passed
-
`--filter=TransactionSideClassification|CashflowSummaryService|RuleEngineParity`:
21/21 passed
- `bun run test rule-engine` (vitest): 13/13 passed
- `vendor/bin/pint --test`: pass; `bun run lint`: 0 errors; `bun run
format:check`: clean
- `bun run types`: 157 errors (unchanged pre-existing baseline), 0 in
touched files
Note: the 6 `Cashflow*` Browser tests fail locally only on "Vite
manifest not found" (no build present); they are environmental, not
logic, and pass in CI.
## Reviewer findings
Two read-only reviewers (architecture/quality and product/behavior)
reviewed the diff.
**Addressed**
- Both flagged that `sumTransactions` silently treated any non-Income
type as expense — added a throwing `match` guard.
- Eager-load expectation documented on the `Transaction` side methods.
**Verified identical** (behavior reviewer):
income/expense/net/savings_rate across all three endpoints; net worth
for both compared dates including no-record / all-records-after-range /
same-date edge cases; category spending (uncategorized excluded,
soft-deleted categories excluded, rollup/drill preserved); rule-engine
normalization output; multi-currency conversion.
**Deferred (documented)**
- The income/expense **summation** itself is still computed three ways
with differing uncategorized-transaction handling (dashboard
`whereExists` + sign vs analytics `join` excluding uncategorized vs
in-memory `isIncomeSide`). Unifying it would change numbers, so it is
out of scope for this behavior-preserving PR — worth a dedicated
follow-up.
- `savings_rate` keeps its `int|float` union (int `0` when income is 0).
Intentionally preserved to keep JSON output byte-identical.
- `BalanceLookup`'s `empty()` guard never short-circuits a `Collection`,
so a zero-account user runs 3 empty (harmless) queries. Left untouched —
it lives in a shared, unchanged service and only affects a no-account
edge case.
Do not merge before Wave 1 (#640) is in main.
## Why
On the **analysis** drawer, income/expense were derived from each
transaction's amount sign alone, ignoring the category type.
Transactions in `transfer` / `savings` / `investment` categories
therefore leaked into income/expense totals and every breakdown — e.g.
moving money between your own accounts inflated both sides. The
**cashflow** screen already keys off the category type; analysis now
does the same.
## What changed
Three commits:
1. **fix(analysis): exclude transfer, savings and investment categories
from income and expense**
Only `expense` categories (or uncategorized outflows) count as expenses,
and only `income` categories (or uncategorized inflows) count as income.
`transfer` / `savings` / `investment` are internal movements and sit on
neither side — identical to how cashflow computes income/expense/net.
2. **refactor(analytics): de-duplicate currency conversion and
category-type helpers**
`convertTransactionAmount()` / `preloadExchangeRates()` were
byte-identical in three controllers, and `categoryType()` in two.
Extracted the currency helpers into a shared
`Api\Concerns\ConvertsTransactionCurrency` trait (following the existing
`OpenBanking/Concerns` pattern) and moved `categoryType()` onto the
`Transaction` model. No behavior change.
3. **fix(analysis): net refunds within a category so totals reconcile
with cashflow**
Classification keyed off each transaction's own sign dropped contra-sign
rows entirely, so a refund booked to an expense category disappeared
instead of netting the spend down — and analysis disagreed with cashflow
on the same data (a `-10000` charge + `3000` refund read as `10000`
spent, not `7000`). A transaction's side is now decided by its category
type and signed amounts are summed before clamping, mirroring cashflow's
reconciliation across summary, category, payee, account, tag and
over-time. The largest-expenses list still shows only genuine outflows.
## Tests
Added analysis coverage for: transfer/savings/investment exclusion
(summary, breakdowns, largest, over-time), refund netting (asserts
parity with cashflow's `7000`), income-category reversals,
foreign-currency conversion, and uncategorized inflows. Full non-browser
suite green (1823 passed); `pint --test`, `bun run format`, `bun run
lint` all clean.
## Follow-up for product (not in this PR)
On **cashflow**, savings/investment outflows are excluded from expense
but re-surfaced in a dedicated "Saved & Invested" card. The **analysis**
drawer has no equivalent surface, so money categorized as
savings/investment is now correctly excluded from spending but isn't
shown anywhere. If we want analysis to fully account for it, we should
add a small "set aside" summary there. Flagged for a product decision
rather than bundled into this bugfix.
## Why
We pay for AI transaction categorization on Gemini, but we couldn't tell
which model produced any given suggestion. The `gemini-flash-latest`
alias used by categorization is **floating** — it silently drifted from
the `flash-lite` tier up to `gemini-3.5-flash`, which is now the
dominant cost on the Gemini dashboard. Before swapping categorization to
a cheaper pinned model we need to measure accuracy per model, and for
that we have to know which model labelled each row.
We already store the AI-suggested category and its confidence on
`transactions`; this adds the missing third axis: the model.
## What
- New nullable `ai_model` column on `transactions`, written by
`CategorizeTransactions::recordOutcome` from
`config('ai_categorization.model')` whenever the model produces a
suggestion (above or below the label bar).
- The migration backfills existing AI-categorized rows
(`ai_suggested_category_at` not null) with `gemini-3.5-flash` — the
model `gemini-flash-latest` resolved to when those suggestions were
made. Done as a raw update so it doesn't bump every row's `updated_at`.
- `ai_model` added to `$fillable` and to `$hidden` (pure ops/measurement
metadata, no need to ship it to the frontend).
## Testing
- Extended `CategorizeTransactionsTest` to assert `ai_model` is stored
on both the auto-applied and below-bar paths.
- `php artisan test tests/Feature/Ai/CategorizeTransactionsTest.php` → 5
passed.
## Follow-ups (not in this PR)
- Pin `AI_CATEGORIZATION_MODEL` to an explicit cheaper tier (mirror the
existing `AI_SUGGESTIONS_MODEL=gemini-2.5-flash-lite`) and kill the
floating alias in the config defaults.
- Capture token usage from the SDK response for cost tracking.
## Summary
Lets existing (non-onboarding) users discover and manage AI
categorization, gated behind a new Pennant feature flag. When a flagged
paid user grants consent, every uncategorized transaction is categorized
in the background with live, on-screen feedback.
Enable per user: `php artisan feature:enable AiConsentSettings
user@example.com`
## What's included
**Feature flag**
- New `AiConsentSettings` Pennant flag (off by default), exposed to the
frontend via the shared `features` prop.
**Billing settings (`/settings/billing`)**
- When the flag is active, a section to grant or revoke AI consent
(checkbox → `POST`/`DELETE /ai/consent`).
**Transactions page**
- A consent prompt rendered as the first row of the transactions table
(new `DataTable` `topRow` slot), styled with the shared gradient
`AiSparkleIcon`. Only shown to paid users with the flag who haven't
consented yet.
- An inline "Enable AI" button records consent without leaving the page.
**Backfill on consent**
- Granting consent dispatches a queued
`CategorizeUncategorizedTransactionsJob` that categorizes all of the
user's uncategorized transactions, **most recent first**, recording
progress in the cache.
- The transactions page polls a new status endpoint (`GET
/ai/categorization/{jobId}/status`): visible uncategorized rows show a
spinner + pulse while the backfill runs, categories stream in via
partial reloads, and a toast tracks `processed / total` until
completion.
## Notes / decisions
- **Polling, not websockets** — the project has no broadcasting
configured; this mirrors the existing `ReEvaluateTransactionRules` job +
status-endpoint pattern.
- The backfill job is kept separate from
`CategorizeOnboardingTransactionsJob` so the onboarding pass stays
progress-free (small, commented duplication).
- Reuses the existing `AiCategorizationGate` (config kill-switch + pro
plan + active consent) — free users and disabled-AI never dispatch the
job.
- Not included (YAGNI): cancelling an in-flight backfill on revoke;
persisting the spinner across full page reloads.
## Testing
- New Pest coverage: feature-flag exposure, billing/transactions consent
props, consent-triggered dispatch + gating, the status endpoint
(200/404), job progress recording, and recent-first ordering.
- `php artisan test --exclude-testsuite=Browser` passes (one unrelated,
pre-existing `DashboardTest` failure on `main`).
- Pint, Prettier and ESLint clean.
## Contexto
El categorizador solo persistía un resultado cuando la confianza
superaba la barra (`ai_categorization.label_confidence`, 0.7). Las
sugerencias por debajo del umbral se calculaban y se descartaban,
dejándonos sin ninguna visibilidad de qué proponía el modelo para las
transacciones que no se autocategorizaban.
## Cambio
Se guarda la sugerencia del modelo en **toda** transacción que el modelo
coloca, mediante columnas tipadas (en vez de un blob JSON):
- `ai_suggested_category_id` (FK a `categories`, nullable)
- `ai_suggested_category_at` (timestamp, nullable)
- `ai_confidence` (ya existía) — ahora se rellena **siempre**, no solo
al aplicar
Por debajo de la barra la transacción sigue sin categorizar, pero la
sugerencia queda guardada; a partir de la barra, además se autoaplica la
categoría como hasta ahora.
## Para qué sirve
- **Afinar el umbral 0.7 con datos reales**: `where
ai_suggested_category_id is not null and category_id is null` muestra
exactamente qué nos perdemos.
- Habilita una futura UI de "la IA cree que es X, ¿confirmas?"
directamente desde el FK.
## Tests
- `CategorizeTransactionsTest`: el caso sub-umbral ahora verifica que la
sugerencia se persiste; el caso aplicado verifica que además se guardan
los campos de sugerencia.
- Suite de IA + tests que tocan transacciones en verde (111 tests).
## What
Auto-categorizes transactions with AI (Gemini) for **pro +
AI-consented** users when no automation rule already matched. Ships the
full backend **behind a Pennant flag, off by default**, so it's
mergeable and testable in isolation; the UI is a deliberate follow-up.
## Why / cost
Prod check first: ~20k txns/month, **~52% of pro-user transactions are
uncategorized** after rules. At Gemini Flash-Lite rates the cost is a
**rounding error** — ~$0.13–$0.75/month for all pro users, single-digit
dollars even on full Flash. So the model is chosen for accuracy, not
price; the real constraints are trust, accuracy and privacy.
## How it works
**Two tiers** (every transaction is covered, rules are an optimization
on top):
- **Tier 1 – label** — a queued listener runs *after* the synchronous
rules; if still uncategorized and the user is eligible, the model picks
a **leaf** category (referenced by numeric index, never a UUID, so it
can't hallucinate one). Auto-applied only above the **label bar**
(`0.7`); below → left blank, no nag. Tagged `category_source = ai` +
`ai_confidence`, fully reversible.
- **Tier 2 – learn** — above the higher **rule bar** (`0.85`) *and* a
clean merchant key *and* the model flags the merchant unambiguous → the
merchant is appended to a single **ai-owned** automation rule for that
category (OR'd conditions, not rule-sprawl), so future transactions
match for free and consistently. AI rules sit at the lowest priority;
**user-owned rules are never touched**.
**Self-heal + signal** — when a user overrides an AI category, a
`category_correction` is logged (calibration signal, bucketable by
confidence) and the offending merchant condition is dropped from the ai
rule (deleted if empty). User rules and manual categories are untouched.
**Safety** — config kill switch + pro + active consent + gradual Pennant
rollout. Dedicated `ai` queue so Gemini never blocks bank syncs.
Encrypted (client-side) transactions are never sent.
**Backfill** — `ai:categorize-backfill {user}`, explicit opt-in,
batched, learns rules as it goes.
## Data model
- `transactions`: `category_source`, `ai_confidence`,
`categorized_by_rule_id`
- `automation_rules`: `origin` (`user`/`ai`)
- new `category_corrections` table
## Screenshots
<img width="921" height="384" alt="image"
src="https://github.com/user-attachments/assets/f04c2a03-b39e-4a3d-81eb-ecf26eaefb83"
/>
## What
Reworks the filtered **transaction analysis drawer** to behave like a
*project view* — a coherent set of related transactions (a trip, a
rental, a renovation, a side hustle) — and surfaces data that was
already computable but never shown.
### Two shapes, auto-detected
The drawer adapts to whether the set is **expense-only** or **income +
expense**, detected from the income share (`income >= 15% of expense`).
A stray refund won't flip a trip into a P&L view; a rental's rent will.
- **Expense mode:** total spent, daily average, cumulative spend line.
- **Income mode:** net result + margin %, income vs expense bars,
cumulative **net** line.
The mode can be overridden per filter (Auto / Expenses only / Income &
expenses), mirroring the existing daily-average override exactly:
remembered in the browser per filter fingerprint and synced to a
matching saved filter via a new `analysis_mode` column + PATCH endpoint.
### New panels (all from existing data)
- **Largest expenses** — top 5, expandable to 10, biggest spends first.
- **Spending by payee** — horizontal bars, gated to ≥2 named payees.
- **Spending by account** — list, gated to ≥2 accounts.
### Smarter table
The largest-expenses table hides any column the filter or the rows have
pinned to a single value (e.g. filtering by one label drops the Labels
column; a one-category result drops the Category column).
## Tests
- **Backend:** new Pest coverage for `largest_expenses`, payee/account
breakdowns, `cumulative_net`, and the `analysis_mode` endpoint
(`TransactionAnalysisTest`, `SavedFilterTest`) — all green.
- **Frontend:** 9 vitest cases covering mode detection/override, the day
override, and column hiding.
- New `__()` keys added to `lang/es.json`.
## Notes
- Adds migration `add_analysis_mode_to_saved_filters_table`.
## Problem
On the transactions page, the **Category** and **Labels** filters were
combined with **AND**. Selecting both a category and a label only showed
transactions that matched the category *and* carried that label.
## Fix
In `Transaction::scopeApplyFilters`, both filters are now wrapped in a
single `where` group with the label condition using `orWhereHas`, so
they combine as **OR**: a transaction matches if it's in a selected
category **or** has a selected label.
- Multi-category OR, category-tree expansion, and the *uncategorized*
option are preserved.
- Other filters (date, amount, account, search) remain AND.
## Tests
Added `category and label filters combine with OR` to
`TransactionFilterTest`. All 34 filter + bulk-update tests pass; pint
clean.
## What
Standardizes how models are serialized across web, API, and Sync
responses by relying on Eloquent `$hidden` + accessors on the models
themselves, instead of ad-hoc `select` scopes and per-controller field
picking.
Touches 9 models (`Account`, `Bank`, `Budget`, `Category`, `Label`,
`LoanDetail`, `RealEstateDetail`, `Transaction`, plus pivot hiding) and
the controllers/middleware that consumed the old ad-hoc shapes.
## Why
- Single source of truth for response shape lives on the model, aligned
with Wayfinder model typegen.
- Removes duplicated field-selection logic scattered across controllers.
- Continues the duplication-removal PR series (#475–#483).
## How
- Hide internal columns and pivots via `$hidden`; expose computed fields
via accessors.
- Controllers return full models / load full relations rather than
hand-picked columns.
- `HandleInertiaRequests` slimmed down to match.
## Notes for reviewers
- Per-commit breakdown: each model standardized in its own commit for
easy review.
- Tests added/updated for each model to assert the serialized shape
(hidden columns absent, relations present).
- Full suite: 1382 passed / 1 skipped / 0 failed. `pint` clean.
## Summary
Adds nested categories (parent → child, up to **3 levels**) across the
app. Children inherit their parent's type and cashflow direction, and
every category selector now renders the hierarchy as an indented tree.
Gated behind the `CategoryTree` Pennant flag (off by default).
## Backend
- **Migration**: nullable self-referencing `parent_id`; uniqueness
scoped per-parent via a `parent_unique_marker` virtual column (root
names stay unique). New composite unique created before dropping the old
one so the `user_id` FK keeps a supporting index.
- **`CategoryTree` service**: descendant/ancestor resolution, depth &
cycle checks, type cascade, subtree deletion.
- **Validation**: depth limit, cycle prevention, inherited + locked
child type.
- **Delete strategies**: reparent (default), promote to root, or cascade
(uncategorizes affected transactions).
- **Transaction filter** expands a selected parent to its descendants.
- **Cashflow Sankey & breakdown** roll up to top-level parents with
click-to-drill (children + a parent "direct" node).
- **Budgets** tracking a parent also count their children's
transactions.
- **Unified** the frontend category query behind
`Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every
selector receives the full Category shape, including `parent_id`.
## Frontend
- New `category-tree.ts` helpers (build/flatten/descendants/path,
tree-aware selection toggle + tri-state).
- **Settings page**: indented tree, sortable by name/color/type
(siblings sorted, hierarchy preserved), parent picker, delete-strategy
dialog.
- **Combobox** (transaction table cell + edit/create modal + parent
picker): indented tree, search keeps matches with their ancestors.
- **Transaction filter**: indented tree, tri-state cascading selection
(parent ↔ children), themed checkboxes, selected branches float to top
on open, ancestor-aware search.
- **Categorizer palette** and **budget multi-select**: same indented
tree + ancestor-aware search.
- **Sankey**: click a parent node to drill into its children, with
breadcrumb.
- Pennant `CategoryTree` flag gates the parent UI.
## Tests
- Pest: model/validation, delete strategies, filter expansion, cashflow
rollup/drill, budget child inclusion.
- Vitest: tree-aware selection logic.
- All green; Pint / ESLint / Prettier clean.
## Rollout
Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent
Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.
## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
## Problem
Production user reported "inaccurate expenses, some appear multiple
times". DB inspection of their active BNP Paribas Fortis connection
confirmed duplicates growing every sync:
- `-3840` on `2026-05-11` x5
- `-2900` on `2026-05-08` x5
- `-2470` on `2026-05-12` x4
- 56 of 776 rows on the account had `external_transaction_id IS NULL`
- 16 (date, amount) duplicate groups, all with NULL upstream id
## Root cause
`TransactionSyncService::importTransaction()` short-circuited dedup when
both `transaction_id` and `entry_reference` were missing:
```php
$externalId = $data['transaction_id'] ?? $data['entry_reference'] ?? null;
if ($externalId) {
// dedup check
}
// else: fall through, always insert
```
BNP returns no stable id for certain card transactions (`status:
"OTHR"`, `bank_transaction_code.code: "CCRD"`, foreign currency). Every
cron tick (every 6h) re-inserts a fresh copy.
Confirmed with prod data: of 16 NULL-id duplicate groups on this user,
**zero** ever got upgraded to a real id later. BNP simply doesn't issue
one.
## Fix
Deterministic per-transaction fingerprint, persisted in a new column,
protected by a unique index.
- **Migration**: adds `transactions.dedup_fingerprint` (nullable string,
80) and unique index on `(account_id, dedup_fingerprint)`. The unique
index is the real source of truth — it also closes the race between
overlapping sync runs that the prior `exists()` + `create()` pattern
couldn't.
- **`TransactionFingerprint::for($data)`**: two-mode fingerprint. If
`transaction_id` or `entry_reference` exists, the fingerprint is based
only on that canonical upstream id. If no upstream id exists, the
fallback fingerprint uses the prod-verified stable fields:
`booking_date`, amount, currency, credit/debit indicator,
creditor/debtor names + accounts, bank tx codes, reference number, and
remittance info. It intentionally excludes volatile fields (`status`,
`value_date`, raw `transaction_date`). Prefix `fp_` avoids mixing with
bank-issued ids.
- **`TransactionSyncService`**:
- Always computes the fingerprint and writes it on every insert.
- Dedup lookup checks the fingerprint **and** (as a fallback) the legacy
`external_transaction_id` to gracefully handle rows imported before the
backfill runs.
- Wraps the insert in a `try/catch UniqueConstraintViolationException`
so concurrent syncs that pass `exists()` together don't crash.
## Rollout plan
Ship migration + service change → new duplicates stop. Existing
duplicate cleanup is intentionally out of scope for this PR.
## Tradeoff
Two genuinely distinct same-day, same-amount card transactions from the
same merchant on the same card collapse into one (no time-of-day in
raw_data for this BNP class). Today we over-count by 3–5x; after the fix
we may rarely under-count by 1. Acceptable net win, can monitor via logs
if needed.
## Tests
- `tests/Unit/Services/Banking/TransactionFingerprintTest.php` — 4 new
tests covering canonical id behavior and volatile-field exclusion.
- `tests/Feature/OpenBanking/TransactionSyncServiceTest.php` — 3 new
tests:
- Dedupes payloads without an upstream id across consecutive syncs.
- Doesn't crash when a payload arrives later with an upstream id
(bounded behavior).
- Dedupes against soft-deleted fingerprinted rows.
- Targeted suite green locally: **17 passed**.
## Files
-
`database/migrations/2026_05_13_085027_add_dedup_fingerprint_to_transactions_table.php`
*(new)*
- `app/Services/Banking/TransactionFingerprint.php` *(new)*
- `app/Services/Banking/TransactionSyncService.php`
- `app/Models/Transaction.php` (fillable)
- Tests above
## Out of scope
A smaller secondary pattern exists where BNP emitted distinct
`transaction_id`s for the same booking (Feb–Apr only, ~10 groups on the
reporter). Not addressed here because: (a) fingerprint includes
`transaction_id` so different ids = different fingerprints, (b) no
recent occurrences, (c) needs API-level analysis to determine when this
is genuine vs noise.
## 🚪 Why?
### Problem
PHPStan was running with a baseline of 56 suppressed errors, meaning
static analysis was not enforcing type safety across a significant
portion of the codebase. These errors were real type mismatches,
redundant null-safety operators, and incorrect PHPDoc annotations that
could mask bugs and make the code harder to reason about.
## 🔑 What?
### Changes
- Add `@property` PHPDoc annotations to `Account`, `BankingConnection`,
`ExchangeRate`, and `Transaction` models so Enum casts and typed columns
are visible to PHPStan
- Add `instanceof User` guards in `ScheduleDripEmailsListener`,
`SyncUserToResendListener`, and `FortifyServiceProvider` to properly
narrow `Authenticatable` to `App\Models\User`
- Remove redundant `?? false` and unnecessary nullsafe `?->value` in
`HandleInertiaRequests`
- Fix `SyncBankingConnectionJob`: use `->name` instead of `?->name` on
an always-loaded `bank` relation
- Remove `is_countable()` guard in `BalanceLookup` (parameter is always
`Collection|array`, both countable)
- Remove `?? []` / `?? default` fallbacks on fully-typed array keys
across `BalanceSyncService`, `BinanceBalanceSyncService`,
`BinanceClient`, `BitpandaBalanceSyncService`, `BitpandaClient`,
`IndexaCapitalClient`, `IndexaCapitalBalanceSyncService`, and
`AuthorizationController`
- Fix `BinanceClient::publicClient()` `retry()` call: use `when:` named
argument and `\Throwable` type hint to match `PendingRequest::retry()`
signature
- Update `IndexaCapitalClient::getPerformance()` `@return` to include
`portfolios` and `net_amounts` keys; simplify sync service to remove
dead null checks
- Replace nullsafe chain with ternary in `BudgetPeriodService`
- Replace `match` statement in `SetupMainUser` with `if/else` to
eliminate always-true comparison
- Clear `phpstan-baseline.neon` entirely (was 56 suppressed errors, now
0)
## ✅ Verification
### Tests
- Existing tests pass: PHPStan level 5 reports 0 errors with empty
baseline
## Summary
- **Server-side filtering/sorting/search**: With encryption removed, all
transaction filtering, sorting, and full-text search (LIKE on
description + notes) now happens via SQL queries instead of client-side
IndexedDB with decryption
- **Cursor pagination**: Transactions are returned with cursor-based
pagination (default 50 per page, "Load More" pattern) instead of loading
all into IndexedDB
- **Removed IndexedDB dependency**: The main transactions page no longer
depends on Dexie/IndexedDB for data or sync — other pages (categorize,
accounts/show, budgets/show) are unaffected and will be migrated
separately
## Changes
| File | Change |
|------|--------|
| `app/Models/Transaction.php` | Added `scopeApplyFilters` query scope |
| `app/Http/Requests/IndexTransactionRequest.php` | New form request for
filter validation |
| `app/Http/Controllers/TransactionController.php` | Server-filtered
cursor-paginated index, refactored bulkUpdate to reuse scope |
| `resources/js/pages/transactions/index.tsx` | Major rewrite —
server-driven filtering, sorting, pagination via `router.visit()` |
| `resources/js/components/transactions/transaction-filters.tsx` |
Search always enabled (removed encryption key dependency) |
| `resources/js/types/transaction.ts` | Added `ServerTransaction` type |
| `resources/js/contexts/sync-context.tsx` | Simplified — removed
transaction sync service |
| `tests/Feature/TransactionFilterTest.php` | 20 new Pest tests for all
filter scenarios |
## Test plan
- [x] Run `php artisan test` — all 523+ tests pass
- [x] Navigate to `/transactions` — paginated results load from server
- [x] Apply each filter type (date, amount, category, account, label,
search) and verify server round-trip
- [x] Test "uncategorized" category filter
- [x] Test combined filters
- [x] Test sorting by date, amount, description (ascending/descending)
- [x] Test "Load More" button for cursor pagination
- [x] Test bulk operations (select some → update, select all filtered →
update)
- [x] Verify URL persistence (refresh page with filters in URL)
- [x] Test search on both description and notes fields
## Summary
- Adds a `BankFormatter` interface with a `BbvaFormatter` implementation
that transforms ALL CAPS `//`-separated BBVA descriptions into readable
Title Case (preserving acronyms like SEPA, S.A., BIZUM and Spanish
stopwords)
- Stores the raw description in a new `original_description` column when
formatting changes it, keeping sync matching intact
- Existing non-BBVA banks and null-bank transactions pass through
unchanged
## Test plan
- [x] Unit tests for `BbvaFormatter` (11 tests: title case, acronyms,
stopwords, reference numbers, mixed-case passthrough, whitespace)
- [x] Unit tests for `TransactionDescriptionFormatter` dispatcher (4
tests: BBVA match, no-match, null bank)
- [x] Feature tests for BBVA sync integration (formatted description +
original stored)
- [x] Feature tests for non-BBVA sync (description unchanged, original
null)
- [x] All 25 existing + new tests pass
- [x] Pint formatting clean
## Summary
- Adds **EnableBanking** as the first open banking provider, allowing
users to connect real bank accounts and automatically sync transactions
and balances
- Uses a **`BankingProviderInterface`** contract so future providers
(Plaid, GoCardless, etc.) can be added by implementing the same
interface
- Feature-flagged behind the **`open-banking`** Pennant flag (default:
off)
- Connected accounts are **unencrypted** and transactions have `source =
'enablebanking'`
### What's included
**Backend:**
- `BankingProviderInterface` contract + `EnableBankingProvider`
implementation (JWT RS256 auth)
- `BankingConnection` model with full lifecycle (pending →
awaiting_mapping → active → expired/revoked/error)
- `TransactionSyncService` — pagination, deduplication by
`external_transaction_id`, amount/date mapping
- `BalanceSyncService` — preferred balance type selection (CLBD → ITAV
fallback)
- Authorization flow: start auth → bank redirect → callback → session
creation → account mapping → sync
- `SyncBankingConnectionJob` (unique per connection, 3 retries) +
scheduled every 6 hours
- `banking:sync` artisan command
- 5 migrations: `banking_connections` table, account fields, transaction
`external_transaction_id`, `pending_accounts_data`, `linked_at`
**Frontend:**
- Manual vs Connected account choice in the create account dialog
- Multi-step bank connection dialog (country → bank selection →
confirmation → redirect)
- Account mapping page — map discovered bank accounts to existing
accounts, create new ones, or skip
- Settings/Connections page with status badges, sync/disconnect actions
- "Connected" badge on linked accounts in settings
**Tests:**
- 49 tests covering feature flags, controllers, account mapping,
transaction sync, balance sync, deduplication, and pagination
### Feature Flags
This PR introduces **two Pennant feature flags**:
1. **`open-banking`** — Gates the entire open banking feature
(institutions endpoint, authorization flow, connections page). When
disabled, all open banking routes return 404.
2. **`account-mapping`** — Controls whether users see an intermediate
account mapping step after connecting a bank. When **enabled**, users
are redirected to a mapping page where they can choose to create new
accounts, link to existing ones, or skip each discovered bank account.
When **disabled**, all discovered accounts are automatically created
(original behavior). Linked accounts only sync transactions from their
last transaction date and only update the current balance from the
provider (no historical balance calculation or daily balance tracking).
Enable per-user:
```bash
php artisan feature:enable open-banking user@example.com
php artisan feature:enable account-mapping user@example.com
```
Enable for all users:
```bash
php artisan feature:enable open-banking all
php artisan feature:enable account-mapping all
```
## Test plan
- [x] Enable feature flag: `php artisan feature:enable open-banking`
- [x] Verify "Connected" option appears in create account dialog
- [x] Start authorization flow and verify redirect to bank
- [x] With `account-mapping` **disabled**: verify callback creates
accounts directly and dispatches sync
- [x] With `account-mapping` **enabled**: verify callback redirects to
mapping page
- [x] Test mapping page: create new, link to existing, and skip actions
- [x] Verify linked accounts sync only from last transaction date and
only update current balance
- [x] Verify connections page shows "Setup Required" badge for
awaiting_mapping status
- [x] Run `php artisan banking:sync` and verify transactions sync
- [x] Verify connections page shows status, sync, and disconnect actions
- [x] Run full test suite: `php artisan test --compact`
## Overview
We're excited to introduce budgeting capabilities to Whisper Money! This
feature helps you take control of your finances by setting spending
limits and tracking your progress over time.
## Screenshots
<img width="1316" height="793" alt="image"
src="https://github.com/user-attachments/assets/ac394d36-cded-4ea4-9883-120785e260f1"
/>
<img width="1315" height="907" alt="image"
src="https://github.com/user-attachments/assets/7c682474-5aa7-4388-b626-29b56f5ebbef"
/>
<img width="1315" height="992" alt="image"
src="https://github.com/user-attachments/assets/21eace45-23c6-472d-9aa0-0feb6db3fba4"
/>
## What's New
### Create Flexible Budgets
- Set budgets for specific categories or labels
- Choose from monthly, weekly, bi-weekly, or custom periods
- Set your own budget start date for better alignment with your pay
schedule
### Track Your Spending
- Visual spending charts show how much you've spent vs. your budget
- See at a glance which budgets are on track and which need attention
- View all transactions that count toward each budget
### Smart Budget Management
- **Carry Over**: Unused budget amounts automatically roll into the next
period
- **Reset**: Unused amounts return to your available money pool
- Edit or delete budgets anytime as your needs change
### Easy Access
- New Budgets section in the main navigation
- Quick overview cards showing budget status
- Detailed budget pages with spending history and transaction lists
## How It Works
1. Create a budget by selecting a category or label and setting your
spending limit
2. Your transactions are automatically matched to relevant budgets
3. Track your progress with visual charts and spending summaries
4. Adjust your budgets as needed to stay on track with your financial
goals
This feature is now available behind a feature flag and can be enabled
for users who want to start budgeting their expenses.
## Summary
Add a labeling system for transactions that differs from categories in
that labels are ephemeral and created on-the-fly when assigned (no
pre-creation required). A transaction can have multiple labels
(many-to-many relationship), and labels can be used for filtering in the
transaction table and as actions in automation rules.
<img width="1372" height="484" alt="image"
src="https://github.com/user-attachments/assets/fd342b11-dafb-44ed-b818-578e1ea856e6"
/>
<img width="1373" height="613" alt="image"
src="https://github.com/user-attachments/assets/aa5cfb8b-50b7-4101-a872-54904924f234"
/>
<img width="1035" height="594" alt="image"
src="https://github.com/user-attachments/assets/ffa946b2-b01f-496b-8cb8-ab55ab5b79ed"
/>
## Changes
### Backend (Laravel)
- Add `labels` table with user_id, name, color, and soft deletes
- Add `label_transaction` pivot table for transaction-label many-to-many
- Add `automation_rule_labels` pivot table for automation rule-label
many-to-many
- Create Label model with relationships to User, Transaction, and
AutomationRule
- Add LabelController for CRUD operations (returns JSON for on-the-fly
creation)
- Add LabelSyncController for frontend sync
- Add LabelPolicy for authorization
- Update TransactionController to handle bulk label updates
- Update AutomationRuleController to handle label actions
- Add validation for label_ids in transactions and automation rules
### Frontend (React/TypeScript)
- Add Label type and color utilities
- Add labels table to IndexedDB (Dexie version 6)
- Create label-sync service with findOrCreate functionality
- Create LabelCombobox component with multi-select and create-on-type
- Add labels column to transactions table
- Add labels filter to transaction filters
- Add bulk label assignment in bulk actions bar
- Add labels action in create automation rule dialog
- Update rule engine to include labels in evaluation results
## Testing
- 11 feature tests for label CRUD
- 3 tests for label sync
- All 241 feature tests pass
## Screenshots
N/A - UI changes can be reviewed in browser
- Change transaction_date cast to 'date:Y-m-d' in Transaction model
- Add transformFromServer to normalize dates when syncing from server
- Normalize dates in checkDuplicates method for consistent comparison
- Fix checkDuplicates to return boolean[] instead of debug objects