@hed-hog/finance 0.0.251 → 0.0.253

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.
@@ -3,12 +3,13 @@ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
4
  import { AiService, FileService } from '@hed-hog/core';
5
5
  import {
6
- BadRequestException,
7
- forwardRef,
8
- Inject,
9
- Injectable,
10
- Logger,
11
- NotFoundException,
6
+ BadRequestException,
7
+ ConflictException,
8
+ forwardRef,
9
+ Inject,
10
+ Injectable,
11
+ Logger,
12
+ NotFoundException,
12
13
  } from '@nestjs/common';
13
14
  import { createHash } from 'node:crypto';
14
15
  import { readFile } from 'node:fs/promises';
@@ -833,7 +834,9 @@ export class FinanceService {
833
834
  const day7 = this.addDays(today, 7);
834
835
  const day30 = this.addDays(today, 30);
835
836
 
836
- const payableInstallments = this.extractOpenInstallments(payables);
837
+ const payableInstallments = this.extractOpenInstallments(
838
+ (payables || []).filter((title) => this.isPayableTitleApproved(title)),
839
+ );
837
840
  const receivableInstallments = this.extractOpenInstallments(receivables);
838
841
 
839
842
  const saldoCaixa = (bankAccounts || [])
@@ -887,6 +890,11 @@ export class FinanceService {
887
890
  );
888
891
  }
889
892
 
893
+ private isPayableTitleApproved(title: any) {
894
+ const status = String(title?.status || '').toLowerCase();
895
+ return status !== 'rascunho' && status !== 'cancelado';
896
+ }
897
+
890
898
  private sumInstallmentsDueBetween(
891
899
  installments: any[],
892
900
  startDate: Date,
@@ -1059,6 +1067,192 @@ export class FinanceService {
1059
1067
  );
1060
1068
  }
1061
1069
 
