PHP's default \s is ASCII-only, so the duplicate check missed rows differing
only by a non-breaking space (U+00A0) or other Unicode whitespace — common in
bank statement descriptions — whereas the old client-side check (JS \s) caught
them. Normalize the full Unicode whitespace set before keying, then trim.
Also harden the endpoint's tests: Unicode-whitespace matching, the date-range
boundary (an out-of-range existing row must not match), and request validation.
Follow-up to the backend migration, addressing review feedback.
- Replace the inline non-transactional type list in AccountController@show with
a purpose-named AccountType::hasTransactionLedger(). This gives the backend a
single named concept that mirrors the frontend isTransactionalAccount, and its
docblock spells out why it differs from the existing isNonTransactional()
(which excludes loan) so a future change won't reach for the wrong method.
- Add an `id` tiebreak to the account_id-filtered index ordering so
simplePaginate produces a deterministic order for same-day transactions,
matching AccountController@show.
- Cover the per_page lower clamp and cross-user account scoping with tests.
Migrate the last two flows off the deprecated client-side IndexedDB/Dexie
"offline-first" model so they read from the backend instead of the local DB.
- Import duplicate detection now hits POST /api/transactions/check-duplicates,
which matches on day + amount (integer cents) + normalized description
server-side, replacing the sync()-then-read-Dexie approach that silently
missed duplicates beyond the local cache window.
- The account detail transaction list is now served as an Inertia prop from
AccountController@show (eager-loading category + labels) instead of fetching
every transaction from /api/sync/transactions and filtering client-side,
which could show an incomplete list for accounts with long history.
- The import preview's "existing transactions" panel reads from
GET /api/transactions?account_id=…&per_page=10 (index gains an account_id
filter and a clamped per_page) instead of Dexie.
- TransactionList renders only from the provided prop; the /api/sync fetch,
the Dexie-backed search, and the refreshKey refetch are removed (search is
now in-memory; create triggers a scoped Inertia reload).
Tests cover the new endpoint (matching, normalization, account scoping, IDOR),
the account_id filter, and the account-show transactions prop.
## Why
Transactions and account names used to be stored encrypted client-side.
They now live **unencrypted in the backend**, so the browser no longer
needs to encrypt, decrypt, or mask them. A handful of legacy accounts
may still have encrypted transactions, but the auto-run
decrypt-migration handles those — so this PR strips the now-dead
encryption machinery while keeping the migration path fully intact.
Net: **−1213 / +176** lines across 25 files.
## Removed (dead machinery)
- `setup-encryption` page, `POST /api/encryption/setup`,
`SetupEncryptionRequest`, and `encrypt()` / `generateSalt()` — new data
is never encrypted again
- `EncryptedText` and the decrypt-on-render description component →
replaced by a plaintext `TransactionDescription` that keeps the
privacy-mode fake labels
- All `isKeySet`-gated client decryption: transaction list (load /
search / sort), categorize flow, import dedup
- `isKeySet` masking / locking on amount display, app logo, import
button, add-transaction button, account page, and filters
- The unused `?encrypted=false` API filter and `isKeyPersistent()`
## Kept (legacy decrypt-migration)
- The two migration hooks, the unlock dialog, and the dashboard/login
unlock prompt
- `decrypt` / `importKey` + key derivation, key-storage,
`EncryptedMessage`, salt, `GET /api/encryption/message`,
`?encrypted=true`, and the middleware salt-cleanup
- `rule-engine` / edit / import-drawer account-name decryption —
plaintext-first (`if (!account.encrypted) return name`) legacy fallback
on the kept primitives; intentionally untouched
## Testing
- Pint ✅ · ESLint ✅ · Prettier ✅
- 184 vitest tests ✅
- 1425 PHP Feature/Unit tests ✅ (Browser suite not run locally — needs
`bun run build`; no Browser test references the encryption UI)
## Follow-up (out of scope)
`DecryptedTransaction.decryptedDescription` / `decryptedNotes` are now
just plaintext aliases. Collapsing that type touches 13+ files (rule
engine, categorizer, edit dialog) and was left as a separate cleanup.
## 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
- Add `GET /api/transactions?encrypted=true` endpoint for paginated
listing of encrypted transactions
- Add `PATCH /api/transactions/bulk` endpoint for batch-updating
decrypted transaction data (max 50 per request, bypasses model events)
- Add `useDecryptTransactions` hook that mirrors the existing account
name decryption flow: fetches encrypted transactions page-by-page,
decrypts `description`/`notes` client-side via Web Crypto API, sends
plaintext back and clears IVs
- Wire the hook into `AppSidebarLayout` so decryption runs automatically
when the user unlocks their encryption key
- Once all transactions (and accounts) are decrypted, the encryption key
button in the header disappears automatically
## Test plan
- [x] 11 Pest tests covering encrypted/plaintext filtering, pagination,
user scoping, bulk update, authorization, validation, nullable notes,
guest access, and no-model-events behavior
- [x] Manual: create encrypted transactions, unlock encryption key,
verify transactions get decrypted and the key button disappears