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