Commit Graph

510 Commits

Author SHA1 Message Date
Víctor Falcón 6671d89ea1
fix(layout): keep bottom padding while floating nav is visible (#537)
## Problem

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

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

## Fix

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

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

Both flip at `md` now.

## Testing

Only a Tailwind class swap, no JS changed. Resize the window between 640
and 768px: the nav no longer covers content.
2026-06-15 18:23:26 +02:00
Víctor Falcón 7dde67c606
feat(ai): share AI sparkle icon and mark AI-generated rules (#538)
## What

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

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

## Tests

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

## Note

Per review, the marker sits before the rule **name**, not next to the
category badge.
2026-06-15 18:22:11 +02:00
Toni Grunwald dbec1c4c13
feat: add catch-all budgets (#527)
## Summary
Adds **catch-all budgets** — a budget flagged `is_catch_all` absorbs
every expense-category transaction not already claimed by another
(non-catch-all) budget, so out-of-budget spending is still tracked.

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
2026-06-15 16:07:19 +00:00
Víctor Falcón 8013a0b6f2
feat(ai): auto-categorize transactions with AI (behind flag) (#535)
## What

Auto-categorizes transactions with AI (Gemini) for **pro +
AI-consented** users when no automation rule already matched. Ships the
full backend **behind a Pennant flag, off by default**, so it's
mergeable and testable in isolation; the UI is a deliberate follow-up.

## Why / cost

Prod check first: ~20k txns/month, **~52% of pro-user transactions are
uncategorized** after rules. At Gemini Flash-Lite rates the cost is a
**rounding error** — ~$0.13–$0.75/month for all pro users, single-digit
dollars even on full Flash. So the model is chosen for accuracy, not
price; the real constraints are trust, accuracy and privacy.

## How it works

**Two tiers** (every transaction is covered, rules are an optimization
on top):
- **Tier 1 – label** — a queued listener runs *after* the synchronous
rules; if still uncategorized and the user is eligible, the model picks
a **leaf** category (referenced by numeric index, never a UUID, so it
can't hallucinate one). Auto-applied only above the **label bar**
(`0.7`); below → left blank, no nag. Tagged `category_source = ai` +
`ai_confidence`, fully reversible.
- **Tier 2 – learn** — above the higher **rule bar** (`0.85`) *and* a
clean merchant key *and* the model flags the merchant unambiguous → the
merchant is appended to a single **ai-owned** automation rule for that
category (OR'd conditions, not rule-sprawl), so future transactions
match for free and consistently. AI rules sit at the lowest priority;
**user-owned rules are never touched**.

**Self-heal + signal** — when a user overrides an AI category, a
`category_correction` is logged (calibration signal, bucketable by
confidence) and the offending merchant condition is dropped from the ai
rule (deleted if empty). User rules and manual categories are untouched.

**Safety** — config kill switch + pro + active consent + gradual Pennant
rollout. Dedicated `ai` queue so Gemini never blocks bank syncs.
Encrypted (client-side) transactions are never sent.

**Backfill** — `ai:categorize-backfill {user}`, explicit opt-in,
batched, learns rules as it goes.

## Data model

- `transactions`: `category_source`, `ai_confidence`,
`categorized_by_rule_id`
- `automation_rules`: `origin` (`user`/`ai`)
- new `category_corrections` table

## Screenshots

<img width="921" height="384" alt="image"
src="https://github.com/user-attachments/assets/f04c2a03-b39e-4a3d-81eb-ecf26eaefb83"
/>
2026-06-15 16:35:20 +02:00
Víctor Falcón 2bfb569a22
fix(account): block deletion while subscription or trial is active (#531)
## Problem

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

## Fix

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

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

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

## Tests

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

All passing locally, alongside Pint / Prettier / ESLint.
2026-06-14 20:46:44 +02:00
Víctor Falcón 8056ede636
feat(ai): suggest automation rules during onboarding (#523)
Suggests transaction categorization rules during onboarding.

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

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

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

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

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

## Notes

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

## Verification

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

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

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

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

## Change

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

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

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

## Testing

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

## Follow-up (not in this PR)

The hint guides new rules but doesn't retroactively fix rules already
saved wrong. A future enhancement could warn when saving a
positive-value amount condition, since most finance rules target
expenses.
2026-06-12 19:04:43 +02:00
Víctor Falcón 3223824edb
fix(transactions): improve mobile analysis button affordance (#520)
## Summary

Improves the transaction actions menu on mobile:

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

## Notes

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

## Test plan

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

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

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

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

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

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

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

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

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

## Why

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

## Change

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

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

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

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

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

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

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

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

## Note
Dashboard card has no test file — refactor preserves its public
behaviour; worth a visual check.
2026-06-10 12:36:20 +02:00
Víctor Falcón 21f8f3b277
fix(analysis): align summary card amounts to a common baseline (#515)
## What

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

## Fix

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

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

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

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

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

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

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

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

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

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

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

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

Two mobile-header fixes:

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

Desktop header is untouched.

## Test plan

- [ ] Logged-out mobile: logo on one line, login icon + Register both
visible, no overflow
- [ ] Logged-in mobile: Dashboard button unaffected
- [ ] Desktop unchanged
2026-06-09 14:44:35 +02:00
Víctor Falcón 6486908716
fix(transactions): tidy filter bar into two balanced rows (#510)
## What

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

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

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

## Screenshots

**Desktop**


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

**Mobile**


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

## Notes

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

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

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

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

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

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

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

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

## Behavior

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

## Backend

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

## Tests

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

## Notes

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

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

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

## Testing

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

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

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

## Why

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

## Testing

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

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

## Changes
- `config/currencies.php` — NZD entry (`allows_primary` +
`allows_account`)
- `resources/js/utils/currency.ts` — `NZ$` symbol
- `lang/es.json` — Spanish translation (name passes through `__()`)

## Tests
Currency + translation suites pass.
2026-06-08 09:18:17 +02:00
Víctor Falcón f4bbbfd767
fix(auth): prevent FormData crash on successful login (#503)
## What

Removes `resetOnSuccess={['password']}` from the login form.

## Sentry

- Fixes PHP-LARAVEL-2Y — `TypeError: Failed to construct 'FormData':
parameter 1 is not of type 'HTMLFormElement'.` (6 users, on `/login`)

## Root cause

On a successful login the server redirects to the dashboard, which
**unmounts the login form**. `resetOnSuccess` then fires a form reset,
and Inertia's reset handler constructs `new FormData(ref)` from the form
ref — now stale/null — which throws.

The reset serves no purpose on login (the user navigates away
regardless), so removing it eliminates the crash.

## Tests

- Added `resources/js/pages/auth/login.test.tsx`: renders the login page
and asserts the `<Form>` is not configured to reset on success.
2026-06-08 09:03:15 +02:00
Víctor Falcón a7dde5fbc5
feat(open-banking): finalize bank connection without a session via state token (#498)
## Why

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

## What

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

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

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

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

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

## Flow

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

## Tests

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

Add an eye icon to every password field that toggles between masked and
plaintext, so users can verify what they typed.

New reusable `PasswordInput` component
(`components/ui/password-input.tsx`) wrapping the base `Input` with an
`Eye`/`EyeOff` toggle.

## Where

- **Auth**: login, register (password + confirm), reset password
(password + confirm)
- **Settings**: password form & account form (current + new + confirm)
- **Dialogs**: delete-account confirmation, encryption unlock dialog

## Details

- Toggle is keyboard-skipped (`tabIndex={-1}`), mirrors the input's
`disabled` state.
- Accessible: `aria-pressed` + `aria-label` (`Show password` / `Hide
password`), translated and added to `lang/es.json`.
- Dark-mode aware via existing `text-muted-foreground` /
`hover:text-foreground` tokens.

## Tests

- New vitest suite for `PasswordInput` (mask default, toggle, ref
forwarding, disabled state) — 4 passing.
- `LocalizationTest` passing (new translation keys present).
- eslint + prettier clean.

## Not included

Left out (can add on request): `confirm-password.tsx`,
`setup-encryption.tsx`, and the open-banking API key/secret fields.
2026-06-06 11:42:20 +02:00
Víctor Falcón 8df44c2ef4
feat(transactions): save and reuse transaction filters (#496)
## What

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

## Why

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

## Changes

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

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

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

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

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

## Testing
- \`tests/Feature/SavedFilterTest.php\` — 9 passing (auth, per-user
listing/uniqueness, ownership on update/delete).
- Pint, Prettier, ESLint clean. No new TypeScript errors introduced.
2026-06-05 18:00:14 +02:00
Víctor Falcón 744d874464
fix(import): honor selected date format for CSV imports (#494)
## Problem

Importing a CSV with `DD/MM/YYYY` dates (e.g. `02/06/2026`) parsed them
as `MM/DD/YYYY`, and the **Date Format** selector either didn't appear
or had no effect — the preview showed the same wrong date regardless of
the chosen format.

## Root cause

The `xlsx` parser coerced CSV date-like strings into Excel **serial
numbers** at read time, guessing the format itself. `parseDate` then hit
its numeric short-circuit and **ignored the selected format entirely**,
so the Date Format radio never changed the preview.

On top of that, ambiguous dates (valid as both DD/MM and MM/DD) were
resolved silently by browser locale, and a saved import config force-hid
the selector — so a wrong guess couldn't be corrected.

## Changes

- **`parseFile` reads with `raw: true`** — CSV cells stay as their
original strings; `parseDate` applies the chosen format. Native
`.xls/.xlsx` dates still arrive as numbers and use the serial path.
- **`detectDateFormat()`** now returns `{ format, ambiguous }`. When
multiple formats parse the sample equally, the importer keeps the Date
Format selector visible (defaulting to the locale/saved best guess) —
even when a saved config exists — so a previously-saved wrong format can
be fixed. `autoDetectDateFormat()` kept as a thin wrapper.
- Applied to both the transactions and account-balances import drawers.
- **UI:** moved the Date Format selector above the preview and switched
the mapping form from broken Tailwind v4 `space-y`/`space-x` to
`flex`/`gap`.

## Tests

- New `parseFile` regression test: `02/06/2026` stays a string and
parses to June (DD-MM) vs February (MM-DD).
- New `detectDateFormat` tests for the `ambiguous` flag.
- All 39 `file-parser` tests pass; lint/format clean.
2026-06-05 15:10:48 +02:00
Víctor Falcón 300188f135
feat(landing): real testimonials with gravatar/facehash avatars (#493)
## Summary
- Replaces the placeholder landing testimonials with **11 real ones**
sourced from welcome-email replies and the Discord community.
- All testimonials shown in **English** with **Spanish translations**
added to `lang/es.json`.
- Adds a testimonial from Víctor Falcón (co-owner).

## Avatars
- Uses **Gravatar** with `d=404`; on miss, Radix `Avatar` falls back to
**`<Facehash>`** (same config as `user-info.tsx`).
- Privacy: raw emails are **not** stored in the repo — only precomputed
MD5 hashes.

## Layout
- Testimonials render in **two marquee rows**, same direction but with
different speeds + a phase offset so cards don't line up into a grid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-05 14:37:49 +02:00
Víctor Falcón 45e25e018d
feat: optionally update manual account balance on transaction delete (#491)
## What

When deleting a transaction that belongs to a **manual**
(non-bank-connected) account, the user can now opt to update the
account's **current balance** for today, so it stays accurate without
re-syncing.

- Deleting an **expense** (amount < 0) → balance **increases** by the
amount.
- Deleting **income** (amount > 0) → balance **decreases** by the
amount.
- Formula: `new_balance = current_balance − transaction.amount`. If
today has no balance row, it's seeded from the latest known balance.
- Available for **single delete** and **bulk delete** (a checkbox in the
confirmation dialog).
- Connected accounts are never touched — their balances come from bank
sync.
- On the account page, the balance display refreshes automatically after
the delete (no page reload).

## How

- New `ManualBalanceAdjuster` service; `TransactionController@destroy`
calls it when the request carries `update_balance` and the account is
manual.
- Frontend `transactionSyncService.delete/deleteMany` accept an
`updateBalance` option; both delete dialogs show the checkbox only when
a manual account is affected.
- `TransactionList` gains an `onBalanceUpdated` callback;
`Accounts/Show` uses it to bump the balance chart's refresh key.
- Added `banking_connection_id` to the accounts payload (transactions,
budgets, and the shared Inertia props feeding accounts/show) so the
frontend can identify manual accounts.

<img width="896" height="400" alt="image"
src="https://github.com/user-attachments/assets/e7f35ccb-13a1-40a1-bf04-615752bddf1a"
/>
2026-06-05 11:30:31 +02:00
Víctor Falcón f8cc8816a8
feat: enable category tree for all users, remove flag (#488)
## Summary
Removes the `CategoryTree` Pennant feature flag so the parent/child
category tree UI is active for all existing and new users.

## Changes
- Delete `app/Features/CategoryTree.php` (flag resolved `false`).
- `CategoryController` — drop `Feature`/`CategoryTreeFeature` imports
and the `categoryTreeEnabled` Inertia prop.
- `parent-category-field.tsx` — remove the `usePage` flag read and `if
(!enabled) return null` gate; the parent field now always renders.

## Testing
- `vendor/bin/pint --dirty` — pass
- `bun run lint` — clean (1 pre-existing unrelated warning in
`chart.tsx`)
- `php artisan test tests/Feature/Settings/CategoryTreeTest.php` — 11
passed
- `php artisan test tests/Feature/Settings/CategoryTest.php` — 32 passed
2026-06-04 13:44:57 +02:00
Víctor Falcón 721cbef024
feat: single-open Sankey expand, fit overflowing children (#487)
## What

- Expanding a category on the Sankey chart now **collapses any other
open category**, so only one subcategory column is shown at a time.
- Grow the SVG \`viewBox\` to the real content bounds so tall child
stacks no longer get **clipped above or below** the canvas.

## Why

Multiple simultaneously-expanded categories cluttered the chart, and a
parent with 3+ children produced a stack taller than the canvas — the
top entries were cut off (e.g. "Traveling €2,011" rendered half-hidden).

## Tests

- Added a test asserting opening a second parent collapses the first.
- Existing expand/collapse tests still pass (5/5).
2026-06-04 11:35:26 +02:00
Víctor Falcón 53d051800b
feat: expand parent categories inline in breakdowns (#486)
## What

Expand/collapse child categories inline in three places:
- Dashboard → **Top spending categories**
- Cashflow → **Income sources**
- Cashflow → **Expense categories**

## How it looks

<img width="1225" height="928" alt="image"
src="https://github.com/user-attachments/assets/aadce904-bfdd-46eb-b919-8695577845d7"
/>

## Implementation

**Frontend**
- `useExpandableCategories` hook: tracks expanded set, lazy-fetches
children once, caches, resets on period change.
- `AnimatedCollapse` component for the height animation.
- Recursive rows in `BreakdownCard` and `TopCategoriesCard`.

**Backend**
- Extracted `rollUpByTree`/`displayNodeFor` out of
`CashflowAnalyticsController` into `CategoryTree::rollUp` (now shared by
cashflow + dashboard).
- Dashboard `top-categories` emits `has_children`/`is_direct` and
accepts `?parent` to drill into a category's children (children + a
direct "Parent" node), mirroring the existing cashflow breakdown drill.
- Added Spanish translations for the new toggle labels.

## Tests
- Backend: `has_children` true/false + parent-drill split (children +
direct node) for dashboard top categories.
- Component: chevron expands, lazily fetches `parent=…`, renders the
child, flips to "Hide subcategories".
- Full suite green; pint / eslint / prettier clean.

> Note: children fetch lazily on first expand (same pattern as the
Sankey inline expand).
2026-06-04 11:19:21 +02:00
Víctor Falcón 679c8d7bff
feat: expand Sankey subcategories inline (#485)
## What

Clicking a parent category in the cashflow **Money Flow** Sankey now
expands its children inline as an extra column branching off the parent
— expenses grow to the right, income to the left — instead of swapping
the whole chart for a drilled-in view.

<img width="782" height="392" alt="image"
src="https://github.com/user-attachments/assets/6ce7d925-19d2-4744-a5da-6884a9583a4d"
/>

## Behaviour

- Toggle: click a parent to expand, click again to collapse. Multiple
parents can be open at once.
- One level deep (a child with its own children is not further
expandable).
- The parent stays as an intermediate node and its flow splits into the
children + a node for transactions booked directly on the parent
(labelled **Parent**).
- Changing the period resets expansions.

## Implementation

- `sankey-chart.tsx`: dynamic column layout (was fixed 3-column); lazily
fetches children via the existing `/api/cashflow/sankey?parent=`
endpoint and caches them; label + amount now render in a `foreignObject`
so flexbox handles spacing, vertical centering, and CSS-ellipsis
truncation (full name kept in a `title`); expand affordance is a
`ChevronsRight` icon.
- Child nodes use the same `MIN_NODE_HEIGHT` and gap as top-level nodes,
centered on the parent so links fan out cleanly.
- Removed the old drill-replace + breadcrumb flow (and the now-unused
`sankeyParent` hook option).
- Backend: the direct-transactions node is renamed from `"<name>
(direct)"` to `"Parent"` (+ `es` translation).
2026-06-04 10:31:40 +02:00
Víctor Falcón 1cc10566a3
feat: parent/child category tree (#474)
## Summary

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

## Backend

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

## Frontend

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

## Tests

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

## Rollout

Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
2026-06-03 19:30:12 +02:00
Víctor Falcón faccbc3c5a
refactor(js): use InputError for inline form errors (#483)
## What

15 hand-rolled `{errors.x && <p className="text-sm
text-red-500">...</p>}` blocks across 6 components (label/category
create+edit dialogs, automation-rule form, rule builder) replaced with
the existing `InputError` component.

Bonus: `InputError` uses `text-red-600 dark:text-red-400` — the inline
copies had **no dark-mode variant** (CLAUDE.md requires dark-mode
support).

## Stats

- **-63 / +21 lines**

## Checks

- `bun run test` — 154 passed
- `bun run lint` / `format` — clean (1 pre-existing warning)
- `tsc --noEmit` — 59 pre-existing errors, none new

Part of duplication-removal series (#475–#482).
2026-06-03 19:01:21 +02:00
Víctor Falcón 4028e5dfa3
refactor(js): use shared formatMonthFromYearMonth in dashboard (#482)
## What

`use-dashboard-data.ts` had a private `formatMonth()` duplicating
`utils/date.ts` `formatMonthFromYearMonth()`.

Removed the local copy; dashboard now uses the shared util.

**Tiny visual change:** non-current-year labels on dashboard net-worth
chart render `Jan '26` instead of `Jan 26` — now consistent with
cashflow-trend and account-balance charts which already use the shared
util.

## Stats

- **-14 / +2 lines**

## Checks

- `bun run test` — 154 passed
- `bun run lint` — clean (1 pre-existing warning)

Part of duplication-removal series (#475–#481).
2026-06-03 19:00:46 +02:00
Víctor Falcón aa3b811027
refactor(js): extract shared getCsrfToken util (#475)
## What

Same XSRF-TOKEN cookie-parsing snippet was duplicated inline in **12
files** (some as local `getCsrfToken()` copies, some inline
`decodeURIComponent(...)` blocks).

Extracted to single util: `resources/js/lib/csrf.ts`, with vitest
coverage.

## Why

Part of duplication-removal series — PRs that mostly delete code.

## Stats

- App code: **-89 / +23 lines**
- 12 files now import one shared util
- New: `lib/csrf.ts` (8 lines) + `lib/csrf.test.ts`

## Checks

- `bun run test` — 154 pass (3 new)
- `bun run lint` — clean (1 pre-existing warning in chart.tsx)
- `bun run format` — applied
- `tsc --noEmit` — 59 pre-existing errors on main, same 59 on branch
(none introduced)
2026-06-03 17:26:09 +02:00
Víctor Falcón e178f1b1bd
feat(settings): let users disable bank transactions email (#472)
## Summary

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

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

## Testing

- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
2026-06-02 12:24:39 +02:00
Víctor Falcón e5b493329a
feat(currencies): add Colombian and Dominican peso (#471)
## Summary

Adds **COP** (Colombian Peso) and **DOP** (Dominican Peso) to the
supported currency list for both accounts and user primary currency.

## Compatibility

Both are supported by the conversion API (fawazahmed0 currency-api):
- 1 USD = 3686.83 COP
- 1 USD = 58.74 DOP

No migration needed — `currency_code` columns are plain strings and
validation reads the whitelist from `config/currencies.php`.

## Changes

- `config/currencies.php` — add COP + DOP (primary + account)
- `resources/js/utils/currency.ts` — add `RD$` symbol for DOP
- `tests/Feature/Settings/AccountTest.php` — acceptance tests for both

## Testing

`php artisan test --filter="Colombian|Dominican"` → passing
2026-06-01 18:14:16 +02:00
Víctor Falcón f9d1303d98
feat(importer): support YYYYMMDD date format (#470)
## Summary
Adds a compact `YYYYMMDD` (no separators) date format to the
transactions and balance importers.

- New `DateFormat.YearMonthDayCompact = 'YYYYMMDD'` enum value
- `parseDate` parses the 8-digit form (`20241231` → `2024-12-31`)
- `autoDetectDateFormat` recognizes it (unambiguous, no separators)
- Both mapping UIs (transactions + balances) expose it as a selectable
option

## Testing
- `bunx vitest run resources/js/lib/file-parser.test.ts` — 34 passed
- Added tests: compact auto-detection + compact conversion
- `bun run format`, `bun run lint` clean
2026-06-01 16:44:46 +02:00
Víctor Falcón d68fee6c2d
fix(sentry): only report errors in production (#467)
## What

Gate Sentry error reporting on the production environment so nothing is
sent from local, staging, or testing.

- **Backend** (`config/sentry.php`): DSN resolves to `null` unless
`APP_ENV=production`. A null DSN disables the Laravel SDK entirely.
- **Frontend** (`resources/js/app.tsx`): `enabled` now requires
`import.meta.env.MODE === 'production'` (plus a DSN).
2026-06-01 12:41:31 +02:00
Víctor Falcón 71dd6e2b7f
feat(budgets): track multiple categories and labels per budget (#466)
## Summary

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

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

## Changes

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

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

## Testing
- Updated all budget/transaction/listener/browser tests for the pivot
model; added cases for multi-category matching, mixed category+label
pooling, and store validation (empty selection + foreign-ownership).
- Added the new `__()` strings to `lang/es.json`.
- Local `pint`, `lint`, `format` pass. Relying on CI for the full suite.
2026-06-01 12:32:23 +02:00
Víctor Falcón 5ce439f463
feat(cashflow): rework summary cards into net + saved/invested (#465)
## What

Reworks the two top summary cards on the **Cashflow** page so they no
longer both show net flow.

### Net Cashflow (card 1)
- Shows the net amount **and** its share of income (%).
- Compares **both** the amount and the percentage with the previous
period.

### Saved & Invested (card 2, replaces "Savings Rate")
- Headline = money moved to savings + investment categories (amount).
- Percentage = that total as a share of **net cashflow** (income −
expenses).
- Compares amount and percentage vs. the previous period.
- Keeps the per-category Saved / Invested breakdown at the bottom.
- Adds a **click-triggered help popover** (shadcn `Popover`, works on
touch + desktop, dismisses on outside click / Escape) explaining the
numbers come from transactions using a **"saving"** or **"investment"**
category type.

### i18n
- The card's "Saved" label uses a dedicated key so it renders
**"Ahorrado"** on the cashflow card while form buttons keep
**"Guardado"**. A minimal `lang/en.json` keeps the English label as
"Saved".
- Spanish strings added for the new card + popover copy.

## Testing
- `vitest` — net-cashflow + saved-invested card specs (amount/percentage
comparisons, click-to-reveal popover)
- Full `pest` suite (excl. Browser) — green; localization key-coverage
test passes
- `pint --test`, `prettier --check`, `eslint` — clean
- production `bun run build` — succeeds
2026-06-01 11:37:28 +02:00
Víctor Falcón 448bb2e64a
feat(posthog): route analytics through reverse proxy (#463)
## What

Route PostHog through the managed reverse proxy at `t.whisper.money` to
dodge ad blockers and keep analytics first-party.

We use the **JS library method** (`posthog-js` in
`resources/js/lib/posthog.ts`), so only that init needed changes — no
snippet.

## Changes
- `api_host` default → `https://t.whisper.money` (proxy domain)
- Add `ui_host` (default `https://eu.posthog.com`) so dashboard links
resolve back to PostHog correctly
- Both stay env-overridable: `VITE_POSTHOG_HOST`, new
`VITE_POSTHOG_UI_HOST`
- Update `.env.example`

## Test
- `posthog.test.ts` passes
- format + lint clean (one pre-existing unrelated warning)
2026-06-01 09:29:30 +02:00
Víctor Falcón 96ee311299
fix(register): don't block signup on unrecognized browser timezone (#462)
## Problem

Some users reported they couldn't register: they filled the form
correctly, pressed the button, saw a loading spinner, then got bounced
back to the registration page with **no error message**. Meanwhile 20-30
users register fine every day, and the issue wasn't reproducible by the
team.

## Root cause

The register form sends a **hidden, browser-auto-detected `timezone`**
field (`Intl.DateTimeFormat().resolvedOptions().timeZone`). The backend
validated it with Laravel's `timezone` rule:

```php
'timezone' => ['nullable', 'string', 'timezone'],
```

That rule rejects **legacy IANA aliases** that many browsers/OSes still
emit — e.g. `Asia/Calcutta`, `Asia/Saigon`, `Asia/Rangoon`,
`US/Pacific`. Proof on PHP 8.4:

```
in_array("Asia/Calcutta", DateTimeZone::listIdentifiers())  →  false
```

When validation failed, Fortify redirected back with a `timezone` error
— but the form had **no error display** for timezone (every other field
has one). Result: spinner → silent bounce → no message → and the user
can't fix a hidden field anyway. The team couldn't reproduce because
their browsers send canonical zones that pass. Production tzdata may
reject even more zones.

## Fix

- Stop validating the auto-detected hidden field against the strict
timezone list.
- Add `normalizeTimezone()`: keep recognized zones (including BC aliases
via `DateTimeZone::ALL_WITH_BC`), drop anything unrecognized to `null`.
Registration can never be blocked by it again.
- Surface `errors.timezone` in the form as a safety net so a
hidden-field failure can never be silent in the future.

## Tests

- Legacy alias `Asia/Calcutta` → registers, stored as-is.
- Unrecognized zone → registers, stored `null`.
- All existing registration tests pass (13 passed).
2026-06-01 09:03:15 +02:00
Víctor Falcón 85ea3cc714
feat(accounts): show 50 transactions per page and link to full list (#459)
## What

- Bump the account page transaction list page size from **10 → 50**
(`Accounts/Show.tsx`).
- Add a **View in Transactions** button next to *Load more* (only on the
account page) that opens the Transactions page pre-filtered to the
current account via `account_ids`.
- Add Spanish translation `View in Transactions` → `Ver en
Transacciones`.

## Notes

- The account list is synced client-side (IndexedDB); `pageSize`
controls how many rows render before *Load more*, so no backend change
needed.
- The Transactions index already defaults to `per_page = 50` and reads
`account_ids` from the query string.

## Testing

- `vitest` Accounts/Show tests pass.
- `bun run format`, `bun run lint`, `bun run build` pass.
2026-05-30 18:48:21 +02:00
Víctor Falcón 144d919c0b
fix(import): correct balance for same-day, zero and negative values (#456)
## Problem

CSV imports only carry a date (no timestamp). Rows are newest-on-top and
we import top-to-bottom. Three balance bugs:

1. **Same-day balance wrong.** The store loop did `Map.set(date,
balance)` per row, so when a day had 2+ transactions the **last**
(bottom = oldest) row overwrote the newest. The correct daily balance is
the **first** (newest) row.
2. **Zero balance dropped.** The parser guard `row[mapping.balance]`
treated numeric `0` as falsy, so a real `0` balance became `null`. `0`
is valid.
3. **Negative balance lost its sign.** `parseAmount` stripped `-` via
`replace(/[^\d]/g,'')`, so negative balances (and amounts) came out
positive.

## Fix

- `import-transactions-drawer.tsx`: keep the **first** occurrence per
date (`!balancesToImport.has(date)`).
- `file-parser.ts`: guard balance on null/undefined/empty-string only,
so `0` is kept.
- `file-parser.ts`: `parseAmount` now detects a leading `-` (or `(...)`)
and applies the sign — fixes negative balances and negative amounts.

## Tests

Added 4 cases to `file-parser.test.ts`: zero (number), zero (string),
negative, empty→null. Full suite: **144 passed**.

> Note: the same-day "keep first" logic lives in the React component and
relies on `newTransactions` staying in CSV order (top = newest), which
holds in current code.
2026-05-30 16:19:27 +02:00
Víctor Falcón 65175e184a
fix(accounts): translate update button in edit account modal (#455)
## Problem
Update button in edit account modal showed hardcoded English (`Update` /
`Updating...`) instead of translated text.

## Fix
Wrap button label in `__()` so it resolves the existing `es.json` keys
(`Actualizar` / `Actualizando...`).

## Test
`edit-account-dialog.test.tsx` passes.
2026-05-30 16:09:30 +02:00
Víctor Falcón 05ee8dc442
feat(categorize): show debtor and creditor names (#454)
## What
Show the debtor and creditor names (when present) on the categorization
page (`/transactions/categorize`).

## Changes
- `TransactionController@categorize`: select `creditor_name` and
`debtor_name` — they were never sent to the frontend.
- `categorizer-card.tsx`: render Creditor/Debtor rows below the account
when present, using the same i18n labels as the transaction columns.
- `TransactionTest`: assert both names are exposed on the categorize
page.

## Testing
- `php artisan test --filter="debtor and creditor"` — passes.
- pint, format, lint clean.
2026-05-30 12:27:59 +02:00
Víctor Falcón cbc2f4f85f
chore: update Discord invite link (#452)
Swap Discord invite link `discord.gg/2WZmDW9QZ8` →
`discord.gg/m8hUhx6D9D` across all files (README, header, user menu,
connections page, welcome email, test).
2026-05-29 15:56:48 +02:00
Víctor Falcón 5119528149
fix(categories): expose cashflow setting on create (#448)
## Summary
- Always show a simplified Cashflow and charts explanation in the
category form
- Explain how each category type affects cashflow, income/spending
charts, top spending categories, savings, and investments
- For transfer categories, keep a cashflow chart visibility selector so
users can choose hidden/inflow/outflow
- Show savings and investment categories as outflows in the cashflow
chart and store default/new categories as outflow
- Add a new migration to update existing production savings/investment
categories to outflow
- Avoid user-facing Sankey terminology and update Spanish copy to use
dinero/saldo instead of efectivo
- Add missing Spanish translations and browser/feature coverage

## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
- php artisan test --compact
tests/Feature/Console/ResetUserCategoriesCommandTest.php
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter="migration updates existing default saving and investment
categories|migration sets saving and investment categories to cashflow
outflow|users can create savings and investment categories|default
categories are created when user registers"
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter="sankey includes savings and investment categories on the
expense side|sankey includes outflow transfer categories on the expense
side"
- php artisan test --compact tests/Browser/CategoriesTest.php
--filter="explains savings and investment category cashflow impact in
the create dialog|can create a new transfer category with a cashflow
analytics direction"
- php artisan test --compact tests/Browser/CategoriesTest.php
- ./vendor/bin/pest --testsuite=Performance --filter="account show page
does not exceed query threshold"
- vendor/bin/pint --dirty --format agent
- npx prettier --write
resources/js/components/categories/category-cashflow-direction-fields.tsx
- npm run build (assets emitted; Sentry sourcemap upload reports local
SSL error)

## Video
- screenshots/category-create-cashflow-setting.webm

## Notes
- npm run types still fails on existing unrelated TypeScript errors.
2026-05-29 11:05:04 +02:00