<?php

namespace Tests\Feature;

use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\RevenueRouter;
use App\Domain\Financial\Services\RevenueRoutingService;
use App\Domain\Inventory\Models\Product;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

/**
 * Revenue routing: where a collected pound lands, and what part of it was tax.
 *
 * Runs against a restored Postgres tenant rather than the SQLite suite. The
 * seeded chart of accounts and the CHECK constraints on the routing tables are
 * the fixture — rebuilding them by hand would be testing a different system,
 * and the constraints are half of what makes a rule safe:
 *
 *   DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_DATABASE=oc_sport_test \
 *   DB_USERNAME=mahmoudaglan DB_PASSWORD= ./vendor/bin/phpunit --filter RevenueRoutingTest
 *
 * Every case runs inside a transaction that is rolled back, so the copy is left
 * exactly as it was found.
 */
class RevenueRoutingTest extends TestCase
{
    private int $academyId;

    protected function setUp(): void
    {
        parent::setUp();

        if (config('database.default') !== 'pgsql') {
            $this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
        }

        DB::beginTransaction();

        $academy = DB::table('academies')->orderBy('id')->first();
        $this->academyId = (int) $academy->id;

        app()->instance('current_academy', \App\Domain\Shared\Models\Academy::withoutGlobalScopes()->find($this->academyId));
    }

    protected function tearDown(): void
    {
        if (config('database.default') === 'pgsql') {
            DB::rollBack();
        }

        parent::tearDown();
    }

    private function router(): RevenueRouter
    {
        // A fresh instance per call: the router memoises resolved rules, and a
        // test that edits a rule must not read the copy from before the edit.
        return new RevenueRouter(app(\App\Domain\Financial\Services\LedgerAccountResolver::class));
    }

    private function service(): RevenueRoutingService
    {
        return app(RevenueRoutingService::class);
    }

    private function account(string $code): FinancialAccount
    {
        return FinancialAccount::withoutGlobalScopes()
            ->where('academy_id', $this->academyId)
            ->where('code', $code)
            ->firstOrFail();
    }

    private function actor(): User
    {
        return User::query()->withoutGlobalScopes()->firstOrFail();
    }

    // ---- the seed changes nothing ------------------------------------------

    public function test_the_installed_defaults_reproduce_the_old_hardcoded_behaviour(): void
    {
        foreach ([
            [RevenueSource::Subscription, '4000'],
            [RevenueSource::Product, '4020'],
            [RevenueSource::Kit, '4020'],
            [RevenueSource::Event, '4050'],
            [RevenueSource::SpaceReservation, '4030'],
            [RevenueSource::External, '4060'],
        ] as [$source, $expectedCode]) {
            $allocation = $this->router()->allocateSource($this->academyId, $source, 50_000);

            $this->assertTrue($allocation->isBalanced());
            $this->assertCount(1, $allocation->postings, "{$source->value} should post to exactly one account out of the box");
            $this->assertSame($expectedCode, $allocation->postings[0]->account->code);
            $this->assertSame(50_000, $allocation->postings[0]->amount);
            $this->assertSame(0, $allocation->tax);
        }
    }

    public function test_the_platform_fee_is_left_folded_in_until_someone_asks_for_it(): void
    {
        // Shipped inactive: carving it out on the night of a deploy would move
        // money between accounts in every client's books unasked.
        $this->assertNull(
            $this->router()->mappingFor($this->academyId, RevenueSource::ServiceFee),
            'service_fee must ship inactive so today\'s split is untouched'
        );
    }

    // ---- the arithmetic ----------------------------------------------------

