feat(transactions): allow editing all fields of manual transactions
Manually created transactions can now edit every field (account, date, amount, description) after creation, not just category/notes/labels. Imported/bank transactions keep amount, date, account, currency and description locked to their source data. - UpdateTransactionRequest validates amount/date/account/currency only for manually_created transactions, so the fields are dropped for imported ones. - On edit, the manual account balance is moved to match a changed amount/date/account, opt-in via the same update_balance flag as create/delete; connected accounts are skipped. ManualBalanceAdjuster gains a shared adjust() primitive and reverseCreatedTransaction(). - The edit dialog unifies create/edit editability behind canEditAllFields and shows the account/date/amount inputs (and the update-balance checkbox) when editing a manual transaction.
This commit is contained in:
parent
c7719fe5ad
commit
148aee0b63
|
|
@ -213,7 +213,7 @@ class TransactionController extends Controller
|
|||
], 201);
|
||||
}
|
||||
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $transaction);
|
||||
|
||||
|
|
@ -238,6 +238,10 @@ class TransactionController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
// Snapshot the pre-edit account/date/amount before filling, so a manual
|
||||
// account balance can be moved off the old values if the edit changes them.
|
||||
$originalSnapshot = clone $transaction;
|
||||
|
||||
// Update attributes directly without firing events yet
|
||||
if (! empty($data)) {
|
||||
$transaction->fill($data);
|
||||
|
|
@ -260,6 +264,18 @@ class TransactionController extends Controller
|
|||
$transaction->save();
|
||||
}
|
||||
|
||||
// Move the manual account balance to match an edited amount/date/account.
|
||||
// ponytail: like create/delete, this trusts the opt-in flag and nudges a
|
||||
// single dated snapshot — it does not cascade to later snapshots and keeps
|
||||
// no record of whether creation adjusted the balance. So it is exact for
|
||||
// the common case (recent transaction, flag used consistently) and can
|
||||
// drift otherwise; upgrade to a transaction-derived balance if that drift
|
||||
// ever matters. Connected accounts are skipped inside the adjuster.
|
||||
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
|
||||
$balanceAdjuster->reverseCreatedTransaction($originalSnapshot);
|
||||
$balanceAdjuster->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh()->load('labels'),
|
||||
'learned_rule' => $learnedRule === null ? null : [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTransactionRequest extends FormRequest
|
||||
|
|
@ -16,7 +18,7 @@ class UpdateTransactionRequest extends FormRequest
|
|||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$rules = [
|
||||
'category_id' => ['nullable', $this->userOwned('categories')],
|
||||
'description' => ['sometimes', 'string'],
|
||||
'description_iv' => ['nullable', 'string', 'size:16'],
|
||||
|
|
@ -27,6 +29,20 @@ class UpdateTransactionRequest extends FormRequest
|
|||
'label_ids' => ['nullable', 'array'],
|
||||
'label_ids.*' => ['required', 'string', 'uuid', $this->userOwned('labels')],
|
||||
];
|
||||
|
||||
// Manually created transactions can edit every field after creation.
|
||||
// Imported ones keep amount, date, account and currency locked to the
|
||||
// source data, so those keys are only validated (and thus persisted)
|
||||
// for manual transactions.
|
||||
$transaction = $this->route('transaction');
|
||||
if ($transaction instanceof Transaction && $transaction->source === TransactionSource::ManuallyCreated) {
|
||||
$rules['account_id'] = ['sometimes', $this->userOwned('accounts')];
|
||||
$rules['transaction_date'] = ['sometimes', 'date'];
|
||||
$rules['amount'] = ['sometimes', 'integer'];
|
||||
$rules['currency_code'] = ['sometimes', 'string', 'size:3'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
|
|
|
|||
|
|
@ -17,28 +17,7 @@ class ManualBalanceAdjuster
|
|||
*/
|
||||
public function reverseDeletedTransaction(Transaction $transaction): void
|
||||
{
|
||||
$account = $transaction->account;
|
||||
|
||||
if ($account === null || $account->isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$currentBalance = $account->balances()
|
||||
->where('balance_date', '<=', $today)
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $today,
|
||||
],
|
||||
[
|
||||
'balance' => $currentBalance - $transaction->amount,
|
||||
],
|
||||
);
|
||||
$this->adjust($transaction, Carbon::now()->toDateString(), -$transaction->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +29,25 @@ class ManualBalanceAdjuster
|
|||
* skipped because their balances come from bank sync.
|
||||
*/
|
||||
public function applyCreatedTransaction(Transaction $transaction): void
|
||||
{
|
||||
$this->adjust($transaction, $transaction->transaction_date->toDateString(), $transaction->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a transaction's effect on its manual account's balance on the
|
||||
* transaction's own date. Pair with applyCreatedTransaction to move the
|
||||
* balance when an existing manual transaction is edited.
|
||||
*/
|
||||
public function reverseCreatedTransaction(Transaction $transaction): void
|
||||
{
|
||||
$this->adjust($transaction, $transaction->transaction_date->toDateString(), -$transaction->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the manual account's stored balance on a given date by a delta.
|
||||
* Connected accounts are skipped because their balances come from bank sync.
|
||||
*/
|
||||
private function adjust(Transaction $transaction, string $balanceDate, int $delta): void
|
||||
{
|
||||
$account = $transaction->account;
|
||||
|
||||
|
|
@ -57,20 +55,18 @@ class ManualBalanceAdjuster
|
|||
return;
|
||||
}
|
||||
|
||||
$transactionDate = $transaction->transaction_date->toDateString();
|
||||
|
||||
$baseBalance = $account->balances()
|
||||
->where('balance_date', '<=', $transactionDate)
|
||||
->where('balance_date', '<=', $balanceDate)
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $transactionDate,
|
||||
'balance_date' => $balanceDate,
|
||||
],
|
||||
[
|
||||
'balance' => $baseBalance + $transaction->amount,
|
||||
'balance' => $baseBalance + $delta,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1745,6 +1745,7 @@
|
|||
"Update the owed amount record.": "Actualiza el registro de monto adeudado.",
|
||||
"Update the rule to automatically categorize transactions and add labels.": "Actualiza la regla para categorizar automáticamente transacciones y agregar etiquetas.",
|
||||
"Update the rule to automatically categorize transactions.": "Actualiza la regla para categorizar transacciones automáticamente.",
|
||||
"Update this transaction.": "Actualiza esta transacción.",
|
||||
"Update your account's appearance settings": "Actualiza la configuración de apariencia de tu cuenta",
|
||||
"Update your budget settings. To change the allocated amount or tracking, use the budget page directly.": "Actualiza la configuración de tu presupuesto. Para cambiar el valor asignado o el seguimiento, usa la página de presupuesto directamente.",
|
||||
"Update your budget settings. To change the allocated\\n amount or tracking, use the budget page directly.": "Actualiza la configuración de tu presupuesto. Para cambiar el monto asignado o el seguimiento, usa la página de presupuesto directamente.",
|
||||
|
|
|
|||
|
|
@ -229,6 +229,93 @@ describe('EditTransactionDialog', () => {
|
|||
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||
});
|
||||
|
||||
it('lets you edit every field of a manually created transaction', () => {
|
||||
render(
|
||||
<EditTransactionDialog
|
||||
transaction={{
|
||||
id: 'tx-manual',
|
||||
user_id: 'user-1',
|
||||
account_id: 'account-1',
|
||||
category_id: null,
|
||||
description: 'Groceries',
|
||||
description_iv: null,
|
||||
transaction_date: '2026-05-27',
|
||||
amount: -1200,
|
||||
currency_code: 'EUR',
|
||||
notes: null,
|
||||
notes_iv: null,
|
||||
creditor_name: null,
|
||||
debtor_name: null,
|
||||
source: 'manually_created',
|
||||
created_at: '2026-05-27T00:00:00Z',
|
||||
updated_at: '2026-05-27T00:00:00Z',
|
||||
decryptedDescription: 'Groceries',
|
||||
decryptedNotes: null,
|
||||
label_ids: [],
|
||||
}}
|
||||
categories={[]}
|
||||
accounts={[checkingAccount]}
|
||||
banks={[]}
|
||||
labels={[]}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
mode="edit"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Amount, date and description render as editable inputs, not read-only text.
|
||||
expect(screen.getByPlaceholderText('25.00')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Transaction description'),
|
||||
).toBeInTheDocument();
|
||||
expect(document.querySelector('input[type="date"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps amount and description read-only for an imported transaction', () => {
|
||||
render(
|
||||
<EditTransactionDialog
|
||||
transaction={{
|
||||
id: 'tx-imported',
|
||||
user_id: 'user-1',
|
||||
account_id: 'account-1',
|
||||
category_id: null,
|
||||
description: 'Card payment',
|
||||
description_iv: null,
|
||||
transaction_date: '2026-05-27',
|
||||
amount: -1200,
|
||||
currency_code: 'EUR',
|
||||
notes: null,
|
||||
notes_iv: null,
|
||||
creditor_name: null,
|
||||
debtor_name: null,
|
||||
source: 'imported',
|
||||
created_at: '2026-05-27T00:00:00Z',
|
||||
updated_at: '2026-05-27T00:00:00Z',
|
||||
decryptedDescription: 'Card payment',
|
||||
decryptedNotes: null,
|
||||
label_ids: [],
|
||||
}}
|
||||
categories={[]}
|
||||
accounts={[checkingAccount]}
|
||||
banks={[]}
|
||||
labels={[]}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
mode="edit"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByPlaceholderText('25.00')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Transaction description'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('input[type="date"]'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "update account balance" for a connected account', () => {
|
||||
const connectedAccount = {
|
||||
...checkingAccount,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ export function EditTransactionDialog({
|
|||
return true;
|
||||
});
|
||||
|
||||
// Manually created transactions can edit every field (account, date, amount,
|
||||
// description) both on creation and afterwards. Imported ones keep those locked.
|
||||
const canEditAllFields =
|
||||
mode === 'create' || transaction?.source === 'manually_created';
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && transaction) {
|
||||
setTransactionDate(transaction.transaction_date);
|
||||
|
|
@ -135,7 +140,7 @@ export function EditTransactionDialog({
|
|||
}, [mode, transaction, open, accounts, initialAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== 'create') return;
|
||||
if (!open || !canEditAllFields) return;
|
||||
|
||||
async function decryptAccountNames() {
|
||||
const keyString = getStoredKey();
|
||||
|
|
@ -185,7 +190,7 @@ export function EditTransactionDialog({
|
|||
}
|
||||
|
||||
decryptAccountNames();
|
||||
}, [open, mode, accounts]);
|
||||
}, [open, canEditAllFields, accounts]);
|
||||
|
||||
async function checkAndApplyAutomationRules() {
|
||||
if (mode !== 'create' || automationRules.length === 0) {
|
||||
|
|
@ -273,7 +278,7 @@ export function EditTransactionDialog({
|
|||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (mode === 'create') {
|
||||
if (canEditAllFields) {
|
||||
if (!description.trim()) {
|
||||
toast.error(__('Description is required'));
|
||||
return;
|
||||
|
|
@ -290,14 +295,6 @@ export function EditTransactionDialog({
|
|||
toast.error(__('Date is required'));
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
mode === 'edit' &&
|
||||
transaction?.source === 'manually_created'
|
||||
) {
|
||||
if (!description.trim()) {
|
||||
toast.error(__('Description is required'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -423,6 +420,10 @@ export function EditTransactionDialog({
|
|||
description?: string;
|
||||
description_iv?: string | null;
|
||||
label_ids?: string[];
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
account_id?: string;
|
||||
currency_code?: string;
|
||||
} = {
|
||||
category_id: selectedCategoryId,
|
||||
notes: encryptedNotes,
|
||||
|
|
@ -433,18 +434,34 @@ export function EditTransactionDialog({
|
|||
let finalDecryptedDescription =
|
||||
transaction.decryptedDescription;
|
||||
|
||||
if (
|
||||
transaction.source === 'manually_created' &&
|
||||
trimmedDescription
|
||||
) {
|
||||
const editedAccount = accounts.find(
|
||||
(acc) => acc.id === accountId,
|
||||
);
|
||||
const editedCurrencyCode =
|
||||
editedAccount?.currency_code ?? transaction.currency_code;
|
||||
|
||||
if (canEditAllFields) {
|
||||
updateData.description = trimmedDescription;
|
||||
updateData.description_iv = null;
|
||||
finalDecryptedDescription = trimmedDescription;
|
||||
updateData.amount = amount;
|
||||
updateData.transaction_date = transactionDate;
|
||||
updateData.account_id = accountId;
|
||||
updateData.currency_code = editedCurrencyCode;
|
||||
}
|
||||
|
||||
const result = await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
{
|
||||
// Gate on the transaction being manual, not on the target
|
||||
// account: the backend adjuster skips connected accounts
|
||||
// per-account, so this still reverses the old manual
|
||||
// account when the edit moves it onto a connected one.
|
||||
updateBalance: canEditAllFields
|
||||
? updateAccountBalance
|
||||
: false,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedRecord = await transactionSyncService.getById(
|
||||
|
|
@ -476,6 +493,20 @@ export function EditTransactionDialog({
|
|||
labels: selectedLabels,
|
||||
updated_at:
|
||||
updatedRecord?.updated_at ?? transaction.updated_at,
|
||||
...(canEditAllFields
|
||||
? {
|
||||
amount,
|
||||
transaction_date: transactionDate,
|
||||
account_id: accountId,
|
||||
currency_code: editedCurrencyCode,
|
||||
account: editedAccount ?? transaction.account,
|
||||
bank: editedAccount?.bank?.id
|
||||
? banks.find(
|
||||
(b) => b.id === editedAccount.bank?.id,
|
||||
)
|
||||
: transaction.bank,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
toast.success(__('Transaction updated successfully'));
|
||||
|
|
@ -549,15 +580,17 @@ export function EditTransactionDialog({
|
|||
<DialogDescription>
|
||||
{mode === 'create'
|
||||
? __('Create a new transaction.')
|
||||
: __(
|
||||
'Update the category and notes for this transaction.',
|
||||
)}
|
||||
: canEditAllFields
|
||||
? __('Update this transaction.')
|
||||
: __(
|
||||
'Update the category and notes for this transaction.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{mode === 'create' && (
|
||||
{canEditAllFields && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="account">
|
||||
{__('Account')}
|
||||
|
|
@ -597,14 +630,14 @@ export function EditTransactionDialog({
|
|||
<FormLabel
|
||||
htmlFor="date"
|
||||
className={
|
||||
mode === 'edit'
|
||||
? 'text-sm text-muted-foreground'
|
||||
: ''
|
||||
canEditAllFields
|
||||
? ''
|
||||
: 'text-sm text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{__('Date')}
|
||||
</FormLabel>
|
||||
{mode === 'create' ? (
|
||||
{canEditAllFields ? (
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
|
|
@ -652,17 +685,14 @@ export function EditTransactionDialog({
|
|||
<FormLabel
|
||||
htmlFor="description"
|
||||
className={
|
||||
mode === 'edit' &&
|
||||
transaction?.source === 'imported'
|
||||
? 'text-sm text-muted-foreground'
|
||||
: ''
|
||||
canEditAllFields
|
||||
? ''
|
||||
: 'text-sm text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{__('Description')}
|
||||
</FormLabel>
|
||||
{mode === 'create' ||
|
||||
(mode === 'edit' &&
|
||||
transaction?.source === 'manually_created') ? (
|
||||
{canEditAllFields ? (
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
|
|
@ -736,14 +766,14 @@ export function EditTransactionDialog({
|
|||
<FormLabel
|
||||
htmlFor="amount"
|
||||
className={
|
||||
mode === 'edit'
|
||||
? 'text-sm text-muted-foreground'
|
||||
: ''
|
||||
canEditAllFields
|
||||
? ''
|
||||
: 'text-sm text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{__('Amount')}
|
||||
</FormLabel>
|
||||
{mode === 'create' ? (
|
||||
{canEditAllFields ? (
|
||||
<>
|
||||
<AmountInput
|
||||
id="amount"
|
||||
|
|
|
|||
|
|
@ -102,12 +102,14 @@ class TransactionSyncService {
|
|||
async update(
|
||||
id: string,
|
||||
data: TransactionUpdateData,
|
||||
options?: { updateBalance?: boolean },
|
||||
): Promise<UpdatedTransaction> {
|
||||
const { label_ids, ...transactionData } = data;
|
||||
|
||||
const response = await axios.patch(`/transactions/${id}`, {
|
||||
...transactionData,
|
||||
label_ids,
|
||||
...(options?.updateBalance ? { update_balance: true } : {}),
|
||||
});
|
||||
|
||||
const serverData = response.data.data || response.data;
|
||||
|
|
|
|||
|
|
@ -202,6 +202,66 @@ test('notes_iv must be exactly 16 characters', function () {
|
|||
$response->assertJsonValidationErrors(['notes_iv']);
|
||||
});
|
||||
|
||||
test('users can edit the amount, date and account of a manually created transaction', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id, 'currency_code' => 'USD']);
|
||||
$otherAccount = Account::factory()->create(['user_id' => $user->id, 'currency_code' => 'EUR']);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2026-01-01',
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'description' => 'updated_description',
|
||||
'amount' => 9900,
|
||||
'transaction_date' => '2026-02-15',
|
||||
'account_id' => $otherAccount->id,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'description' => 'updated_description',
|
||||
'amount' => 9900,
|
||||
'transaction_date' => '2026-02-15',
|
||||
'account_id' => $otherAccount->id,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
});
|
||||
|
||||
test('users cannot edit the amount, date or account of an imported transaction', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
$otherAccount = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->imported()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2026-01-01',
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'amount' => 9900,
|
||||
'transaction_date' => '2026-02-15',
|
||||
'account_id' => $otherAccount->id,
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2026-01-01',
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('users can soft delete their own transactions', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
|
@ -485,6 +545,154 @@ test('creating a connected account transaction never changes the balance', funct
|
|||
]);
|
||||
});
|
||||
|
||||
test('editing a manual transaction amount moves the balance by the delta when requested', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
// Balance already reflects the transaction's original 2500 amount (100000 base + 2500).
|
||||
$account->balances()->create([
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 102500,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2025-11-11',
|
||||
'source' => 'manually_created',
|
||||
]);
|
||||
|
||||
actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'amount' => 5000,
|
||||
'update_balance' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 105000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('editing a manual transaction amount does not change the balance when not requested', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$account->balances()->create([
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 102500,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2025-11-11',
|
||||
'source' => 'manually_created',
|
||||
]);
|
||||
|
||||
actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'amount' => 5000,
|
||||
])->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 102500,
|
||||
]);
|
||||
});
|
||||
|
||||
test('moving a manual transaction between accounts reverses the old balance and credits the new one', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$fromAccount = Account::factory()->create(['user_id' => $user->id]);
|
||||
$toAccount = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
// Origin balance embeds the transaction's 2500; destination starts flat.
|
||||
$fromAccount->balances()->create(['balance_date' => '2025-11-11', 'balance' => 102500]);
|
||||
$toAccount->balances()->create(['balance_date' => '2025-11-11', 'balance' => 50000]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $fromAccount->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2025-11-11',
|
||||
'source' => 'manually_created',
|
||||
]);
|
||||
|
||||
actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'account_id' => $toAccount->id,
|
||||
'update_balance' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $fromAccount->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 100000,
|
||||
]);
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $toAccount->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 52500,
|
||||
]);
|
||||
});
|
||||
|
||||
test('editing only the currency of a manual transaction does not change the balance', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$account->balances()->create(['balance_date' => '2025-11-11', 'balance' => 102500]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2025-11-11',
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
]);
|
||||
|
||||
actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'currency_code' => 'EUR',
|
||||
'update_balance' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 102500,
|
||||
]);
|
||||
});
|
||||
|
||||
test('editing a connected account transaction never changes the balance', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->connected()->create(['user_id' => $user->id]);
|
||||
|
||||
$account->balances()->create([
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'amount' => 2500,
|
||||
'transaction_date' => '2025-11-11',
|
||||
'source' => 'manually_created',
|
||||
]);
|
||||
|
||||
actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'amount' => 5000,
|
||||
'update_balance' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 100000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('transactions index page passes user categories', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
|
|
|||
Loading…
Reference in New Issue