feat(transactions): allow editing all fields of manual transactions (#683)
## What & why
Until now only the **category, notes, labels and (for manual
transactions) the description** could be changed after a transaction was
created — the **amount and date were immutable and the account was
create-only**. Users creating transactions by hand had no way to fix a
wrong amount or date.
This lets **manually created transactions edit every field at any time
after creation**: account, date, description and amount, on top of
category/labels/notes. **Imported / bank-synced transactions keep
amount, date, account, currency and description locked** to their source
data.
## Changes
**Backend**
- `UpdateTransactionRequest` now validates `amount`, `transaction_date`,
`account_id` and `currency_code` **only when the transaction's `source`
is `manually_created`**. For imported transactions those keys are not
validated, so `validated()` drops them and they can't be changed even
via a crafted request.
- `TransactionController::update()` moves the manual account balance to
match an edited amount/date/account, **opt-in via the same
`update_balance` flag used by create/delete**. It snapshots the pre-edit
state, and only rebalances when one of amount/date/account actually
changed. Connected accounts are skipped inside the adjuster.
- `ManualBalanceAdjuster` was refactored to a shared private `adjust()`
primitive (removing duplication between the existing create/delete
paths) and gains `reverseCreatedTransaction()` so an edit can reverse
the old contribution and apply the new one.
**Frontend**
- `edit-transaction-dialog.tsx` unifies the create/edit editability
decision behind a single `canEditAllFields` flag and renders the
account, date and amount inputs (plus the "update account balance"
checkbox) when editing a manual transaction.
- `transaction-sync.ts` `update()` gains an `{ updateBalance }` option,
mirroring `create()`.
## Known limitation (by design)
Like create/delete, the balance update trusts the opt-in flag and nudges
a **single dated snapshot** — it doesn't cascade to later snapshots and
keeps no record of whether creation adjusted the balance. So it's exact
for the common case (recent transaction, flag used consistently) and can
drift otherwise. This matches the app's existing snapshot-based balance
model; a transaction-derived balance would be a separate, larger change.
Documented with a `ponytail:` comment at the call site.
## Tests
- **Feature (`tests/Feature/TransactionTest.php`)**: manual transaction
edits amount/date/account/currency; imported transaction cannot; balance
moves by the delta on amount change; no change when not requested;
moving between accounts reverses the old and credits the new;
currency-only edit doesn't rebalance; connected accounts never change.
All green locally (55 passed).
- **Component (`edit-transaction-dialog.test.tsx`)**: manual transaction
shows editable amount/date/description; imported keeps them read-only.
## QA
Real browser QA (Playwright) against the running app with the
`demo@whisper.money` data:
- Opened a manual transaction → edited date, description and amount,
kept "update account balance" checked, saved. Verified in the DB:
`amount -4200 → -7550`, `date 2026-07-10 → 2026-07-12`, description
updated, and the account balance snapshots moved accordingly.
- Opened an imported transaction → amount and description render
read-only (locked).
## Demo
https://github.com/user-attachments/assets/1c8790e8-31f5-4283-b260-353650ad007c
This commit is contained in:
parent
9312d24f93
commit
a873582191
|
|
@ -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:
|
||||
// strip the pre-edit contribution (exactly as a deletion would) and apply
|
||||
// the new one (exactly as a creation would), both cascading forward.
|
||||
// ponytail: like create/delete, this trusts the opt-in flag and keeps no
|
||||
// record of whether creation adjusted the balance, so mixing the flag
|
||||
// across create and edit can drift; a transaction-derived balance would
|
||||
// remove that trust. Connected accounts are skipped inside the adjuster.
|
||||
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
|
||||
$balanceAdjuster->reverseDeletedTransaction($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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
/**
|
||||
* @property Carbon $transaction_date
|
||||
* @property int|float $total_amount
|
||||
* @property TransactionSource $source
|
||||
* @property ?CategorySource $category_source
|
||||
* @property ?float $ai_confidence
|
||||
* @property ?string $categorized_by_rule_id
|
||||
|
|
|
|||
|
|
@ -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,95 @@ 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]);
|
||||
|
|
@ -518,6 +578,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