    public function test_a_flat_amount_a_percentage_and_the_remainder_split_exactly(): void
    {
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::None->value,
        ], [
            ['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 500],
            ['account_id' => $this->account('4060')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 1000],
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        // 1,000.00 EGP: 5.00 flat, 10% of the whole (100.00), 895.00 left.
        $allocation = $this->router()->allocateSource($this->academyId, RevenueSource::Subscription, 100_000);

        $this->assertTrue($allocation->isBalanced());

        $byCode = $this->byCode($allocation);
        $this->assertSame(500, $byCode['4010']);
        $this->assertSame(10_000, $byCode['4060']);
        $this->assertSame(89_500, $byCode['4000']);
    }

    public function test_vat_is_carved_out_of_an_inclusive_price_and_credited_to_a_liability(): void
    {
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::Inclusive->value,
            'tax_rate_bp' => 1400,
            'tax_account_id' => $this->account('2030')->id,
        ], [
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        // 1,140.00 collected at 14% inclusive is exactly 1,000.00 + 140.00.
        $allocation = $this->router()->allocateSource($this->academyId, RevenueSource::Subscription, 114_000);

        $this->assertTrue($allocation->isBalanced());
        $this->assertSame(14_000, $allocation->tax);
        $this->assertSame(100_000, $allocation->net);

        $byCode = $this->byCode($allocation);
        $this->assertSame(100_000, $byCode['4000']);
        $this->assertSame(14_000, $byCode['2030']);

        $tax = $allocation->taxPostings();
        $this->assertCount(1, $tax);
        $this->assertSame('liability', $tax[0]->account->type->value, 'VAT is owed, not earned');
    }

    public function test_tax_and_split_together_still_balance_on_an_amount_that_does_not_divide(): void
    {
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::Inclusive->value,
            'tax_rate_bp' => 1400,
            'tax_account_id' => $this->account('2030')->id,
        ], [
            ['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 333],
            ['account_id' => $this->account('4060')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 3333],
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        $router = $this->router();

        // Piaster-level fuzz. Nothing here may ever post more or less than came
        // in — an unbalanced entry is a ledger that stops adding up, and it can
        // only ever be reversed afterwards, never corrected.
        foreach ([1, 7, 99, 101, 333, 1_000, 12_345, 99_999, 100_001, 7_777_777] as $amount) {
            $allocation = $router->allocateSource($this->academyId, RevenueSource::Subscription, $amount);

            $this->assertSame(
                $amount,
                $allocation->totalPosted(),
                "Allocating {$amount} piasters did not post {$amount} piasters"
            );
            $this->assertSame($amount, $allocation->net + $allocation->tax);

            foreach ($allocation->postings as $posting) {
                $this->assertGreaterThan(0, $posting->amount, 'No zero or negative postings');
            }
        }
    }

    public function test_a_fixed_amount_larger_than_the_payment_cannot_post_money_that_never_arrived(): void
    {
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::None->value,
        ], [
            ['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 50_000],
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        // Someone pays 100.00 against a rule that wants 500.00 off the top.
        $allocation = $this->router()->allocateSource($this->academyId, RevenueSource::Subscription, 10_000);

        $this->assertTrue($allocation->isBalanced());
        $this->assertSame(10_000, $this->byCode($allocation)['4010']);
        $this->assertArrayNotHasKey('4000', $this->byCode($allocation));
    }

    // ---- what the rules refuse ---------------------------------------------

    public function test_percentages_that_do_not_reach_a_hundred_are_refused(): void
    {
        $this->expectException(DomainException::class);
        $this->expectExceptionMessageMatches('/100%/u');

        $this->service()->save($this->academyId, RevenueSource::Subscription, [], [
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 6000],
            ['account_id' => $this->account('4010')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 3000],
        ]);
    }

    public function test_two_remainder_lines_are_refused(): void
    {
        $this->expectException(DomainException::class);

        $this->service()->save($this->academyId, RevenueSource::Subscription, [], [
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
            ['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder'],
        ]);
    }

    public function test_tax_may_not_be_credited_to_a_revenue_account(): void
    {
        $this->expectException(DomainException::class);
        $this->expectExceptionMessageMatches('/التزامات/u');

        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::Inclusive->value,
            'tax_rate_bp' => 1400,
            'tax_account_id' => $this->account('4000')->id,
        ], [
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);
    }

    public function test_collected_money_may_not_be_routed_to_a_cash_or_expense_account(): void
    {
        $this->expectException(DomainException::class);

        $this->service()->save($this->academyId, RevenueSource::Subscription, [], [
            ['account_id' => $this->account('1000')->id, 'allocation_type' => 'remainder'],
        ]);
    }

    public function test_a_rule_with_no_lines_is_refused(): void
    {
        $this->expectException(DomainException::class);

        $this->service()->save($this->academyId, RevenueSource::Subscription, [], []);
    }

    // ---- specific beats general --------------------------------------------

    public function test_a_rule_written_for_one_product_beats_the_default(): void
    {
        $product = Product::withoutGlobalScopes()->where('academy_id', $this->academyId)->firstOrFail();

        $this->service()->save(
            $this->academyId,
            RevenueSource::Product,
            ['tax_mode' => TaxMode::None->value],
            [['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder']],
            null,
            Product::class,
            $product->id,
        );

        $router = $this->router();

        $specific = $router->allocateSource($this->academyId, RevenueSource::Product, 20_000, Product::class, $product->id);
        $general = $router->allocateSource($this->academyId, RevenueSource::Product, 20_000, Product::class, $product->id + 999_999);

        $this->assertSame('4010', $specific->postings[0]->account->code);
        $this->assertSame('4020', $general->postings[0]->account->code, 'Other products keep the default');
    }

    // ---- end to end: money actually moves ----------------------------------

    public function test_recording_a_payment_writes_the_configured_accounts_to_the_ledger(): void
    {
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::Inclusive->value,
            'tax_rate_bp' => 1400,
            'tax_account_id' => $this->account('2030')->id,
        ], [
            ['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 1_000],
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        $invoice = Invoice::withoutGlobalScopes()
            ->where('academy_id', $this->academyId)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->whereNull('deleted_at')
            ->whereHas('items', fn ($q) => $q->withoutGlobalScopes()->whereNull('itemable_type'))
            ->orderBy('id')
            ->firstOrFail();

        $amount = min(114_000, (int) $invoice->due_amount);
        $before = Transaction::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count();

        app(PaymentService::class)->recordPayment([
            'academy_id' => $this->academyId,
            'branch_id' => $invoice->branch_id,
            'invoice_id' => $invoice->id,
            'amount' => $amount,
            'method' => 'cash',
            'direction' => 'inbound',
            'payment_date' => now()->toDateString(),
        ], $this->actor());

        $written = Transaction::withoutGlobalScopes()
            ->where('invoice_id', $invoice->id)
            ->orderByDesc('id')
            ->limit(Transaction::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count() - $before)
            ->get();

        $this->assertGreaterThan(0, $written->count(), 'The payment must reach the ledger');

        // Every row debits the money that arrived and credits where it went,
        // and the credits add back up to the payment.
        $this->assertSame($amount, (int) $written->sum('amount'), 'The ledger rows must sum to the payment');

        $cash = $this->account('1000')->id;
        foreach ($written as $row) {
            $this->assertSame($cash, (int) $row->debit_account_id, 'Cash was debited');
        }

        $credited = $written->pluck('credit_account_id')->map(
            fn ($id) => FinancialAccount::withoutGlobalScopes()->find($id)->code
        )->all();

        $this->assertContains('2030', $credited, 'The VAT portion must land in the liability account');
        $this->assertContains('4010', $credited, 'The flat portion must land where the rule says');

        // The rule that produced each row is written on the row itself, because
        // the rule can be edited tomorrow and the entry cannot.
        $this->assertNotEmpty($written->first()->metadata['routing_source'] ?? null);
    }

    public function test_a_refund_unwinds_the_accounts_the_money_went_into_even_after_the_rule_changed(): void
    {
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::Inclusive->value,
            'tax_rate_bp' => 1400,
            'tax_account_id' => $this->account('2030')->id,
        ], [
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        $invoice = Invoice::withoutGlobalScopes()
            ->where('academy_id', $this->academyId)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->whereNull('deleted_at')
            ->whereHas('items', fn ($q) => $q->withoutGlobalScopes()->whereNull('itemable_type'))
            ->orderBy('id')
            ->firstOrFail();

        $amount = min(114_000, (int) $invoice->due_amount);

        $payment = app(PaymentService::class)->recordPayment([
            'academy_id' => $this->academyId,
            'branch_id' => $invoice->branch_id,
            'invoice_id' => $invoice->id,
            'amount' => $amount,
            'method' => 'cash',
            'direction' => 'inbound',
            'payment_date' => now()->toDateString(),
        ], $this->actor());

        $creditedOnCollection = Transaction::withoutGlobalScopes()
            ->where('payment_id', $payment->id)
            ->pluck('amount', 'credit_account_id');

        // The accountant changes their mind after the money was taken.
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::None->value,
        ], [
            ['account_id' => $this->account('4060')->id, 'allocation_type' => 'remainder'],
        ]);

        app(\App\Domain\Financial\Services\RefundService::class)
            ->processRefunds([$payment->uuid], 'اختبار', $this->actor());

        $refund = \App\Domain\Financial\Models\Payment::withoutGlobalScopes()
            ->where('metadata->refund_of_payment_id', $payment->id)
            ->latest('id')
            ->firstOrFail();

        $debited = Transaction::withoutGlobalScopes()
            ->where('payment_id', $refund->id)
            ->pluck('amount', 'debit_account_id');

        $this->assertSame(
            $creditedOnCollection->keys()->sort()->values()->all(),
            $debited->keys()->sort()->values()->all(),
            'A refund debits the accounts the collection credited, not the ones today\'s rule names'
        );

        foreach ($creditedOnCollection as $accountId => $credited) {
            $this->assertSame((int) $credited, (int) $debited[$accountId]);
        }

        $this->assertSame($amount, (int) $debited->sum());
    }

