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')); } }