aluno/contrato reaproveitado por unidade */ private array $studentContracts = []; protected function setUp(): void { parent::setUp(); Bus::fake(); $this->service = new TbrCalculationService; $this->unit = $this->makeUnit(); $this->sizeGp = $this->makeSize('GP', 'De 100 mil a 200 mil habitantes'); $this->makeBrackets($this->sizeGp->id, [ [1, 3, 0.0], [4, 12, 0.75], [13, 60, 1.00], ]); $this->makeTbrYear(2026, 1000.00); } // ---------------------------------------------------------------- helpers private function makeUnit(string $name = 'Unidade Teste'): 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' => '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 makeSize(string $acronym, string $description): MunicipalitySize { return MunicipalitySize::create(['acronym' => $acronym, 'description' => $description]); } /** @param array $brackets [start, end, percentual] */ private function makeBrackets(int $sizeId, array $brackets, bool $isRenewal = false): void { foreach ($brackets as [$start, $end, $percentage]) { InhabitantClassification::create([ 'municipality_size_id' => $sizeId, 'description' => "Faixa {$start}-{$end}", 'start' => $start, 'end' => $end, 'tbr_percentage' => $percentage, 'is_renewal' => $isRenewal, ]); } } private function makeTbrYear(int $year, float $value, float $fnm = 0.20, float $maintenance = 0.30): Tbr { return Tbr::create([ 'year' => $year, 'tbr_value' => $value, 'royalties_percentage' => 0.08, 'fnm_percentage' => $fnm, 'maintenance_percentage' => $maintenance, ]); } private function makeContract(array $overrides = []): FranchiseeContract { return FranchiseeContract::create(array_merge([ 'unit_id' => $this->unit->id, 'protocol' => 1, 'name' => 'Contrato de Franquia', 'description' => 'Contrato de teste', 'start_date' => '2026-01-01', 'end_date' => '2031-01-01', 'signature_date' => '2026-01-01', 'validity_months' => 60, 'invoice_due_date' => 10, 'municipality_size_id' => $this->sizeGp->id, 'tbr_fixed_value' => 1000.00, ], $overrides)); } /** Aluno + contrato mínimos para pendurar as parcelas que formam o faturamento. */ private function studentContractFor(int $unitId): array { if (isset($this->studentContracts[$unitId])) { return $this->studentContracts[$unitId]; } $student = Student::create([ 'name' => 'Aluno Teste', 'unit_id' => $unitId, 'status' => 'active', ]); $contract = StudentContract::create([ 'student_id' => $student->id, 'unit_id' => $unitId, 'status' => 'active', ]); return $this->studentContracts[$unitId] = [ 'student_id' => $student->id, 'contract_id' => $contract->id, ]; } private function makeInstallment(array $overrides = []): StudentContractInstallment { $unitId = $overrides['unit_id'] ?? $this->unit->id; $owner = $this->studentContractFor($unitId); return StudentContractInstallment::create(array_merge([ 'student_contract_id' => $owner['contract_id'], 'unit_id' => $unitId, 'student_id' => $owner['student_id'], 'type' => 'monthly', 'history' => 'Mensalidade', 'installment_number' => 1, 'total_installments' => 12, 'value' => 100.00, 'due_date' => '2026-04-10', 'status' => 'pending', ], $overrides)); } /** Atalho: preview do cenário base para uma competência, com faturamento informado. */ private function preview(int $year, int $month, float $revenue = 0.0): array { return $this->service->preview([ 'unit_id' => $this->unit->id, 'reference_year' => $year, 'reference_month' => $month, 'revenue_value' => $revenue, ]); } // ------------------------------------------------- mês de contrato / isenção /** * Regra: Como o sistema descobre em que "mês de contrato" a competência cai. * * Cenário: Contrato iniciado em 01/01/2026; consulta várias competências. * * Espera: * - 01/2026 -> mês 1 * - 03/2026 -> mês 3 * - 04/2026 -> mês 4 * - 01/2027 -> mês 13 * * Nota: Fórmula: meses entre o início do mês de start_date e o início do mês de referência, + 1. */ public function test_mes_de_contrato_conta_a_partir_do_start_date(): void { $this->makeContract(); $this->assertSame(1, $this->preview(2026, 1, 1000)['contract_month_reference']); $this->assertSame(3, $this->preview(2026, 3, 1000)['contract_month_reference']); $this->assertSame(4, $this->preview(2026, 4, 1000)['contract_month_reference']); $this->assertSame(13, $this->preview(2027, 1, 1000)['contract_month_reference']); } /** * Regra: Competência anterior ao início do contrato não pode gerar mês zero ou negativo. * * Cenário: Contrato inicia 01/01/2026 e pede-se a competência 10/2025. * * Espera: * - mês de contrato = 1 * * Nota: O cálculo é travado por max(1, diff + 1). */ public function test_competencia_anterior_ao_inicio_do_contrato_e_tratada_como_mes_1(): void { $this->makeContract(); $this->assertSame(1, $this->preview(2025, 10, 1000)['contract_month_reference']); } /** * Regra: nos meses 1, 2 e 3 não se cobra Royalties nem FNM. * * Cenário: Faturamento alto (R$ 40.000), que normalmente geraria R$ 3.200 de ROI e R$ 800 de FNM. * * Espera: * - Royalties = R$ 0,00 nos três meses * - FNM = R$ 0,00 nos três meses * - critério registrado = 'tbr_fixo' * * Nota: A isenção ignora o faturamento: mesmo faturando muito, os dois ficam zerados. */ public function test_meses_1_a_3_isentam_royalties_e_fnm(): void { $this->makeContract(); foreach ([1, 2, 3] as $month) { $result = $this->preview(2026, $month, 40000); $this->assertSame(0.0, $result['royalties_effective_value'], "mês {$month}"); $this->assertSame(0.0, $result['fnm_effective_value'], "mês {$month}"); $this->assertSame('tbr_fixo', $result['royalties_applied_criteria'], "mês {$month}"); } } /** * Regra: A isenção dos meses 1-3 vale só para Royalties e FNM; a Manutenção continua. * * Cenário: Competência 02/2026 (mês 2), TBR de R$ 1.000,00. * * Espera: * - Manutenção = R$ 300,00 (30% da TBR) * - Total do título = R$ 300,00 * * Nota: Ponto que costuma gerar dúvida: o mês isento ainda gera cobrança. */ public function test_taxa_de_manutencao_e_cobrada_mesmo_nos_meses_isentos(): void { $this->makeContract(); $result = $this->preview(2026, 2, 40000); $this->assertSame(300.0, $result['maintenance_effective_value']); $this->assertSame(300.0, $result['final_value']); } /** * Regra: A isenção termina exatamente no fim do mês 3. * * Cenário: Competência 04/2026 (mês 4), faturamento baixo. * * Espera: * - Royalties > 0 * - FNM > 0 */ public function test_isencao_acaba_no_mes_4(): void { $this->makeContract(); $result = $this->preview(2026, 4, 1000); $this->assertGreaterThan(0, $result['royalties_effective_value']); $this->assertGreaterThan(0, $result['fnm_effective_value']); } // ------------------------------------------------------- regra do maior valor /** * Regra: vence o fixo sobre a TBR quando o faturamento é pequeno. * * Cenário: Mês 4, TBR R$ 1.000,00, faturamento R$ 1.000,00. * * Espera: * - Royalties = R$ 750,00 (75% x 1.000) e não R$ 80,00 (8% x 1.000) * - FNM = R$ 200,00 (20% x 1.000) e não R$ 20,00 (2% x 1.000) * - Manutenção = R$ 300,00 * - Total = R$ 1.250,00 * - critério = 'tbr_fixo' */ public function test_usa_valor_fixo_da_tbr_quando_faturamento_e_baixo(): void { $this->makeContract(); $result = $this->preview(2026, 4, 1000.00); // Royalties: 75% x 1000 = 750,00 vs 8% x 1000 = 80,00 $this->assertSame(750.0, $result['royalties_effective_value']); $this->assertSame(0.75, $result['royalties_effective_percentage']); $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']); // FNM: 20% x 1000 = 200,00 vs 2% x 1000 = 20,00 $this->assertSame(200.0, $result['fnm_effective_value']); $this->assertSame(0.20, $result['fnm_effective_percentage']); $this->assertSame(300.0, $result['maintenance_effective_value']); $this->assertSame(1250.0, $result['final_value']); } /** * Regra: vence o percentual do faturamento quando ele supera o fixo. * * Cenário: Mês 4, TBR R$ 1.000,00, faturamento R$ 40.000,00. * * Espera: * - Royalties = R$ 3.200,00 (8% x 40.000) e não R$ 750,00 * - FNM = R$ 800,00 (2% x 40.000) e não R$ 200,00 * - Manutenção = R$ 300,00 * - Total = R$ 4.300,00 * - critério = 'percentual_faturamento' */ public function test_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void { $this->makeContract(); $result = $this->preview(2026, 4, 40000.00); // Royalties: 8% x 40.000 = 3.200,00 > 750,00 $this->assertSame(3200.0, $result['royalties_effective_value']); $this->assertSame(0.08, $result['royalties_effective_percentage']); $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']); // FNM: 2% x 40.000 = 800,00 > 200,00 $this->assertSame(800.0, $result['fnm_effective_value']); $this->assertSame(0.02, $result['fnm_effective_percentage']); $this->assertSame(300.0, $result['maintenance_effective_value']); $this->assertSame(4300.0, $result['final_value']); } /** * Regra: Critério de desempate — valores iguais mantem o fixo da TBR. * * Cenário: Faturamento R$ 9.375,00, em que 8% = R$ 750,00 = exatamente o valor fixo. * * Espera: * - Royalties = R$ 750,00 * - critério = 'tbr_fixo' * - percentual gravado = 0,75 (o da faixa, não 0,08) * * Nota: A comparacao no código e >=, então o empate cai no fixo. */ public function test_empate_entre_fixo_e_faturamento_mantem_o_fixo_da_tbr(): void { $this->makeContract(); // 8% x 9.375 = 750,00 = valor fixo dos royalties $result = $this->preview(2026, 4, 9375.00); $this->assertSame(750.0, $result['royalties_effective_value']); $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']); $this->assertSame(0.75, $result['royalties_effective_percentage']); } /** * Regra: Royalties e FNM são comparados de forma independente. * * Cenário: Faturamento R$ 9.500,00: 8% = R$ 760 (passa do fixo 750), mas 2% = R$ 190 (abaixo do fixo 200). * * Espera: * - Royalties = R$ 760,00 por 'percentual_faturamento' * - FNM = R$ 200,00 pelo fixo (percentual 0,20) */ public function test_royalties_e_fnm_podem_usar_criterios_diferentes_no_mesmo_mes(): void { $this->makeContract(); // Royalties: 8% x 11.000 = 880 > 750 (faturamento) // FNM: 2% x 11.000 = 220 > 200 (faturamento) — escolhe faturamento nos dois // Já com 9.500: royalties 760 > 750 (faturamento) e FNM 190 < 200 (fixo) $result = $this->preview(2026, 4, 9500.00); $this->assertSame(760.0, $result['royalties_effective_value']); $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']); $this->assertSame(200.0, $result['fnm_effective_value']); $this->assertSame(0.20, $result['fnm_effective_percentage']); } /** * Regra: A Taxa de Manutenção é sempre um percentual fixo da TBR. * * Cenário: Mesmo contrato com faturamento de R$ 100,00 e depois de R$ 900.000,00. * * Espera: * - Manutenção = R$ 300,00 nos dois casos * - percentual = 0,30 */ public function test_manutencao_nunca_e_comparada_com_o_faturamento(): void { $this->makeContract(); $baixo = $this->preview(2026, 4, 100.00); $alto = $this->preview(2026, 12, 900000.00); $this->assertSame(300.0, $baixo['maintenance_effective_value']); $this->assertSame(300.0, $alto['maintenance_effective_value']); $this->assertSame(0.30, $alto['maintenance_effective_percentage']); } // ------------------------------------------------------ faixas / porte da cidade /** * Regra: A faixa de royalties do porte muda conforme o mês de contrato avanca. * * Cenário: Porte GP: 75% nos meses 4-12 e 100% nos meses 13-60. * * Espera: * - mês 12 -> 75% da TBR = R$ 750,00 * - mês 13 -> 100% da TBR = R$ 1.000,00 */ public function test_faixa_de_royalties_muda_no_mes_13(): void { $this->makeContract(); $this->makeTbrYear(2027, 1000.00); $mes12 = $this->preview(2026, 12, 1000); $mes13 = $this->preview(2027, 1, 1000); $this->assertSame(0.75, $mes12['royalties_bracket_percentage']); $this->assertSame(750.0, $mes12['royalties_bracket_value']); $this->assertSame(1.0, $mes13['royalties_bracket_percentage']); $this->assertSame(1000.0, $mes13['royalties_bracket_value']); } /** * Regra: O porte do município do contrato define o percentual de royalties. * * Cenário: Contrato em porte MGP (100% no mês 4) no lugar de GP (75%). * * Espera: * - percentual da faixa = 1,00 * - Royalties = R$ 1.000,00 em vez de R$ 750,00 */ public function test_porte_maior_do_municipio_cobra_percentual_maior(): void { $sizeMgp = $this->makeSize('MGP', 'Acima de 200 mil habitantes'); $this->makeBrackets($sizeMgp->id, [[1, 3, 0.0], [4, 12, 1.00], [13, 60, 1.50]]); $this->makeContract(['municipality_size_id' => $sizeMgp->id]); $result = $this->preview(2026, 4, 1000); $this->assertSame(1.0, $result['royalties_bracket_percentage']); $this->assertSame(1000.0, $result['royalties_effective_value']); } /** * Regra: A busca de faixa é fixada em is_renewal = false: faixas de renovacao nunca entram. * * Cenário: Unica faixa que cobre o mês 61 e de renovacao. * * Espera: * - ValidationException 'Nao foi encontrada faixa de royalties' * * Nota: Comportamento atual documentado como divergência (item 5 do doc). Se as faixas de renovacao * passarem a valer, este teste deve ser reescrito. */ public function test_faixa_de_renovacao_nao_e_considerada(): void { // Só existe faixa de renovação cobrindo o mês 61 — o cálculo ignora e estoura. $this->makeBrackets($this->sizeGp->id, [[61, 120, 1.00]], true); $this->makeContract(); $this->makeTbrYear(2031, 1000.00); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Não foi encontrada faixa de royalties'); $this->preview(2031, 1, 1000); // mês de contrato 61 } /** * Regra: Contrato que passa do último mês com faixa cadastrada não calcula. * * Cenário: Faixas cadastradas até o mês 60; competência cai no mês 61. * * Espera: * - ValidationException 'Nao foi encontrada faixa de royalties' */ public function test_erro_quando_nao_existe_faixa_para_o_mes_de_contrato(): void { $this->makeContract(); $this->makeTbrYear(2031, 1000.00); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Não foi encontrada faixa de royalties'); $this->preview(2031, 1, 1000); // mês 61, fora das faixas 1-60 } // --------------------------------------------------------------- valor da TBR /** * Regra: A TBR do ano cadastrada pelo admin (tabela tbrs) manda sobre o valor do contrato. * * Cenário: tbrs/2026 = R$ 1.000,00 (setUp) e contrato com tbr_fixed_value = R$ 2.000,00. * * Espera: * - TBR usada = R$ 1.000,00 * - Royalties = R$ 750,00 (75% x 1.000) * - Manutenção = R$ 300,00 */ public function test_tbr_do_ano_configurada_pelo_admin_tem_prioridade_sobre_o_contrato(): void { // tbrs/2026 = 1.000,00 (setUp) e o contrato traz 2.000,00: vale a tabela do ano. $this->makeContract(['tbr_fixed_value' => 2000.00]); $result = $this->preview(2026, 4, 1000); $this->assertSame(1000.0, $result['tbr_value']); $this->assertSame(750.0, $result['royalties_effective_value']); // 75% x 1000 $this->assertSame(300.0, $result['maintenance_effective_value']); // 30% x 1000 } /** * Regra: A busca da TBR é feita pelo ano da competência, não pelo ano corrente. * * Cenário: tbrs/2026 = R$ 1.000,00 e tbrs/2027 = R$ 1.800,00. * * Espera: * - competência de 2026 usa R$ 1.000,00 * - competência de 2027 usa R$ 1.800,00 */ public function test_cada_ano_usa_a_sua_propria_configuracao_de_tbr(): void { $this->makeTbrYear(2027, 1800.00); $this->makeContract(['tbr_fixed_value' => 2000.00]); $this->assertSame(1000.0, $this->preview(2026, 4, 1000)['tbr_value']); $this->assertSame(1800.0, $this->preview(2027, 1, 1000)['tbr_value']); } /** * Regra: O tbr_fixed_value do contrato é a reserva para anos sem cadastro em tbrs. * * Cenário: 2027 sem linha em tbrs; contrato com R$ 2.000,00; competência 01/2027 (mês 13, faixa 100%). * * Espera: * - TBR usada = R$ 2.000,00 * - Royalties = R$ 2.000,00 * - Manutenção = R$ 600,00 */ public function test_usa_a_tbr_do_contrato_quando_o_ano_nao_esta_configurado(): void { // 2027 não tem linha em `tbrs`; cai no valor gravado no contrato. $this->makeContract(['tbr_fixed_value' => 2000.00]); $result = $this->preview(2027, 1, 1000); // mês 13 $this->assertSame(2000.0, $result['tbr_value']); $this->assertSame(2000.0, $result['royalties_effective_value']); // 100% x 2000 $this->assertSame(600.0, $result['maintenance_effective_value']); // 30% x 2000 } /** * Regra: Linha do ano com tbr_value = 0 conta como 'não configurada'. * * Cenário: tbrs/2027 = R$ 0,00 e contrato com R$ 2.000,00. * * Espera: * - TBR usada = R$ 2.000,00 */ public function test_tbr_do_ano_zerada_cai_no_valor_do_contrato(): void { $this->makeTbrYear(2027, 0.00); $this->makeContract(['tbr_fixed_value' => 2000.00]); $this->assertSame(2000.0, $this->preview(2027, 1, 1000)['tbr_value']); } /** * Regra: Sem TBR em lugar nenhum o cálculo não pode seguir. * * Cenário: Competência 2029 (sem linha em tbrs) e contrato sem tbr_fixed_value. * * Espera: * - ValidationException 'TBR não definida' */ public function test_erro_quando_nao_ha_tbr_no_ano_nem_no_contrato(): void { $this->makeContract(['tbr_fixed_value' => null]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('TBR não definida'); $this->preview(2029, 4, 1000); // sem linha em tbrs para 2029 } /** * Regra: Os percentuais de FNM e Manutenção saem da linha do ano em tbrs. * * Cenário: tbrs/2027 com FNM 10% e Manutenção 50%, TBR R$ 1.000,00. * * Espera: * - FNM fixo = 10% = R$ 100,00 * - Manutenção = 50% = R$ 500,00 */ public function test_percentuais_de_fnm_e_manutencao_vem_da_tabela_do_ano(): void { $this->makeTbrYear(2027, 1000.00, fnm: 0.10, maintenance: 0.50); $this->makeContract(); $result = $this->preview(2027, 1, 1000); // mês 13 $this->assertSame(0.10, $result['fnm_bracket_percentage']); $this->assertSame(100.0, $result['fnm_bracket_value']); $this->assertSame(0.50, $result['maintenance_effective_percentage']); $this->assertSame(500.0, $result['maintenance_effective_value']); } /** * Regra: Sem linha do ano, os percentuais caem nas constantes do serviço. * * Cenário: 2027 sem cadastro em tbrs; a TBR vem do contrato. * * Espera: * - FNM fixo = 20% (FNM_BRACKET_PERCENTAGE) * - Manutenção = 30% (MAINTENANCE_RATE) */ public function test_sem_tabela_do_ano_usa_os_padroes_do_codigo(): void { // 2027 não tem linha em `tbrs`; a TBR vem do contrato e os percentuais dos defaults. $this->makeContract(); $result = $this->preview(2027, 1, 1000); // mês 13 $this->assertSame(0.20, $result['fnm_bracket_percentage']); $this->assertSame(0.30, $result['maintenance_effective_percentage']); } // ------------------------------------------------------- flags de cobrança /** * Regra: Flag charge_roi = false na unidade tira os Royalties da conta. * * Cenário: unit_financials com charge_roi = false, mês 4, faturamento R$ 40.000,00. * * Espera: * - Royalties = R$ 0,00 e percentual = 0 * - critério = 'nao_cobrado' * - royalties_bracket_id = null (a faixa nem é buscada) * - Total = R$ 1.100,00 (FNM 800 + Manutenção 300) */ public function test_charge_roi_desligado_zera_os_royalties(): void { UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]); $this->makeContract(); $result = $this->preview(2026, 4, 40000); $this->assertSame(0.0, $result['royalties_effective_value']); $this->assertSame(0.0, $result['royalties_effective_percentage']); $this->assertSame('nao_cobrado', $result['royalties_applied_criteria']); $this->assertNull($result['royalties_bracket_id']); $this->assertSame(1100.0, $result['final_value']); // FNM 800 + manutenção 300 } /** * Regra: Com os Royalties desligados, a falta de faixa não derruba o cálculo. * * Cenário: charge_roi = false e competência no mês 61 (sem faixa cadastrada). * * Espera: * - calcula sem exceção * - Royalties = R$ 0,00 * - Total = R$ 500,00 (FNM 200 + Manutenção 300) */ public function test_charge_roi_desligado_dispensa_a_existencia_de_faixa(): void { UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]); $this->makeContract(); $this->makeTbrYear(2031, 1000.00); $result = $this->preview(2031, 1, 1000); // mês 61, sem faixa cadastrada $this->assertSame(0.0, $result['royalties_effective_value']); $this->assertSame(500.0, $result['final_value']); // FNM 200 + manutenção 300 } /** * Regra: Flag charge_fnm = false na unidade tira o FNM da conta. * * Cenário: unit_financials com charge_fnm = false, mês 4, faturamento baixo. * * Espera: * - FNM = R$ 0,00 e percentual = 0 * - Total = R$ 1.050,00 (Royalties 750 + Manutenção 300) */ public function test_charge_fnm_desligado_zera_o_fnm(): void { UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => true, 'charge_fnm' => false]); $this->makeContract(); $result = $this->preview(2026, 4, 1000); $this->assertSame(0.0, $result['fnm_effective_value']); $this->assertSame(0.0, $result['fnm_effective_percentage']); $this->assertSame(1050.0, $result['final_value']); // royalties 750 + manutenção 300 } /** * Regra: Unidade sem linha em unit_financials é tratada como 'cobra tudo'. * * Cenário: Nenhum registro financeiro criado para a unidade. * * Espera: * - Royalties = R$ 750,00 * - FNM = R$ 200,00 * * Nota: Padrão seguro: a ausencia de configuração não deve isentar ninguem. */ public function test_unidade_sem_registro_financeiro_cobra_royalties_e_fnm(): void { $this->makeContract(); $this->assertSame(0, UnitFinancial::where('unit_id', $this->unit->id)->count()); $result = $this->preview(2026, 4, 1000); $this->assertSame(750.0, $result['royalties_effective_value']); $this->assertSame(200.0, $result['fnm_effective_value']); } /** * Regra: A Taxa de Manutenção não tem flag de liga/desliga. * * Cenário: charge_roi e charge_fnm ambos false, faturamento alto. * * Espera: * - Total = R$ 300,00, só a Manutenção */ public function test_manutencao_e_cobrada_mesmo_com_as_duas_flags_desligadas(): void { UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => false]); $this->makeContract(); $result = $this->preview(2026, 4, 40000); $this->assertSame(300.0, $result['final_value']); } // ---------------------------------------------------------------- faturamento /** * Regra: Composição da base de faturamento do mês — três fontes somadas. * * Cenário: Parcelas de aluno R$ 100 + R$ 250, recebível da unidade R$ 50 e conta 'manual_unit' da matriz R$ 25. * * Espera: * - faturamento de 04/2026 = R$ 425,00 */ public function test_faturamento_soma_parcelas_recebiveis_e_contas_da_matriz(): void { $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-01']); $this->makeInstallment(['value' => 250.00, 'due_date' => '2026-04-30']); UnitAccountReceivable::create([ 'unit_id' => $this->unit->id, 'origin' => 'manual', 'history' => 'Venda de material', 'value' => 50.00, 'due_date' => '2026-04-15', 'status' => 'pending', ]); FranchiseeAccountReceive::create([ 'unit_id' => $this->unit->id, 'origin' => 'manual_unit', 'history' => 'Cobrança avulsa da matriz', 'value' => 25.00, 'due_date' => '2026-04-20', 'status' => 'pending', ]); $this->assertSame(425.0, $this->service->resolveRevenue($this->unit->id, 2026, 4)); } /** * Regra: Filtro do faturamento — só entra o que vence no mês e não está cancelado. * * Cenário: R$ 100 válida, R$ 999 cancelada, R$ 777 em 31/03 e R$ 555 em 01/05. * * Espera: * - faturamento de 04/2026 = R$ 100,00 * * Nota: Confirma também que o intervalo do mês e fechado nas duas pontas. */ public function test_faturamento_ignora_parcelas_canceladas_e_de_outros_meses(): void { $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']); $this->makeInstallment(['value' => 999.00, 'due_date' => '2026-04-10', 'status' => 'cancelled']); $this->makeInstallment(['value' => 777.00, 'due_date' => '2026-03-31']); $this->makeInstallment(['value' => 555.00, 'due_date' => '2026-05-01']); $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4)); } /** * Regra: Parcela removida (soft delete) sai da base de faturamento. * * Cenário: R$ 100 ativa e R$ 400 excluida, ambas vencendo no mês. * * Espera: * - faturamento = R$ 100,00 */ public function test_faturamento_ignora_parcelas_excluidas(): void { $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']); $this->makeInstallment(['value' => 400.00, 'due_date' => '2026-04-10'])->delete(); $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4)); } /** * Regra: O próprio título de TBR não pode entrar na base que gera o TBR. * * Cenário: Parcela de R$ 100 e um franchisee_account_receive de R$ 5.000 com origin = 'tbr'. * * Espera: * - faturamento = R$ 100,00 * * Nota: Evita realimentação: cobrar TBR aumentaria a base do mês seguinte. */ public function test_faturamento_ignora_recebiveis_de_tbr(): void { $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']); FranchiseeAccountReceive::create([ 'unit_id' => $this->unit->id, 'origin' => 'tbr', 'history' => 'Royalties / FNM / Manutenção — 04/2026', 'value' => 5000.00, 'due_date' => '2026-04-10', 'status' => 'pending', ]); $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4)); } /** * Regra: O faturamento é apurado por unidade. * * Cenário: R$ 100 na unidade do teste e R$ 900 em outra unidade, mesma data. * * Espera: * - faturamento da unidade = R$ 100,00 */ public function test_faturamento_ignora_movimento_de_outra_unidade(): void { $outra = $this->makeUnit('Outra Unidade'); $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']); $this->makeInstallment(['value' => 900.00, 'due_date' => '2026-04-10', 'unit_id' => $outra->id]); $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4)); } /** * Regra: resolveRevenueBetween com lista de unidades vazia representa a rede inteira. * * Cenário: R$ 100 numa unidade e R$ 900 em outra, ambas em 04/2026. * * Espera: * - total da rede = R$ 1.000,00 * * Nota: Mesma composicao usada pelos dashboards. */ public function test_faturamento_da_rede_soma_todas_as_unidades(): void { $outra = $this->makeUnit('Outra Unidade'); $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']); $this->makeInstallment(['value' => 900.00, 'due_date' => '2026-04-10', 'unit_id' => $outra->id]); $total = $this->service->resolveRevenueBetween( [], \Carbon\Carbon::parse('2026-04-01'), \Carbon\Carbon::parse('2026-04-30'), ); $this->assertSame(1000.0, $total); } /** * Regra: revenue_value maior que zero sobrepoe a apuração automática. * * Cenário: Existe R$ 100 de parcela no mês; um preview manda revenue_value = R$ 40.000. * * Espera: * - com override: revenue_value = R$ 40.000,00 * - sem override: revenue_value = R$ 100,00 * * Nota: Zero (ou ausente) significa 'calcula automático', não 'faturamento zero'. */ public function test_faturamento_informado_manualmente_substitui_o_calculo_automatico(): void { $this->makeContract(); $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']); $comOverride = $this->preview(2026, 4, 40000.00); $automatico = $this->preview(2026, 4, 0.0); $this->assertSame(40000.0, $comOverride['revenue_value']); $this->assertSame(100.0, $automatico['revenue_value']); } // -------------------------------------------------------- contrato obrigatório /** * Regra: Sem contrato não ha regra de cobrança. * * Cenário: Unidade criada sem nenhum franchisee_contract. * * Espera: * - ValidationException 'Unidade não possui contrato cadastrado.' */ public function test_erro_quando_a_unidade_nao_tem_contrato(): void { $this->expectException(ValidationException::class); $this->expectExceptionMessage('Unidade não possui contrato cadastrado.'); $this->preview(2026, 4, 1000); } /** * Regra: O porte do município é obrigatório para achar a faixa de royalties. * * Cenário: Contrato com municipality_size_id nulo. * * Espera: * - ValidationException citando a faixa de habitantes */ public function test_erro_quando_o_contrato_nao_tem_porte_do_municipio(): void { $this->makeContract(['municipality_size_id' => null]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('faixa de habitantes'); $this->preview(2026, 4, 1000); } /** * Regra: Com vários contratos na unidade, vale o de start_date mais recente. * * Cenário: Contrato de 01/01/2026 e outro de 01/03/2026; competência 06/2026. * * Espera: * - contract_id = o contrato de março * - mês de contrato = 4, contado a partir de 03/2026 */ public function test_usa_sempre_o_contrato_mais_recente_da_unidade(): void { $antigo = $this->makeContract(['start_date' => '2026-01-01']); $novo = $this->makeContract(['protocol' => 2, 'start_date' => '2026-03-01']); $result = $this->preview(2026, 6, 1000); $this->assertSame($novo->id, $result['contract_id']); $this->assertNotSame($antigo->id, $result['contract_id']); $this->assertSame(4, $result['contract_month_reference']); // conta a partir de 03/2026 } // ------------------------------------------------------------------ persistência /** * Regra: calculate() persiste em tbr_calculations exatamente o que o preview mostrou. * * Cenário: Mês 4, faturamento R$ 40.000,00. * * Espera: * - linha gravada com a competência e o mês de contrato 4 * - Royalties R$ 3.200,00 / FNM R$ 800,00 / Manutenção R$ 300,00 / total R$ 4.300,00 * - critério 'percentual_faturamento' * - receivable_generated = false */ public function test_calculate_grava_o_calculo_com_os_valores_do_preview(): void { $this->makeContract(); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 40000.00, ]); $this->assertDatabaseHas('tbr_calculations', [ 'id' => $calculation->id, 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'contract_month_reference' => 4, 'receivable_generated' => false, ]); $this->assertSame('3200.00', (string) $calculation->royalties_effective_value); $this->assertSame('800.00', (string) $calculation->fnm_effective_value); $this->assertSame('300.00', (string) $calculation->maintenance_effective_value); $this->assertSame('4300.00', (string) $calculation->final_value); $this->assertSame('percentual_faturamento', $calculation->royalties_applied_criteria); } /** * Regra: generateReceivable() gera o título da matriz e o espelho na unidade. * * Cenário: Cálculo do mês 4 com faturamento baixo (total R$ 1.250,00). * * Espera: * - franchisee_account_receives de R$ 1.250,00, status pending, order = mês 4 * - histórico com a competência 04/2026 * - 3 detalhes (Royalties, FNM, Manutenção) somando o total * - cálculo marcado como receivable_generated * - unit_account_payables espelho com origin 'tbr' * - SyncFranchiseeChargeJob despachado */ public function test_gerar_titulo_cria_recebivel_detalhes_e_conta_a_pagar(): void { $this->makeContract(); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame('1250.00', (string) $receive->value); $this->assertSame('pending', $receive->status->value); $this->assertSame(4, $receive->order); $this->assertStringContainsString('04/2026', $receive->history); $this->assertCount(3, $receive->details); $this->assertEqualsWithDelta( 1250.00, $receive->details->sum(fn ($detail) => (float) $detail->value), 0.01, ); $this->assertTrue($calculation->fresh()->receivable_generated); $this->assertDatabaseHas('unit_account_payables', [ 'unit_id' => $this->unit->id, 'franchisee_account_receive_id' => $receive->id, 'origin' => UnitAccountPayable::ORIGIN_TBR, 'value' => '1250.00', 'status' => 'pending', ]); Bus::assertDispatched(\App\Jobs\SyncFranchiseeChargeJob::class); } /** * Regra: Um cálculo só pode virar título uma vez. * * Cenário: generateReceivable() chamado duas vezes com o mesmo id. * * Espera: * - ValidationException 'Já existe um título gerado para este cálculo.' */ public function test_gerar_titulo_duas_vezes_para_o_mesmo_calculo_e_bloqueado(): void { $this->makeContract(); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $this->service->generateReceivable($calculation->id); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Já existe um título gerado para este cálculo.'); $this->service->generateReceivable($calculation->id); } /** * Regra: Uma unidade só pode ter um título por mês de contrato. * * Cenário: Dois calculos distintos para a mesma competência; o primeiro já virou título. * * Espera: * - ValidationException citando o mês de contrato 4 * * Nota: A checagem hoje olha só o mês de contrato, sem o ano/mês de competência (item 4 do doc). */ public function test_gerar_titulo_para_o_mesmo_mes_de_contrato_e_bloqueado(): void { $this->makeContract(); $primeiro = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $this->service->generateReceivable($primeiro->id); $segundo = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Já existe um título gerado para esta unidade no mês de contrato 4.'); $this->service->generateReceivable($segundo->id); } /** * Regra: O preview sinaliza a duplicidade antes de tentar gerar. * * Cenário: Preview antes e depois de gerar o título do mês 4. * * Espera: * - antes: receivable_already_generated = false * - depois: true */ public function test_preview_avisa_quando_ja_existe_titulo_no_mes_de_contrato(): void { $this->makeContract(); $this->assertFalse($this->preview(2026, 4, 1000)['receivable_already_generated']); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $this->service->generateReceivable($calculation->id); $this->assertTrue($this->preview(2026, 4, 1000)['receivable_already_generated']); } // ---------------------------------------------------------------- vencimento /** * Regra: Vencimento = mês da competência + 1, no dia invoice_due_date do contrato. * * Cenário: Contrato com invoice_due_date = 15, competência 04/2026. * * Espera: * - vencimento = 15/05/2026 */ public function test_vencimento_usa_o_dia_do_contrato_no_mes_seguinte(): void { $this->makeContract(['invoice_due_date' => 15]); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame('2026-05-15', $receive->due_date->toDateString()); } /** * Regra: Sem invoice_due_date, o vencimento usa o dia 10. * * Cenário: Contrato com invoice_due_date nulo, competência 04/2026. * * Espera: * - vencimento = 10/05/2026 */ public function test_vencimento_cai_no_dia_10_quando_o_contrato_nao_informa(): void { $this->makeContract(['invoice_due_date' => null]); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame('2026-05-10', $receive->due_date->toDateString()); } /** * Regra: O dia de vencimento é travado entre 1 e 28 (evita mês sem dia 29-31). * * Cenário: Contrato com invoice_due_date = 31, competência 04/2026. * * Espera: * - vencimento = 28/05/2026 */ public function test_vencimento_e_limitado_ao_dia_28(): void { $this->makeContract(['invoice_due_date' => 31]); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame('2026-05-28', $receive->due_date->toDateString()); } // ------------------------------------------------------------------- em lote /** * Regra: generateBatch() percorre os contratos ativos na competência e gera os títulos. * * Cenário: Uma unidade com contrato ativo e R$ 1.000 de faturamento apurado no mês. * * Espera: * - generated_count = 1 e nenhum erro * - total gerado = R$ 1.250,00 * - 1 recebível criado */ public function test_lote_gera_titulo_para_cada_contrato_ativo(): void { $this->makeContract(); $this->makeInstallment(['value' => 1000.00, 'due_date' => '2026-04-10']); $resultado = $this->service->generateBatch(2026, 4); $this->assertSame(1, $resultado['generated_count']); $this->assertSame(0, $resultado['error_count']); $this->assertSame(1250.0, $resultado['generated'][0]['total']); $this->assertSame(1, FranchiseeAccountReceive::count()); } /** * Regra: O lote não duplica título: unidade já cobrada entra em 'skipped'. * * Cenário: Titulo do mês 4 gerado antes de rodar o lote da mesma competência. * * Espera: * - generated_count = 0 e skipped_count = 1 * - continua com apenas 1 recebível */ public function test_lote_pula_unidade_que_ja_tem_titulo_no_mes_de_contrato(): void { $this->makeContract(); $calculation = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00, ]); $this->service->generateReceivable($calculation->id); $resultado = $this->service->generateBatch(2026, 4); $this->assertSame(0, $resultado['generated_count']); $this->assertSame(1, $resultado['skipped_count']); $this->assertSame(1, FranchiseeAccountReceive::count()); } /** * Regra: Contrato com end_date anterior ao mês não e cobrado. * * Cenário: Contrato encerrado em 28/02/2026; lote de 04/2026. * * Espera: * - nada gerado e nenhum cálculo gravado */ public function test_lote_ignora_contrato_encerrado_antes_da_competencia(): void { $this->makeContract(['end_date' => '2026-02-28']); $resultado = $this->service->generateBatch(2026, 4); $this->assertSame(0, $resultado['generated_count']); $this->assertSame(0, TbrCalculation::count()); } /** * Regra: Contrato com start_date posterior a competência não e cobrado. * * Cenário: Contrato comeca em 01/06/2026; lote de 04/2026. * * Espera: * - nada gerado */ public function test_lote_ignora_contrato_que_ainda_nao_comecou(): void { $this->makeContract(['start_date' => '2026-06-01']); $resultado = $this->service->generateBatch(2026, 4); $this->assertSame(0, $resultado['generated_count']); } /** * Regra: Contrato sem porte fica fora da selecao do lote (nem gera erro). * * Cenário: Contrato com municipality_size_id nulo; lote de 04/2026. * * Espera: * - generated_count = 0 e error_count = 0 * * Nota: Diferente do cálculo individual, que devolve ValidationException nesse caso. */ public function test_lote_ignora_contrato_sem_porte_definido(): void { $this->makeContract(['municipality_size_id' => null]); $resultado = $this->service->generateBatch(2026, 4); $this->assertSame(0, $resultado['generated_count']); $this->assertSame(0, $resultado['error_count']); } /** * Regra: generateBatch() aceita uma lista de unidades e ignora as demais. * * Cenário: duas unidades com contrato ativo; o lote roda filtrando só a segunda. * * Espera: * - generated_count = 1 * - o título gerado pertence a unidade filtrada */ public function test_lote_pode_ser_filtrado_por_unidade(): void { $this->makeContract(); $outra = $this->makeUnit('Outra Unidade'); FranchiseeContract::create([ 'unit_id' => $outra->id, 'protocol' => 1, 'name' => 'Contrato Outra', 'description' => 'Teste', 'start_date' => '2026-01-01', 'end_date' => '2031-01-01', 'signature_date' => '2026-01-01', 'validity_months' => 60, 'invoice_due_date' => 10, 'municipality_size_id' => $this->sizeGp->id, 'tbr_fixed_value' => 1000.00, ]); $resultado = $this->service->generateBatch(2026, 4, [$outra->id]); $this->assertSame(1, $resultado['generated_count']); $this->assertSame($outra->id, $resultado['generated'][0]['unit_id']); } /** * Regra: Falha numa unidade não derruba o lote inteiro. * * Cenário: Uma unidade cai no mês 61 sem faixa; outra tem faixa válida até o mês 120. * * Espera: * - generated_count = 1 e error_count = 1 * - o erro aponta a unidade problemática */ public function test_lote_reporta_erro_sem_interromper_as_demais_unidades(): void { // Unidade base: mês 61 sem faixa cadastrada → erro $this->makeContract(); $this->makeTbrYear(2031, 1000.00); $outra = $this->makeUnit('Unidade OK'); $sizeOk = $this->makeSize('PP', 'Até 50 mil habitantes'); $this->makeBrackets($sizeOk->id, [[1, 120, 0.40]]); FranchiseeContract::create([ 'unit_id' => $outra->id, 'protocol' => 1, 'name' => 'Contrato OK', 'description' => 'Teste', 'start_date' => '2026-01-01', 'signature_date' => '2026-01-01', 'validity_months' => 120, 'invoice_due_date' => 10, 'municipality_size_id' => $sizeOk->id, 'tbr_fixed_value' => 1000.00, ]); $resultado = $this->service->generateBatch(2031, 1); $this->assertSame(1, $resultado['generated_count']); $this->assertSame(1, $resultado['error_count']); $this->assertSame($this->unit->id, $resultado['errors'][0]['unit_id']); } }