Failed Conditions
Push — master ( 13b360...fb4a0d )
by Sylvain
12:23
created

Accounting::checkAccounts()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 23
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 3.0026

Importance

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