| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429 |
- <?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/regras-financeiras-contrato-unidade.md
- *
- * Cenário base usado na maioria dos testes:
- * - TBR = R$ 1.000,00 configurada em `tbrs` para 2026 (fácil de conferir a olho)
- * - 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 com três partes:
- * Regra - a regra de negócio que está sendo verificada
- * Cenário - o que foi montado no banco antes de chamar o serviço
- * Espera - o resultado esperado, com as contas abertas
- * Assim da para conferir se a regra descrita bate com o que o negócio quer, e não
- * só se o teste passa. Se a regra mudar, o bloco muda junto com o assert.
- *
- * Para adicionar um teste novo:
- * 1. Escreva primeiro o bloco Regra/Cenário/Espera, com os números na mão.
- * 2. Monte o cenário com os helpers: makeContract(), makeTbrYear(), makeBrackets(),
- * makeSize(), makeInstallment(), makeUnit().
- * 3. Use preview($ano, $mês, $faturamento) para simular sem gravar nada;
- * calculate() para gravar o cálculo; generateReceivable() para gerar o título.
- * 4. Prefira assertSame com float (ex.: 750.0) nos retornos do preview — são floats.
- * Nos modelos gravados os decimais voltam como string ('750.00').
- * 5. Erros de regra chegam como ValidationException; use expectExceptionMessage
- * com um trecho curto da mensagem.
- */
- 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' => "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']);
- }
- }
|