@hed-hog/finance 0.0.252 → 0.0.256

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 (32) hide show
  1. package/dist/dto/reverse-settlement.dto.d.ts +1 -0
  2. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -1
  3. package/dist/dto/reverse-settlement.dto.js +5 -0
  4. package/dist/dto/reverse-settlement.dto.js.map +1 -1
  5. package/dist/finance-installments.controller.d.ts +106 -4
  6. package/dist/finance-installments.controller.d.ts.map +1 -1
  7. package/dist/finance-installments.controller.js +38 -2
  8. package/dist/finance-installments.controller.js.map +1 -1
  9. package/dist/finance.service.d.ts +104 -2
  10. package/dist/finance.service.d.ts.map +1 -1
  11. package/dist/finance.service.js +366 -121
  12. package/dist/finance.service.js.map +1 -1
  13. package/hedhog/data/route.yaml +27 -0
  14. package/hedhog/frontend/app/_components/finance-entity-field-with-create.tsx.ejs +572 -0
  15. package/hedhog/frontend/app/_components/finance-title-actions-menu.tsx.ejs +244 -0
  16. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +143 -51
  17. package/hedhog/frontend/app/_lib/title-action-rules.ts.ejs +36 -0
  18. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +449 -293
  19. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1189 -545
  20. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +176 -133
  21. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +1459 -312
  22. package/hedhog/frontend/app/page.tsx.ejs +15 -4
  23. package/hedhog/frontend/messages/en.json +294 -5
  24. package/hedhog/frontend/messages/pt.json +294 -5
  25. package/hedhog/query/settlement-auditability.sql +175 -0
  26. package/hedhog/table/bank_reconciliation.yaml +11 -0
  27. package/hedhog/table/settlement.yaml +17 -1
  28. package/hedhog/table/settlement_allocation.yaml +3 -0
  29. package/package.json +7 -7
  30. package/src/dto/reverse-settlement.dto.ts +4 -0
  31. package/src/finance-installments.controller.ts +45 -12
  32. package/src/finance.service.ts +521 -146
@@ -1,15 +1,19 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
- import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
2
+ import {
3
+ PageOrderDirection,
4
+ PaginationDTO,
5
+ PaginationService,
6
+ } from '@hed-hog/api-pagination';
3
7
  import { PrismaService } from '@hed-hog/api-prisma';
4
8
  import { AiService, FileService } from '@hed-hog/core';
5
9
  import {
6
- BadRequestException,
7
- ConflictException,
8
- forwardRef,
9
- Inject,
10
- Injectable,
11
- Logger,
12
- NotFoundException,
10
+ BadRequestException,
11
+ ConflictException,
12
+ forwardRef,
13
+ Inject,
14
+ Injectable,
15
+ Logger,
16
+ NotFoundException,
13
17
  } from '@nestjs/common';
14
18
  import { createHash } from 'node:crypto';
15
19
  import { readFile } from 'node:fs/promises';
@@ -1067,6 +1071,192 @@ export class FinanceService {
1067
1071
  );
1068
1072
  }
1069
1073
 
