Passed
Push — master ( e7daf4...e2c26a )
by Sylvain
09:40
created

Accounting::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 9
ccs 7
cts 7
cp 1
rs 10
cc 1
nc 1
nop 3
crap 1
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->updateAccountsBalance();
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
119 1
        $this->generateClosingEntries($allAccountsToClose, $closingTransaction, $closingAccount, $endDate);
120
121 1
        $profitOrLoss = $closingAccount->getBalance();
122 1
        if ($profitOrLoss->isZero()) {
123
            if (count($closingTransaction->getTransactionLines())) {
124
                if (is_array($output)) {
125
                    $output[] = 'Résultat équilibré, ni bénéfice, ni déficit: rien à reporter';
126
                }
127
                _em()->flush();
128
129
                return $closingTransaction;
130
            }
131
            if (is_array($output)) {
132
                $output[] = 'Aucun mouvement ou solde des comptes nul depuis le dernier bouclement: rien à reporter';
133
            }
134
135
            return null;
136
        }
137 1
        $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 1
        if ($profitOrLoss->isPositive()) {
143 1
            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
        } elseif ($profitOrLoss->isNegative()) {
150
            if (is_array($output)) {
151
                $output[] = 'Déficit : ' . Format::money($profitOrLoss->absolute());
152
            }
153
            $carryForward->setName('Report du déficit');
154
            $carryForward->setDebit($carryForwardAccount);
155
            $carryForward->setCredit($closingAccount);
156
        }
157
158 1
        _em()->flush();
159
160 1
        return $closingTransaction;
161
    }
162
163 1
    private function generateClosingEntries(array $allAccountsToClose, Transaction $closingTransaction, Account $closingAccount, Date $endDate): void
164
    {
165 1
        $closingEntries = [];
166 1
        foreach ($allAccountsToClose as $accountType => $accountsToClose) {
167 1
            foreach ($accountsToClose as $account) {
168
                /** @var Money $balance */
169 1
                $balance = $account->getBalanceAtDate($endDate);
170 1
                if ($balance->isZero()) {
171 1
                    continue;
172
                }
173 1
                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 1
                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
                $entry = [
180 1
                    'transactionDate' => $closingTransaction->getTransactionDate(),
181 1
                    'name' => 'Bouclement ' . $account->getName(),
182 1
                    'balance' => $balance->absolute(),
183
                ];
184 1
                if ($account->getType() === AccountTypeType::EXPENSE) {
185 1
                    if ($balance->isPositive()) {
186 1
                        $entry['credit'] = $account;
187 1
                        $entry['debit'] = $closingAccount;
188
                    } else {
189
                        $entry['credit'] = $closingAccount;
190 1
                        $entry['debit'] = $account;
191
                    }
192 1
                } elseif ($account->getType() === AccountTypeType::REVENUE) {
193 1
                    if ($balance->isPositive()) {
194 1
                        $entry['credit'] = $closingAccount;
195 1
                        $entry['debit'] = $account;
196
                    } else {
197
                        $entry['credit'] = $account;
198 1
                        $entry['debit'] = $closingAccount;
199
                    }
200
                } else {
201
                    throw new Exception('I don\'t know how to close account ' . $account->getCode() . ' of type ' . $account->getType());
202
                }
203 1
                $closingEntries[] = $entry;
204
            }
205
        }
206 1
        if (count($closingEntries)) {
207 1
            $this->transactionRepository->hydrateLinesAndFlush($closingTransaction, $closingEntries);
208
        }
209 1
    }
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
        }
219
220
        echo $message . PHP_EOL;
221
        $this->hasError = true;
222
    }
223
224 1
    private function checkAccounts(): void
225
    {
226 1
        $assets = $this->accountRepository->totalBalanceByType(AccountTypeType::ASSET);
227 1
        $liabilities = $this->accountRepository->totalBalanceByType(AccountTypeType::LIABILITY);
228 1
        $revenue = $this->accountRepository->totalBalanceByType(AccountTypeType::REVENUE);
229 1
        $expense = $this->accountRepository->totalBalanceByType(AccountTypeType::EXPENSE);
230 1
        $equity = $this->accountRepository->totalBalanceByType(AccountTypeType::EQUITY);
231
232 1
        $income = $revenue->subtract($expense);
233
234 1
        $discrepancy = $assets->subtract($income)->subtract($liabilities->add($equity));
235
236
        echo '
237 1
Produits  : ' . Format::money($revenue) . '
238 1
Charges   : ' . Format::money($expense) . '
239 1
' . ($income->isNegative() ? 'Déficit   : ' : 'Bénéfice  : ') . Format::money($income) . '
240 1
Actifs    : ' . Format::money($assets) . '
241 1
Passifs   : ' . Format::money($liabilities) . '
242 1
Capital   : ' . Format::money($equity) . '
243 1
Écart     : ' . Format::money($discrepancy) . PHP_EOL;
244
245 1
        if (!$discrepancy->isZero()) {
246
            $this->error(sprintf('ERREUR: écart de %s au bilan des comptes', Format::money($discrepancy)));
247
        }
248 1
    }
249
250 1
    private function checkTransactionsAreBalanced(): void
251
    {
252 1
        $connection = _em()->getConnection();
253
254
        $sql = <<<STRING
255 1
             SELECT transaction_id,
256
                 SUM(IF(debit_id IS NOT NULL, balance, 0))  as totalDebit,
257
                 SUM(IF(credit_id IS NOT NULL, balance, 0)) as totalCredit
258
             FROM transaction_line
259
             GROUP BY transaction_id
260
             HAVING totalDebit <> totalCredit
261
            STRING;
262
263 1
        $result = $connection->executeQuery($sql);
264
265 1
        while ($row = $result->fetchAssociative()) {
266
            $msg = sprintf(
267
                'Transaction %s non-équilibrée, débits: %s, crédits: %s',
268
                $row['transaction_id'] ?? 'NEW',
269
                Format::money(Money::CHF($row['totalDebit'])),
270
                Format::money(Money::CHF($row['totalCredit'])),
271
            );
272
            $this->error($msg);
273
        }
274 1
    }
275
276 1
    private function checkMissingAccountsForUsers(): void
277
    {
278
        // Create missing accounts for users
279 1
        foreach ($this->userRepository->getAllFamilyOwners() as $user) {
280 1
            if (!empty($user->getLogin()) && !$user->getAccount()) {
281 1
                $account = $this->accountRepository->getOrCreate($user);
282 1
                echo sprintf('Création du compte %d pour l\'utilisateur %d...', $account->getCode(), $user->getId()) . PHP_EOL;
283 1
                _em()->flush();
284
            }
285
        }
286 1
    }
287
288 1
    private function checkUnnecessaryAccounts(): void
289
    {
290 1
        $deletedAccount = $this->accountRepository->deleteAccountOfNonFamilyOwnerWithoutAnyTransactions();
291 1
        if ($deletedAccount) {
292
            // Strictly speaking this is not an error, but we would like to be informed by email when it happens
293
            $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");
294
        }
295 1
    }
296
297 1
    private function checkFamilyMembersShareSameAccount(): void
298
    {
299
        // Make sure users of the same family share the same account
300 1
        foreach ($this->userRepository->getAllNonFamilyOwnersWithAccount() as $user) {
301
            $this->error(
302
                sprintf(
303
                    'User#%d (%s) ne devrait pas avoir son propre compte débiteur mais partager celui du User#%d (%s)',
304
                    $user->getId(),
305
                    $user->getName(),
306
                    $user->getOwner()->getId(),
307
                    $user->getOwner()->getName()
308
                )
309
            );
310
        }
311 1
    }
312
}
313