| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 | <?php
use App\Enums\Accounting\JournalEntryType;
use App\Enums\Accounting\TransactionType;
use App\Filament\Company\Resources\Accounting\TransactionResource\Pages\ListTransactions;
use App\Filament\Forms\Components\JournalEntryRepeater;
use App\Filament\Tables\Actions\EditTransactionAction;
use App\Filament\Tables\Actions\ReplicateBulkAction;
use App\Models\Accounting\Account;
use App\Models\Accounting\Transaction;
use App\Utilities\Currency\ConfigureCurrencies;
use App\Utilities\Currency\CurrencyConverter;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ReplicateAction;
use function Pest\Livewire\livewire;
it('creates correct journal entries for a deposit transaction', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asDeposit(1000)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($transaction->journalEntries->count())->toBe(2)
        ->and($debitAccount->name)->toBe('Cash on Hand')
        ->and($creditAccount->name)->toBe('Uncategorized Income');
});
it('creates correct journal entries for a withdrawal transaction', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedExpense()
        ->asWithdrawal(500)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($transaction->journalEntries->count())->toBe(2)
        ->and($debitAccount->name)->toBe('Uncategorized Expense')
        ->and($creditAccount->name)->toBe('Cash on Hand');
});
it('creates correct journal entries for a transfer transaction', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forDestinationBankAccount()
        ->asTransfer(1500)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    // Acts as a withdrawal transaction for the source account
    expect($transaction->journalEntries->count())->toBe(2)
        ->and($debitAccount->name)->toBe('Destination Bank Account')
        ->and($creditAccount->name)->toBe('Cash on Hand');
});
it('does not create journal entries for a journal transaction', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asJournal(1000)
        ->create();
    // Journal entries for a journal transaction are created manually
    expect($transaction->journalEntries->count())->toBe(0);
});
it('stores and sums correct debit and credit amounts for different transaction types', function ($method, $setupMethod, $amount) {
    /** @var Transaction $transaction */
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->{$setupMethod}()
        ->{$method}($amount)
        ->create();
    expect($transaction)
        ->journalEntries->sumDebits()->getAmount()->toEqual($amount)
        ->journalEntries->sumCredits()->getAmount()->toEqual($amount);
})->with([
    ['asDeposit', 'forUncategorizedRevenue', 2000],
    ['asWithdrawal', 'forUncategorizedExpense', 500],
    ['asTransfer', 'forDestinationBankAccount', 1500],
]);
it('deletes associated journal entries when transaction is deleted', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asDeposit(1000)
        ->create();
    expect($transaction->journalEntries()->count())->toBe(2);
    $transaction->delete();
    $this->assertModelMissing($transaction);
    $this->assertDatabaseCount('journal_entries', 0);
});
it('handles multi-currency transfers without conversion when the source bank account is in the default currency', function () {
    $foreignBankAccount = Account::factory()
        ->withForeignBankAccount('Foreign Bank Account', 'EUR', 0.92)
        ->create();
    /** @var Transaction $transaction */
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forDestinationBankAccount($foreignBankAccount)
        ->asTransfer(1500)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($debitAccount->is($foreignBankAccount))->toBeTrue()
        ->and($creditAccount->name)->toBe('Cash on Hand');
    $expectedUSDValue = 1500;
    expect($transaction)
        ->amount->toBe(1500)
        ->journalEntries->count()->toBe(2)
        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
});
it('handles multi-currency transfers correctly', function () {
    $foreignBankAccount = Account::factory()
        ->withForeignBankAccount('CAD Bank Account', 'CAD', 1.36)
        ->create();
    $foreignBankAccount->refresh();
    ConfigureCurrencies::syncCurrencies();
    // Create a transfer of 1500 CAD from the foreign bank account to USD bank account
    /** @var Transaction $transaction */
    $transaction = Transaction::factory()
        ->forBankAccount($foreignBankAccount->bankAccount)
        ->forDestinationBankAccount()
        ->asTransfer(1500)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($debitAccount->name)->toBe('Destination Bank Account') // Debit: Destination (USD) account
        ->and($creditAccount->is($foreignBankAccount))->toBeTrue(); // Credit: Foreign Bank Account (CAD) account
    // The 1500 CAD is worth approximately 1103 USD (1500 CAD / 1.36)
    $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'CAD', 'USD');
    // Verify that the debit and credit are converted to USD cents
    // Transaction amount stays in source bank account currency
    expect($transaction)
        ->amount->toBe(1500)
        ->journalEntries->count()->toBe(2)
        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
});
it('handles multi-currency deposits correctly', function () {
    $foreignBankAccount = Account::factory()
        ->withForeignBankAccount('BHD Bank Account', 'BHD', 0.38)
        ->create();
    $foreignBankAccount->refresh();
    ConfigureCurrencies::syncCurrencies();
    // Create a deposit of 1500 BHD (in fils - BHD subunits) to the foreign bank account
    /** @var Transaction $transaction */
    $transaction = Transaction::factory()
        ->forBankAccount($foreignBankAccount->bankAccount)
        ->forUncategorizedRevenue()
        ->asDeposit(1500)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($debitAccount->is($foreignBankAccount))->toBeTrue() // Debit: Foreign Bank Account (BHD) account
        ->and($creditAccount->name)->toBe('Uncategorized Income'); // Credit: Uncategorized Income (USD) account
    $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'BHD', 'USD');
    // Verify that journal entries are converted to USD cents
    expect($transaction)
        ->amount->toBe(1500)
        ->journalEntries->count()->toBe(2)
        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
});
it('handles multi-currency withdrawals correctly', function () {
    $foreignBankAccount = Account::factory()
        ->withForeignBankAccount('Foreign Bank Account', 'GBP', 0.76) // GBP account
        ->create();
    $foreignBankAccount->refresh();
    ConfigureCurrencies::syncCurrencies();
    /** @var Transaction $transaction */
    $transaction = Transaction::factory()
        ->forBankAccount($foreignBankAccount->bankAccount)
        ->forUncategorizedExpense()
        ->asWithdrawal(1500)
        ->create();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($debitAccount->name)->toBe('Uncategorized Expense')
        ->and($creditAccount->is($foreignBankAccount))->toBeTrue();
    $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'GBP', 'USD');
    expect($transaction)
        ->amount->toBe(1500)
        ->journalEntries->count()->toBe(2)
        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
});
it('can add an income or expense transaction', function (TransactionType $transactionType, string $actionName) {
    $testCompany = $this->testCompany;
    $defaultBankAccount = $testCompany->default->bankAccount;
    $defaultAccount = Transaction::getUncategorizedAccountByType($transactionType);
    livewire(ListTransactions::class)
        ->mountAction($actionName)
        ->assertActionDataSet([
            'posted_at' => company_today()->toDateString(),
            'type' => $transactionType,
            'bank_account_id' => $defaultBankAccount->id,
            'amount' => '0.00',
            'account_id' => $defaultAccount->id,
        ])
        ->setActionData([
            'amount' => '500.00',
        ])
        ->callMountedAction()
        ->assertHasNoActionErrors();
    $transaction = Transaction::first();
    expect($transaction)
        ->not->toBeNull()
        ->amount->toBe(50000) // 500.00 in cents
        ->type->toBe($transactionType)
        ->bankAccount->is($defaultBankAccount)->toBeTrue()
        ->account->is($defaultAccount)->toBeTrue()
        ->journalEntries->count()->toBe(2);
})->with([
    [TransactionType::Deposit, 'createDeposit'],
    [TransactionType::Withdrawal, 'createWithdrawal'],
]);
it('can add a transfer transaction', function () {
    $testCompany = $this->testCompany;
    $sourceBankAccount = $testCompany->default->bankAccount;
    $destinationBankAccount = Account::factory()->withBankAccount('Destination Bank Account')->create();
    livewire(ListTransactions::class)
        ->mountAction('createTransfer')
        ->assertActionDataSet([
            'posted_at' => company_today()->toDateString(),
            'type' => TransactionType::Transfer,
            'bank_account_id' => $sourceBankAccount->id,
            'amount' => '0.00',
            'account_id' => null,
        ])
        ->setActionData([
            'account_id' => $destinationBankAccount->id,
            'amount' => '1,500.00',
        ])
        ->callMountedAction()
        ->assertHasNoActionErrors();
    $transaction = Transaction::first();
    expect($transaction)
        ->not->toBeNull()
        ->amount->toBe(150000) // 1,500.00 in cents
        ->type->toBe(TransactionType::Transfer)
        ->bankAccount->is($sourceBankAccount)->toBeTrue()
        ->account->is($destinationBankAccount)->toBeTrue()
        ->journalEntries->count()->toBe(2);
});
it('can add a journal transaction', function () {
    $defaultDebitAccount = Transaction::getUncategorizedAccountByType(TransactionType::Withdrawal);
    $defaultCreditAccount = Transaction::getUncategorizedAccountByType(TransactionType::Deposit);
    $undoRepeaterFake = JournalEntryRepeater::fake();
    livewire(ListTransactions::class)
        ->mountAction('createJournalEntry')
        ->assertActionDataSet([
            'posted_at' => company_today()->toDateString(),
            'journalEntries' => [
                ['type' => JournalEntryType::Debit, 'account_id' => $defaultDebitAccount->id, 'amount' => '0.00'],
                ['type' => JournalEntryType::Credit, 'account_id' => $defaultCreditAccount->id, 'amount' => '0.00'],
            ],
        ])
        ->setActionData([
            'journalEntries' => [
                ['amount' => '1,000.00'],
                ['amount' => '1,000.00'],
            ],
        ])
        ->callMountedAction()
        ->assertHasNoActionErrors();
    $undoRepeaterFake();
    $transaction = Transaction::first();
    [$debitAccount, $creditAccount] = getTransactionDebitAndCreditAccounts($transaction);
    expect($transaction)
        ->not->toBeNull()
        ->amount->toBe(100000) // 1,000.00 in cents
        ->type->isJournal()->toBeTrue()
        ->bankAccount->toBeNull()
        ->account->toBeNull()
        ->journalEntries->count()->toBe(2)
        ->journalEntries->sumDebits()->getAmount()->toEqual(100000)
        ->journalEntries->sumCredits()->getAmount()->toEqual(100000)
        ->and($debitAccount->is($defaultDebitAccount))->toBeTrue()
        ->and($creditAccount->is($defaultCreditAccount))->toBeTrue();
});
it('can update a deposit or withdrawal transaction', function (TransactionType $transactionType) {
    $defaultAccount = Transaction::getUncategorizedAccountByType($transactionType);
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forAccount($defaultAccount)
        ->forType($transactionType, 1000)
        ->create();
    $newDescription = 'Updated Description';
    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
    livewire(ListTransactions::class)
        ->mountTableAction(EditTransactionAction::class, $transaction)
        ->assertTableActionDataSet([
            'type' => $transactionType->value,
            'description' => $transaction->description,
            'amount' => $formattedAmount,
        ])
        ->setTableActionData([
            'description' => $newDescription,
            'amount' => '1,500.00',
        ])
        ->callMountedTableAction()
        ->assertHasNoTableActionErrors();
    $transaction->refresh();
    expect($transaction->description)->toBe($newDescription)
        ->and($transaction->amount)->toBe(150000); // 1,500.00 in cents
})->with([
    TransactionType::Deposit,
    TransactionType::Withdrawal,
]);
it('can update a transfer transaction', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forDestinationBankAccount()
        ->asTransfer(1500)
        ->create();
    $newDescription = 'Updated Transfer Description';
    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
    livewire(ListTransactions::class)
        ->mountTableAction(EditTransactionAction::class, $transaction)
        ->assertTableActionDataSet([
            'type' => TransactionType::Transfer->value,
            'description' => $transaction->description,
            'amount' => $formattedAmount,
        ])
        ->setTableActionData([
            'description' => $newDescription,
            'amount' => '2,000.00',
        ])
        ->callMountedTableAction()
        ->assertHasNoTableActionErrors();
    $transaction->refresh();
    expect($transaction->description)->toBe($newDescription)
        ->and($transaction->amount)->toBe(200000); // 2,000.00 in cents
});
it('replicates a transaction with correct journal entries', function () {
    $originalTransaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asDeposit(1000)
        ->create();
    livewire(ListTransactions::class)
        ->callTableAction(ReplicateAction::class, $originalTransaction);
    $replicatedTransaction = Transaction::whereKeyNot($originalTransaction->getKey())->first();
    expect($replicatedTransaction)->not->toBeNull();
    [$originalDebitAccount, $originalCreditAccount] = getTransactionDebitAndCreditAccounts($originalTransaction);
    [$replicatedDebitAccount, $replicatedCreditAccount] = getTransactionDebitAndCreditAccounts($replicatedTransaction);
    expect($replicatedTransaction)
        ->journalEntries->count()->toBe(2)
        ->journalEntries->sumDebits()->getAmount()->toEqual(1000)
        ->journalEntries->sumCredits()->getAmount()->toEqual(1000)
        ->description->toBe('(Copy of) ' . $originalTransaction->description)
        ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
        ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
});
it('bulk replicates transactions with correct journal entries', function () {
    $originalTransactions = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asDeposit(1000)
        ->count(3)
        ->create();
    livewire(ListTransactions::class)
        ->callTableBulkAction(ReplicateBulkAction::class, $originalTransactions);
    $replicatedTransactions = Transaction::whereKeyNot($originalTransactions->modelKeys())->get();
    expect($replicatedTransactions->count())->toBe(3);
    $originalTransactions->each(function (Transaction $originalTransaction) use ($replicatedTransactions) {
        /** @var Transaction $replicatedTransaction */
        $replicatedTransaction = $replicatedTransactions->firstWhere('description', '(Copy of) ' . $originalTransaction->description);
        expect($replicatedTransaction)->not->toBeNull();
        [$originalDebitAccount, $originalCreditAccount] = getTransactionDebitAndCreditAccounts($originalTransaction);
        [$replicatedDebitAccount, $replicatedCreditAccount] = getTransactionDebitAndCreditAccounts($replicatedTransaction);
        expect($replicatedTransaction)
            ->journalEntries->count()->toBe(2)
            ->journalEntries->sumDebits()->getAmount()->toEqual(1000)
            ->journalEntries->sumCredits()->getAmount()->toEqual(1000)
            ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
            ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
    });
});
it('can delete a transaction with journal entries', function () {
    $transaction = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asDeposit(1000)
        ->create();
    expect($transaction->journalEntries()->count())->toBe(2);
    livewire(ListTransactions::class)
        ->callTableAction(DeleteAction::class, $transaction);
    $this->assertModelMissing($transaction);
    $this->assertDatabaseEmpty('journal_entries');
});
it('can bulk delete transactions with journal entries', function () {
    $transactions = Transaction::factory()
        ->forDefaultBankAccount()
        ->forUncategorizedRevenue()
        ->asDeposit(1000)
        ->count(3)
        ->create();
    expect($transactions->count())->toBe(3);
    livewire(ListTransactions::class)
        ->callTableBulkAction(DeleteBulkAction::class, $transactions);
    $this->assertDatabaseEmpty('transactions');
    $this->assertDatabaseEmpty('journal_entries');
});
 |