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..f1185fa0 --- /dev/null +++ b/app/Models/AccountImportConfig.php @@ -0,0 +1,44 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'account_id', + 'type', + 'config', + ]; + + /** @var list */ + protected $hidden = [ + 'id', + 'account_id', + 'created_at', + 'updated_at', + ]; + + 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..f84b92df 100644 --- a/resources/js/components/accounts/import-balances-drawer.tsx +++ b/resources/js/components/accounts/import-balances-drawer.tsx @@ -283,7 +283,7 @@ export function ImportBalancesDrawer({ let finalDateFormat = detectedFormat; if (state.selectedAccountId) { - const savedConfig = loadBalanceImportConfig( + const savedConfig = await loadBalanceImportConfig( state.selectedAccountId, ); @@ -390,7 +390,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..bb85bfee 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -227,7 +227,9 @@ export function ImportTransactionsDrawer({ let finalDateFormat = detectedFormat; if (state.selectedAccountId) { - const savedConfig = loadImportConfig(state.selectedAccountId); + const savedConfig = await loadImportConfig( + state.selectedAccountId, + ); if (savedConfig) { const isValidMapping = ( @@ -458,7 +460,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 index 4c386f99..8643238f 100644 --- a/resources/js/lib/balance-import-config-storage.ts +++ b/resources/js/lib/balance-import-config-storage.ts @@ -1,44 +1,43 @@ import type { BalanceColumnMapping } from '@/types/balance-import'; import { DateFormat } from '@/types/import'; import type { UUID } from '@/types/uuid'; +import axios from 'axios'; interface BalanceImportConfig { columnMapping: BalanceColumnMapping; dateFormat: DateFormat; } -const STORAGE_KEY_PREFIX = 'balance_import_config_account_'; +function configUrl(accountId: UUID): string { + return `/api/accounts/${accountId}/import-config`; +} -export function saveBalanceImportConfig( +export async function saveBalanceImportConfig( accountId: UUID, config: BalanceImportConfig, -): void { - if (typeof window === 'undefined') return; - +): Promise { try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - localStorage.setItem(key, JSON.stringify(config)); + await axios.put(configUrl(accountId), { + type: 'balance', + config, + }); } catch (error) { console.error('Failed to save balance import configuration:', error); } } -export function loadBalanceImportConfig( +export async function loadBalanceImportConfig( accountId: UUID, -): BalanceImportConfig | null { - if (typeof window === 'undefined') return null; - +): Promise { try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - const stored = localStorage.getItem(key); + const { data } = await axios.get<{ data: BalanceImportConfig | null }>( + configUrl(accountId), + { params: { type: 'balance' } }, + ); - if (!stored) { - return null; - } + const config = data.data; - const config = JSON.parse(stored) as BalanceImportConfig; - - if (!config.columnMapping || !config.dateFormat) { + if (!config || !config.columnMapping || !config.dateFormat) { return null; } @@ -48,14 +47,3 @@ export function loadBalanceImportConfig( 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..eed5e8c5 100644 --- a/resources/js/lib/import-config-storage.ts +++ b/resources/js/lib/import-config-storage.ts @@ -1,38 +1,42 @@ 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_'; - -export function saveImportConfig(accountId: UUID, config: ImportConfig): void { - if (typeof window === 'undefined') return; +function configUrl(accountId: UUID): string { + return `/api/accounts/${accountId}/import-config`; +} +export async function saveImportConfig( + accountId: UUID, + config: ImportConfig, +): Promise { try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - localStorage.setItem(key, JSON.stringify(config)); + await axios.put(configUrl(accountId), { + type: 'transaction', + config, + }); } catch (error) { console.error('Failed to save import configuration:', error); } } -export function loadImportConfig(accountId: UUID): ImportConfig | null { - if (typeof window === 'undefined') return null; - +export async function loadImportConfig( + accountId: UUID, +): Promise { try { - const key = `${STORAGE_KEY_PREFIX}${accountId}`; - const stored = localStorage.getItem(key); + const { data } = await axios.get<{ data: ImportConfig | null }>( + configUrl(accountId), + { params: { type: 'transaction' } }, + ); - 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; } @@ -42,14 +46,3 @@ export function loadImportConfig(accountId: UUID): ImportConfig | null { 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); - } -} 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'); +});