    public function test_an_untouched_academy_still_posts_exactly_where_it_always_did(): void
    {
        $invoice = Invoice::withoutGlobalScopes()
            ->where('academy_id', $this->academyId)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->whereNull('deleted_at')
            ->orderBy('id')
            ->firstOrFail();

        $allocation = $this->router()->allocatePayment($this->academyId, $invoice, (int) $invoice->due_amount);

        $this->assertTrue($allocation->isBalanced());
        $this->assertSame(0, $allocation->tax, 'No tax until an accountant configures one');

        foreach ($allocation->postings as $posting) {
            $this->assertSame('revenue', $posting->account->type->value);
        }
    }

    // ---- one invoice, two kinds of money, two tax treatments ---------------

    public function test_each_line_of_a_mixed_invoice_obeys_its_own_rule_and_its_own_tax(): void
    {
        // Training exempt, merchandise at 14% inclusive — the ordinary Egyptian
        // case, and the one the old hardcoded split could not express at all.
        $this->service()->save($this->academyId, RevenueSource::Subscription, [
            'tax_mode' => TaxMode::None->value,
        ], [
            ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
        ]);

        $this->service()->save($this->academyId, RevenueSource::Product, [
            'tax_mode' => TaxMode::Inclusive->value,
            'tax_rate_bp' => 1400,
            'tax_account_id' => $this->account('2030')->id,
        ], [
            ['account_id' => $this->account('4020')->id, 'allocation_type' => 'remainder'],
        ]);

        $invoice = $this->mixedInvoice();

        $lines = DB::table('invoice_items')->where('invoice_id', $invoice->id)
            ->selectRaw("coalesce(sum(total_amount) FILTER (WHERE itemable_type IS NULL), 0) AS subs,
                         coalesce(sum(total_amount) FILTER (WHERE itemable_type IS NOT NULL), 0) AS products")
            ->first();

        $base = (int) $lines->subs + (int) $lines->products;
        $allocation = $this->router()->allocatePayment($this->academyId, $invoice, $base);

        $this->assertTrue($allocation->isBalanced());

        $byCode = $this->byCode($allocation);

        // The subscription half is untaxed and lands whole in 4000.
        $this->assertSame((int) $lines->subs, $byCode['4000']);

        // The merchandise half is 14% inclusive, so it splits into net + VAT.
        $productShare = (int) $lines->products;
        $expectedNet = intdiv($productShare * 10000, 11400);

        $this->assertSame($expectedNet, $byCode['4020']);
        $this->assertSame($productShare - $expectedNet, $byCode['2030']);
        $this->assertSame($productShare - $expectedNet, $allocation->tax);
    }

    public function test_a_partial_payment_splits_across_the_lines_in_the_invoice_s_own_proportions(): void
    {
        $invoice = $this->mixedInvoice();
        $half = intdiv((int) $invoice->total_amount, 2);

        $allocation = $this->router()->allocatePayment($this->academyId, $invoice, $half);

        $this->assertSame($half, $allocation->totalPosted(), 'A part payment posts exactly what was paid');
        $this->assertGreaterThan(1, count($allocation->postings), 'Both kinds of line take a share');
    }

    public function test_the_platform_fee_becomes_its_own_line_once_the_academy_turns_it_on(): void
    {
        $invoice = $this->mixedInvoice();

        // A fee on an invoice that has none, so the assertion is about the rule
        // rather than about this tenant's data.
        DB::table('invoices')->where('id', $invoice->id)->update([
            'service_fee_amount' => 5_000,
            'total_amount' => (int) $invoice->total_amount + 5_000,
        ]);

        $invoice->refresh();

        $before = $this->router()->allocatePayment($this->academyId, $invoice, (int) $invoice->total_amount);
        $this->assertArrayNotHasKey('4060', $this->byCode($before), 'Inactive: the fee stays folded into the lines');

        $this->service()->save($this->academyId, RevenueSource::ServiceFee, [
            'is_active' => true,
            'tax_mode' => TaxMode::None->value,
        ], [
            ['account_id' => $this->account('4060')->id, 'allocation_type' => 'remainder'],
        ]);

        $after = $this->router()->allocatePayment($this->academyId, $invoice, (int) $invoice->total_amount);

        $this->assertTrue($after->isBalanced());
        $this->assertSame(5_000, $this->byCode($after)['4060'], 'Active: the fee lands in its own account');
    }

    private function mixedInvoice(): Invoice
    {
        $id = DB::table('invoices as i')
            ->join('invoice_items as ii', 'ii.invoice_id', '=', 'i.id')
            ->where('i.academy_id', $this->academyId)
            ->whereNull('i.deleted_at')
            ->groupBy('i.id')
            ->havingRaw('count(*) FILTER (WHERE ii.itemable_type IS NULL) > 0')
            ->havingRaw('count(*) FILTER (WHERE ii.itemable_type IS NOT NULL) > 0')
            ->orderBy('i.id')
            ->value('i.id');

        if (! $id) {
            $this->markTestSkipped('No invoice mixing a subscription line and a product line on this tenant.');
        }

        return Invoice::withoutGlobalScopes()->findOrFail($id);
    }

    public function test_an_override_for_an_item_from_another_tenant_is_refused(): void
    {
        $this->expectException(DomainException::class);

        $this->service()->save(
            $this->academyId,
            RevenueSource::Product,
            [],
            [['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder']],
            null,
            Product::class,
            999_999_999,
        );
    }

    // ---- the screen --------------------------------------------------------

    public function test_the_list_renders_an_override_next_to_the_rule_it_narrows(): void
    {
        $owner = User::withoutGlobalScopes()
            ->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
            ->first();

        if (! $owner) {
            $this->markTestSkipped('No academy_owner in the restored tenant.');
        }

        $product = Product::withoutGlobalScopes()->where('academy_id', $this->academyId)->firstOrFail();

        $this->service()->save(
            $this->academyId,
            RevenueSource::Product,
            ['tax_mode' => TaxMode::None->value],
            [['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder']],
            null,
            Product::class,
            $product->id,
        );

        $this->actingAs($owner);

        \Livewire\Livewire::test(\App\Livewire\Financial\RevenueRouting::class)
            ->assertOk()
            ->assertSee('تخصيص')
            ->assertSee((string) ($product->name_ar ?: $product->name))
            // And it opens, which is the markup that only exists for overrides.
            ->call('editRule', RevenueSource::Product->value, $product->id)
            ->assertSet('scopeId', $product->id)
            ->assertSee('وحده');
    }

    public function test_the_editor_renders_and_previews_the_entry_a_rule_would_produce(): void
    {
        $owner = User::withoutGlobalScopes()
            ->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
            ->first();

        if (! $owner) {
            $this->markTestSkipped('No academy_owner in the restored tenant.');
        }

        $this->actingAs($owner);

        \Livewire\Livewire::test(\App\Livewire\Financial\RevenueRouting::class)
            ->assertOk()
            ->assertSee('توجيه الإيرادات')
            ->call('editRule', RevenueSource::Subscription->value)
            ->assertSet('editingSource', RevenueSource::Subscription->value)
            ->set('taxMode', TaxMode::Inclusive->value)
            ->set('taxRateDisplay', '14')
            ->set('taxAccountId', $this->account('2030')->id)
            ->set('lines', [
                ['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'value' => '5', 'label' => ''],
                ['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder', 'value' => '', 'label' => ''],
            ])
            ->set('previewAmount', '1140')
            // 1,140.00 inclusive of 14% = 1,000.00 net + 140.00 tax; the flat
            // 5.00 comes off the net, leaving 995.00.
            ->assertSee('140.00')
            ->assertSee('995.00')
            ->assertSee('القيد متوازن')
            ->call('save')
            ->assertHasNoErrors()
            ->assertSet('editingSource', null);

        $mapping = $this->router()->mappingFor($this->academyId, RevenueSource::Subscription);

        $this->assertSame(TaxMode::Inclusive, $mapping->tax_mode);
        $this->assertSame(1400, $mapping->tax_rate_bp);
        $this->assertSame('2030', $mapping->taxAccount->code);
        $this->assertCount(2, $mapping->lines);
        $this->assertSame(500, (int) $mapping->lines->firstWhere('allocation_type', AllocationType::Fixed)->fixed_amount);
    }

    public function test_a_rule_the_screen_refuses_never_reaches_the_database(): void
    {
        $owner = User::withoutGlobalScopes()
            ->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
            ->first();

        if (! $owner) {
            $this->markTestSkipped('No academy_owner in the restored tenant.');
        }

        $this->actingAs($owner);

        \Livewire\Livewire::test(\App\Livewire\Financial\RevenueRouting::class)
            ->call('editRule', RevenueSource::Subscription->value)
            ->set('lines', [
                ['account_id' => $this->account('4000')->id, 'allocation_type' => 'percentage', 'value' => '60', 'label' => ''],
                ['account_id' => $this->account('4010')->id, 'allocation_type' => 'percentage', 'value' => '30', 'label' => ''],
            ])
            ->call('save')
            // The editor stays open on the rule that was refused rather than
            // closing over a change that did not happen.
            ->assertSet('editingSource', RevenueSource::Subscription->value);

        $mapping = $this->router()->mappingFor($this->academyId, RevenueSource::Subscription);

        $this->assertCount(1, $mapping->lines, 'The refused rule must not have been written');
        $this->assertSame(AllocationType::Remainder, $mapping->lines->first()->allocation_type);
    }

    /** @return array<string, int> */
    private function byCode($allocation): array
    {
        $out = [];

        foreach ($allocation->collapsed()->postings as $posting) {
            $out[$posting->account->code] = ($out[$posting->account->code] ?? 0) + $posting->amount;
        }

        return $out;
    }
}
