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