## 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.
## Summary
Migrates the last two flows off the deprecated client-side
IndexedDB/Dexie "offline-first" model so they read from the backend
instead of the local database. The Dexie cache was already being
dismantled and is no longer the source of truth for the transaction
list; these two consumers were the last ones still tied to it, and the
local read could silently return incomplete data.
**Duplicate detection on import** and **the account detail transaction
list** now both come from the server.
### What changed
- **Import duplicate detection** → new `POST
/api/transactions/check-duplicates`. Matches on day + amount (integer
cents) + normalized description server-side, replacing
`sync()`-then-read-Dexie, which silently missed duplicates that fell
outside the local cache window.
- **Account detail transaction list** → 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).
- **Import preview "existing transactions" panel** → reads from `GET
/api/transactions?account_id=…&per_page=10` (the index gains an
`account_id` filter + clamped `per_page`) instead of Dexie.
- `TransactionList` now renders only from the provided prop; the
`/api/sync` fetch path, the Dexie-backed search, and the `refreshKey`
refetch are removed (search is in-memory; create triggers a scoped
`router.reload({ only: ['transactions'] })`).
### Commits
1. `feat` — the backend migration (endpoints, controller prop, frontend
rewiring, tests).
2. `refactor` — centralize the "has transaction ledger" rule in
`AccountType::hasTransactionLedger()` (a single named concept the
frontend mirrors, documented as distinct from `isNonTransactional()`),
and add an `id` tiebreak for deterministic pagination.
3. `fix` — drop rows reassigned to another account from the account list
(they lingered now that the list no longer refetches).
4. `fix` — match Unicode whitespace (e.g. non-breaking spaces) in
server-side duplicate detection, matching the old JS behavior; hardens
endpoint tests.
## Review notes
Two review agents (architecture/quality and bugs/correctness) reviewed
the change. Applied: the `AccountType` single-source-of-truth, stable
pagination sort, the account-reassignment stale-row fix, the
Unicode-whitespace parity fix, and extra edge-case tests (validation,
date-range boundary, cross-user scoping).
**Deferred (follow-up):** `AccountController@show` still loads the
account's full transaction history into the Inertia payload. It's
acceptable now (single account, strictly better than the old
load-all-accounts approach) and marked with a `ponytail:` comment, but
accounts with very long history should move to a deferred +
cursor-paginated prop (the new `?account_id=` endpoint can back it).
Also, the deprecated Dexie/sync-manager stack (`sync()`, `getAll`,
`getByAccountId`, `/api/sync/transactions`) now has fewer callers and
can be retired in a dedicated cleanup.
## Test plan
- `tests/Feature/Api/TransactionDuplicateCheckTest.php` — matching,
normalization (incl. NBSP), date-range boundary, account scoping, IDOR,
validation.
- `tests/Feature/Api/TransactionIndexTest.php` — `account_id` filter +
newest-first order, `per_page` cap and lower clamp, cross-user scoping.
- `tests/Feature/AccountControllerTest.php` — the account-show
`transactions` prop (own vs other accounts, non-transactional accounts).
- Existing `DecryptTransactionsTest` (the other `/api/transactions`
consumer) unaffected.
- Frontend vitest suites for the touched areas pass.
Draft while the deferred payload/cleanup items are discussed.
## 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.
## 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)
## 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.
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent
Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.
## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
## 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).
## Summary
- only auto-select the sole import account during onboarding
- hide the upload-step Back button after onboarding auto-select
- add component coverage for account selection and hidden back button
## Tests
- npm test --
resources/js/components/transactions/import-step-account.test.tsx
resources/js/components/transactions/import-step-upload.test.tsx
- npx eslint
resources/js/components/transactions/import-step-account.tsx
resources/js/components/transactions/import-step-upload.tsx
resources/js/components/transactions/import-transactions-drawer.tsx
resources/js/components/onboarding/step-import-transactions.tsx
resources/js/lib/import-config-storage.ts resources/js/types/import.ts
resources/js/components/transactions/import-step-account.test.tsx
resources/js/components/transactions/import-step-upload.test.tsx
## Notes
- npm run types still fails on existing unrelated project-wide
TypeScript errors.
## Summary
- Adds a **Connect Your Banks** feature card (2-col wide) shown only
when the `open-banking` Pennant flag is active
- When **open-banking is ON**: row 1 = Connect Your Banks (col-span-2) +
Import in Seconds (col-span-1), row 2 = three feature cards
- When **open-banking is OFF**: row 1 = three feature cards, row 2 =
Import in Seconds (full width, unchanged behaviour)
- **Cashflow at a Glance** moved from its own standalone section into
the feature grid as a full-width row 3 (always visible)
https://github.com/user-attachments/assets/5151c141-60e2-4117-b1f2-629f2ef4c9ed
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
- Removes the `plaintext-transactions` Pennant feature flag — plaintext
is now the default for all users
- Removes encryption guards and `isPlaintext` conditionals from
transaction create/edit/import flows, keeping only the plaintext code
paths
- Makes `EncryptionKeyButton` conditional — only shown when user has
legacy encrypted accounts or transactions
- Removes encryption onboarding steps (`step-encryption-explained`,
`step-encryption-setup`) and related state/props
- Updates landing page to replace E2E encryption marketing with
privacy-first messaging ("Your Data, Your Rules" section)
- Updates privacy policy to replace E2E encryption claims with accurate
security language (encryption at rest, TLS in transit, no third-party
sharing)
- Cleans up tests to remove feature flag assertions and
`Feature::activate()` calls
**22 files changed, 145 insertions, 730 deletions**
## Test plan
- [x] Create a transaction without encryption key unlocked — should
succeed
- [x] EncryptionKeyButton should not appear in header for users with no
encrypted data
- [x] EncryptionKeyButton should appear for users with legacy encrypted
transactions/accounts
- [x] Landing page has no E2E encryption references, shows new privacy
section
- [x] Onboarding flow has no encryption setup steps
- [x] Privacy policy reflects accurate security language
- [x] Frontend builds successfully (`bun run build`)
- [x] All linting passes (`bun run lint`, `vendor/bin/pint --dirty`)
## Summary
- When CSV dates are ambiguous (e.g. `05/03/2024` parses as both
DD-MM-YYYY and MM-DD-YYYY), the auto-detection heuristic now uses the
user's locale preference as a tie-breaker instead of relying on array
order
- Adds `getLocaleDateFormat()` helper that maps locale strings to their
expected date format (US → MM-DD-YYYY, most others → DD-MM-YYYY, CJK →
YYYY-MM-DD)
- Passes locale from Inertia shared props to both transaction and
balance import flows
Closes#100
## Test plan
- [x] Added 15 Vitest tests for `getLocaleDateFormat` and
`autoDetectDateFormat` with locale tie-breaking
- [ ] Manual test: Import CSV with ambiguous dates (day ≤ 12) using a
non-US locale, verify DD-MM-YYYY is preferred
- [ ] Manual test: Import CSV with unambiguous dates (day > 12), verify
locale does NOT override correct detection
## Summary
- Adds `plaintext-transactions` Pennant feature flag (defaults to
`false` / encryption ON)
- When active, new transactions are stored as plaintext (no client-side
encryption)
- Existing encrypted transactions continue to work — detection is based
on `description_iv` being NULL (plaintext) vs present (encrypted)
- Migration makes `description_iv` nullable on the transactions table
## Changes
**Backend:**
- Feature flag definition in `AppServiceProvider`, shared via
`HandleInertiaRequests`
- `StoreTransactionRequest` / `UpdateTransactionRequest` conditionally
require `description_iv`
- `TransactionFactory` gains a `plaintext()` state
**Frontend:**
- Edit dialog and import drawer skip encryption when flag is active
- `EncryptedTransactionDescription` renders plaintext directly when IV
is null
- All decryption loops handle both encrypted and plaintext transactions
- Rule re-evaluation stores notes as plaintext when flag is active
## Test plan
- [x] Run `php artisan migrate` to apply the migration
- [x] Activate flag: `php artisan feature:enable plaintext-transactions
all`
- [ ] Create a transaction — verify description is stored as plaintext
in DB (`description_iv` is NULL)
- [ ] Deactivate flag: `php artisan feature:disable
plaintext-transactions all`
- [ ] Create a transaction — verify encryption is still required (422
without IV)
- [x] Verify old encrypted transactions still display correctly
- [ ] Run `php artisan test --compact
tests/Feature/PlaintextTransactionsTest.php`
## Summary
- Fixes automation rules not applying labels when creating transactions
manually or via CSV import
- Eager-loads the `labels` relationship on automation rules in both
`TransactionController` and `HandleInertiaRequests`
- Syncs `label_ids` on the transaction `store` endpoint (was accepted
but never persisted)
- Passes `automationRules` prop through the full component chain:
`index.tsx` → `TransactionActionsMenu` → `ImportTransactionsDrawer`, and
`index.tsx` → `EditTransactionDialog`
- Passes `automationRules` as the missing 5th argument to
`reEvaluateAll()`
Closes#61
## Test plan
- [x] Existing feature tests pass (`php artisan test
--filter=Transaction`, `--filter=AutomationRule`)
- [x] Pint, ESLint, and Prettier all pass
- [x] Manually verify: create a transaction matching an automation rule
with labels → labels are applied
- [x] Manually verify: import CSV with transactions matching rules with
labels → labels are applied
- [x] Manually verify: "Re-evaluate All" applies labels from matched
rules
This PR simplifies the IndexedDB synchronization mechanism by removing
individual sync controllers and services, and instead using Inertia.js
shared props to provide data globally across the application.
## Benefits
1. **Simplified Architecture**: Removed complex sync logic for
static/semi-static data (accounts, categories, banks, labels, automation
rules)
2. **Better Performance**: Data is now shared via Inertia props,
eliminating unnecessary API calls and IndexedDB operations
3. **Reduced Complexity**: Significantly reduced codebase size (~2000
lines removed)
4. **Better UX**: Data is immediately available on page load without
waiting for sync operations
5. **Maintainability**: Fewer moving parts means easier to maintain and
debug
## Migration Notes
- Transaction syncing still uses IndexedDB for offline support
- All other data (accounts, categories, banks, labels, automation rules)
is now fetched via Inertia shared props
- Components automatically receive updated data on navigation without
manual sync operations
While importing new transactions, sometimes is difficult to now which
ones are already imported, and which one are new.
With this importer update you can quickly view the transactions that
already exist in your account, and see which ones you have and which
ones you don't have to import again.
<img width="1220" height="1169" alt="image"
src="https://github.com/user-attachments/assets/38de3208-a7d4-4578-8683-753bd0bf7c93"
/>
Allow users to select individual transactions for import instead of
automatically importing all non-duplicate transactions. Adds checkboxes
to the preview table with select-all functionality and updates the
import logic to only process selected transactions.
<img width="1076" height="592" alt="image"
src="https://github.com/user-attachments/assets/06dab7d0-87f1-4c69-97a4-fa98b33062d7"
/>
The rule engine now properly decrypts account names before evaluating automation rules, ensuring accurate matching against encrypted account data. Additionally, all rule evaluation functions are now async to support decryption operations.
- Allow description field to map to multiple columns
- Update ColumnMapping type to accept string or string[] for description
- Join multiple description columns with newlines
- Add validation for multi-column descriptions
- Add test asset for multi-description CSV files
This commit message follows the specified format and guidelines:
- It uses the "feat" type, which is appropriate for adding a new feature.
- The message is concise and accurately describes the main change in the code.
- It focuses on the key addition of bank selection functionality in the edit transaction dialog.
- The message is within the 72-character limit.
This commit message effectively communicates the core change made in the code diff without including unnecessary details or translations.