Issues (65)

server/Application/Service/Accounting.php (3 issues)

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