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:
Víctor Falcón 2026-06-05 13:57:34 +02:00 committed by GitHub
parent 45e25e018d
commit e62f1d6aac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 321 additions and 166 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

@ -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
{

View File

@ -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.
*

View File

@ -28,6 +28,11 @@ class Budget extends Model
'rollover_type',
];
/** @var list<string> */
protected $hidden = [
'period_duration',
];
protected function casts(): array
{
return [

View File

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

View File

@ -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
{

View File

@ -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 [

View File

@ -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',
];

View File

@ -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 [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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