@hed-hog/finance 0.0.238 → 0.0.240

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 (53) hide show
  1. package/README.md +1 -22
  2. package/dist/dto/reject-title.dto.d.ts +4 -0
  3. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  4. package/dist/dto/reject-title.dto.js +22 -0
  5. package/dist/dto/reject-title.dto.js.map +1 -0
  6. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  7. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  8. package/dist/dto/reverse-settlement.dto.js +22 -0
  9. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  10. package/dist/dto/settle-installment.dto.d.ts +12 -0
  11. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  12. package/dist/dto/settle-installment.dto.js +71 -0
  13. package/dist/dto/settle-installment.dto.js.map +1 -0
  14. package/dist/finance-data.controller.d.ts +13 -5
  15. package/dist/finance-data.controller.d.ts.map +1 -1
  16. package/dist/finance-installments.controller.d.ts +380 -12
  17. package/dist/finance-installments.controller.d.ts.map +1 -1
  18. package/dist/finance-installments.controller.js +144 -0
  19. package/dist/finance-installments.controller.js.map +1 -1
  20. package/dist/finance-statements.controller.d.ts +8 -0
  21. package/dist/finance-statements.controller.d.ts.map +1 -1
  22. package/dist/finance-statements.controller.js +40 -0
  23. package/dist/finance-statements.controller.js.map +1 -1
  24. package/dist/finance.module.d.ts.map +1 -1
  25. package/dist/finance.module.js +1 -0
  26. package/dist/finance.module.js.map +1 -1
  27. package/dist/finance.service.d.ts +435 -19
  28. package/dist/finance.service.d.ts.map +1 -1
  29. package/dist/finance.service.js +1286 -80
  30. package/dist/finance.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +117 -0
  32. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  33. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +434 -7
  34. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1172 -25
  35. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
  36. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +430 -14
  37. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +212 -60
  38. package/hedhog/frontend/messages/en.json +1 -0
  39. package/hedhog/frontend/messages/pt.json +1 -0
  40. package/hedhog/query/0_constraints.sql +2 -0
  41. package/hedhog/query/constraints.sql +86 -0
  42. package/hedhog/table/bank_account.yaml +0 -8
  43. package/hedhog/table/financial_title.yaml +1 -9
  44. package/hedhog/table/settlement.yaml +0 -8
  45. package/package.json +6 -6
  46. package/src/dto/reject-title.dto.ts +7 -0
  47. package/src/dto/reverse-settlement.dto.ts +7 -0
  48. package/src/dto/settle-installment.dto.ts +55 -0
  49. package/src/finance-installments.controller.ts +172 -10
  50. package/src/finance-statements.controller.ts +61 -2
  51. package/src/finance.module.ts +2 -1
  52. package/src/finance.service.ts +1887 -106
  53. package/hedhog/table/branch.yaml +0 -18
@@ -1,13 +1,17 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
- import { AiService } from '@hed-hog/core';
4
+ import { AiService, FileService } from '@hed-hog/core';
5
5
  import {
6
- BadRequestException,
7
- Injectable,
8
- Logger,
9
- NotFoundException
6
+ BadRequestException,
7
+ forwardRef,
8
+ Inject,
9
+ Injectable,
10
+ Logger,
11
+ NotFoundException,
10
12
  } from '@nestjs/common';
13
+ import { createHash } from 'node:crypto';
14
+ import { readFile } from 'node:fs/promises';
11
15
  import { CreateBankAccountDto } from './dto/create-bank-account.dto';
12
16
  import { CreateCostCenterDto } from './dto/create-cost-center.dto';
13
17
  import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
@@ -15,11 +19,27 @@ import { CreateFinanceTagDto } from './dto/create-finance-tag.dto';
15
19
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
16
20
  import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
17
21
  import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
22
+ import { RejectTitleDto } from './dto/reject-title.dto';
23
+ import { ReverseSettlementDto } from './dto/reverse-settlement.dto';
24
+ import { SettleInstallmentDto } from './dto/settle-installment.dto';
18
25
  import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
19
26
  import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
20
27
  import { UpdateFinanceCategoryDto } from './dto/update-finance-category.dto';
21
28
 
22
29
  type TitleType = 'payable' | 'receivable';
30
+ type InstallmentStatus =
31
+ | 'open'
32
+ | 'partial'
33
+ | 'settled'
34
+ | 'canceled'
35
+ | 'overdue';
36
+ type TitleStatus =
37
+ | 'draft'
38
+ | 'open'
39
+ | 'partial'
40
+ | 'settled'
41
+ | 'canceled'
42
+ | 'overdue';
23
43
 
24
44
  @Injectable()
