Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

TransactionTest.php 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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 Filament\Tables\Actions\DeleteAction;
  12. use Filament\Tables\Actions\DeleteBulkAction;
  13. use Filament\Tables\Actions\ReplicateAction;
  14. use function Pest\Livewire\livewire;
  15. it('creates correct journal entries for a deposit transaction', function () {
  16. $transaction = Transaction::factory()
  17. ->forDefaultBankAccount()
  18. ->forUncategorizedRevenue()
  19. ->asDeposit(1000)
  20. ->create();
  21. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  22. expect($transaction->journalEntries->count())->toBe(2)
  23. ->and($debitAccount->name)->toBe('Cash on Hand')
  24. ->and($creditAccount->name)->toBe('Uncategorized Income');
  25. });
  26. it('creates correct journal entries for a withdrawal transaction', function () {
  27. $transaction = Transaction::factory()
  28. ->forDefaultBankAccount()
  29. ->forUncategorizedExpense()
  30. ->asWithdrawal(500)
  31. ->create();
  32. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  33. expect($transaction->journalEntries->count())->toBe(2)
  34. ->and($debitAccount->name)->toBe('Uncategorized Expense')
  35. ->and($creditAccount->name)->toBe('Cash on Hand');
  36. });
  37. it('creates correct journal entries for a transfer transaction', function () {
  38. $transaction = Transaction::factory()
  39. ->forDefaultBankAccount()
  40. ->forDestinationBankAccount()
  41. ->asTransfer(1500)
  42. ->create();
  43. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  44. // Acts as a withdrawal transaction for the source account
  45. expect($transaction->journalEntries->count())->toBe(2)
  46. ->and($debitAccount->name)->toBe('Destination Bank Account')
  47. ->and($creditAccount->name)->toBe('Cash on Hand');
  48. });
  49. it('does not create journal entries for a journal transaction', function () {
  50. $transaction = Transaction::factory()
  51. ->forDefaultBankAccount()
  52. ->forUncategorizedRevenue()
  53. ->asJournal(1000)
  54. ->create();
  55. // Journal entries for a journal transaction are created manually
  56. expect($transaction->journalEntries->count())->toBe(0);
  57. });
  58. it('stores and sums correct debit and credit amounts for different transaction types', function ($method, $setupMethod, $amount) {
  59. /** @var Transaction $transaction */
  60. $transaction = Transaction::factory()
  61. ->forDefaultBankAccount()
  62. ->{$setupMethod}()
  63. ->{$method}($amount)
  64. ->create();
  65. expect($transaction)
  66. ->journalEntries->sumDebits()->getValue()->toEqual($amount)
  67. ->journalEntries->sumCredits()->getValue()->toEqual($amount);
  68. })->with([
  69. ['asDeposit', 'forUncategorizedRevenue', 2000],
  70. ['asWithdrawal', 'forUncategorizedExpense', 500],
  71. ['asTransfer', 'forDestinationBankAccount', 1500],
  72. ]);
  73. it('deletes associated journal entries when transaction is deleted', function () {
  74. $transaction = Transaction::factory()
  75. ->forDefaultBankAccount()
  76. ->forUncategorizedRevenue()
  77. ->asDeposit(1000)
  78. ->create();
  79. expect($transaction->journalEntries()->count())->toBe(2);
  80. $transaction->delete();
  81. $this->assertModelMissing($transaction);
  82. $this->assertDatabaseCount('journal_entries', 0);
  83. });
  84. it('handles multi-currency transfers without conversion when the source bank account is in the default currency', function () {
  85. $foreignBankAccount = Account::factory()
  86. ->withForeignBankAccount('Foreign Bank Account', 'EUR', 0.92)
  87. ->create();
  88. /** @var Transaction $transaction */
  89. $transaction = Transaction::factory()
  90. ->forDefaultBankAccount()
  91. ->forDestinationBankAccount($foreignBankAccount)
  92. ->asTransfer(1500)
  93. ->create();
  94. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  95. expect($debitAccount->is($foreignBankAccount))->toBeTrue()
  96. ->and($creditAccount->name)->toBe('Cash on Hand');
  97. $expectedUSDValue = 1500;
  98. expect($transaction)
  99. ->amount->toEqual('1,500.00')
  100. ->journalEntries->count()->toBe(2)
  101. ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
  102. ->journalEntries->sumCredits()->getValue()->toEqual($expectedUSDValue);
  103. });
  104. it('handles multi-currency transfers correctly', function () {
  105. $foreignBankAccount = Account::factory()
  106. ->withForeignBankAccount('CAD Bank Account', 'CAD', 1.36)
  107. ->create();
  108. $foreignBankAccount->refresh();
  109. ConfigureCurrencies::syncCurrencies();
  110. // Create a transfer of 1500 CAD from the foreign bank account to USD bank account
  111. /** @var Transaction $transaction */
  112. $transaction = Transaction::factory()
  113. ->forBankAccount($foreignBankAccount->bankAccount)
  114. ->forDestinationBankAccount()
  115. ->asTransfer(1500)
  116. ->create();
  117. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  118. expect($debitAccount->name)->toBe('Destination Bank Account') // Debit: Destination (USD) account
  119. ->and($creditAccount->is($foreignBankAccount))->toBeTrue(); // Credit: Foreign Bank Account (CAD) account
  120. // The 1500 CAD is worth 1102.94 USD (1500 CAD / 1.36)
  121. $expectedUSDValue = round(1500 / 1.36, 2);
  122. // Verify that the debit is 1102.94 USD and the credit is 1500 CAD converted to 1102.94 USD
  123. // Transaction amount stays in source bank account currency (cast is applied)
  124. expect($transaction)
  125. ->amount->toEqual('1,500.00')
  126. ->journalEntries->count()->toBe(2)
  127. ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
  128. ->journalEntries->sumCredits()->getValue()->toEqual($expectedUSDValue);
  129. });
  130. it('handles multi-currency deposits correctly', function () {
  131. $foreignBankAccount = Account::factory()
  132. ->withForeignBankAccount('BHD Bank Account', 'BHD', 0.38)
  133. ->create();
  134. $foreignBankAccount->refresh();
  135. ConfigureCurrencies::syncCurrencies();
  136. // Create a deposit of 1500 BHD to the foreign bank account
  137. /** @var Transaction $transaction */
  138. $transaction = Transaction::factory()
  139. ->forBankAccount($foreignBankAccount->bankAccount)
  140. ->forUncategorizedRevenue()
  141. ->asDeposit(1500)
  142. ->create();
  143. [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
  144. expect($debitAccount->is($foreignBankAccount))->toBeTrue() // Debit: Foreign Bank Account (BHD) account
  145. ->and($creditAccount->name)->toBe('Uncategorized Income'); // Credit: Uncategorized Income (USD) account
  146. // Convert to USD using the rate 0.38 BHD per USD
  147. $expectedUSDValue = round(1500 / 0.38, 2);
  148. // Verify that the debit is 39473.68 USD and the credit is 1500 BHD converted to 39473.68 USD
  149. expect($transaction)
  150. ->amount->toEqual('1,500.000')
  151. ->journalEntries->count()->toBe(2)
  152. ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
  153. ->journalEntries->sumCredits()->getValue()->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 = round(1500 / 0.76, 2);
  171. expect($transaction)
  172. ->amount->toEqual('1,500.00')
  173. ->journalEntries->count()->toBe(2)
  174. ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
  175. ->journalEntries->sumCredits()->getValue()->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->toEqual('500.00')
  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->toEqual('1,500.00')
  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->toEqual('1,000.00')
  262. ->type->isJournal()->toBeTrue()
  263. ->bankAccount->toBeNull()
  264. ->account->toBeNull()
  265. ->journalEntries->count()->toBe(2)
  266. ->journalEntries->sumDebits()->getValue()->toEqual(1000)
  267. ->journalEntries->sumCredits()->getValue()->toEqual(1000)
  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. livewire(ListTransactions::class)
  280. ->mountTableAction(EditTransactionAction::class, $transaction)
  281. ->assertTableActionDataSet([
  282. 'type' => $transactionType->value,
  283. 'description' => $transaction->description,
  284. 'amount' => $transaction->amount,
  285. ])
  286. ->setTableActionData([
  287. 'description' => $newDescription,
  288. 'amount' => '1,500.00',
  289. ])
  290. ->callMountedTableAction()
  291. ->assertHasNoTableActionErrors();
  292. $transaction->refresh();
  293. expect($transaction->description)->toBe($newDescription)
  294. ->and($transaction->amount)->toEqual('1,500.00');
  295. })->with([
  296. TransactionType::Deposit,
  297. TransactionType::Withdrawal,
  298. ]);
  299. it('can update a transfer transaction', function () {
  300. $transaction = Transaction::factory()
  301. ->forDefaultBankAccount()
  302. ->forDestinationBankAccount()
  303. ->asTransfer(1500)
  304. ->create();
  305. $newDescription = 'Updated Transfer Description';
  306. livewire(ListTransactions::class)
  307. ->mountTableAction(EditTransactionAction::class, $transaction)
  308. ->assertTableActionDataSet([
  309. 'type' => TransactionType::Transfer->value,
  310. 'description' => $transaction->description,
  311. 'amount' => $transaction->amount,
  312. ])
  313. ->setTableActionData([
  314. 'description' => $newDescription,
  315. 'amount' => '2,000.00',
  316. ])
  317. ->callMountedTableAction()
  318. ->assertHasNoTableActionErrors();
  319. $transaction->refresh();
  320. expect($transaction->description)->toBe($newDescription)
  321. ->and($transaction->amount)->toEqual('2,000.00');
  322. });
  323. it('replicates a transaction with correct journal entries', function () {
  324. $originalTransaction = Transaction::factory()
  325. ->forDefaultBankAccount()
  326. ->forUncategorizedRevenue()
  327. ->asDeposit(1000)
  328. ->create();
  329. livewire(ListTransactions::class)
  330. ->callTableAction(ReplicateAction::class, $originalTransaction);
  331. $replicatedTransaction = Transaction::whereKeyNot($originalTransaction->getKey())->first();
  332. expect($replicatedTransaction)->not->toBeNull();
  333. [$originalDebitAccount, $originalCreditAccount] = getTransactionDebitAndCreditAccounts($originalTransaction);
  334. [$replicatedDebitAccount, $replicatedCreditAccount] = getTransactionDebitAndCreditAccounts($replicatedTransaction);
  335. expect($replicatedTransaction)
  336. ->journalEntries->count()->toBe(2)
  337. ->journalEntries->sumDebits()->getValue()->toEqual(1000)
  338. ->journalEntries->sumCredits()->getValue()->toEqual(1000)
  339. ->description->toBe('(Copy of) ' . $originalTransaction->description)
  340. ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
  341. ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
  342. });
  343. it('bulk replicates transactions with correct journal entries', function () {
  344. $originalTransactions = Transaction::factory()
  345. ->forDefaultBankAccount()
  346. ->forUncategorizedRevenue()
  347. ->asDeposit(1000)
  348. ->count(3)
  349. ->create();
  350. livewire(ListTransactions::class)
  351. ->callTableBulkAction(ReplicateBulkAction::class, $originalTransactions);
  352. $replicatedTransactions = Transaction::whereKeyNot($originalTransactions->modelKeys())->get();
  353. expect($replicatedTransactions->count())->toBe(3);
  354. $originalTransactions->each(function (Transaction $originalTransaction) use ($replicatedTransactions) {
  355. /** @var Transaction $replicatedTransaction */
  356. $replicatedTransaction = $replicatedTransactions->firstWhere('description', '(Copy of) ' . $originalTransaction->description);
  357. expect($replicatedTransaction)->not->toBeNull();
  358. [$originalDebitAccount, $originalCreditAccount] = getTransactionDebitAndCreditAccounts($originalTransaction);
  359. [$replicatedDebitAccount, $replicatedCreditAccount] = getTransactionDebitAndCreditAccounts($replicatedTransaction);
  360. expect($replicatedTransaction)
  361. ->journalEntries->count()->toBe(2)
  362. ->journalEntries->sumDebits()->getValue()->toEqual(1000)
  363. ->journalEntries->sumCredits()->getValue()->toEqual(1000)
  364. ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
  365. ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
  366. });
  367. });
  368. it('can delete a transaction with journal entries', function () {
  369. $transaction = Transaction::factory()
  370. ->forDefaultBankAccount()
  371. ->forUncategorizedRevenue()
  372. ->asDeposit(1000)
  373. ->create();
  374. expect($transaction->journalEntries()->count())->toBe(2);
  375. livewire(ListTransactions::class)
  376. ->callTableAction(DeleteAction::class, $transaction);
  377. $this->assertModelMissing($transaction);
  378. $this->assertDatabaseEmpty('journal_entries');
  379. });
  380. it('can bulk delete transactions with journal entries', function () {
  381. $transactions = Transaction::factory()
  382. ->forDefaultBankAccount()
  383. ->forUncategorizedRevenue()
  384. ->asDeposit(1000)
  385. ->count(3)
  386. ->create();
  387. expect($transactions->count())->toBe(3);
  388. livewire(ListTransactions::class)
  389. ->callTableBulkAction(DeleteBulkAction::class, $transactions);
  390. $this->assertDatabaseEmpty('transactions');
  391. $this->assertDatabaseEmpty('journal_entries');
  392. });