Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.92% covered (success)
96.92%
126 / 130
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccountingService
96.92% covered (success)
96.92%
126 / 130
85.71% covered (warning)
85.71%
12 / 14
36
0.00% covered (danger)
0.00%
0 / 1
 postIncome
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postExpense
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 transfer
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 reverse
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 alreadyPosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 balance
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 ledger
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 rowsFor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 defaultAccount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 resolveCategory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 autoPostOrderPayment
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 autoPostPurchasePayment
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
 autoPostRefund
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3namespace App\Services\Accounting;
4
5use App\Models\Account;
6use App\Models\AccountCategory;
7use App\Models\admin\Purchase;
8use App\Models\admin\PurchasePayment;
9use App\Models\Order;
10use App\Models\Payment;
11use App\Models\Transaction;
12use Illuminate\Support\Collection;
13use Illuminate\Support\Facades\Auth;
14
15/**
16 * The whole Accounting module's write + read path: post income/expense,
17 * transfer between accounts, reverse a mistake (never edit/delete a posted
18 * row), and compute balances/ledgers. Auto-postings from orders/purchases
19 * call postIncome()/postExpense() too — see alreadyPosted() for the
20 * idempotency guard every auto-post hook uses before calling in.
21 */
22class AccountingService
23{
24    public function postIncome(array $data): Transaction
25    {
26        return $this->post('income', $data);
27    }
28
29    public function postExpense(array $data): Transaction
30    {
31        return $this->post('expense', $data);
32    }
33
34    private function post(string $type, array $data): Transaction
35    {
36        return Transaction::create([
37            'date' => $data['date'] ?? now()->toDateString(),
38            'account_id' => $data['account_id'],
39            'category_id' => $data['category_id'] ?? null,
40            'type' => $type,
41            'amount' => (float) $data['amount'],
42            'ref' => $data['ref'] ?? null,
43            'note' => $data['note'] ?? null,
44            'party_type' => $data['party_type'] ?? null,
45            'party_id' => $data['party_id'] ?? null,
46            'party_name' => $data['party_name'] ?? null,
47            'source' => $data['source'] ?? 'manual',
48            'source_id' => $data['source_id'] ?? null,
49            'created_by' => $data['created_by'] ?? Auth::id(),
50        ]);
51    }
52
53    /** Move money from one account to another as a single ledger event. */
54    public function transfer(array $data): Transaction
55    {
56        return Transaction::create([
57            'date' => $data['date'] ?? now()->toDateString(),
58            'account_id' => $data['from_account_id'],
59            'to_account_id' => $data['to_account_id'],
60            'type' => 'transfer',
61            'amount' => (float) $data['amount'],
62            'note' => $data['note'] ?? null,
63            'created_by' => $data['created_by'] ?? Auth::id(),
64        ]);
65    }
66
67    /**
68     * A correction that doesn't touch the original row: a new transaction
69     * with the opposite sign, on the same account/category, linked back via
70     * reverses_transaction_id. The original is flagged reversed_at (still
71     * readable, just superseded) rather than edited or deleted.
72     */
73    public function reverse(Transaction $transaction, ?string $note = null): Transaction
74    {
75        if ($transaction->isReversed()) {
76            throw new \RuntimeException('This transaction has already been reversed.');
77        }
78
79        $opposite = $transaction->type === 'income' ? 'expense' : ($transaction->type === 'expense' ? 'income' : 'transfer');
80
81        $reversal = Transaction::create([
82            'date' => now()->toDateString(),
83            'account_id' => $transaction->type === 'transfer' ? $transaction->to_account_id : $transaction->account_id,
84            'to_account_id' => $transaction->type === 'transfer' ? $transaction->account_id : null,
85            'category_id' => $transaction->category_id,
86            'type' => $opposite,
87            'amount' => $transaction->amount,
88            'ref' => $transaction->ref,
89            'note' => $note ?? "Reversal of #{$transaction->id}",
90            'party_type' => $transaction->party_type,
91            'party_id' => $transaction->party_id,
92            'party_name' => $transaction->party_name,
93            'source' => $transaction->source,
94            'source_id' => $transaction->source_id,
95            'reverses_transaction_id' => $transaction->id,
96            'created_by' => Auth::id(),
97        ]);
98
99        $transaction->update(['reversed_at' => now()]);
100
101        return $reversal;
102    }
103
104    /** Idempotency guard for every auto-post hook — never double-post the same source event. */
105    public function alreadyPosted(string $source, int $sourceId): bool
106    {
107        return Transaction::where('source', $source)->where('source_id', $sourceId)->exists();
108    }
109
110    /** Opening balance + every signed transaction up to (and including) $asOf. */
111    public function balance(Account $account, ?\DateTimeInterface $asOf = null): float
112    {
113        $balance = (float) $account->opening_balance;
114
115        foreach ($this->rowsFor($account, null, $asOf) as $row) {
116            $balance += $row->signedAmountFor($account->id);
117        }
118
119        return round($balance, 2);
120    }
121
122    /**
123     * The CashBook/BankBook screen's data: every row touching this account,
124     * oldest first, each carrying a running balance.
125     *
126     * @return Collection<int, array{transaction: Transaction, balance: float}>
127     */
128    public function ledger(Account $account, ?\DateTimeInterface $from = null, ?\DateTimeInterface $to = null): Collection
129    {
130        $running = $from ? $this->balance($account, (clone $from)->modify('-1 day')) : (float) $account->opening_balance;
131        $rows = $this->rowsFor($account, $from, $to);
132
133        return $rows->map(function (Transaction $t) use (&$running, $account) {
134            $running += $t->signedAmountFor($account->id);
135
136            return ['transaction' => $t, 'balance' => round($running, 2)];
137        });
138    }
139
140    private function rowsFor(Account $account, ?\DateTimeInterface $from, ?\DateTimeInterface $to): Collection
141    {
142        return Transaction::query()
143            ->where(fn ($q) => $q->where('account_id', $account->id)->orWhere('to_account_id', $account->id))
144            ->when($from, fn ($q) => $q->whereDate('date', '>=', $from))
145            ->when($to, fn ($q) => $q->whereDate('date', '<=', $to))
146            ->orderBy('date')->orderBy('id')
147            ->with('category')
148            ->get();
149    }
150
151    /** The account an auto-post (or a quick-add form with nothing picked) falls back to. */
152    public function defaultAccount(?string $type = null, ?int $branchId = null): ?Account
153    {
154        return Account::query()->where('is_active', true)
155            ->when($type, fn ($q) => $q->where('type', $type))
156            ->when(feature('branch_management') && $branchId, fn ($q) => $q->where('branch_id', $branchId))
157            ->orderByDesc('is_default')
158            ->orderBy('id')
159            ->first();
160    }
161
162    /** Auto-post hooks call this instead of hand-rolling a category lookup — "Sales"/"Purchase" get created on first use. */
163    public function resolveCategory(string $type, string $name): AccountCategory
164    {
165        return AccountCategory::firstOrCreate(
166            ['type' => $type, 'name' => $name, 'parent_id' => null],
167            ['is_active' => true]
168        );
169    }
170
171    /* -------------------------------------------------- Auto-postings (§ event hooks)
172     *
173     * Called from PaymentCallbackController, PurchaseController, and
174     * OrderController::refundPayment() — kept here rather than inline in
175     * those controllers so the feature/setting/idempotency/default-account
176     * checks are in one tested place, not copy-pasted three times.
177     */
178
179    /** A gateway payment just went Paid — post it as Income under "Sales". Returns null if skipped (feature/setting off, no default account, nothing paid, or already posted). */
180    public function autoPostOrderPayment(Order $order, Payment $payment): ?Transaction
181    {
182        if (! feature('accounting') || ! settings('accounting.auto_post_orders', true)) {
183            return null;
184        }
185
186        if ($this->alreadyPosted('payment', $payment->id)) {
187            return null;
188        }
189
190        $account = $this->defaultAccount(branchId: $order->branch_id);
191
192        if (! $account || (float) $payment->paid <= 0) {
193            return null;
194        }
195
196        return $this->postIncome([
197            'account_id' => $account->id,
198            'category_id' => $this->resolveCategory('income', 'Sales')->id,
199            'amount' => (float) $payment->paid,
200            'party_type' => 'customer',
201            'party_id' => $order->customer_id,
202            'party_name' => $order->customer->name ?? null,
203            'ref' => $order->bar_code,
204            'note' => 'Order payment via ' . $payment->payment_type,
205            'source' => 'payment',
206            'source_id' => $payment->id,
207        ]);
208    }
209
210    /** A supplier payment was just recorded — post it as Expense under "Purchase". */
211    public function autoPostPurchasePayment(Purchase $purchase, PurchasePayment $payment): ?Transaction
212    {
213        if (! feature('accounting') || ! settings('accounting.auto_post_purchases', true)) {
214            return null;
215        }
216
217        if ($this->alreadyPosted('purchase_payment', $payment->id)) {
218            return null;
219        }
220
221        $account = $payment->account_id ? Account::where('is_active', true)->find($payment->account_id) : $this->defaultAccount(branchId: $purchase->branch_id);
222
223        if (! $account || (float) $payment->paid <= 0) {
224            return null;
225        }
226
227        return $this->postExpense([
228            'account_id' => $account->id,
229            'category_id' => $this->resolveCategory('expense', 'Purchase')->id,
230            'amount' => (float) $payment->paid,
231            'party_type' => 'supplier',
232            'party_id' => $purchase->supplier_id,
233            'party_name' => $purchase->supplier->name ?? null,
234            'ref' => $purchase->ref,
235            'note' => 'Supplier payment for purchase ' . $purchase->ref,
236            'source' => 'purchase_payment',
237            'source_id' => $payment->id,
238        ]);
239    }
240
241    /**
242     * A refund was just processed — the contra entry, an expense under
243     * "Refunds". Not idempotency-guarded like the two hooks above: this
244     * fires once per explicit admin click, and a payment can legitimately
245     * be partially refunded more than once.
246     */
247    public function autoPostRefund(Payment $payment, float $amount): ?Transaction
248    {
249        if (! feature('accounting') || ! settings('accounting.auto_post_orders', true)) {
250            return null;
251        }
252
253        $account = $this->defaultAccount();
254
255        if (! $account) {
256            return null;
257        }
258
259        return $this->postExpense([
260            'account_id' => $account->id,
261            'category_id' => $this->resolveCategory('expense', 'Refunds')->id,
262            'amount' => $amount,
263            'party_type' => 'customer',
264            'party_id' => $payment->order?->customer_id,
265            'ref' => $payment->order?->bar_code,
266            'note' => 'Refund on payment #' . $payment->id,
267            'source' => 'refund',
268            'source_id' => $payment->id,
269        ]);
270    }
271}