@hed-hog/finance 0.0.300 → 0.0.301

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 (39) 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/frontend/app/_components/finance-layout.tsx.ejs +108 -0
  13. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +91 -106
  14. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1306 -1145
  15. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +288 -268
  16. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +491 -351
  17. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +157 -173
  18. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +44 -62
  19. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +62 -80
  20. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +151 -170
  21. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +332 -286
  22. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +204 -226
  23. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +122 -140
  24. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +32 -49
  25. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +84 -108
  26. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +53 -70
  27. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +98 -95
  28. package/hedhog/frontend/app/reports/actual-vs-forecast/page.tsx.ejs +100 -125
  29. package/hedhog/frontend/app/reports/aging-default/page.tsx.ejs +77 -105
  30. package/hedhog/frontend/app/reports/cash-position/page.tsx.ejs +99 -134
  31. package/hedhog/frontend/app/reports/overview-results/page.tsx.ejs +147 -182
  32. package/hedhog/frontend/app/reports/top-customers/page.tsx.ejs +49 -61
  33. package/hedhog/frontend/app/reports/top-operational-expenses/page.tsx.ejs +49 -67
  34. package/hedhog/frontend/messages/en.json +176 -68
  35. package/hedhog/frontend/messages/pt.json +176 -68
  36. package/package.json +7 -6
  37. package/src/finance.contract-activated.subscriber.spec.ts +392 -0
  38. package/src/finance.contract-activated.subscriber.ts +780 -0
  39. 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
+ }