Failed Conditions
Push — master ( 76f788...13b360 )
by Sylvain
08:55
created

Accounting::check()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 12
ccs 8
cts 8
cp 1
crap 1
rs 10
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;
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);
0 ignored issues
show
Bug introduced by
Are you sure $endDateTime of type Cake\Chronos\ChronosInterface can be used in concatenation? Consider adding a __toString()-method. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

111
            throw new ExceptionWithoutMailLogging('Le bouclement a déjà été fait au ' . /** @scrutinizer ignore-type */ $endDateTime);
Loading history...
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
        $closingEntries = [];
119
120 1
        foreach ($allAccountsToClose as $accountType => $accountsToClose) {
121 1
            foreach ($accountsToClose as $account) {
122
                /** @var Money $balance */
123 1
                $balance = $account->getBalanceAtDate($endDate);
124 1
                if ($balance->isZero()) {
125 1
                    continue;
126
                }
127 1
                if ($account->getType() === AccountTypeType::EXPENSE && !in_array(mb_substr((string) $account->getCode(), 0, 1), ['4', '5', '6'], true)) {
128
                    throw new Exception('Account ' . $account->getCode() . ' has an invalid code for an expense account');
129
                }
130 1
                if ($account->getType() === AccountTypeType::REVENUE && mb_substr((string) $account->getCode(), 0, 1) !== '3') {
131
                    throw new Exception('Account ' . $account->getCode() . ' has an invalid code for a revenue account');
132
                }
133
                $entry = [
134 1
                    'transactionDate' => $closingTransaction->getTransactionDate(),
135 1
                    'name' => 'Bouclement ' . $account->getName(),
136 1
                    'balance' => $balance->absolute(),
137
                ];
138 1
                if ($account->getType() === AccountTypeType::EXPENSE) {
139 1
                    if ($balance->isPositive()) {
140 1
                        $entry['credit'] = $account;
141 1
                        $entry['debit'] = $closingAccount;
142
                    } else {
143
                        $entry['credit'] = $closingAccount;
144 1
                        $entry['debit'] = $account;
145
                    }
146 1
                } elseif ($account->getType() === AccountTypeType::REVENUE) {
147 1
                    if ($balance->isPositive()) {
148 1
                        $entry['credit'] = $closingAccount;
149 1
                        $entry['debit'] = $account;
150
                    } else {
151
                        $entry['credit'] = $account;
152 1
                        $entry['debit'] = $closingAccount;
153
                    }
154
                } else {
155
                    throw new Exception('I don\'t know how to close account ' . $account->getCode() . ' of type ' . $account->getType());
156
                }
157 1
                $closingEntries[] = $entry;
158
            }
159
        }
160 1
        if (count($closingEntries)) {
161 1
            $this->transactionRepository->hydrateLinesAndFlush($closingTransaction, $closingEntries);
162
        }
163 1
        $profitOrLoss = $closingAccount->getBalance();
164 1
        if ($profitOrLoss->isZero()) {
165
            if (count($closingTransaction->getTransactionLines())) {
166
                if (is_array($output)) {
167
                    $output[] = 'Résultat équilibré, ni bénéfice, ni déficit: rien à reporter';
168
                }
169
                _em()->flush();
170
171
                return $closingTransaction;
172
            }
173
            if (is_array($output)) {
174
                $output[] = 'Aucun mouvement ou solde des comptes nul depuis le dernier bouclement: rien à reporter';
175
            }
176
177
            return null;
178
        }
179 1
        $carryForward = new TransactionLine();
180 1
        _em()->persist($carryForward);
181 1
        $carryForward->setTransaction($closingTransaction);
182 1
        $carryForward->setBalance($profitOrLoss->absolute());
183 1
        $carryForward->setTransactionDate($closingTransaction->getTransactionDate());
184 1
        if ($profitOrLoss->isPositive()) {
185 1
            if (is_array($output)) {
186 1
                $output[] = 'Bénéfice : ' . Format::money($profitOrLoss);
187
            }
188 1
            $carryForward->setName('Report du bénéfice');
189 1
            $carryForward->setDebit($closingAccount);
190 1
            $carryForward->setCredit($carryForwardAccount);
191
        } elseif ($profitOrLoss->isNegative()) {
192
            if (is_array($output)) {
193
                $output[] = 'Déficit : ' . Format::money($profitOrLoss->absolute());
194
            }
195
            $carryForward->setName('Report du déficit');
196
            $carryForward->setDebit($carryForwardAccount);
197
            $carryForward->setCredit($closingAccount);
198
        }
199
200 1
        _em()->flush();
201
202 1
        return $closingTransaction;
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(AccountTypeType::ASSET);
221 1
        $liabilities = $this->accountRepository->totalBalanceByType(AccountTypeType::LIABILITY);
222 1
        $revenue = $this->accountRepository->totalBalanceByType(AccountTypeType::REVENUE);
223 1
        $expense = $this->accountRepository->totalBalanceByType(AccountTypeType::EXPENSE);
224 1
        $equity = $this->accountRepository->totalBalanceByType(AccountTypeType::EQUITY);
225
226 1
        $income = $revenue->subtract($expense);
227
228 1
        $discrepancy = $assets->subtract($income)->subtract($liabilities->add($equity));
229
230
        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 1
    }
243
244 1
    private function checkTransactionsAreBalanced(): void
245
    {
246 1
        foreach (_em()->getRepository(Transaction::class)->findAll() as $transaction) {
247
            try {
248 1
                $transaction->checkBalance();
249
            } catch (Throwable $e) {
250
                $this->error($e->getMessage());
251
            }
252
        }
253 1
    }
254
255 1
    private function checkMissingAccountsForUsers(): void
256
    {
257
        // Create missing accounts for users
258 1
        foreach ($this->userRepository->getAllFamilyOwners() as $user) {
259 1
            if (!$user->getAccount()) {
260 1
                $account = $this->accountRepository->getOrCreate($user);
261 1
                echo sprintf('Création du compte %d pour l\'utilisateur %d...', $account->getCode(), $user->getId()) . PHP_EOL;
262 1
                _em()->flush();
263
            }
264
        }
265 1
    }
266
267 1
    private function checkUnnecessaryAccounts(): void
268
    {
269 1
        $deletedAccount = $this->accountRepository->deleteAccountOfNonFamilyOwnerWithoutAnyTransactions();
270 1
        if ($deletedAccount) {
271
            // Strictly speaking this is not an error, but we would like to be informed by email when it happens
272
            $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");
273
        }
274 1
    }
275
276 1
    private function checkFamilyMembersShareSameAccount(): void
277
    {
278
        // Make sure users of the same family share the same account
279 1
        foreach ($this->userRepository->getAllNonFamilyOwnersWithAccount() as $user) {
280
            $this->error(
281
                sprintf(
282
                    'User#%d (%s) ne devrait pas avoir son propre compte débiteur mais partager celui du User#%d (%s)',
283
                    $user->getId(),
284
                    $user->getName(),
285
                    $user->getOwner()->getId(),
286
                    $user->getOwner()->getName()
287
                )
288
            );
289
        }
290 1
    }
291
}
292