Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.92% |
126 / 130 |
|
85.71% |
12 / 14 |
CRAP | |
0.00% |
0 / 1 |
| AccountingService | |
96.92% |
126 / 130 |
|
85.71% |
12 / 14 |
36 | |
0.00% |
0 / 1 |
| postIncome | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| postExpense | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| post | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
| transfer | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| reverse | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
| alreadyPosted | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| balance | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| ledger | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| rowsFor | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| defaultAccount | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| resolveCategory | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| autoPostOrderPayment | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
| autoPostPurchasePayment | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
7.06 | |||
| autoPostRefund | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
4.03 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services\Accounting; |
| 4 | |
| 5 | use App\Models\Account; |
| 6 | use App\Models\AccountCategory; |
| 7 | use App\Models\admin\Purchase; |
| 8 | use App\Models\admin\PurchasePayment; |
| 9 | use App\Models\Order; |
| 10 | use App\Models\Payment; |
| 11 | use App\Models\Transaction; |
| 12 | use Illuminate\Support\Collection; |
| 13 | use 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 | */ |
| 22 | class 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 | } |