Sfoglia il codice sorgente

feat: add comprehensive tests for student billing revenue and installment generation

alvesantos 1 giorno fa
parent
commit
ed28947b6a

+ 154 - 0
tests/Unit/Financeiro/StudentBillingRevenueTest.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace Tests\Unit\Financeiro;
+
+use App\Models\City;
+use App\Models\Country;
+use App\Models\FranchiseeAccountReceive;
+use App\Models\State;
+use App\Models\Student;
+use App\Models\Unit;
+use App\Services\StudentContractService;
+use App\Services\TbrCalculationService;
+use App\Services\UnitFinancialDashboardService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+/**
+ * Ponta a ponta: contrato de aluno assinado -> parcelas geradas -> faturamento
+ * da unidade. Cobre o pedido de "ver se o faturamento da unidade está sendo
+ * cobrado corretamente" usando o fluxo real (StudentContractService), não
+ * inserindo parcela direto no banco como os outros testes de TBR fazem.
+ */
+class StudentBillingRevenueTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private Unit $unit;
+
+    private StudentContractService $contractService;
+
+    private TbrCalculationService $tbrService;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->unit = $this->makeUnit();
+        $this->contractService = new StudentContractService();
+        $this->tbrService = new TbrCalculationService();
+    }
+
+    private function makeUnit(): Unit
+    {
+        $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
+        $state   = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
+        $city    = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
+
+        return Unit::create([
+            'fantasy_name' => 'Unidade Teste', 'social_reason' => 'Unidade Teste LTDA', 'cnpj' => '00000000000191',
+            'street' => 'Rua Teste', 'neighborhood' => 'Centro', 'postal_code' => '87000000',
+            'city_id' => $city->id, 'state_id' => $state->id,
+            'email' => 'unidade@teste.com', 'name_responsible' => 'Responsável Teste',
+        ]);
+    }
+
+    /**
+     * Regra: O faturamento de um mês soma as parcelas de aluno com vencimento
+     * naquele mês — incluindo a entrada, quando cai no mesmo mês, e mesmo entre
+     * alunos/contratos diferentes.
+     *
+     * Cenário: Aluno A com pacote em 3x de R$ 100 (venc. dia 10) e entrada de
+     * R$ 500 vencendo no mesmo mês da 1ª parcela; Aluno B com matrícula de R$ 150
+     * vencendo no mesmo mês.
+     *
+     * Espera: Faturamento de 08/2026 = 100 (1ª parcela do pacote) + 500 (entrada) + 150 (matrícula) = 750.
+     * Faturamento de 09/2026 = 100 (2ª parcela do pacote, sozinha).
+     */
+    public function test_faturamento_do_mes_soma_parcelas_de_contratos_diferentes_incluindo_entrada(): void
+    {
+        $alunoA = Student::create(['name' => 'Aluno A', 'unit_id' => $this->unit->id, 'status' => 'active']);
+        $this->contractService->create([
+            'student_id'           => $alunoA->id,
+            'unit_id'              => $this->unit->id,
+            'recurring_day'        => 10,
+            'down_payment_value'   => 500.00,
+            'down_payment_date'    => '2026-08-10',
+            'package_value'        => 300.00,
+            'package_installments' => 3,
+            'package_due_date'     => '2026-08-10',
+        ]);
+
+        $alunoB = Student::create(['name' => 'Aluno B', 'unit_id' => $this->unit->id, 'status' => 'active']);
+        $this->contractService->create([
+            'student_id'          => $alunoB->id,
+            'unit_id'             => $this->unit->id,
+            'tax_register'        => 150.00,
+            'installments'        => 1,
+            'enrollment_due_date' => '2026-08-20',
+        ]);
+
+        $revenueAgosto = $this->tbrService->resolveRevenue($this->unit->id, 2026, 8);
+        $revenueSetembro = $this->tbrService->resolveRevenue($this->unit->id, 2026, 9);
+
+        $this->assertSame(750.0, $revenueAgosto);
+        $this->assertSame(100.0, $revenueSetembro);
+    }
+
+    /**
+     * Regra: A entrada com vencimento num mês diferente das parcelas normais
+     * entra no faturamento do MÊS EM QUE ELA VENCE, não no mês da 1ª parcela.
+     */
+    public function test_entrada_com_vencimento_em_mes_diferente_entra_no_faturamento_do_seu_proprio_mes(): void
+    {
+        $aluno = Student::create(['name' => 'Aluno Teste', 'unit_id' => $this->unit->id, 'status' => 'active']);
+
+        $this->contractService->create([
+            'student_id'           => $aluno->id,
+            'unit_id'              => $this->unit->id,
+            'recurring_day'        => 10,
+            'down_payment_value'   => 500.00,
+            'down_payment_date'    => '2026-07-25', // entrada assinada antes, vencendo em julho
+            'package_value'        => 300.00,
+            'package_installments' => 3,
+            'package_due_date'     => '2026-08-10', // parcelas normais começam em agosto
+        ]);
+
+        $this->assertSame(500.0, $this->tbrService->resolveRevenue($this->unit->id, 2026, 7));
+        $this->assertSame(100.0, $this->tbrService->resolveRevenue($this->unit->id, 2026, 8));
+    }
+
+    /**
+     * Atenção (achado da investigação, não é uma regra de negócio pedida):
+     * O dashboard financeiro da unidade (UnitFinancialDashboardService::overview,
+     * campo receivable_pending) e o faturamento usado pelo cálculo de royalties
+     * (TbrCalculationService::resolveRevenueBetween) NÃO somam as mesmas fontes.
+     * O dashboard não inclui FranchiseeAccountReceive com origin=manual_unit
+     * (contas que a franqueadora vincula à unidade); o cálculo de TBR inclui.
+     *
+     * Este teste documenta o comportamento atual — não afirma qual dos dois
+     * está "certo". Se um dia precisarem bater, um dos dois serviços muda.
+     */
+    public function test_atencao_dashboard_da_unidade_diverge_do_faturamento_usado_no_tbr(): void
+    {
+        FranchiseeAccountReceive::create([
+            'unit_id'  => $this->unit->id,
+            'origin'   => 'manual_unit',
+            'history'  => 'Conta vinculada pela franqueadora',
+            'value'    => 1000,
+            'due_date' => now()->toDateString(),
+            'status'   => 'pending',
+        ]);
+
+        $tbrRevenueBetween = $this->tbrService->resolveRevenueBetween(
+            [$this->unit->id],
+            now()->startOfMonth(),
+            now()->endOfMonth(),
+        );
+
+        $dashboard = (new UnitFinancialDashboardService())->overview($this->unit->id);
+
+        $this->assertSame(1000.0, $tbrRevenueBetween);
+        $this->assertSame(0.0, $dashboard['receivable_pending']);
+    }
+}

