TbrCalculationRulesTest.php 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429
  1. <?php
  2. namespace Tests\Unit\Financeiro;
  3. use App\Models\City;
  4. use App\Models\Country;
  5. use App\Models\FranchiseeAccountReceive;
  6. use App\Models\FranchiseeContract;
  7. use App\Models\InhabitantClassification;
  8. use App\Models\MunicipalitySize;
  9. use App\Models\State;
  10. use App\Models\Student;
  11. use App\Models\StudentContract;
  12. use App\Models\StudentContractInstallment;
  13. use App\Models\Tbr;
  14. use App\Models\TbrCalculation;
  15. use App\Models\Unit;
  16. use App\Models\UnitAccountPayable;
  17. use App\Models\UnitAccountReceivable;
  18. use App\Models\UnitFinancial;
  19. use App\Services\TbrCalculationService;
  20. use Illuminate\Foundation\Testing\RefreshDatabase;
  21. use Illuminate\Support\Facades\Bus;
  22. use Illuminate\Validation\ValidationException;
  23. use Tests\TestCase;
  24. /**
  25. * Regras de cobrança do contrato da unidade (Royalties / FNM / Taxa de Manutenção).
  26. *
  27. * Documentação de apoio: docs/regras-financeiras-contrato-unidade.md
  28. *
  29. * Cenário base usado na maioria dos testes:
  30. * - TBR = R$ 1.000,00 configurada em `tbrs` para 2026 (fácil de conferir a olho)
  31. * - Porte GP: meses 1-3 = 0%, meses 4-12 = 75%, meses 13-60 = 100%
  32. * - FNM fixo = 20% da TBR | Manutenção = 30% da TBR
  33. * - Royalties sobre faturamento = 8% | FNM sobre faturamento = 2%
  34. * - Contrato iniciado em 01/01/2026
  35. *
  36. * Cada teste tem em cima um bloco com três partes:
  37. * Regra - a regra de negócio que está sendo verificada
  38. * Cenário - o que foi montado no banco antes de chamar o serviço
  39. * Espera - o resultado esperado, com as contas abertas
  40. * Assim da para conferir se a regra descrita bate com o que o negócio quer, e não
  41. * só se o teste passa. Se a regra mudar, o bloco muda junto com o assert.
  42. *
  43. * Para adicionar um teste novo:
  44. * 1. Escreva primeiro o bloco Regra/Cenário/Espera, com os números na mão.
  45. * 2. Monte o cenário com os helpers: makeContract(), makeTbrYear(), makeBrackets(),
  46. * makeSize(), makeInstallment(), makeUnit().
  47. * 3. Use preview($ano, $mês, $faturamento) para simular sem gravar nada;
  48. * calculate() para gravar o cálculo; generateReceivable() para gerar o título.
  49. * 4. Prefira assertSame com float (ex.: 750.0) nos retornos do preview — são floats.
  50. * Nos modelos gravados os decimais voltam como string ('750.00').
  51. * 5. Erros de regra chegam como ValidationException; use expectExceptionMessage
  52. * com um trecho curto da mensagem.
  53. */
  54. class TbrCalculationRulesTest extends TestCase
  55. {
  56. use RefreshDatabase;
  57. private TbrCalculationService $service;
  58. private Unit $unit;
  59. private MunicipalitySize $sizeGp;
  60. /** @var array<int, array{student_id:int, contract_id:int}> aluno/contrato reaproveitado por unidade */
  61. private array $studentContracts = [];
  62. protected function setUp(): void
  63. {
  64. parent::setUp();
  65. Bus::fake();
  66. $this->service = new TbrCalculationService;
  67. $this->unit = $this->makeUnit();
  68. $this->sizeGp = $this->makeSize('GP', 'De 100 mil a 200 mil habitantes');
  69. $this->makeBrackets($this->sizeGp->id, [
  70. [1, 3, 0.0],
  71. [4, 12, 0.75],
  72. [13, 60, 1.00],
  73. ]);
  74. $this->makeTbrYear(2026, 1000.00);
  75. }
  76. // ---------------------------------------------------------------- helpers
  77. private function makeUnit(string $name = 'Unidade Teste'): Unit
  78. {
  79. $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
  80. $state = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
  81. $city = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
  82. return Unit::create([
  83. 'fantasy_name' => $name,
  84. 'social_reason' => $name.' LTDA',
  85. 'cnpj' => '00000000000191',
  86. 'street' => 'Rua Teste',
  87. 'neighborhood' => 'Centro',
  88. 'postal_code' => '87000000',
  89. 'city_id' => $city->id,
  90. 'state_id' => $state->id,
  91. 'email' => 'unidade@teste.com',
  92. 'name_responsible' => 'Responsável Teste',
  93. ]);
  94. }
  95. private function makeSize(string $acronym, string $description): MunicipalitySize
  96. {
  97. return MunicipalitySize::create(['acronym' => $acronym, 'description' => $description]);
  98. }
  99. /** @param array<int, array{0:int,1:?int,2:float}> $brackets [start, end, percentual] */
  100. private function makeBrackets(int $sizeId, array $brackets, bool $isRenewal = false): void
  101. {
  102. foreach ($brackets as [$start, $end, $percentage]) {
  103. InhabitantClassification::create([
  104. 'municipality_size_id' => $sizeId,
  105. 'description' => "Faixa {$start}-{$end}",
  106. 'start' => $start,
  107. 'end' => $end,
  108. 'tbr_percentage' => $percentage,
  109. 'is_renewal' => $isRenewal,
  110. ]);
  111. }
  112. }
  113. private function makeTbrYear(int $year, float $value, float $fnm = 0.20, float $maintenance = 0.30): Tbr
  114. {
  115. return Tbr::create([
  116. 'year' => $year,
  117. 'tbr_value' => $value,
  118. 'royalties_percentage' => 0.08,
  119. 'fnm_percentage' => $fnm,
  120. 'maintenance_percentage' => $maintenance,
  121. ]);
  122. }
  123. private function makeContract(array $overrides = []): FranchiseeContract
  124. {
  125. return FranchiseeContract::create(array_merge([
  126. 'unit_id' => $this->unit->id,
  127. 'protocol' => 1,
  128. 'name' => 'Contrato de Franquia',
  129. 'description' => 'Contrato de teste',
  130. 'start_date' => '2026-01-01',
  131. 'end_date' => '2031-01-01',
  132. 'signature_date' => '2026-01-01',
  133. 'validity_months' => 60,
  134. 'invoice_due_date' => 10,
  135. 'municipality_size_id' => $this->sizeGp->id,
  136. 'tbr_fixed_value' => 1000.00,
  137. ], $overrides));
  138. }
  139. /** Aluno + contrato mínimos para pendurar as parcelas que formam o faturamento. */
  140. private function studentContractFor(int $unitId): array
  141. {
  142. if (isset($this->studentContracts[$unitId])) {
  143. return $this->studentContracts[$unitId];
  144. }
  145. $student = Student::create([
  146. 'name' => 'Aluno Teste',
  147. 'unit_id' => $unitId,
  148. 'status' => 'active',
  149. ]);
  150. $contract = StudentContract::create([
  151. 'student_id' => $student->id,
  152. 'unit_id' => $unitId,
  153. 'status' => 'active',
  154. ]);
  155. return $this->studentContracts[$unitId] = [
  156. 'student_id' => $student->id,
  157. 'contract_id' => $contract->id,
  158. ];
  159. }
  160. private function makeInstallment(array $overrides = []): StudentContractInstallment
  161. {
  162. $unitId = $overrides['unit_id'] ?? $this->unit->id;
  163. $owner = $this->studentContractFor($unitId);
  164. return StudentContractInstallment::create(array_merge([
  165. 'student_contract_id' => $owner['contract_id'],
  166. 'unit_id' => $unitId,
  167. 'student_id' => $owner['student_id'],
  168. 'type' => 'monthly',
  169. 'history' => 'Mensalidade',
  170. 'installment_number' => 1,
  171. 'total_installments' => 12,
  172. 'value' => 100.00,
  173. 'due_date' => '2026-04-10',
  174. 'status' => 'pending',
  175. ], $overrides));
  176. }
  177. /** Atalho: preview do cenário base para uma competência, com faturamento informado. */
  178. private function preview(int $year, int $month, float $revenue = 0.0): array
  179. {
  180. return $this->service->preview([
  181. 'unit_id' => $this->unit->id,
  182. 'reference_year' => $year,
  183. 'reference_month' => $month,
  184. 'revenue_value' => $revenue,
  185. ]);
  186. }
  187. // ------------------------------------------------- mês de contrato / isenção
  188. /**
  189. * Regra: Como o sistema descobre em que "mês de contrato" a competência cai.
  190. *
  191. * Cenário: Contrato iniciado em 01/01/2026; consulta várias competências.
  192. *
  193. * Espera:
  194. * - 01/2026 -> mês 1
  195. * - 03/2026 -> mês 3
  196. * - 04/2026 -> mês 4
  197. * - 01/2027 -> mês 13
  198. *
  199. * 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.
  200. */
  201. public function test_mes_de_contrato_conta_a_partir_do_start_date(): void
  202. {
  203. $this->makeContract();
  204. $this->assertSame(1, $this->preview(2026, 1, 1000)['contract_month_reference']);
  205. $this->assertSame(3, $this->preview(2026, 3, 1000)['contract_month_reference']);
  206. $this->assertSame(4, $this->preview(2026, 4, 1000)['contract_month_reference']);
  207. $this->assertSame(13, $this->preview(2027, 1, 1000)['contract_month_reference']);
  208. }
  209. /**
  210. * Regra: Competência anterior ao início do contrato não pode gerar mês zero ou negativo.
  211. *
  212. * Cenário: Contrato inicia 01/01/2026 e pede-se a competência 10/2025.
  213. *
  214. * Espera:
  215. * - mês de contrato = 1
  216. *
  217. * Nota: O cálculo é travado por max(1, diff + 1).
  218. */
  219. public function test_competencia_anterior_ao_inicio_do_contrato_e_tratada_como_mes_1(): void
  220. {
  221. $this->makeContract();
  222. $this->assertSame(1, $this->preview(2025, 10, 1000)['contract_month_reference']);
  223. }
  224. /**
  225. * Regra: nos meses 1, 2 e 3 não se cobra Royalties nem FNM.
  226. *
  227. * Cenário: Faturamento alto (R$ 40.000), que normalmente geraria R$ 3.200 de ROI e R$ 800 de FNM.
  228. *
  229. * Espera:
  230. * - Royalties = R$ 0,00 nos três meses
  231. * - FNM = R$ 0,00 nos três meses
  232. * - critério registrado = 'tbr_fixo'
  233. *
  234. * Nota: A isenção ignora o faturamento: mesmo faturando muito, os dois ficam zerados.
  235. */
  236. public function test_meses_1_a_3_isentam_royalties_e_fnm(): void
  237. {
  238. $this->makeContract();
  239. foreach ([1, 2, 3] as $month) {
  240. $result = $this->preview(2026, $month, 40000);
  241. $this->assertSame(0.0, $result['royalties_effective_value'], "mês {$month}");
  242. $this->assertSame(0.0, $result['fnm_effective_value'], "mês {$month}");
  243. $this->assertSame('tbr_fixo', $result['royalties_applied_criteria'], "mês {$month}");
  244. }
  245. }
  246. /**
  247. * Regra: A isenção dos meses 1-3 vale só para Royalties e FNM; a Manutenção continua.
  248. *
  249. * Cenário: Competência 02/2026 (mês 2), TBR de R$ 1.000,00.
  250. *
  251. * Espera:
  252. * - Manutenção = R$ 300,00 (30% da TBR)
  253. * - Total do título = R$ 300,00
  254. *
  255. * Nota: Ponto que costuma gerar dúvida: o mês isento ainda gera cobrança.
  256. */
  257. public function test_taxa_de_manutencao_e_cobrada_mesmo_nos_meses_isentos(): void
  258. {
  259. $this->makeContract();
  260. $result = $this->preview(2026, 2, 40000);
  261. $this->assertSame(300.0, $result['maintenance_effective_value']);
  262. $this->assertSame(300.0, $result['final_value']);
  263. }
  264. /**
  265. * Regra: A isenção termina exatamente no fim do mês 3.
  266. *
  267. * Cenário: Competência 04/2026 (mês 4), faturamento baixo.
  268. *
  269. * Espera:
  270. * - Royalties > 0
  271. * - FNM > 0
  272. */
  273. public function test_isencao_acaba_no_mes_4(): void
  274. {
  275. $this->makeContract();
  276. $result = $this->preview(2026, 4, 1000);
  277. $this->assertGreaterThan(0, $result['royalties_effective_value']);
  278. $this->assertGreaterThan(0, $result['fnm_effective_value']);
  279. }
  280. // ------------------------------------------------------- regra do maior valor
  281. /**
  282. * Regra: vence o fixo sobre a TBR quando o faturamento é pequeno.
  283. *
  284. * Cenário: Mês 4, TBR R$ 1.000,00, faturamento R$ 1.000,00.
  285. *
  286. * Espera:
  287. * - Royalties = R$ 750,00 (75% x 1.000) e não R$ 80,00 (8% x 1.000)
  288. * - FNM = R$ 200,00 (20% x 1.000) e não R$ 20,00 (2% x 1.000)
  289. * - Manutenção = R$ 300,00
  290. * - Total = R$ 1.250,00
  291. * - critério = 'tbr_fixo'
  292. */
  293. public function test_usa_valor_fixo_da_tbr_quando_faturamento_e_baixo(): void
  294. {
  295. $this->makeContract();
  296. $result = $this->preview(2026, 4, 1000.00);
  297. // Royalties: 75% x 1000 = 750,00 vs 8% x 1000 = 80,00
  298. $this->assertSame(750.0, $result['royalties_effective_value']);
  299. $this->assertSame(0.75, $result['royalties_effective_percentage']);
  300. $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
  301. // FNM: 20% x 1000 = 200,00 vs 2% x 1000 = 20,00
  302. $this->assertSame(200.0, $result['fnm_effective_value']);
  303. $this->assertSame(0.20, $result['fnm_effective_percentage']);
  304. $this->assertSame(300.0, $result['maintenance_effective_value']);
  305. $this->assertSame(1250.0, $result['final_value']);
  306. }
  307. /**
  308. * Regra: vence o percentual do faturamento quando ele supera o fixo.
  309. *
  310. * Cenário: Mês 4, TBR R$ 1.000,00, faturamento R$ 40.000,00.
  311. *
  312. * Espera:
  313. * - Royalties = R$ 3.200,00 (8% x 40.000) e não R$ 750,00
  314. * - FNM = R$ 800,00 (2% x 40.000) e não R$ 200,00
  315. * - Manutenção = R$ 300,00
  316. * - Total = R$ 4.300,00
  317. * - critério = 'percentual_faturamento'
  318. */
  319. public function test_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void
  320. {
  321. $this->makeContract();
  322. $result = $this->preview(2026, 4, 40000.00);
  323. // Royalties: 8% x 40.000 = 3.200,00 > 750,00
  324. $this->assertSame(3200.0, $result['royalties_effective_value']);
  325. $this->assertSame(0.08, $result['royalties_effective_percentage']);
  326. $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
  327. // FNM: 2% x 40.000 = 800,00 > 200,00
  328. $this->assertSame(800.0, $result['fnm_effective_value']);
  329. $this->assertSame(0.02, $result['fnm_effective_percentage']);
  330. $this->assertSame(300.0, $result['maintenance_effective_value']);
  331. $this->assertSame(4300.0, $result['final_value']);
  332. }
  333. /**
  334. * Regra: Critério de desempate — valores iguais mantem o fixo da TBR.
  335. *
  336. * Cenário: Faturamento R$ 9.375,00, em que 8% = R$ 750,00 = exatamente o valor fixo.
  337. *
  338. * Espera:
  339. * - Royalties = R$ 750,00
  340. * - critério = 'tbr_fixo'
  341. * - percentual gravado = 0,75 (o da faixa, não 0,08)
  342. *
  343. * Nota: A comparacao no código e >=, então o empate cai no fixo.
  344. */
  345. public function test_empate_entre_fixo_e_faturamento_mantem_o_fixo_da_tbr(): void
  346. {
  347. $this->makeContract();
  348. // 8% x 9.375 = 750,00 = valor fixo dos royalties
  349. $result = $this->preview(2026, 4, 9375.00);
  350. $this->assertSame(750.0, $result['royalties_effective_value']);
  351. $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
  352. $this->assertSame(0.75, $result['royalties_effective_percentage']);
  353. }
  354. /**
  355. * Regra: Royalties e FNM são comparados de forma independente.
  356. *
  357. * Cenário: Faturamento R$ 9.500,00: 8% = R$ 760 (passa do fixo 750), mas 2% = R$ 190 (abaixo do fixo 200).
  358. *
  359. * Espera:
  360. * - Royalties = R$ 760,00 por 'percentual_faturamento'
  361. * - FNM = R$ 200,00 pelo fixo (percentual 0,20)
  362. */
  363. public function test_royalties_e_fnm_podem_usar_criterios_diferentes_no_mesmo_mes(): void
  364. {
  365. $this->makeContract();
  366. // Royalties: 8% x 11.000 = 880 > 750 (faturamento)
  367. // FNM: 2% x 11.000 = 220 > 200 (faturamento) — escolhe faturamento nos dois
  368. // Já com 9.500: royalties 760 > 750 (faturamento) e FNM 190 < 200 (fixo)
  369. $result = $this->preview(2026, 4, 9500.00);
  370. $this->assertSame(760.0, $result['royalties_effective_value']);
  371. $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
  372. $this->assertSame(200.0, $result['fnm_effective_value']);
  373. $this->assertSame(0.20, $result['fnm_effective_percentage']);
  374. }
  375. /**
  376. * Regra: A Taxa de Manutenção é sempre um percentual fixo da TBR.
  377. *
  378. * Cenário: Mesmo contrato com faturamento de R$ 100,00 e depois de R$ 900.000,00.
  379. *
  380. * Espera:
  381. * - Manutenção = R$ 300,00 nos dois casos
  382. * - percentual = 0,30
  383. */
  384. public function test_manutencao_nunca_e_comparada_com_o_faturamento(): void
  385. {
  386. $this->makeContract();
  387. $baixo = $this->preview(2026, 4, 100.00);
  388. $alto = $this->preview(2026, 12, 900000.00);
  389. $this->assertSame(300.0, $baixo['maintenance_effective_value']);
  390. $this->assertSame(300.0, $alto['maintenance_effective_value']);
  391. $this->assertSame(0.30, $alto['maintenance_effective_percentage']);
  392. }
  393. // ------------------------------------------------------ faixas / porte da cidade
  394. /**
  395. * Regra: A faixa de royalties do porte muda conforme o mês de contrato avanca.
  396. *
  397. * Cenário: Porte GP: 75% nos meses 4-12 e 100% nos meses 13-60.
  398. *
  399. * Espera:
  400. * - mês 12 -> 75% da TBR = R$ 750,00
  401. * - mês 13 -> 100% da TBR = R$ 1.000,00
  402. */
  403. public function test_faixa_de_royalties_muda_no_mes_13(): void
  404. {
  405. $this->makeContract();
  406. $this->makeTbrYear(2027, 1000.00);
  407. $mes12 = $this->preview(2026, 12, 1000);
  408. $mes13 = $this->preview(2027, 1, 1000);
  409. $this->assertSame(0.75, $mes12['royalties_bracket_percentage']);
  410. $this->assertSame(750.0, $mes12['royalties_bracket_value']);
  411. $this->assertSame(1.0, $mes13['royalties_bracket_percentage']);
  412. $this->assertSame(1000.0, $mes13['royalties_bracket_value']);
  413. }
  414. /**
  415. * Regra: O porte do município do contrato define o percentual de royalties.
  416. *
  417. * Cenário: Contrato em porte MGP (100% no mês 4) no lugar de GP (75%).
  418. *
  419. * Espera:
  420. * - percentual da faixa = 1,00
  421. * - Royalties = R$ 1.000,00 em vez de R$ 750,00
  422. */
  423. public function test_porte_maior_do_municipio_cobra_percentual_maior(): void
  424. {
  425. $sizeMgp = $this->makeSize('MGP', 'Acima de 200 mil habitantes');
  426. $this->makeBrackets($sizeMgp->id, [[1, 3, 0.0], [4, 12, 1.00], [13, 60, 1.50]]);
  427. $this->makeContract(['municipality_size_id' => $sizeMgp->id]);
  428. $result = $this->preview(2026, 4, 1000);
  429. $this->assertSame(1.0, $result['royalties_bracket_percentage']);
  430. $this->assertSame(1000.0, $result['royalties_effective_value']);
  431. }
  432. /**
  433. * Regra: A busca de faixa é fixada em is_renewal = false: faixas de renovacao nunca entram.
  434. *
  435. * Cenário: Unica faixa que cobre o mês 61 e de renovacao.
  436. *
  437. * Espera:
  438. * - ValidationException 'Nao foi encontrada faixa de royalties'
  439. *
  440. * Nota: Comportamento atual documentado como divergência (item 5 do doc). Se as faixas de renovacao
  441. * passarem a valer, este teste deve ser reescrito.
  442. */
  443. public function test_faixa_de_renovacao_nao_e_considerada(): void
  444. {
  445. // Só existe faixa de renovação cobrindo o mês 61 — o cálculo ignora e estoura.
  446. $this->makeBrackets($this->sizeGp->id, [[61, 120, 1.00]], true);
  447. $this->makeContract();
  448. $this->makeTbrYear(2031, 1000.00);
  449. $this->expectException(ValidationException::class);
  450. $this->expectExceptionMessage('Não foi encontrada faixa de royalties');
  451. $this->preview(2031, 1, 1000); // mês de contrato 61
  452. }
  453. /**
  454. * Regra: Contrato que passa do último mês com faixa cadastrada não calcula.
  455. *
  456. * Cenário: Faixas cadastradas até o mês 60; competência cai no mês 61.
  457. *
  458. * Espera:
  459. * - ValidationException 'Nao foi encontrada faixa de royalties'
  460. */
  461. public function test_erro_quando_nao_existe_faixa_para_o_mes_de_contrato(): void
  462. {
  463. $this->makeContract();
  464. $this->makeTbrYear(2031, 1000.00);
  465. $this->expectException(ValidationException::class);
  466. $this->expectExceptionMessage('Não foi encontrada faixa de royalties');
  467. $this->preview(2031, 1, 1000); // mês 61, fora das faixas 1-60
  468. }
  469. // --------------------------------------------------------------- valor da TBR
  470. /**
  471. * Regra: A TBR do ano cadastrada pelo admin (tabela tbrs) manda sobre o valor do contrato.
  472. *
  473. * Cenário: tbrs/2026 = R$ 1.000,00 (setUp) e contrato com tbr_fixed_value = R$ 2.000,00.
  474. *
  475. * Espera:
  476. * - TBR usada = R$ 1.000,00
  477. * - Royalties = R$ 750,00 (75% x 1.000)
  478. * - Manutenção = R$ 300,00
  479. */
  480. public function test_tbr_do_ano_configurada_pelo_admin_tem_prioridade_sobre_o_contrato(): void
  481. {
  482. // tbrs/2026 = 1.000,00 (setUp) e o contrato traz 2.000,00: vale a tabela do ano.
  483. $this->makeContract(['tbr_fixed_value' => 2000.00]);
  484. $result = $this->preview(2026, 4, 1000);
  485. $this->assertSame(1000.0, $result['tbr_value']);
  486. $this->assertSame(750.0, $result['royalties_effective_value']); // 75% x 1000
  487. $this->assertSame(300.0, $result['maintenance_effective_value']); // 30% x 1000
  488. }
  489. /**
  490. * Regra: A busca da TBR é feita pelo ano da competência, não pelo ano corrente.
  491. *
  492. * Cenário: tbrs/2026 = R$ 1.000,00 e tbrs/2027 = R$ 1.800,00.
  493. *
  494. * Espera:
  495. * - competência de 2026 usa R$ 1.000,00
  496. * - competência de 2027 usa R$ 1.800,00
  497. */
  498. public function test_cada_ano_usa_a_sua_propria_configuracao_de_tbr(): void
  499. {
  500. $this->makeTbrYear(2027, 1800.00);
  501. $this->makeContract(['tbr_fixed_value' => 2000.00]);
  502. $this->assertSame(1000.0, $this->preview(2026, 4, 1000)['tbr_value']);
  503. $this->assertSame(1800.0, $this->preview(2027, 1, 1000)['tbr_value']);
  504. }
  505. /**
  506. * Regra: O tbr_fixed_value do contrato é a reserva para anos sem cadastro em tbrs.
  507. *
  508. * Cenário: 2027 sem linha em tbrs; contrato com R$ 2.000,00; competência 01/2027 (mês 13, faixa 100%).
  509. *
  510. * Espera:
  511. * - TBR usada = R$ 2.000,00
  512. * - Royalties = R$ 2.000,00
  513. * - Manutenção = R$ 600,00
  514. */
  515. public function test_usa_a_tbr_do_contrato_quando_o_ano_nao_esta_configurado(): void
  516. {
  517. // 2027 não tem linha em `tbrs`; cai no valor gravado no contrato.
  518. $this->makeContract(['tbr_fixed_value' => 2000.00]);
  519. $result = $this->preview(2027, 1, 1000); // mês 13
  520. $this->assertSame(2000.0, $result['tbr_value']);
  521. $this->assertSame(2000.0, $result['royalties_effective_value']); // 100% x 2000
  522. $this->assertSame(600.0, $result['maintenance_effective_value']); // 30% x 2000
  523. }
  524. /**
  525. * Regra: Linha do ano com tbr_value = 0 conta como 'não configurada'.
  526. *
  527. * Cenário: tbrs/2027 = R$ 0,00 e contrato com R$ 2.000,00.
  528. *
  529. * Espera:
  530. * - TBR usada = R$ 2.000,00
  531. */
  532. public function test_tbr_do_ano_zerada_cai_no_valor_do_contrato(): void
  533. {
  534. $this->makeTbrYear(2027, 0.00);
  535. $this->makeContract(['tbr_fixed_value' => 2000.00]);
  536. $this->assertSame(2000.0, $this->preview(2027, 1, 1000)['tbr_value']);
  537. }
  538. /**
  539. * Regra: Sem TBR em lugar nenhum o cálculo não pode seguir.
  540. *
  541. * Cenário: Competência 2029 (sem linha em tbrs) e contrato sem tbr_fixed_value.
  542. *
  543. * Espera:
  544. * - ValidationException 'TBR não definida'
  545. */
  546. public function test_erro_quando_nao_ha_tbr_no_ano_nem_no_contrato(): void
  547. {
  548. $this->makeContract(['tbr_fixed_value' => null]);
  549. $this->expectException(ValidationException::class);
  550. $this->expectExceptionMessage('TBR não definida');
  551. $this->preview(2029, 4, 1000); // sem linha em tbrs para 2029
  552. }
  553. /**
  554. * Regra: Os percentuais de FNM e Manutenção saem da linha do ano em tbrs.
  555. *
  556. * Cenário: tbrs/2027 com FNM 10% e Manutenção 50%, TBR R$ 1.000,00.
  557. *
  558. * Espera:
  559. * - FNM fixo = 10% = R$ 100,00
  560. * - Manutenção = 50% = R$ 500,00
  561. */
  562. public function test_percentuais_de_fnm_e_manutencao_vem_da_tabela_do_ano(): void
  563. {
  564. $this->makeTbrYear(2027, 1000.00, fnm: 0.10, maintenance: 0.50);
  565. $this->makeContract();
  566. $result = $this->preview(2027, 1, 1000); // mês 13
  567. $this->assertSame(0.10, $result['fnm_bracket_percentage']);
  568. $this->assertSame(100.0, $result['fnm_bracket_value']);
  569. $this->assertSame(0.50, $result['maintenance_effective_percentage']);
  570. $this->assertSame(500.0, $result['maintenance_effective_value']);
  571. }
  572. /**
  573. * Regra: Sem linha do ano, os percentuais caem nas constantes do serviço.
  574. *
  575. * Cenário: 2027 sem cadastro em tbrs; a TBR vem do contrato.
  576. *
  577. * Espera:
  578. * - FNM fixo = 20% (FNM_BRACKET_PERCENTAGE)
  579. * - Manutenção = 30% (MAINTENANCE_RATE)
  580. */
  581. public function test_sem_tabela_do_ano_usa_os_padroes_do_codigo(): void
  582. {
  583. // 2027 não tem linha em `tbrs`; a TBR vem do contrato e os percentuais dos defaults.
  584. $this->makeContract();
  585. $result = $this->preview(2027, 1, 1000); // mês 13
  586. $this->assertSame(0.20, $result['fnm_bracket_percentage']);
  587. $this->assertSame(0.30, $result['maintenance_effective_percentage']);
  588. }
  589. // ------------------------------------------------------- flags de cobrança
  590. /**
  591. * Regra: Flag charge_roi = false na unidade tira os Royalties da conta.
  592. *
  593. * Cenário: unit_financials com charge_roi = false, mês 4, faturamento R$ 40.000,00.
  594. *
  595. * Espera:
  596. * - Royalties = R$ 0,00 e percentual = 0
  597. * - critério = 'nao_cobrado'
  598. * - royalties_bracket_id = null (a faixa nem é buscada)
  599. * - Total = R$ 1.100,00 (FNM 800 + Manutenção 300)
  600. */
  601. public function test_charge_roi_desligado_zera_os_royalties(): void
  602. {
  603. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]);
  604. $this->makeContract();
  605. $result = $this->preview(2026, 4, 40000);
  606. $this->assertSame(0.0, $result['royalties_effective_value']);
  607. $this->assertSame(0.0, $result['royalties_effective_percentage']);
  608. $this->assertSame('nao_cobrado', $result['royalties_applied_criteria']);
  609. $this->assertNull($result['royalties_bracket_id']);
  610. $this->assertSame(1100.0, $result['final_value']); // FNM 800 + manutenção 300
  611. }
  612. /**
  613. * Regra: Com os Royalties desligados, a falta de faixa não derruba o cálculo.
  614. *
  615. * Cenário: charge_roi = false e competência no mês 61 (sem faixa cadastrada).
  616. *
  617. * Espera:
  618. * - calcula sem exceção
  619. * - Royalties = R$ 0,00
  620. * - Total = R$ 500,00 (FNM 200 + Manutenção 300)
  621. */
  622. public function test_charge_roi_desligado_dispensa_a_existencia_de_faixa(): void
  623. {
  624. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]);
  625. $this->makeContract();
  626. $this->makeTbrYear(2031, 1000.00);
  627. $result = $this->preview(2031, 1, 1000); // mês 61, sem faixa cadastrada
  628. $this->assertSame(0.0, $result['royalties_effective_value']);
  629. $this->assertSame(500.0, $result['final_value']); // FNM 200 + manutenção 300
  630. }
  631. /**
  632. * Regra: Flag charge_fnm = false na unidade tira o FNM da conta.
  633. *
  634. * Cenário: unit_financials com charge_fnm = false, mês 4, faturamento baixo.
  635. *
  636. * Espera:
  637. * - FNM = R$ 0,00 e percentual = 0
  638. * - Total = R$ 1.050,00 (Royalties 750 + Manutenção 300)
  639. */
  640. public function test_charge_fnm_desligado_zera_o_fnm(): void
  641. {
  642. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => true, 'charge_fnm' => false]);
  643. $this->makeContract();
  644. $result = $this->preview(2026, 4, 1000);
  645. $this->assertSame(0.0, $result['fnm_effective_value']);
  646. $this->assertSame(0.0, $result['fnm_effective_percentage']);
  647. $this->assertSame(1050.0, $result['final_value']); // royalties 750 + manutenção 300
  648. }
  649. /**
  650. * Regra: Unidade sem linha em unit_financials é tratada como 'cobra tudo'.
  651. *
  652. * Cenário: Nenhum registro financeiro criado para a unidade.
  653. *
  654. * Espera:
  655. * - Royalties = R$ 750,00
  656. * - FNM = R$ 200,00
  657. *
  658. * Nota: Padrão seguro: a ausencia de configuração não deve isentar ninguem.
  659. */
  660. public function test_unidade_sem_registro_financeiro_cobra_royalties_e_fnm(): void
  661. {
  662. $this->makeContract();
  663. $this->assertSame(0, UnitFinancial::where('unit_id', $this->unit->id)->count());
  664. $result = $this->preview(2026, 4, 1000);
  665. $this->assertSame(750.0, $result['royalties_effective_value']);
  666. $this->assertSame(200.0, $result['fnm_effective_value']);
  667. }
  668. /**
  669. * Regra: A Taxa de Manutenção não tem flag de liga/desliga.
  670. *
  671. * Cenário: charge_roi e charge_fnm ambos false, faturamento alto.
  672. *
  673. * Espera:
  674. * - Total = R$ 300,00, só a Manutenção
  675. */
  676. public function test_manutencao_e_cobrada_mesmo_com_as_duas_flags_desligadas(): void
  677. {
  678. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => false]);
  679. $this->makeContract();
  680. $result = $this->preview(2026, 4, 40000);
  681. $this->assertSame(300.0, $result['final_value']);
  682. }
  683. // ---------------------------------------------------------------- faturamento
  684. /**
  685. * Regra: Composição da base de faturamento do mês — três fontes somadas.
  686. *
  687. * Cenário: Parcelas de aluno R$ 100 + R$ 250, recebível da unidade R$ 50 e conta 'manual_unit' da matriz R$ 25.
  688. *
  689. * Espera:
  690. * - faturamento de 04/2026 = R$ 425,00
  691. */
  692. public function test_faturamento_soma_parcelas_recebiveis_e_contas_da_matriz(): void
  693. {
  694. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-01']);
  695. $this->makeInstallment(['value' => 250.00, 'due_date' => '2026-04-30']);
  696. UnitAccountReceivable::create([
  697. 'unit_id' => $this->unit->id,
  698. 'origin' => 'manual',
  699. 'history' => 'Venda de material',
  700. 'value' => 50.00,
  701. 'due_date' => '2026-04-15',
  702. 'status' => 'pending',
  703. ]);
  704. FranchiseeAccountReceive::create([
  705. 'unit_id' => $this->unit->id,
  706. 'origin' => 'manual_unit',
  707. 'history' => 'Cobrança avulsa da matriz',
  708. 'value' => 25.00,
  709. 'due_date' => '2026-04-20',
  710. 'status' => 'pending',
  711. ]);
  712. $this->assertSame(425.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
  713. }
  714. /**
  715. * Regra: Filtro do faturamento — só entra o que vence no mês e não está cancelado.
  716. *
  717. * Cenário: R$ 100 válida, R$ 999 cancelada, R$ 777 em 31/03 e R$ 555 em 01/05.
  718. *
  719. * Espera:
  720. * - faturamento de 04/2026 = R$ 100,00
  721. *
  722. * Nota: Confirma também que o intervalo do mês e fechado nas duas pontas.
  723. */
  724. public function test_faturamento_ignora_parcelas_canceladas_e_de_outros_meses(): void
  725. {
  726. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
  727. $this->makeInstallment(['value' => 999.00, 'due_date' => '2026-04-10', 'status' => 'cancelled']);
  728. $this->makeInstallment(['value' => 777.00, 'due_date' => '2026-03-31']);
  729. $this->makeInstallment(['value' => 555.00, 'due_date' => '2026-05-01']);
  730. $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
  731. }
  732. /**
  733. * Regra: Parcela removida (soft delete) sai da base de faturamento.
  734. *
  735. * Cenário: R$ 100 ativa e R$ 400 excluida, ambas vencendo no mês.
  736. *
  737. * Espera:
  738. * - faturamento = R$ 100,00
  739. */
  740. public function test_faturamento_ignora_parcelas_excluidas(): void
  741. {
  742. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
  743. $this->makeInstallment(['value' => 400.00, 'due_date' => '2026-04-10'])->delete();
  744. $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
  745. }
  746. /**
  747. * Regra: O próprio título de TBR não pode entrar na base que gera o TBR.
  748. *
  749. * Cenário: Parcela de R$ 100 e um franchisee_account_receive de R$ 5.000 com origin = 'tbr'.
  750. *
  751. * Espera:
  752. * - faturamento = R$ 100,00
  753. *
  754. * Nota: Evita realimentação: cobrar TBR aumentaria a base do mês seguinte.
  755. */
  756. public function test_faturamento_ignora_recebiveis_de_tbr(): void
  757. {
  758. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
  759. FranchiseeAccountReceive::create([
  760. 'unit_id' => $this->unit->id,
  761. 'origin' => 'tbr',
  762. 'history' => 'Royalties / FNM / Manutenção — 04/2026',
  763. 'value' => 5000.00,
  764. 'due_date' => '2026-04-10',
  765. 'status' => 'pending',
  766. ]);
  767. $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
  768. }
  769. /**
  770. * Regra: O faturamento é apurado por unidade.
  771. *
  772. * Cenário: R$ 100 na unidade do teste e R$ 900 em outra unidade, mesma data.
  773. *
  774. * Espera:
  775. * - faturamento da unidade = R$ 100,00
  776. */
  777. public function test_faturamento_ignora_movimento_de_outra_unidade(): void
  778. {
  779. $outra = $this->makeUnit('Outra Unidade');
  780. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
  781. $this->makeInstallment(['value' => 900.00, 'due_date' => '2026-04-10', 'unit_id' => $outra->id]);
  782. $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
  783. }
  784. /**
  785. * Regra: resolveRevenueBetween com lista de unidades vazia representa a rede inteira.
  786. *
  787. * Cenário: R$ 100 numa unidade e R$ 900 em outra, ambas em 04/2026.
  788. *
  789. * Espera:
  790. * - total da rede = R$ 1.000,00
  791. *
  792. * Nota: Mesma composicao usada pelos dashboards.
  793. */
  794. public function test_faturamento_da_rede_soma_todas_as_unidades(): void
  795. {
  796. $outra = $this->makeUnit('Outra Unidade');
  797. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
  798. $this->makeInstallment(['value' => 900.00, 'due_date' => '2026-04-10', 'unit_id' => $outra->id]);
  799. $total = $this->service->resolveRevenueBetween(
  800. [],
  801. \Carbon\Carbon::parse('2026-04-01'),
  802. \Carbon\Carbon::parse('2026-04-30'),
  803. );
  804. $this->assertSame(1000.0, $total);
  805. }
  806. /**
  807. * Regra: revenue_value maior que zero sobrepoe a apuração automática.
  808. *
  809. * Cenário: Existe R$ 100 de parcela no mês; um preview manda revenue_value = R$ 40.000.
  810. *
  811. * Espera:
  812. * - com override: revenue_value = R$ 40.000,00
  813. * - sem override: revenue_value = R$ 100,00
  814. *
  815. * Nota: Zero (ou ausente) significa 'calcula automático', não 'faturamento zero'.
  816. */
  817. public function test_faturamento_informado_manualmente_substitui_o_calculo_automatico(): void
  818. {
  819. $this->makeContract();
  820. $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
  821. $comOverride = $this->preview(2026, 4, 40000.00);
  822. $automatico = $this->preview(2026, 4, 0.0);
  823. $this->assertSame(40000.0, $comOverride['revenue_value']);
  824. $this->assertSame(100.0, $automatico['revenue_value']);
  825. }
  826. // -------------------------------------------------------- contrato obrigatório
  827. /**
  828. * Regra: Sem contrato não ha regra de cobrança.
  829. *
  830. * Cenário: Unidade criada sem nenhum franchisee_contract.
  831. *
  832. * Espera:
  833. * - ValidationException 'Unidade não possui contrato cadastrado.'
  834. */
  835. public function test_erro_quando_a_unidade_nao_tem_contrato(): void
  836. {
  837. $this->expectException(ValidationException::class);
  838. $this->expectExceptionMessage('Unidade não possui contrato cadastrado.');
  839. $this->preview(2026, 4, 1000);
  840. }
  841. /**
  842. * Regra: O porte do município é obrigatório para achar a faixa de royalties.
  843. *
  844. * Cenário: Contrato com municipality_size_id nulo.
  845. *
  846. * Espera:
  847. * - ValidationException citando a faixa de habitantes
  848. */
  849. public function test_erro_quando_o_contrato_nao_tem_porte_do_municipio(): void
  850. {
  851. $this->makeContract(['municipality_size_id' => null]);
  852. $this->expectException(ValidationException::class);
  853. $this->expectExceptionMessage('faixa de habitantes');
  854. $this->preview(2026, 4, 1000);
  855. }
  856. /**
  857. * Regra: Com vários contratos na unidade, vale o de start_date mais recente.
  858. *
  859. * Cenário: Contrato de 01/01/2026 e outro de 01/03/2026; competência 06/2026.
  860. *
  861. * Espera:
  862. * - contract_id = o contrato de março
  863. * - mês de contrato = 4, contado a partir de 03/2026
  864. */
  865. public function test_usa_sempre_o_contrato_mais_recente_da_unidade(): void
  866. {
  867. $antigo = $this->makeContract(['start_date' => '2026-01-01']);
  868. $novo = $this->makeContract(['protocol' => 2, 'start_date' => '2026-03-01']);
  869. $result = $this->preview(2026, 6, 1000);
  870. $this->assertSame($novo->id, $result['contract_id']);
  871. $this->assertNotSame($antigo->id, $result['contract_id']);
  872. $this->assertSame(4, $result['contract_month_reference']); // conta a partir de 03/2026
  873. }
  874. // ------------------------------------------------------------------ persistência
  875. /**
  876. * Regra: calculate() persiste em tbr_calculations exatamente o que o preview mostrou.
  877. *
  878. * Cenário: Mês 4, faturamento R$ 40.000,00.
  879. *
  880. * Espera:
  881. * - linha gravada com a competência e o mês de contrato 4
  882. * - Royalties R$ 3.200,00 / FNM R$ 800,00 / Manutenção R$ 300,00 / total R$ 4.300,00
  883. * - critério 'percentual_faturamento'
  884. * - receivable_generated = false
  885. */
  886. public function test_calculate_grava_o_calculo_com_os_valores_do_preview(): void
  887. {
  888. $this->makeContract();
  889. $calculation = $this->service->calculate([
  890. 'unit_id' => $this->unit->id,
  891. 'reference_year' => 2026,
  892. 'reference_month' => 4,
  893. 'revenue_value' => 40000.00,
  894. ]);
  895. $this->assertDatabaseHas('tbr_calculations', [
  896. 'id' => $calculation->id,
  897. 'unit_id' => $this->unit->id,
  898. 'reference_year' => 2026,
  899. 'reference_month' => 4,
  900. 'contract_month_reference' => 4,
  901. 'receivable_generated' => false,
  902. ]);
  903. $this->assertSame('3200.00', (string) $calculation->royalties_effective_value);
  904. $this->assertSame('800.00', (string) $calculation->fnm_effective_value);
  905. $this->assertSame('300.00', (string) $calculation->maintenance_effective_value);
  906. $this->assertSame('4300.00', (string) $calculation->final_value);
  907. $this->assertSame('percentual_faturamento', $calculation->royalties_applied_criteria);
  908. }
  909. /**
  910. * Regra: generateReceivable() gera o título da matriz e o espelho na unidade.
  911. *
  912. * Cenário: Cálculo do mês 4 com faturamento baixo (total R$ 1.250,00).
  913. *
  914. * Espera:
  915. * - franchisee_account_receives de R$ 1.250,00, status pending, order = mês 4
  916. * - histórico com a competência 04/2026
  917. * - 3 detalhes (Royalties, FNM, Manutenção) somando o total
  918. * - cálculo marcado como receivable_generated
  919. * - unit_account_payables espelho com origin 'tbr'
  920. * - SyncFranchiseeChargeJob despachado
  921. */
  922. public function test_gerar_titulo_cria_recebivel_detalhes_e_conta_a_pagar(): void
  923. {
  924. $this->makeContract();
  925. $calculation = $this->service->calculate([
  926. 'unit_id' => $this->unit->id,
  927. 'reference_year' => 2026,
  928. 'reference_month' => 4,
  929. 'revenue_value' => 1000.00,
  930. ]);
  931. $receive = $this->service->generateReceivable($calculation->id);
  932. $this->assertSame('1250.00', (string) $receive->value);
  933. $this->assertSame('pending', $receive->status->value);
  934. $this->assertSame(4, $receive->order);
  935. $this->assertStringContainsString('04/2026', $receive->history);
  936. $this->assertCount(3, $receive->details);
  937. $this->assertEqualsWithDelta(
  938. 1250.00,
  939. $receive->details->sum(fn ($detail) => (float) $detail->value),
  940. 0.01,
  941. );
  942. $this->assertTrue($calculation->fresh()->receivable_generated);
  943. $this->assertDatabaseHas('unit_account_payables', [
  944. 'unit_id' => $this->unit->id,
  945. 'franchisee_account_receive_id' => $receive->id,
  946. 'origin' => UnitAccountPayable::ORIGIN_TBR,
  947. 'value' => '1250.00',
  948. 'status' => 'pending',
  949. ]);
  950. Bus::assertDispatched(\App\Jobs\SyncFranchiseeChargeJob::class);
  951. }
  952. /**
  953. * Regra: Um cálculo só pode virar título uma vez.
  954. *
  955. * Cenário: generateReceivable() chamado duas vezes com o mesmo id.
  956. *
  957. * Espera:
  958. * - ValidationException 'Já existe um título gerado para este cálculo.'
  959. */
  960. public function test_gerar_titulo_duas_vezes_para_o_mesmo_calculo_e_bloqueado(): void
  961. {
  962. $this->makeContract();
  963. $calculation = $this->service->calculate([
  964. 'unit_id' => $this->unit->id,
  965. 'reference_year' => 2026,
  966. 'reference_month' => 4,
  967. 'revenue_value' => 1000.00,
  968. ]);
  969. $this->service->generateReceivable($calculation->id);
  970. $this->expectException(ValidationException::class);
  971. $this->expectExceptionMessage('Já existe um título gerado para este cálculo.');
  972. $this->service->generateReceivable($calculation->id);
  973. }
  974. /**
  975. * Regra: Uma unidade só pode ter um título por mês de contrato.
  976. *
  977. * Cenário: Dois calculos distintos para a mesma competência; o primeiro já virou título.
  978. *
  979. * Espera:
  980. * - ValidationException citando o mês de contrato 4
  981. *
  982. * Nota: A checagem hoje olha só o mês de contrato, sem o ano/mês de competência (item 4 do doc).
  983. */
  984. public function test_gerar_titulo_para_o_mesmo_mes_de_contrato_e_bloqueado(): void
  985. {
  986. $this->makeContract();
  987. $primeiro = $this->service->calculate([
  988. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  989. ]);
  990. $this->service->generateReceivable($primeiro->id);
  991. $segundo = $this->service->calculate([
  992. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  993. ]);
  994. $this->expectException(ValidationException::class);
  995. $this->expectExceptionMessage('Já existe um título gerado para esta unidade no mês de contrato 4.');
  996. $this->service->generateReceivable($segundo->id);
  997. }
  998. /**
  999. * Regra: O preview sinaliza a duplicidade antes de tentar gerar.
  1000. *
  1001. * Cenário: Preview antes e depois de gerar o título do mês 4.
  1002. *
  1003. * Espera:
  1004. * - antes: receivable_already_generated = false
  1005. * - depois: true
  1006. */
  1007. public function test_preview_avisa_quando_ja_existe_titulo_no_mes_de_contrato(): void
  1008. {
  1009. $this->makeContract();
  1010. $this->assertFalse($this->preview(2026, 4, 1000)['receivable_already_generated']);
  1011. $calculation = $this->service->calculate([
  1012. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  1013. ]);
  1014. $this->service->generateReceivable($calculation->id);
  1015. $this->assertTrue($this->preview(2026, 4, 1000)['receivable_already_generated']);
  1016. }
  1017. // ---------------------------------------------------------------- vencimento
  1018. /**
  1019. * Regra: Vencimento = mês da competência + 1, no dia invoice_due_date do contrato.
  1020. *
  1021. * Cenário: Contrato com invoice_due_date = 15, competência 04/2026.
  1022. *
  1023. * Espera:
  1024. * - vencimento = 15/05/2026
  1025. */
  1026. public function test_vencimento_usa_o_dia_do_contrato_no_mes_seguinte(): void
  1027. {
  1028. $this->makeContract(['invoice_due_date' => 15]);
  1029. $calculation = $this->service->calculate([
  1030. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  1031. ]);
  1032. $receive = $this->service->generateReceivable($calculation->id);
  1033. $this->assertSame('2026-05-15', $receive->due_date->toDateString());
  1034. }
  1035. /**
  1036. * Regra: Sem invoice_due_date, o vencimento usa o dia 10.
  1037. *
  1038. * Cenário: Contrato com invoice_due_date nulo, competência 04/2026.
  1039. *
  1040. * Espera:
  1041. * - vencimento = 10/05/2026
  1042. */
  1043. public function test_vencimento_cai_no_dia_10_quando_o_contrato_nao_informa(): void
  1044. {
  1045. $this->makeContract(['invoice_due_date' => null]);
  1046. $calculation = $this->service->calculate([
  1047. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  1048. ]);
  1049. $receive = $this->service->generateReceivable($calculation->id);
  1050. $this->assertSame('2026-05-10', $receive->due_date->toDateString());
  1051. }
  1052. /**
  1053. * Regra: O dia de vencimento é travado entre 1 e 28 (evita mês sem dia 29-31).
  1054. *
  1055. * Cenário: Contrato com invoice_due_date = 31, competência 04/2026.
  1056. *
  1057. * Espera:
  1058. * - vencimento = 28/05/2026
  1059. */
  1060. public function test_vencimento_e_limitado_ao_dia_28(): void
  1061. {
  1062. $this->makeContract(['invoice_due_date' => 31]);
  1063. $calculation = $this->service->calculate([
  1064. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  1065. ]);
  1066. $receive = $this->service->generateReceivable($calculation->id);
  1067. $this->assertSame('2026-05-28', $receive->due_date->toDateString());
  1068. }
  1069. // ------------------------------------------------------------------- em lote
  1070. /**
  1071. * Regra: generateBatch() percorre os contratos ativos na competência e gera os títulos.
  1072. *
  1073. * Cenário: Uma unidade com contrato ativo e R$ 1.000 de faturamento apurado no mês.
  1074. *
  1075. * Espera:
  1076. * - generated_count = 1 e nenhum erro
  1077. * - total gerado = R$ 1.250,00
  1078. * - 1 recebível criado
  1079. */
  1080. public function test_lote_gera_titulo_para_cada_contrato_ativo(): void
  1081. {
  1082. $this->makeContract();
  1083. $this->makeInstallment(['value' => 1000.00, 'due_date' => '2026-04-10']);
  1084. $resultado = $this->service->generateBatch(2026, 4);
  1085. $this->assertSame(1, $resultado['generated_count']);
  1086. $this->assertSame(0, $resultado['error_count']);
  1087. $this->assertSame(1250.0, $resultado['generated'][0]['total']);
  1088. $this->assertSame(1, FranchiseeAccountReceive::count());
  1089. }
  1090. /**
  1091. * Regra: O lote não duplica título: unidade já cobrada entra em 'skipped'.
  1092. *
  1093. * Cenário: Titulo do mês 4 gerado antes de rodar o lote da mesma competência.
  1094. *
  1095. * Espera:
  1096. * - generated_count = 0 e skipped_count = 1
  1097. * - continua com apenas 1 recebível
  1098. */
  1099. public function test_lote_pula_unidade_que_ja_tem_titulo_no_mes_de_contrato(): void
  1100. {
  1101. $this->makeContract();
  1102. $calculation = $this->service->calculate([
  1103. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
  1104. ]);
  1105. $this->service->generateReceivable($calculation->id);
  1106. $resultado = $this->service->generateBatch(2026, 4);
  1107. $this->assertSame(0, $resultado['generated_count']);
  1108. $this->assertSame(1, $resultado['skipped_count']);
  1109. $this->assertSame(1, FranchiseeAccountReceive::count());
  1110. }
  1111. /**
  1112. * Regra: Contrato com end_date anterior ao mês não e cobrado.
  1113. *
  1114. * Cenário: Contrato encerrado em 28/02/2026; lote de 04/2026.
  1115. *
  1116. * Espera:
  1117. * - nada gerado e nenhum cálculo gravado
  1118. */
  1119. public function test_lote_ignora_contrato_encerrado_antes_da_competencia(): void
  1120. {
  1121. $this->makeContract(['end_date' => '2026-02-28']);
  1122. $resultado = $this->service->generateBatch(2026, 4);
  1123. $this->assertSame(0, $resultado['generated_count']);
  1124. $this->assertSame(0, TbrCalculation::count());
  1125. }
  1126. /**
  1127. * Regra: Contrato com start_date posterior a competência não e cobrado.
  1128. *
  1129. * Cenário: Contrato comeca em 01/06/2026; lote de 04/2026.
  1130. *
  1131. * Espera:
  1132. * - nada gerado
  1133. */
  1134. public function test_lote_ignora_contrato_que_ainda_nao_comecou(): void
  1135. {
  1136. $this->makeContract(['start_date' => '2026-06-01']);
  1137. $resultado = $this->service->generateBatch(2026, 4);
  1138. $this->assertSame(0, $resultado['generated_count']);
  1139. }
  1140. /**
  1141. * Regra: Contrato sem porte fica fora da selecao do lote (nem gera erro).
  1142. *
  1143. * Cenário: Contrato com municipality_size_id nulo; lote de 04/2026.
  1144. *
  1145. * Espera:
  1146. * - generated_count = 0 e error_count = 0
  1147. *
  1148. * Nota: Diferente do cálculo individual, que devolve ValidationException nesse caso.
  1149. */
  1150. public function test_lote_ignora_contrato_sem_porte_definido(): void
  1151. {
  1152. $this->makeContract(['municipality_size_id' => null]);
  1153. $resultado = $this->service->generateBatch(2026, 4);
  1154. $this->assertSame(0, $resultado['generated_count']);
  1155. $this->assertSame(0, $resultado['error_count']);
  1156. }
  1157. /**
  1158. * Regra: generateBatch() aceita uma lista de unidades e ignora as demais.
  1159. *
  1160. * Cenário: duas unidades com contrato ativo; o lote roda filtrando só a segunda.
  1161. *
  1162. * Espera:
  1163. * - generated_count = 1
  1164. * - o título gerado pertence a unidade filtrada
  1165. */
  1166. public function test_lote_pode_ser_filtrado_por_unidade(): void
  1167. {
  1168. $this->makeContract();
  1169. $outra = $this->makeUnit('Outra Unidade');
  1170. FranchiseeContract::create([
  1171. 'unit_id' => $outra->id,
  1172. 'protocol' => 1,
  1173. 'name' => 'Contrato Outra',
  1174. 'description' => 'Teste',
  1175. 'start_date' => '2026-01-01',
  1176. 'end_date' => '2031-01-01',
  1177. 'signature_date' => '2026-01-01',
  1178. 'validity_months' => 60,
  1179. 'invoice_due_date' => 10,
  1180. 'municipality_size_id' => $this->sizeGp->id,
  1181. 'tbr_fixed_value' => 1000.00,
  1182. ]);
  1183. $resultado = $this->service->generateBatch(2026, 4, [$outra->id]);
  1184. $this->assertSame(1, $resultado['generated_count']);
  1185. $this->assertSame($outra->id, $resultado['generated'][0]['unit_id']);
  1186. }
  1187. /**
  1188. * Regra: Falha numa unidade não derruba o lote inteiro.
  1189. *
  1190. * Cenário: Uma unidade cai no mês 61 sem faixa; outra tem faixa válida até o mês 120.
  1191. *
  1192. * Espera:
  1193. * - generated_count = 1 e error_count = 1
  1194. * - o erro aponta a unidade problemática
  1195. */
  1196. public function test_lote_reporta_erro_sem_interromper_as_demais_unidades(): void
  1197. {
  1198. // Unidade base: mês 61 sem faixa cadastrada → erro
  1199. $this->makeContract();
  1200. $this->makeTbrYear(2031, 1000.00);
  1201. $outra = $this->makeUnit('Unidade OK');
  1202. $sizeOk = $this->makeSize('PP', 'Até 50 mil habitantes');
  1203. $this->makeBrackets($sizeOk->id, [[1, 120, 0.40]]);
  1204. FranchiseeContract::create([
  1205. 'unit_id' => $outra->id,
  1206. 'protocol' => 1,
  1207. 'name' => 'Contrato OK',
  1208. 'description' => 'Teste',
  1209. 'start_date' => '2026-01-01',
  1210. 'signature_date' => '2026-01-01',
  1211. 'validity_months' => 120,
  1212. 'invoice_due_date' => 10,
  1213. 'municipality_size_id' => $sizeOk->id,
  1214. 'tbr_fixed_value' => 1000.00,
  1215. ]);
  1216. $resultado = $this->service->generateBatch(2031, 1);
  1217. $this->assertSame(1, $resultado['generated_count']);
  1218. $this->assertSame(1, $resultado['error_count']);
  1219. $this->assertSame($this->unit->id, $resultado['errors'][0]['unit_id']);
  1220. }
  1221. }