| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165 |
- <?php
- namespace Tests\Unit\Financeiro;
- use App\Models\City;
- use App\Models\Country;
- use App\Models\FranchiseeAccountReceive;
- use App\Models\FranchiseeContract;
- use App\Models\InhabitantClassification;
- use App\Models\MunicipalitySize;
- use App\Models\State;
- use App\Models\Student;
- use App\Models\StudentContract;
- use App\Models\StudentContractInstallment;
- use App\Models\Tbr;
- use App\Models\TbrCalculation;
- use App\Models\Unit;
- use App\Models\UnitAccountPayable;
- use App\Models\UnitAccountReceivable;
- use App\Models\UnitFinancial;
- use App\Services\TbrCalculationService;
- use Illuminate\Foundation\Testing\RefreshDatabase;
- use Illuminate\Support\Facades\Bus;
- use Illuminate\Validation\ValidationException;
- use Tests\TestCase;
- /**
- * Regras de cobrança do contrato da unidade (Royalties / FNM / Taxa de Manutenção).
- *
- * Documentação de apoio: docs/calculo-royalties.md
- *
- * Resumo das regras validadas aqui (ver o md para o texto original do cliente):
- * - Royalties: meses 1-3 isento; meses 4-60 = maior entre % fixo da faixa de
- * porte (Classificação de Habitantes) x TBR, ou 8% do faturamento da unidade.
- * - FNM: meses 1-3 isento; meses 4-60 = maior entre % fixo do FNM (tabela TBR
- * do ano) x TBR, ou 2% do faturamento da unidade.
- * - Taxa de Manutenção: meses 1-60, sempre 30% da TBR — nunca isenta, nunca
- * comparada com faturamento, sem flag de liga/desliga.
- * - A partir do mês 61 (fim do primeiro ciclo de 60 meses) o contrato entra em
- * renovação: a faixa de royalties passa a vir de is_renewal=true, com o mês
- * de contrato "enrolado" de volta para o intervalo 1-60.
- * - Critério aplicado (tbr_fixo | percentual_faturamento | nao_cobrado) é
- * sempre gravado, como pedido no md.
- *
- * Cenário base usado na maioria dos testes:
- * - TBR = R$ 1.000,00 configurada em `tbrs` para 2026
- * - Porte GP: meses 1-3 = 0%, meses 4-12 = 75%, meses 13-60 = 100%
- * - FNM fixo = 20% da TBR | Manutenção = 30% da TBR
- * - Royalties sobre faturamento = 8% | FNM sobre faturamento = 2%
- * - Contrato iniciado em 01/01/2026
- *
- * Cada teste tem em cima um bloco Regra / Cenário / Espera para conferir a
- * regra de negócio, não só se o teste passa.
- */
- class TbrCalculationRulesTest extends TestCase
- {
- use RefreshDatabase;
- private TbrCalculationService $service;
- private Unit $unit;
- private MunicipalitySize $sizeGp;
- /** @var array<int, array{student_id:int, contract_id:int}> 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<int, array{0:int,1:?int,2:float}> $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 x TBR ou 8% do faturamento.
- * O % fixo é a taxa-base configurada em "Configurações do TBR"
- * (tbrs.royalties_percentage, 8% no cenário base) multiplicada pelo percentual
- * da faixa de porte (Classificação de Habitantes) — a faixa é um multiplicador,
- * não o percentual final.
- *
- * Cenário: Mês 4, faixa GP = 75% (multiplicador), taxa-base = 8% -> fixo efetivo
- * = 6% da TBR = R$ 60,00 (TBR R$ 1.000,00). Faturamento baixo (R$ 500,00, 8% = R$ 40,00).
- *
- * Espera: Royalties = R$ 60,00, critério = 'tbr_fixo'.
- */
- public function test_royalties_usa_valor_fixo_quando_faturamento_e_baixo(): void
- {
- $this->makeContract();
- $result = $this->preview(2026, 4, 500.00);
- $this->assertSame(60.0, $result['royalties_effective_value']);
- $this->assertSame(0.06, $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: Fixo do mês 4 = 8% (base) x 75% (faixa GP) = 6% x TBR R$ 1.000 = R$ 60,00.
- * Faturamento R$ 750,00, em que 8% = R$ 60,00 = exatamente o valor fixo.
- *
- * Espera: Royalties = R$ 60,00, critério = 'tbr_fixo', percentual gravado = 0,06.
- */
- public function test_empate_entre_fixo_e_faturamento_mantem_o_fixo_da_tbr(): void
- {
- $this->makeContract();
- $result = $this->preview(2026, 4, 750.00);
- $this->assertSame(60.0, $result['royalties_effective_value']);
- $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
- $this->assertSame(0.06, $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);
- // 8% (base) x 75% (faixa 4-12) = 6%
- $this->assertSame(0.06, $mes12['royalties_bracket_percentage']);
- $this->assertSame(60.0, $mes12['royalties_bracket_value']);
- // 8% (base) x 100% (faixa 13-60) = 8%
- $this->assertSame(0.08, $mes13['royalties_bracket_percentage']);
- $this->assertSame(80.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 (multiplicador 100% no mês 4) no lugar de GP (75%).
- *
- * Espera: percentual efetivo da faixa = 8% (base) x 100% = 0,08; Royalties = R$ 80,00
- * em vez dos R$ 60,00 do porte GP.
- */
- 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(0.08, $result['royalties_bracket_percentage']);
- $this->assertSame(80.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 multiplicador 100%
- * (igual à última faixa normal). Competência cai no mês 61 do contrato.
- *
- * Espera: encontra a faixa de renovação; percentual efetivo = 8% (base) x 100% = 0,08;
- * Royalties = R$ 80,00 (empata com os 8% de faturamento de R$ 1.000,00, fica no fixo).
- */
- 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(0.08, $result['royalties_bracket_percentage']);
- $this->assertSame(80.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 (percentual efetivo = 8% base x multiplicador da faixa de renovação):
- * - mês 65 do contrato (5º mês da renovação, multiplicador 60%) -> 8% x 60% = 0,048
- * - mês 120 do contrato (60º mês da renovação, multiplicador 100%) -> 8% x 100% = 0,08
- */
- 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.048, $mes65['royalties_bracket_percentage']);
- $this->assertSame(0.08, $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, multiplicador 100%); competência cai no mês 121.
- *
- * Espera: continua encontrando a faixa de renovação (não estoura erro); percentual
- * efetivo = 8% (base) x 100% = 0,08.
- */
- 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(0.08, $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.
- * Faturamento baixo (R$ 500,00) para o fixo (R$ 60,00) prevalecer sobre o percentual.
- *
- * Espera: TBR usada = R$ 1.000,00; Royalties = R$ 60,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, 500);
- $this->assertSame(1000.0, $result['tbr_value']);
- $this->assertSame(60.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']);
- }
- }
|