You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TransactionTest.php 19KB

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