1074
+ async getTitleSettlementsHistory(titleId: number, locale: string) {
1075
+ const title = await this.prisma.financial_title.findUnique({
1076
+ where: { id: titleId },
1077
+ select: { id: true },
1078
+ });
1079
+
1080
+ if (!title) {
1081
+ throw new NotFoundException(
1082
+ getLocaleText(
1083
+ 'itemNotFound',
1084
+ locale,
1085
+ `Financial title with ID ${titleId} not found`,
1086
+ ).replace('{{item}}', 'Financial title'),
1087
+ );
1088
+ }
1089
+
1090
+ const rows = await this.prisma.$queryRaw<
1091
+ Array<{
1092
+ normal_id: number;
1093
+ normal_paid_at: Date;
1094
+ normal_amount_cents: number;
1095
+ normal_method: string | null;
1096
+ normal_account_id: number | null;
1097
+ normal_account_name: string | null;
1098
+ normal_created_at: Date;
1099
+ normal_created_by: string | null;
1100
+ normal_memo: string | null;
1101
+ reconciliation_id: number | null;
1102
+ reconciliation_status: string | null;
1103
+ installment_id: number;
1104
+ installment_seq: number;
1105
+ allocation_amount_cents: number;
1106
+ reversal_id: number | null;
1107
+ reversal_paid_at: Date | null;
1108
+ reversal_amount_cents: number | null;
1109
+ reversal_created_at: Date | null;
1110
+ reversal_created_by: string | null;
1111
+ reversal_memo: string | null;
1112
+ }>
1113
+ >`
1114
+ SELECT
1115
+ s.id AS normal_id,
1116
+ s.settled_at AS normal_paid_at,
1117
+ s.amount_cents AS normal_amount_cents,
1118
+ pm.type::text AS normal_method,
1119
+ s.bank_account_id AS normal_account_id,
1120
+ ba.name AS normal_account_name,
1121
+ s.created_at AS normal_created_at,
1122
+ u.name AS normal_created_by,
1123
+ s.description AS normal_memo,
1124
+ br.id AS reconciliation_id,
1125
+ br.status::text AS reconciliation_status,
1126
+ fi.id AS installment_id,
1127
+ fi.installment_number AS installment_seq,
1128
+ COALESCE(sa.amount_cents, sa.allocated_amount_cents) AS allocation_amount_cents,
1129
+ r.id AS reversal_id,
1130
+ r.settled_at AS reversal_paid_at,
1131
+ r.amount_cents AS reversal_amount_cents,
1132
+ r.created_at AS reversal_created_at,
1133
+ ur.name AS reversal_created_by,
1134
+ r.description AS reversal_memo
1135
+ FROM settlement s
1136
+ INNER JOIN settlement_allocation sa ON sa.settlement_id = s.id
1137
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
1138
+ LEFT JOIN payment_method pm ON pm.id = s.payment_method_id
1139
+ LEFT JOIN bank_account ba ON ba.id = s.bank_account_id
1140
+ LEFT JOIN "user" u ON u.id = s.created_by_user_id
1141
+ LEFT JOIN bank_reconciliation br ON br.settlement_id = s.id
1142
+ LEFT JOIN settlement r ON r.reverses_settlement_id = s.id
1143
+ LEFT JOIN "user" ur ON ur.id = r.created_by_user_id
1144
+ WHERE fi.title_id = ${titleId}
1145
+ AND COALESCE(s.entry_type::text, 'normal') = 'normal'
1146
+ ORDER BY s.settled_at DESC, s.id DESC, fi.installment_number ASC
1147
+ `;
1148
+
1149
+ const groups = new Map<string, any>();
1150
+
1151
+ for (const row of rows) {
1152
+ const key = String(row.normal_id);
1153
+ const existing = groups.get(key);
1154
+
1155
+ if (!existing) {
1156
+ groups.set(key, {
1157
+ normal: {
1158
+ id: key,
1159
+ paidAt: row.normal_paid_at?.toISOString?.() || null,
1160
+ amountCents: Number(row.normal_amount_cents || 0),
1161
+ type: 'NORMAL',
1162
+ method: this.mapPaymentMethodToPt(row.normal_method) || row.normal_method,
1163
+ account: row.normal_account_name || null,
1164
+ accountId: row.normal_account_id
1165
+ ? String(row.normal_account_id)
1166
+ : null,
1167
+ createdAt: row.normal_created_at?.toISOString?.() || null,
1168
+ createdBy: row.normal_created_by || null,
1169
+ memo: row.normal_memo || null,
1170
+ reconciled: row.reconciliation_status === 'reconciled',
1171
+ reconciliationId: row.reconciliation_id
1172
+ ? String(row.reconciliation_id)
1173
+ : null,
1174
+ },
1175
+ reversal: row.reversal_id
1176
+ ? {
1177
+ id: String(row.reversal_id),
1178
+ paidAt: row.reversal_paid_at?.toISOString?.() || null,
1179
+ amountCents: Number(row.reversal_amount_cents || 0),
1180
+ type: 'REVERSAL',
1181
+ createdAt: row.reversal_created_at?.toISOString?.() || null,
1182
+ createdBy: row.reversal_created_by || null,
1183
+ memo: row.reversal_memo || null,
1184
+ }
1185
+ : null,
1186
+ allocations: [],
1187
+ statusLabel: row.reversal_id ? 'ESTORNADO' : 'ATIVO',
1188
+ });
1189
+ }
1190
+
1191
+ groups.get(key).allocations.push({
1192
+ installmentId: String(row.installment_id),
1193
+ installmentSeq: Number(row.installment_seq || 0),
1194
+ amountCents: Number(row.allocation_amount_cents || 0),
1195
+ });
1196
+ }
1197
+
1198
+ return Array.from(groups.values());
1199
+ }
1200
+
1201
+ async reverseSettlementById(
1202
+ settlementId: number,
1203
+ data: ReverseSettlementDto,
1204
+ locale: string,
1205
+ userId?: number,
1206
+ ) {
1207
+ const updatedTitle = await this.reverseSettlementInternal(
1208
+ settlementId,
1209
+ data,
1210
+ locale,
1211
+ userId,
1212
+ );
1213
+
1214
+ return this.mapTitleToFront(updatedTitle);
1215
+ }
1216
+
1217
+ async unreconcileBankReconciliation(id: number, userId?: number) {
1218
+ const reconciliation = await this.prisma.bank_reconciliation.findUnique({
1219
+ where: { id },
1220
+ select: {
1221
+ id: true,
1222
+ settlement_id: true,
1223
+ bank_statement_line_id: true,
1224
+ },
1225
+ });
1226
+
1227
+ if (!reconciliation) {
1228
+ throw new NotFoundException('Conciliação bancária não encontrada');
1229
+ }
1230
+
1231
+ await this.prisma.$transaction(async (tx) => {
1232
+ await tx.bank_reconciliation.delete({
1233
+ where: { id: reconciliation.id },
1234
+ });
1235
+
1236
+ await tx.bank_statement_line.updateMany({
1237
+ where: {
1238
+ id: reconciliation.bank_statement_line_id,
1239
+ status: {
1240
+ in: ['reconciled', 'adjusted'],
1241
+ },
1242
+ },
1243
+ data: {
1244
+ status: 'pending',
1245
+ },
1246
+ });
1247
+
1248
+ await this.createAuditLog(tx, {
1249
+ action: 'UNRECONCILE_SETTLEMENT',
1250
+ entityTable: 'bank_reconciliation',
1251
+ entityId: String(reconciliation.id),
1252
+ actorUserId: userId,
1253
+ summary: `Unreconciled settlement ${reconciliation.settlement_id}`,
1254
+ });
1255
+ });
1256
+
1257
+ return { success: true };
1258
+ }
1259
+
1070
1260
  async createTag(data: CreateFinanceTagDto) {
1071
1261
  const slug = this.normalizeTagSlug(data.name);
1072
1262
 
@@ -2213,6 +2403,7 @@ export class FinanceService {
2213
2403
  status?: string,
2214
2404
  ) {
2215
2405
  const prismaStatus = this.mapStatusFromPt(status);
2406
+ const search = paginationParams?.search?.trim();
2216
2407
  const where: any = {
2217
2408
  title_type: titleType,
2218
2409
  };
@@ -2221,15 +2412,45 @@ export class FinanceService {
2221
2412
  where.status = prismaStatus;
2222
2413
  }
2223
2414
 
2224
- return this.paginationService.paginate(
2415
+ if (search) {
2416
+ where.OR = [
2417
+ {
2418
+ document_number: {
2419
+ contains: search,
2420
+ mode: 'insensitive',
2421
+ },
2422
+ },
2423
+ {
2424
+ person: {
2425
+ name: {
2426
+ contains: search,
2427
+ mode: 'insensitive',
2428
+ },
2429
+ },
2430
+ },
2431
+ ];
2432
+ }
2433
+
2434
+ const normalizedPaginationParams: PaginationDTO = {
2435
+ ...paginationParams,
2436
+ sortField: paginationParams?.sortField || 'created_at',
2437
+ sortOrder: paginationParams?.sortOrder || PageOrderDirection.Desc,
2438
+ };
2439
+
2440
+ const paginated = await this.paginationService.paginate(
2225
2441
  this.prisma.financial_title,
2226
- paginationParams,
2442
+ normalizedPaginationParams,
2227
2443
  {
2228
2444
  where,
2229
2445
  include: this.defaultTitleInclude(),
2230
2446
  orderBy: { created_at: 'desc' },
2231
2447
  },
2232
2448
  );
2449
+
2450
+ return {
2451
+ ...paginated,
2452
+ data: (paginated.data || []).map((title) => this.mapTitleToFront(title)),
2453
+ };
2233
2454
  }
2234
2455
 
2235
2456
  private async getTitleById(id: number, titleType: TitleType, locale: string) {
@@ -2914,21 +3135,20 @@ export class FinanceService {
2914
3135
  throw new BadRequestException('Title cannot be canceled in current status');
2915
3136
  }
2916
3137
 
2917
- const hasActiveSettlements = await tx.settlement_allocation.findFirst({
2918
- where: {
2919
- financial_installment: {
2920
- title_id: title.id,
2921
- },
2922
- settlement: {
2923
- status: {
2924
- not: 'reversed',
2925
- },
2926
- },
2927
- },
2928
- select: {
2929
- id: true,
2930
- },
2931
- });
3138
+ const activeSettlements = await tx.$queryRaw<Array<{ has_active: boolean }>>`
3139
+ SELECT EXISTS (
3140
+ SELECT 1
3141
+ FROM settlement_allocation sa
3142
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
3143
+ INNER JOIN settlement s ON s.id = sa.settlement_id
3144
+ LEFT JOIN settlement r ON r.reverses_settlement_id = s.id
3145
+ WHERE fi.title_id = ${title.id}
3146
+ AND COALESCE(s.entry_type::text, 'normal') = 'normal'
3147
+ AND r.id IS NULL
3148
+ ) AS has_active
3149
+ `;
3150
+
3151
+ const hasActiveSettlements = activeSettlements[0]?.has_active;
2932
3152
 
2933
3153
  if (hasActiveSettlements) {
2934
3154
  throw new ConflictException(
@@ -3101,9 +3321,18 @@ export class FinanceService {
3101
3321
 
3102
3322
  await tx.settlement_allocation.create({
3103
3323
  data: {
3104
- settlement_id: settlement.id,
3105
- installment_id: installment.id,
3324
+ settlement: {
3325
+ connect: {
3326
+ id: settlement.id,
3327
+ },
3328
+ },
3329
+ financial_installment: {
3330
+ connect: {
3331
+ id: installment.id,
3332
+ },
3333
+ },
3106
3334
  allocated_amount_cents: amountCents,
3335
+ amount_cents: amountCents,
3107
3336
  discount_cents: this.toCents(data.discount || 0),
3108
3337
  interest_cents: this.toCents(data.interest || 0),
3109
3338
  penalty_cents: this.toCents(data.penalty || 0),
@@ -3227,97 +3456,251 @@ export class FinanceService {
3227
3456
  locale: string,
3228
3457
  userId?: number,
3229
3458
  ) {
3230
- const updatedTitle = await this.prisma.$transaction(async (tx) => {
3231
- const title = await tx.financial_title.findFirst({
3232
- where: {
3233
- id: titleId,
3234
- title_type: titleType,
3235
- },
3236
- select: {
3237
- id: true,
3238
- status: true,
3239
- competence_date: true,
3240
- },
3241
- });
3459
+ const updatedTitle = await this.reverseSettlementInternal(
3460
+ settlementId,
3461
+ data,
3462
+ locale,
3463
+ userId,
3464
+ {
3465
+ titleId,
3466
+ titleType,
3467
+ },
3468
+ );
3242
3469
 
3243
- if (!title) {
3244
- throw new NotFoundException(
3245
- getLocaleText(
3246
- 'itemNotFound',
3247
- locale,
3248
- `Financial title with ID ${titleId} not found`,
3249
- ).replace('{{item}}', 'Financial title'),
3250
- );
3470
+ if (!updatedTitle) {
3471
+ throw new NotFoundException('Financial title not found');
3472
+ }
3473
+
3474
+ return this.mapTitleToFront(updatedTitle);
3475
+ }
3476
+
3477
+ private async reverseSettlementInternal(
3478
+ settlementId: number,
3479
+ data: ReverseSettlementDto,
3480
+ locale: string,
3481
+ userId?: number,
3482
+ scope?: {
3483
+ titleId?: number;
3484
+ titleType?: TitleType;
3485
+ },
3486
+ ) {
3487
+ const { title } = await this.prisma.$transaction(async (tx) => {
3488
+ const settlementRows = await tx.$queryRaw<
3489
+ Array<{
3490
+ id: number;
3491
+ settlement_type: string;
3492
+ settled_at: Date;
3493
+ amount_cents: number;
3494
+ description: string | null;
3495
+ person_id: number | null;
3496
+ bank_account_id: number | null;
3497
+ payment_method_id: number | null;
3498
+ created_by_user_id: number | null;
3499
+ entry_type: string | null;
3500
+ title_id: number;
3501
+ title_type: string;
3502
+ title_status: string;
3503
+ title_competence_date: Date | null;
3504
+ }>
3505
+ >`
3506
+ SELECT
3507
+ s.id,
3508
+ s.settlement_type::text,
3509
+ s.settled_at,
3510
+ s.amount_cents,
3511
+ s.description,
3512
+ s.person_id,
3513
+ s.bank_account_id,
3514
+ s.payment_method_id,
3515
+ s.created_by_user_id,
3516
+ COALESCE(s.entry_type::text, 'normal') AS entry_type,
3517
+ ft.id AS title_id,
3518
+ ft.title_type::text AS title_type,
3519
+ ft.status::text AS title_status,
3520
+ ft.competence_date AS title_competence_date
3521
+ FROM settlement s
3522
+ INNER JOIN settlement_allocation sa ON sa.settlement_id = s.id
3523
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
3524
+ INNER JOIN financial_title ft ON ft.id = fi.title_id
3525
+ WHERE s.id = ${settlementId}
3526
+ LIMIT 1
3527
+ FOR UPDATE OF s
3528
+ `;
3529
+
3530
+ const settlement = settlementRows[0];
3531
+
3532
+ if (!settlement) {
3533
+ throw new NotFoundException('Settlement not found');
3534
+ }
3535
+
3536
+ if (scope?.titleId && settlement.title_id !== scope.titleId) {
3537
+ throw new NotFoundException('Settlement not found for this title');
3538
+ }
3539
+
3540
+ if (scope?.titleType && settlement.title_type !== scope.titleType) {
3541
+ throw new NotFoundException('Settlement not found for this title type');
3542
+ }
3543
+
3544
+ if (settlement.entry_type !== 'normal') {
3545
+ throw new BadRequestException('Somente liquidações normais podem ser estornadas');
3251
3546
  }
3252
3547
 
3253
3548
  await this.assertDateNotInClosedPeriod(
3254
3549
  tx,
3255
- title.competence_date,
3550
+ settlement.title_competence_date,
3256
3551
  'reverse settlement',
3257
3552
  );
3258
3553
 
3259
- const settlement = await tx.settlement.findFirst({
3260
- where: {
3261
- id: settlementId,
3262
- settlement_type: titleType,
3263
- settlement_allocation: {
3264
- some: {
3265
- financial_installment: {
3266
- title_id: title.id,
3267
- },
3268
- },
3269
- },
3270
- },
3271
- include: {
3272
- settlement_allocation: {
3273
- include: {
3274
- financial_installment: {
3275
- select: {
3276
- id: true,
3277
- amount_cents: true,
3278
- open_amount_cents: true,
3279
- due_date: true,
3280
- status: true,
3281
- },
3282
- },
3283
- },
3284
- },
3285
- },
3286
- });
3554
+ const alreadyReversed = await tx.$queryRaw<Array<{ id: number }>>`
3555
+ SELECT id
3556
+ FROM settlement
3557
+ WHERE reverses_settlement_id = ${settlement.id}
3558
+ LIMIT 1
3559
+ `;
3287
3560
 
3288
- if (!settlement) {
3289
- throw new NotFoundException('Settlement not found for this title');
3561
+ if (alreadyReversed.length > 0) {
3562
+ throw new ConflictException('Liquidação estornada.');
3290
3563
  }
3291
3564
 
3292
- if (settlement.status === 'reversed') {
3293
- throw new ConflictException('Liquidação já estornada.');
3565
+ const isReconciled = await tx.$queryRaw<Array<{ id: number }>>`
3566
+ SELECT id
3567
+ FROM bank_reconciliation
3568
+ WHERE settlement_id = ${settlement.id}
3569
+ AND status = 'reconciled'
3570
+ LIMIT 1
3571
+ `;
3572
+
3573
+ if (isReconciled.length > 0) {
3574
+ throw new ConflictException('Desconciliar primeiro');
3294
3575
  }
3295
3576
 
3296
- for (const allocation of settlement.settlement_allocation) {
3297
- const installment = allocation.financial_installment;
3577
+ const allocations = await tx.$queryRaw<
3578
+ Array<{
3579
+ id: number;
3580
+ installment_id: number;
3581
+ allocated_amount_cents: number;
3582
+ amount_cents: number | null;
3583
+ discount_cents: number;
3584
+ interest_cents: number;
3585
+ penalty_cents: number;
3586
+ installment_amount_cents: number;
3587
+ installment_open_amount_cents: number;
3588
+ installment_due_date: Date;
3589
+ installment_status: string;
3590
+ }>
3591
+ >`
3592
+ SELECT
3593
+ sa.id,
3594
+ sa.installment_id,
3595
+ sa.allocated_amount_cents,
3596
+ sa.amount_cents,
3597
+ sa.discount_cents,
3598
+ sa.interest_cents,
3599
+ sa.penalty_cents,
3600
+ fi.amount_cents AS installment_amount_cents,
3601
+ fi.open_amount_cents AS installment_open_amount_cents,
3602
+ fi.due_date AS installment_due_date,
3603
+ fi.status::text AS installment_status
3604
+ FROM settlement_allocation sa
3605
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
3606
+ WHERE sa.settlement_id = ${settlement.id}
3607
+ FOR UPDATE OF fi
3608
+ `;
3609
+
3610
+ if (allocations.length === 0) {
3611
+ throw new BadRequestException('Settlement has no allocations to reverse');
3612
+ }
3298
3613
 
3299
- if (!installment) {
3300
- continue;
3301
- }
3614
+ const reversalMemo = data.reason?.trim() || data.memo?.trim() || 'Estorno';
3615
+ const reversalAmountCents = -Math.abs(Number(settlement.amount_cents || 0));
3616
+
3617
+ const reversalResult = await tx.$queryRaw<Array<{ id: number }>>`
3618
+ INSERT INTO settlement (
3619
+ person_id,
3620
+ bank_account_id,
3621
+ payment_method_id,
3622
+ settlement_type,
3623
+ entry_type,
3624
+ status,
3625
+ settled_at,
3626
+ amount_cents,
3627
+ description,
3628
+ external_reference,
3629
+ created_by_user_id,
3630
+ reverses_settlement_id,
3631
+ created_at,
3632
+ updated_at
3633
+ )
3634
+ VALUES (
3635
+ ${settlement.person_id},
3636
+ ${settlement.bank_account_id},
3637
+ ${settlement.payment_method_id},
3638
+ ${settlement.settlement_type}::settlement_settlement_type_enum,
3639
+ 'reversal'::settlement_entry_type_enum,
3640
+ 'confirmed'::settlement_status_enum,
3641
+ NOW(),
3642
+ ${reversalAmountCents},
3643
+ ${reversalMemo},
3644
+ NULL,
3645
+ ${userId || settlement.created_by_user_id || null},
3646
+ ${settlement.id},
3647
+ NOW(),
3648
+ NOW()
3649
+ )
3650
+ RETURNING id
3651
+ `;
3652
+
3653
+ const reversalId = reversalResult[0]?.id;
3654
+
3655
+ if (!reversalId) {
3656
+ throw new BadRequestException('Could not create reversal settlement');
3657
+ }
3658
+
3659
+ for (const allocation of allocations) {
3660
+ const originalAmount = Number(
3661
+ allocation.amount_cents ?? allocation.allocated_amount_cents ?? 0,
3662
+ );
3663
+
3664
+ await tx.settlement_allocation.create({
3665
+ data: {
3666
+ settlement: {
3667
+ connect: {
3668
+ id: reversalId,
3669
+ },
3670
+ },
3671
+ financial_installment: {
3672
+ connect: {
3673
+ id: allocation.installment_id,
3674
+ },
3675
+ },
3676
+ allocated_amount_cents: -Math.abs(originalAmount),
3677
+ amount_cents: -Math.abs(originalAmount),
3678
+ discount_cents: -Math.abs(allocation.discount_cents || 0),
3679
+ interest_cents: -Math.abs(allocation.interest_cents || 0),
3680
+ penalty_cents: -Math.abs(allocation.penalty_cents || 0),
3681
+ },
3682
+ });
3302
3683
 
3303
3684
  const nextOpenAmountCents =
3304
- installment.open_amount_cents + allocation.allocated_amount_cents;
3685
+ Number(allocation.installment_open_amount_cents || 0) +
3686
+ Math.abs(originalAmount);
3305
3687
 
3306
- if (nextOpenAmountCents > installment.amount_cents) {
3307
- throw new BadRequestException(
3308
- `Reverse would exceed installment amount for installment ${installment.id}`,
3688
+ if (nextOpenAmountCents > Number(allocation.installment_amount_cents || 0)) {
3689
+ throw new ConflictException(
3690
+ `Estorno excederia o valor original da parcela ${allocation.installment_id}`,
3309
3691
  );
3310
3692
  }
3311
3693
 
3312
3694
  const nextInstallmentStatus = this.resolveInstallmentStatus(
3313
- installment.amount_cents,
3695
+ Number(allocation.installment_amount_cents || 0),
3314
3696
  nextOpenAmountCents,
3315
- installment.due_date,
3697
+ new Date(allocation.installment_due_date),
3698
+ allocation.installment_status,
3316
3699
  );
3317
3700
 
3318
3701
  await tx.financial_installment.update({
3319
3702
  where: {
3320
- id: installment.id,
3703
+ id: allocation.installment_id,
3321
3704
  },
3322
3705
  data: {
3323
3706
  open_amount_cents: nextOpenAmountCents,
@@ -3326,76 +3709,47 @@ export class FinanceService {
3326
3709
  });
3327
3710
  }
3328
3711
 
3329
- await tx.settlement.update({
3330
- where: {
3331
- id: settlement.id,
3332
- },
3333
- data: {
3334
- status: 'reversed',
3335
- description: [
3336
- settlement.description,
3337
- data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
3338
- ]
3339
- .filter(Boolean)
3340
- .join(' | '),
3341
- },
3342
- });
3343
-
3344
- await tx.bank_reconciliation.updateMany({
3345
- where: {
3346
- settlement_id: settlement.id,
3347
- status: 'pending',
3348
- },
3349
- data: {
3350
- status: 'reversed',
3351
- },
3352
- });
3353
-
3354
- await tx.bank_reconciliation.updateMany({
3355
- where: {
3356
- settlement_id: settlement.id,
3357
- status: {
3358
- in: ['reconciled', 'adjusted'],
3359
- },
3360
- },
3361
- data: {
3362
- status: 'adjusted',
3363
- },
3364
- });
3365
-
3366
- const previousTitleStatus = title.status;
3367
- const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
3712
+ const nextTitleStatus = await this.recalculateTitleStatus(
3713
+ tx,
3714
+ settlement.title_id,
3715
+ );
3368
3716
 
3369
3717
  await this.createAuditLog(tx, {
3370
3718
  action: 'REVERSE_SETTLEMENT',
3371
3719
  entityTable: 'financial_title',
3372
- entityId: String(title.id),
3720
+ entityId: String(settlement.title_id),
3373
3721
  actorUserId: userId,
3374
- summary: `Reversed settlement ${settlement.id} from title ${title.id}`,
3722
+ summary: `Created reversal ${reversalId} for settlement ${settlement.id}`,
3375
3723
  beforeData: JSON.stringify({
3376
- title_status: previousTitleStatus,
3377
- settlement_status: settlement.status,
3724
+ title_status: settlement.title_status,
3725
+ settlement_id: settlement.id,
3726
+ settlement_entry_type: settlement.entry_type,
3378
3727
  }),
3379
3728
  afterData: JSON.stringify({
3380
3729
  title_status: nextTitleStatus,
3381
- settlement_status: 'reversed',
3730
+ settlement_id: settlement.id,
3731
+ reversal_settlement_id: reversalId,
3382
3732
  }),
3383
3733
  });
3384
3734
 
3385
- return tx.financial_title.findFirst({
3735
+ const updatedTitle = await tx.financial_title.findFirst({
3386
3736
  where: {
3387
- id: title.id,
3388
- title_type: titleType,
3737
+ id: settlement.title_id,
3738
+ title_type: settlement.title_type as TitleType,
3389
3739
  },
3390
3740
  include: this.defaultTitleInclude(),
3391
3741
  });
3392
- });
3393
3742
 
3394
- if (!updatedTitle) {
3395
- throw new NotFoundException('Financial title not found');
3396
- }
3743
+ if (!updatedTitle) {
3744
+ throw new NotFoundException('Financial title not found');
3745
+ }
3397
3746
 
3398
- return this.mapTitleToFront(updatedTitle);
3747
+ return {
3748
+ title: updatedTitle,
3749
+ };
3750
+ });
3751
+
3752
+ return title;
3399
3753
  }
3400
3754
 
3401
3755
  private async updateTitleTags(
@@ -4213,7 +4567,28 @@ export class FinanceService {
4213
4567
  return Math.round(value * 100);
4214
4568
  }
4215
4569
 
4216
- private fromCents(value: number) {
4217
- return Number((value / 100).toFixed(2));
4570
+ private fromCents(value: number | bigint | string | null | undefined) {
4571
+ if (value === null || value === undefined) {
4572
+ return 0;
4573
+ }
4574
+
4575
+ if (typeof value === 'bigint') {
4576
+ const isNegative = value < BigInt(0);
4577
+ const absoluteValue = isNegative ? -value : value;
4578
+ const whole = absoluteValue / BigInt(100);
4579
+ const cents = absoluteValue % BigInt(100);
4580
+ const composedValue = Number(whole) + Number(cents) / 100;
4581
+
4582
+ return Number((isNegative ? -composedValue : composedValue).toFixed(2));
4583
+ }
4584
+
4585
+ const numericValue =
4586
+ typeof value === 'string' ? Number(value) : Number(value || 0);
4587
+
4588
+ if (!Number.isFinite(numericValue)) {
4589
+ return 0;
4590
+ }
4591
+
4592
+ return Number((numericValue / 100).toFixed(2));
4218
4593
  }
4219
4594
  }