+ 371 - 0
tests/Unit/StudentContractInstallmentGenerationTest.php

@@ -0,0 +1,371 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Models\City;
+use App\Models\Country;
+use App\Models\State;
+use App\Models\Student;
+use App\Models\StudentContractInstallment;
+use App\Models\Unit;
+use App\Services\StudentContractService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+/**
+ * Geração de parcelas a partir do contrato de aluno assinado.
+ *
+ * O contrato não tem um "pacote" único: tem 5 blocos independentes (Matrícula,
+ * Entrada, Pacote, Materiais, Total do Curso). Cada bloco só gera parcela
+ * quando tem valor + quantidade de parcelas + data de vencimento preenchidos —
+ * e cada um tem sua própria data-base, por isso a Entrada pode cair num dia
+ * diferente das demais parcelas.
+ */
+class StudentContractInstallmentGenerationTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private Unit $unit;
+
+    private StudentContractService $service;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->unit = $this->makeUnit();
+        $this->service = new StudentContractService();
+    }
+
+    private function makeUnit(): Unit
+    {
+        $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
+        $state   = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
+        $city    = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
+
+        return Unit::create([
+            'fantasy_name'     => 'Unidade Teste',
+            'social_reason'    => 'Unidade Teste LTDA',
+            'cnpj'             => '00000000000191',
+            'street'           => 'Rua Teste',
+            'neighborhood'     => 'Centro',
+            'postal_code'      => '87000000',
+            'city_id'          => $city->id,
+            'state_id'         => $state->id,
+            'email'            => 'unidade@teste.com',
+            'name_responsible' => 'Responsável Teste',
+        ]);
+    }
+
+    private function makeStudent(string $name = 'Aluno Teste'): Student
+    {
+        return Student::create([
+            'name'    => $name,
+            'unit_id' => $this->unit->id,
+            'status'  => 'active',
+        ]);
+    }
+
+    private function installmentsFor(int $contractId, ?string $type = null)
+    {
+        return StudentContractInstallment::where('student_contract_id', $contractId)
+            ->when($type, fn ($q) => $q->where('type', $type))
+            ->orderBy('installment_number')
+            ->get();
+    }
+
+    // -------------------------------------------------------------- blocos isolados
+
+    public function test_bloco_matricula_gera_parcela_unica_com_data_e_historico_proprios(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'          => $student->id,
+            'unit_id'             => $this->unit->id,
+            'tax_register'        => 150.00,
+            'installments'        => 1,
+            'enrollment_due_date' => '2026-08-05',
+        ]);
+
+        $installments = $this->installmentsFor($contract->id, 'enrollment');
+
+        $this->assertCount(1, $installments);
+        $this->assertSame('REF. MATRÍCULA', $installments->first()->history);
+        $this->assertEquals(150.00, $installments->first()->value);
+        $this->assertSame('2026-08-05', $installments->first()->due_date->format('Y-m-d'));
+    }
+
+    public function test_bloco_pacote_gera_parcelas_proporcionais_ao_valor_informado(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'            => $student->id,
+            'unit_id'               => $this->unit->id,
+            'package_value'         => 1200.00,
+            'package_installments'  => 12,
+            'package_due_date'      => '2026-08-10',
+            'recurring_day'         => 10,
+        ]);
+
+        $installments = $this->installmentsFor($contract->id, 'package');
+
+        $this->assertCount(12, $installments);
+        $this->assertSame('REF. PACOTE', $installments->first()->history);
+        $this->assertEquals(100.00, $installments->first()->value);
+    }
+
+    public function test_bloco_materiais_gera_parcelas_proporcionais_ao_valor_informado(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'              => $student->id,
+            'unit_id'                 => $this->unit->id,
+            'materials_value'         => 300.00,
+            'materials_installments'  => 3,
+            'materials_due_date'      => '2026-08-10',
+            'recurring_day'           => 10,
+        ]);
+
+        $installments = $this->installmentsFor($contract->id, 'materials');
+
+        $this->assertCount(3, $installments);
+        $this->assertSame('REF. MATERIAIS', $installments->first()->history);
+        $this->assertEquals(100.00, $installments->first()->value);
+    }
+
+    /**
+     * Regra: Um bloco sem uma das 3 pernas (valor, parcelas, vencimento) não gera
+     * nenhuma parcela — nem parcial.
+     */
+    public function test_bloco_sem_vencimento_nao_gera_parcela(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'     => $student->id,
+            'unit_id'        => $this->unit->id,
+            'package_value'  => 1200.00,
+            'package_installments' => 12,
+            // sem package_due_date
+        ]);
+
+        $this->assertCount(0, $this->installmentsFor($contract->id));
+    }
+
+    // ------------------------------------------------------ blocos combinados
+
+    /**
+     * Regra: Matrícula, Entrada, Pacote e Materiais coexistem no mesmo contrato,
+     * cada um com sua própria contagem de parcelas e sua própria data-base —
+     * exatamente o "quantas vezes foi parcelado, teve entrada, valores corretos
+     * por vencimento" que a análise pede.
+     */
+    public function test_contrato_completo_gera_todos_os_blocos_de_forma_independente(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'              => $student->id,
+            'unit_id'                 => $this->unit->id,
+            'recurring_day'           => 10,
+            'tax_register'            => 150.00,
+            'installments'            => 1,
+            'enrollment_due_date'     => '2026-08-05',
+            'down_payment_value'      => 500.00,
+            'down_payment_date'       => '2026-08-20',
+            'package_value'           => 1200.00,
+            'package_installments'    => 12,
+            'package_due_date'        => '2026-09-10',
+            'materials_value'         => 300.00,
+            'materials_installments'  => 3,
+            'materials_due_date'      => '2026-09-10',
+        ]);
+
+        $all = $this->installmentsFor($contract->id);
+        $this->assertCount(1 + 1 + 12 + 3, $all);
+
+        $this->assertCount(1, $all->where('type', 'enrollment'));
+        $this->assertCount(1, $all->where('type', 'down_payment'));
+        $this->assertCount(12, $all->where('type', 'package'));
+        $this->assertCount(3, $all->where('type', 'materials'));
+
+        // Entrada tem data própria, diferente do dia recorrente (10) das demais parcelas.
+        $this->assertSame('2026-08-20', $all->where('type', 'down_payment')->first()->due_date->format('Y-m-d'));
+    }
+
+    /**
+     * Regra: A entrada só abate do bloco "Total do Curso" — não abate de "Pacote".
+     * Achado da investigação: são fluxos diferentes (pricing_mode novo usa Total;
+     * pacote é o fluxo antigo) e hoje não se combinam automaticamente.
+     */
+    public function test_entrada_nao_abate_do_bloco_pacote(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'            => $student->id,
+            'unit_id'               => $this->unit->id,
+            'recurring_day'         => 10,
+            'down_payment_value'    => 500.00,
+            'down_payment_date'     => '2026-08-20',
+            'package_value'         => 1200.00,
+            'package_installments'  => 12,
+            'package_due_date'      => '2026-09-10',
+        ]);
+
+        $package = $this->installmentsFor($contract->id, 'package');
+
+        // Se a entrada abatesse, seria (1200-500)/12 = 58,33. Sem abater: 1200/12 = 100.
+        $this->assertEquals(100.00, $package->first()->value);
+    }
+
+    /**
+     * Regra: A entrada abate do bloco "Total do Curso" antes de dividir as
+     * parcelas restantes (reforça o teste já existente em
+     * StudentContractDownPaymentTest, agora junto com outros blocos no mesmo contrato).
+     */
+    public function test_entrada_abate_do_bloco_total_mesmo_com_outros_blocos_presentes(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'          => $student->id,
+            'unit_id'             => $this->unit->id,
+            'recurring_day'       => 10,
+            'tax_register'        => 150.00,
+            'installments'        => 1,
+            'enrollment_due_date' => '2026-08-05',
+            'down_payment_value'  => 500.00,
+            'down_payment_date'   => '2026-08-20',
+            'total_value'         => 5843.00,
+            'total_installments'  => 13,
+            'total_due_date'      => '2026-09-10',
+        ]);
+
+        $total = $this->installmentsFor($contract->id, 'total');
+        $this->assertEquals(411.00, $total->first()->value); // (5843-500)/13
+
+        // Matrícula não é afetada pela entrada.
+        $enrollment = $this->installmentsFor($contract->id, 'enrollment');
+        $this->assertEquals(150.00, $enrollment->first()->value);
+    }
+
+    // --------------------------------------------------- datas (buildInstallmentDates)
+
+    /**
+     * Regra: A partir da 2ª parcela, usa-se o dia recorrente configurado; se o mês
+     * for mais curto que esse dia (ex.: fevereiro não tem dia 31), cai no último
+     * dia do mês.
+     */
+    public function test_parcela_com_dia_recorrente_31_cai_no_ultimo_dia_de_fevereiro(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'           => $student->id,
+            'unit_id'              => $this->unit->id,
+            'recurring_day'        => 31,
+            'package_value'        => 400.00,
+            'package_installments' => 4,
+            'package_due_date'     => '2026-01-31', // jan, fev, mar, abr
+        ]);
+
+        $dates = $this->installmentsFor($contract->id, 'package')->pluck('due_date')
+            ->map(fn ($d) => $d->format('Y-m-d'))->values();
+
+        $this->assertSame([
+            '2026-01-31',
+            '2026-02-28', // 2026 não é bissexto
+            '2026-03-31',
+            '2026-04-30', // abril só tem 30 dias
+        ], $dates->toArray());
+    }
+
+    /**
+     * Regra: Sem recurring_day informado, o fallback é o dia 1.
+     */
+    public function test_sem_dia_recorrente_informado_usa_dia_1_a_partir_da_segunda_parcela(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'           => $student->id,
+            'unit_id'              => $this->unit->id,
+            'package_value'        => 200.00,
+            'package_installments' => 2,
+            'package_due_date'     => '2026-05-15',
+        ]);
+
+        $dates = $this->installmentsFor($contract->id, 'package')->pluck('due_date')
+            ->map(fn ($d) => $d->format('Y-m-d'))->values();
+
+        $this->assertSame(['2026-05-15', '2026-06-01'], $dates->toArray());
+    }
+
+    // --------------------------------------------------------------- versionamento
+
+    /**
+     * Regra: Editar um contrato cria uma nova versão e realoca as parcelas da
+     * versão antiga para a nova — a "conta a receber" continua existindo, só
+     * muda de dono (student_contract_id).
+     */
+    public function test_editar_contrato_realoca_parcelas_para_a_nova_versao(): void
+    {
+        $student = $this->makeStudent();
+
+        $original = $this->service->create([
+            'student_id'           => $student->id,
+            'unit_id'              => $this->unit->id,
+            'package_value'        => 1200.00,
+            'package_installments' => 12,
+            'package_due_date'     => '2026-08-10',
+        ]);
+
+        $newVersion = $this->service->update($original->id, ['package_value' => 1200.00, 'notes' => 'Ajuste']);
+
+        $this->assertNotSame($original->id, $newVersion->id);
+        $this->assertSame(0, StudentContractInstallment::where('student_contract_id', $original->id)->count());
+        $this->assertSame(12, StudentContractInstallment::where('student_contract_id', $newVersion->id)->count());
+    }
+
+    // -------------------------------------------------------------------- freeze
+
+    /**
+     * Regra: Congelar o contrato por N meses empurra o vencimento das parcelas
+     * pendentes em N meses — parcelas já pagas não são tocadas.
+     */
+    public function test_congelar_contrato_adia_vencimento_das_parcelas_pendentes(): void
+    {
+        $student = $this->makeStudent();
+
+        $contract = $this->service->create([
+            'student_id'           => $student->id,
+            'unit_id'              => $this->unit->id,
+            'recurring_day'        => 10,
+            'package_value'        => 300.00,
+            'package_installments' => 3,
+            'package_due_date'     => '2026-08-10',
+        ]);
+
+        $installments = $this->installmentsFor($contract->id, 'package');
+        $installments->first()->update(['status' => 'paid', 'paid_value' => 100, 'payment_date' => '2026-08-10']);
+
+        // freeze() muda o status, o que por si só já cria uma nova versão do
+        // contrato (update() versiona sempre que há campo alterado) e realoca
+        // as parcelas para ela — por isso consultamos pelo id retornado, não
+        // pelo id original.
+        $frozen = $this->service->freeze($contract->id, 2);
+
+        $refreshed = StudentContractInstallment::where('student_contract_id', $frozen->id)
+            ->orderBy('installment_number')->get();
+
+        // Parcela paga não muda.
+        $this->assertSame('2026-08-10', $refreshed[0]->due_date->format('Y-m-d'));
+        // Parcelas pendentes andam 2 meses.
+        $this->assertSame('2026-11-10', $refreshed[1]->due_date->format('Y-m-d'));
+        $this->assertSame('2026-12-10', $refreshed[2]->due_date->format('Y-m-d'));
+    }
+}

