TbrCalculationRulesTest.php 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185
  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/calculo-royalties.md
  28. *
  29. * Resumo das regras validadas aqui (ver o md para o texto original do cliente):
  30. * - Royalties: meses 1-3 isento; meses 4-60 = maior entre % fixo da faixa de
  31. * porte (Classificação de Habitantes) x TBR, ou 8% do faturamento da unidade.
  32. * - FNM: meses 1-3 isento; meses 4-60 = maior entre % fixo do FNM (tabela TBR
  33. * do ano) x TBR, ou 2% do faturamento da unidade.
  34. * - Taxa de Manutenção: meses 1-60, sempre 30% da TBR — nunca isenta, nunca
  35. * comparada com faturamento, sem flag de liga/desliga.
  36. * - A partir do mês 61 (fim do primeiro ciclo de 60 meses) o contrato entra em
  37. * renovação: a faixa de royalties passa a vir de is_renewal=true, com o mês
  38. * de contrato "enrolado" de volta para o intervalo 1-60.
  39. * - Critério aplicado (tbr_fixo | percentual_faturamento | nao_cobrado) é
  40. * sempre gravado, como pedido no md.
  41. *
  42. * Cenário base usado na maioria dos testes:
  43. * - TBR = R$ 1.000,00 configurada em `tbrs` para 2026
  44. * - Porte GP: meses 1-3 = 0%, meses 4-12 = 75%, meses 13-60 = 100%
  45. * - FNM fixo = 20% da TBR | Manutenção = 30% da TBR
  46. * - Royalties sobre faturamento = 8% | FNM sobre faturamento = 2%
  47. * - Contrato iniciado em 01/01/2026
  48. *
  49. * Cada teste tem em cima um bloco Regra / Cenário / Espera para conferir a
  50. * regra de negócio, não só se o teste passa.
  51. */
  52. class TbrCalculationRulesTest extends TestCase
  53. {
  54. use RefreshDatabase;
  55. private TbrCalculationService $service;
  56. private Unit $unit;
  57. private MunicipalitySize $sizeGp;
  58. /** @var array<int, array{student_id:int, contract_id:int}> aluno/contrato reaproveitado por unidade */
  59. private array $studentContracts = [];
  60. protected function setUp(): void
  61. {
  62. parent::setUp();
  63. Bus::fake();
  64. $this->service = new TbrCalculationService;
  65. $this->unit = $this->makeUnit();
  66. $this->sizeGp = $this->makeSize('GP', 'De 100 mil a 200 mil habitantes');
  67. $this->makeBrackets($this->sizeGp->id, [
  68. [1, 3, 0.0],
  69. [4, 12, 0.75],
  70. [13, 60, 1.00],
  71. ]);
  72. $this->makeTbrYear(2026, 1000.00);
  73. }
  74. // ---------------------------------------------------------------- helpers
  75. private function makeUnit(string $name = 'Unidade Teste'): Unit
  76. {
  77. $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
  78. $state = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
  79. $city = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
  80. return Unit::create([
  81. 'fantasy_name' => $name,
  82. 'social_reason' => $name.' LTDA',
  83. 'cnpj' => '00000000000191',
  84. 'street' => 'Rua Teste',
  85. 'neighborhood' => 'Centro',
  86. 'postal_code' => '87000000',
  87. 'city_id' => $city->id,
  88. 'state_id' => $state->id,
  89. 'email' => 'unidade@teste.com',
  90. 'name_responsible' => 'Responsável Teste',
  91. ]);
  92. }
  93. private function makeSize(string $acronym, string $description): MunicipalitySize
  94. {
  95. return MunicipalitySize::create(['acronym' => $acronym, 'description' => $description]);
  96. }
  97. /** @param array<int, array{0:int,1:?int,2:float}> $brackets [start, end, percentual] */
  98. private function makeBrackets(int $sizeId, array $brackets, bool $isRenewal = false): void
  99. {
  100. foreach ($brackets as [$start, $end, $percentage]) {
  101. InhabitantClassification::create([
  102. 'municipality_size_id' => $sizeId,
  103. 'description' => ($isRenewal ? 'Renovação ' : 'Faixa ')."{$start}-{$end}",
  104. 'start' => $start,
  105. 'end' => $end,
  106. 'tbr_percentage' => $percentage,
  107. 'is_renewal' => $isRenewal,
  108. ]);
  109. }
  110. }
  111. private function makeTbrYear(int $year, float $value, float $fnm = 0.20, float $maintenance = 0.30, float $royalties = 0.08): Tbr
  112. {
  113. return Tbr::create([
  114. 'year' => $year,
  115. 'tbr_value' => $value,
  116. 'royalties_percentage' => $royalties,
  117. 'fnm_percentage' => $fnm,
  118. 'maintenance_percentage' => $maintenance,
  119. ]);
  120. }
  121. private function makeContract(array $overrides = []): FranchiseeContract
  122. {
  123. return FranchiseeContract::create(array_merge([
  124. 'unit_id' => $this->unit->id,
  125. 'protocol' => 1,
  126. 'name' => 'Contrato de Franquia',
  127. 'description' => 'Contrato de teste',
  128. 'start_date' => '2026-01-01',
  129. 'end_date' => '2031-01-01',
  130. 'signature_date' => '2026-01-01',
  131. 'validity_months' => 60,
  132. 'invoice_due_date' => 10,
  133. 'municipality_size_id' => $this->sizeGp->id,
  134. 'tbr_fixed_value' => 1000.00,
  135. ], $overrides));
  136. }
  137. /** Aluno + contrato mínimos para pendurar as parcelas que formam o faturamento. */
  138. private function studentContractFor(int $unitId): array
  139. {
  140. if (isset($this->studentContracts[$unitId])) {
  141. return $this->studentContracts[$unitId];
  142. }
  143. $student = Student::create([
  144. 'name' => 'Aluno Teste',
  145. 'unit_id' => $unitId,
  146. 'status' => 'active',
  147. ]);
  148. $contract = StudentContract::create([
  149. 'student_id' => $student->id,
  150. 'unit_id' => $unitId,
  151. 'status' => 'active',
  152. ]);
  153. return $this->studentContracts[$unitId] = [
  154. 'student_id' => $student->id,
  155. 'contract_id' => $contract->id,
  156. ];
  157. }
  158. private function makeInstallment(array $overrides = []): StudentContractInstallment
  159. {
  160. $unitId = $overrides['unit_id'] ?? $this->unit->id;
  161. $owner = $this->studentContractFor($unitId);
  162. return StudentContractInstallment::create(array_merge([
  163. 'student_contract_id' => $owner['contract_id'],
  164. 'unit_id' => $unitId,
  165. 'student_id' => $owner['student_id'],
  166. 'type' => 'monthly',
  167. 'history' => 'Mensalidade',
  168. 'installment_number' => 1,
  169. 'total_installments' => 12,
  170. 'value' => 100.00,
  171. 'due_date' => '2026-04-10',
  172. 'status' => 'pending',
  173. ], $overrides));
  174. }
  175. /** Atalho: preview do cenário base para uma competência, com faturamento informado. */
  176. private function preview(int $year, int $month, float $revenue = 0.0): array
  177. {
  178. return $this->service->preview([
  179. 'unit_id' => $this->unit->id,
  180. 'reference_year' => $year,
  181. 'reference_month' => $month,
  182. 'revenue_value' => $revenue,
  183. ]);
  184. }
  185. // ------------------------------------------------- mês de contrato / isenção
  186. /**
  187. * Regra: Como o sistema descobre em que "mês de contrato" a competência cai.
  188. *
  189. * Cenário: Contrato iniciado em 01/01/2026; consulta várias competências.
  190. *
  191. * Espera:
  192. * - 01/2026 -> mês 1
  193. * - 03/2026 -> mês 3
  194. * - 04/2026 -> mês 4
  195. * - 01/2027 -> mês 13
  196. */
  197. public function test_mes_de_contrato_conta_a_partir_do_start_date(): void
  198. {
  199. $this->makeContract();
  200. $this->assertSame(1, $this->preview(2026, 1, 1000)['contract_month_reference']);
  201. $this->assertSame(3, $this->preview(2026, 3, 1000)['contract_month_reference']);
  202. $this->assertSame(4, $this->preview(2026, 4, 1000)['contract_month_reference']);
  203. $this->assertSame(13, $this->preview(2027, 1, 1000)['contract_month_reference']);
  204. }
  205. /**
  206. * Regra: Competência anterior ao início do contrato não pode gerar mês zero ou negativo.
  207. *
  208. * Cenário: Contrato inicia 01/01/2026 e pede-se a competência 10/2025.
  209. *
  210. * Espera: mês de contrato = 1.
  211. */
  212. public function test_competencia_anterior_ao_inicio_do_contrato_e_tratada_como_mes_1(): void
  213. {
  214. $this->makeContract();
  215. $this->assertSame(1, $this->preview(2025, 10, 1000)['contract_month_reference']);
  216. }
  217. /**
  218. * Regra (md "Royalties" e "FNM"): meses 1 a 3 = isento.
  219. *
  220. * Cenário: Faturamento alto (R$ 40.000), que normalmente geraria R$ 3.200 de
  221. * royalties e R$ 800 de FNM.
  222. *
  223. * Espera:
  224. * - Royalties = R$ 0,00 nos três meses
  225. * - FNM = R$ 0,00 nos três meses
  226. * - critério registrado = 'tbr_fixo'
  227. */
  228. public function test_meses_1_a_3_isentam_royalties_e_fnm(): void
  229. {
  230. $this->makeContract();
  231. foreach ([1, 2, 3] as $month) {
  232. $result = $this->preview(2026, $month, 40000);
  233. $this->assertSame(0.0, $result['royalties_effective_value'], "mês {$month}");
  234. $this->assertSame(0.0, $result['fnm_effective_value'], "mês {$month}");
  235. $this->assertSame('tbr_fixo', $result['royalties_applied_criteria'], "mês {$month}");
  236. }
  237. }
  238. /**
  239. * Regra (md "Taxa de Manutenção"): meses 1 a 60 = 30% do fixo da TBR, sem exceção.
  240. * A isenção de Royalties/FNM dos meses 1-3 não se aplica à Manutenção.
  241. *
  242. * Cenário: Competência 02/2026 (mês 2), TBR de R$ 1.000,00.
  243. *
  244. * Espera: Manutenção = R$ 300,00; total do título = R$ 300,00.
  245. */
  246. public function test_taxa_de_manutencao_e_cobrada_mesmo_nos_meses_isentos(): void
  247. {
  248. $this->makeContract();
  249. $result = $this->preview(2026, 2, 40000);
  250. $this->assertSame(300.0, $result['maintenance_effective_value']);
  251. $this->assertSame(300.0, $result['final_value']);
  252. }
  253. /**
  254. * Regra: A isenção termina exatamente no fim do mês 3.
  255. *
  256. * Cenário: Competência 04/2026 (mês 4), faturamento baixo.
  257. *
  258. * Espera: Royalties > 0 e FNM > 0.
  259. */
  260. public function test_isencao_acaba_no_mes_4(): void
  261. {
  262. $this->makeContract();
  263. $result = $this->preview(2026, 4, 1000);
  264. $this->assertGreaterThan(0, $result['royalties_effective_value']);
  265. $this->assertGreaterThan(0, $result['fnm_effective_value']);
  266. }
  267. // ---------------------------------------------- Royalties: regra do maior valor
  268. /**
  269. * Regra (md "Royalties" meses 4-60): maior entre % fixo da faixa de porte x TBR
  270. * ou 8% do faturamento. O percentual da faixa (Classificação de Habitantes) já é
  271. * o percentual final sobre a TBR — confirmado pelo cenário do cliente (Excel):
  272. * faixa 40% x TBR R$ 1.621,00 = R$ 648,40 direto, sem taxa-base intermediária.
  273. *
  274. * Cenário: Mês 4, TBR R$ 1.000,00 (75% da faixa = R$ 750,00), faturamento
  275. * R$ 1.000,00 (8% = R$ 80,00).
  276. *
  277. * Espera: Royalties = R$ 750,00, critério = 'tbr_fixo'.
  278. */
  279. public function test_royalties_usa_valor_fixo_quando_faturamento_e_baixo(): void
  280. {
  281. $this->makeContract();
  282. $result = $this->preview(2026, 4, 1000.00);
  283. $this->assertSame(750.0, $result['royalties_effective_value']);
  284. $this->assertSame(0.75, $result['royalties_effective_percentage']);
  285. $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
  286. }
  287. /**
  288. * Regra (md "Royalties" meses 4-60): quando 8% do faturamento supera o fixo, vence o faturamento.
  289. *
  290. * Cenário: Mês 4, TBR R$ 1.000,00 (fixo R$ 750,00), faturamento R$ 40.000,00 (8% = R$ 3.200,00).
  291. *
  292. * Espera: Royalties = R$ 3.200,00, critério = 'percentual_faturamento'.
  293. */
  294. public function test_royalties_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void
  295. {
  296. $this->makeContract();
  297. $result = $this->preview(2026, 4, 40000.00);
  298. $this->assertSame(3200.0, $result['royalties_effective_value']);
  299. $this->assertSame(0.08, $result['royalties_effective_percentage']);
  300. $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
  301. }
  302. /**
  303. * Regra: Critério de desempate — valor igual mantém o fixo da TBR (comparação é >=).
  304. *
  305. * Cenário: Faturamento R$ 9.375,00, em que 8% = R$ 750,00 = exatamente o valor fixo.
  306. *
  307. * Espera: Royalties = R$ 750,00, critério = 'tbr_fixo', percentual gravado = 0,75 (o da faixa).
  308. */
  309. public function test_empate_entre_fixo_e_faturamento_mantem_o_fixo_da_tbr(): void
  310. {
  311. $this->makeContract();
  312. // 8% x 9.375 = 750,00 = valor fixo dos royalties
  313. $result = $this->preview(2026, 4, 9375.00);
  314. $this->assertSame(750.0, $result['royalties_effective_value']);
  315. $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
  316. $this->assertSame(0.75, $result['royalties_effective_percentage']);
  317. }
  318. // --------------------------------------------------------- FNM: regra do maior valor
  319. /**
  320. * Regra (md "FNM" meses 4-60): maior entre % fixo do FNM (tabela TBR do ano) x TBR
  321. * ou 2% do faturamento. O percentual fixo vem de tbrs.fnm_percentage (20% no cenário base).
  322. *
  323. * Cenário: Mês 4, TBR R$ 1.000,00 (fixo 20% = R$ 200,00), faturamento R$ 1.000,00 (2% = R$ 20,00).
  324. *
  325. * Espera: FNM = R$ 200,00, percentual efetivo = 0,20.
  326. */
  327. public function test_fnm_usa_valor_fixo_quando_faturamento_e_baixo(): void
  328. {
  329. $this->makeContract();
  330. $result = $this->preview(2026, 4, 1000.00);
  331. $this->assertSame(200.0, $result['fnm_effective_value']);
  332. $this->assertSame(0.20, $result['fnm_effective_percentage']);
  333. }
  334. /**
  335. * Regra (md "FNM" meses 4-60): quando 2% do faturamento supera o fixo, vence o faturamento.
  336. *
  337. * Cenário: Mês 4, TBR R$ 1.000,00 (fixo R$ 200,00), faturamento R$ 40.000,00 (2% = R$ 800,00).
  338. *
  339. * Espera: FNM = R$ 800,00, percentual efetivo = 0,02.
  340. */
  341. public function test_fnm_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void
  342. {
  343. $this->makeContract();
  344. $result = $this->preview(2026, 4, 40000.00);
  345. $this->assertSame(800.0, $result['fnm_effective_value']);
  346. $this->assertSame(0.02, $result['fnm_effective_percentage']);
  347. }
  348. /**
  349. * Regra: Royalties e FNM são comparados de forma independente — um pode cair no
  350. * fixo e o outro no percentual de faturamento no mesmo mês.
  351. *
  352. * Cenário: Faturamento R$ 9.500,00: 8% = R$ 760 (passa do fixo de royalties, R$ 750),
  353. * mas 2% = R$ 190 (fica abaixo do fixo de FNM, R$ 200).
  354. *
  355. * Espera:
  356. * - Royalties = R$ 760,00 por 'percentual_faturamento'
  357. * - FNM = R$ 200,00 pelo fixo (percentual 0,20)
  358. */
  359. public function test_royalties_e_fnm_podem_usar_criterios_diferentes_no_mesmo_mes(): void
  360. {
  361. $this->makeContract();
  362. $result = $this->preview(2026, 4, 9500.00);
  363. $this->assertSame(760.0, $result['royalties_effective_value']);
  364. $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
  365. $this->assertSame(200.0, $result['fnm_effective_value']);
  366. $this->assertSame(0.20, $result['fnm_effective_percentage']);
  367. }
  368. // ------------------------------------------------------------- Taxa de Manutenção
  369. /**
  370. * Regra (md "Taxa de Manutenção"): 30% do fixo da TBR, meses 1-60, nunca comparada
  371. * com o faturamento — não existe "maior valor" para a manutenção.
  372. *
  373. * Cenário: Mês 6, TBR R$ 1.000,00, faturamento altíssimo (R$ 1.000.000,00).
  374. *
  375. * Espera: Manutenção = R$ 300,00 de qualquer forma.
  376. */
  377. public function test_manutencao_nunca_e_comparada_com_o_faturamento(): void
  378. {
  379. $this->makeContract();
  380. $result = $this->preview(2026, 6, 1_000_000.00);
  381. $this->assertSame(300.0, $result['maintenance_effective_value']);
  382. $this->assertSame(0.30, $result['maintenance_effective_percentage']);
  383. }
  384. // --------------------------------------------- Classificação de Habitantes (porte)
  385. /**
  386. * Regra (md "Classificação de Habitantes"): a faixa de royalties muda conforme o
  387. * mês de contrato (4-12 x 13-60), dentro do mesmo porte.
  388. *
  389. * Cenário: Porte GP — mês 12 (75%) e mês 13 (100%).
  390. *
  391. * Espera: mês 12 -> R$ 750,00; mês 13 -> R$ 1.000,00.
  392. */
  393. public function test_faixa_de_royalties_muda_no_mes_13(): void
  394. {
  395. $this->makeContract();
  396. $this->makeTbrYear(2027, 1000.00);
  397. $mes12 = $this->preview(2026, 12, 1000);
  398. $mes13 = $this->preview(2027, 1, 1000);
  399. $this->assertSame(0.75, $mes12['royalties_bracket_percentage']);
  400. $this->assertSame(750.0, $mes12['royalties_bracket_value']);
  401. $this->assertSame(1.0, $mes13['royalties_bracket_percentage']);
  402. $this->assertSame(1000.0, $mes13['royalties_bracket_value']);
  403. }
  404. /**
  405. * Regra (md "Classificação de Habitantes"): o porte do município do contrato
  406. * define qual faixa de royalties se aplica.
  407. *
  408. * Cenário: Contrato em porte MGP (100% no mês 4) no lugar de GP (75%).
  409. *
  410. * Espera: percentual da faixa = 1,00; Royalties = R$ 1.000,00 em vez de R$ 750,00.
  411. */
  412. public function test_porte_maior_do_municipio_cobra_percentual_maior(): void
  413. {
  414. $sizeMgp = $this->makeSize('MGP', 'Acima de 200 mil habitantes');
  415. $this->makeBrackets($sizeMgp->id, [[1, 3, 0.0], [4, 12, 1.00], [13, 60, 1.50]]);
  416. $this->makeContract(['municipality_size_id' => $sizeMgp->id]);
  417. $result = $this->preview(2026, 4, 1000);
  418. $this->assertSame(1.0, $result['royalties_bracket_percentage']);
  419. $this->assertSame(1000.0, $result['royalties_effective_value']);
  420. }
  421. /**
  422. * Regra: Contrato sem faixa cadastrada para o porte/mês, e sem faixa de renovação
  423. * cobrindo o período, não calcula.
  424. *
  425. * Cenário: Faixas cadastradas só até o mês 60, sem nenhuma faixa de renovação;
  426. * competência cai no mês 61.
  427. *
  428. * Espera: ValidationException 'Não foi encontrada faixa de royalties'.
  429. */
  430. public function test_erro_quando_nao_existe_faixa_nem_de_renovacao_para_o_mes(): void
  431. {
  432. $this->makeContract();
  433. $this->makeTbrYear(2031, 1000.00);
  434. $this->expectException(ValidationException::class);
  435. $this->expectExceptionMessage('Não foi encontrada faixa de royalties');
  436. $this->preview(2031, 1, 1000); // mês 61, sem faixa de renovação cadastrada
  437. }
  438. // ---------------------------------------------------- Renovação (mês 61 em diante)
  439. /**
  440. * Regra: A partir do mês 61 (fim do primeiro ciclo de 60 meses), a faixa de
  441. * royalties passa a vir de is_renewal=true — o contrato "renova" e continua sendo
  442. * cobrado, em vez de estourar erro.
  443. *
  444. * Cenário: Faixa de renovação do porte GP cobrindo 1-60, com 100% (igual à última
  445. * faixa normal). Competência cai no mês 61 do contrato.
  446. *
  447. * Espera: encontra a faixa de renovação, percentual = 1,00, Royalties = R$ 1.000,00.
  448. */
  449. public function test_mes_61_usa_a_faixa_de_renovacao(): void
  450. {
  451. $this->makeBrackets($this->sizeGp->id, [[1, 60, 1.00]], true);
  452. $this->makeContract();
  453. $this->makeTbrYear(2031, 1000.00);
  454. $result = $this->preview(2031, 1, 1000); // mês de contrato 61
  455. $this->assertSame(1.0, $result['royalties_bracket_percentage']);
  456. $this->assertSame(1000.0, $result['royalties_effective_value']);
  457. }
  458. /**
  459. * Regra: O mês de contrato dentro do ciclo de renovação é "enrolado" de volta para
  460. * o intervalo 1-60 (mês 61 = mês 1 da renovação, mês 120 = mês 60 da renovação).
  461. *
  462. * Cenário: Faixa de renovação com duas fatias (1-12 = 60%, 13-60 = 100%), simulando
  463. * uma nova isenção/escalonamento dentro do próprio ciclo de renovação.
  464. *
  465. * Espera:
  466. * - mês 65 do contrato (5º mês da renovação) -> 60%
  467. * - mês 120 do contrato (60º mês da renovação) -> 100%
  468. */
  469. public function test_mes_de_renovacao_e_calculado_dentro_do_ciclo_de_60_meses(): void
  470. {
  471. $this->makeBrackets($this->sizeGp->id, [[1, 12, 0.60], [13, 60, 1.00]], true);
  472. $this->makeContract();
  473. $this->makeTbrYear(2031, 1000.00); // mês 61
  474. $this->makeTbrYear(2035, 1000.00); // mês 61 + 59 = mês 120
  475. $mes65 = $this->preview(2031, 5, 1000); // mês de contrato 65 -> renovação mês 5
  476. $mes120 = $this->preview(2035, 12, 1000); // mês de contrato 120 -> renovação mês 60
  477. $this->assertSame(0.60, $mes65['royalties_bracket_percentage']);
  478. $this->assertSame(1.00, $mes120['royalties_bracket_percentage']);
  479. }
  480. /**
  481. * Regra: Passado um segundo ciclo completo de 60 meses (mês 121 em diante), o
  482. * ciclo de renovação recomeça do zero.
  483. *
  484. * Cenário: Faixa de renovação única (1-60 = 100%); competência cai no mês 121.
  485. *
  486. * Espera: continua encontrando a faixa de renovação (não estoura erro), 100%.
  487. */
  488. public function test_segundo_ciclo_de_renovacao_reinicia_a_contagem(): void
  489. {
  490. $this->makeBrackets($this->sizeGp->id, [[1, 60, 1.00]], true);
  491. $this->makeContract();
  492. $this->makeTbrYear(2036, 1000.00); // mês de contrato 121
  493. $result = $this->preview(2036, 1, 1000);
  494. $this->assertSame(1.0, $result['royalties_bracket_percentage']);
  495. }
  496. // --------------------------------------------------------------- valor da TBR
  497. /**
  498. * Regra (md): o percentual/valor fixo vem da "TBR Fixo do ano (Atribuído em
  499. * Configurações do TBR)". A tabela `tbrs` do ano cadastrada pelo admin manda
  500. * sobre o valor gravado no contrato.
  501. *
  502. * Cenário: tbrs/2026 = R$ 1.000,00 (setUp) e contrato com tbr_fixed_value = R$ 2.000,00.
  503. *
  504. * Espera: TBR usada = R$ 1.000,00; Royalties = R$ 750,00; Manutenção = R$ 300,00.
  505. */
  506. public function test_tbr_do_ano_configurada_pelo_admin_tem_prioridade_sobre_o_contrato(): void
  507. {
  508. $this->makeContract(['tbr_fixed_value' => 2000.00]);
  509. $result = $this->preview(2026, 4, 1000);
  510. $this->assertSame(1000.0, $result['tbr_value']);
  511. $this->assertSame(750.0, $result['royalties_effective_value']);
  512. $this->assertSame(300.0, $result['maintenance_effective_value']);
  513. }
  514. /**
  515. * Regra: Cada ano de referência usa sua própria configuração de TBR.
  516. *
  517. * Cenário: tbrs/2026 = R$ 1.000,00 (setUp), tbrs/2027 = R$ 2.000,00.
  518. *
  519. * Espera: mês em 2026 usa R$ 1.000,00; mês em 2027 usa R$ 2.000,00.
  520. */
  521. public function test_cada_ano_usa_a_sua_propria_configuracao_de_tbr(): void
  522. {
  523. $this->makeContract();
  524. $this->makeTbrYear(2027, 2000.00);
  525. $this->assertSame(1000.0, $this->preview(2026, 4, 1000)['tbr_value']);
  526. $this->assertSame(2000.0, $this->preview(2027, 1, 1000)['tbr_value']);
  527. }
  528. /**
  529. * Regra: Quando o ano de referência não está configurado em `tbrs`, usa-se o
  530. * valor gravado no contrato (tbr_fixed_value) como reserva.
  531. *
  532. * Cenário: Competência em 2030 (sem linha em `tbrs`), contrato com tbr_fixed_value = R$ 1.500,00.
  533. *
  534. * Espera: TBR usada = R$ 1.500,00.
  535. */
  536. public function test_usa_a_tbr_do_contrato_quando_o_ano_nao_esta_configurado(): void
  537. {
  538. $this->makeContract(['tbr_fixed_value' => 1500.00]);
  539. $result = $this->preview(2030, 1, 1000);
  540. $this->assertSame(1500.0, $result['tbr_value']);
  541. }
  542. /**
  543. * Regra: Sem TBR configurada nem no ano nem no contrato, o cálculo não pode seguir.
  544. *
  545. * Cenário: Ano sem `tbrs` e contrato com tbr_fixed_value = 0.
  546. *
  547. * Espera: ValidationException.
  548. */
  549. public function test_erro_quando_nao_ha_tbr_no_ano_nem_no_contrato(): void
  550. {
  551. $this->makeContract(['tbr_fixed_value' => 0]);
  552. $this->expectException(ValidationException::class);
  553. $this->preview(2030, 1, 1000);
  554. }
  555. /**
  556. * Regra: Os percentuais fixos de FNM e Manutenção vêm da tabela `tbrs` do ano.
  557. *
  558. * Cenário: tbrs/2027 com FNM = 25% e Manutenção = 35%.
  559. *
  560. * Espera: FNM fixo = R$ 250,00 (25% x 1000) e Manutenção = R$ 350,00 (35% x 1000).
  561. */
  562. public function test_percentuais_de_fnm_e_manutencao_vem_da_tabela_do_ano(): void
  563. {
  564. $this->makeContract();
  565. $this->makeTbrYear(2027, 1000.00, fnm: 0.25, maintenance: 0.35);
  566. $result = $this->preview(2027, 1, 1000); // mês 13, sem faturamento suficiente pra superar o fixo
  567. $this->assertSame(250.0, $result['fnm_bracket_value']);
  568. $this->assertSame(350.0, $result['maintenance_effective_value']);
  569. }
  570. /**
  571. * Regra: O campo "Royalties %" da tela de Configurações do TBR controla o
  572. * percentual do lado FATURAMENTO da comparação "maior valor" — não o lado fixo
  573. * (que continua vindo direto da faixa de porte, como confirmado pelo cliente).
  574. * Antes, esse percentual era fixo em 8% no código; agora é configurável por ano.
  575. *
  576. * Cenário: tbrs/2027 com royalties_percentage = 10% (em vez dos 8% padrão).
  577. * Faturamento R$ 8.000,00: 10% = R$ 800,00, contra o fixo da faixa GP mês 13-60
  578. * (100% x TBR R$ 1.000,00 = R$ 1.000,00) — o fixo ainda vence aqui.
  579. * Faturamento R$ 12.000,00: 10% = R$ 1.200,00 > R$ 1.000,00 fixo — agora vence o
  580. * faturamento, usando 10% (e não os 8% hardcoded antigos, que dariam R$ 960,00).
  581. */
  582. public function test_royalties_percentual_de_faturamento_vem_da_configuracao_do_tbr_do_ano(): void
  583. {
  584. $this->makeContract();
  585. $this->makeTbrYear(2027, 1000.00, royalties: 0.10); // mês 13 (100% da faixa GP)
  586. $fixoVence = $this->preview(2027, 1, 8000.00);
  587. $this->assertSame(1000.0, $fixoVence['royalties_effective_value']);
  588. $this->assertSame('tbr_fixo', $fixoVence['royalties_applied_criteria']);
  589. $faturamentoVence = $this->preview(2027, 1, 12000.00);
  590. $this->assertSame(1200.0, $faturamentoVence['royalties_effective_value']);
  591. $this->assertSame(0.10, $faturamentoVence['royalties_effective_percentage']);
  592. $this->assertSame('percentual_faturamento', $faturamentoVence['royalties_applied_criteria']);
  593. }
  594. // ------------------------------------------------ flags charge_roi / charge_fnm
  595. /**
  596. * Regra (md "UnitActionPage"): quando a cobrança de Royalties (ROI) está desativada
  597. * para a unidade, o valor efetivo de royalties é zerado.
  598. *
  599. * Cenário: `unit_financials.charge_roi = false`.
  600. *
  601. * Espera: royalties_effective_value = 0, critério = 'nao_cobrado'; Manutenção continua.
  602. */
  603. public function test_charge_roi_desligado_zera_os_royalties(): void
  604. {
  605. $this->makeContract();
  606. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]);
  607. $result = $this->preview(2026, 4, 40000);
  608. $this->assertSame(0.0, $result['royalties_effective_value']);
  609. $this->assertSame('nao_cobrado', $result['royalties_applied_criteria']);
  610. $this->assertGreaterThan(0, $result['maintenance_effective_value']);
  611. }
  612. /**
  613. * Regra: Com charge_roi desligado, o cálculo nem exige que exista faixa de
  614. * royalties cadastrada para o porte/mês (não busca a faixa).
  615. *
  616. * Cenário: Nenhuma faixa cadastrada para o porte MP; charge_roi = false.
  617. *
  618. * Espera: preview não lança exceção; royalties = 0.
  619. */
  620. public function test_charge_roi_desligado_dispensa_a_existencia_de_faixa(): void
  621. {
  622. $sizeMp = $this->makeSize('MP', 'Sem faixas cadastradas');
  623. $this->makeContract(['municipality_size_id' => $sizeMp->id]);
  624. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]);
  625. $result = $this->preview(2026, 4, 1000);
  626. $this->assertSame(0.0, $result['royalties_effective_value']);
  627. $this->assertNull($result['royalties_bracket_id']);
  628. }
  629. /**
  630. * Regra: charge_fnm desligado zera apenas o FNM.
  631. *
  632. * Cenário: `unit_financials.charge_fnm = false`.
  633. *
  634. * Espera: fnm_effective_value = 0; royalties e manutenção continuam normais.
  635. */
  636. public function test_charge_fnm_desligado_zera_o_fnm(): void
  637. {
  638. $this->makeContract();
  639. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => true, 'charge_fnm' => false]);
  640. $result = $this->preview(2026, 4, 1000);
  641. $this->assertSame(0.0, $result['fnm_effective_value']);
  642. $this->assertGreaterThan(0, $result['royalties_effective_value']);
  643. }
  644. /**
  645. * Regra: Unidade sem registro em `unit_financials` cobra normalmente (default seguro = cobrar).
  646. *
  647. * Cenário: Nenhum `UnitFinancial` criado para a unidade.
  648. *
  649. * Espera: royalties e FNM calculados normalmente (> 0).
  650. */
  651. public function test_unidade_sem_registro_financeiro_cobra_royalties_e_fnm(): void
  652. {
  653. $this->makeContract();
  654. $result = $this->preview(2026, 4, 1000);
  655. $this->assertGreaterThan(0, $result['royalties_effective_value']);
  656. $this->assertGreaterThan(0, $result['fnm_effective_value']);
  657. }
  658. /**
  659. * Regra (md "Taxa de Manutenção"): a manutenção não tem flag de liga/desliga —
  660. * é sempre cobrada, mesmo com charge_roi e charge_fnm desligados.
  661. *
  662. * Cenário: charge_roi = false e charge_fnm = false.
  663. *
  664. * Espera: Manutenção = R$ 300,00; total = R$ 300,00.
  665. */
  666. public function test_manutencao_e_cobrada_mesmo_com_as_duas_flags_desligadas(): void
  667. {
  668. $this->makeContract();
  669. UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => false]);
  670. $result = $this->preview(2026, 4, 1000);
  671. $this->assertSame(300.0, $result['maintenance_effective_value']);
  672. $this->assertSame(300.0, $result['final_value']);
  673. }
  674. // ---------------------------------------------------------- composição do faturamento
  675. /**
  676. * Regra: O faturamento-base do TBR soma parcelas de aluno, recebíveis avulsos da
  677. * unidade e contas vinculadas pela franqueadora (origin=manual_unit).
  678. *
  679. * Cenário: 1 parcela de R$ 100, 1 recebível avulso de R$ 50, 1 conta da matriz
  680. * vinculada de R$ 30, todos com vencimento no mês de referência.
  681. *
  682. * Espera: faturamento resolvido = R$ 180,00.
  683. */
  684. public function test_faturamento_soma_parcelas_recebiveis_e_contas_da_matriz(): void
  685. {
  686. $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10']);
  687. UnitAccountReceivable::create([
  688. 'unit_id' => $this->unit->id,
  689. 'history' => 'Avulso',
  690. 'value' => 50,
  691. 'due_date' => '2026-04-15',
  692. 'status' => 'pending',
  693. ]);
  694. FranchiseeAccountReceive::create([
  695. 'unit_id' => $this->unit->id,
  696. 'origin' => 'manual_unit',
  697. 'history' => 'Conta vinculada',
  698. 'value' => 30,
  699. 'due_date' => '2026-04-20',
  700. 'status' => 'pending',
  701. ]);
  702. $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4);
  703. $this->assertSame(180.0, $revenue);
  704. }
  705. /**
  706. * Regra: Parcelas canceladas e de outros meses não entram no faturamento.
  707. */
  708. public function test_faturamento_ignora_parcelas_canceladas_e_de_outros_meses(): void
  709. {
  710. $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10', 'status' => 'cancelled']);
  711. $this->makeInstallment(['value' => 200, 'due_date' => '2026-05-10', 'installment_number' => 2]);
  712. $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4);
  713. $this->assertSame(0.0, $revenue);
  714. }
  715. /**
  716. * Regra: Parcelas com soft delete não entram no faturamento.
  717. */
  718. public function test_faturamento_ignora_parcelas_excluidas(): void
  719. {
  720. $installment = $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10']);
  721. $installment->delete();
  722. $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4);
  723. $this->assertSame(0.0, $revenue);
  724. }
  725. /**
  726. * Regra: Recebíveis originados do próprio TBR não realimentam a base de faturamento.
  727. */
  728. public function test_faturamento_ignora_recebiveis_de_tbr(): void
  729. {
  730. FranchiseeAccountReceive::create([
  731. 'unit_id' => $this->unit->id,
  732. 'origin' => 'tbr',
  733. 'history' => 'Royalties gerado',
  734. 'value' => 500,
  735. 'due_date' => '2026-04-20',
  736. 'status' => 'pending',
  737. ]);
  738. $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4);
  739. $this->assertSame(0.0, $revenue);
  740. }
  741. /**
  742. * Regra: Movimentos de outra unidade não entram no faturamento consultado.
  743. */
  744. public function test_faturamento_ignora_movimento_de_outra_unidade(): void
  745. {
  746. $outraUnidade = $this->makeUnit('Outra Unidade');
  747. $this->makeInstallment(['unit_id' => $outraUnidade->id, 'value' => 999, 'due_date' => '2026-04-10']);
  748. $revenue = $this->service->resolveRevenue($this->unit->id, 2026, 4);
  749. $this->assertSame(0.0, $revenue);
  750. }
  751. /**
  752. * Regra: Faturamento informado manualmente sobrepõe o cálculo automático (override).
  753. */
  754. public function test_faturamento_informado_manualmente_substitui_o_calculo_automatico(): void
  755. {
  756. $this->makeContract();
  757. $this->makeInstallment(['value' => 100, 'due_date' => '2026-04-10']);
  758. $result = $this->service->preview([
  759. 'unit_id' => $this->unit->id,
  760. 'reference_year' => 2026,
  761. 'reference_month' => 4,
  762. 'revenue_value' => 40000,
  763. ]);
  764. $this->assertSame(40000.0, $result['revenue_value']);
  765. $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
  766. }
  767. // -------------------------------------------------------------- contrato / erros
  768. /**
  769. * Regra: Unidade sem contrato cadastrado não calcula.
  770. */
  771. public function test_erro_quando_a_unidade_nao_tem_contrato(): void
  772. {
  773. $this->expectException(ValidationException::class);
  774. $this->expectExceptionMessage('Unidade não possui contrato cadastrado.');
  775. $this->preview(2026, 4, 1000);
  776. }
  777. /**
  778. * Regra: Contrato sem porte do município definido não calcula.
  779. */
  780. public function test_erro_quando_o_contrato_nao_tem_porte_do_municipio(): void
  781. {
  782. $this->makeContract(['municipality_size_id' => null]);
  783. $this->expectException(ValidationException::class);
  784. $this->expectExceptionMessage('faixa de habitantes definida');
  785. $this->preview(2026, 4, 1000);
  786. }
  787. /**
  788. * Regra: Havendo mais de um contrato para a unidade, usa-se sempre o mais recente
  789. * (maior start_date).
  790. */
  791. public function test_usa_sempre_o_contrato_mais_recente_da_unidade(): void
  792. {
  793. $this->makeContract(['start_date' => '2020-01-01', 'tbr_fixed_value' => 500]);
  794. $this->makeContract(['start_date' => '2026-01-01', 'tbr_fixed_value' => 1500]);
  795. $result = $this->preview(2026, 1, 1000);
  796. $this->assertSame(1, $result['contract_month_reference']);
  797. }
  798. // -------------------------------------------------------------- persistência
  799. /**
  800. * Regra: calculate() grava em `tbr_calculations` exatamente os valores do preview
  801. * (a tabela de auditoria pedida no md).
  802. */
  803. public function test_calculate_grava_o_calculo_com_os_valores_do_preview(): void
  804. {
  805. $this->makeContract();
  806. $calculation = $this->service->calculate([
  807. 'unit_id' => $this->unit->id,
  808. 'reference_year' => 2026,
  809. 'reference_month' => 4,
  810. 'revenue_value' => 40000,
  811. ]);
  812. $this->assertInstanceOf(TbrCalculation::class, $calculation);
  813. $this->assertSame(4, $calculation->contract_month_reference);
  814. $this->assertSame('percentual_faturamento', $calculation->royalties_applied_criteria);
  815. $this->assertEquals(3200.0, (float) $calculation->royalties_effective_value);
  816. $this->assertEquals(800.0, (float) $calculation->fnm_effective_value);
  817. $this->assertEquals(300.0, (float) $calculation->maintenance_effective_value);
  818. $this->assertEquals(4300.0, (float) $calculation->final_value);
  819. $this->assertFalse($calculation->receivable_generated);
  820. }
  821. // ---------------------------------------------------------- geração de título
  822. /**
  823. * Regra (md "Tabela de Auditoria" + contas a receber/pagar): gerar o título cria
  824. * o recebível da matriz, os 3 detalhes por componente e o espelho na unidade
  825. * (conta a pagar), além de disparar a sincronização com o Asaas.
  826. */
  827. public function test_gerar_titulo_cria_recebivel_detalhes_e_conta_a_pagar(): void
  828. {
  829. $this->makeContract();
  830. $calculation = $this->service->calculate([
  831. 'unit_id' => $this->unit->id,
  832. 'reference_year' => 2026,
  833. 'reference_month' => 4,
  834. 'revenue_value' => 40000,
  835. ]);
  836. $receive = $this->service->generateReceivable($calculation->id);
  837. $this->assertSame(4300.0, (float) $receive->value);
  838. $this->assertCount(3, $receive->details);
  839. $this->assertDatabaseHas('unit_account_payables', [
  840. 'unit_id' => $this->unit->id,
  841. 'franchisee_account_receive_id' => $receive->id,
  842. 'origin' => UnitAccountPayable::ORIGIN_TBR,
  843. ]);
  844. $this->assertTrue($calculation->fresh()->receivable_generated);
  845. Bus::assertDispatched(\App\Jobs\SyncFranchiseeChargeJob::class);
  846. }
  847. /**
  848. * Regra: Não é possível gerar título duas vezes para o mesmo cálculo.
  849. */
  850. public function test_gerar_titulo_duas_vezes_para_o_mesmo_calculo_e_bloqueado(): void
  851. {
  852. $this->makeContract();
  853. $calculation = $this->service->calculate([
  854. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  855. ]);
  856. $this->service->generateReceivable($calculation->id);
  857. $this->expectException(ValidationException::class);
  858. $this->expectExceptionMessage('Já existe um título gerado para este cálculo.');
  859. $this->service->generateReceivable($calculation->id);
  860. }
  861. /**
  862. * Regra: Não é possível gerar dois títulos para o mesmo mês de contrato da mesma unidade.
  863. */
  864. public function test_gerar_titulo_para_o_mesmo_mes_de_contrato_e_bloqueado(): void
  865. {
  866. $this->makeContract();
  867. $primeiro = $this->service->calculate([
  868. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  869. ]);
  870. $this->service->generateReceivable($primeiro->id);
  871. $segundo = $this->service->calculate([
  872. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 2000,
  873. ]);
  874. $this->expectException(ValidationException::class);
  875. $this->expectExceptionMessage('mês de contrato');
  876. $this->service->generateReceivable($segundo->id);
  877. }
  878. /**
  879. * Regra: O preview avisa (flag) quando já existe título gerado para o mês de contrato,
  880. * sem impedir a nova simulação.
  881. */
  882. public function test_preview_avisa_quando_ja_existe_titulo_no_mes_de_contrato(): void
  883. {
  884. $this->makeContract();
  885. $calculation = $this->service->calculate([
  886. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  887. ]);
  888. $this->service->generateReceivable($calculation->id);
  889. $result = $this->preview(2026, 4, 1000);
  890. $this->assertTrue($result['receivable_already_generated']);
  891. }
  892. // ----------------------------------------------------------------- vencimento
  893. public function test_vencimento_usa_o_dia_do_contrato_no_mes_seguinte(): void
  894. {
  895. $this->makeContract(['invoice_due_date' => 15]);
  896. $calculation = $this->service->calculate([
  897. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  898. ]);
  899. $receive = $this->service->generateReceivable($calculation->id);
  900. $this->assertSame('2026-05-15', $receive->due_date->toDateString());
  901. }
  902. public function test_vencimento_cai_no_dia_10_quando_o_contrato_nao_informa(): void
  903. {
  904. $this->makeContract(['invoice_due_date' => null]);
  905. $calculation = $this->service->calculate([
  906. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  907. ]);
  908. $receive = $this->service->generateReceivable($calculation->id);
  909. $this->assertSame('2026-05-10', $receive->due_date->toDateString());
  910. }
  911. public function test_vencimento_e_limitado_ao_dia_28(): void
  912. {
  913. $this->makeContract(['invoice_due_date' => 31]);
  914. $calculation = $this->service->calculate([
  915. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  916. ]);
  917. $receive = $this->service->generateReceivable($calculation->id);
  918. $this->assertSame(28, $receive->due_date->day);
  919. }
  920. // ---------------------------------------------------------------------- lote
  921. public function test_lote_gera_titulo_para_cada_contrato_ativo(): void
  922. {
  923. $unidade2 = $this->makeUnit('Unidade 2');
  924. $this->makeContract();
  925. $this->makeContract(['unit_id' => $unidade2->id]);
  926. $result = $this->service->generateBatch(2026, 4, null);
  927. $this->assertSame(2, $result['generated_count']);
  928. $this->assertSame(0, $result['error_count']);
  929. }
  930. public function test_lote_pula_unidade_que_ja_tem_titulo_no_mes_de_contrato(): void
  931. {
  932. $this->makeContract();
  933. $calculation = $this->service->calculate([
  934. 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000,
  935. ]);
  936. $this->service->generateReceivable($calculation->id);
  937. $result = $this->service->generateBatch(2026, 4, null);
  938. $this->assertSame(0, $result['generated_count']);
  939. $this->assertSame(1, $result['skipped_count']);
  940. }
  941. public function test_lote_ignora_contrato_encerrado_antes_da_competencia(): void
  942. {
  943. $this->makeContract(['end_date' => '2026-02-28']);
  944. $result = $this->service->generateBatch(2026, 4, null);
  945. $this->assertSame(0, $result['generated_count']);
  946. $this->assertSame(0, $result['skipped_count']);
  947. $this->assertSame(0, $result['error_count']);
  948. }
  949. public function test_lote_ignora_contrato_que_ainda_nao_comecou(): void
  950. {
  951. $this->makeContract(['start_date' => '2026-08-01']);
  952. $result = $this->service->generateBatch(2026, 4, null);
  953. $this->assertSame(0, $result['generated_count']);
  954. }
  955. public function test_lote_ignora_contrato_sem_porte_definido(): void
  956. {
  957. $this->makeContract(['municipality_size_id' => null]);
  958. $result = $this->service->generateBatch(2026, 4, null);
  959. $this->assertSame(0, $result['generated_count']);
  960. $this->assertSame(0, $result['error_count']);
  961. }
  962. public function test_lote_pode_ser_filtrado_por_unidade(): void
  963. {
  964. $unidade2 = $this->makeUnit('Unidade 2');
  965. $this->makeContract();
  966. $this->makeContract(['unit_id' => $unidade2->id]);
  967. $result = $this->service->generateBatch(2026, 4, [$unidade2->id]);
  968. $this->assertSame(1, $result['generated_count']);
  969. $this->assertSame($unidade2->id, $result['generated'][0]['unit_id']);
  970. }
  971. public function test_lote_reporta_erro_sem_interromper_as_demais_unidades(): void
  972. {
  973. $unidadeComErro = $this->makeUnit('Unidade Sem TBR');
  974. $this->makeContract(['tbr_fixed_value' => 0]);
  975. $this->makeContract(['unit_id' => $unidadeComErro->id, 'tbr_fixed_value' => 0]);
  976. Tbr::query()->delete();
  977. $result = $this->service->generateBatch(2030, 1, null);
  978. $this->assertSame(0, $result['generated_count']);
  979. $this->assertSame(2, $result['error_count']);
  980. }
  981. }