Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.78% covered (danger)
34.78%
16 / 46
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
TransactionController
34.78% covered (danger)
34.78%
16 / 46
40.00% covered (danger)
40.00%
4 / 10
44.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 incomeIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 expenseIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 typeIndex
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 incomeStore
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 expenseStore
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validatedEntry
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 transferStore
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 reverse
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 toggleReconciled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Http\Controllers\admin;
4
5use App\Http\Controllers\Controller;
6use App\Models\AccountCategory;
7use App\Models\Transaction;
8use App\Services\Accounting\AccountingService;
9use App\Services\Location\BranchContext;
10use Illuminate\Http\RedirectResponse;
11use Illuminate\Http\Request;
12use Illuminate\View\View;
13
14/**
15 * Settings → Accounting → Income / Expense / Transfer. Quick-add entry
16 * screens over App\Services\Accounting\AccountingService — every row here
17 * is a manual entry (source = 'manual'); auto-posted rows from orders and
18 * purchases never go through here, only appear in the CashBook/BankBook.
19 */
20class TransactionController extends Controller
21{
22    public function __construct(
23        private readonly AccountingService $accounting,
24        private readonly BranchContext $branchContext,
25    ) {}
26
27    public function incomeIndex(): View
28    {
29        return $this->typeIndex('income');
30    }
31
32    public function expenseIndex(): View
33    {
34        return $this->typeIndex('expense');
35    }
36
37    private function typeIndex(string $type): View
38    {
39        $transactions = Transaction::query()->where('type', $type)
40            ->when($this->branchContext->id(), fn ($query, $branchId) => $query->whereHas('account', fn ($account) => $account->where('branch_id', $branchId)))
41            ->with('account', 'category')
42            ->latest('date')->latest('id')->paginate(20);
43
44        return view("admin.accounting.transactions.{$type}", [
45            'transactions' => $transactions,
46            'accounts' => $this->branchContext->accounts()->where('is_active', true)->orderBy('name')->get(),
47            'categories' => AccountCategory::where('type', $type)->where('is_active', true)->orderBy('name')->get(),
48        ]);
49    }
50
51    public function incomeStore(Request $request): RedirectResponse
52    {
53        $this->accounting->postIncome($this->validatedEntry($request));
54
55        return redirect()->route('admin.transactions.income.index')->with('success', 'Income recorded.');
56    }
57
58    public function expenseStore(Request $request): RedirectResponse
59    {
60        $this->accounting->postExpense($this->validatedEntry($request));
61
62        return redirect()->route('admin.transactions.expense.index')->with('success', 'Expense recorded.');
63    }
64
65    private function validatedEntry(Request $request): array
66    {
67        $data = $request->validate([
68            'date' => ['required', 'date'],
69            'account_id' => ['required', 'exists:accounts,id'],
70            'category_id' => ['nullable', 'exists:account_categories,id'],
71            'amount' => ['required', 'numeric', 'min:0.01'],
72            'party_name' => ['nullable', 'string', 'max:150'],
73            'ref' => ['nullable', 'string', 'max:100'],
74            'note' => ['nullable', 'string', 'max:500'],
75        ]);
76
77        $this->branchContext->ensureAccount((int) $data['account_id']);
78
79        return $data;
80    }
81
82    public function transferStore(Request $request): RedirectResponse
83    {
84        $data = $request->validate([
85            'date' => ['required', 'date'],
86            'from_account_id' => ['required', 'exists:accounts,id', 'different:to_account_id'],
87            'to_account_id' => ['required', 'exists:accounts,id'],
88            'amount' => ['required', 'numeric', 'min:0.01'],
89            'note' => ['nullable', 'string', 'max:500'],
90        ]);
91
92        $this->branchContext->ensureAccount((int) $data['from_account_id'], 'from_account_id');
93        $this->branchContext->ensureAccount((int) $data['to_account_id'], 'to_account_id');
94
95        $this->accounting->transfer($data);
96
97        return back()->with('success', 'Transfer recorded.');
98    }
99
100    public function reverse(Transaction $transaction): RedirectResponse
101    {
102        $this->branchContext->ensureAccount($transaction->account_id);
103        try {
104            $this->accounting->reverse($transaction);
105        } catch (\RuntimeException $e) {
106            return back()->with('error', $e->getMessage());
107        }
108
109        return back()->with('success', 'Transaction reversed.');
110    }
111
112    public function toggleReconciled(Transaction $transaction): RedirectResponse
113    {
114        $this->branchContext->ensureAccount($transaction->account_id);
115        $transaction->update(['is_reconciled' => ! $transaction->is_reconciled]);
116
117        return back();
118    }
119}