+ 163 - 0
tests/Unit/UnitReceivableServiceTest.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Models\City;
+use App\Models\Country;
+use App\Models\State;
+use App\Models\Student;
+use App\Models\StudentContractInstallment;
+use App\Models\Unit;
+use App\Services\StudentContractService;
+use App\Services\UnitReceivableService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Validation\ValidationException;
+use Tests\TestCase;
+
+/**
+ * Contas a Receber da unidade (Decisão de modelagem A, documentada em
+ * UnitReceivableService): as parcelas de contrato de aluno JÁ SÃO a conta a
+ * receber — não existe tabela duplicada nem registro do lado do "responsável
+ * financeiro". Aqui validamos que a listagem e a baixa manual funcionam em
+ * cima dessa mesma tabela (student_contract_installments).
+ */
+class UnitReceivableServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private Unit $unit;
+
+    private UnitReceivableService $service;
+
+    private StudentContractService $contractService;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->unit = $this->makeUnit();
+        $this->service = new UnitReceivableService();
+        $this->contractService = new StudentContractService();
+    }
+
+    private function makeUnit(string $name = 'Unidade Teste', string $cnpj = '00000000000191'): Unit
+    {
+        $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
+        $state   = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
+        $city    = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
+
+        return Unit::create([
+            'fantasy_name' => $name, 'social_reason' => $name.' LTDA', 'cnpj' => $cnpj,
+            'street' => 'Rua Teste', 'neighborhood' => 'Centro', 'postal_code' => '87000000',
+            'city_id' => $city->id, 'state_id' => $state->id,
+            'email' => 'unidade@teste.com', 'name_responsible' => 'Responsável Teste',
+        ]);
+    }
+
+    private function makeInstallmentViaContract(int $unitId, array $overrides = []): StudentContractInstallment
+    {
+        $student = Student::create(['name' => 'Aluno Teste', 'unit_id' => $unitId, 'status' => 'active']);
+
+        $contract = $this->contractService->create(array_merge([
+            'student_id'           => $student->id,
+            'unit_id'              => $unitId,
+            'package_value'        => 300.00,
+            'package_installments' => 3,
+            'package_due_date'     => '2026-08-10',
+        ], $overrides));
+
+        return StudentContractInstallment::where('student_contract_id', $contract->id)
+            ->orderBy('installment_number')->first();
+    }
+
+    /**
+     * Regra: A listagem de "contas a receber" da unidade é exatamente a tabela
+     * de parcelas de aluno, filtrada por unidade, ordenada por vencimento.
+     */
+    public function test_list_retorna_as_parcelas_da_unidade_ordenadas_por_vencimento(): void
+    {
+        $this->makeInstallmentViaContract($this->unit->id);
+
+        $outraUnidade = $this->makeUnit('Outra Unidade', '00000000000272');
+        $this->makeInstallmentViaContract($outraUnidade->id);
+
+        $result = $this->service->list($this->unit->id);
+
+        $this->assertCount(3, $result); // as 3 parcelas do pacote da unidade de teste
+        $this->assertTrue($result->every(fn ($i) => $i->unit_id === $this->unit->id));
+    }
+
+    public function test_list_filtra_por_status_e_por_tipo(): void
+    {
+        $installment = $this->makeInstallmentViaContract($this->unit->id);
+        $installment->update(['status' => 'paid', 'paid_value' => 100, 'payment_date' => '2026-08-10']);
+
+        $pending = $this->service->list($this->unit->id, ['status' => 'pending']);
+        $paid = $this->service->list($this->unit->id, ['status' => 'paid']);
+
+        $this->assertCount(2, $pending);
+        $this->assertCount(1, $paid);
+
+        $byType = $this->service->list($this->unit->id, ['type' => 'package']);
+        $this->assertCount(3, $byType);
+    }
+
+    /**
+     * Regra: A baixa manual, sem informar paid_value, cobra o valor da parcela
+     * menos desconto mais multa.
+     */
+    public function test_settle_calcula_paid_value_padrao_a_partir_de_desconto_e_multa(): void
+    {
+        $installment = $this->makeInstallmentViaContract($this->unit->id);
+
+        $settled = $this->service->settle($installment, ['discount' => 10, 'fine' => 5]);
+
+        $this->assertSame('paid', $settled->status);
+        $this->assertEquals(95.00, $settled->paid_value); // 100 - 10 + 5
+    }
+
+    public function test_settle_bloqueia_parcela_ja_paga(): void
+    {
+        $installment = $this->makeInstallmentViaContract($this->unit->id);
+        $installment->update(['status' => 'paid', 'paid_value' => 100, 'payment_date' => '2026-08-10']);
+
+        $this->expectException(ValidationException::class);
+        $this->expectExceptionMessage('já está paga');
+
+        $this->service->settle($installment, []);
+    }
+
+    public function test_settle_bloqueia_parcela_cancelada(): void
+    {
+        $installment = $this->makeInstallmentViaContract($this->unit->id);
+        $installment->update(['status' => 'cancelled']);
+
+        $this->expectException(ValidationException::class);
+        $this->expectExceptionMessage('cancelada');
+
+        $this->service->settle($installment, []);
+    }
+
+    /**
+     * Regra: Uma parcela paga nunca reabre — a baixa manual é definitiva
+     * (estorno de verdade não é suportado por este fluxo).
+     */
+    public function test_reopen_sempre_bloqueia(): void
+    {
+        $installment = $this->makeInstallmentViaContract($this->unit->id);
+        $installment->update(['status' => 'paid', 'paid_value' => 100, 'payment_date' => '2026-08-10']);
+
+        $this->expectException(ValidationException::class);
+
+        $this->service->reopen($installment);
+    }
+
+    public function test_find_for_unit_nao_retorna_parcela_de_outra_unidade(): void
+    {
+        $installment = $this->makeInstallmentViaContract($this->unit->id);
+        $outraUnidade = $this->makeUnit('Outra Unidade', '00000000000272');
+
+        $this->assertNull($this->service->findForUnit($installment->id, $outraUnidade->id));
+        $this->assertNotNull($this->service->findForUnit($installment->id, $this->unit->id));
+    }
+}