refactor(api): standardize serialization via model $hidden (#492)
## 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.
This commit is contained in:
parent
45e25e018d
commit
e62f1d6aac
|
|
@ -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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string> */
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'bank_id',
|
||||
'iban',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
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<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ class Bank extends Model
|
|||
'user_id',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Scope to banks visible to the given user: public banks plus their own.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ class Budget extends Model
|
|||
'rollover_type',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'period_duration',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
*/
|
||||
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<string> */
|
||||
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<Category> $query
|
||||
* @return Builder<Category>
|
||||
*/
|
||||
public function scopeForDisplay(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('name')->select(self::FRONTEND_COLUMNS);
|
||||
return $query->orderBy('name');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'pivot',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ class LoanDetail extends Model
|
|||
'original_amount',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'account_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -34,12 +34,19 @@ class RealEstateDetail extends Model
|
|||
'revaluation_percentage',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'original_description',
|
||||
'external_transaction_id',
|
||||
'dedup_fingerprint',
|
||||
'raw_data',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue