feat(import): persist per-account import config on the backend

Move the CSV/Excel import column mapping and date format from device
localStorage to a per-account backend record, so the configuration is
preconfigured and loaded automatically when importing from another
device.

Adds an account_import_configs table (one row per account per import
type), an API endpoint scoped by the AccountPolicy, and swaps the
client-side storage helpers from localStorage to axios.
This commit is contained in:
Víctor Falcón 2026-07-18 12:49:17 +02:00
parent 2d7e905ddd
commit b6c759cfd6
13 changed files with 367 additions and 62 deletions

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum ImportConfigType: string
{
case Transaction = 'transaction';
case Balance = 'balance';
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ImportConfigType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\UpdateAccountImportConfigRequest;
use App\Models\Account;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AccountImportConfigController extends Controller
{
use AuthorizesRequests;
public function show(Request $request, Account $account): JsonResponse
{
$this->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]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Api;
use App\Enums\ImportConfigType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAccountImportConfigRequest extends FormRequest
{
/**
* Authorization is handled by the controller via the AccountPolicy.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|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'],
];
}
}

View File

@ -104,6 +104,12 @@ class Account extends Model
return $this->hasMany(AccountBalance::class);
}
/** @return HasMany<AccountImportConfig, $this> */
public function importConfigs(): HasMany
{
return $this->hasMany(AccountImportConfig::class);
}
/** @return BelongsTo<BankingConnection, $this> */
public function bankingConnection(): BelongsTo
{

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Enums\ImportConfigType;
use Database\Factories\AccountImportConfigFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AccountImportConfig extends Model
{
/** @use HasFactory<AccountImportConfigFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'account_id',
'type',
'config',
];
/** @var list<string> */
protected $hidden = [
'id',
'account_id',
'created_at',
'updated_at',
];
protected function casts(): array
{
return [
'type' => ImportConfigType::class,
'config' => 'array',
];
}
/** @return BelongsTo<Account, $this> */
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\ImportConfigType;
use App\Models\Account;
use App\Models\AccountImportConfig;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<AccountImportConfig>
*/
class AccountImportConfigFactory extends Factory
{
/**
* @return array<string, mixed>
*/
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',
],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('account_import_configs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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<void> {
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<BalanceImportConfig | null> {
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);
}
}

View File

@ -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<void> {
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<ImportConfig | null> {
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);
}
}

View File

@ -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');

View File

@ -0,0 +1,114 @@
<?php
use App\Models\Account;
use App\Models\AccountImportConfig;
use App\Models\User;
beforeEach(function () {
$this->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');
});