Переглянути джерело

Refactor code structure for improved readability and maintainability

alvesantos 2 днів тому
батько
коміт
5de5d38038

+ 5 - 3
app/Services/TbrCalculationService.php

@@ -308,15 +308,17 @@ private function buildPreview(FranchiseeContract $contract, int $referenceYear,
     {
         $tbrConfig = Tbr::where('year', $referenceYear)->orderByDesc('id')->first();
 
-        $tbrValue = (float) ($contract->tbr_fixed_value ?? 0);
+        // A TBR do ano configurada pelo admin manda: é a tabela oficial da rede.
+        // O valor gravado no contrato só entra quando o ano não está configurado.
+        $tbrValue = (float) ($tbrConfig->tbr_value ?? 0);
 
         if ($tbrValue <= 0) {
-            $tbrValue = (float) ($tbrConfig->tbr_value ?? 0);
+            $tbrValue = (float) ($contract->tbr_fixed_value ?? 0);
         }
 
         if ($tbrValue <= 0) {
             throw ValidationException::withMessages([
-                'unit_id' => 'TBR não definida para o contrato nem para o ano de referência.',
+                'unit_id' => 'TBR não definida para o ano de referência nem para o contrato.',
             ]);
         }
 

+ 305 - 0
docs/regras-financeiras-contrato-unidade.md

@@ -0,0 +1,305 @@
+# Regras financeiras do contrato da unidade (TBR / Royalties / FNM / Manutenção)
+
+Documento simples e direto sobre **como o sistema cobra a unidade franqueada**.
+Base do código: `app/Services/TbrCalculationService.php`.
+
+---
+
+## 1. O que é criado junto com a unidade
+
+Ao criar uma unidade (`UnitService::create`), o sistema já cria:
+
+| Registro | Tabela | Para quê |
+|---|---|---|
+| Unidade | `units` | cadastro |
+| Franqueado + vínculo | `franchisees`, `franchisee_units` | dono da unidade |
+| **Financeiro da unidade** | `unit_financials` | nasce com `charge_roi = true` e `charge_fnm = true` |
+| Perfis de acesso | `user_types` | ADMIN_FRANCHISEE + templates |
+| Pacotes de aula | `class_package_units` | réplica dos pacotes base |
+
+O **contrato** é um cadastro à parte (`franchisee_contracts`), criado depois pela franqueadora.
+Uma unidade pode ter vários contratos; o sistema sempre usa **o de `start_date` mais recente**.
+
+> Atenção ao nome: a tabela `unit_contracts` **não** tem regra de cobrança — é só upload de PDF.
+> Quem carrega as regras é `franchisee_contracts`.
+
+---
+
+## 2. O contrato (`franchisee_contracts`)
+
+Campos que importam para a cobrança:
+
+| Campo | Papel |
+|---|---|
+| `unit_id` | unidade dona do contrato |
+| `start_date` | **marco zero**: define o "mês de contrato" (mês 1, 2, 3...) |
+| `end_date` | fim da vigência (nulo = sem fim) |
+| `municipality_size_id` | **porte do município** (PP / MP / GP / MGP) → define o % de royalties |
+| `tbr_fixed_value` | TBR de reserva: só é usada se o ano de referência não estiver cadastrado em `tbrs` |
+| `invoice_due_date` | dia do vencimento da fatura (1 a 28; padrão 10) |
+
+Regras de cadastro (`FranchiseeContractService`):
+
+- `protocol` = maior protocolo daquela unidade + 1 (sequência **por unidade**).
+- `signature_date` = `start_date`.
+- `validity_months` = meses entre `start_date` e `end_date`.
+- Ao alterar campos de imposto (`municipality_size_id`, `tbr_fixed_value`, `tbr_fixed_value_percentage`,
+  `marketing_fund_percentage`, `maintance_tax_percentage`) o sistema grava
+  `franchisee_contract_tax_histories`: na **primeira** alteração grava o "antes" e o "depois";
+  nas seguintes, só o "depois".
+
+---
+
+## 3. Os três valores cobrados todo mês
+
+Toda competência (mês/ano) a franqueadora cobra da unidade:
+
+1. **Royalties (ROI)**
+2. **FNM** — Fundo Nacional de Marketing
+3. **Taxa de Manutenção**
+
+Total do título = soma dos três (`final_value`). Não há desconto/juros embutidos no cálculo.
+
+---
+
+## 4. Passo a passo do cálculo
+
+### 4.1. Achar o valor da TBR
+
+```
+TBR = tbrs.tbr_value do ano de referência   (se > 0)
+    senão contrato.tbr_fixed_value
+    senão → erro "TBR não definida para o ano de referência nem para o contrato."
+```
+
+**A tabela anual `tbrs` manda.** É a configuração que o admin cadastra por ano e vale para
+a rede inteira; se o ano de referência estiver cadastrado, o valor do contrato é ignorado.
+O `tbr_fixed_value` do contrato só é usado quando aquele ano não tem configuração
+(ou está com valor zerado).
+
+Exemplo: `tbrs` 2026 = R$ 1.621,00 e contrato com `tbr_fixed_value` = R$ 1.400,00
+→ competência de 2026 calcula sobre **R$ 1.621,00**.
+
+### 4.2. Achar o "mês de contrato"
+
+```
+mês_de_contrato = meses entre (início do mês de start_date) e (início do mês de referência) + 1
+mínimo = 1
+```
+
+Exemplo: contrato começa em **jan/2026**.
+- competência jan/2026 → mês 1
+- competência mar/2026 → mês 3
+- competência abr/2026 → mês 4
+
+Se o contrato não tiver `start_date`, o mês vira 1.
+
+### 4.3. Isenção dos meses 1 a 3
+
+**Meses 1, 2 e 3: Royalties = 0 e FNM = 0.**
+O critério registrado é `tbr_fixo`.
+
+> A **Taxa de Manutenção continua sendo cobrada** nos meses 1 a 3. A isenção é só de ROI e FNM.
+
+### 4.4. Do mês 4 em diante — a regra do "maior valor"
+
+Cada componente tem dois candidatos: um valor **fixo sobre a TBR** e um **percentual do faturamento**.
+Vence o **maior**. Em caso de empate, vence o fixo da TBR.
+
+**Royalties**
+
+```
+fixo      = % da faixa do porte  ×  TBR
+faturamento = 8% × faturamento do mês
+Royalties  = max(fixo, faturamento)
+critério   = 'tbr_fixo' se fixo >= faturamento, senão 'percentual_faturamento'
+```
+
+**FNM**
+
+```
+fixo      = tbrs.fnm_percentage do ano  ×  TBR    (fallback do código: 20%)
+faturamento = 2% × faturamento do mês
+FNM        = max(fixo, faturamento)
+```
+
+**Taxa de Manutenção** — não tem comparação e não tem isenção:
+
+```
+Manutenção = tbrs.maintenance_percentage do ano × TBR   (fallback do código: 30%)
+```
+
+### 4.5. Faixas de royalties por porte do município
+
+Vêm de `inhabitant_classifications`, filtrando `is_renewal = false` e
+`start <= mês_de_contrato <= end`. Valores atuais do seeder:
+
+| Porte | Habitantes | Meses 1–3 | Meses 4–12 | Meses 13–60 |
+|---|---|---|---|---|
+| PP  | até 50 mil       | 0% | **40%** da TBR | **60%** da TBR |
+| MP  | 50 mil–100 mil   | 0% | **50%** | **75%** |
+| GP  | 100 mil–200 mil  | 0% | **75%** | **100%** |
+| MGP | acima de 200 mil | 0% | **100%** | **150%** |
+
+Se não achar faixa para o mês → erro
+"Não foi encontrada faixa de royalties para o porte e mês de contrato informados."
+
+### 4.6. Chaves liga/desliga da unidade (`unit_financials`)
+
+| Flag | Efeito quando `false` |
+|---|---|
+| `charge_roi` | Royalties = 0, critério vira `nao_cobrado` e o sistema **nem procura a faixa** |
+| `charge_fnm` | FNM = 0 |
+
+Se a unidade **não tiver** registro em `unit_financials`, o sistema assume **cobra os dois**.
+A Taxa de Manutenção não tem flag: é sempre cobrada.
+
+---
+
+## 5. Como o faturamento do mês é apurado
+
+Só entra o que **vence dentro do mês de referência** e **não está `cancelled`** (nem soft-deleted):
+
+| Fonte | Tabela | Filtro |
+|---|---|---|
+| Parcelas de aluno | `student_contract_installments` | `unit_id`, `due_date` no mês |
+| Recebíveis da unidade | `unit_account_receivables` | `unit_id`, `due_date` no mês |
+| Contas que a matriz lançou na unidade | `franchisee_account_receives` | `origin = 'manual_unit'` e `unit_id` preenchido |
+
+**Fora da base:** recebíveis de TBR (`origin = 'tbr'`) — senão a cobrança realimentaria a própria base.
+
+Duas formas de informar o faturamento:
+
+- **Automático**: soma acima, do próprio mês de referência
+  (a constante `REVENUE_FROM_PREVIOUS_MONTH` está `false`).
+- **Manual**: quem calcula manda `revenue_value` > 0 e esse valor **substitui** a soma automática.
+
+---
+
+## 6. Da conta ao título
+
+### 6.1. Cálculo (`preview` / `calculate`)
+
+- `preview` só simula; `calculate` grava um registro em `tbr_calculations`.
+- O contrato usado é o mais recente com `start_date` preenchido.
+- Sem contrato → "Unidade não possui contrato cadastrado."
+- Contrato sem `municipality_size_id` → "O contrato da unidade não tem a faixa de habitantes definida."
+
+### 6.2. Geração do título (`generateReceivable`)
+
+Bloqueios:
+- cálculo já usado (`receivable_generated = true`) → erro;
+- já existe outro título gerado para **a mesma unidade no mesmo mês de contrato** → erro.
+
+O que é criado:
+
+| Registro | Conteúdo |
+|---|---|
+| `franchisee_account_receives` | valor total, `status = 'pending'`, `order` = mês de contrato, histórico "Royalties / FNM / Manutenção — MM/AAAA" |
+| 3× `franchisee_account_receive_details` | Royalties, FNM e Taxa Manutenção separados |
+| `unit_account_payables` | espelho da dívida no financeiro da unidade, `origin = 'tbr'` |
+| `SyncFranchiseeChargeJob` | tenta emitir a cobrança no Asaas (sem chave configurada, fica para baixa manual) |
+
+**Vencimento:**
+
+```
+vencimento = (mês de referência + 1 mês) no dia invoice_due_date
+dia limitado entre 1 e 28; padrão 10 quando o contrato não informa
+```
+
+Ex.: competência 03/2026, `invoice_due_date = 15` → vence **15/04/2026**.
+
+### 6.3. Geração em lote (`generateBatch`)
+
+Pega os contratos **ativos** na competência: `start_date <= último dia do mês`,
+`end_date` nulo ou `>= primeiro dia do mês`, `municipality_size_id` preenchido,
+**um contrato por unidade** (o de `start_date` mais recente).
+Retorna `generated` / `skipped` (já gerado) / `errors`.
+
+---
+
+## 7. Exemplo numérico
+
+Contrato: porte **GP**, `start_date = 01/01/2026`, TBR = R$ 1.621,00, `invoice_due_date = 10`.
+Tabela `tbrs` 2026: FNM 20%, Manutenção 30%.
+
+**Competência 02/2026 (mês 2) — faturamento R$ 40.000**
+
+| Componente | Conta | Valor |
+|---|---|---|
+| Royalties | isento (mês ≤ 3) | R$ 0,00 |
+| FNM | isento (mês ≤ 3) | R$ 0,00 |
+| Manutenção | 30% × 1.621,00 | R$ 486,30 |
+| **Total** | | **R$ 486,30** |
+
+**Competência 05/2026 (mês 5) — faturamento R$ 40.000**
+
+| Componente | Fixo TBR | % faturamento | Vence | Valor |
+|---|---|---|---|---|
+| Royalties | 75% × 1.621 = 1.215,75 | 8% × 40.000 = 3.200,00 | faturamento | R$ 3.200,00 |
+| FNM | 20% × 1.621 = 324,20 | 2% × 40.000 = 800,00 | faturamento | R$ 800,00 |
+| Manutenção | 30% × 1.621 = 486,30 | — | — | R$ 486,30 |
+| **Total** | | | | **R$ 4.486,30** |
+
+Vencimento: **10/06/2026**.
+
+**Competência 05/2026 (mês 5) — faturamento R$ 5.000**
+
+| Componente | Fixo TBR | % faturamento | Vence | Valor |
+|---|---|---|---|---|
+| Royalties | 1.215,75 | 400,00 | fixo TBR | R$ 1.215,75 |
+| FNM | 324,20 | 100,00 | fixo TBR | R$ 324,20 |
+| Manutenção | — | — | — | R$ 486,30 |
+| **Total** | | | | **R$ 2.026,25** |
+
+---
+
+## 8. Pontos de atenção encontrados na varredura
+
+Itens que **divergem entre si** no código atual — vale decidir qual é o certo:
+
+0. **Contratos com `tbr_fixed_value` diferente da tabela anual passam a ser ignorados.**
+   Com a tabela do ano no comando, qualquer TBR negociada e gravada no contrato deixa de
+   valer enquanto aquele ano estiver cadastrado em `tbrs`. Se existirem contratos com TBR
+   negociada, eles precisam de outro campo/regra.
+
+1. **`tbrs.fnm_percentage` semeado como 0,02.**
+   O `TbrSeeder` grava `fnm_percentage = 0.0200`, mas o cálculo usa esse campo como
+   *percentual sobre a TBR* (o fallback do código é `0.20`). Com o dado semeado, o FNM fixo
+   de uma TBR de R$ 1.621 vira **R$ 32,42** em vez de **R$ 324,20**. O 2% parece ter sido
+   pensado para o percentual sobre faturamento, que já é constante no código.
+
+2. **`tbrs.royalties_percentage` nunca é lido.** Os 8% sobre faturamento são a constante
+   `ROYALTIES_REVENUE_RATE` no serviço.
+
+3. **`TbrBillingPreviewService` tem uma segunda versão das regras.** Ele usa
+   `contrato.tbr_fixed_value_percentage` / `marketing_fund_percentage`, ignora o faturamento
+   e ignora `charge_roi` / `charge_fnm`. Resultado diferente do `TbrCalculationService`.
+
+4. **Duplicidade é checada só pelo mês de contrato**, sem olhar ano/mês de competência
+   (`existingReceivable(unit_id, contract_month)`).
+
+5. **Faixas de renovação não são usadas.** A busca fixa `is_renewal = false`, e as faixas
+   terminam no mês 60 — a partir do mês 61 o cálculo estoura com "faixa não encontrada".
+
+6. **Campos do contrato sem uso no cálculo**: `marketing_fund_fixed_value`,
+   `maintance_tax_fixed_value`, `maintance_tax_percentage`, `cancellation_fine`,
+   `discount_until_due_date`.
+
+---
+
+## 9. Onde estão os testes
+
+`tests/Unit/Financeiro/TbrCalculationRulesTest.php` cobre as regras acima.
+Rodar com:
+
+```bash
+php artisan test --filter=TbrCalculationRulesTest
+```
+
+Os testes usam o banco `db_test_ginastica_cerebro`, apontado em `phpunit.xml`.
+Na primeira vez, crie o banco (o `RefreshDatabase` cuida das migrations):
+
+```bash
+createdb -h localhost -U postgres db_test_ginastica_cerebro
+```

+ 2 - 2
phpunit.xml

@@ -22,8 +22,8 @@
         <env name="APP_MAINTENANCE_DRIVER" value="file"/>
         <env name="BCRYPT_ROUNDS" value="4"/>
         <env name="CACHE_STORE" value="array"/>
-        <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
-        <!-- <env name="DB_DATABASE" value=":memory:"/> -->
+        <env name="DB_CONNECTION" value="pgsql"/>
+        <env name="DB_DATABASE" value="db_test_ginastica_cerebro"/>
         <env name="MAIL_MAILER" value="array"/>
         <env name="PULSE_ENABLED" value="false"/>
         <env name="QUEUE_CONNECTION" value="sync"/>

+ 1429 - 0
tests/Unit/Financeiro/TbrCalculationRulesTest.php

@@ -0,0 +1,1429 @@
+<?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']);
+    }
+}