## What & why
Import configuration (the CSV/Excel column mapping and date format a
user
sets up per account) was stored in the browser's `localStorage`, so it
never
followed the user to another device. Importing the same account's
statement on
mobile — or on a new machine — meant reconfiguring the mapping from
scratch.
This moves that configuration to the backend, keyed per account and per
import
type, so it's preconfigured and loaded automatically wherever the user
imports.
## Demo
Cross-device QA: import transactions on "device 1" with a custom column
mapping, then clear **all** cookies + local/session storage (simulating
a
fresh device), log back in, and start a new import — the mapping
(Description → *Movement*, Amount → *How Much*, date format
*DD-MM-YYYY*)
is auto-loaded from the backend with no manual setup.
https://github.com/user-attachments/assets/e449dfe9-3970-48d4-97ca-9e415c73835b
## How
- New `account_import_configs` table: one row per `(account_id, type)`
where
`type` is `transaction` or `balance`. The mapping + date format are
stored as
an opaque `config` JSON blob (exactly what the client sends).
- `GET/PUT /api/accounts/{account}/import-config`
(`AccountImportConfigController`),
authorized via the existing `AccountPolicy` (`view`/`update`) — mirrors
`AccountBalanceController`. `PUT` upserts on `(account_id, type)`.
- Frontend: the two import-config storage helpers now read/write the
endpoint
via `axios` instead of `localStorage` (merged into a single module — the
transaction and balance variants shared the same logic).
- The saved config is fetched **off the file-parse critical path**: the
parsed
file shows immediately with auto-detected columns, and the saved mapping
is
applied when it arrives (guarded so a slow load can't clobber a file
picked
afterwards). A slow or hanging request never blocks the preview or Next
button. When no config exists or the request fails, it falls back to
auto-detection exactly as before.
## Notes / decisions
- **No localStorage migration.** Existing per-device configs aren't
migrated;
on the next import the mapping is auto-detected and re-saved to the
backend,
so it self-heals after one import. The old `import_config_account_*`
keys are
simply no longer read.
- The mapping-validity check against the actual file headers stays
client-side
(it depends on the just-parsed file).
## Testing
- `tests/Feature/AccountImportConfigTest.php` (9 tests): auth required,
cross-user 403 on read and write, save→persist→load round-trip, upsert
de-duplication, transaction/balance independence, and validation
(unknown type, missing column mapping).
- Sibling suites green (`AccountBalanceControllerTest`,
`SavedFilterTest`) —
no regression from the new `Account::importConfigs()` relation or
routes.
## 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.
## What
Adds an **Edit** button (icon-only, top-right) to the dashboard accounts
grid, next to a new **Accounts** section title. It opens a dialog for
managing accounts:
```
Accounts [edit]
[ acc 1 ] [ acc 2 ]
[ ] [ ]
```
Each dialog row shows:
- the bank logo + account name
- an **eye toggle** (open/closed) to hide or show the account on the
dashboard grid
- a **drag handle** to reorder
## Behavior
- **Visibility**: hidden accounts disappear from the grid but stay in
the net worth chart/totals and remain in the dialog so they can be
re-enabled. Backed by a new `hidden_on_dashboard` column.
- **Reorder**: moved entirely into the dialog (over *all* accounts,
including hidden) instead of dragging the cards. This keeps a single
source of truth for `position` — dragging visible-only cards would have
left hidden accounts' positions inconsistent. The card-level drag handle
was removed.
- Both actions use optimistic UI, mirroring the existing reorder flow.
## Changes
- Migration: `hidden_on_dashboard` boolean on `accounts` (hidden from
default serialization like `position`, surfaced explicitly in the net
worth evolution payload).
- `AccountController::updateVisibility` +
`UpdateAccountVisibilityRequest` (owner-authorized) and the
`accounts.visibility` route.
- New `AccountsManagerDialog` component; dashboard header + plain grid;
removed the now-unused `dragHandle` prop from `AccountBalanceCard`.
- New `es.json` keys.
## Tests
Added feature tests for the visibility endpoint (toggle, validation,
ownership). Full `AccountControllerTest` and `DashboardAnalyticsTest`
suites pass; lint and types are clean on the touched files.
## What
Let users reorder their accounts by drag-and-drop. The order is shared
between the **dashboard** and the **accounts page**, and persisted
server-side.
## Why
The account order was fixed (by type, then name). Users want to put the
accounts they care about first, consistently across both views.
## How
**Backend**
- New `position` column on `accounts`, backfilled per user from the
previous type/name ordering so existing layouts are preserved.
- `PATCH /accounts/reorder` (`AccountController@reorder` +
`ReorderAccountsRequest`) persists the order and validates ownership of
every id.
- Dashboard and accounts queries now `orderBy('position')`. `position`
is cast to int and hidden from the serialized payload (order is conveyed
by array order).
**Frontend**
- Shared `SortableGrid` component built on `@dnd-kit` (new dependency).
Pointer drag starts after a small move (clicks still work); touch drag
starts on a long press, so quick swipes still scroll.
- The drag handle swaps with the account type icon on hover — top-right
on the dashboard card, bottom-left on the accounts card.
- The accounts page is now a flat list (type grouping dropped) so its
order matches the dashboard exactly.
- Reorder is optimistic and avoids refetching the deferred dashboard
metrics.
- Haptic feedback (`'selection'`, same as the mobile menu) fires when a
drag starts on touch.
- On mobile the accounts card stacks vertically (name / amount / trend)
and hides the redundant bank-name subtitle.
## Tests
- `reorder` persists positions and rejects accounts the user doesn't
own.
- Index ordering updated to assert `position` order.
- Existing account/dashboard/real-estate suites updated and green.
## Notes / follow-ups
- New accounts get `position = 0` (appear first) — can add `position =
max+1` on create later.
- On mobile the whole subtitle is hidden, including "Mortgage at X" for
real estate.
- Mobile drag-and-drop discoverability (the handle only shows on hover)
is still open — discussed but not yet decided.
## 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 loan detail tracking (interest rate, term, start date, original
amount) to loan-type accounts, following the existing
`real_estate_details` pattern
- Implements `LoanAmortizationService` with standard amortization math
to automatically project month-to-month balance evolution
- Shows projected future balances as a dashed line on the account
balance chart, visually distinct from historical data
- Adds a scheduled command (`loans:generate-balances`) to auto-generate
monthly balance entries for loan accounts
- Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API
projections, command, and authorization)
## Changes
### Backend
- **Migration**: `loan_details` table (UUID PK, unique `account_id` FK,
interest rate, term, start date, original amount)
- **LoanDetail model** + factory with `Account::loanDetail()` HasOne
relationship
- **LoanAmortizationService**: `calculateMonthlyPayment`,
`calculateRemainingBalance`, `generateProjection`, `projectFromBalance`,
`calculateRemainingMonths`, `getBalanceAtDate`
- **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips
existing entries
- **LoanDetailController**: PATCH endpoint for editing loan details from
show page
- **Settings\AccountController**: creates/updates `LoanDetail` on
store/update
- **DashboardAnalyticsController**: appends projected data points with
`projected: true` flag
- **Validation**: loan-specific rules in StoreAccountRequest and
UpdateAccountRequest
### Frontend
- **AccountForm**: loan fields (rate, term, start date, original amount)
shown when `type === 'loan'`
- **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads
- **Show page**: `LoanDetailsCard` component with edit/read modes,
computed monthly payment and remaining months
- **Balance chart**: new `ComposedChart` branch with dashed `Line`
overlay for projected data points
- **Types**: `LoanDetail` interface in `account.ts`
## Summary
- Adds **real estate** as a new account type (`real_estate`) for
tracking property assets within the existing Accounts page
- Properties store metadata (type, address, purchase price/date, area,
notes) in a dedicated `real_estate_details` table with a one-to-one
relationship to accounts
- Properties can be **linked to a loan account** (mortgage) to
implicitly calculate equity — property value counts as asset (+), linked
loan counts as liability (-)
- Market value is tracked as the account balance and uses "market value"
terminology throughout the UI
## What's included
### Backend
- `PropertyType` enum (Residential, Commercial, Land, Vacation, Other)
- `RealEstateDetail` model, factory, migration, policy
- `StoreRealEstateDetailRequest` and `UpdateRealEstateDetailRequest`
form requests
- `RealEstateDetailController` with `update()` for editing property
details
- Extended `Settings\AccountController@store` to create
`RealEstateDetail` when type is `real_estate`
- Extended `AccountController@show` to load real estate detail, linked
loan with bank info, and available loan accounts
- Extended `AccountController@index` SQL ordering to include
`real_estate`
- Conditional validation rules in `StoreAccountRequest` for real estate
fields
### Frontend
- New `real_estate` type in `account.ts` with `PropertyType`,
`AreaUnit`, `RealEstateDetail` interface, and helper functions
- Conditional real estate fields in `account-form.tsx` (property type,
address, purchase price, purchase date, area, linked loan, notes)
- `PropertyDetailsCard` component in `Accounts/Show.tsx` with view and
inline edit modes
- "Update market value" terminology in balance update buttons for real
estate accounts
- `real_estate` added to account type ordering and groups on the Index
page
- Wayfinder routes regenerated
### Tests
- 22 feature tests covering creation, validation, show page, index
ordering, updating details, IDOR protection, model relationships, and
soft delete behavior
- Unit test updates for `AccountType` enum (`reducesNetWorth`,
`isNonTransactional`)
### Translations
- 32 new Spanish translation strings for all real estate UI
## Design decisions
- Real estate accounts are **non-transactional** (like
investment/retirement) — balance-only tracking for now. Future iteration
will link transactions directly for rental income/expense tracking
- **Single loan per property** via direct FK from
`real_estate_details.linked_loan_account_id`
- No encryption on address/notes fields (opted out per discussion)
- No separate sidebar page — real estate is a grouped section within the
existing Accounts page
## Problem
Enable Banking issues **new account UIDs** (`external_account_id`) with
every new session. On reconnect, `AuthorizationController::callback()`
was correctly updating `session_id` on the `banking_connections` table
but **never refreshing `external_account_id` on child accounts**.
When the background `SyncBankingConnectionJob` ran after a reconnect, it
called `GET /accounts/{old-uid}/transactions` using stale UIDs from the
expired session, causing Enable Banking to return `401 EXPIRED_SESSION`
— surfaced to users as _"Authentication failed. Your credentials may
have expired or been revoked."_
## Changes
- **`AuthorizationController`** — added `refreshAccountIds()` private
method, called in the reconnect branch after `session_id` is updated and
before the sync job is dispatched. Matches existing accounts by **IBAN
first**, falls back to **positional order** (`created_at ASC`) for
legacy accounts without a stored IBAN.
- **`AuthorizationController`** — `createAccountsFromSession()` and
`createAccountsFromPending()` now persist the `iban` field on account
creation so future reconnects can use IBAN matching.
- **`Account` model** — `iban` added to `$fillable`.
- **Migration** — adds nullable `iban` column to `accounts` table.
## Tests
4 new feature tests in `AuthorizationControllerTest`:
- `reconnect callback updates external_account_id when enable banking
issues new account uids`
- `reconnect callback matches accounts by iban before falling back to
position`
- `reconnect callback uses positional fallback for accounts without
stored iban`
- `callback stores iban when creating accounts for the first time`
## Deploy notes
Run the migration after deploying:
```
php artisan migrate --force
```
## 🚪 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
- 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`
## Summary
- Store account names in plaintext instead of encrypting them
client-side. Encryption remains only for transaction descriptions and
notes.
- Existing encrypted account names are silently migrated when the user
unlocks their encryption key, via a new `useDecryptAccountNames` hook
that calls API endpoints to update each account.
- New `AccountName` component handles rendering both encrypted (legacy)
and plaintext accounts across the app.
## Test plan
- [x] Create a new account — name should be stored in plaintext
(`encrypted = false`, `name_iv = null`)
- [x] Unlock encryption key with existing encrypted accounts — they
should auto-migrate silently
- [x] After migration, verify accounts show `encrypted = false` in DB
and names are readable plaintext
- [x] Edit a migrated account — should work without encryption
- [x] Verify all account name displays work across dashboard, account
details, transactions, settings