Passed
Push — master ( 423891...66570a )
by Sylvain
06:45
created

Accounting   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 283
Duplicated Lines 0 %

Test Coverage

Coverage 72.26%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 44
eloc 158
c 3
b 0
f 0
dl 0
loc 283
ccs 112
cts 155
cp 0.7226
rs 8.8798

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 1
A check() 0 12 1
C close() 0 85 14
A checkUnnecessaryAccounts() 0 6 2
A checkTransactionsAreBalanced() 0 23 2
A checkAccounts() 0 23 3
C generateClosingEntries() 0 45 13
A error() 0 8 2
A checkFamilyMembersShareSameAccount() 0 11 2
A checkMissingAccountsForUsers() 0 8 4

How to fix   Complexity   

Complex Class

Complex classes like Accounting often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Accounting, and based on these observations, apply Extract Interface, too.

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