Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

TransactionTest.php 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <?php
  2. use App\Enums\Accounting\JournalEntryType;
  3. use App\Enums\Accounting\TransactionType;
  4. use App\Filament\Company\Resources\Accounting\TransactionResource\Pages\ListTransactions;
  5. use App\Filament\Forms\Components\JournalEntryRepeater;
  6. use App\Filament\Tables\Actions\EditTransactionAction;
  7. use App\Filament\Tables\Actions\ReplicateBulkAction;
  8. use App\Models\Accounting\Account;
  9. use App\Models\Accounting\Transaction;
  10. use App\Utilities\Currency\ConfigureCurrencies;
  11. use App\Utilities\Currency\CurrencyConverter;
  12. use Filament\Tables\Actions\DeleteAction;
  13. use Filament\Tables\Actions\DeleteBulkAction;
  14. use Filament\Tables\Actions\ReplicateAction;
  15. use function Pest\Livewire\livewire;
  16. it('creates correct journal entries for a deposit transaction', function () {
  17. $transaction = Transaction::factory()
  18. ->forDefaultBankAccount()
  19. ->forUncategorizedRevenue()
  20. ->asDeposit(1000)
  21. ->create();
  22. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  23. expect($transaction->journalEntries->count())->toBe(2)
  24. ->and($debitAccount->name)->toBe('Cash on Hand')
  25. ->and($creditAccount->name)->toBe('Uncategorized Income');
  26. });
  27. it('creates correct journal entries for a withdrawal transaction', function () {
  28. $transaction = Transaction::factory()
  29. ->forDefaultBankAccount()
  30. ->forUncategorizedExpense()
  31. ->asWithdrawal(500)
  32. ->create();
  33. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  34. expect($transaction->journalEntries->count())->toBe(2)
  35. ->and($debitAccount->name)->toBe('Uncategorized Expense')
  36. ->and($creditAccount->name)->toBe('Cash on Hand');
  37. });
  38. it('creates correct journal entries for a transfer transaction', function () {
  39. $transaction = Transaction::factory()
  40. ->forDefaultBankAccount()
  41. ->forDestinationBankAccount()
  42. ->asTransfer(1500)
  43. ->create();
  44. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  45. // Acts as a withdrawal transaction for the source account
  46. expect($transaction->journalEntries->count())->toBe(2)
  47. ->and($debitAccount->name)->toBe('Destination Bank Account')
  48. ->and($creditAccount->name)->toBe('Cash on Hand');
  49. });
  50. it('does not create journal entries for a journal transaction', function () {
  51. $transaction = Transaction::factory()
  52. ->forDefaultBankAccount()
  53. ->forUncategorizedRevenue()
  54. ->asJournal(1000)
  55. ->create();
  56. // Journal entries for a journal transaction are created manually
  57. expect($transaction->journalEntries->count())->toBe(0);
  58. });
  59. it('stores and sums correct debit and credit amounts for different transaction types', function ($method, $setupMethod, $amount) {
  60. /** @var Transaction $transaction */
  61. $transaction = Transaction::factory()
  62. ->forDefaultBankAccount()
  63. ->{$setupMethod}()
  64. ->{$method}($amount)
  65. ->create();
  66. expect($transaction)
  67. ->journalEntries->sumDebits()->getAmount()->toEqual($amount)
  68. ->journalEntries->sumCredits()->getAmount()->toEqual($amount);
  69. })->with([
  70. ['asDeposit', 'forUncategorizedRevenue', 2000],
  71. ['asWithdrawal', 'forUncategorizedExpense', 500],
  72. ['asTransfer', 'forDestinationBankAccount', 1500],
  73. ]);
  74. it('deletes associated journal entries when transaction is deleted', function () {
  75. $transaction = Transaction::factory()
  76. ->forDefaultBankAccount()
  77. ->forUncategorizedRevenue()
  78. ->asDeposit(1000)
  79. ->create();
  80. expect($transaction->journalEntries()->count())->toBe(2);
  81. $transaction->delete();
  82. $this->assertModelMissing($transaction);
  83. $this->assertDatabaseCount('journal_entries', 0);
  84. });
  85. it('handles multi-currency transfers without conversion when the source bank account is in the default currency', function () {
  86. $foreignBankAccount = Account::factory()
  87. ->withForeignBankAccount('Foreign Bank Account', 'EUR', 0.92)
  88. ->create();
  89. /** @var Transaction $transaction */
  90. $transaction = Transaction::factory()
  91. ->forDefaultBankAccount()
  92. ->forDestinationBankAccount($foreignBankAccount)
  93. ->asTransfer(1500)
  94. ->create();
  95. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  96. expect($debitAccount->is($foreignBankAccount))->toBeTrue()
  97. ->and($creditAccount->name)->toBe('Cash on Hand');
  98. $expectedUSDValue = 1500;
  99. expect($transaction)
  100. ->amount->toBe(1500)
  101. ->journalEntries->count()->toBe(2)
  102. ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
  103. ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
  104. });
  105. it('handles multi-currency transfers correctly', function () {
  106. $foreignBankAccount = Account::factory()
  107. ->withForeignBankAccount('CAD Bank Account', 'CAD', 1.36)
  108. ->create();
  109. $foreignBankAccount->refresh();
  110. ConfigureCurrencies::syncCurrencies();
  111. // Create a transfer of 1500 CAD from the foreign bank account to USD bank account
  112. /** @var Transaction $transaction */
  113. $transaction = Transaction::factory()
  114. ->forBankAccount($foreignBankAccount->bankAccount)
  115. ->forDestinationBankAccount()
  116. ->asTransfer(1500)
  117. ->create();
  118. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  119. expect($debitAccount->name)->toBe('Destination Bank Account') // Debit: Destination (USD) account
  120. ->and($creditAccount->is($foreignBankAccount))->toBeTrue(); // Credit: Foreign Bank Account (CAD) account
  121. // The 1500 CAD is worth approximately 1103 USD (1500 CAD / 1.36)
  122. $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'CAD', 'USD');
  123. // Verify that the debit and credit are converted to USD cents
  124. // Transaction amount stays in source bank account currency
  125. expect($transaction)
  126. ->amount->toBe(1500)
  127. ->journalEntries->count()->toBe(2)
  128. ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
  129. ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
  130. });
  131. it('handles multi-currency deposits correctly', function () {
  132. $foreignBankAccount = Account::factory()
  133. ->withForeignBankAccount('BHD Bank Account', 'BHD', 0.38)
  134. ->create();
  135. $foreignBankAccount->refresh();
  136. ConfigureCurrencies::syncCurrencies();
  137. // Create a deposit of 1500 BHD (in fils - BHD subunits) to the foreign bank account
  138. /** @var Transaction $transaction */
  139. $transaction = Transaction::factory()
  140. ->forBankAccount($foreignBankAccount->bankAccount)
  141. ->forUncategorizedRevenue()
  142. ->asDeposit(1500)
  143. ->create();
  144. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  145. expect($debitAccount->is($foreignBankAccount))->toBeTrue() // Debit: Foreign Bank Account (BHD) account
  146. ->and($creditAccount->name)->toBe('Uncategorized Income'); // Credit: Uncategorized Income (USD) account
  147. $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'BHD', 'USD');
  148. // Verify that journal entries are converted to USD cents
  149. expect($transaction)
  150. ->amount->toBe(1500)
  151. ->journalEntries->count()->toBe(2)
  152. ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
  153. ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
  154. });
  155. it('handles multi-currency withdrawals correctly', function () {
  156. $foreignBankAccount = Account::factory()
  157. ->withForeignBankAccount('Foreign Bank Account', 'GBP', 0.76) // GBP account
  158. ->create();
  159. $foreignBankAccount->refresh();
  160. ConfigureCurrencies::syncCurrencies();
  161. /** @var Transaction $transaction */
  162. $transaction = Transaction::factory()
  163. ->forBankAccount($foreignBankAccount->bankAccount)
  164. ->forUncategorizedExpense()
  165. ->asWithdrawal(1500)
  166. ->create();
  167. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  168. expect($debitAccount->name)->toBe('Uncategorized Expense')
  169. ->and($creditAccount->is($foreignBankAccount))->toBeTrue();
  170. $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'GBP', 'USD');
  171. expect($transaction)
  172. ->amount->toBe(1500)
  173. ->journalEntries->count()->toBe(2)
  174. ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
  175. ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
  176. });
  177. it('can add an income or expense transaction', function (TransactionType $transactionType, string $actionName) {
  178. $testCompany = $this->testCompany;
  179. $defaultBankAccount = $testCompany->default->bankAccount;
  180. $defaultAccount = Transaction::getUncategorizedAccountByType($transactionType);
  181. livewire(ListTransactions::class)
  182. ->mountAction($actionName)
  183. ->assertActionDataSet([
  184. 'posted_at' => today(),
  185. 'type' => $transactionType,
  186. 'bank_account_id' => $defaultBankAccount->id,
  187. 'amount' => '0.00',
  188. 'account_id' => $defaultAccount->id,
  189. ])
  190. ->setActionData([
  191. 'amount' => '500.00',
  192. ])
  193. ->callMountedAction()
  194. ->assertHasNoActionErrors();
  195. $transaction = Transaction::first();
  196. expect($transaction)
  197. ->not->toBeNull()
  198. ->amount->toBe(50000) // 500.00 in cents
  199. ->type->toBe($transactionType)
  200. ->bankAccount->is($defaultBankAccount)->toBeTrue()
  201. ->account->is($defaultAccount)->toBeTrue()
  202. ->journalEntries->count()->toBe(2);
  203. })->with([
  204. [TransactionType::Deposit, 'createDeposit'],
  205. [TransactionType::Withdrawal, 'createWithdrawal'],
  206. ]);
  207. it('can add a transfer transaction', function () {
  208. $testCompany = $this->testCompany;
  209. $sourceBankAccount = $testCompany->default->bankAccount;
  210. $destinationBankAccount = Account::factory()->withBankAccount('Destination Bank Account')->create();
  211. livewire(ListTransactions::class)
  212. ->mountAction('createTransfer')
  213. ->assertActionDataSet([
  214. 'posted_at' => today(),
  215. 'type' => TransactionType::Transfer,
  216. 'bank_account_id' => $sourceBankAccount->id,
  217. 'amount' => '0.00',
  218. 'account_id' => null,
  219. ])
  220. ->setActionData([
  221. 'account_id' => $destinationBankAccount->id,
  222. 'amount' => '1,500.00',
  223. ])
  224. ->callMountedAction()
  225. ->assertHasNoActionErrors();
  226. $transaction = Transaction::first();
  227. expect($transaction)
  228. ->not->toBeNull()
  229. ->amount->toBe(150000) // 1,500.00 in cents
  230. ->type->toBe(TransactionType::Transfer)
  231. ->bankAccount->is($sourceBankAccount)->toBeTrue()
  232. ->account->is($destinationBankAccount)->toBeTrue()
  233. ->journalEntries->count()->toBe(2);
  234. });
  235. it('can add a journal transaction', function () {
  236. $defaultDebitAccount = Transaction::getUncategorizedAccountByType(TransactionType::Withdrawal);
  237. $defaultCreditAccount = Transaction::getUncategorizedAccountByType(TransactionType::Deposit);
  238. $undoRepeaterFake = JournalEntryRepeater::fake();
  239. livewire(ListTransactions::class)
  240. ->mountAction('createJournalEntry')
  241. ->assertActionDataSet([
  242. 'posted_at' => today(),
  243. 'journalEntries' => [
  244. ['type' => JournalEntryType::Debit, 'account_id' => $defaultDebitAccount->id, 'amount' => '0.00'],
  245. ['type' => JournalEntryType::Credit, 'account_id' => $defaultCreditAccount->id, 'amount' => '0.00'],
  246. ],
  247. ])
  248. ->setActionData([
  249. 'journalEntries' => [
  250. ['amount' => '1,000.00'],
  251. ['amount' => '1,000.00'],
  252. ],
  253. ])
  254. ->callMountedAction()
  255. ->assertHasNoActionErrors();
  256. $undoRepeaterFake();
  257. $transaction = Transaction::first();
  258. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  259. expect($transaction)
  260. ->not->toBeNull()
  261. ->amount->toBe(100000) // 1,000.00 in cents
  262. ->type->isJournal()->toBeTrue()
  263. ->bankAccount->toBeNull()
  264. ->account->toBeNull()
  265. ->journalEntries->count()->toBe(2)
  266. ->journalEntries->sumDebits()->getAmount()->toEqual(100000)
  267. ->journalEntries->sumCredits()->getAmount()->toEqual(100000)
  268. ->and($debitAccount->is($defaultDebitAccount))->toBeTrue()
  269. ->and($creditAccount->is($defaultCreditAccount))->toBeTrue();
  270. });
  271. it('can update a deposit or withdrawal transaction', function (TransactionType $transactionType) {
  272. $defaultAccount = Transaction::getUncategorizedAccountByType($transactionType);
  273. $transaction = Transaction::factory()
  274. ->forDefaultBankAccount()
  275. ->forAccount($defaultAccount)
  276. ->forType($transactionType, 1000)
  277. ->create();
  278. $newDescription = 'Updated Description';
  279. $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
  280. livewire(ListTransactions::class)
  281. ->mountTableAction(EditTransactionAction::class, $transaction)
  282. ->assertTableActionDataSet([
  283. 'type' => $transactionType->value,
  284. 'description' => $transaction->description,
  285. 'amount' => $formattedAmount,
  286. ])
  287. ->setTableActionData([
  288. 'description' => $newDescription,
  289. 'amount' => '1,500.00',
  290. ])
  291. ->callMountedTableAction()
  292. ->assertHasNoTableActionErrors();
  293. $transaction->refresh();
  294. expect($transaction->description)->toBe($newDescription)
  295. ->and($transaction->amount)->toBe(150000); // 1,500.00 in cents
  296. })->with([
  297. TransactionType::Deposit,
  298. TransactionType::Withdrawal,
  299. ]);
  300. it('can update a transfer transaction', function () {
  301. $transaction = Transaction::factory()
  302. ->forDefaultBankAccount()
  303. ->forDestinationBankAccount()
  304. ->asTransfer(1500)
  305. ->create();
  306. $newDescription = 'Updated Transfer Description';
  307. $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
  308. livewire(ListTransactions::class)
  309. ->mountTableAction(EditTransactionAction::class, $transaction)
  310. ->assertTableActionDataSet([
  311. 'type' => TransactionType::Transfer->value,
  312. 'description' => $transaction->description,
  313. 'amount' => $formattedAmount,
  314. ])
  315. ->setTableActionData([
  316. 'description' => $newDescription,
  317. 'amount' => '2,000.00',
  318. ])
  319. ->callMountedTableAction()
  320. ->assertHasNoTableActionErrors();
  321. $transaction->refresh();
  322. expect($transaction->description)->toBe($newDescription)
  323. ->and($transaction->amount)->toBe(200000); // 2,000.00 in cents
  324. });
  325. it('replicates a transaction with correct journal entries', function () {
  326. $originalTransaction = Transaction::factory()
  327. ->forDefaultBankAccount()
  328. ->forUncategorizedRevenue()
  329. ->asDeposit(1000)
  330. ->create();
  331. livewire(ListTransactions::class)
  332. ->callTableAction(ReplicateAction::class, $originalTransaction);
  333. $replicatedTransaction = Transaction::whereKeyNot($originalTransaction->getKey())->first();
  334. expect($replicatedTransaction)->not->toBeNull();
  335. [$originalDebitAccount, $originalCreditAccount] = getTransactionDebitAndCreditAccounts($originalTransaction);
  336. [$replicatedDebitAccount, $replicatedCreditAccount] = getTransactionDebitAndCreditAccounts($replicatedTransaction);
  337. expect($replicatedTransaction)
  338. ->journalEntries->count()->toBe(2)
  339. ->journalEntries->sumDebits()->getAmount()->toEqual(1000)
  340. ->journalEntries->sumCredits()->getAmount()->toEqual(1000)
  341. ->description->toBe('(Copy of) ' . $originalTransaction->description)
  342. ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
  343. ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
  344. });
  345. it('bulk replicates transactions with correct journal entries', function () {
  346. $originalTransactions = Transaction::factory()
  347. ->forDefaultBankAccount()
  348. ->forUncategorizedRevenue()
  349. ->asDeposit(1000)
  350. ->count(3)
  351. ->create();
  352. livewire(ListTransactions::class)
  353. ->callTableBulkAction(ReplicateBulkAction::class, $originalTransactions);
  354. $replicatedTransactions = Transaction::whereKeyNot($originalTransactions->modelKeys())->get();
  355. expect($replicatedTransactions->count())->toBe(3);
  356. $originalTransactions->each(function (Transaction $originalTransaction) use ($replicatedTransactions) {
  357. /** @var Transaction $replicatedTransaction */
  358. $replicatedTransaction = $replicatedTransactions->firstWhere('description', '(Copy of) ' . $originalTransaction->description);
  359. expect($replicatedTransaction)->not->toBeNull();
  360. [$originalDebitAccount, $originalCreditAccount] = getTransactionDebitAndCreditAccounts($originalTransaction);
  361. [$replicatedDebitAccount, $replicatedCreditAccount] = getTransactionDebitAndCreditAccounts($replicatedTransaction);
  362. expect($replicatedTransaction)
  363. ->journalEntries->count()->toBe(2)
  364. ->journalEntries->sumDebits()->getAmount()->toEqual(1000)
  365. ->journalEntries->sumCredits()->getAmount()->toEqual(1000)
  366. ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
  367. ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
  368. });
  369. });
  370. it('can delete a transaction with journal entries', function () {
  371. $transaction = Transaction::factory()
  372. ->forDefaultBankAccount()
  373. ->forUncategorizedRevenue()
  374. ->asDeposit(1000)
  375. ->create();
  376. expect($transaction->journalEntries()->count())->toBe(2);
  377. livewire(ListTransactions::class)
  378. ->callTableAction(DeleteAction::class, $transaction);
  379. $this->assertModelMissing($transaction);
  380. $this->assertDatabaseEmpty('journal_entries');
  381. });
  382. it('can bulk delete transactions with journal entries', function () {
  383. $transactions = Transaction::factory()
  384. ->forDefaultBankAccount()
  385. ->forUncategorizedRevenue()
  386. ->asDeposit(1000)
  387. ->count(3)
  388. ->create();
  389. expect($transactions->count())->toBe(3);
  390. livewire(ListTransactions::class)
  391. ->callTableBulkAction(DeleteBulkAction::class, $transactions);
  392. $this->assertDatabaseEmpty('transactions');
  393. $this->assertDatabaseEmpty('journal_entries');
  394. });