Accounting::checkAccounts()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 23
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 3.0015

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 23
ccs 17
cts 18
cp 0.9444
rs 9.7
c 0
b 0
f 0
cc 3
nc 2
nop 0
crap 3.0015
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Service;
6
7
use Application\Enum\AccountType;
8
use Application\Model\Account;
9
use Application\Model\Transaction;
10
use Application\Model\TransactionLine;
11
use Application\Model\User;
12
use Application\Repository\AccountRepository;
13
use Application\Repository\TransactionRepository;
14
use Application\Repository\UserRepository;
15
use Cake\Chronos\Chronos;
16
use Cake\Chronos\ChronosDate;
17
use Doctrine\ORM\EntityManager;
18
use Ecodev\Felix\Api\Exception;
19
use Ecodev\Felix\Api\ExceptionWithoutMailLogging;
20
use Ecodev\Felix\Format;
21
use Money\Money;
22
23
/**
24
 * Service to process accounting tasks.
25
 */
26
class Accounting
27
{
28
    private readonly TransactionRepository $transactionRepository;
29
30
    private readonly AccountRepository $accountRepository;
31
32
    private readonly UserRepository $userRepository;
33
34
    private bool $hasError = false;
35
36 1
    public function __construct(private readonly EntityManager $entityManager, private readonly array $accountingConfig)
37
    {
38 1
        $this->transactionRepository = $this->entityManager->getRepository(Transaction::class);
0 ignored issues
show
Bug introduced by
The property transactionRepository is declared read-only in Application\Service\Accounting.
Loading history...
39 1
        $this->accountRepository = $this->entityManager->getRepository(Account::class);
0 ignored issues
show
Bug introduced by
The property accountRepository is declared read-only in Application\Service\Accounting.
Loading history...
40 1
        $this->userRepository = $this->entityManager->getRepository(User::class);
0 ignored issues
show
Bug introduced by
The property userRepository is declared read-only in Application\Service\Accounting.
Loading history...
41
    }
42
43
    /**
44
     * Check and print various things about accounting.
45
     *
46
     * @return bool `true` if errors are found
47
     */
48 1
    public function check(): bool
49
    {
50
        // Update all accounts' balance from transactions
51 1
        $this->accountRepository->updateAccountsBalance();
52
53 1
        $this->checkAccounts();
54 1
        $this->checkTransactionsAreBalanced();
55 1
        $this->checkMissingAccountsForUsers();
56 1
        $this->checkUnnecessaryAccounts();
57 1
        $this->checkFamilyMembersShareSameAccount();
58
59 1
        return $this->hasError;
60
    }
61
62
    /**
63
     * Generate the closing entries at the end of an accounting period.
64
     *
65
     * @param ChronosDate $endDate the end of fiscal period
66
     * @param null|array $output an optional array to output log
67
     *
68
     * @return null|Transaction the closing transaction or null if no entry written
69
     */
70 1
    public function close(ChronosDate $endDate, ?array &$output = null): ?Transaction
71
    {
72 1
        if ($endDate->isFuture()) {
73
            throw new ExceptionWithoutMailLogging('La date du bouclement ne peut pas être dans le futur');
74
        }
75
76
        // We actually generate the closure transaction at the beggining of the next day (ie. Jan 1st)
77
        // so that it is not taken into account by the accounting report for the closing day (ie. Dec 31th)
78 1
        $endDateTime = (new Chronos($endDate))->addDays(1)->startOfDay();
79
80
        /** @var null|Account $closingAccount */
81 1
        $closingAccount = $this->accountRepository->findOneBy(['code' => $this->accountingConfig['closingAccountCode']]);
82 1
        if ($closingAccount === null) {
83
            throw new Exception('Could not find closing account, maybe it is missing in config or in accounts');
84
        }
85
        /** @var null|Account $carryForwardAccount */
86 1
        $carryForwardAccount = $this->accountRepository->findOneBy(['code' => $this->accountingConfig['carryForwardAccountCode']]);
87 1
        if ($carryForwardAccount === null) {
88
            throw new Exception('Could not find carry forward account, maybe it is missing in config or in accounts');
89
        }
90
91 1
        $allAccountsToClose = [
92 1
            'expenses' => $this->accountRepository->findBy(['type' => AccountType::Expense]),
93 1
            'revenues' => $this->accountRepository->findBy(['type' => AccountType::Revenue]),
94 1
        ];
95
96 1
        $closingTransactioName = 'Bouclement au ' . $endDate->format('d.m.Y');
97
98 1
        if (is_array($output)) {
99 1
            $output[] = $closingTransactioName;
100
        }
101
102 1
        $existingClosingTransaction = $this->transactionRepository->findOneBy(['name' => $closingTransactioName, 'transactionDate' => $endDateTime]);
103
104 1
        if ($existingClosingTransaction) {
105 1
            throw new ExceptionWithoutMailLogging('Le bouclement a déjà été fait au ' . $endDate);
106
        }
107
108 1
        $closingTransaction = new Transaction();
109 1
        $closingTransaction->setTransactionDate($endDateTime);
110 1
        $closingTransaction->setInternalRemarks('Écriture générée automatiquement');
111 1
        $closingTransaction->setName($closingTransactioName);
112
113 1
        $this->generateClosingEntries($allAccountsToClose, $closingTransaction, $closingAccount, $endDate);
114
115 1
        $profitOrLoss = $closingAccount->getBalance();
116 1
        if ($profitOrLoss->isZero()) {
117
            if (count($closingTransaction->getTransactionLines())) {
118
                if (is_array($output)) {
119
                    $output[] = 'Résultat équilibré, ni bénéfice, ni déficit: rien à reporter';
120
                }
121
                _em()->flush();
122
123
                return $closingTransaction;
124
            }
125
            if (is_array($output)) {
126
                $output[] = 'Aucun mouvement ou solde des comptes nul depuis le dernier bouclement: rien à reporter';
127
            }
128
129
            return null;
130
        }
131 1
        $carryForward = new TransactionLine();
132 1
        _em()->persist($carryForward);
133 1
        $carryForward->setTransaction($closingTransaction);
134 1
        $carryForward->setBalance($profitOrLoss->absolute());
135 1
        $carryForward->setTransactionDate($closingTransaction->getTransactionDate());
136 1
        if ($profitOrLoss->isPositive()) {
137 1
            if (is_array($output)) {
138 1
                $output[] = 'Bénéfice : ' . Format::money($profitOrLoss);
139
            }
140 1
            $carryForward->setName('Report du bénéfice');
141 1
            $carryForward->setDebit($closingAccount);
142 1
            $carryForward->setCredit($carryForwardAccount);
143
        } elseif ($profitOrLoss->isNegative()) {
144
            if (is_array($output)) {
145
                $output[] = 'Déficit : ' . Format::money($profitOrLoss->absolute());
146
            }
147
            $carryForward->setName('Report du déficit');
148
            $carryForward->setDebit($carryForwardAccount);
149
            $carryForward->setCredit($closingAccount);
150
        }
151
152 1
        _em()->flush();
153
154 1
        return $closingTransaction;
155
    }
156
157 1
    private function generateClosingEntries(array $allAccountsToClose, Transaction $closingTransaction, Account $closingAccount, ChronosDate $endDate): void
158
    {
159 1
        $closingEntries = [];
160 1
        foreach ($allAccountsToClose as $accountType => $accountsToClose) {
161 1
            foreach ($accountsToClose as $account) {
162
                /** @var Money $balance */
163 1
                $balance = $account->getBalanceAtDate($endDate);
164 1
                if ($balance->isZero()) {
165 1
                    continue;
166
                }
167 1
                if ($account->getType() === AccountType::Expense && !in_array(mb_substr((string) $account->getCode(), 0, 1), ['4', '5', '6'], true)) {
168
                    throw new Exception('Account ' . $account->getCode() . ' has an invalid code for an expense account');
169
                }
170 1
                if ($account->getType() === AccountType::Revenue && mb_substr((string) $account->getCode(), 0, 1) !== '3') {
171
                    throw new Exception('Account ' . $account->getCode() . ' has an invalid code for a revenue account');
172
                }
173 1
                $entry = [
174 1
                    'transactionDate' => $closingTransaction->getTransactionDate(),
175 1
                    'name' => 'Bouclement ' . $account->getName(),
176 1
                    'balance' => $balance->absolute(),
177 1
                ];
178 1
                if ($account->getType() === AccountType::Expense) {
179 1
                    if ($balance->isPositive()) {
180 1
                        $entry['credit'] = $account;
181 1
                        $entry['debit'] = $closingAccount;
182
                    } else {
183
                        $entry['credit'] = $closingAccount;
184
                        $entry['debit'] = $account;
185
                    }
186 1
                } elseif ($account->getType() === AccountType::Revenue) {
187 1
                    if ($balance->isPositive()) {
188 1
                        $entry['credit'] = $closingAccount;
189 1
                        $entry['debit'] = $account;
190
                    } else {
191
                        $entry['credit'] = $account;
192
                        $entry['debit'] = $closingAccount;
193
                    }
194
                } else {
195
                    throw new Exception('I don\'t know how to close account ' . $account->getCode() . ' of type ' . $account->getType());
196
                }
197 1
                $closingEntries[] = $entry;
198
            }
199
        }
200 1
        if (count($closingEntries)) {
201 1
            $this->transactionRepository->hydrateLinesAndFlush($closingTransaction, $closingEntries);
202
        }
203
    }
204
205
    /**
206
     * Print the error message and remember that at least one error was found.
207
     */
208
    private function error(string $message): void
209
    {
210
        if (!$this->hasError) {
211
            echo PHP_EOL;
212
        }
213
214
        echo $message . PHP_EOL;
215
        $this->hasError = true;
216
    }
217
218 1
    private function checkAccounts(): void
219
    {
220 1
        $assets = $this->accountRepository->totalBalanceByType(AccountType::Asset);
221 1
        $liabilities = $this->accountRepository->totalBalanceByType(AccountType::Liability);
222 1
        $revenue = $this->accountRepository->totalBalanceByType(AccountType::Revenue);
223 1
        $expense = $this->accountRepository->totalBalanceByType(AccountType::Expense);
224 1
        $equity = $this->accountRepository->totalBalanceByType(AccountType::Equity);
225
226 1
        $income = $revenue->subtract($expense);
227
228 1
        $discrepancy = $assets->subtract($income)->subtract($liabilities->add($equity));
229
230 1
        echo '
231 1
Produits  : ' . Format::money($revenue) . '
232 1
Charges   : ' . Format::money($expense) . '
233 1
' . ($income->isNegative() ? 'Déficit   : ' : 'Bénéfice  : ') . Format::money($income) . '
234 1
Actifs    : ' . Format::money($assets) . '
235 1
Passifs   : ' . Format::money($liabilities) . '
236 1
Capital   : ' . Format::money($equity) . '
237 1
Écart     : ' . Format::money($discrepancy) . PHP_EOL;
238
239 1
        if (!$discrepancy->isZero()) {
240
            $this->error(sprintf('ERREUR: écart de %s au bilan des comptes', Format::money($discrepancy)));
241
        }
242
    }
243
244 1
    private function checkTransactionsAreBalanced(): void
245
    {
246 1
        $connection = _em()->getConnection();
247
248 1
        $sql = <<<STRING
249
             SELECT transaction_id,
250
                 SUM(IF(debit_id IS NOT NULL, balance, 0))  AS totalDebit,
251
                 SUM(IF(credit_id IS NOT NULL, balance, 0)) AS totalCredit
252
             FROM transaction_line
253
             GROUP BY transaction_id
254
             HAVING totalDebit <> totalCredit
255 1
            STRING;
256
257 1
        $result = $connection->executeQuery($sql);
258
259 1
        while ($row = $result->fetchAssociative()) {
260
            $msg = sprintf(
261
                'Transaction %s non-équilibrée, débits: %s, crédits: %s',
262
                $row['transaction_id'] ?? 'NEW',
263
                Format::money(Money::CHF($row['totalDebit'])),
264
                Format::money(Money::CHF($row['totalCredit'])),
265
            );
266
            $this->error($msg);
267
        }
268
    }
269
270 1
    private function checkMissingAccountsForUsers(): void
271
    {
272
        // Create missing accounts for users
273 1
        foreach ($this->userRepository->getAllFamilyOwners() as $user) {
274 1
            if (!empty($user->getLogin()) && !$user->getAccount()) {
275 1
                $account = $this->accountRepository->getOrCreate($user);
276 1
                echo sprintf('Création du compte %d pour l\'utilisateur %d...', $account->getCode(), $user->getId()) . PHP_EOL;
277 1
                _em()->flush();
278
            }
279
        }
280
    }
281
282 1
    private function checkUnnecessaryAccounts(): void
283
    {
284 1
        $deletedAccount = $this->accountRepository->deleteAccountOfNonFamilyOwnerWithoutAnyTransactions();
285 1
        if ($deletedAccount) {
286
            // Strictly speaking this is not an error, but we would like to be informed by email when it happens
287
            $this->error("$deletedAccount compte(s) été effacé parce qu'il appartenait à un utilisateur qui n'était pas chef de famille et que le compte avait aucune transaction");
288
        }
289
    }
290
291 1
    private function checkFamilyMembersShareSameAccount(): void
292
    {
293
        // Make sure users of the same family share the same account
294 1
        foreach ($this->userRepository->getAllNonFamilyOwnersWithAccount() as $user) {
295
            $this->error(
296
                sprintf(
297
                    'User#%d (%s) ne devrait pas avoir son propre compte débiteur mais partager celui du User#%d (%s)',
298
                    $user->getId(),
299
                    $user->getName(),
300
                    $user->getOwner()->getId(),
301
                    $user->getOwner()->getName()
302
                )
303
            );
304
        }
305
    }
306
}
307