1070
+ async getTitleSettlementsHistory(titleId: number, locale: string) {
1071
+ const title = await this.prisma.financial_title.findUnique({
1072
+ where: { id: titleId },
1073
+ select: { id: true },
1074
+ });
1075
+
1076
+ if (!title) {
1077
+ throw new NotFoundException(
1078
+ getLocaleText(
1079
+ 'itemNotFound',
1080
+ locale,
1081
+ `Financial title with ID ${titleId} not found`,
1082
+ ).replace('{{item}}', 'Financial title'),
1083
+ );
1084
+ }
1085
+
1086
+ const rows = await this.prisma.$queryRaw<
1087
+ Array<{
1088
+ normal_id: number;
1089
+ normal_paid_at: Date;
1090
+ normal_amount_cents: number;
1091
+ normal_method: string | null;
1092
+ normal_account_id: number | null;
1093
+ normal_account_name: string | null;
1094
+ normal_created_at: Date;
1095
+ normal_created_by: string | null;
1096
+ normal_memo: string | null;
1097
+ reconciliation_id: number | null;
1098
+ reconciliation_status: string | null;
1099
+ installment_id: number;
1100
+ installment_seq: number;
1101
+ allocation_amount_cents: number;
1102
+ reversal_id: number | null;
1103
+ reversal_paid_at: Date | null;
1104
+ reversal_amount_cents: number | null;
1105
+ reversal_created_at: Date | null;
1106
+ reversal_created_by: string | null;
1107
+ reversal_memo: string | null;
1108
+ }>
1109
+ >`
1110
+ SELECT
1111
+ s.id AS normal_id,
1112
+ s.settled_at AS normal_paid_at,
1113
+ s.amount_cents AS normal_amount_cents,
1114
+ pm.type::text AS normal_method,
1115
+ s.bank_account_id AS normal_account_id,
1116
+ ba.name AS normal_account_name,
1117
+ s.created_at AS normal_created_at,
1118
+ u.name AS normal_created_by,
1119
+ s.description AS normal_memo,
1120
+ br.id AS reconciliation_id,
1121
+ br.status::text AS reconciliation_status,
1122
+ fi.id AS installment_id,
1123
+ fi.installment_number AS installment_seq,
1124
+ COALESCE(sa.amount_cents, sa.allocated_amount_cents) AS allocation_amount_cents,
1125
+ r.id AS reversal_id,
1126
+ r.settled_at AS reversal_paid_at,
1127
+ r.amount_cents AS reversal_amount_cents,
1128
+ r.created_at AS reversal_created_at,
1129
+ ur.name AS reversal_created_by,
1130
+ r.description AS reversal_memo
1131
+ FROM settlement s
1132
+ INNER JOIN settlement_allocation sa ON sa.settlement_id = s.id
1133
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
1134
+ LEFT JOIN payment_method pm ON pm.id = s.payment_method_id
1135
+ LEFT JOIN bank_account ba ON ba.id = s.bank_account_id
1136
+ LEFT JOIN "user" u ON u.id = s.created_by_user_id
1137
+ LEFT JOIN bank_reconciliation br ON br.settlement_id = s.id
1138
+ LEFT JOIN settlement r ON r.reverses_settlement_id = s.id
1139
+ LEFT JOIN "user" ur ON ur.id = r.created_by_user_id
1140
+ WHERE fi.title_id = ${titleId}
1141
+ AND COALESCE(s.entry_type::text, 'normal') = 'normal'
1142
+ ORDER BY s.settled_at DESC, s.id DESC, fi.installment_number ASC
1143
+ `;
1144
+
1145
+ const groups = new Map<string, any>();
1146
+
1147
+ for (const row of rows) {
1148
+ const key = String(row.normal_id);
1149
+ const existing = groups.get(key);
1150
+
1151
+ if (!existing) {
1152
+ groups.set(key, {
1153
+ normal: {
1154
+ id: key,
1155
+ paidAt: row.normal_paid_at?.toISOString?.() || null,
1156
+ amountCents: Number(row.normal_amount_cents || 0),
1157
+ type: 'NORMAL',
1158
+ method: this.mapPaymentMethodToPt(row.normal_method) || row.normal_method,
1159
+ account: row.normal_account_name || null,
1160
+ accountId: row.normal_account_id
1161
+ ? String(row.normal_account_id)
1162
+ : null,
1163
+ createdAt: row.normal_created_at?.toISOString?.() || null,
1164
+ createdBy: row.normal_created_by || null,
1165
+ memo: row.normal_memo || null,
1166
+ reconciled: row.reconciliation_status === 'reconciled',
1167
+ reconciliationId: row.reconciliation_id
1168
+ ? String(row.reconciliation_id)
1169
+ : null,
1170
+ },
1171
+ reversal: row.reversal_id
1172
+ ? {
1173
+ id: String(row.reversal_id),
1174
+ paidAt: row.reversal_paid_at?.toISOString?.() || null,
1175
+ amountCents: Number(row.reversal_amount_cents || 0),
1176
+ type: 'REVERSAL',
1177
+ createdAt: row.reversal_created_at?.toISOString?.() || null,
1178
+ createdBy: row.reversal_created_by || null,
1179
+ memo: row.reversal_memo || null,
1180
+ }
1181
+ : null,
1182
+ allocations: [],
1183
+ statusLabel: row.reversal_id ? 'ESTORNADO' : 'ATIVO',
1184
+ });
1185
+ }
1186
+
1187
+ groups.get(key).allocations.push({
1188
+ installmentId: String(row.installment_id),
1189
+ installmentSeq: Number(row.installment_seq || 0),
1190
+ amountCents: Number(row.allocation_amount_cents || 0),
1191
+ });
1192
+ }
1193
+
1194
+ return Array.from(groups.values());
1195
+ }
1196
+
1197
+ async reverseSettlementById(
1198
+ settlementId: number,
1199
+ data: ReverseSettlementDto,
1200
+ locale: string,
1201
+ userId?: number,
1202
+ ) {
1203
+ const updatedTitle = await this.reverseSettlementInternal(
1204
+ settlementId,
1205
+ data,
1206
+ locale,
1207
+ userId,
1208
+ );
1209
+
1210
+ return this.mapTitleToFront(updatedTitle);
1211
+ }
1212
+
1213
+ async unreconcileBankReconciliation(id: number, userId?: number) {
1214
+ const reconciliation = await this.prisma.bank_reconciliation.findUnique({
1215
+ where: { id },
1216
+ select: {
1217
+ id: true,
1218
+ settlement_id: true,
1219
+ bank_statement_line_id: true,
1220
+ },
1221
+ });
1222
+
1223
+ if (!reconciliation) {
1224
+ throw new NotFoundException('Conciliação bancária não encontrada');
1225
+ }
1226
+
1227
+ await this.prisma.$transaction(async (tx) => {
1228
+ await tx.bank_reconciliation.delete({
1229
+ where: { id: reconciliation.id },
1230
+ });
1231
+
1232
+ await tx.bank_statement_line.updateMany({
1233
+ where: {
1234
+ id: reconciliation.bank_statement_line_id,
1235
+ status: {
1236
+ in: ['reconciled', 'adjusted'],
1237
+ },
1238
+ },
1239
+ data: {
1240
+ status: 'pending',
1241
+ },
1242
+ });
1243
+
1244
+ await this.createAuditLog(tx, {
1245
+ action: 'UNRECONCILE_SETTLEMENT',
1246
+ entityTable: 'bank_reconciliation',
1247
+ entityId: String(reconciliation.id),
1248
+ actorUserId: userId,
1249
+ summary: `Unreconciled settlement ${reconciliation.settlement_id}`,
1250
+ });
1251
+ });
1252
+
1253
+ return { success: true };
1254
+ }
1255
+
1062
1256
  async createTag(data: CreateFinanceTagDto) {
1063
1257
  const slug = this.normalizeTagSlug(data.name);
1064
1258
 
@@ -2906,12 +3100,45 @@ export class FinanceService {
2906
3100
  throw new BadRequestException('Title cannot be canceled in current status');
2907
3101
  }
2908
3102
 
3103
+ const activeSettlements = await tx.$queryRaw<Array<{ has_active: boolean }>>`
3104
+ SELECT EXISTS (
3105
+ SELECT 1
3106
+ FROM settlement_allocation sa
3107
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
3108
+ INNER JOIN settlement s ON s.id = sa.settlement_id
3109
+ LEFT JOIN settlement r ON r.reverses_settlement_id = s.id
3110
+ WHERE fi.title_id = ${title.id}
3111
+ AND COALESCE(s.entry_type::text, 'normal') = 'normal'
3112
+ AND r.id IS NULL
3113
+ ) AS has_active
3114
+ `;
3115
+
3116
+ const hasActiveSettlements = activeSettlements[0]?.has_active;
3117
+
3118
+ if (hasActiveSettlements) {
3119
+ throw new ConflictException(
3120
+ 'Não é possível cancelar enquanto houver liquidações ativas. Estorne primeiro.',
3121
+ );
3122
+ }
3123
+
2909
3124
  await this.assertDateNotInClosedPeriod(
2910
3125
  tx,
2911
3126
  title.competence_date,
2912
3127
  'cancel title',
2913
3128
  );
2914
3129
 
3130
+ await tx.financial_installment.updateMany({
3131
+ where: {
3132
+ title_id: title.id,
3133
+ status: {
3134
+ not: 'canceled',
3135
+ },
3136
+ },
3137
+ data: {
3138
+ status: 'canceled',
3139
+ },
3140
+ });
3141
+
2915
3142
  await tx.financial_title.update({
2916
3143
  where: { id: title.id },
2917
3144
  data: {
@@ -2966,8 +3193,9 @@ export class FinanceService {
2966
3193
  throw new BadRequestException('Invalid settlement date');
2967
3194
  }
2968
3195
 
2969
- const result = await this.prisma.$transaction(async (tx) => {
2970
- const title = await tx.financial_title.findFirst({
3196
+ try {
3197
+ const result = await this.prisma.$transaction(async (tx) => {
3198
+ const title = await tx.financial_title.findFirst({
2971
3199
  where: {
2972
3200
  id: titleId,
2973
3201
  title_type: titleType,
@@ -2991,6 +3219,10 @@ export class FinanceService {
2991
3219
  }
2992
3220
 
2993
3221
  if (!['open', 'partial'].includes(title.status)) {
3222
+ if (title.status === 'canceled') {
3223
+ throw new ConflictException('Título cancelado não pode receber baixa');
3224
+ }
3225
+
2994
3226
  throw new BadRequestException(
2995
3227
  'Only open/partial titles can be settled',
2996
3228
  );
@@ -2998,7 +3230,7 @@ export class FinanceService {
2998
3230
 
2999
3231
  await this.assertDateNotInClosedPeriod(
3000
3232
  tx,
3001
- title.competence_date,
3233
+ settledAt,
3002
3234
  'settle installment',
3003
3235
  );
3004
3236
 
@@ -3022,11 +3254,15 @@ export class FinanceService {
3022
3254
  }
3023
3255
 
3024
3256
  if (installment.status === 'settled' || installment.status === 'canceled') {
3025
- throw new BadRequestException('This installment cannot be settled');
3257
+ if (installment.status === 'settled') {
3258
+ throw new ConflictException('Parcela já liquidada');
3259
+ }
3260
+
3261
+ throw new ConflictException('Parcela cancelada não pode receber baixa');
3026
3262
  }
3027
3263
 
3028
3264
  if (amountCents > installment.open_amount_cents) {
3029
- throw new BadRequestException('Settlement amount exceeds open amount');
3265
+ throw new ConflictException('Settlement amount exceeds open amount');
3030
3266
  }
3031
3267
 
3032
3268
  const paymentMethodId = await this.resolvePaymentMethodId(
@@ -3145,16 +3381,27 @@ export class FinanceService {
3145
3381
  throw new NotFoundException('Financial title not found');
3146
3382
  }
3147
3383
 
3384
+ return {
3385
+ title: updatedTitle,
3386
+ settlementId: settlement.id,
3387
+ };
3388
+ });
3389
+
3148
3390
  return {
3149
- title: updatedTitle,
3150
- settlementId: settlement.id,
3391
+ ...this.mapTitleToFront(result.title),
3392
+ settlementId: String(result.settlementId),
3151
3393
  };
3152
- });
3394
+ } catch (error: any) {
3395
+ const message = String(error?.message || '');
3153
3396
 
3154
- return {
3155
- ...this.mapTitleToFront(result.title),
3156
- settlementId: String(result.settlementId),
3157
- };
3397
+ if (message.includes('Soma de settlement_allocation')) {
3398
+ throw new ConflictException(
3399
+ 'Não foi possível registrar a baixa. Existe alocação ativa acima do limite da parcela.',
3400
+ );
3401
+ }
3402
+
3403
+ throw error;
3404
+ }
3158
3405
  }
3159
3406
 
3160
3407
  private async reverseTitleSettlement(
@@ -3165,97 +3412,241 @@ export class FinanceService {
3165
3412
  locale: string,
3166
3413
  userId?: number,
3167
3414
  ) {
3168
- const updatedTitle = await this.prisma.$transaction(async (tx) => {
3169
- const title = await tx.financial_title.findFirst({
3170
- where: {
3171
- id: titleId,
3172
- title_type: titleType,
3173
- },
3174
- select: {
3175
- id: true,
3176
- status: true,
3177
- competence_date: true,
3178
- },
3179
- });
3415
+ const updatedTitle = await this.reverseSettlementInternal(
3416
+ settlementId,
3417
+ data,
3418
+ locale,
3419
+ userId,
3420
+ {
3421
+ titleId,
3422
+ titleType,
3423
+ },
3424
+ );
3180
3425
 
3181
- if (!title) {
3182
- throw new NotFoundException(
3183
- getLocaleText(
3184
- 'itemNotFound',
3185
- locale,
3186
- `Financial title with ID ${titleId} not found`,
3187
- ).replace('{{item}}', 'Financial title'),
3188
- );
3426
+ if (!updatedTitle) {
3427
+ throw new NotFoundException('Financial title not found');
3428
+ }
3429
+
3430
+ return this.mapTitleToFront(updatedTitle);
3431
+ }
3432
+
3433
+ private async reverseSettlementInternal(
3434
+ settlementId: number,
3435
+ data: ReverseSettlementDto,
3436
+ locale: string,
3437
+ userId?: number,
3438
+ scope?: {
3439
+ titleId?: number;
3440
+ titleType?: TitleType;
3441
+ },
3442
+ ) {
3443
+ const { title } = await this.prisma.$transaction(async (tx) => {
3444
+ const settlementRows = await tx.$queryRaw<
3445
+ Array<{
3446
+ id: number;
3447
+ settlement_type: string;
3448
+ settled_at: Date;
3449
+ amount_cents: number;
3450
+ description: string | null;
3451
+ person_id: number | null;
3452
+ bank_account_id: number | null;
3453
+ payment_method_id: number | null;
3454
+ created_by_user_id: number | null;
3455
+ entry_type: string | null;
3456
+ title_id: number;
3457
+ title_type: string;
3458
+ title_status: string;
3459
+ title_competence_date: Date | null;
3460
+ }>
3461
+ >`
3462
+ SELECT DISTINCT
3463
+ s.id,
3464
+ s.settlement_type::text,
3465
+ s.settled_at,
3466
+ s.amount_cents,
3467
+ s.description,
3468
+ s.person_id,
3469
+ s.bank_account_id,
3470
+ s.payment_method_id,
3471
+ s.created_by_user_id,
3472
+ COALESCE(s.entry_type::text, 'normal') AS entry_type,
3473
+ ft.id AS title_id,
3474
+ ft.title_type::text AS title_type,
3475
+ ft.status::text AS title_status,
3476
+ ft.competence_date AS title_competence_date
3477
+ FROM settlement s
3478
+ INNER JOIN settlement_allocation sa ON sa.settlement_id = s.id
3479
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
3480
+ INNER JOIN financial_title ft ON ft.id = fi.title_id
3481
+ WHERE s.id = ${settlementId}
3482
+ FOR UPDATE OF s
3483
+ `;
3484
+
3485
+ const settlement = settlementRows[0];
3486
+
3487
+ if (!settlement) {
3488
+ throw new NotFoundException('Settlement not found');
3489
+ }
3490
+
3491
+ if (scope?.titleId && settlement.title_id !== scope.titleId) {
3492
+ throw new NotFoundException('Settlement not found for this title');
3493
+ }
3494
+
3495
+ if (scope?.titleType && settlement.title_type !== scope.titleType) {
3496
+ throw new NotFoundException('Settlement not found for this title type');
3497
+ }
3498
+
3499
+ if (settlement.entry_type !== 'normal') {
3500
+ throw new BadRequestException('Somente liquidações normais podem ser estornadas');
3189
3501
  }
3190
3502
 
3191
3503
  await this.assertDateNotInClosedPeriod(
3192
3504
  tx,
3193
- title.competence_date,
3505
+ settlement.title_competence_date,
3194
3506
  'reverse settlement',
3195
3507
  );
3196
3508
 
3197
- const settlement = await tx.settlement.findFirst({
3198
- where: {
3199
- id: settlementId,
3200
- settlement_type: titleType,
3201
- settlement_allocation: {
3202
- some: {
3203
- financial_installment: {
3204
- title_id: title.id,
3205
- },
3206
- },
3207
- },
3208
- },
3209
- include: {
3210
- settlement_allocation: {
3211
- include: {
3212
- financial_installment: {
3213
- select: {
3214
- id: true,
3215
- amount_cents: true,
3216
- open_amount_cents: true,
3217
- due_date: true,
3218
- status: true,
3219
- },
3220
- },
3221
- },
3222
- },
3223
- },
3224
- });
3509
+ const alreadyReversed = await tx.$queryRaw<Array<{ id: number }>>`
3510
+ SELECT id
3511
+ FROM settlement
3512
+ WHERE reverses_settlement_id = ${settlement.id}
3513
+ LIMIT 1
3514
+ `;
3225
3515
 
3226
- if (!settlement) {
3227
- throw new NotFoundException('Settlement not found for this title');
3516
+ if (alreadyReversed.length > 0) {
3517
+ throw new ConflictException('Liquidação estornada.');
3228
3518
  }
3229
3519
 
3230
- if (settlement.status === 'reversed') {
3231
- throw new BadRequestException('This settlement is already reversed');
3520
+ const isReconciled = await tx.$queryRaw<Array<{ id: number }>>`
3521
+ SELECT id
3522
+ FROM bank_reconciliation
3523
+ WHERE settlement_id = ${settlement.id}
3524
+ AND status = 'reconciled'
3525
+ LIMIT 1
3526
+ `;
3527
+
3528
+ if (isReconciled.length > 0) {
3529
+ throw new ConflictException('Desconciliar primeiro');
3232
3530
  }
3233
3531
 
3234
- for (const allocation of settlement.settlement_allocation) {
3235
- const installment = allocation.financial_installment;
3532
+ const allocations = await tx.$queryRaw<
3533
+ Array<{
3534
+ id: number;
3535
+ installment_id: number;
3536
+ allocated_amount_cents: number;
3537
+ amount_cents: number | null;
3538
+ discount_cents: number;
3539
+ interest_cents: number;
3540
+ penalty_cents: number;
3541
+ installment_amount_cents: number;
3542
+ installment_open_amount_cents: number;
3543
+ installment_due_date: Date;
3544
+ installment_status: string;
3545
+ }>
3546
+ >`
3547
+ SELECT
3548
+ sa.id,
3549
+ sa.installment_id,
3550
+ sa.allocated_amount_cents,
3551
+ sa.amount_cents,
3552
+ sa.discount_cents,
3553
+ sa.interest_cents,
3554
+ sa.penalty_cents,
3555
+ fi.amount_cents AS installment_amount_cents,
3556
+ fi.open_amount_cents AS installment_open_amount_cents,
3557
+ fi.due_date AS installment_due_date,
3558
+ fi.status::text AS installment_status
3559
+ FROM settlement_allocation sa
3560
+ INNER JOIN financial_installment fi ON fi.id = sa.installment_id
3561
+ WHERE sa.settlement_id = ${settlement.id}
3562
+ FOR UPDATE OF fi
3563
+ `;
3564
+
3565
+ if (allocations.length === 0) {
3566
+ throw new BadRequestException('Settlement has no allocations to reverse');
3567
+ }
3236
3568
 
3237
- if (!installment) {
3238
- continue;
3239
- }
3569
+ const reversalMemo = data.reason?.trim() || data.memo?.trim() || 'Estorno';
3570
+ const reversalAmountCents = -Math.abs(Number(settlement.amount_cents || 0));
3571
+
3572
+ const reversalResult = await tx.$queryRaw<Array<{ id: number }>>`
3573
+ INSERT INTO settlement (
3574
+ person_id,
3575
+ bank_account_id,
3576
+ payment_method_id,
3577
+ settlement_type,
3578
+ entry_type,
3579
+ status,
3580
+ settled_at,
3581
+ amount_cents,
3582
+ description,
3583
+ external_reference,
3584
+ created_by_user_id,
3585
+ reverses_settlement_id,
3586
+ created_at,
3587
+ updated_at
3588
+ )
3589
+ VALUES (
3590
+ ${settlement.person_id},
3591
+ ${settlement.bank_account_id},
3592
+ ${settlement.payment_method_id},
3593
+ ${settlement.settlement_type}::settlement_settlement_type_enum,
3594
+ 'reversal'::settlement_entry_type_enum,
3595
+ 'confirmed'::settlement_status_enum,
3596
+ NOW(),
3597
+ ${reversalAmountCents},
3598
+ ${reversalMemo},
3599
+ NULL,
3600
+ ${userId || settlement.created_by_user_id || null},
3601
+ ${settlement.id},
3602
+ NOW(),
3603
+ NOW()
3604
+ )
3605
+ RETURNING id
3606
+ `;
3607
+
3608
+ const reversalId = reversalResult[0]?.id;
3609
+
3610
+ if (!reversalId) {
3611
+ throw new BadRequestException('Could not create reversal settlement');
3612
+ }
3613
+
3614
+ for (const allocation of allocations) {
3615
+ const originalAmount = Number(
3616
+ allocation.amount_cents ?? allocation.allocated_amount_cents ?? 0,
3617
+ );
3618
+
3619
+ await tx.settlement_allocation.create({
3620
+ data: {
3621
+ settlement_id: reversalId,
3622
+ installment_id: allocation.installment_id,
3623
+ allocated_amount_cents: -Math.abs(originalAmount),
3624
+ discount_cents: -Math.abs(allocation.discount_cents || 0),
3625
+ interest_cents: -Math.abs(allocation.interest_cents || 0),
3626
+ penalty_cents: -Math.abs(allocation.penalty_cents || 0),
3627
+ },
3628
+ });
3240
3629
 
3241
3630
  const nextOpenAmountCents =
3242
- installment.open_amount_cents + allocation.allocated_amount_cents;
3631
+ Number(allocation.installment_open_amount_cents || 0) +
3632
+ Math.abs(originalAmount);
3243
3633
 
3244
- if (nextOpenAmountCents > installment.amount_cents) {
3245
- throw new BadRequestException(
3246
- `Reverse would exceed installment amount for installment ${installment.id}`,
3634
+ if (nextOpenAmountCents > Number(allocation.installment_amount_cents || 0)) {
3635
+ throw new ConflictException(
3636
+ `Estorno excederia o valor original da parcela ${allocation.installment_id}`,
3247
3637
  );
3248
3638
  }
3249
3639
 
3250
3640
  const nextInstallmentStatus = this.resolveInstallmentStatus(
3251
- installment.amount_cents,
3641
+ Number(allocation.installment_amount_cents || 0),
3252
3642
  nextOpenAmountCents,
3253
- installment.due_date,
3643
+ new Date(allocation.installment_due_date),
3644
+ allocation.installment_status,
3254
3645
  );
3255
3646
 
3256
3647
  await tx.financial_installment.update({
3257
3648
  where: {
3258
- id: installment.id,
3649
+ id: allocation.installment_id,
3259
3650
  },
3260
3651
  data: {
3261
3652
  open_amount_cents: nextOpenAmountCents,
@@ -3264,54 +3655,47 @@ export class FinanceService {
3264
3655
  });
3265
3656
  }
3266
3657
 
3267
- await tx.settlement.update({
3268
- where: {
3269
- id: settlement.id,
3270
- },
3271
- data: {
3272
- status: 'reversed',
3273
- description: [
3274
- settlement.description,
3275
- data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
3276
- ]
3277
- .filter(Boolean)
3278
- .join(' | '),
3279
- },
3280
- });
3281
-
3282
- const previousTitleStatus = title.status;
3283
- const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
3658
+ const nextTitleStatus = await this.recalculateTitleStatus(
3659
+ tx,
3660
+ settlement.title_id,
3661
+ );
3284
3662
 
3285
3663
  await this.createAuditLog(tx, {
3286
3664
  action: 'REVERSE_SETTLEMENT',
3287
3665
  entityTable: 'financial_title',
3288
- entityId: String(title.id),
3666
+ entityId: String(settlement.title_id),
3289
3667
  actorUserId: userId,
3290
- summary: `Reversed settlement ${settlement.id} from title ${title.id}`,
3668
+ summary: `Created reversal ${reversalId} for settlement ${settlement.id}`,
3291
3669
  beforeData: JSON.stringify({
3292
- title_status: previousTitleStatus,
3293
- settlement_status: settlement.status,
3670
+ title_status: settlement.title_status,
3671
+ settlement_id: settlement.id,
3672
+ settlement_entry_type: settlement.entry_type,
3294
3673
  }),
3295
3674
  afterData: JSON.stringify({
3296
3675
  title_status: nextTitleStatus,
3297
- settlement_status: 'reversed',
3676
+ settlement_id: settlement.id,
3677
+ reversal_settlement_id: reversalId,
3298
3678
  }),
3299
3679
  });
3300
3680
 
3301
- return tx.financial_title.findFirst({
3681
+ const updatedTitle = await tx.financial_title.findFirst({
3302
3682
  where: {
3303
- id: title.id,
3304
- title_type: titleType,
3683
+ id: settlement.title_id,
3684
+ title_type: settlement.title_type as TitleType,
3305
3685
  },
3306
3686
  include: this.defaultTitleInclude(),
3307
3687
  });
3308
- });
3309
3688
 
3310
- if (!updatedTitle) {
3311
- throw new NotFoundException('Financial title not found');
3312
- }
3689
+ if (!updatedTitle) {
3690
+ throw new NotFoundException('Financial title not found');
3691
+ }
3313
3692
 
3314
- return this.mapTitleToFront(updatedTitle);
3693
+ return {
3694
+ title: updatedTitle,
3695
+ };
3696
+ });
3697
+
3698
+ return title;
3315
3699
  }
3316
3700
 
3317
3701
  private async updateTitleTags(