StudentContractInstallmentGenerationTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. <?php
  2. namespace Tests\Unit;
  3. use App\Models\City;
  4. use App\Models\Country;
  5. use App\Models\State;
  6. use App\Models\Student;
  7. use App\Models\StudentContractInstallment;
  8. use App\Models\Unit;
  9. use App\Services\StudentContractService;
  10. use Illuminate\Foundation\Testing\RefreshDatabase;
  11. use Tests\TestCase;
  12. /**
  13. * Geração de parcelas a partir do contrato de aluno assinado.
  14. *
  15. * O contrato não tem um "pacote" único: tem 5 blocos independentes (Matrícula,
  16. * Entrada, Pacote, Materiais, Total do Curso). Cada bloco só gera parcela
  17. * quando tem valor + quantidade de parcelas + data de vencimento preenchidos —
  18. * e cada um tem sua própria data-base, por isso a Entrada pode cair num dia
  19. * diferente das demais parcelas.
  20. */
  21. class StudentContractInstallmentGenerationTest extends TestCase
  22. {
  23. use RefreshDatabase;
  24. private Unit $unit;
  25. private StudentContractService $service;
  26. protected function setUp(): void
  27. {
  28. parent::setUp();
  29. $this->unit = $this->makeUnit();
  30. $this->service = new StudentContractService();
  31. }
  32. private function makeUnit(): Unit
  33. {
  34. $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
  35. $state = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
  36. $city = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
  37. return Unit::create([
  38. 'fantasy_name' => 'Unidade Teste',
  39. 'social_reason' => 'Unidade Teste LTDA',
  40. 'cnpj' => '00000000000191',
  41. 'street' => 'Rua Teste',
  42. 'neighborhood' => 'Centro',
  43. 'postal_code' => '87000000',
  44. 'city_id' => $city->id,
  45. 'state_id' => $state->id,
  46. 'email' => 'unidade@teste.com',
  47. 'name_responsible' => 'Responsável Teste',
  48. ]);
  49. }
  50. private function makeStudent(string $name = 'Aluno Teste'): Student
  51. {
  52. return Student::create([
  53. 'name' => $name,
  54. 'unit_id' => $this->unit->id,
  55. 'status' => 'active',
  56. ]);
  57. }
  58. private function installmentsFor(int $contractId, ?string $type = null)
  59. {
  60. return StudentContractInstallment::where('student_contract_id', $contractId)
  61. ->when($type, fn ($q) => $q->where('type', $type))
  62. ->orderBy('installment_number')
  63. ->get();
  64. }
  65. // -------------------------------------------------------------- blocos isolados
  66. public function test_bloco_matricula_gera_parcela_unica_com_data_e_historico_proprios(): void
  67. {
  68. $student = $this->makeStudent();
  69. $contract = $this->service->create([
  70. 'student_id' => $student->id,
  71. 'unit_id' => $this->unit->id,
  72. 'tax_register' => 150.00,
  73. 'installments' => 1,
  74. 'enrollment_due_date' => '2026-08-05',
  75. ]);
  76. $installments = $this->installmentsFor($contract->id, 'enrollment');
  77. $this->assertCount(1, $installments);
  78. $this->assertSame('REF. MATRÍCULA', $installments->first()->history);
  79. $this->assertEquals(150.00, $installments->first()->value);
  80. $this->assertSame('2026-08-05', $installments->first()->due_date->format('Y-m-d'));
  81. }
  82. public function test_bloco_pacote_gera_parcelas_proporcionais_ao_valor_informado(): void
  83. {
  84. $student = $this->makeStudent();
  85. $contract = $this->service->create([
  86. 'student_id' => $student->id,
  87. 'unit_id' => $this->unit->id,
  88. 'package_value' => 1200.00,
  89. 'package_installments' => 12,
  90. 'package_due_date' => '2026-08-10',
  91. 'recurring_day' => 10,
  92. ]);
  93. $installments = $this->installmentsFor($contract->id, 'package');
  94. $this->assertCount(12, $installments);
  95. $this->assertSame('REF. PACOTE', $installments->first()->history);
  96. $this->assertEquals(100.00, $installments->first()->value);
  97. }
  98. public function test_bloco_materiais_gera_parcelas_proporcionais_ao_valor_informado(): void
  99. {
  100. $student = $this->makeStudent();
  101. $contract = $this->service->create([
  102. 'student_id' => $student->id,
  103. 'unit_id' => $this->unit->id,
  104. 'materials_value' => 300.00,
  105. 'materials_installments' => 3,
  106. 'materials_due_date' => '2026-08-10',
  107. 'recurring_day' => 10,
  108. ]);
  109. $installments = $this->installmentsFor($contract->id, 'materials');
  110. $this->assertCount(3, $installments);
  111. $this->assertSame('REF. MATERIAIS', $installments->first()->history);
  112. $this->assertEquals(100.00, $installments->first()->value);
  113. }
  114. /**
  115. * Regra: Um bloco sem uma das 3 pernas (valor, parcelas, vencimento) não gera
  116. * nenhuma parcela — nem parcial.
  117. */
  118. public function test_bloco_sem_vencimento_nao_gera_parcela(): void
  119. {
  120. $student = $this->makeStudent();
  121. $contract = $this->service->create([
  122. 'student_id' => $student->id,
  123. 'unit_id' => $this->unit->id,
  124. 'package_value' => 1200.00,
  125. 'package_installments' => 12,
  126. // sem package_due_date
  127. ]);
  128. $this->assertCount(0, $this->installmentsFor($contract->id));
  129. }
  130. // ------------------------------------------------------ blocos combinados
  131. /**
  132. * Regra: Matrícula, Entrada, Pacote e Materiais coexistem no mesmo contrato,
  133. * cada um com sua própria contagem de parcelas e sua própria data-base —
  134. * exatamente o "quantas vezes foi parcelado, teve entrada, valores corretos
  135. * por vencimento" que a análise pede.
  136. */
  137. public function test_contrato_completo_gera_todos_os_blocos_de_forma_independente(): void
  138. {
  139. $student = $this->makeStudent();
  140. $contract = $this->service->create([
  141. 'student_id' => $student->id,
  142. 'unit_id' => $this->unit->id,
  143. 'recurring_day' => 10,
  144. 'tax_register' => 150.00,
  145. 'installments' => 1,
  146. 'enrollment_due_date' => '2026-08-05',
  147. 'down_payment_value' => 500.00,
  148. 'down_payment_date' => '2026-08-20',
  149. 'package_value' => 1200.00,
  150. 'package_installments' => 12,
  151. 'package_due_date' => '2026-09-10',
  152. 'materials_value' => 300.00,
  153. 'materials_installments' => 3,
  154. 'materials_due_date' => '2026-09-10',
  155. ]);
  156. $all = $this->installmentsFor($contract->id);
  157. $this->assertCount(1 + 1 + 12 + 3, $all);
  158. $this->assertCount(1, $all->where('type', 'enrollment'));
  159. $this->assertCount(1, $all->where('type', 'down_payment'));
  160. $this->assertCount(12, $all->where('type', 'package'));
  161. $this->assertCount(3, $all->where('type', 'materials'));
  162. // Entrada tem data própria, diferente do dia recorrente (10) das demais parcelas.
  163. $this->assertSame('2026-08-20', $all->where('type', 'down_payment')->first()->due_date->format('Y-m-d'));
  164. }
  165. /**
  166. * Regra: A entrada só abate do bloco "Total do Curso" — não abate de "Pacote".
  167. * Achado da investigação: são fluxos diferentes (pricing_mode novo usa Total;
  168. * pacote é o fluxo antigo) e hoje não se combinam automaticamente.
  169. */
  170. public function test_entrada_nao_abate_do_bloco_pacote(): void
  171. {
  172. $student = $this->makeStudent();
  173. $contract = $this->service->create([
  174. 'student_id' => $student->id,
  175. 'unit_id' => $this->unit->id,
  176. 'recurring_day' => 10,
  177. 'down_payment_value' => 500.00,
  178. 'down_payment_date' => '2026-08-20',
  179. 'package_value' => 1200.00,
  180. 'package_installments' => 12,
  181. 'package_due_date' => '2026-09-10',
  182. ]);
  183. $package = $this->installmentsFor($contract->id, 'package');
  184. // Se a entrada abatesse, seria (1200-500)/12 = 58,33. Sem abater: 1200/12 = 100.
  185. $this->assertEquals(100.00, $package->first()->value);
  186. }
  187. /**
  188. * Regra: A entrada abate do bloco "Total do Curso" antes de dividir as
  189. * parcelas restantes (reforça o teste já existente em
  190. * StudentContractDownPaymentTest, agora junto com outros blocos no mesmo contrato).
  191. */
  192. public function test_entrada_abate_do_bloco_total_mesmo_com_outros_blocos_presentes(): void
  193. {
  194. $student = $this->makeStudent();
  195. $contract = $this->service->create([
  196. 'student_id' => $student->id,
  197. 'unit_id' => $this->unit->id,
  198. 'recurring_day' => 10,
  199. 'tax_register' => 150.00,
  200. 'installments' => 1,
  201. 'enrollment_due_date' => '2026-08-05',
  202. 'down_payment_value' => 500.00,
  203. 'down_payment_date' => '2026-08-20',
  204. 'total_value' => 5843.00,
  205. 'total_installments' => 13,
  206. 'total_due_date' => '2026-09-10',
  207. ]);
  208. $total = $this->installmentsFor($contract->id, 'total');
  209. $this->assertEquals(411.00, $total->first()->value); // (5843-500)/13
  210. // Matrícula não é afetada pela entrada.
  211. $enrollment = $this->installmentsFor($contract->id, 'enrollment');
  212. $this->assertEquals(150.00, $enrollment->first()->value);
  213. }
  214. // --------------------------------------------------- datas (buildInstallmentDates)
  215. /**
  216. * Regra: A partir da 2ª parcela, usa-se o dia recorrente configurado; se o mês
  217. * for mais curto que esse dia (ex.: fevereiro não tem dia 31), cai no último
  218. * dia do mês.
  219. */
  220. public function test_parcela_com_dia_recorrente_31_cai_no_ultimo_dia_de_fevereiro(): void
  221. {
  222. $student = $this->makeStudent();
  223. $contract = $this->service->create([
  224. 'student_id' => $student->id,
  225. 'unit_id' => $this->unit->id,
  226. 'recurring_day' => 31,
  227. 'package_value' => 400.00,
  228. 'package_installments' => 4,
  229. 'package_due_date' => '2026-01-31', // jan, fev, mar, abr
  230. ]);
  231. $dates = $this->installmentsFor($contract->id, 'package')->pluck('due_date')
  232. ->map(fn ($d) => $d->format('Y-m-d'))->values();
  233. $this->assertSame([
  234. '2026-01-31',
  235. '2026-02-28', // 2026 não é bissexto
  236. '2026-03-31',
  237. '2026-04-30', // abril só tem 30 dias
  238. ], $dates->toArray());
  239. }
  240. /**
  241. * Regra: Sem recurring_day informado, o fallback é o dia 1.
  242. */
  243. public function test_sem_dia_recorrente_informado_usa_dia_1_a_partir_da_segunda_parcela(): void
  244. {
  245. $student = $this->makeStudent();
  246. $contract = $this->service->create([
  247. 'student_id' => $student->id,
  248. 'unit_id' => $this->unit->id,
  249. 'package_value' => 200.00,
  250. 'package_installments' => 2,
  251. 'package_due_date' => '2026-05-15',
  252. ]);
  253. $dates = $this->installmentsFor($contract->id, 'package')->pluck('due_date')
  254. ->map(fn ($d) => $d->format('Y-m-d'))->values();
  255. $this->assertSame(['2026-05-15', '2026-06-01'], $dates->toArray());
  256. }
  257. // --------------------------------------------------------------- versionamento
  258. /**
  259. * Regra: Editar um contrato cria uma nova versão e realoca as parcelas da
  260. * versão antiga para a nova — a "conta a receber" continua existindo, só
  261. * muda de dono (student_contract_id).
  262. */
  263. public function test_editar_contrato_realoca_parcelas_para_a_nova_versao(): void
  264. {
  265. $student = $this->makeStudent();
  266. $original = $this->service->create([
  267. 'student_id' => $student->id,
  268. 'unit_id' => $this->unit->id,
  269. 'package_value' => 1200.00,
  270. 'package_installments' => 12,
  271. 'package_due_date' => '2026-08-10',
  272. ]);
  273. $newVersion = $this->service->update($original->id, ['package_value' => 1200.00, 'notes' => 'Ajuste']);
  274. $this->assertNotSame($original->id, $newVersion->id);
  275. $this->assertSame(0, StudentContractInstallment::where('student_contract_id', $original->id)->count());
  276. $this->assertSame(12, StudentContractInstallment::where('student_contract_id', $newVersion->id)->count());
  277. }
  278. // -------------------------------------------------------------------- freeze
  279. /**
  280. * Regra: Congelar o contrato por N meses empurra o vencimento das parcelas
  281. * pendentes em N meses — parcelas já pagas não são tocadas.
  282. */
  283. public function test_congelar_contrato_adia_vencimento_das_parcelas_pendentes(): void
  284. {
  285. $student = $this->makeStudent();
  286. $contract = $this->service->create([
  287. 'student_id' => $student->id,
  288. 'unit_id' => $this->unit->id,
  289. 'recurring_day' => 10,
  290. 'package_value' => 300.00,
  291. 'package_installments' => 3,
  292. 'package_due_date' => '2026-08-10',
  293. ]);
  294. $installments = $this->installmentsFor($contract->id, 'package');
  295. $installments->first()->update(['status' => 'paid', 'paid_value' => 100, 'payment_date' => '2026-08-10']);
  296. // freeze() muda o status, o que por si só já cria uma nova versão do
  297. // contrato (update() versiona sempre que há campo alterado) e realoca
  298. // as parcelas para ela — por isso consultamos pelo id retornado, não
  299. // pelo id original.
  300. $frozen = $this->service->freeze($contract->id, 2);
  301. $refreshed = StudentContractInstallment::where('student_contract_id', $frozen->id)
  302. ->orderBy('installment_number')->get();
  303. // Parcela paga não muda.
  304. $this->assertSame('2026-08-10', $refreshed[0]->due_date->format('Y-m-d'));
  305. // Parcelas pendentes andam 2 meses.
  306. $this->assertSame('2026-11-10', $refreshed[1]->due_date->format('Y-m-d'));
  307. $this->assertSame('2026-12-10', $refreshed[2]->due_date->format('Y-m-d'));
  308. }
  309. }