From 4c83cb8b335c341b713f9ba3d2c816aff0c5e845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 18 Jul 2026 16:10:40 +0200 Subject: [PATCH] feat(import): persist per-account import configuration on the backend (#698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- app/Enums/ImportConfigType.php | 9 ++ .../Api/AccountImportConfigController.php | 44 +++++++ .../Api/UpdateAccountImportConfigRequest.php | 32 +++++ app/Models/Account.php | 6 + app/Models/AccountImportConfig.php | 36 ++++++ .../factories/AccountImportConfigFactory.php | 36 ++++++ ...33_create_account_import_configs_table.php | 32 +++++ .../accounts/import-balances-drawer.tsx | 62 +++++----- .../import-transactions-drawer.tsx | 52 ++++---- .../js/lib/balance-import-config-storage.ts | 61 ---------- resources/js/lib/import-config-storage.ts | 80 +++++++----- routes/api.php | 5 + tests/Feature/AccountImportConfigTest.php | 114 ++++++++++++++++++ 13 files changed, 433 insertions(+), 136 deletions(-) create mode 100644 app/Enums/ImportConfigType.php create mode 100644 app/Http/Controllers/Api/AccountImportConfigController.php create mode 100644 app/Http/Requests/Api/UpdateAccountImportConfigRequest.php create mode 100644 app/Models/AccountImportConfig.php create mode 100644 database/factories/AccountImportConfigFactory.php create mode 100644 database/migrations/2026_07_18_104433_create_account_import_configs_table.php delete mode 100644 resources/js/lib/balance-import-config-storage.ts create mode 100644 tests/Feature/AccountImportConfigTest.php diff --git a/app/Enums/ImportConfigType.php b/app/Enums/ImportConfigType.php new file mode 100644 index 00000000..bb6f60df --- /dev/null +++ b/app/Enums/ImportConfigType.php @@ -0,0 +1,9 @@ +authorize('view', $account); + + $validated = $request->validate([ + 'type' => ['required', Rule::enum(ImportConfigType::class)], + ]); + + $config = $account->importConfigs() + ->where('type', $validated['type']) + ->first(); + + return response()->json(['data' => $config?->config]); + } + + public function update(UpdateAccountImportConfigRequest $request, Account $account): JsonResponse + { + $this->authorize('update', $account); + + $config = $account->importConfigs()->updateOrCreate( + ['type' => $request->validated('type')], + ['config' => $request->validated('config')], + ); + + return response()->json(['data' => $config->config]); + } +} diff --git a/app/Http/Requests/Api/UpdateAccountImportConfigRequest.php b/app/Http/Requests/Api/UpdateAccountImportConfigRequest.php new file mode 100644 index 00000000..99ae1a9a --- /dev/null +++ b/app/Http/Requests/Api/UpdateAccountImportConfigRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::enum(ImportConfigType::class)], + 'config' => ['required', 'array'], + 'config.columnMapping' => ['required', 'array'], + 'config.dateFormat' => ['required', 'string', 'max:20'], + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 3d96c7ca..cd96895e 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -104,6 +104,12 @@ class Account extends Model return $this->hasMany(AccountBalance::class); } + /** @return HasMany */ + public function importConfigs(): HasMany + { + return $this->hasMany(AccountImportConfig::class); + } + /** @return BelongsTo */ public function bankingConnection(): BelongsTo { diff --git a/app/Models/AccountImportConfig.php b/app/Models/AccountImportConfig.php new file mode 100644 index 00000000..883305f2 --- /dev/null +++ b/app/Models/AccountImportConfig.php @@ -0,0 +1,36 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'account_id', + 'type', + 'config', + ]; + + protected function casts(): array + { + return [ + 'type' => ImportConfigType::class, + 'config' => 'array', + ]; + } + + /** @return BelongsTo */ + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} diff --git a/database/factories/AccountImportConfigFactory.php b/database/factories/AccountImportConfigFactory.php new file mode 100644 index 00000000..4e5c5e49 --- /dev/null +++ b/database/factories/AccountImportConfigFactory.php @@ -0,0 +1,36 @@ + + */ +class AccountImportConfigFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'account_id' => Account::factory(), + 'type' => ImportConfigType::Transaction, + 'config' => [ + 'columnMapping' => [ + 'transaction_date' => 'Date', + 'description' => 'Description', + 'amount' => 'Amount', + 'balance' => null, + 'creditor_name' => null, + 'debtor_name' => null, + ], + 'dateFormat' => 'YYYY-MM-DD', + ], + ]; + } +} diff --git a/database/migrations/2026_07_18_104433_create_account_import_configs_table.php b/database/migrations/2026_07_18_104433_create_account_import_configs_table.php new file mode 100644 index 00000000..00125332 --- /dev/null +++ b/database/migrations/2026_07_18_104433_create_account_import_configs_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->foreignUuid('account_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->json('config'); + $table->timestamps(); + + $table->unique(['account_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('account_import_configs'); + } +}; diff --git a/resources/js/components/accounts/import-balances-drawer.tsx b/resources/js/components/accounts/import-balances-drawer.tsx index 6e12c1e1..44491582 100644 --- a/resources/js/components/accounts/import-balances-drawer.tsx +++ b/resources/js/components/accounts/import-balances-drawer.tsx @@ -8,10 +8,6 @@ import { DrawerTitle, } from '@/components/ui/drawer'; import { Progress } from '@/components/ui/progress'; -import { - loadBalanceImportConfig, - saveBalanceImportConfig, -} from '@/lib/balance-import-config-storage'; import { getCsrfToken } from '@/lib/csrf'; import { detectDateFormat, @@ -19,6 +15,10 @@ import { parseDate, parseFile, } from '@/lib/file-parser'; +import { + loadBalanceImportConfig, + saveBalanceImportConfig, +} from '@/lib/import-config-storage'; import { type SharedData } from '@/types'; import { supportsInvestedAmount, type Account } from '@/types/account'; import { @@ -279,13 +279,23 @@ export function ImportBalancesDrawer({ } } - let finalMapping = autoMapping; - let finalDateFormat = detectedFormat; + // Show the parsed file immediately with auto-detected columns; the + // saved per-account config is fetched off the critical path so a + // slow or hanging network never blocks the preview or Next button. + setState((prev) => ({ + ...prev, + file, + parsedData: data, + columnHeaders: headers, + columnOptions, + columnMapping: autoMapping, + dateFormat: detectedFormat, + dateFormatDetected: formatDetected, + })); - if (state.selectedAccountId) { - const savedConfig = loadBalanceImportConfig( - state.selectedAccountId, - ); + const accountId = state.selectedAccountId; + if (accountId) { + const savedConfig = await loadBalanceImportConfig(accountId); if (savedConfig) { const isValidMapping = ( @@ -300,26 +310,24 @@ export function ImportBalancesDrawer({ }; if (isValidMapping(savedConfig.columnMapping)) { - finalMapping = savedConfig.columnMapping; - finalDateFormat = savedConfig.dateFormat; - // Keep the saved format as the default, but still show - // the selector when the dates are ambiguous so a + // Only apply if this file is still the selected one, so a + // slow load can't clobber a file picked afterwards. Keep + // the saved format as the default, but still show the + // selector when the dates are ambiguous so a // previously-saved wrong format can be corrected. - formatDetected = !formatAmbiguous; + setState((prev) => + prev.file === file + ? { + ...prev, + columnMapping: savedConfig.columnMapping, + dateFormat: savedConfig.dateFormat, + dateFormatDetected: !formatAmbiguous, + } + : prev, + ); } } } - - setState((prev) => ({ - ...prev, - file, - parsedData: data, - columnHeaders: headers, - columnOptions, - columnMapping: finalMapping, - dateFormat: finalDateFormat, - dateFormatDetected: formatDetected, - })); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to parse file', @@ -390,7 +398,7 @@ export function ImportBalancesDrawer({ } if (state.selectedAccountId) { - saveBalanceImportConfig(state.selectedAccountId, { + void saveBalanceImportConfig(state.selectedAccountId, { columnMapping: state.columnMapping, dateFormat: state.dateFormat, }); diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 062e660b..18ceae2f 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -223,11 +223,23 @@ export function ImportTransactionsDrawer({ } } - let finalMapping = autoMapping; - let finalDateFormat = detectedFormat; + // Show the parsed file immediately with auto-detected columns; the + // saved per-account config is fetched off the critical path so a + // slow or hanging network never blocks the preview or Next button. + setState((prev) => ({ + ...prev, + file, + parsedData: data, + columnHeaders: headers, + columnOptions, + columnMapping: autoMapping, + dateFormat: detectedFormat, + dateFormatDetected: formatDetected, + })); - if (state.selectedAccountId) { - const savedConfig = loadImportConfig(state.selectedAccountId); + const accountId = state.selectedAccountId; + if (accountId) { + const savedConfig = await loadImportConfig(accountId); if (savedConfig) { const isValidMapping = ( @@ -247,26 +259,24 @@ export function ImportTransactionsDrawer({ }; if (isValidMapping(savedConfig.columnMapping)) { - finalMapping = savedConfig.columnMapping; - finalDateFormat = savedConfig.dateFormat; - // Keep the saved format as the default, but still show - // the selector when the dates are ambiguous so a + // Only apply if this file is still the selected one, so a + // slow load can't clobber a file picked afterwards. Keep + // the saved format as the default, but still show the + // selector when the dates are ambiguous so a // previously-saved wrong format can be corrected. - formatDetected = !formatAmbiguous; + setState((prev) => + prev.file === file + ? { + ...prev, + columnMapping: savedConfig.columnMapping, + dateFormat: savedConfig.dateFormat, + dateFormatDetected: !formatAmbiguous, + } + : prev, + ); } } } - - setState((prev) => ({ - ...prev, - file, - parsedData: data, - columnHeaders: headers, - columnOptions, - columnMapping: finalMapping, - dateFormat: finalDateFormat, - dateFormatDetected: formatDetected, - })); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to parse file', @@ -458,7 +468,7 @@ export function ImportTransactionsDrawer({ } if (state.selectedAccountId) { - saveImportConfig(state.selectedAccountId, { + void saveImportConfig(state.selectedAccountId, { columnMapping: state.columnMapping, dateFormat: state.dateFormat, }); diff --git a/resources/js/lib/balance-import-config-storage.ts b/resources/js/lib/balance-import-config-storage.ts deleted file mode 100644 index 4c386f99..00000000 --- a/resources/js/lib/balance-import-config-storage.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { BalanceColumnMapping } from '@/types/balance-import'; -import { DateFormat } from '@/types/import'; -import type { UUID } from '@/types/uuid'; - -interface BalanceImportConfig { - columnMapping: BalanceColumnMapping; - dateFormat: DateFormat; -} - -const STORAGE_KEY_PREFIX = 'balance_import_config_account_'; - -export function saveBalanceImportConfig( - accountId: UUID, - config: BalanceImportConfig, -): void { - if (typeof window === 'undefined') return; - - try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - localStorage.setItem(key, JSON.stringify(config)); - } catch (error) { - console.error('Failed to save balance import configuration:', error); - } -} - -export function loadBalanceImportConfig( - accountId: UUID, -): BalanceImportConfig | null { - if (typeof window === 'undefined') return null; - - try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - const stored = localStorage.getItem(key); - - if (!stored) { - return null; - } - - const config = JSON.parse(stored) as BalanceImportConfig; - - if (!config.columnMapping || !config.dateFormat) { - return null; - } - - return config; - } catch (error) { - console.error('Failed to load balance import configuration:', error); - return null; - } -} - -export function clearBalanceImportConfig(accountId: UUID): void { - if (typeof window === 'undefined') return; - - try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - localStorage.removeItem(key); - } catch (error) { - console.error('Failed to clear balance import configuration:', error); - } -} diff --git a/resources/js/lib/import-config-storage.ts b/resources/js/lib/import-config-storage.ts index 5a85a811..57848a5d 100644 --- a/resources/js/lib/import-config-storage.ts +++ b/resources/js/lib/import-config-storage.ts @@ -1,55 +1,81 @@ +import type { BalanceColumnMapping } from '@/types/balance-import'; import { type ColumnMapping, DateFormat } from '@/types/import'; import { type UUID } from '@/types/uuid'; +import axios from 'axios'; interface ImportConfig { columnMapping: ColumnMapping; dateFormat: DateFormat; } -const STORAGE_KEY_PREFIX = 'import_config_account_'; +interface BalanceImportConfig { + columnMapping: BalanceColumnMapping; + dateFormat: DateFormat; +} -export function saveImportConfig(accountId: UUID, config: ImportConfig): void { - if (typeof window === 'undefined') return; +type ImportConfigType = 'transaction' | 'balance'; +function configUrl(accountId: UUID): string { + return `/api/accounts/${accountId}/import-config`; +} + +async function saveConfig( + accountId: UUID, + type: ImportConfigType, + config: ImportConfig | BalanceImportConfig, +): Promise { try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - localStorage.setItem(key, JSON.stringify(config)); + await axios.put(configUrl(accountId), { type, config }); } catch (error) { - console.error('Failed to save import configuration:', error); + console.error(`Failed to save ${type} import configuration:`, error); } } -export function loadImportConfig(accountId: UUID): ImportConfig | null { - if (typeof window === 'undefined') return null; - +async function loadConfig( + accountId: UUID, + type: ImportConfigType, +): Promise { try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - const stored = localStorage.getItem(key); + const { data } = await axios.get<{ data: T | null }>( + configUrl(accountId), + { params: { type } }, + ); - if (!stored) { - return null; - } + const config = data.data; - const config = JSON.parse(stored) as ImportConfig; - - if (!config.columnMapping || !config.dateFormat) { + if (!config || !config.columnMapping || !config.dateFormat) { return null; } return config; } catch (error) { - console.error('Failed to load import configuration:', error); + console.error(`Failed to load ${type} import configuration:`, error); return null; } } -export function clearImportConfig(accountId: UUID): void { - if (typeof window === 'undefined') return; - - try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - localStorage.removeItem(key); - } catch (error) { - console.error('Failed to clear import configuration:', error); - } +export function saveImportConfig( + accountId: UUID, + config: ImportConfig, +): Promise { + return saveConfig(accountId, 'transaction', config); +} + +export function loadImportConfig( + accountId: UUID, +): Promise { + return loadConfig(accountId, 'transaction'); +} + +export function saveBalanceImportConfig( + accountId: UUID, + config: BalanceImportConfig, +): Promise { + return saveConfig(accountId, 'balance', config); +} + +export function loadBalanceImportConfig( + accountId: UUID, +): Promise { + return loadConfig(accountId, 'balance'); } diff --git a/routes/api.php b/routes/api.php index 8cc9eeac..030fb3d9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AccountBalanceController; use App\Http\Controllers\Api\AccountController; +use App\Http\Controllers\Api\AccountImportConfigController; use App\Http\Controllers\Api\CashflowAnalyticsController; use App\Http\Controllers\Api\CategoryMonthlyBreakdownController; use App\Http\Controllers\Api\DashboardAnalyticsController; @@ -38,6 +39,10 @@ Route::middleware(['web', 'auth', 'throttle:300,1'])->group(function () { Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index'); Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update'); + // Account import configuration (per-account column mapping, synced across devices) + Route::get('accounts/{account}/import-config', [AccountImportConfigController::class, 'show'])->name('api.accounts.import-config.show'); + Route::put('accounts/{account}/import-config', [AccountImportConfigController::class, 'update'])->name('api.accounts.import-config.update'); + // Account Balances Route::put('accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('api.accounts.balance.update-current'); Route::get('accounts/{account}/balances', [AccountBalanceController::class, 'index'])->name('api.accounts.balances.index'); diff --git a/tests/Feature/AccountImportConfigTest.php b/tests/Feature/AccountImportConfigTest.php new file mode 100644 index 00000000..a91faff7 --- /dev/null +++ b/tests/Feature/AccountImportConfigTest.php @@ -0,0 +1,114 @@ +user = User::factory()->create(); + $this->account = Account::factory()->create(['user_id' => $this->user->id]); + $this->actingAs($this->user); +}); + +$transactionConfig = [ + 'columnMapping' => [ + 'transaction_date' => 'Date', + 'description' => 'Concept', + 'amount' => 'Amount', + 'balance' => null, + 'creditor_name' => null, + 'debtor_name' => null, + ], + 'dateFormat' => 'DD-MM-YYYY', +]; + +test('import config endpoints require authentication', function () { + auth()->logout(); + + $this->getJson("/api/accounts/{$this->account->id}/import-config?type=transaction") + ->assertUnauthorized(); +}); + +test('returns null when no config is saved yet', function () { + $this->getJson("/api/accounts/{$this->account->id}/import-config?type=transaction") + ->assertOk() + ->assertJsonPath('data', null); +}); + +test('saves and returns an import config for an account', function () use ($transactionConfig) { + $this->putJson("/api/accounts/{$this->account->id}/import-config", [ + 'type' => 'transaction', + 'config' => $transactionConfig, + ]) + ->assertOk() + ->assertJsonPath('data.dateFormat', 'DD-MM-YYYY') + ->assertJsonPath('data.columnMapping.description', 'Concept'); + + $this->assertDatabaseHas('account_import_configs', [ + 'account_id' => $this->account->id, + 'type' => 'transaction', + ]); + + $this->getJson("/api/accounts/{$this->account->id}/import-config?type=transaction") + ->assertOk() + ->assertJsonPath('data.dateFormat', 'DD-MM-YYYY'); +}); + +test('upserts the config instead of creating duplicates', function () use ($transactionConfig) { + $url = "/api/accounts/{$this->account->id}/import-config"; + + $this->putJson($url, ['type' => 'transaction', 'config' => $transactionConfig])->assertOk(); + + $updated = $transactionConfig; + $updated['dateFormat'] = 'YYYY-MM-DD'; + $this->putJson($url, ['type' => 'transaction', 'config' => $updated])->assertOk(); + + expect(AccountImportConfig::where('account_id', $this->account->id)->count())->toBe(1); + $this->getJson("{$url}?type=transaction") + ->assertJsonPath('data.dateFormat', 'YYYY-MM-DD'); +}); + +test('transaction and balance configs are stored independently', function () use ($transactionConfig) { + $url = "/api/accounts/{$this->account->id}/import-config"; + $balanceConfig = [ + 'columnMapping' => ['balance_date' => 'Date', 'balance' => 'Saldo', 'invested_amount' => null], + 'dateFormat' => 'YYYY-MM-DD', + ]; + + $this->putJson($url, ['type' => 'transaction', 'config' => $transactionConfig])->assertOk(); + $this->putJson($url, ['type' => 'balance', 'config' => $balanceConfig])->assertOk(); + + expect(AccountImportConfig::where('account_id', $this->account->id)->count())->toBe(2); + $this->getJson("{$url}?type=balance") + ->assertJsonPath('data.columnMapping.balance', 'Saldo'); +}); + +test('cannot read the import config of another user account', function () { + $otherAccount = Account::factory()->create(); + + $this->getJson("/api/accounts/{$otherAccount->id}/import-config?type=transaction") + ->assertForbidden(); +}); + +test('cannot write the import config of another user account', function () use ($transactionConfig) { + $otherAccount = Account::factory()->create(); + + $this->putJson("/api/accounts/{$otherAccount->id}/import-config", [ + 'type' => 'transaction', + 'config' => $transactionConfig, + ])->assertForbidden(); +}); + +test('rejects an unknown config type', function () use ($transactionConfig) { + $this->putJson("/api/accounts/{$this->account->id}/import-config", [ + 'type' => 'nonsense', + 'config' => $transactionConfig, + ])->assertJsonValidationErrors('type'); +}); + +test('rejects a config without a column mapping', function () { + $this->putJson("/api/accounts/{$this->account->id}/import-config", [ + 'type' => 'transaction', + 'config' => ['dateFormat' => 'YYYY-MM-DD'], + ])->assertJsonValidationErrors('config.columnMapping'); +});