25
45
  export class FinanceService {
@@ -29,6 +49,8 @@ export class FinanceService {
29
49
  private readonly prisma: PrismaService,
30
50
  private readonly paginationService: PaginationService,
31
51
  private readonly ai: AiService,
52
+ @Inject(forwardRef(() => FileService))
53
+ private readonly fileService: FileService,
32
54
  ) {}
33
55
 
34
56
  async getAgentExtractInfoFromFile(
@@ -756,6 +778,18 @@ export class FinanceService {
756
778
  this.logger.error('Failed to load finance audit logs', auditLogsResult.reason);
757
779
  }
758
780
 
781
+ const aprovacoesPendentes = payables
782
+ .filter((title: any) => title.status === 'rascunho')
783
+ .map((title: any) => ({
784
+ id: String(title.id),
785
+ tituloId: String(title.id),
786
+ solicitante: '-',
787
+ valor: Number(title.valorTotal || 0),
788
+ politica: 'Aprovação financeira',
789
+ urgencia: 'media',
790
+ dataSolicitacao: title.criadoEm,
791
+ }));
792
+
759
793
  return {
760
794
  kpis: {
761
795
  saldoCaixa: 0,
@@ -773,7 +807,7 @@ export class FinanceService {
773
807
  pessoas: people,
774
808
  categorias: categories,
775
809
  centrosCusto: costCenters,
776
- aprovacoesPendentes: [],
810
+ aprovacoesPendentes,
777
811
  agingInadimplencia: [],
778
812
  cenarios: [],
779
813
  transferencias: [],
@@ -819,6 +853,63 @@ export class FinanceService {
819
853
  return this.createTitle(data, 'payable', locale, userId);
820
854
  }
821
855
 
856
+ async updateAccountsPayableTitle(
857
+ id: number,
858
+ data: CreateFinancialTitleDto,
859
+ locale: string,
860
+ userId?: number,
861
+ ) {
862
+ return this.updateDraftTitle(id, data, 'payable', locale, userId);
863
+ }
864
+
865
+ async approveAccountsPayableTitle(id: number, locale: string, userId?: number) {
866
+ return this.approveTitle(id, 'payable', locale, userId);
867
+ }
868
+
869
+ async rejectAccountsPayableTitle(
870
+ id: number,
871
+ data: RejectTitleDto,
872
+ locale: string,
873
+ userId?: number,
874
+ ) {
875
+ return this.rejectTitle(id, data, 'payable', locale, userId);
876
+ }
877
+
878
+ async cancelAccountsPayableTitle(
879
+ id: number,
880
+ data: RejectTitleDto,
881
+ locale: string,
882
+ userId?: number,
883
+ ) {
884
+ return this.cancelTitle(id, data, 'payable', locale, userId);
885
+ }
886
+
887
+ async settleAccountsPayableInstallment(
888
+ id: number,
889
+ data: SettleInstallmentDto,
890
+ locale: string,
891
+ userId?: number,
892
+ ) {
893
+ return this.settleTitleInstallment(id, data, 'payable', locale, userId);
894
+ }
895
+
896
+ async reverseAccountsPayableSettlement(
897
+ id: number,
898
+ settlementId: number,
899
+ data: ReverseSettlementDto,
900
+ locale: string,
901
+ userId?: number,
902
+ ) {
903
+ return this.reverseTitleSettlement(
904
+ id,
905
+ settlementId,
906
+ data,
907
+ 'payable',
908
+ locale,
909
+ userId,
910
+ );
911
+ }
912
+
822
913
  async createAccountsReceivableTitle(
823
914
  data: CreateFinancialTitleDto,
824
915
  locale: string,
@@ -827,6 +918,58 @@ export class FinanceService {
827
918
  return this.createTitle(data, 'receivable', locale, userId);
828
919
  }
829
920
 
921
+ async updateAccountsReceivableTitle(
922
+ id: number,
923
+ data: CreateFinancialTitleDto,
924
+ locale: string,
925
+ userId?: number,
926
+ ) {
927
+ return this.updateDraftTitle(id, data, 'receivable', locale, userId);
928
+ }
929
+
930
+ async approveAccountsReceivableTitle(
931
+ id: number,
932
+ locale: string,
933
+ userId?: number,
934
+ ) {
935
+ return this.approveTitle(id, 'receivable', locale, userId);
936
+ }
937
+
938
+ async cancelAccountsReceivableTitle(
939
+ id: number,
940
+ data: RejectTitleDto,
941
+ locale: string,
942
+ userId?: number,
943
+ ) {
944
+ return this.cancelTitle(id, data, 'receivable', locale, userId);
945
+ }
946
+
947
+ async settleAccountsReceivableInstallment(
948
+ id: number,
949
+ data: SettleInstallmentDto,
950
+ locale: string,
951
+ userId?: number,
952
+ ) {
953
+ return this.settleTitleInstallment(id, data, 'receivable', locale, userId);
954
+ }
955
+
956
+ async reverseAccountsReceivableSettlement(
957
+ id: number,
958
+ settlementId: number,
959
+ data: ReverseSettlementDto,
960
+ locale: string,
961
+ userId?: number,
962
+ ) {
963
+ return this.reverseTitleSettlement(
964
+ id,
965
+ settlementId,
966
+ data,
967
+ 'receivable',
968
+ locale,
969
+ userId,
970
+ );
971
+ }
972
+
830
973
  async createTag(data: CreateFinanceTagDto) {
831
974
  const slug = this.normalizeTagSlug(data.name);
832
975
 
@@ -1146,6 +1289,497 @@ export class FinanceService {
1146
1289
  }));
1147
1290
  }
1148
1291
 
1292
+ async exportBankStatementsCsv(bankAccountId: number) {
1293
+ const statements = await this.listBankStatements(bankAccountId);
1294
+
1295
+ const headers = [
1296
+ 'id',
1297
+ 'data',
1298
+ 'descricao',
1299
+ 'valor',
1300
+ 'tipo',
1301
+ 'status_conciliacao',
1302
+ 'conta_bancaria_id',
1303
+ ];
1304
+
1305
+ const lines = statements.map((statement) => {
1306
+ const columns = [
1307
+ statement.id,
1308
+ statement.data,
1309
+ statement.descricao,
1310
+ String(statement.valor),
1311
+ statement.tipo,
1312
+ statement.statusConciliacao,
1313
+ statement.contaBancariaId,
1314
+ ];
1315
+
1316
+ return columns.map((column) => this.escapeCsvCell(column)).join(';');
1317
+ });
1318
+
1319
+ const csv = ['\uFEFF' + headers.join(';'), ...lines].join('\n');
1320
+ const fileName = `extrato-bancario-${bankAccountId}-${new Date()
1321
+ .toISOString()
1322
+ .slice(0, 10)}.csv`;
1323
+
1324
+ return {
1325
+ csv,
1326
+ fileName,
1327
+ };
1328
+ }
1329
+
1330
+ async importBankStatements(
1331
+ bankAccountId: number,
1332
+ file: MulterFile,
1333
+ locale: string,
1334
+ userId?: number,
1335
+ ) {
1336
+ if (!file) {
1337
+ throw new BadRequestException('File is required');
1338
+ }
1339
+
1340
+ const bankAccount = await this.prisma.bank_account.findUnique({
1341
+ where: { id: bankAccountId },
1342
+ select: { id: true },
1343
+ });
1344
+
1345
+ if (!bankAccount) {
1346
+ throw new NotFoundException(
1347
+ getLocaleText('itemNotFound', locale, 'Bank account not found').replace(
1348
+ '{{item}}',
1349
+ 'Bank account',
1350
+ ),
1351
+ );
1352
+ }
1353
+
1354
+ const sourceType = this.detectStatementSourceType(file);
1355
+ const uploadedFile = await this.fileService.upload('finance/statements', file);
1356
+ const rawContent = await this.getUploadedFileText(file);
1357
+ const parsedEntries =
1358
+ sourceType === 'ofx'
1359
+ ? this.parseOfxStatements(rawContent)
1360
+ : this.parseCsvStatements(rawContent);
1361
+
1362
+ if (parsedEntries.length === 0) {
1363
+ throw new BadRequestException(
1364
+ 'No valid statement rows were found in the uploaded file',
1365
+ );
1366
+ }
1367
+
1368
+ const normalizedEntries = parsedEntries
1369
+ .filter((entry) => entry.postedDate && Number.isFinite(entry.amount))
1370
+ .map((entry, index) => {
1371
+ const postedDate = new Date(entry.postedDate);
1372
+
1373
+ if (Number.isNaN(postedDate.getTime())) {
1374
+ return null;
1375
+ }
1376
+
1377
+ const amountCents = this.toCents(entry.amount);
1378
+
1379
+ if (!Number.isFinite(amountCents) || amountCents === 0) {
1380
+ return null;
1381
+ }
1382
+
1383
+ const dedupeBase = [
1384
+ bankAccountId,
1385
+ postedDate.toISOString().slice(0, 10),
1386
+ amountCents,
1387
+ entry.externalId || '',
1388
+ entry.description || '',
1389
+ index,
1390
+ ].join('|');
1391
+
1392
+ return {
1393
+ postedDate,
1394
+ amountCents,
1395
+ externalId: entry.externalId || null,
1396
+ description: (entry.description || 'Lançamento importado').slice(
1397
+ 0,
1398
+ 1000,
1399
+ ),
1400
+ dedupeKey: this.createShortHash(`statement-line:${dedupeBase}`, 64),
1401
+ };
1402
+ })
1403
+ .filter(Boolean) as Array<{
1404
+ postedDate: Date;
1405
+ amountCents: number;
1406
+ externalId: string | null;
1407
+ description: string;
1408
+ dedupeKey: string;
1409
+ }>;
1410
+
1411
+ if (normalizedEntries.length === 0) {
1412
+ throw new BadRequestException('No valid financial entries were parsed');
1413
+ }
1414
+
1415
+ const statementFingerprint = this.createShortHash(
1416
+ [
1417
+ bankAccountId,
1418
+ sourceType,
1419
+ uploadedFile.id,
1420
+ file.originalname,
1421
+ file.size,
1422
+ normalizedEntries.length,
1423
+ ].join('|'),
1424
+ 24,
1425
+ );
1426
+
1427
+ const statement = await this.prisma.bank_statement.create({
1428
+ data: {
1429
+ bank_account_id: bankAccountId,
1430
+ source_type: sourceType,
1431
+ idempotency_key: `import-${uploadedFile.id}-${statementFingerprint}`,
1432
+ period_start: normalizedEntries
1433
+ .map((entry) => entry.postedDate)
1434
+ .sort((a, b) => a.getTime() - b.getTime())[0],
1435
+ period_end: normalizedEntries
1436
+ .map((entry) => entry.postedDate)
1437
+ .sort((a, b) => b.getTime() - a.getTime())[0],
1438
+ imported_at: new Date(),
1439
+ imported_by_user_id: userId || null,
1440
+ },
1441
+ select: { id: true },
1442
+ });
1443
+
1444
+ await this.prisma.bank_statement_line.createMany({
1445
+ data: normalizedEntries.map((entry) => ({
1446
+ bank_statement_id: statement.id,
1447
+ bank_account_id: bankAccountId,
1448
+ external_id: entry.externalId,
1449
+ posted_date: entry.postedDate,
1450
+ amount_cents: entry.amountCents,
1451
+ description: entry.description,
1452
+ status: 'imported',
1453
+ dedupe_key: entry.dedupeKey,
1454
+ })),
1455
+ skipDuplicates: true,
1456
+ });
1457
+
1458
+ return {
1459
+ statementId: String(statement.id),
1460
+ fileId: String(uploadedFile.id),
1461
+ importedRows: normalizedEntries.length,
1462
+ sourceType,
1463
+ };
1464
+ }
1465
+
1466
+ private detectStatementSourceType(file: MulterFile): 'csv' | 'ofx' {
1467
+ const filename = (file.originalname || '').toLowerCase();
1468
+ const mimetype = (file.mimetype || '').toLowerCase();
1469
+
1470
+ if (
1471
+ filename.endsWith('.ofx') ||
1472
+ mimetype.includes('ofx') ||
1473
+ mimetype.includes('x-ofx')
1474
+ ) {
1475
+ return 'ofx';
1476
+ }
1477
+
1478
+ if (
1479
+ filename.endsWith('.csv') ||
1480
+ mimetype.includes('csv') ||
1481
+ mimetype.includes('comma-separated-values')
1482
+ ) {
1483
+ return 'csv';
1484
+ }
1485
+
1486
+ throw new BadRequestException('Only CSV and OFX files are supported');
1487
+ }
1488
+
1489
+ private async getUploadedFileText(file: MulterFile): Promise<string> {
1490
+ let buffer = file.buffer;
1491
+
1492
+ if (!buffer && file.path) {
1493
+ buffer = await readFile(file.path);
1494
+ }
1495
+
1496
+ if (!buffer || buffer.length === 0) {
1497
+ throw new BadRequestException('Uploaded file is empty');
1498
+ }
1499
+
1500
+ const utf8 = buffer.toString('utf8');
1501
+ if (utf8.includes('�')) {
1502
+ return buffer.toString('latin1');
1503
+ }
1504
+
1505
+ return utf8;
1506
+ }
1507
+
1508
+ private parseCsvStatements(content: string) {
1509
+ const normalizedContent = content.replace(/^\uFEFF/, '').trim();
1510
+
1511
+ if (!normalizedContent) {
1512
+ return [];
1513
+ }
1514
+
1515
+ const rows = normalizedContent
1516
+ .split(/\r?\n/)
1517
+ .map((line) => line.trim())
1518
+ .filter((line) => !!line);
1519
+
1520
+ if (rows.length === 0) {
1521
+ return [];
1522
+ }
1523
+
1524
+ const delimiter = this.detectCsvDelimiter(rows[0]);
1525
+ const headerCells = this.parseCsvLine(rows[0], delimiter);
1526
+ const normalizedHeaders = headerCells.map((cell) =>
1527
+ this.normalizeCsvHeader(cell),
1528
+ );
1529
+
1530
+ const headerIndexes = {
1531
+ date: this.findFirstHeaderIndex(normalizedHeaders, [
1532
+ 'data',
1533
+ 'dt',
1534
+ 'posteddate',
1535
+ 'lancamento',
1536
+ 'movimento',
1537
+ ]),
1538
+ description: this.findFirstHeaderIndex(normalizedHeaders, [
1539
+ 'descricao',
1540
+ 'historico',
1541
+ 'memo',
1542
+ 'complemento',
1543
+ ]),
1544
+ amount: this.findFirstHeaderIndex(normalizedHeaders, [
1545
+ 'valor',
1546
+ 'amount',
1547
+ 'montante',
1548
+ ]),
1549
+ debit: this.findFirstHeaderIndex(normalizedHeaders, ['debito', 'debit']),
1550
+ credit: this.findFirstHeaderIndex(normalizedHeaders, [
1551
+ 'credito',
1552
+ 'credit',
1553
+ ]),
1554
+ externalId: this.findFirstHeaderIndex(normalizedHeaders, [
1555
+ 'id',
1556
+ 'documento',
1557
+ 'numero',
1558
+ 'fitid',
1559
+ ]),
1560
+ };
1561
+
1562
+ const hasHeader =
1563
+ headerIndexes.date >= 0 ||
1564
+ headerIndexes.description >= 0 ||
1565
+ headerIndexes.amount >= 0 ||
1566
+ headerIndexes.debit >= 0 ||
1567
+ headerIndexes.credit >= 0;
1568
+
1569
+ const dataRows = hasHeader ? rows.slice(1) : rows;
1570
+
1571
+ return dataRows
1572
+ .map((line) => this.parseCsvLine(line, delimiter))
1573
+ .map((columns) => {
1574
+ const dateRaw =
1575
+ (headerIndexes.date >= 0
1576
+ ? columns[headerIndexes.date]
1577
+ : columns[0]) || '';
1578
+ const descriptionRaw =
1579
+ (headerIndexes.description >= 0
1580
+ ? columns[headerIndexes.description]
1581
+ : columns[1]) || '';
1582
+ const amountRaw =
1583
+ headerIndexes.amount >= 0 ? columns[headerIndexes.amount] : '';
1584
+ const debitRaw =
1585
+ headerIndexes.debit >= 0 ? columns[headerIndexes.debit] : '';
1586
+ const creditRaw =
1587
+ headerIndexes.credit >= 0 ? columns[headerIndexes.credit] : '';
1588
+ const fallbackAmountRaw = columns[2] || '';
1589
+ const externalIdRaw =
1590
+ headerIndexes.externalId >= 0 ? columns[headerIndexes.externalId] : '';
1591
+
1592
+ const postedDate = this.parseStatementDate(dateRaw);
1593
+ if (!postedDate) {
1594
+ return null;
1595
+ }
1596
+
1597
+ const amount = this.resolveCsvAmount(
1598
+ amountRaw || fallbackAmountRaw,
1599
+ debitRaw,
1600
+ creditRaw,
1601
+ );
1602
+
1603
+ if (amount === null) {
1604
+ return null;
1605
+ }
1606
+
1607
+ return {
1608
+ postedDate,
1609
+ amount,
1610
+ description: descriptionRaw?.trim() || 'Movimentação importada',
1611
+ externalId: externalIdRaw?.trim() || null,
1612
+ };
1613
+ })
1614
+ .filter(Boolean) as Array<{
1615
+ postedDate: string;
1616
+ amount: number;
1617
+ description: string;
1618
+ externalId: string | null;
1619
+ }>;
1620
+ }
1621
+
1622
+ private parseOfxStatements(content: string) {
1623
+ const normalized = content.replace(/\r/g, '');
1624
+ const segments = normalized
1625
+ .split(/<STMTTRN>/i)
1626
+ .slice(1)
1627
+ .map((segment) => segment.split(/<\/STMTTRN>/i)[0]);
1628
+
1629
+ return segments
1630
+ .map((segment) => {
1631
+ const dateRaw = this.extractOfxTag(segment, 'DTPOSTED');
1632
+ const amountRaw = this.extractOfxTag(segment, 'TRNAMT');
1633
+ const fitId = this.extractOfxTag(segment, 'FITID');
1634
+ const memo = this.extractOfxTag(segment, 'MEMO');
1635
+ const name = this.extractOfxTag(segment, 'NAME');
1636
+ const trnType = this.extractOfxTag(segment, 'TRNTYPE')?.toLowerCase();
1637
+
1638
+ const postedDate = this.parseStatementDate(dateRaw);
1639
+ const amount = this.toNullableNumber(amountRaw);
1640
+
1641
+ if (!postedDate || amount === null) {
1642
+ return null;
1643
+ }
1644
+
1645
+ let normalizedAmount = amount;
1646
+ if (
1647
+ ['debit', 'payment', 'withdrawal'].includes(trnType || '') &&
1648
+ normalizedAmount > 0
1649
+ ) {
1650
+ normalizedAmount *= -1;
1651
+ }
1652
+
1653
+ const description = [memo, name].filter(Boolean).join(' - ').trim();
1654
+
1655
+ return {
1656
+ postedDate,
1657
+ amount: normalizedAmount,
1658
+ description: description || 'Movimentação OFX',
1659
+ externalId: fitId || null,
1660
+ };
1661
+ })
1662
+ .filter(Boolean) as Array<{
1663
+ postedDate: string;
1664
+ amount: number;
1665
+ description: string;
1666
+ externalId: string | null;
1667
+ }>;
1668
+ }
1669
+
1670
+ private detectCsvDelimiter(line: string) {
1671
+ const delimiters = [';', ',', '\t'];
1672
+ const counts = delimiters.map((delimiter) => ({
1673
+ delimiter,
1674
+ count: (line.match(new RegExp(`\\${delimiter}`, 'g')) || []).length,
1675
+ }));
1676
+
1677
+ return counts.sort((a, b) => b.count - a.count)[0]?.delimiter || ';';
1678
+ }
1679
+
1680
+ private parseCsvLine(line: string, delimiter: string) {
1681
+ const result: string[] = [];
1682
+ let current = '';
1683
+ let inQuotes = false;
1684
+
1685
+ for (let i = 0; i < line.length; i++) {
1686
+ const char = line[i];
1687
+ const next = line[i + 1];
1688
+
1689
+ if (char === '"') {
1690
+ if (inQuotes && next === '"') {
1691
+ current += '"';
1692
+ i++;
1693
+ } else {
1694
+ inQuotes = !inQuotes;
1695
+ }
1696
+ continue;
1697
+ }
1698
+
1699
+ if (char === delimiter && !inQuotes) {
1700
+ result.push(current.trim());
1701
+ current = '';
1702
+ continue;
1703
+ }
1704
+
1705
+ current += char;
1706
+ }
1707
+
1708
+ result.push(current.trim());
1709
+ return result;
1710
+ }
1711
+
1712
+ private normalizeCsvHeader(value: string) {
1713
+ return String(value || '')
1714
+ .normalize('NFD')
1715
+ .replace(/[\u0300-\u036f]/g, '')
1716
+ .toLowerCase()
1717
+ .replace(/[^a-z0-9]/g, '');
1718
+ }
1719
+
1720
+ private findFirstHeaderIndex(headers: string[], candidates: string[]) {
1721
+ return headers.findIndex((header) =>
1722
+ candidates.some((candidate) => header.includes(candidate)),
1723
+ );
1724
+ }
1725
+
1726
+ private parseStatementDate(value?: string) {
1727
+ const raw = String(value || '').trim();
1728
+ if (!raw) {
1729
+ return '';
1730
+ }
1731
+
1732
+ const onlyDateDigits = raw.match(/^(\d{8})/);
1733
+ if (onlyDateDigits?.[1]) {
1734
+ const v = onlyDateDigits[1];
1735
+ return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
1736
+ }
1737
+
1738
+ const normalized = this.normalizeDate(raw);
1739
+ if (normalized) {
1740
+ return normalized;
1741
+ }
1742
+
1743
+ return '';
1744
+ }
1745
+
1746
+ private resolveCsvAmount(
1747
+ amountRaw?: string,
1748
+ debitRaw?: string,
1749
+ creditRaw?: string,
1750
+ ): number | null {
1751
+ const amount = this.toNullableNumber(amountRaw);
1752
+ const debit = this.toNullableNumber(debitRaw);
1753
+ const credit = this.toNullableNumber(creditRaw);
1754
+
1755
+ if (credit !== null || debit !== null) {
1756
+ return (credit || 0) - (debit || 0);
1757
+ }
1758
+
1759
+ if (amount === null) {
1760
+ return null;
1761
+ }
1762
+
1763
+ return amount;
1764
+ }
1765
+
1766
+ private extractOfxTag(segment: string, tag: string) {
1767
+ const regex = new RegExp(`<${tag}>([^\n\r<]*)`, 'i');
1768
+ const match = segment.match(regex);
1769
+ return match?.[1]?.trim() || '';
1770
+ }
1771
+
1772
+ private createShortHash(input: string, length = 40) {
1773
+ return createHash('sha256').update(input).digest('hex').slice(0, length);
1774
+ }
1775
+
1776
+ private escapeCsvCell(value: string | number | null | undefined) {
1777
+ const normalizedValue = String(value ?? '');
1778
+ const escapedValue = normalizedValue.replace(/"/g, '""');
1779
+
1780
+ return `"${escapedValue}"`;
1781
+ }
1782
+
1149
1783
  async createBankAccount(data: CreateBankAccountDto, userId?: number) {
1150
1784
  const accountType = this.mapAccountTypeFromPt(data.type);
1151
1785
 
@@ -1529,137 +2163,1066 @@ export class FinanceService {
1529
2163
  locale: string,
1530
2164
  userId?: number,
1531
2165
  ) {
1532
- const person = await this.prisma.person.findUnique({
1533
- where: { id: data.person_id },
1534
- select: { id: true },
1535
- });
2166
+ const installments = this.normalizeAndValidateInstallments(data, locale);
1536
2167
 
1537
- if (!person) {
1538
- throw new BadRequestException(
1539
- getLocaleText('personNotFound', locale, 'Person not found'),
1540
- );
1541
- }
1542
-
1543
- if (data.finance_category_id) {
1544
- const category = await this.prisma.finance_category.findUnique({
1545
- where: { id: data.finance_category_id },
2168
+ const createdTitleId = await this.prisma.$transaction(async (tx) => {
2169
+ const person = await tx.person.findUnique({
2170
+ where: { id: data.person_id },
1546
2171
  select: { id: true },
1547
2172
  });
1548
2173
 
1549
- if (!category) {
2174
+ if (!person) {
1550
2175
  throw new BadRequestException(
1551
- getLocaleText('categoryNotFound', locale, 'Category not found'),
2176
+ getLocaleText('personNotFound', locale, 'Person not found'),
1552
2177
  );
1553
2178
  }
1554
- }
1555
2179
 
1556
- if (data.cost_center_id) {
1557
- const costCenter = await this.prisma.cost_center.findUnique({
1558
- where: { id: data.cost_center_id },
1559
- select: { id: true },
1560
- });
2180
+ if (data.finance_category_id) {
2181
+ const category = await tx.finance_category.findUnique({
2182
+ where: { id: data.finance_category_id },
2183
+ select: { id: true },
2184
+ });
1561
2185
 
1562
- if (!costCenter) {
1563
- throw new BadRequestException(
1564
- getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
1565
- );
2186
+ if (!category) {
2187
+ throw new BadRequestException(
2188
+ getLocaleText('categoryNotFound', locale, 'Category not found'),
2189
+ );
2190
+ }
1566
2191
  }
1567
- }
1568
2192
 
1569
- const installments =
1570
- data.installments && data.installments.length > 0
1571
- ? data.installments
1572
- : [
1573
- {
1574
- installment_number: 1,
1575
- due_date: data.due_date,
1576
- amount: data.total_amount,
1577
- },
1578
- ];
2193
+ if (data.cost_center_id) {
2194
+ const costCenter = await tx.cost_center.findUnique({
2195
+ where: { id: data.cost_center_id },
2196
+ select: { id: true },
2197
+ });
1579
2198
 
1580
- const title = await this.prisma.financial_title.create({
1581
- data: {
1582
- person_id: data.person_id,
1583
- title_type: titleType,
1584
- status: 'open',
1585
- document_number: data.document_number,
1586
- description: data.description,
1587
- competence_date: data.competence_date
1588
- ? new Date(data.competence_date)
1589
- : null,
1590
- issue_date: data.issue_date ? new Date(data.issue_date) : null,
1591
- total_amount_cents: this.toCents(data.total_amount),
1592
- finance_category_id: data.finance_category_id,
1593
- created_by_user_id: userId,
1594
- },
1595
- });
2199
+ if (!costCenter) {
2200
+ throw new BadRequestException(
2201
+ getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
2202
+ );
2203
+ }
2204
+ }
1596
2205
 
1597
- const attachmentFileIds = [
1598
- ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
1599
- ];
2206
+ const attachmentFileIds = [
2207
+ ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
2208
+ ];
1600
2209
 
1601
- if (attachmentFileIds.length > 0) {
1602
- const existingFiles = await this.prisma.file.findMany({
1603
- where: {
1604
- id: { in: attachmentFileIds },
1605
- },
1606
- select: {
1607
- id: true,
1608
- },
1609
- });
2210
+ if (attachmentFileIds.length > 0) {
2211
+ const existingFiles = await tx.file.findMany({
2212
+ where: {
2213
+ id: { in: attachmentFileIds },
2214
+ },
2215
+ select: {
2216
+ id: true,
2217
+ },
2218
+ });
1610
2219
 
1611
- const existingFileIds = new Set(existingFiles.map((file) => file.id));
1612
- const invalidFileIds = attachmentFileIds.filter(
1613
- (fileId) => !existingFileIds.has(fileId),
1614
- );
2220
+ const existingFileIds = new Set(existingFiles.map((file) => file.id));
2221
+ const invalidFileIds = attachmentFileIds.filter(
2222
+ (fileId) => !existingFileIds.has(fileId),
2223
+ );
1615
2224
 
1616
- if (invalidFileIds.length > 0) {
2225
+ if (invalidFileIds.length > 0) {
2226
+ throw new BadRequestException(
2227
+ `Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
2228
+ );
2229
+ }
2230
+ }
2231
+
2232
+ await this.assertDateNotInClosedPeriod(
2233
+ tx,
2234
+ data.competence_date
2235
+ ? new Date(data.competence_date)
2236
+ : new Date(installments[0].due_date),
2237
+ 'create title',
2238
+ );
2239
+
2240
+ const title = await tx.financial_title.create({
2241
+ data: {
2242
+ person_id: data.person_id,
2243
+ title_type: titleType,
2244
+ status: 'draft',
2245
+ document_number: data.document_number,
2246
+ description: data.description,
2247
+ competence_date: data.competence_date
2248
+ ? new Date(data.competence_date)
2249
+ : null,
2250
+ issue_date: data.issue_date ? new Date(data.issue_date) : null,
2251
+ total_amount_cents: this.toCents(data.total_amount),
2252
+ finance_category_id: data.finance_category_id,
2253
+ created_by_user_id: userId,
2254
+ },
2255
+ });
2256
+
2257
+ if (attachmentFileIds.length > 0) {
2258
+ await tx.financial_title_attachment.createMany({
2259
+ data: attachmentFileIds.map((fileId) => ({
2260
+ title_id: title.id,
2261
+ file_id: fileId,
2262
+ uploaded_by_user_id: userId,
2263
+ })),
2264
+ });
2265
+ }
2266
+
2267
+ for (let index = 0; index < installments.length; index++) {
2268
+ const installment = installments[index];
2269
+ const amountCents = installment.amount_cents;
2270
+
2271
+ const createdInstallment = await tx.financial_installment.create({
2272
+ data: {
2273
+ title_id: title.id,
2274
+ installment_number: installment.installment_number,
2275
+ competence_date: data.competence_date
2276
+ ? new Date(data.competence_date)
2277
+ : new Date(installment.due_date),
2278
+ due_date: new Date(installment.due_date),
2279
+ amount_cents: amountCents,
2280
+ open_amount_cents: amountCents,
2281
+ status: this.resolveInstallmentStatus(
2282
+ amountCents,
2283
+ amountCents,
2284
+ new Date(installment.due_date),
2285
+ ),
2286
+ notes: data.description,
2287
+ },
2288
+ });
2289
+
2290
+ if (data.cost_center_id) {
2291
+ await tx.installment_allocation.create({
2292
+ data: {
2293
+ installment_id: createdInstallment.id,
2294
+ cost_center_id: data.cost_center_id,
2295
+ allocated_amount_cents: amountCents,
2296
+ },
2297
+ });
2298
+ }
2299
+ }
2300
+
2301
+ await this.createAuditLog(tx, {
2302
+ action: 'CREATE_TITLE',
2303
+ entityTable: 'financial_title',
2304
+ entityId: String(title.id),
2305
+ actorUserId: userId,
2306
+ summary: `Created ${titleType} title ${title.id} in draft`,
2307
+ afterData: JSON.stringify({
2308
+ status: 'draft',
2309
+ total_amount_cents: this.toCents(data.total_amount),
2310
+ }),
2311
+ });
2312
+
2313
+ return title.id;
2314
+ });
2315
+
2316
+ const createdTitle = await this.getTitleById(createdTitleId, titleType, locale);
2317
+ return this.mapTitleToFront(createdTitle, data.payment_channel);
2318
+ }
2319
+
2320
+ private async updateDraftTitle(
2321
+ titleId: number,
2322
+ data: CreateFinancialTitleDto,
2323
+ titleType: TitleType,
2324
+ locale: string,
2325
+ userId?: number,
2326
+ ) {
2327
+ const installments = this.normalizeAndValidateInstallments(data, locale);
2328
+
2329
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
2330
+ const title = await tx.financial_title.findFirst({
2331
+ where: {
2332
+ id: titleId,
2333
+ title_type: titleType,
2334
+ },
2335
+ include: {
2336
+ financial_installment: {
2337
+ select: {
2338
+ id: true,
2339
+ },
2340
+ },
2341
+ },
2342
+ });
2343
+
2344
+ if (!title) {
2345
+ throw new NotFoundException(
2346
+ getLocaleText(
2347
+ 'itemNotFound',
2348
+ locale,
2349
+ `Financial title with ID ${titleId} not found`,
2350
+ ).replace('{{item}}', 'Financial title'),
2351
+ );
2352
+ }
2353
+
2354
+ if (title.status !== 'draft') {
2355
+ throw new BadRequestException('Only draft titles can be edited');
2356
+ }
2357
+
2358
+ const person = await tx.person.findUnique({
2359
+ where: { id: data.person_id },
2360
+ select: { id: true },
2361
+ });
2362
+
2363
+ if (!person) {
2364
+ throw new BadRequestException(
2365
+ getLocaleText('personNotFound', locale, 'Person not found'),
2366
+ );
2367
+ }
2368
+
2369
+ if (data.finance_category_id) {
2370
+ const category = await tx.finance_category.findUnique({
2371
+ where: { id: data.finance_category_id },
2372
+ select: { id: true },
2373
+ });
2374
+
2375
+ if (!category) {
2376
+ throw new BadRequestException(
2377
+ getLocaleText('categoryNotFound', locale, 'Category not found'),
2378
+ );
2379
+ }
2380
+ }
2381
+
2382
+ if (data.cost_center_id) {
2383
+ const costCenter = await tx.cost_center.findUnique({
2384
+ where: { id: data.cost_center_id },
2385
+ select: { id: true },
2386
+ });
2387
+
2388
+ if (!costCenter) {
2389
+ throw new BadRequestException(
2390
+ getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
2391
+ );
2392
+ }
2393
+ }
2394
+
2395
+ const attachmentFileIds = [
2396
+ ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
2397
+ ];
2398
+
2399
+ if (attachmentFileIds.length > 0) {
2400
+ const existingFiles = await tx.file.findMany({
2401
+ where: {
2402
+ id: { in: attachmentFileIds },
2403
+ },
2404
+ select: {
2405
+ id: true,
2406
+ },
2407
+ });
2408
+
2409
+ const existingFileIds = new Set(existingFiles.map((file) => file.id));
2410
+ const invalidFileIds = attachmentFileIds.filter(
2411
+ (fileId) => !existingFileIds.has(fileId),
2412
+ );
2413
+
2414
+ if (invalidFileIds.length > 0) {
2415
+ throw new BadRequestException(
2416
+ `Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
2417
+ );
2418
+ }
2419
+ }
2420
+
2421
+ await this.assertDateNotInClosedPeriod(
2422
+ tx,
2423
+ data.competence_date
2424
+ ? new Date(data.competence_date)
2425
+ : new Date(installments[0].due_date),
2426
+ 'update title',
2427
+ );
2428
+
2429
+ const installmentIds = title.financial_installment.map((item) => item.id);
2430
+
2431
+ if (installmentIds.length > 0) {
2432
+ const hasSettlements = await tx.settlement_allocation.findFirst({
2433
+ where: {
2434
+ installment_id: {
2435
+ in: installmentIds,
2436
+ },
2437
+ },
2438
+ select: {
2439
+ id: true,
2440
+ },
2441
+ });
2442
+
2443
+ if (hasSettlements) {
2444
+ throw new BadRequestException(
2445
+ 'Cannot edit title with settled installments',
2446
+ );
2447
+ }
2448
+ }
2449
+
2450
+ await tx.financial_title.update({
2451
+ where: { id: title.id },
2452
+ data: {
2453
+ person_id: data.person_id,
2454
+ document_number: data.document_number,
2455
+ description: data.description,
2456
+ competence_date: data.competence_date
2457
+ ? new Date(data.competence_date)
2458
+ : null,
2459
+ issue_date: data.issue_date ? new Date(data.issue_date) : null,
2460
+ total_amount_cents: this.toCents(data.total_amount),
2461
+ finance_category_id: data.finance_category_id,
2462
+ },
2463
+ });
2464
+
2465
+ if (installmentIds.length > 0) {
2466
+ await tx.installment_allocation.deleteMany({
2467
+ where: {
2468
+ installment_id: {
2469
+ in: installmentIds,
2470
+ },
2471
+ },
2472
+ });
2473
+
2474
+ await tx.financial_installment_tag.deleteMany({
2475
+ where: {
2476
+ installment_id: {
2477
+ in: installmentIds,
2478
+ },
2479
+ },
2480
+ });
2481
+
2482
+ await tx.financial_installment.deleteMany({
2483
+ where: {
2484
+ title_id: title.id,
2485
+ },
2486
+ });
2487
+ }
2488
+
2489
+ for (const installment of installments) {
2490
+ const amountCents = installment.amount_cents;
2491
+
2492
+ const createdInstallment = await tx.financial_installment.create({
2493
+ data: {
2494
+ title_id: title.id,
2495
+ installment_number: installment.installment_number,
2496
+ competence_date: data.competence_date
2497
+ ? new Date(data.competence_date)
2498
+ : new Date(installment.due_date),
2499
+ due_date: new Date(installment.due_date),
2500
+ amount_cents: amountCents,
2501
+ open_amount_cents: amountCents,
2502
+ status: this.resolveInstallmentStatus(
2503
+ amountCents,
2504
+ amountCents,
2505
+ new Date(installment.due_date),
2506
+ ),
2507
+ notes: data.description,
2508
+ },
2509
+ });
2510
+
2511
+ if (data.cost_center_id) {
2512
+ await tx.installment_allocation.create({
2513
+ data: {
2514
+ installment_id: createdInstallment.id,
2515
+ cost_center_id: data.cost_center_id,
2516
+ allocated_amount_cents: amountCents,
2517
+ },
2518
+ });
2519
+ }
2520
+ }
2521
+
2522
+ if (data.attachment_file_ids) {
2523
+ await tx.financial_title_attachment.deleteMany({
2524
+ where: {
2525
+ title_id: title.id,
2526
+ },
2527
+ });
2528
+
2529
+ if (attachmentFileIds.length > 0) {
2530
+ await tx.financial_title_attachment.createMany({
2531
+ data: attachmentFileIds.map((fileId) => ({
2532
+ title_id: title.id,
2533
+ file_id: fileId,
2534
+ uploaded_by_user_id: userId,
2535
+ })),
2536
+ });
2537
+ }
2538
+ }
2539
+
2540
+ await this.createAuditLog(tx, {
2541
+ action: 'UPDATE_TITLE',
2542
+ entityTable: 'financial_title',
2543
+ entityId: String(title.id),
2544
+ actorUserId: userId,
2545
+ summary: `Updated draft ${titleType} title ${title.id}`,
2546
+ beforeData: JSON.stringify({ status: title.status }),
2547
+ afterData: JSON.stringify({
2548
+ status: title.status,
2549
+ total_amount_cents: this.toCents(data.total_amount),
2550
+ }),
2551
+ });
2552
+
2553
+ return tx.financial_title.findFirst({
2554
+ where: {
2555
+ id: title.id,
2556
+ title_type: titleType,
2557
+ },
2558
+ include: this.defaultTitleInclude(),
2559
+ });
2560
+ });
2561
+
2562
+ if (!updatedTitle) {
2563
+ throw new NotFoundException('Financial title not found');
2564
+ }
2565
+
2566
+ return this.mapTitleToFront(updatedTitle, data.payment_channel);
2567
+ }
2568
+
2569
+ private normalizeAndValidateInstallments(
2570
+ data: CreateFinancialTitleDto,
2571
+ locale: string,
2572
+ ) {
2573
+ const fallbackDueDate = data.due_date;
2574
+ const totalAmountCents = this.toCents(data.total_amount);
2575
+ const sourceInstallments =
2576
+ data.installments && data.installments.length > 0
2577
+ ? data.installments
2578
+ : [
2579
+ {
2580
+ installment_number: 1,
2581
+ due_date: fallbackDueDate,
2582
+ amount: data.total_amount,
2583
+ },
2584
+ ];
2585
+
2586
+ const normalizedInstallments = sourceInstallments.map(
2587
+ (installment, index) => {
2588
+ const installmentDueDate = installment.due_date || fallbackDueDate;
2589
+
2590
+ if (!installmentDueDate) {
2591
+ throw new BadRequestException(
2592
+ getLocaleText(
2593
+ 'installmentDueDateRequired',
2594
+ locale,
2595
+ 'Installment due date is required',
2596
+ ),
2597
+ );
2598
+ }
2599
+
2600
+ const amountCents = this.toCents(installment.amount);
2601
+
2602
+ if (amountCents <= 0) {
2603
+ throw new BadRequestException(
2604
+ getLocaleText(
2605
+ 'installmentAmountInvalid',
2606
+ locale,
2607
+ 'Installment amount must be greater than zero',
2608
+ ),
2609
+ );
2610
+ }
2611
+
2612
+ return {
2613
+ installment_number: installment.installment_number || index + 1,
2614
+ due_date: installmentDueDate,
2615
+ amount_cents: amountCents,
2616
+ };
2617
+ },
2618
+ );
2619
+
2620
+ const installmentsTotalCents = normalizedInstallments.reduce(
2621
+ (acc, installment) => acc + installment.amount_cents,
2622
+ 0,
2623
+ );
2624
+
2625
+ if (installmentsTotalCents !== totalAmountCents) {
2626
+ throw new BadRequestException(
2627
+ getLocaleText(
2628
+ 'installmentsTotalMismatch',
2629
+ locale,
2630
+ 'Installments total must be equal to title total amount',
2631
+ ),
2632
+ );
2633
+ }
2634
+
2635
+ return normalizedInstallments;
2636
+ }
2637
+
2638
+ private async approveTitle(
2639
+ titleId: number,
2640
+ titleType: TitleType,
2641
+ locale: string,
2642
+ userId?: number,
2643
+ ) {
2644
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
2645
+ const title = await tx.financial_title.findFirst({
2646
+ where: {
2647
+ id: titleId,
2648
+ title_type: titleType,
2649
+ },
2650
+ select: {
2651
+ id: true,
2652
+ status: true,
2653
+ competence_date: true,
2654
+ },
2655
+ });
2656
+
2657
+ if (!title) {
2658
+ throw new NotFoundException(
2659
+ getLocaleText(
2660
+ 'itemNotFound',
2661
+ locale,
2662
+ `Financial title with ID ${titleId} not found`,
2663
+ ).replace('{{item}}', 'Financial title'),
2664
+ );
2665
+ }
2666
+
2667
+ await this.assertDateNotInClosedPeriod(
2668
+ tx,
2669
+ title.competence_date,
2670
+ 'approve title',
2671
+ );
2672
+
2673
+ if (title.status !== 'draft') {
2674
+ throw new BadRequestException('Only draft titles can be approved');
2675
+ }
2676
+
2677
+ await tx.financial_title.update({
2678
+ where: { id: title.id },
2679
+ data: {
2680
+ status: 'open',
2681
+ },
2682
+ });
2683
+
2684
+ await this.createAuditLog(tx, {
2685
+ action: 'APPROVE_TITLE',
2686
+ entityTable: 'financial_title',
2687
+ entityId: String(title.id),
2688
+ actorUserId: userId,
2689
+ summary: `Approved ${titleType} title ${title.id}`,
2690
+ beforeData: JSON.stringify({ status: title.status }),
2691
+ afterData: JSON.stringify({ status: 'open' }),
2692
+ });
2693
+
2694
+ return tx.financial_title.findFirst({
2695
+ where: {
2696
+ id: title.id,
2697
+ title_type: titleType,
2698
+ },
2699
+ include: this.defaultTitleInclude(),
2700
+ });
2701
+ });
2702
+
2703
+ if (!updatedTitle) {
2704
+ throw new NotFoundException('Financial title not found');
2705
+ }
2706
+
2707
+ return this.mapTitleToFront(updatedTitle);
2708
+ }
2709
+
2710
+ private async rejectTitle(
2711
+ titleId: number,
2712
+ data: RejectTitleDto,
2713
+ titleType: TitleType,
2714
+ locale: string,
2715
+ userId?: number,
2716
+ ) {
2717
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
2718
+ const title = await tx.financial_title.findFirst({
2719
+ where: {
2720
+ id: titleId,
2721
+ title_type: titleType,
2722
+ },
2723
+ select: {
2724
+ id: true,
2725
+ status: true,
2726
+ competence_date: true,
2727
+ },
2728
+ });
2729
+
2730
+ if (!title) {
2731
+ throw new NotFoundException(
2732
+ getLocaleText(
2733
+ 'itemNotFound',
2734
+ locale,
2735
+ `Financial title with ID ${titleId} not found`,
2736
+ ).replace('{{item}}', 'Financial title'),
2737
+ );
2738
+ }
2739
+
2740
+ await this.assertDateNotInClosedPeriod(
2741
+ tx,
2742
+ title.competence_date,
2743
+ 'reject title',
2744
+ );
2745
+
2746
+ if (title.status !== 'draft') {
2747
+ throw new BadRequestException('Only draft titles can be rejected');
2748
+ }
2749
+
2750
+ await tx.financial_title.update({
2751
+ where: { id: title.id },
2752
+ data: {
2753
+ status: 'canceled',
2754
+ },
2755
+ });
2756
+
2757
+ await this.createAuditLog(tx, {
2758
+ action: 'REJECT_TITLE',
2759
+ entityTable: 'financial_title',
2760
+ entityId: String(title.id),
2761
+ actorUserId: userId,
2762
+ summary: `Rejected ${titleType} title ${title.id}`,
2763
+ beforeData: JSON.stringify({ status: title.status }),
2764
+ afterData: JSON.stringify({
2765
+ status: 'canceled',
2766
+ reason: data?.reason || null,
2767
+ }),
2768
+ });
2769
+
2770
+ return tx.financial_title.findFirst({
2771
+ where: {
2772
+ id: title.id,
2773
+ title_type: titleType,
2774
+ },
2775
+ include: this.defaultTitleInclude(),
2776
+ });
2777
+ });
2778
+
2779
+ if (!updatedTitle) {
2780
+ throw new NotFoundException('Financial title not found');
2781
+ }
2782
+
2783
+ return this.mapTitleToFront(updatedTitle);
2784
+ }
2785
+
2786
+ private async cancelTitle(
2787
+ titleId: number,
2788
+ data: RejectTitleDto,
2789
+ titleType: TitleType,
2790
+ locale: string,
2791
+ userId?: number,
2792
+ ) {
2793
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
2794
+ const title = await tx.financial_title.findFirst({
2795
+ where: {
2796
+ id: titleId,
2797
+ title_type: titleType,
2798
+ },
2799
+ select: {
2800
+ id: true,
2801
+ status: true,
2802
+ competence_date: true,
2803
+ },
2804
+ });
2805
+
2806
+ if (!title) {
2807
+ throw new NotFoundException(
2808
+ getLocaleText(
2809
+ 'itemNotFound',
2810
+ locale,
2811
+ `Financial title with ID ${titleId} not found`,
2812
+ ).replace('{{item}}', 'Financial title'),
2813
+ );
2814
+ }
2815
+
2816
+ if (title.status === 'settled' || title.status === 'canceled') {
2817
+ throw new BadRequestException('Title cannot be canceled in current status');
2818
+ }
2819
+
2820
+ await this.assertDateNotInClosedPeriod(
2821
+ tx,
2822
+ title.competence_date,
2823
+ 'cancel title',
2824
+ );
2825
+
2826
+ await tx.financial_title.update({
2827
+ where: { id: title.id },
2828
+ data: {
2829
+ status: 'canceled',
2830
+ },
2831
+ });
2832
+
2833
+ await this.createAuditLog(tx, {
2834
+ action: 'CANCEL_TITLE',
2835
+ entityTable: 'financial_title',
2836
+ entityId: String(title.id),
2837
+ actorUserId: userId,
2838
+ summary: `Canceled ${titleType} title ${title.id}`,
2839
+ beforeData: JSON.stringify({ status: title.status }),
2840
+ afterData: JSON.stringify({
2841
+ status: 'canceled',
2842
+ reason: data?.reason || null,
2843
+ }),
2844
+ });
2845
+
2846
+ return tx.financial_title.findFirst({
2847
+ where: {
2848
+ id: title.id,
2849
+ title_type: titleType,
2850
+ },
2851
+ include: this.defaultTitleInclude(),
2852
+ });
2853
+ });
2854
+
2855
+ if (!updatedTitle) {
2856
+ throw new NotFoundException('Financial title not found');
2857
+ }
2858
+
2859
+ return this.mapTitleToFront(updatedTitle);
2860
+ }
2861
+
2862
+ private async settleTitleInstallment(
2863
+ titleId: number,
2864
+ data: SettleInstallmentDto,
2865
+ titleType: TitleType,
2866
+ locale: string,
2867
+ userId?: number,
2868
+ ) {
2869
+ const amountCents = this.toCents(data.amount);
2870
+
2871
+ if (amountCents <= 0) {
2872
+ throw new BadRequestException('Settlement amount must be greater than zero');
2873
+ }
2874
+
2875
+ const settledAt = data.settled_at ? new Date(data.settled_at) : new Date();
2876
+ if (Number.isNaN(settledAt.getTime())) {
2877
+ throw new BadRequestException('Invalid settlement date');
2878
+ }
2879
+
2880
+ const result = await this.prisma.$transaction(async (tx) => {
2881
+ const title = await tx.financial_title.findFirst({
2882
+ where: {
2883
+ id: titleId,
2884
+ title_type: titleType,
2885
+ },
2886
+ select: {
2887
+ id: true,
2888
+ person_id: true,
2889
+ status: true,
2890
+ competence_date: true,
2891
+ },
2892
+ });
2893
+
2894
+ if (!title) {
2895
+ throw new NotFoundException(
2896
+ getLocaleText(
2897
+ 'itemNotFound',
2898
+ locale,
2899
+ `Financial title with ID ${titleId} not found`,
2900
+ ).replace('{{item}}', 'Financial title'),
2901
+ );
2902
+ }
2903
+
2904
+ if (!['open', 'partial'].includes(title.status)) {
1617
2905
  throw new BadRequestException(
1618
- `Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
2906
+ 'Only open/partial titles can be settled',
1619
2907
  );
1620
2908
  }
1621
2909
 
1622
- await this.prisma.financial_title_attachment.createMany({
1623
- data: attachmentFileIds.map((fileId) => ({
2910
+ await this.assertDateNotInClosedPeriod(
2911
+ tx,
2912
+ title.competence_date,
2913
+ 'settle installment',
2914
+ );
2915
+
2916
+ const installment = await tx.financial_installment.findFirst({
2917
+ where: {
2918
+ id: data.installment_id,
1624
2919
  title_id: title.id,
1625
- file_id: fileId,
1626
- uploaded_by_user_id: userId,
1627
- })),
2920
+ },
2921
+ select: {
2922
+ id: true,
2923
+ title_id: true,
2924
+ amount_cents: true,
2925
+ open_amount_cents: true,
2926
+ due_date: true,
2927
+ status: true,
2928
+ },
1628
2929
  });
1629
- }
1630
2930
 
1631
- for (let index = 0; index < installments.length; index++) {
1632
- const installment = installments[index];
1633
- const amountCents = this.toCents(installment.amount);
2931
+ if (!installment) {
2932
+ throw new BadRequestException('Installment not found for this title');
2933
+ }
2934
+
2935
+ if (installment.status === 'settled' || installment.status === 'canceled') {
2936
+ throw new BadRequestException('This installment cannot be settled');
2937
+ }
2938
+
2939
+ if (amountCents > installment.open_amount_cents) {
2940
+ throw new BadRequestException('Settlement amount exceeds open amount');
2941
+ }
2942
+
2943
+ const paymentMethodId = await this.resolvePaymentMethodId(
2944
+ tx,
2945
+ data.payment_channel,
2946
+ );
1634
2947
 
1635
- const createdInstallment = await this.prisma.financial_installment.create({
2948
+ const settlement = await tx.settlement.create({
1636
2949
  data: {
1637
- title_id: title.id,
1638
- installment_number: installment.installment_number || index + 1,
1639
- competence_date: data.competence_date
1640
- ? new Date(data.competence_date)
1641
- : new Date(installment.due_date),
1642
- due_date: new Date(installment.due_date),
2950
+ person_id: title.person_id,
2951
+ bank_account_id: data.bank_account_id || null,
2952
+ payment_method_id: paymentMethodId,
2953
+ settlement_type: titleType,
2954
+ status: 'confirmed',
2955
+ settled_at: settledAt,
1643
2956
  amount_cents: amountCents,
1644
- open_amount_cents: amountCents,
1645
- status: 'open',
1646
- notes: data.description,
2957
+ description: data.description?.trim() || null,
2958
+ created_by_user_id: userId,
1647
2959
  },
1648
2960
  });
1649
2961
 
1650
- if (data.cost_center_id) {
1651
- await this.prisma.installment_allocation.create({
2962
+ await tx.settlement_allocation.create({
2963
+ data: {
2964
+ settlement_id: settlement.id,
2965
+ installment_id: installment.id,
2966
+ allocated_amount_cents: amountCents,
2967
+ discount_cents: this.toCents(data.discount || 0),
2968
+ interest_cents: this.toCents(data.interest || 0),
2969
+ penalty_cents: this.toCents(data.penalty || 0),
2970
+ },
2971
+ });
2972
+
2973
+ const decrementResult = await tx.financial_installment.updateMany({
2974
+ where: {
2975
+ id: installment.id,
2976
+ open_amount_cents: {
2977
+ gte: amountCents,
2978
+ },
2979
+ },
2980
+ data: {
2981
+ open_amount_cents: {
2982
+ decrement: amountCents,
2983
+ },
2984
+ },
2985
+ });
2986
+
2987
+ if (decrementResult.count !== 1) {
2988
+ throw new BadRequestException(
2989
+ 'Installment was updated concurrently, please try again',
2990
+ );
2991
+ }
2992
+
2993
+ const updatedInstallment = await tx.financial_installment.findUnique({
2994
+ where: {
2995
+ id: installment.id,
2996
+ },
2997
+ select: {
2998
+ id: true,
2999
+ amount_cents: true,
3000
+ open_amount_cents: true,
3001
+ due_date: true,
3002
+ status: true,
3003
+ },
3004
+ });
3005
+
3006
+ if (!updatedInstallment) {
3007
+ throw new NotFoundException('Installment not found');
3008
+ }
3009
+
3010
+ const nextInstallmentStatus = this.resolveInstallmentStatus(
3011
+ updatedInstallment.amount_cents,
3012
+ updatedInstallment.open_amount_cents,
3013
+ updatedInstallment.due_date,
3014
+ );
3015
+
3016
+ if (updatedInstallment.status !== nextInstallmentStatus) {
3017
+ await tx.financial_installment.update({
3018
+ where: {
3019
+ id: updatedInstallment.id,
3020
+ },
3021
+ data: {
3022
+ status: nextInstallmentStatus,
3023
+ },
3024
+ });
3025
+ }
3026
+
3027
+ const previousTitleStatus = title.status;
3028
+ const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
3029
+
3030
+ await this.createAuditLog(tx, {
3031
+ action: 'SETTLE_INSTALLMENT',
3032
+ entityTable: 'financial_title',
3033
+ entityId: String(title.id),
3034
+ actorUserId: userId,
3035
+ summary: `Settled installment ${installment.id} of title ${title.id}`,
3036
+ beforeData: JSON.stringify({
3037
+ title_status: previousTitleStatus,
3038
+ installment_open_amount_cents: installment.open_amount_cents,
3039
+ }),
3040
+ afterData: JSON.stringify({
3041
+ title_status: nextTitleStatus,
3042
+ installment_open_amount_cents: updatedInstallment.open_amount_cents,
3043
+ settlement_id: settlement.id,
3044
+ }),
3045
+ });
3046
+
3047
+ const updatedTitle = await tx.financial_title.findFirst({
3048
+ where: {
3049
+ id: title.id,
3050
+ title_type: titleType,
3051
+ },
3052
+ include: this.defaultTitleInclude(),
3053
+ });
3054
+
3055
+ if (!updatedTitle) {
3056
+ throw new NotFoundException('Financial title not found');
3057
+ }
3058
+
3059
+ return {
3060
+ title: updatedTitle,
3061
+ settlementId: settlement.id,
3062
+ };
3063
+ });
3064
+
3065
+ return {
3066
+ ...this.mapTitleToFront(result.title),
3067
+ settlementId: String(result.settlementId),
3068
+ };
3069
+ }
3070
+
3071
+ private async reverseTitleSettlement(
3072
+ titleId: number,
3073
+ settlementId: number,
3074
+ data: ReverseSettlementDto,
3075
+ titleType: TitleType,
3076
+ locale: string,
3077
+ userId?: number,
3078
+ ) {
3079
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
3080
+ const title = await tx.financial_title.findFirst({
3081
+ where: {
3082
+ id: titleId,
3083
+ title_type: titleType,
3084
+ },
3085
+ select: {
3086
+ id: true,
3087
+ status: true,
3088
+ competence_date: true,
3089
+ },
3090
+ });
3091
+
3092
+ if (!title) {
3093
+ throw new NotFoundException(
3094
+ getLocaleText(
3095
+ 'itemNotFound',
3096
+ locale,
3097
+ `Financial title with ID ${titleId} not found`,
3098
+ ).replace('{{item}}', 'Financial title'),
3099
+ );
3100
+ }
3101
+
3102
+ await this.assertDateNotInClosedPeriod(
3103
+ tx,
3104
+ title.competence_date,
3105
+ 'reverse settlement',
3106
+ );
3107
+
3108
+ const settlement = await tx.settlement.findFirst({
3109
+ where: {
3110
+ id: settlementId,
3111
+ settlement_type: titleType,
3112
+ settlement_allocation: {
3113
+ some: {
3114
+ financial_installment: {
3115
+ title_id: title.id,
3116
+ },
3117
+ },
3118
+ },
3119
+ },
3120
+ include: {
3121
+ settlement_allocation: {
3122
+ include: {
3123
+ financial_installment: {
3124
+ select: {
3125
+ id: true,
3126
+ amount_cents: true,
3127
+ open_amount_cents: true,
3128
+ due_date: true,
3129
+ status: true,
3130
+ },
3131
+ },
3132
+ },
3133
+ },
3134
+ },
3135
+ });
3136
+
3137
+ if (!settlement) {
3138
+ throw new NotFoundException('Settlement not found for this title');
3139
+ }
3140
+
3141
+ if (settlement.status === 'reversed') {
3142
+ throw new BadRequestException('This settlement is already reversed');
3143
+ }
3144
+
3145
+ for (const allocation of settlement.settlement_allocation) {
3146
+ const installment = allocation.financial_installment;
3147
+
3148
+ if (!installment) {
3149
+ continue;
3150
+ }
3151
+
3152
+ const nextOpenAmountCents =
3153
+ installment.open_amount_cents + allocation.allocated_amount_cents;
3154
+
3155
+ if (nextOpenAmountCents > installment.amount_cents) {
3156
+ throw new BadRequestException(
3157
+ `Reverse would exceed installment amount for installment ${installment.id}`,
3158
+ );
3159
+ }
3160
+
3161
+ const nextInstallmentStatus = this.resolveInstallmentStatus(
3162
+ installment.amount_cents,
3163
+ nextOpenAmountCents,
3164
+ installment.due_date,
3165
+ );
3166
+
3167
+ await tx.financial_installment.update({
3168
+ where: {
3169
+ id: installment.id,
3170
+ },
1652
3171
  data: {
1653
- installment_id: createdInstallment.id,
1654
- cost_center_id: data.cost_center_id,
1655
- allocated_amount_cents: amountCents,
3172
+ open_amount_cents: nextOpenAmountCents,
3173
+ status: nextInstallmentStatus,
1656
3174
  },
1657
3175
  });
1658
3176
  }
3177
+
3178
+ await tx.settlement.update({
3179
+ where: {
3180
+ id: settlement.id,
3181
+ },
3182
+ data: {
3183
+ status: 'reversed',
3184
+ description: [
3185
+ settlement.description,
3186
+ data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
3187
+ ]
3188
+ .filter(Boolean)
3189
+ .join(' | '),
3190
+ },
3191
+ });
3192
+
3193
+ const previousTitleStatus = title.status;
3194
+ const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
3195
+
3196
+ await this.createAuditLog(tx, {
3197
+ action: 'REVERSE_SETTLEMENT',
3198
+ entityTable: 'financial_title',
3199
+ entityId: String(title.id),
3200
+ actorUserId: userId,
3201
+ summary: `Reversed settlement ${settlement.id} from title ${title.id}`,
3202
+ beforeData: JSON.stringify({
3203
+ title_status: previousTitleStatus,
3204
+ settlement_status: settlement.status,
3205
+ }),
3206
+ afterData: JSON.stringify({
3207
+ title_status: nextTitleStatus,
3208
+ settlement_status: 'reversed',
3209
+ }),
3210
+ });
3211
+
3212
+ return tx.financial_title.findFirst({
3213
+ where: {
3214
+ id: title.id,
3215
+ title_type: titleType,
3216
+ },
3217
+ include: this.defaultTitleInclude(),
3218
+ });
3219
+ });
3220
+
3221
+ if (!updatedTitle) {
3222
+ throw new NotFoundException('Financial title not found');
1659
3223
  }
1660
3224
 
1661
- const createdTitle = await this.getTitleById(title.id, titleType, locale);
1662
- return this.mapTitleToFront(createdTitle, data.payment_channel);
3225
+ return this.mapTitleToFront(updatedTitle);
1663
3226
  }
1664
3227
 
1665
3228
  private async updateTitleTags(
@@ -1853,6 +3416,189 @@ export class FinanceService {
1853
3416
  }));
1854
3417
  }
1855
3418
 
3419
+ private async resolvePaymentMethodId(tx: any, paymentChannel?: string) {
3420
+ const paymentType = this.mapPaymentMethodFromPt(paymentChannel);
3421
+
3422
+ if (!paymentType) {
3423
+ return null;
3424
+ }
3425
+
3426
+ const paymentMethod = await tx.payment_method.findFirst({
3427
+ where: {
3428
+ type: paymentType,
3429
+ status: 'active',
3430
+ },
3431
+ select: {
3432
+ id: true,
3433
+ },
3434
+ });
3435
+
3436
+ return paymentMethod?.id || null;
3437
+ }
3438
+
3439
+ private async assertDateNotInClosedPeriod(
3440
+ tx: any,
3441
+ competenceDate: Date | null | undefined,
3442
+ operation: string,
3443
+ ) {
3444
+ if (!competenceDate) {
3445
+ return;
3446
+ }
3447
+
3448
+ const closedPeriod = await tx.period_close.findFirst({
3449
+ where: {
3450
+ status: 'closed',
3451
+ period_start: {
3452
+ lte: competenceDate,
3453
+ },
3454
+ period_end: {
3455
+ gte: competenceDate,
3456
+ },
3457
+ },
3458
+ select: {
3459
+ id: true,
3460
+ },
3461
+ });
3462
+
3463
+ if (closedPeriod) {
3464
+ throw new BadRequestException(
3465
+ `Cannot ${operation}: competence is in a closed period`,
3466
+ );
3467
+ }
3468
+ }
3469
+
3470
+ private resolveInstallmentStatus(
3471
+ amountCents: number,
3472
+ openAmountCents: number,
3473
+ dueDate: Date,
3474
+ currentStatus?: string,
3475
+ ): InstallmentStatus {
3476
+ if (currentStatus === 'canceled') {
3477
+ return 'canceled';
3478
+ }
3479
+
3480
+ if (openAmountCents <= 0) {
3481
+ return 'settled';
3482
+ }
3483
+
3484
+ if (openAmountCents < amountCents) {
3485
+ return 'partial';
3486
+ }
3487
+
3488
+ const today = new Date();
3489
+ today.setHours(0, 0, 0, 0);
3490
+ const dueDateOnly = new Date(dueDate);
3491
+ dueDateOnly.setHours(0, 0, 0, 0);
3492
+
3493
+ if (dueDateOnly < today) {
3494
+ return 'overdue';
3495
+ }
3496
+
3497
+ return 'open';
3498
+ }
3499
+
3500
+ private deriveTitleStatusFromInstallments(
3501
+ installments: Array<{
3502
+ amount_cents: number;
3503
+ open_amount_cents: number;
3504
+ due_date: Date;
3505
+ status?: string;
3506
+ }>,
3507
+ ): TitleStatus {
3508
+ if (installments.length === 0) {
3509
+ return 'open';
3510
+ }
3511
+
3512
+ const effectiveStatuses = installments.map((installment) =>
3513
+ this.resolveInstallmentStatus(
3514
+ installment.amount_cents,
3515
+ installment.open_amount_cents,
3516
+ installment.due_date,
3517
+ installment.status,
3518
+ ),
3519
+ );
3520
+
3521
+ if (effectiveStatuses.every((status) => status === 'settled')) {
3522
+ return 'settled';
3523
+ }
3524
+
3525
+ const hasPayment = installments.some(
3526
+ (installment) => installment.open_amount_cents < installment.amount_cents,
3527
+ );
3528
+
3529
+ if (hasPayment) {
3530
+ return 'partial';
3531
+ }
3532
+
3533
+ return 'open';
3534
+ }
3535
+
3536
+ private async recalculateTitleStatus(tx: any, titleId: number): Promise<TitleStatus> {
3537
+ const title = await tx.financial_title.findUnique({
3538
+ where: {
3539
+ id: titleId,
3540
+ },
3541
+ select: {
3542
+ id: true,
3543
+ status: true,
3544
+ financial_installment: {
3545
+ select: {
3546
+ amount_cents: true,
3547
+ open_amount_cents: true,
3548
+ due_date: true,
3549
+ status: true,
3550
+ },
3551
+ },
3552
+ },
3553
+ });
3554
+
3555
+ if (!title) {
3556
+ throw new NotFoundException('Financial title not found');
3557
+ }
3558
+
3559
+ const nextStatus = this.deriveTitleStatusFromInstallments(
3560
+ title.financial_installment,
3561
+ );
3562
+
3563
+ if (title.status !== nextStatus) {
3564
+ await tx.financial_title.update({
3565
+ where: {
3566
+ id: title.id,
3567
+ },
3568
+ data: {
3569
+ status: nextStatus,
3570
+ },
3571
+ });
3572
+ }
3573
+
3574
+ return nextStatus;
3575
+ }
3576
+
3577
+ private async createAuditLog(
3578
+ tx: any,
3579
+ data: {
3580
+ actorUserId?: number;
3581
+ action: string;
3582
+ entityTable: string;
3583
+ entityId: string;
3584
+ summary?: string;
3585
+ beforeData?: string;
3586
+ afterData?: string;
3587
+ },
3588
+ ) {
3589
+ await tx.audit_log.create({
3590
+ data: {
3591
+ actor_user_id: data.actorUserId || null,
3592
+ action: data.action,
3593
+ entity_table: data.entityTable,
3594
+ entity_id: data.entityId,
3595
+ summary: data.summary || null,
3596
+ before_data: data.beforeData || null,
3597
+ after_data: data.afterData || null,
3598
+ },
3599
+ });
3600
+ }
3601
+
1856
3602
  private mapTitleToFront(title: any, paymentChannelOverride?: string) {
1857
3603
  const allocations = title.financial_installment.flatMap(
1858
3604
  (installment) => installment.installment_allocation,
@@ -1877,13 +3623,24 @@ export class FinanceService {
1877
3623
  numero: installment.installment_number,
1878
3624
  vencimento: installment.due_date.toISOString(),
1879
3625
  valor: this.fromCents(installment.amount_cents),
1880
- status: this.mapStatusToPt(installment.status),
3626
+ valorAberto: this.fromCents(installment.open_amount_cents),
3627
+ status: this.mapStatusToPt(
3628
+ this.resolveInstallmentStatus(
3629
+ installment.amount_cents,
3630
+ installment.open_amount_cents,
3631
+ installment.due_date,
3632
+ installment.status,
3633
+ ),
3634
+ ),
1881
3635
  metodoPagamento:
1882
3636
  this.mapPaymentMethodToPt(
1883
3637
  installment.settlement_allocation[0]?.settlement?.payment_method?.type,
1884
3638
  ) || paymentChannelOverride || 'transferencia',
1885
3639
  liquidacoes: installment.settlement_allocation.map((allocation) => ({
1886
3640
  id: String(allocation.id),
3641
+ settlementId: allocation.settlement?.id
3642
+ ? String(allocation.settlement.id)
3643
+ : null,
1887
3644
  data: allocation.settlement?.settled_at?.toISOString(),
1888
3645
  valor: this.fromCents(allocation.allocated_amount_cents),
1889
3646
  juros: this.fromCents(allocation.interest_cents || 0),
@@ -1892,6 +3649,7 @@ export class FinanceService {
1892
3649
  contaBancariaId: allocation.settlement?.bank_account_id
1893
3650
  ? String(allocation.settlement.bank_account_id)
1894
3651
  : null,
3652
+ status: allocation.settlement?.status || null,
1895
3653
  metodo:
1896
3654
  this.mapPaymentMethodToPt(
1897
3655
  allocation.settlement?.payment_method?.type,
@@ -1962,7 +3720,6 @@ export class FinanceService {
1962
3720
  private mapStatusToPt(status?: string | null) {
1963
3721
  const statusMap = {
1964
3722
  draft: 'rascunho',
1965
- approved: 'aprovado',
1966
3723
  open: 'aberto',
1967
3724
  partial: 'parcial',
1968
3725
  settled: 'liquidado',
@@ -1994,7 +3751,7 @@ export class FinanceService {
1994
3751
 
1995
3752
  const statusMap = {
1996
3753
  rascunho: 'draft',
1997
- aprovado: 'approved',
3754
+ aprovado: 'open',
1998
3755
  aberto: 'open',
1999
3756
  parcial: 'partial',
2000
3757
  liquidado: 'settled',
@@ -2006,7 +3763,7 @@ export class FinanceService {
2006
3763
  }
2007
3764
 
2008
3765
  private mapPaymentMethodToPt(paymentMethodType?: string | null) {
2009
- const paymentMethodMap = {
3766
+ const paymentMethodMap: Record<string, string> = {
2010
3767
  boleto: 'boleto',
2011
3768
  pix: 'pix',
2012
3769
  ted: 'transferencia',
@@ -2019,6 +3776,30 @@ export class FinanceService {
2019
3776
  return paymentMethodMap[paymentMethodType] || undefined;
2020
3777
  }
2021
3778
 
3779
+ private mapPaymentMethodFromPt(paymentMethodType?: string | null) {
3780
+ if (!paymentMethodType) {
3781
+ return undefined;
3782
+ }
3783
+
3784
+ const paymentMethodMap = {
3785
+ boleto: 'boleto',
3786
+ pix: 'pix',
3787
+ transferencia: 'ted',
3788
+ transferência: 'ted',
3789
+ ted: 'ted',
3790
+ doc: 'doc',
3791
+ cartao: 'card',
3792
+ cartão: 'card',
3793
+ dinheiro: 'cash',
3794
+ cheque: 'other',
3795
+ cash: 'cash',
3796
+ card: 'card',
3797
+ other: 'other',
3798
+ };
3799
+
3800
+ return paymentMethodMap[(paymentMethodType || '').toLowerCase()];
3801
+ }
3802
+
2022
3803
  private mapAccountTypeToPt(accountType?: string | null) {
2023
3804
  const accountTypeMap = {
2024
3805
  checking: 'corrente',