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' => ($isRenewal ? 'Renovação ' : '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 */ 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. */ 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 (md "Royalties" e "FNM"): meses 1 a 3 = isento. * * Cenário: Faturamento alto (R$ 40.000), que normalmente geraria R$ 3.200 de * royalties 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' */ 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 (md "Taxa de Manutenção"): meses 1 a 60 = 30% do fixo da TBR, sem exceção. * A isenção de Royalties/FNM dos meses 1-3 não se aplica à Manutenção. * * Cenário: Competência 02/2026 (mês 2), TBR de R$ 1.000,00. * * Espera: Manutenção = R$ 300,00; total do título = R$ 300,00. */ 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 e 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']); } // ---------------------------------------------- Royalties: regra do maior valor /** * Regra (md "Royalties" meses 4-60): maior entre % fixo da faixa de porte x TBR * ou 8% do faturamento. * * Cenário: Mês 4, TBR R$ 1.000,00 (75% da faixa = R$ 750,00), faturamento * R$ 1.000,00 (8% = R$ 80,00). * * Espera: Royalties = R$ 750,00, critério = 'tbr_fixo'. */ public function test_royalties_usa_valor_fixo_quando_faturamento_e_baixo(): void { $this->makeContract(); $result = $this->preview(2026, 4, 1000.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']); } /** * Regra (md "Royalties" meses 4-60): quando 8% do faturamento supera o fixo, vence o faturamento. * * Cenário: Mês 4, TBR R$ 1.000,00 (fixo R$ 750,00), faturamento R$ 40.000,00 (8% = R$ 3.200,00). * * Espera: Royalties = R$ 3.200,00, critério = 'percentual_faturamento'. */ public function test_royalties_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void { $this->makeContract(); $result = $this->preview(2026, 4, 40000.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']); } /** * Regra: Critério de desempate — valor igual mantém o fixo da TBR (comparação é >=). * * 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). */ public function test_empate_entre_fixo_e_faturamento_mantem_o_fixo_da_tbr(): void { $this->makeContract(); $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']); } // --------------------------------------------------------- FNM: regra do maior valor /** * Regra (md "FNM" meses 4-60): maior entre % fixo do FNM (tabela TBR do ano) x TBR * ou 2% do faturamento. O percentual fixo vem de tbrs.fnm_percentage (20% no cenário base). * * Cenário: Mês 4, TBR R$ 1.000,00 (fixo 20% = R$ 200,00), faturamento R$ 1.000,00 (2% = R$ 20,00). * * Espera: FNM = R$ 200,00, percentual efetivo = 0,20. */ public function test_fnm_usa_valor_fixo_quando_faturamento_e_baixo(): void { $this->makeContract(); $result = $this->preview(2026, 4, 1000.00); $this->assertSame(200.0, $result['fnm_effective_value']); $this->assertSame(0.20, $result['fnm_effective_percentage']); } /** * Regra (md "FNM" meses 4-60): quando 2% do faturamento supera o fixo, vence o faturamento. * * Cenário: Mês 4, TBR R$ 1.000,00 (fixo R$ 200,00), faturamento R$ 40.000,00 (2% = R$ 800,00). * * Espera: FNM = R$ 800,00, percentual efetivo = 0,02. */ public function test_fnm_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void { $this->makeContract(); $result = $this->preview(2026, 4, 40000.00); $this->assertSame(800.0, $result['fnm_effective_value']); $this->assertSame(0.02, $result['fnm_effective_percentage']); } /** * Regra: Royalties e FNM são comparados de forma independente — um pode cair no * fixo e o outro no percentual de faturamento no mesmo mês. * * Cenário: Faturamento R$ 9.500,00: 8% = R$ 760 (passa do fixo de royalties, R$ 750), * mas 2% = R$ 190 (fica abaixo do fixo de FNM, R$ 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(); $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']); } // ------------------------------------------------------------- Taxa de Manutenção /** * Regra (md "Taxa de Manutenção"): 30% do fixo da TBR, meses 1-60, nunca comparada * com o faturamento — não existe "maior valor" para a manutenção. * * Cenário: Mês 6, TBR R$ 1.000,00, faturamento altíssimo (R$ 1.000.000,00). * * Espera: Manutenção = R$ 300,00 de qualquer forma. */ public function test_manutencao_nunca_e_comparada_com_o_faturamento(): void { $this->makeContract(); $result = $this->preview(2026, 6, 1_000_000.00); $this->assertSame(300.0, $result['maintenance_effective_value']); $this->assertSame(0.30, $result['maintenance_effective_percentage']); } // --------------------------------------------- Classificação de Habitantes (porte) /** * Regra (md "Classificação de Habitantes"): a faixa de royalties muda conforme o * mês de contrato (4-12 x 13-60), dentro do mesmo porte. * * Cenário: Porte GP — mês 12 (75%) e mês 13 (100%). * * Espera: mês 12 -> R$ 750,00; mês 13 -> 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 (md "Classificação de Habitantes"): o porte do município do contrato * define qual faixa de royalties se aplica. * * 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: Contrato sem faixa cadastrada para o porte/mês, e sem faixa de renovação * cobrindo o período, não calcula. * * Cenário: Faixas cadastradas só até o mês 60, sem nenhuma faixa de renovação; * competência cai no mês 61. * * Espera: ValidationException 'Não foi encontrada faixa de royalties'. */ public function test_erro_quando_nao_existe_faixa_nem_de_renovacao_para_o_mes(): 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, sem faixa de renovação cadastrada } // ---------------------------------------------------- Renovação (mês 61 em diante) /** * Regra: A partir do mês 61 (fim do primeiro ciclo de 60 meses), a faixa de * royalties passa a vir de is_renewal=true — o contrato "renova" e continua sendo * cobrado, em vez de estourar erro. * * Cenário: Faixa de renovação do porte GP cobrindo 1-60, com 100% (igual à última * faixa normal). Competência cai no mês 61 do contrato. * * Espera: encontra a faixa de renovação, percentual = 1,00, Royalties = R$ 1.000,00. */ public function test_mes_61_usa_a_faixa_de_renovacao(): void { $this->makeBrackets($this->sizeGp->id, [[1, 60, 1.00]], true); $this->makeContract(); $this->makeTbrYear(2031, 1000.00); $result = $this->preview(2031, 1, 1000); // mês de contrato 61 $this->assertSame(1.0, $result['royalties_bracket_percentage']); $this->assertSame(1000.0, $result['royalties_effective_value']); } /** * Regra: O mês de contrato dentro do ciclo de renovação é "enrolado" de volta para * o intervalo 1-60 (mês 61 = mês 1 da renovação, mês 120 = mês 60 da renovação). * * Cenário: Faixa de renovação com duas fatias (1-12 = 60%, 13-60 = 100%), simulando * uma nova isenção/escalonamento dentro do próprio ciclo de renovação. * * Espera: * - mês 65 do contrato (5º mês da renovação) -> 60% * - mês 120 do contrato (60º mês da renovação) -> 100% */ public function test_mes_de_renovacao_e_calculado_dentro_do_ciclo_de_60_meses(): void { $this->makeBrackets($this->sizeGp->id, [[1, 12, 0.60], [13, 60, 1.00]], true); $this->makeContract(); $this->makeTbrYear(2031, 1000.00); // mês 61 $this->makeTbrYear(2035, 1000.00); // mês 61 + 59 = mês 120 $mes65 = $this->preview(2031, 5, 1000); // mês de contrato 65 -> renovação mês 5 $mes120 = $this->preview(2035, 12, 1000); // mês de contrato 120 -> renovação mês 60 $this->assertSame(0.60, $mes65['royalties_bracket_percentage']); $this->assertSame(1.00, $mes120['royalties_bracket_percentage']); } /** * Regra: Passado um segundo ciclo completo de 60 meses (mês 121 em diante), o * ciclo de renovação recomeça do zero. * * Cenário: Faixa de renovação única (1-60 = 100%); competência cai no mês 121. * * Espera: continua encontrando a faixa de renovação (não estoura erro), 100%. */ public function test_segundo_ciclo_de_renovacao_reinicia_a_contagem(): void { $this->makeBrackets($this->sizeGp->id, [[1, 60, 1.00]], true); $this->makeContract(); $this->makeTbrYear(2036, 1000.00); // mês de contrato 121 $result = $this->preview(2036, 1, 1000); $this->assertSame(1.0, $result['royalties_bracket_percentage']); } // --------------------------------------------------------------- valor da TBR /** * Regra (md): o percentual/valor fixo vem da "TBR Fixo do ano (Atribuído em * Configurações do TBR)". A tabela `tbrs` do ano cadastrada pelo admin manda * sobre o valor gravado no 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; Manutenção = R$ 300,00. */ public function test_tbr_do_ano_configurada_pelo_admin_tem_prioridade_sobre_o_contrato(): void { $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']); $this->assertSame(300.0, $result['maintenance_effective_value']); } /** * Regra: Cada ano de referência usa sua própria configuração de TBR. * * Cenário: tbrs/2026 = R$ 1.000,00 (setUp), tbrs/2027 = R$ 2.000,00. * * Espera: mês em 2026 usa R$ 1.000,00; mês em 2027 usa R$ 2.000,00. */ public function test_cada_ano_usa_a_sua_propria_configuracao_de_tbr(): void { $this->makeContract(); $this->makeTbrYear(2027, 2000.00); $this->assertSame(1000.0, $this->preview(2026, 4, 1000)['tbr_value']); $this->assertSame(2000.0, $this->preview(2027, 1, 1000)['tbr_value']); } /** * Regra: Quando o ano de referência não está configurado em `tbrs`, usa-se o * valor gravado no contrato (tbr_fixed_value) como reserva. * * Cenário: Competência em 2030 (sem linha em `tbrs`), contrato com tbr_fixed_value = R$ 1.500,00. * * Espera: TBR usada = R$ 1.500,00. */ public function test_usa_a_tbr_do_contrato_quando_o_ano_nao_esta_configurado(): void { $this->makeContract(['tbr_fixed_value' => 1500.00]); $result = $this->preview(2030, 1, 1000); $this->assertSame(1500.0, $result['tbr_value']); } /** * Regra: Sem TBR configurada nem no ano nem no contrato, o cálculo não pode seguir. * * Cenário: Ano sem `tbrs` e contrato com tbr_fixed_value = 0. * * Espera: ValidationException. */ public function test_erro_quando_nao_ha_tbr_no_ano_nem_no_contrato(): void { $this->makeContract(['tbr_fixed_value' => 0]); $this->expectException(ValidationException::class); $this->preview(2030, 1, 1000); } /** * Regra: Os percentuais fixos de FNM e Manutenção vêm da tabela `tbrs` do ano. * * Cenário: tbrs/2027 com FNM = 25% e Manutenção = 35%. * * Espera: FNM fixo = R$ 250,00 (25% x 1000) e Manutenção = R$ 350,00 (35% x 1000). */ public function test_percentuais_de_fnm_e_manutencao_vem_da_tabela_do_ano(): void { $this->makeContract(); $this->makeTbrYear(2027, 1000.00, fnm: 0.25, maintenance: 0.35); $result = $this->preview(2027, 1, 1000); // mês 13, sem faturamento suficiente pra superar o fixo $this->assertSame(250.0, $result['fnm_bracket_value']); $this->assertSame(350.0, $result['maintenance_effective_value']); } // ------------------------------------------------ flags charge_roi / charge_fnm /** * Regra (md "UnitActionPage"): quando a cobrança de Royalties (ROI) está desativada * para a unidade, o valor efetivo de royalties é zerado. * * Cenário: `unit_financials.charge_roi = false`. * * Espera: royalties_effective_value = 0, critério = 'nao_cobrado'; Manutenção continua. */ public function test_charge_roi_desligado_zera_os_royalties(): void { $this->makeContract(); UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]); $result = $this->preview(2026, 4, 40000); $this->assertSame(0.0, $result['royalties_effective_value']); $this->assertSame('nao_cobrado', $result['royalties_applied_criteria']); $this->assertGreaterThan(0, $result['maintenance_effective_value']); } /** * Regra: Com charge_roi desligado, o cálculo nem exige que exista faixa de * royalties cadastrada para o porte/mês (não busca a faixa). * * Cenário: Nenhuma faixa cadastrada para o porte MP; charge_roi = false. * * Espera: preview não lança exceção; royalties = 0. */ public function test_charge_roi_desligado_dispensa_a_existencia_de_faixa(): void { $sizeMp = $this->makeSize('MP', 'Sem faixas cadastradas'); $this->makeContract(['municipality_size_id' => $sizeMp->id]); UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]); $result = $this->preview(2026, 4, 1000); $this->assertSame(0.0, $result['royalties_effective_value']); $this->assertNull($result['royalties_bracket_id']); } /** * Regra: charge_fnm desligado zera apenas o FNM. * * Cenário: `unit_financials.charge_fnm = false`. * * Espera: fnm_effective_value = 0; royalties e manutenção continuam normais. */ public function test_charge_fnm_desligado_zera_o_fnm(): void { $this->makeContract(); UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => true, 'charge_fnm' => false]); $result = $this->preview(2026, 4, 1000); $this->assertSame(0.0, $result['fnm_effective_value']); $this->assertGreaterThan(0, $result['royalties_effective_value']); } /** * Regra: Unidade sem registro em `unit_financials` cobra normalmente (default seguro = cobrar). * * Cenário: Nenhum `UnitFinancial` criado para a unidade. * * Espera: royalties e FNM calculados normalmente (> 0). */ public function test_unidade_sem_registro_financeiro_cobra_royalties_e_fnm(): void { $this->makeContract(); $result = $this->preview(2026, 4, 1000); $this->assertGreaterThan(0, $result['royalties_effective_value']); $this->assertGreaterThan(0, $result['fnm_effective_value']); } /** * Regra (md "Taxa de Manutenção"): a manutenção não tem flag de liga/desliga — * é sempre cobrada, mesmo com charge_roi e charge_fnm desligados. * * Cenário: charge_roi = false e charge_fnm = false. * * Espera: Manutenção = R$ 300,00; total = R$ 300,00. */ public function test_manutencao_e_cobrada_mesmo_com_as_duas_flags_desligadas(): void { $this->makeContract(); UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => false]); $result = $this->preview(2026, 4, 1000); $this->assertSame(300.0, $result['maintenance_effective_value']); $this->assertSame(300.0, $result['final_value']); } // ---------------------------------------------------------- composição do faturamento /** * Regra: O faturamento-base do TBR soma parcelas de aluno, recebíveis avulsos da * unidade e contas vinculadas pela franqueadora (origin=manual_unit). * * Cenário: 1 parcela de R$ 100, 1 recebível avulso de R$ 50, 1 conta da matriz * vinculada de R$ 30, todos com vencimento no mês de referência. * * Espera: faturamento resolvido = R$ 180,00. */ public function test_faturamento_soma_parcelas_recebiveis_e_contas_da_matriz(): void { $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10']); UnitAccountReceivable::create([ 'unit_id' => $this->unit->id, 'history' => 'Avulso', 'value' => 50, 'due_date' => '2026-04-15', 'status' => 'pending', ]); FranchiseeAccountReceive::create([ 'unit_id' => $this->unit->id, 'origin' => 'manual_unit', 'history' => 'Conta vinculada', 'value' => 30, 'due_date' => '2026-04-20', 'status' => 'pending', ]); $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4); $this->assertSame(180.0, $revenue); } /** * Regra: Parcelas canceladas e de outros meses não entram no faturamento. */ public function test_faturamento_ignora_parcelas_canceladas_e_de_outros_meses(): void { $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10', 'status' => 'cancelled']); $this->makeInstallment(['value' => 200, 'due_date' => '2026-05-10', 'installment_number' => 2]); $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4); $this->assertSame(0.0, $revenue); } /** * Regra: Parcelas com soft delete não entram no faturamento. */ public function test_faturamento_ignora_parcelas_excluidas(): void { $installment = $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10']); $installment->delete(); $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4); $this->assertSame(0.0, $revenue); } /** * Regra: Recebíveis originados do próprio TBR não realimentam a base de faturamento. */ public function test_faturamento_ignora_recebiveis_de_tbr(): void { FranchiseeAccountReceive::create([ 'unit_id' => $this->unit->id, 'origin' => 'tbr', 'history' => 'Royalties gerado', 'value' => 500, 'due_date' => '2026-04-20', 'status' => 'pending', ]); $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4); $this->assertSame(0.0, $revenue); } /** * Regra: Movimentos de outra unidade não entram no faturamento consultado. */ public function test_faturamento_ignora_movimento_de_outra_unidade(): void { $outraUnidade = $this->makeUnit('Outra Unidade'); $this->makeInstallment(['unit_id' => $outraUnidade->id, 'value' => 999, 'due_date' => '2026-04-10']); $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4); $this->assertSame(0.0, $revenue); } /** * Regra: Faturamento informado manualmente sobrepõe o cálculo automático (override). */ public function test_faturamento_informado_manualmente_substitui_o_calculo_automatico(): void { $this->makeContract(); $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10']); $result = $this->service->preview([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 40000, ]); $this->assertSame(40000.0, $result['revenue_value']); $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']); } // -------------------------------------------------------------- contrato / erros /** * Regra: Unidade sem contrato cadastrado não calcula. */ 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: Contrato sem porte do município definido não calcula. */ 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 definida'); $this->preview(2026, 4, 1000); } /** * Regra: Havendo mais de um contrato para a unidade, usa-se sempre o mais recente * (maior start_date). */ public function test_usa_sempre_o_contrato_mais_recente_da_unidade(): void { $this->makeContract(['start_date' => '2020-01-01', 'tbr_fixed_value' => 500]); $this->makeContract(['start_date' => '2026-01-01', 'tbr_fixed_value' => 1500]); $result = $this->preview(2026, 1, 1000); $this->assertSame(1, $result['contract_month_reference']); } // -------------------------------------------------------------- persistência /** * Regra: calculate() grava em `tbr_calculations` exatamente os valores do preview * (a tabela de auditoria pedida no md). */ 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, ]); $this->assertInstanceOf(TbrCalculation::class, $calculation); $this->assertSame(4, $calculation->contract_month_reference); $this->assertSame('percentual_faturamento', $calculation->royalties_applied_criteria); $this->assertEquals(3200.0, (float) $calculation->royalties_effective_value); $this->assertEquals(800.0, (float) $calculation->fnm_effective_value); $this->assertEquals(300.0, (float) $calculation->maintenance_effective_value); $this->assertEquals(4300.0, (float) $calculation->final_value); $this->assertFalse($calculation->receivable_generated); } // ---------------------------------------------------------- geração de título /** * Regra (md "Tabela de Auditoria" + contas a receber/pagar): gerar o título cria * o recebível da matriz, os 3 detalhes por componente e o espelho na unidade * (conta a pagar), além de disparar a sincronização com o Asaas. */ 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' => 40000, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame(4300.0, (float) $receive->value); $this->assertCount(3, $receive->details); $this->assertDatabaseHas('unit_account_payables', [ 'unit_id' => $this->unit->id, 'franchisee_account_receive_id' => $receive->id, 'origin' => UnitAccountPayable::ORIGIN_TBR, ]); $this->assertTrue($calculation->fresh()->receivable_generated); Bus::assertDispatched(\App\Jobs\SyncFranchiseeChargeJob::class); } /** * Regra: Não é possível gerar título duas vezes para o mesmo 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, ]); $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: Não é possível gerar dois títulos para o mesmo mês de contrato da mesma unidade. */ 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, ]); $this->service->generateReceivable($primeiro->id); $segundo = $this->service->calculate([ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 2000, ]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('mês de contrato'); $this->service->generateReceivable($segundo->id); } /** * Regra: O preview avisa (flag) quando já existe título gerado para o mês de contrato, * sem impedir a nova simulação. */ public function test_preview_avisa_quando_ja_existe_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, ]); $this->service->generateReceivable($calculation->id); $result = $this->preview(2026, 4, 1000); $this->assertTrue($result['receivable_already_generated']); } // ----------------------------------------------------------------- vencimento 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, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame('2026-05-15', $receive->due_date->toDateString()); } 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, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame('2026-05-10', $receive->due_date->toDateString()); } 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, ]); $receive = $this->service->generateReceivable($calculation->id); $this->assertSame(28, $receive->due_date->day); } // ---------------------------------------------------------------------- lote public function test_lote_gera_titulo_para_cada_contrato_ativo(): void { $unidade2 = $this->makeUnit('Unidade 2'); $this->makeContract(); $this->makeContract(['unit_id' => $unidade2->id]); $result = $this->service->generateBatch(2026, 4, null); $this->assertSame(2, $result['generated_count']); $this->assertSame(0, $result['error_count']); } 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, ]); $this->service->generateReceivable($calculation->id); $result = $this->service->generateBatch(2026, 4, null); $this->assertSame(0, $result['generated_count']); $this->assertSame(1, $result['skipped_count']); } public function test_lote_ignora_contrato_encerrado_antes_da_competencia(): void { $this->makeContract(['end_date' => '2026-02-28']); $result = $this->service->generateBatch(2026, 4, null); $this->assertSame(0, $result['generated_count']); $this->assertSame(0, $result['skipped_count']); $this->assertSame(0, $result['error_count']); } public function test_lote_ignora_contrato_que_ainda_nao_comecou(): void { $this->makeContract(['start_date' => '2026-08-01']); $result = $this->service->generateBatch(2026, 4, null); $this->assertSame(0, $result['generated_count']); } public function test_lote_ignora_contrato_sem_porte_definido(): void { $this->makeContract(['municipality_size_id' => null]); $result = $this->service->generateBatch(2026, 4, null); $this->assertSame(0, $result['generated_count']); $this->assertSame(0, $result['error_count']); } public function test_lote_pode_ser_filtrado_por_unidade(): void { $unidade2 = $this->makeUnit('Unidade 2'); $this->makeContract(); $this->makeContract(['unit_id' => $unidade2->id]); $result = $this->service->generateBatch(2026, 4, [$unidade2->id]); $this->assertSame(1, $result['generated_count']); $this->assertSame($unidade2->id, $result['generated'][0]['unit_id']); } public function test_lote_reporta_erro_sem_interromper_as_demais_unidades(): void { $unidadeComErro = $this->makeUnit('Unidade Sem TBR'); $this->makeContract(['tbr_fixed_value' => 0]); $this->makeContract(['unit_id' => $unidadeComErro->id, 'tbr_fixed_value' => 0]); Tbr::query()->delete(); $result = $this->service->generateBatch(2030, 1, null); $this->assertSame(0, $result['generated_count']); $this->assertSame(2, $result['error_count']); } }