From e62f1d6aac924d957027e153c1b0f9b7830928bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 5 Jun 2026 13:57:34 +0200 Subject: [PATCH] refactor(api): standardize serialization via model $hidden (#492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- app/Http/Controllers/AccountController.php | 145 ++++++++---------- .../Controllers/Api/AccountController.php | 5 +- .../Controllers/Api/ImportDataController.php | 8 +- .../Controllers/Api/TransactionController.php | 2 +- app/Http/Controllers/BudgetController.php | 8 +- app/Http/Controllers/CashflowController.php | 6 +- app/Http/Controllers/OnboardingController.php | 10 +- .../ReEvaluateTransactionRulesController.php | 2 +- .../Settings/AccountController.php | 4 +- .../AutomationRuleApplicationController.php | 2 +- .../Controllers/Settings/LabelController.php | 2 +- .../Sync/TransactionSyncController.php | 2 +- .../Controllers/TransactionController.php | 28 ++-- app/Http/Middleware/HandleInertiaRequests.php | 31 +--- app/Models/Account.php | 28 ++++ app/Models/Bank.php | 7 + app/Models/Budget.php | 5 + app/Models/Category.php | 32 ++-- app/Models/Label.php | 10 ++ app/Models/LoanDetail.php | 7 + app/Models/RealEstateDetail.php | 9 +- app/Models/Transaction.php | 14 ++ tests/Feature/AccountControllerTest.php | 18 +++ tests/Feature/AutomationRuleTest.php | 25 +++ tests/Feature/BudgetTest.php | 14 ++ tests/Feature/LabelTest.php | 12 ++ tests/Feature/Settings/BankTest.php | 13 ++ tests/Feature/Settings/CategoryTest.php | 12 ++ tests/Feature/TransactionTest.php | 26 ++++ 29 files changed, 321 insertions(+), 166 deletions(-) diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index c21dd571..db86921b 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Enums\AccountType; use App\Models\Account; use App\Models\AccountBalance; +use App\Models\LoanDetail; use App\Services\AccountMetricsService; use App\Services\LoanAmortizationService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -27,23 +28,17 @@ class AccountController extends Controller $accounts = Account::query() ->where('user_id', $user->id) - ->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id']) + ->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id']) ->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')") ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); + ->get(); - $accountsData = $accounts->map(function (Account $account) { - $data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']); - - if ($account->type === AccountType::RealEstate && $account->realEstateDetail?->linked_loan_account_id) { - $data['linked_loan_account_id'] = $account->realEstateDetail->linked_loan_account_id; - } - - return $data; - }); + // The real estate detail is loaded only to feed the linked_loan_account_id + // accessor; it should not be serialized as a nested relation here. + $accounts->makeHidden('realEstateDetail'); return Inertia::render('Accounts/Index', [ - 'accounts' => $accountsData, + 'accounts' => $accounts, 'accountMetrics' => Inertia::defer(fn () => $this->accountMetricsService->getAccountMetrics($user->currency_code, $accounts)), ]); } @@ -52,110 +47,52 @@ class AccountController extends Controller { $this->authorize('view', $account); - $account->load('bank:id,name,logo'); + $account->load('bank'); - $data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']); + $data = $account->toArray(); if ($account->type === AccountType::RealEstate) { - $account->load('realEstateDetail.linkedLoanAccount.bank:id,name,logo'); + $account->load('realEstateDetail.linkedLoanAccount.bank'); $realEstateDetail = $account->realEstateDetail; if ($realEstateDetail) { $linkedLoan = $realEstateDetail->linkedLoanAccount; $data['real_estate_detail'] = [ - ...$realEstateDetail->only([ - 'id', 'property_type', 'address', 'purchase_price', - 'area_value', 'area_unit', 'notes', - 'revaluation_percentage', 'linked_loan_account_id', - ]), - 'purchase_date' => $realEstateDetail->purchase_date?->format('Y-m-d'), - 'linked_loan_account' => $linkedLoan - ? $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank']) - : null, + ...$realEstateDetail->toArray(), + 'linked_loan_account' => $linkedLoan?->toArray(), ]; // Include current balances for equity calculation if ($linkedLoan) { - $data['real_estate_detail']['current_loan_balance'] = AccountBalance::query() - ->where('account_id', $linkedLoan->id) - ->where('balance_date', '<=', now()->toDateString()) - ->orderByDesc('balance_date') - ->value('balance') ?? 0; + $data['real_estate_detail']['current_loan_balance'] = $this->latestBalance($linkedLoan->id); // Include linked loan account at top level for header actions - $data['linked_loan_account'] = $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank', 'banking_connection_id']); + $data['linked_loan_account'] = $linkedLoan->toArray(); - // Load loan amortization details for the linked loan $linkedLoan->load('loanDetail'); - $loanDetail = $linkedLoan->loanDetail; - if ($loanDetail) { - $remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now()); - - $lastLoanBalance = AccountBalance::query() - ->where('account_id', $linkedLoan->id) - ->orderBy('balance_date', 'desc') - ->value('balance'); - - $monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment( - $lastLoanBalance ?? $loanDetail->original_amount, - (float) $loanDetail->annual_interest_rate, - $lastLoanBalance ? $remainingMonths : $loanDetail->loan_term_months, - ); - - $data['loan_detail'] = [ - ...$loanDetail->only([ - 'id', 'annual_interest_rate', 'loan_term_months', - 'start_date', 'original_amount', - ]), - 'monthly_payment' => $monthlyPayment, - 'remaining_months' => $remainingMonths, - ]; + if ($linkedLoan->loanDetail) { + $data['loan_detail'] = $this->loanDetailData($linkedLoan->loanDetail, $linkedLoan); } } - $data['real_estate_detail']['current_market_value'] = AccountBalance::query() - ->where('account_id', $account->id) - ->where('balance_date', '<=', now()->toDateString()) - ->orderByDesc('balance_date') - ->value('balance') ?? 0; + $data['real_estate_detail']['current_market_value'] = $this->latestBalance($account->id); } // Provide available loan accounts for linking $data['available_loan_accounts'] = $request->user() ->accounts() ->where('type', AccountType::Loan->value) - ->with('bank:id,name,logo') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']); + ->with('bank') + ->get(); } if ($account->type === AccountType::Loan) { $account->load('loanDetail'); - $loanDetail = $account->loanDetail; - if ($loanDetail) { - $remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now()); - - $lastBalance = AccountBalance::query() - ->where('account_id', $account->id) - ->orderBy('balance_date', 'desc') - ->value('balance'); - - $monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment( - $lastBalance ?? $loanDetail->original_amount, - (float) $loanDetail->annual_interest_rate, - $lastBalance ? $remainingMonths : $loanDetail->loan_term_months, - ); - - $data['loan_detail'] = [ - ...$loanDetail->only([ - 'id', 'annual_interest_rate', 'loan_term_months', - 'start_date', 'original_amount', - ]), - 'monthly_payment' => $monthlyPayment, - 'remaining_months' => $remainingMonths, - ]; + if ($account->loanDetail) { + $data['loan_detail'] = $this->loanDetailData($account->loanDetail, $account); } } @@ -163,4 +100,44 @@ class AccountController extends Controller 'account' => $data, ]); } + + /** + * Build the loan detail payload, augmenting the model with the computed + * amortization figures that depend on the account's latest balance. + * + * @return array + */ + private function loanDetailData(LoanDetail $loanDetail, Account $account): array + { + $remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now()); + + $lastBalance = AccountBalance::query() + ->where('account_id', $account->id) + ->orderBy('balance_date', 'desc') + ->value('balance'); + + $monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment( + $lastBalance ?? $loanDetail->original_amount, + (float) $loanDetail->annual_interest_rate, + $lastBalance ? $remainingMonths : $loanDetail->loan_term_months, + ); + + return [ + ...$loanDetail->toArray(), + 'monthly_payment' => $monthlyPayment, + 'remaining_months' => $remainingMonths, + ]; + } + + /** + * The most recent balance for an account on or before today. + */ + private function latestBalance(string $accountId): int + { + return AccountBalance::query() + ->where('account_id', $accountId) + ->where('balance_date', '<=', now()->toDateString()) + ->orderByDesc('balance_date') + ->value('balance') ?? 0; + } } diff --git a/app/Http/Controllers/Api/AccountController.php b/app/Http/Controllers/Api/AccountController.php index 5f61074a..9bcd7950 100644 --- a/app/Http/Controllers/Api/AccountController.php +++ b/app/Http/Controllers/Api/AccountController.php @@ -18,9 +18,12 @@ class AccountController extends Controller */ public function index(Request $request): JsonResponse { + // The decryption-migration flow needs bank_id, which Account hides by + // default; opt it back in explicitly here. $accounts = $request->user() ->accounts() - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']); + ->get() + ->makeVisible('bank_id'); return response()->json($accounts); } diff --git a/app/Http/Controllers/Api/ImportDataController.php b/app/Http/Controllers/Api/ImportDataController.php index 7be77384..bf4f582b 100644 --- a/app/Http/Controllers/Api/ImportDataController.php +++ b/app/Http/Controllers/Api/ImportDataController.php @@ -16,17 +16,17 @@ class ImportDataController extends Controller return response()->json([ 'accounts' => $user->accounts() - ->with('bank:id,name,logo') + ->with('bank') ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']), + ->get(), 'categories' => $user->categories() ->forDisplay() ->get(), 'banks' => $user->banks() ->orderBy('name') - ->get(['id', 'name', 'logo']), + ->get(), 'automationRules' => $user->automationRules() - ->with(['category:id,name,icon,color', 'labels:id,name,color']) + ->with(['category', 'labels']) ->orderBy('priority') ->get(), ]); diff --git a/app/Http/Controllers/Api/TransactionController.php b/app/Http/Controllers/Api/TransactionController.php index 89cb9aa9..2b3ec7fb 100644 --- a/app/Http/Controllers/Api/TransactionController.php +++ b/app/Http/Controllers/Api/TransactionController.php @@ -17,7 +17,7 @@ class TransactionController extends Controller { $query = $request->user() ->transactions() - ->with('labels:id,name,color'); + ->with('labels'); if ($request->query('encrypted') === 'true') { $query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')); diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index c389a8b1..b394e5c6 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -90,19 +90,19 @@ class BudgetController extends Controller $accounts = Account::query() ->where('user_id', $user->id) - ->with('bank:id,name,logo') + ->with('bank') ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); + ->get(); $banks = Bank::query() ->availableForUser($user) ->orderBy('name') - ->get(['id', 'name', 'logo']); + ->get(); $labels = Label::query() ->where('user_id', $user->id) ->orderBy('name') - ->get(['id', 'name', 'color']); + ->get(); return Inertia::render('budgets/show', [ 'budget' => $budget, diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index 57d96b21..67dd0e28 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -22,14 +22,14 @@ class CashflowController extends Controller $accounts = Account::query() ->where('user_id', $user->id) - ->with('bank:id,name,logo') + ->with('bank') ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']); + ->get(); $banks = Bank::query() ->availableForUser($user) ->orderBy('name') - ->get(['id', 'name', 'logo']); + ->get(); $periodType = $request->query('period_type'); $validPeriodType = is_string($periodType) && in_array($periodType, ['month', 'quarter', 'year'], true) diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 9b1e7f7b..ab8ddc81 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -21,11 +21,11 @@ class OnboardingController extends Controller $banks = Bank::query() ->availableForUser($user) ->orderBy('name') - ->get(['id', 'name', 'logo']); + ->get(); $accounts = $user->accounts() - ->with('bank:id,name,logo') - ->get(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank_id', 'banking_connection_id']); + ->with('bank') + ->get(); $categories = Category::query() ->where('user_id', $user->id) @@ -35,10 +35,10 @@ class OnboardingController extends Controller $transactions = Transaction::query() ->where('user_id', $user->id) ->whereNull('category_id') - ->with(['account.bank:id,name,logo', 'labels:id,name,color']) + ->with(['account.bank', 'labels']) ->orderBy('transaction_date', 'desc') ->orderBy('id', 'desc') - ->get(['id', 'account_id', 'category_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv']); + ->get(); return Inertia::render('onboarding/index', [ 'banks' => $banks, diff --git a/app/Http/Controllers/ReEvaluateTransactionRulesController.php b/app/Http/Controllers/ReEvaluateTransactionRulesController.php index aac43bd4..90e0cb13 100644 --- a/app/Http/Controllers/ReEvaluateTransactionRulesController.php +++ b/app/Http/Controllers/ReEvaluateTransactionRulesController.php @@ -25,7 +25,7 @@ class ReEvaluateTransactionRulesController extends Controller $service->applyRules($transaction); - $transaction->refresh()->load('labels:id,name,color', 'category:id,name,icon,color'); + $transaction->refresh()->load('labels', 'category'); return response()->json([ 'data' => $transaction, diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index 591f5eb0..f1fd7a7c 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -35,9 +35,9 @@ class AccountController extends Controller $accounts = $user ->accounts() - ->with(['bank:id,name,logo', 'loanDetail', 'realEstateDetail']) + ->with(['bank', 'loanDetail', 'realEstateDetail']) ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); + ->get(); return Inertia::render('settings/accounts', [ 'accounts' => $accounts, diff --git a/app/Http/Controllers/Settings/AutomationRuleApplicationController.php b/app/Http/Controllers/Settings/AutomationRuleApplicationController.php index f40f44e8..84f16281 100644 --- a/app/Http/Controllers/Settings/AutomationRuleApplicationController.php +++ b/app/Http/Controllers/Settings/AutomationRuleApplicationController.php @@ -46,7 +46,7 @@ class AutomationRuleApplicationController extends Controller $transactions = Transaction::query() ->whereIn('id', $pageIds) - ->with(['account:id,name,bank_id', 'account.bank:id,name', 'category:id,name,icon,color', 'labels:id,name,color']) + ->with(['account.bank', 'category', 'labels']) ->orderByDesc('transaction_date') ->orderByDesc('created_at') ->get(); diff --git a/app/Http/Controllers/Settings/LabelController.php b/app/Http/Controllers/Settings/LabelController.php index 2e91deea..b02cec41 100644 --- a/app/Http/Controllers/Settings/LabelController.php +++ b/app/Http/Controllers/Settings/LabelController.php @@ -24,7 +24,7 @@ class LabelController extends Controller $labels = auth()->user() ->labels() ->orderBy('name') - ->get(['id', 'name', 'color']); + ->get(); return Inertia::render('settings/labels', [ 'labels' => $labels, diff --git a/app/Http/Controllers/Sync/TransactionSyncController.php b/app/Http/Controllers/Sync/TransactionSyncController.php index a4af9148..448fe8eb 100644 --- a/app/Http/Controllers/Sync/TransactionSyncController.php +++ b/app/Http/Controllers/Sync/TransactionSyncController.php @@ -23,7 +23,7 @@ class TransactionSyncController extends Controller } $transactions = $query - ->with('labels:id,name,color') + ->with('labels') ->orderBy('transaction_date', 'desc') ->orderBy('updated_at', 'desc') ->get(); diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index ef82c69c..e9dfcd55 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -50,7 +50,7 @@ class TransactionController extends Controller $transactions = Transaction::query() ->where('user_id', $user->id) - ->with(['account.bank:id,name,logo', 'category:id,name,icon,color', 'labels:id,name,color']) + ->with(['account.bank', 'category', 'labels']) ->applyFilters($filters) ->orderBy($sortColumn, $sortDirection) ->orderBy('id', 'desc') @@ -78,23 +78,23 @@ class TransactionController extends Controller $accounts = Account::query() ->where('user_id', $user->id) - ->with('bank:id,name,logo') + ->with('bank') ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); + ->get(); $banks = Bank::query() ->availableForUser($user) ->orderBy('name') - ->get(['id', 'name', 'logo']); + ->get(); $labels = Label::query() ->where('user_id', $user->id) ->orderBy('name') - ->get(['id', 'name', 'color']); + ->get(); $automationRules = AutomationRule::query() ->where('user_id', $user->id) - ->with(['category:id,name,icon,color', 'labels:id,name,color']) + ->with(['category', 'labels']) ->orderBy('priority') ->get(); @@ -120,27 +120,27 @@ class TransactionController extends Controller $accounts = Account::query() ->where('user_id', $user->id) - ->with('bank:id,name,logo') + ->with('bank') ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); + ->get(); $banks = Bank::query() ->availableForUser($user) ->orderBy('name') - ->get(['id', 'name', 'logo']); + ->get(); $labels = Label::query() ->where('user_id', $user->id) ->orderBy('name') - ->get(['id', 'name', 'color']); + ->get(); $transactions = Transaction::query() ->where('user_id', $user->id) ->whereNull('category_id') - ->with(['account.bank:id,name,logo', 'labels:id,name,color']) + ->with(['account.bank', 'labels']) ->orderBy('transaction_date', 'desc') ->orderBy('id', 'desc') - ->get(['id', 'account_id', 'category_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv', 'creditor_name', 'debtor_name']); + ->get(); return Inertia::render('transactions/categorize', [ 'categories' => $categories, @@ -174,7 +174,7 @@ class TransactionController extends Controller } return response()->json([ - 'data' => $transaction->load('labels:id,name,color'), + 'data' => $transaction->load('labels'), ], 201); } @@ -210,7 +210,7 @@ class TransactionController extends Controller } return response()->json([ - 'data' => $transaction->fresh()->load('labels:id,name,color'), + 'data' => $transaction->fresh()->load('labels'), ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2f650e7b..23fe66fe 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,7 +2,6 @@ namespace App\Http\Middleware; -use App\Enums\AccountType; use App\Enums\BankingConnectionStatus; use App\Features\CalculateBalancesOnImport; use App\Models\BankingConnection; @@ -122,47 +121,29 @@ class HandleInertiaRequests extends Middleware 'reconnect_url' => route('open-banking.reconnect', $connection), ]) : [], 'accounts' => fn () => $user ? $user->accounts() - ->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id']) + ->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id']) ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']) - ->map(function ($account) { - $data = $account->only([ - 'id', - 'name', - 'name_iv', - 'encrypted', - 'bank_id', - 'type', - 'currency_code', - 'banking_connection_id', - 'bank', - ]); - - if ($account->type === AccountType::RealEstate) { - $data['linked_loan_account_id'] = $account->realEstateDetail?->linked_loan_account_id; - } - - return $data; - }) : [], + ->get() + ->makeHidden('realEstateDetail') : [], 'categories' => fn () => $user ? $user->categories() ->forDisplay() ->get() : [], 'banks' => fn () => $user ? $user->banks() ->orderBy('name') - ->get(['id', 'name', 'logo']) : [], + ->get() : [], 'automationRules' => function () use ($user) { if (! $user) { return []; } return $user->automationRules() - ->with(['category:id,name,icon,color', 'labels:id,name,color']) + ->with(['category', 'labels']) ->orderBy('priority') ->get(); }, 'labels' => fn () => $user ? $user->labels() ->orderBy('name') - ->get(['id', 'name', 'color']) : [], + ->get() : [], 'hasEncryptedAccounts' => $hasEncryptedAccounts, 'hasEncryptionSetup' => $user?->encryption_salt !== null, 'hasEncryptedTransactions' => $hasEncryptedTransactions, diff --git a/app/Models/Account.php b/app/Models/Account.php index d3f18367..f7a92b55 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\AccountType; use Database\Factories\AccountFactory; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -34,6 +35,21 @@ class Account extends Model 'linked_at', ]; + /** @var list */ + protected $hidden = [ + 'user_id', + 'bank_id', + 'iban', + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + /** @var list */ + protected $appends = [ + 'linked_loan_account_id', + ]; + protected function casts(): array { return [ @@ -43,6 +59,18 @@ class Account extends Model ]; } + /** + * The linked loan account id, surfaced from the real estate detail. Guarded + * on relationLoaded so serialization never triggers a lazy load, avoiding + * N+1 queries when accounts are listed without the relation. + */ + protected function linkedLoanAccountId(): Attribute + { + return Attribute::get(fn (): ?string => $this->relationLoaded('realEstateDetail') + ? $this->realEstateDetail?->linked_loan_account_id + : null); + } + /** @return BelongsTo */ public function user(): BelongsTo { diff --git a/app/Models/Bank.php b/app/Models/Bank.php index 19dcf822..9bb48fba 100644 --- a/app/Models/Bank.php +++ b/app/Models/Bank.php @@ -22,6 +22,13 @@ class Bank extends Model 'user_id', ]; + /** @var list */ + protected $hidden = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + /** * Scope to banks visible to the given user: public banks plus their own. * diff --git a/app/Models/Budget.php b/app/Models/Budget.php index d70f6a0e..5847f3c7 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -28,6 +28,11 @@ class Budget extends Model 'rollover_type', ]; + /** @var list */ + protected $hidden = [ + 'period_duration', + ]; + protected function casts(): array { return [ diff --git a/app/Models/Category.php b/app/Models/Category.php index 36e3e84d..d735df4b 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -33,22 +33,6 @@ class Category extends Model */ public const int MAX_DEPTH = 3; - /** - * Columns sent to the frontend. Always returns the full Category shape so - * every selector receives the same object regardless of what it needs. - * - * @var list - */ - private const array FRONTEND_COLUMNS = [ - 'id', - 'name', - 'icon', - 'color', - 'type', - 'cashflow_direction', - 'parent_id', - ]; - protected $fillable = [ 'name', 'icon', @@ -59,6 +43,17 @@ class Category extends Model 'parent_id', ]; + /** @var list */ + protected $hidden = [ + 'user_id', + 'created_at', + 'updated_at', + 'deleted_at', + 'active_unique_marker', + 'parent_unique_marker', + 'pivot', + ]; + protected function casts(): array { return [ @@ -111,13 +106,14 @@ class Category extends Model /** * Scope for fetching categories to send to the frontend: the full, ordered - * Category shape used by every category selector. + * Category shape used by every category selector. The serialized shape is + * controlled by $hidden so every consumer receives the same object. * * @param Builder $query * @return Builder */ public function scopeForDisplay(Builder $query): Builder { - return $query->orderBy('name')->select(self::FRONTEND_COLUMNS); + return $query->orderBy('name'); } } diff --git a/app/Models/Label.php b/app/Models/Label.php index f7c0dc9b..da3f425c 100644 --- a/app/Models/Label.php +++ b/app/Models/Label.php @@ -21,6 +21,16 @@ class Label extends Model 'user_id', ]; + /** + * Hide the pivot from serialization so a Label looks identical whether it + * is loaded standalone or through a belongsToMany relation. + * + * @var list + */ + protected $hidden = [ + 'pivot', + ]; + /** @return BelongsTo */ public function user(): BelongsTo { diff --git a/app/Models/LoanDetail.php b/app/Models/LoanDetail.php index bc570ca9..6f90ab0f 100644 --- a/app/Models/LoanDetail.php +++ b/app/Models/LoanDetail.php @@ -28,6 +28,13 @@ class LoanDetail extends Model 'original_amount', ]; + /** @var list */ + protected $hidden = [ + 'account_id', + 'created_at', + 'updated_at', + ]; + protected function casts(): array { return [ diff --git a/app/Models/RealEstateDetail.php b/app/Models/RealEstateDetail.php index a61d4397..ef598204 100644 --- a/app/Models/RealEstateDetail.php +++ b/app/Models/RealEstateDetail.php @@ -34,12 +34,19 @@ class RealEstateDetail extends Model 'revaluation_percentage', ]; + /** @var list */ + protected $hidden = [ + 'account_id', + 'created_at', + 'updated_at', + ]; + protected function casts(): array { return [ 'property_type' => PropertyType::class, 'purchase_price' => 'integer', - 'purchase_date' => 'date', + 'purchase_date' => 'date:Y-m-d', 'area_value' => 'decimal:2', 'revaluation_percentage' => 'decimal:2', ]; diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index d7aa0dea..dd8d249d 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -54,6 +54,20 @@ class Transaction extends Model 'debtor_name', ]; + /** + * Internal columns that must never reach the frontend (raw bank payloads, + * dedup metadata and the pre-formatting description). + * + * @var list + */ + protected $hidden = [ + 'original_description', + 'external_transaction_id', + 'dedup_fingerprint', + 'raw_data', + 'deleted_at', + ]; + protected function casts(): array { return [ diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index 4ef71da6..fd8c460f 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -533,3 +533,21 @@ test('non-real-estate account balance evolution has no mortgage data', function expect($data['data'][0])->not->toHaveKey('mortgage_balance'); }); + +test('accounts index serializes the standard account field set without sensitive columns', function () { + Account::factory()->create([ + 'user_id' => $this->user->id, + 'iban' => 'ES9121000418450200051332', + ]); + + $response = $this->get(route('accounts.list')); + + $account = $response->viewData('page')['props']['accounts'][0]; + + expect(array_keys($account))->toEqualCanonicalizing([ + 'id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', + 'banking_connection_id', 'external_account_id', 'linked_at', + 'bank', 'linked_loan_account_id', + ]); + expect($account)->not->toHaveKeys(['user_id', 'bank_id', 'iban', 'created_at', 'updated_at', 'deleted_at']); +}); diff --git a/tests/Feature/AutomationRuleTest.php b/tests/Feature/AutomationRuleTest.php index 49c35c7c..3afa7399 100644 --- a/tests/Feature/AutomationRuleTest.php +++ b/tests/Feature/AutomationRuleTest.php @@ -428,3 +428,28 @@ test('updating labels touches automation rule updated_at timestamp', function () $rule->refresh(); expect($rule->updated_at->isAfter($originalUpdatedAt))->toBeTrue(); }); + +test('automation rules serialize full nested category and labels', function () { + $user = User::factory()->create(); + $category = Category::factory()->create(['user_id' => $user->id]); + $label = Label::factory()->create(['user_id' => $user->id]); + $rule = AutomationRule::factory()->create([ + 'user_id' => $user->id, + 'action_category_id' => $category->id, + ]); + $rule->labels()->attach($label->id); + + $response = $this->actingAs($user)->get(route('automation-rules.index')); + + $serialized = $response->viewData('page')['props']['automationRules'][0]; + + expect(array_keys($serialized))->toContain( + 'id', 'user_id', 'title', 'priority', 'rules_json', + 'action_category_id', 'action_note', 'action_note_iv', + 'category', 'labels', 'created_at', 'updated_at', 'deleted_at', + ); + expect(array_keys($serialized['category'])) + ->toEqualCanonicalizing(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction', 'parent_id']); + expect(array_keys($serialized['labels'][0])) + ->toEqualCanonicalizing(['id', 'user_id', 'name', 'color', 'created_at', 'updated_at', 'deleted_at']); +}); diff --git a/tests/Feature/BudgetTest.php b/tests/Feature/BudgetTest.php index eeaa2805..0097e761 100644 --- a/tests/Feature/BudgetTest.php +++ b/tests/Feature/BudgetTest.php @@ -406,3 +406,17 @@ test('creating a budget rejects categories owned by another user', function () { $response->assertSessionHasErrors('category_ids.0'); expect(Budget::where('user_id', $user->id)->count())->toBe(0); }); + +test('budget index hides period_duration and category pivot', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + $budget = Budget::factory()->create(['user_id' => $user->id]); + $category = Category::factory()->create(['user_id' => $user->id]); + $budget->categories()->attach($category->id); + + $response = $this->actingAs($user)->get(route('budgets.index')); + + $budgetData = $response->viewData('page')['props']['budgets'][0]; + + expect($budgetData)->not->toHaveKey('period_duration'); + expect($budgetData['categories'][0])->not->toHaveKey('pivot'); +}); diff --git a/tests/Feature/LabelTest.php b/tests/Feature/LabelTest.php index 01e64ef5..94b4155e 100644 --- a/tests/Feature/LabelTest.php +++ b/tests/Feature/LabelTest.php @@ -199,3 +199,15 @@ test('creating a label with the same name as a deleted label creates a new one', $this->assertSoftDeleted('labels', ['id' => $originalId]); }); + +test('labels index returns the standard label field set', function () { + $user = User::factory()->create(); + Label::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->withoutVite()->get(route('labels.index')); + + $props = $response->viewData('page')['props']; + + expect(array_keys($props['labels'][0])) + ->toEqualCanonicalizing(['id', 'user_id', 'name', 'color', 'created_at', 'updated_at', 'deleted_at']); +}); diff --git a/tests/Feature/Settings/BankTest.php b/tests/Feature/Settings/BankTest.php index 7e9975c7..fd3ed2d6 100644 --- a/tests/Feature/Settings/BankTest.php +++ b/tests/Feature/Settings/BankTest.php @@ -162,6 +162,19 @@ it('returns all matching banks without truncating search results', function () { ->toHaveCount(25); }); +it('serializes banks with a standard field set and no timestamps', function () { + actingAs($this->user); + + Bank::factory()->create(['name' => 'ING', 'user_id' => null]); + + $response = $this->getJson(route('banks.index')); + + $response->assertSuccessful(); + + expect(array_keys($response->json('data.0'))) + ->toEqualCanonicalizing(['id', 'name', 'logo', 'user_id']); +}); + it('includes matching user banks in search results', function () { actingAs($this->user); diff --git a/tests/Feature/Settings/CategoryTest.php b/tests/Feature/Settings/CategoryTest.php index 80c3190b..29053966 100644 --- a/tests/Feature/Settings/CategoryTest.php +++ b/tests/Feature/Settings/CategoryTest.php @@ -663,3 +663,15 @@ test('category names are unique per user', function () { 'type' => 'expense', ]); }); + +test('categories index returns the standard category field set without internal columns', function () { + $user = User::factory()->create(); + Category::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->withoutVite()->get(route('categories.index')); + + $props = $response->viewData('page')['props']; + + expect(array_keys($props['categories'][0])) + ->toEqualCanonicalizing(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction', 'parent_id']); +}); diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index 701793be..8a87196a 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -922,3 +922,29 @@ test('guests are redirected from categorize page', function () { $response->assertRedirect(route('register')); }); + +test('transactions index hides internal columns and keeps the standard field set', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'raw_data' => ['foo' => 'bar'], + 'dedup_fingerprint' => 'fingerprint', + 'external_transaction_id' => 'ext-1', + 'original_description' => 'ORIGINAL', + ]); + + $response = actingAs($user)->get(route('transactions.index')); + + $tx = $response->viewData('page')['props']['transactions']['data'][0]; + + expect(array_keys($tx))->toContain( + 'id', 'user_id', 'account_id', 'category_id', 'description', 'description_iv', + 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv', + 'source', 'creditor_name', 'debtor_name', 'created_at', 'updated_at', 'account', 'labels', + ); + expect($tx)->not->toHaveKeys([ + 'raw_data', 'dedup_fingerprint', 'external_transaction_id', 'original_description', 'deleted_at', + ]); +});