@hed-hog/finance 0.0.300 → 0.0.302

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/finance.contract-activated.subscriber.d.ts +24 -0
  2. package/dist/finance.contract-activated.subscriber.d.ts.map +1 -0
  3. package/dist/finance.contract-activated.subscriber.js +519 -0
  4. package/dist/finance.contract-activated.subscriber.js.map +1 -0
  5. package/dist/finance.contract-activated.subscriber.spec.d.ts +2 -0
  6. package/dist/finance.contract-activated.subscriber.spec.d.ts.map +1 -0
  7. package/dist/finance.contract-activated.subscriber.spec.js +302 -0
  8. package/dist/finance.contract-activated.subscriber.spec.js.map +1 -0
  9. package/dist/finance.module.d.ts.map +1 -1
  10. package/dist/finance.module.js +6 -1
  11. package/dist/finance.module.js.map +1 -1
  12. package/hedhog/data/menu.yaml +0 -17
  13. package/hedhog/frontend/app/_components/finance-layout.tsx.ejs +108 -0
  14. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +51 -69
  15. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1312 -1138
  16. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +288 -268
  17. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +1175 -1016
  18. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +157 -173
  19. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +44 -62
  20. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +62 -80
  21. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +151 -170
  22. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +369 -322
  23. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +204 -226
  24. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +122 -140
  25. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +31 -49
  26. package/hedhog/frontend/app/page.tsx.ejs +3 -370
  27. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +150 -182
  28. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +52 -70
  29. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +101 -95
  30. package/hedhog/frontend/app/reports/actual-vs-forecast/page.tsx.ejs +100 -125
  31. package/hedhog/frontend/app/reports/aging-default/page.tsx.ejs +77 -105
  32. package/hedhog/frontend/app/reports/cash-position/page.tsx.ejs +99 -134
  33. package/hedhog/frontend/app/reports/overview-results/page.tsx.ejs +147 -182
  34. package/hedhog/frontend/app/reports/top-customers/page.tsx.ejs +49 -61
  35. package/hedhog/frontend/app/reports/top-operational-expenses/page.tsx.ejs +49 -67
  36. package/hedhog/frontend/messages/en.json +176 -68
  37. package/hedhog/frontend/messages/pt.json +176 -68
  38. package/package.json +6 -5
  39. package/src/finance.contract-activated.subscriber.spec.ts +392 -0
  40. package/src/finance.contract-activated.subscriber.ts +780 -0
  41. package/src/finance.module.ts +6 -1
