Commit Graph

9 Commits

Author SHA1 Message Date
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 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 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 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 66ff427481
feat(import): calculate balances from transactions (#403)
## Why

Some banks return transactions without a balance column. The CSV/Excel
importer happily creates the transactions but leaves the account without
any balance history, making the account look incomplete.

## What

Opt-in flow that derives per-day balances from the transactions
themselves, using a single reference point (the balance on the date of
the latest transaction).

### Mapping step
- New checkbox **"Calculate balances from transactions"** next to the
  Balance column select.
- Available only when no balance column is mapped (disabled + visually
  faded otherwise).
- When checked, a balance input appears labeled with the latest
  transaction date using a relative format:
  - `Today`
  - `Yesterday`
  - `Monday, 3 of Jun`
- If an `AccountBalance` already exists for that date, it is pre-filled
  automatically (no need to ask) with a small hint.
- The reference balance is **mandatory** when the checkbox is checked;
  the Next button is disabled until it is provided.

### Preview step
- A new `Balance` column shows the balance about to be created for each
  date, regardless of whether it came from the file or was calculated.

### Persistence
- Balances are computed by walking backwards across distinct transaction
  dates and subtracting each day's net movement.
- They flow through the existing import path that POSTs to
  `AccountBalanceController@store`, so no new endpoint was needed.

## Feature flag

Hidden behind a new Pennant feature, **off by default**:

```bash
php artisan feature:enable CalculateBalancesOnImport user@example.com
# or
php artisan feature:enable CalculateBalancesOnImport all
```

The flag is exposed to the frontend via Inertia shared props
(`features.calculateBalancesOnImport`).

## Tests

- New vitest cases for `getLatestTransactionDate` and
  `calculateBalancesFromTransactions` (sparse dates, reference date
  without txns, empty list).
- `LocalizationTest` passes (new ES strings added).
- Full vitest suite green (106 tests).
2026-05-20 10:29:14 +01:00
Víctor Falcón 4ba130f310
Enable Coinbase for all users (#398)
## Summary
- remove CoinbaseIntegration Pennant feature flag
- always show Coinbase in open banking institution picker
- drop shared Coinbase feature flag type

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/InertiaSharedDataTest.php
- php artisan test --compact
tests/Feature/OpenBanking/CoinbaseControllerTest.php
- npx eslint
resources/js/components/open-banking/connect-account-inline.tsx
resources/js/components/open-banking/connect-account-dialog.tsx
resources/js/types/index.d.ts

Note: npm run types still fails on existing project-wide Wayfinder/type
issues.
2026-05-14 11:47:47 +01:00
Víctor Falcón e71a743a0a
feat: Coinbase banking integration (#388)
## Summary

Adds **Coinbase** as a banking/investment provider, alongside existing
Binance, Bitpanda, and Indexa Capital integrations. Connection auth uses
Coinbase Developer Platform (CDP) JWT (ES256) API keys.

Sync model mirrors Bitpanda: no historical balance reconstruction
(Coinbase API has no daily snapshot endpoint). Balance tracking starts
from connection date.

## Account model

One single **Crypto Portfolio** Whisper Account in the user's fiat
currency, aggregating all Coinbase wallets (crypto + fiat).

## Backend

- `app/Services/Banking/CoinbaseClient.php` — JWT-signed Coinbase
Advanced Trade client (`firebase/php-jwt` ES256). Per-request JWT, 120s
TTL, `uri` claim `"METHOD host/path"`. Methods:
`getAccounts`/`getAllAccounts` (cursor pagination), `getProduct`,
`getBestBidAsk` (batched), with 429 retry/backoff.
- `app/Services/Banking/CoinbaseBalanceSyncService.php` — Partitions
wallets into fiat vs crypto. Batched `best_bid_ask` for prices, USD
stablecoin shortcut (USDT/USDC/DAI/PYUSD/GUSD),
`CurrencyConversionService` fallback. Aggregates everything into user
fiat.
- `app/Http/Controllers/OpenBanking/CoinbaseController.php` +
`ConnectCoinbaseRequest.php` — mirror Bitpanda flow, validates
`api_key_name` (`organizations/{org}/apiKeys/{id}`) + PEM `private_key`.
Stores key_name in `api_token`, PEM in `api_secret` (already encrypted
TEXT).
- `BankingConnection::isCoinbase()`,
`SyncBankingConnectionJob::syncCoinbase()`, credential-update flow.
- `routes/web.php`: `POST /open-banking/coinbase/connect`.
- Bank seeder entry + factory `coinbase()` state.

## Frontend

- `connect-account-dialog.tsx` / `connect-account-inline.tsx`: Coinbase
appears in the bank picker. Confirm step shows `<Input>` for API key
name and `<Textarea>` (multi-line) for the PEM private key, with link to
CDP portal.
- `update-credentials-dialog.tsx`: Coinbase credentials editable.
- `settings/connections.tsx`: coinbase included in `isApiKeyProvider`.
- Wayfinder auto-generated `CoinbaseController.ts` +
`routes/open-banking/coinbase`.

## Tests

- `tests/Feature/OpenBanking/CoinbaseControllerTest.php` — happy path,
invalid creds (401 → 422), validation errors, subscription gate.
- `tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php` — mixed
crypto+fiat aggregation, USD stablecoin valuation, skip when
external_account_id missing.

All **217 OpenBanking tests pass**. Pint + ESLint clean.

## Follow-ups (not in this PR)

- Upload `storage/banks/logos/coinbase.png` to production storage (URL
referenced in seeder + frontend).
- Invested-amount calc from transaction history (deferred).
- Historical balance reconstruction (deferred — Coinbase has no daily
snapshot endpoint).
2026-05-13 19:53:30 +02:00
Víctor Falcón fc5cb67fe3 feature: enable cashflow for everyone 2026-01-07 11:06:54 +01:00
Víctor Falcón 1cec4a6421
Add Cashflow Analytics Feature (#49)
Summary
- Add new Cashflow page with income/expense analytics, Sankey chart
visualization, and trend analysis
- Add Cashflow summary widget to the dashboard
- Implement feature flag system using Laravel Pennant to control feature
visibility per user
- Add artisan commands to enable/disable features: `php artisan
feature:enable/disable [feature] [email|all]`

## New Features
- **Cashflow Page**: Period-based income/expense breakdown with category
analysis
- **Sankey Chart**: Visual flow diagram showing money movement between
categories
- **Trend Chart**: 12-month historical cashflow visualization
- **Dashboard Widget**: Quick cashflow summary card
- **Feature Flags**: Cashflow feature hidden behind feature flag,
controllable per-user

## How to enable it
This feature it's under a feature flag until we are sure it's working
fine. If you want to test it, [send us a message on the general channel
in our discord](https://discord.gg/9UQWZECDDv). If you're self hosting
just run `php artisan feature:enable CashflowFeature {your_email}`.

## New Cashflow Page
<img width="1278" height="1185" alt="image"
src="https://github.com/user-attachments/assets/83469f24-9f96-4e5f-bef9-86c076d31243"
/>
<img width="1274" height="1035" alt="image"
src="https://github.com/user-attachments/assets/8949ff9d-c370-4fc4-81c6-268307c587a5"
/>

## New Dashboard Widget
<img width="1180" height="289" alt="image"
src="https://github.com/user-attachments/assets/ad984a66-9ffd-4079-85ce-0b2d28278d76"
/>
<img width="1179" height="286" alt="image"
src="https://github.com/user-attachments/assets/bff60286-8ea2-4e9c-8fa6-22755566648f"
/>
2026-01-05 13:06:50 +01:00