@@ -0,0 +1,780 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import {
3
+ IntegrationDeveloperApiService,
4
+ LinkType,
5
+ } from '@hed-hog/core';
6
+ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
7
+ import { FinanceService } from './finance.service';
8
+
9
+ type ContractFinanceEventPayload = {
10
+ contractId?: number | string | null;
11
+ proposalId?: number | null;
12
+ personId?: number | null;
13
+ locale?: string | null;
14
+ correlationId?: string | null;
15
+ sourceEntityType?: string | null;
16
+ sourceEntityId?: string | null;
17
+ sourceEntity?: string | null;
18
+ sourceId?: string | null;
19
+ source_module?: string | null;
20
+ source_entity?: string | null;
21
+ source_id?: string | null;
22
+ activatedByUserId?: number | null;
23
+ signedByUserId?: number | null;
24
+ contract?: {
25
+ code?: string | null;
26
+ name?: string | null;
27
+ description?: string | null;
28
+ contractCategory?: string | null;
29
+ contractType?: string | null;
30
+ billingModel?: string | null;
31
+ startDate?: string | null;
32
+ endDate?: string | null;
33
+ signedAt?: string | null;
34
+ effectiveDate?: string | null;
35
+ financialTerms?: Array<{
36
+ label?: string | null;
37
+ termType?: string | null;
38
+ amount?: number | null;
39
+ recurrence?: string | null;
40
+ dueDay?: number | null;
41
+ notes?: string | null;
42
+ }>;
43
+ } | null;
44
+ person?: {
45
+ id?: number | null;
46
+ name?: string | null;
47
+ } | null;
48
+ receivable?: {
49
+ personId?: number | null;
50
+ totalAmount?: number | null;
51
+ documentNumber?: string | null;
52
+ description?: string | null;
53
+ financeCategoryId?: number | null;
54
+ costCenterId?: number | null;
55
+ installments?: Array<{
56
+ id?: number | string | null;
57
+ installmentNumber?: number | null;
58
+ installment_number?: number | null;
59
+ numero?: number | null;
60
+ dueDate?: string | null;
61
+ due_date?: string | null;
62
+ vencimento?: string | null;
63
+ amount?: number | null;
64
+ valor?: number | null;
65
+ }>;
66
+ } | null;
67
+ };
68
+
69
+ @Injectable()
70
+ export class FinanceContractActivatedSubscriber implements OnModuleInit {
71
+ private readonly logger = new Logger(FinanceContractActivatedSubscriber.name);
72
+
73
+ constructor(
74
+ private readonly integrationApi: IntegrationDeveloperApiService,
75
+ private readonly financeService: FinanceService,
76
+ private readonly prisma: PrismaService,
77
+ ) {}
78
+
79
+ onModuleInit(): void {
80
+ const handler = async (event: any) => {
81
+ const payload = (event.payload || {}) as ContractFinanceEventPayload;
82
+ const sourceEntityType =
83
+ String(
84
+ payload.sourceEntityType ||
85
+ payload.sourceEntity ||
86
+ payload.source_entity ||
87
+ event.aggregateType ||
88
+ '',
89
+ ).trim() || 'contract';
90
+ const sourceEntityId =
91
+ String(
92
+ payload.sourceEntityId ||
93
+ payload.sourceId ||
94
+ payload.source_id ||
95
+ payload.contractId ||
96
+ event.aggregateId ||
97
+ '',
98
+ ).trim() || String(event.aggregateId || '').trim();
99
+
100
+ if (!sourceEntityId) {
101
+ throw new Error(
102
+ `Missing contract aggregate id for ${event.eventName}.`,
103
+ );
104
+ }
105
+
106
+ const locale = String(payload.locale || '').trim() || 'en';
107
+ const correlationId =
108
+ String(payload.correlationId || `contract:${sourceEntityId}`).trim() ||
109
+ `contract:${sourceEntityId}`;
110
+ const personId = Number(
111
+ payload.receivable?.personId ?? payload.personId ?? payload.person?.id,
112
+ );
113
+
114
+ if (!Number.isInteger(personId) || personId <= 0) {
115
+ this.logger.warn(
116
+ `Skipping finance generation for ${sourceEntityType}:${sourceEntityId} because no personId was provided.`,
117
+ );
118
+ return;
119
+ }
120
+
121
+ const installments = this.buildInstallments(payload);
122
+ if (installments.length === 0) {
123
+ this.logger.warn(
124
+ `Skipping finance generation for ${sourceEntityType}:${sourceEntityId} because no installments could be derived.`,
125
+ );
126
+ return;
127
+ }
128
+
129
+ const totalAmount = Number(
130
+ installments
131
+ .reduce(
132
+ (sum, installment) => sum + Number(installment.amount || 0),
133
+ 0,
134
+ )
135
+ .toFixed(2),
136
+ );
137
+ const titleType = this.resolveTitleType(
138
+ payload.contract?.contractCategory,
139
+ payload.contract?.contractType,
140
+ );
141
+ const documentNumber =
142
+ String(
143
+ payload.receivable?.documentNumber ||
144
+ payload.contract?.code ||
145
+ `CONTRACT-${sourceEntityId}`,
146
+ ).trim() || `CONTRACT-${sourceEntityId}`;
147
+ const description =
148
+ String(
149
+ payload.receivable?.description ||
150
+ payload.contract?.name ||
151
+ payload.contract?.description ||
152
+ documentNumber,
153
+ ).trim() || documentNumber;
154
+ const actorUserId =
155
+ Number(payload.activatedByUserId ?? payload.signedByUserId ?? 0) ||
156
+ undefined;
157
+
158
+ const existingLinks = await this.integrationApi.findLinksBySource({
159
+ module: event.sourceModule,
160
+ entityType: sourceEntityType,
161
+ entityId: sourceEntityId,
162
+ });
163
+
164
+ const existingTitleLink = existingLinks.find(
165
+ (link) =>
166
+ link.targetModule === 'finance' &&
167
+ link.targetEntityType === 'financial_title',
168
+ );
169
+
170
+ const createPayload = {
171
+ person_id: personId,
172
+ document_number: documentNumber,
173
+ due_date: installments[0].due_date,
174
+ total_amount: totalAmount,
175
+ description,
176
+ finance_category_id: payload.receivable?.financeCategoryId ?? undefined,
177
+ cost_center_id: payload.receivable?.costCenterId ?? undefined,
178
+ installments,
179
+ };
180
+
181
+ let createdTitle = existingTitleLink?.targetEntityId
182
+ ? await this.loadGeneratedTitleById(existingTitleLink.targetEntityId)
183
+ : null;
184
+
185
+ if (!createdTitle) {
186
+ createdTitle = await this.findRecoverableTitle({
187
+ personId,
188
+ titleType,
189
+ documentNumber,
190
+ totalAmount,
191
+ });
192
+
193
+ if (createdTitle) {
194
+ this.logger.warn(
195
+ `Recovered existing ${titleType} title ${createdTitle.id} for ${sourceEntityType}:${sourceEntityId} after a partial integration write.`,
196
+ );
197
+ }
198
+ }
199
+
200
+ if (!createdTitle) {
201
+ createdTitle =
202
+ titleType === 'payable'
203
+ ? await this.financeService.createAccountsPayableTitle(
204
+ createPayload,
205
+ locale,
206
+ actorUserId,
207
+ )
208
+ : await this.financeService.createAccountsReceivableTitle(
209
+ createPayload,
210
+ locale,
211
+ actorUserId,
212
+ );
213
+ }
214
+
215
+ const targetEntityId = String(createdTitle?.id || '').trim();
216
+ if (!targetEntityId) {
217
+ throw new Error('Could not resolve created financial title id.');
218
+ }
219
+
220
+ const createdInstallments = this.extractCreatedInstallments(createdTitle);
221
+
222
+ await this.ensureContractFinanceLinks({
223
+ event,
224
+ existingLinks,
225
+ sourceEntityType,
226
+ sourceEntityId,
227
+ correlationId,
228
+ titleType,
229
+ targetEntityId,
230
+ documentNumber,
231
+ totalAmount,
232
+ personId,
233
+ contractCode: payload.contract?.code ?? null,
234
+ createdInstallments,
235
+ });
236
+
237
+ this.logger.log(
238
+ `Generated ${titleType} title ${targetEntityId} with ${createdInstallments.length || installments.length} installments from ${event.eventName} for ${sourceEntityType}:${sourceEntityId}`,
239
+ );
240
+
241
+ await this.publishCreatedEvent({
242
+ event,
243
+ payload,
244
+ sourceEntityType,
245
+ sourceEntityId,
246
+ correlationId,
247
+ titleType,
248
+ targetEntityId,
249
+ documentNumber,
250
+ totalAmount,
251
+ locale,
252
+ createdInstallments,
253
+ fallbackInstallments: installments,
254
+ });
255
+ };
256
+
257
+ this.integrationApi.subscribeMany([
258
+ {
259
+ eventName: 'operations.contract.signed',
260
+ consumerName: 'finance.contract-signing',
261
+ priority: 5,
262
+ handler,
263
+ },
264
+ {
265
+ eventName: 'operations.contract.activated',
266
+ consumerName: 'finance.contract-activation',
267
+ priority: 0,
268
+ handler,
269
+ },
270
+ ]);
271
+ }
272
+
273
+ private async publishCreatedEvent(params: {
274
+ event: any;
275
+ payload: ContractFinanceEventPayload;
276
+ sourceEntityType: string;
277
+ sourceEntityId: string;
278
+ correlationId: string;
279
+ titleType: 'payable' | 'receivable';
280
+ targetEntityId: string;
281
+ documentNumber: string;
282
+ totalAmount: number;
283
+ locale: string;
284
+ createdInstallments: Array<{
285
+ id: string;
286
+ installment_number: number;
287
+ due_date: string;
288
+ amount: number;
289
+ }>;
290
+ fallbackInstallments: Array<{
291
+ installment_number: number;
292
+ due_date: string;
293
+ amount: number;
294
+ }>;
295
+ }) {
296
+ const {
297
+ event,
298
+ payload,
299
+ sourceEntityType,
300
+ sourceEntityId,
301
+ correlationId,
302
+ titleType,
303
+ targetEntityId,
304
+ documentNumber,
305
+ totalAmount,
306
+ locale,
307
+ createdInstallments,
308
+ fallbackInstallments,
309
+ } = params;
310
+ const successEventName =
311
+ titleType === 'payable'
312
+ ? 'finance.payable.created_from_contract'
313
+ : 'finance.receivable.created_from_contract';
314
+
315
+ const recentEvents = await (this.prisma as any).outbox_event.findMany({
316
+ where: {
317
+ event_name: successEventName,
318
+ source_module: 'finance',
319
+ aggregate_type: 'financial_title',
320
+ aggregate_id: targetEntityId,
321
+ },
322
+ select: {
323
+ status: true,
324
+ payload: true,
325
+ },
326
+ orderBy: {
327
+ id: 'desc',
328
+ },
329
+ take: 10,
330
+ });
331
+
332
+ const alreadyQueued = recentEvents.some(
333
+ (queuedEvent: any) =>
334
+ queuedEvent?.payload?.correlationId === correlationId &&
335
+ queuedEvent?.payload?.triggerEvent === event.eventName &&
336
+ !['failed', 'dead_letter'].includes(
337
+ String(queuedEvent?.status || ''),
338
+ ),
339
+ );
340
+
341
+ if (alreadyQueued) {
342
+ this.logger.debug(
343
+ `Skipping duplicate ${successEventName} publication for ${sourceEntityType}:${sourceEntityId}`,
344
+ );
345
+ return;
346
+ }
347
+
348
+ const emittedInstallments =
349
+ createdInstallments.length > 0
350
+ ? createdInstallments
351
+ : fallbackInstallments.map((installment) => ({
352
+ id: '',
353
+ installment_number: installment.installment_number,
354
+ due_date: installment.due_date,
355
+ amount: installment.amount,
356
+ }));
357
+
358
+ try {
359
+ await this.integrationApi.publishEvent({
360
+ eventName: successEventName,
361
+ sourceModule: 'finance',
362
+ aggregateType: 'financial_title',
363
+ aggregateId: targetEntityId,
364
+ payload: {
365
+ correlationId,
366
+ contractId: payload.contractId ?? sourceEntityId,
367
+ proposalId: payload.proposalId ?? null,
368
+ personId:
369
+ Number(
370
+ payload.receivable?.personId ?? payload.personId ?? payload.person?.id,
371
+ ) || null,
372
+ locale,
373
+ generatedAt: new Date().toISOString(),
374
+ triggerEvent: event.eventName,
375
+ sourceModule: event.sourceModule,
376
+ sourceEntity: sourceEntityType,
377
+ sourceId: sourceEntityId,
378
+ source_module: event.sourceModule,
379
+ source_entity: sourceEntityType,
380
+ source_id: sourceEntityId,
381
+ financialTitle: {
382
+ id: targetEntityId,
383
+ titleType,
384
+ documentNumber,
385
+ totalAmount,
386
+ installmentCount: emittedInstallments.length,
387
+ },
388
+ installments: emittedInstallments,
389
+ contract: {
390
+ code: payload.contract?.code ?? null,
391
+ name: payload.contract?.name ?? null,
392
+ contractCategory: payload.contract?.contractCategory ?? null,
393
+ contractType: payload.contract?.contractType ?? null,
394
+ billingModel: payload.contract?.billingModel ?? null,
395
+ startDate: payload.contract?.startDate ?? null,
396
+ endDate: payload.contract?.endDate ?? null,
397
+ signedAt: payload.contract?.signedAt ?? null,
398
+ effectiveDate: payload.contract?.effectiveDate ?? null,
399
+ },
400
+ },
401
+ metadata: {
402
+ producer: 'finance',
403
+ correlationId,
404
+ triggerEvent: event.eventName,
405
+ titleType,
406
+ documentNumber,
407
+ totalAmount,
408
+ installmentCount: emittedInstallments.length,
409
+ sourceModule: event.sourceModule,
410
+ sourceEntity: sourceEntityType,
411
+ sourceId: sourceEntityId,
412
+ },
413
+ });
414
+ } catch (error) {
415
+ const errorMessage =
416
+ error instanceof Error ? error.stack || error.message : String(error);
417
+ this.logger.error(
418
+ `Failed to queue ${successEventName} for ${sourceEntityType}:${sourceEntityId}`,
419
+ errorMessage,
420
+ );
421
+ }
422
+ }
423
+
424
+ private resolveTitleType(
425
+ contractCategory?: string | null,
426
+ contractType?: string | null,
427
+ ): 'payable' | 'receivable' {
428
+ if (
429
+ ['employee', 'contractor', 'supplier', 'vendor', 'internal'].includes(
430
+ String(contractCategory || ''),
431
+ ) ||
432
+ ['clt', 'pj', 'freelancer_agreement'].includes(
433
+ String(contractType || ''),
434
+ )
435
+ ) {
436
+ return 'payable';
437
+ }
438
+
439
+ return 'receivable';
440
+ }
441
+
442
+ private buildInstallments(payload: ContractFinanceEventPayload) {
443
+ const explicitInstallments = Array.isArray(payload?.receivable?.installments)
444
+ ? payload.receivable.installments
445
+ .map((installment: any) => ({
446
+ installment_number: Number(
447
+ installment.installmentNumber ||
448
+ installment.installment_number ||
449
+ installment.numero ||
450
+ 0,
451
+ ),
452
+ due_date: this.formatDate(
453
+ this.toDate(
454
+ installment.dueDate ||
455
+ installment.due_date ||
456
+ installment.vencimento,
457
+ ) || new Date(),
458
+ ),
459
+ amount: Number(
460
+ Number(installment.amount ?? installment.valor ?? 0).toFixed(2),
461
+ ),
462
+ }))
463
+ .filter(
464
+ (installment: any) =>
465
+ installment.amount > 0 && !!installment.due_date,
466
+ )
467
+ : [];
468
+
469
+ if (explicitInstallments.length > 0) {
470
+ return explicitInstallments.map((installment: any, index: number) => ({
471
+ installment_number: installment.installment_number || index + 1,
472
+ due_date: installment.due_date,
473
+ amount: installment.amount,
474
+ }));
475
+ }
476
+
477
+ const baseDate =
478
+ this.toDate(payload?.contract?.effectiveDate) ||
479
+ this.toDate(payload?.contract?.signedAt) ||
480
+ this.toDate(payload?.contract?.startDate) ||
481
+ new Date();
482
+ const endDate = this.toDate(payload?.contract?.endDate);
483
+ const financialTerms = Array.isArray(payload?.contract?.financialTerms)
484
+ ? payload.contract?.financialTerms || []
485
+ : [];
486
+
487
+ const installments: Array<{
488
+ installment_number: number;
489
+ due_date: string;
490
+ amount: number;
491
+ }> = [];
492
+
493
+ for (const term of financialTerms) {
494
+ const amount = Number(term?.amount || 0);
495
+ if (!Number.isFinite(amount) || amount <= 0) {
496
+ continue;
497
+ }
498
+
499
+ const recurrence = String(term?.recurrence || 'one_time');
500
+ const dueDay = Number(term?.dueDay || 0);
501
+ const firstDueDate = this.resolveDueDate(baseDate, dueDay);
502
+ const stepMonths =
503
+ recurrence === 'monthly'
504
+ ? 1
505
+ : recurrence === 'quarterly'
506
+ ? 3
507
+ : recurrence === 'yearly'
508
+ ? 12
509
+ : 0;
510
+
511
+ if (stepMonths === 0 || !endDate) {
512
+ installments.push({
513
+ installment_number: installments.length + 1,
514
+ due_date: this.formatDate(firstDueDate),
515
+ amount: Number(amount.toFixed(2)),
516
+ });
517
+ continue;
518
+ }
519
+
520
+ let cursor = new Date(firstDueDate);
521
+ let guard = 0;
522
+ while (cursor <= endDate && guard < 60) {
523
+ installments.push({
524
+ installment_number: installments.length + 1,
525
+ due_date: this.formatDate(cursor),
526
+ amount: Number(amount.toFixed(2)),
527
+ });
528
+ cursor = this.addMonths(cursor, stepMonths, dueDay);
529
+ guard += 1;
530
+ }
531
+ }
532
+
533
+ if (installments.length === 0) {
534
+ const fallbackAmount = Number(payload?.receivable?.totalAmount || 0);
535
+ if (Number.isFinite(fallbackAmount) && fallbackAmount > 0) {
536
+ installments.push({
537
+ installment_number: 1,
538
+ due_date: this.formatDate(this.resolveDueDate(baseDate, 0)),
539
+ amount: Number(fallbackAmount.toFixed(2)),
540
+ });
541
+ }
542
+ }
543
+
544
+ return installments;
545
+ }
546
+
547
+ private async ensureContractFinanceLinks(params: {
548
+ event: any;
549
+ existingLinks: any[];
550
+ sourceEntityType: string;
551
+ sourceEntityId: string;
552
+ correlationId: string;
553
+ titleType: 'payable' | 'receivable';
554
+ targetEntityId: string;
555
+ documentNumber: string;
556
+ totalAmount: number;
557
+ personId: number;
558
+ contractCode?: string | null;
559
+ createdInstallments: Array<{
560
+ id: string;
561
+ installment_number: number;
562
+ due_date: string;
563
+ amount: number;
564
+ }>;
565
+ }) {
566
+ const {
567
+ event,
568
+ existingLinks,
569
+ sourceEntityType,
570
+ sourceEntityId,
571
+ correlationId,
572
+ titleType,
573
+ targetEntityId,
574
+ documentNumber,
575
+ totalAmount,
576
+ personId,
577
+ contractCode,
578
+ createdInstallments,
579
+ } = params;
580
+ const existingKeys = new Set(
581
+ (existingLinks || []).map(
582
+ (link) =>
583
+ `${link.targetModule}:${link.targetEntityType}:${link.targetEntityId}`,
584
+ ),
585
+ );
586
+
587
+ const titleKey = `finance:financial_title:${targetEntityId}`;
588
+ if (!existingKeys.has(titleKey)) {
589
+ await this.integrationApi.createLink({
590
+ sourceModule: event.sourceModule,
591
+ sourceEntityType,
592
+ sourceEntityId,
593
+ targetModule: 'finance',
594
+ targetEntityType: 'financial_title',
595
+ targetEntityId,
596
+ linkType: LinkType.REFERENCE,
597
+ metadata: {
598
+ eventName: event.eventName,
599
+ correlationId,
600
+ titleType,
601
+ documentNumber,
602
+ totalAmount,
603
+ installmentCount: createdInstallments.length,
604
+ personId,
605
+ contractCode: contractCode ?? null,
606
+ sourceModule: event.sourceModule,
607
+ sourceEntity: sourceEntityType,
608
+ sourceId: sourceEntityId,
609
+ source_module: event.sourceModule,
610
+ source_entity: sourceEntityType,
611
+ source_id: sourceEntityId,
612
+ },
613
+ });
614
+ existingKeys.add(titleKey);
615
+ }
616
+
617
+ for (const installment of createdInstallments) {
618
+ if (!installment.id) {
619
+ continue;
620
+ }
621
+
622
+ const installmentKey = `finance:financial_installment:${installment.id}`;
623
+ if (existingKeys.has(installmentKey)) {
624
+ continue;
625
+ }
626
+
627
+ await this.integrationApi.createLink({
628
+ sourceModule: event.sourceModule,
629
+ sourceEntityType,
630
+ sourceEntityId,
631
+ targetModule: 'finance',
632
+ targetEntityType: 'financial_installment',
633
+ targetEntityId: installment.id,
634
+ linkType: LinkType.REFERENCE,
635
+ metadata: {
636
+ eventName: event.eventName,
637
+ correlationId,
638
+ titleId: targetEntityId,
639
+ installmentNumber: installment.installment_number,
640
+ dueDate: installment.due_date,
641
+ amount: installment.amount,
642
+ sourceModule: event.sourceModule,
643
+ sourceEntity: sourceEntityType,
644
+ sourceId: sourceEntityId,
645
+ source_module: event.sourceModule,
646
+ source_entity: sourceEntityType,
647
+ source_id: sourceEntityId,
648
+ },
649
+ });
650
+ existingKeys.add(installmentKey);
651
+ }
652
+ }
653
+
654
+ private async loadGeneratedTitleById(titleId: string) {
655
+ const normalizedId = Number(titleId || 0);
656
+
657
+ if (!Number.isInteger(normalizedId) || normalizedId <= 0) {
658
+ return null;
659
+ }
660
+
661
+ return (this.prisma as any).financial_title.findFirst({
662
+ where: {
663
+ id: normalizedId,
664
+ },
665
+ include: {
666
+ financial_installment: {
667
+ orderBy: {
668
+ installment_number: 'asc',
669
+ },
670
+ },
671
+ },
672
+ });
673
+ }
674
+
675
+ private async findRecoverableTitle(params: {
676
+ personId: number;
677
+ titleType: 'payable' | 'receivable';
678
+ documentNumber: string;
679
+ totalAmount: number;
680
+ }) {
681
+ const expectedAmountCents = Math.round(Number(params.totalAmount || 0) * 100);
682
+
683
+ if (expectedAmountCents <= 0) {
684
+ return null;
685
+ }
686
+
687
+ return (this.prisma as any).financial_title.findFirst({
688
+ where: {
689
+ person_id: params.personId,
690
+ title_type: params.titleType,
691
+ document_number: params.documentNumber,
692
+ total_amount_cents: expectedAmountCents,
693
+ },
694
+ orderBy: {
695
+ id: 'desc',
696
+ },
697
+ include: {
698
+ financial_installment: {
699
+ orderBy: {
700
+ installment_number: 'asc',
701
+ },
702
+ },
703
+ },
704
+ });
705
+ }
706
+
707
+ private extractCreatedInstallments(createdTitle: any) {
708
+ if (Array.isArray(createdTitle?.parcelas)) {
709
+ return createdTitle.parcelas
710
+ .map((installment: any) => ({
711
+ id: String(installment.id || '').trim(),
712
+ installment_number: Number(
713
+ installment.numero || installment.installment_number || 0,
714
+ ),
715
+ due_date: String(
716
+ installment.vencimento || installment.due_date || '',
717
+ ).trim(),
718
+ amount: Number(installment.valor ?? installment.amount ?? 0),
719
+ }))
720
+ .filter((installment) => installment.id);
721
+ }
722
+
723
+ return Array.isArray(createdTitle?.financial_installment)
724
+ ? createdTitle.financial_installment
725
+ .map((installment: any) => ({
726
+ id: String(installment.id || '').trim(),
727
+ installment_number: Number(installment.installment_number || 0),
728
+ due_date: this.formatDate(this.toDate(installment.due_date) || new Date()),
729
+ amount: Number(
730
+ (Number(installment.amount_cents || 0) / 100).toFixed(2),
731
+ ),
732
+ }))
733
+ .filter((installment) => installment.id)
734
+ : [];
735
+ }
736
+
737
+ private toDate(value?: string | Date | null) {
738
+ if (!value) {
739
+ return null;
740
+ }
741
+
742
+ if (value instanceof Date) {
743
+ return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
744
+ }
745
+
746
+ const normalized = String(value).trim();
747
+ const dateOnlyMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(normalized);
748
+
749
+ if (dateOnlyMatch) {
750
+ const [, year, month, day] = dateOnlyMatch;
751
+ return new Date(
752
+ Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0),
753
+ );
754
+ }
755
+
756
+ const parsed = new Date(normalized);
757
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
758
+ }
759
+
760
+ private resolveDueDate(baseDate: Date, dueDay?: number) {
761
+ const resolved = new Date(baseDate.getTime());
762
+ if (Number.isInteger(dueDay) && dueDay > 0 && dueDay <= 28) {
763
+ resolved.setUTCDate(dueDay);
764
+ }
765
+ return resolved;
766
+ }
767
+
768
+ private addMonths(date: Date, months: number, dueDay?: number) {
769
+ const resolved = new Date(date.getTime());
770
+ resolved.setUTCMonth(resolved.getUTCMonth() + months);
771
+ if (Number.isInteger(dueDay) && dueDay > 0 && dueDay <= 28) {
772
+ resolved.setUTCDate(dueDay);
773
+ }
774
+ return resolved;
775
+ }
776
+
777
+ private formatDate(date: Date) {
778
+ return date.toISOString().slice(0, 10);
779
+ }
780
+ }