@hed-hog/finance 0.0.239 → 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 (35) hide show
  1. package/README.md +1 -22
  2. package/dist/finance-installments.controller.d.ts +132 -0
  3. package/dist/finance-installments.controller.d.ts.map +1 -1
  4. package/dist/finance-installments.controller.js +52 -0
  5. package/dist/finance-installments.controller.js.map +1 -1
  6. package/dist/finance-statements.controller.d.ts +8 -0
  7. package/dist/finance-statements.controller.d.ts.map +1 -1
  8. package/dist/finance-statements.controller.js +40 -0
  9. package/dist/finance-statements.controller.js.map +1 -1
  10. package/dist/finance.module.d.ts.map +1 -1
  11. package/dist/finance.module.js +1 -0
  12. package/dist/finance.module.js.map +1 -1
  13. package/dist/finance.service.d.ts +160 -2
  14. package/dist/finance.service.d.ts.map +1 -1
  15. package/dist/finance.service.js +626 -8
  16. package/dist/finance.service.js.map +1 -1
  17. package/hedhog/data/route.yaml +54 -0
  18. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +80 -4
  19. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +736 -13
  20. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +1 -1
  21. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +1 -3
  22. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +212 -60
  23. package/hedhog/frontend/messages/en.json +1 -0
  24. package/hedhog/frontend/messages/pt.json +1 -0
  25. package/hedhog/query/0_constraints.sql +2 -0
  26. package/hedhog/query/constraints.sql +86 -0
  27. package/hedhog/table/bank_account.yaml +0 -8
  28. package/hedhog/table/financial_title.yaml +1 -9
  29. package/hedhog/table/settlement.yaml +0 -8
  30. package/package.json +6 -6
  31. package/src/finance-installments.controller.ts +70 -10
  32. package/src/finance-statements.controller.ts +61 -2
  33. package/src/finance.module.ts +2 -1
  34. package/src/finance.service.ts +868 -12
  35. 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';
@@ -31,7 +35,6 @@ type InstallmentStatus =
31
35
  | 'overdue';
32
36
  type TitleStatus =
33
37
  | 'draft'
34
- | 'approved'
35
38
  | 'open'
36
39
  | 'partial'
37
40
  | 'settled'
@@ -46,6 +49,8 @@ export class FinanceService {
46
49
  private readonly prisma: PrismaService,
47
50
  private readonly paginationService: PaginationService,
48
51
  private readonly ai: AiService,
52
+ @Inject(forwardRef(() => FileService))
53
+ private readonly fileService: FileService,
49
54
  ) {}
50
55
 
51
56
  async getAgentExtractInfoFromFile(
@@ -848,6 +853,15 @@ export class FinanceService {
848
853
  return this.createTitle(data, 'payable', locale, userId);
849
854
  }
850
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
+
851
865
  async approveAccountsPayableTitle(id: number, locale: string, userId?: number) {
852
866
  return this.approveTitle(id, 'payable', locale, userId);
853
867
  }
@@ -861,6 +875,15 @@ export class FinanceService {
861
875
  return this.rejectTitle(id, data, 'payable', locale, userId);
862
876
  }
863
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
+
864
887
  async settleAccountsPayableInstallment(
865
888
  id: number,
866
889
  data: SettleInstallmentDto,
@@ -895,6 +918,15 @@ export class FinanceService {
895
918
  return this.createTitle(data, 'receivable', locale, userId);
896
919
  }
897
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
+
898
930
  async approveAccountsReceivableTitle(
899
931
  id: number,
900
932
  locale: string,
@@ -903,6 +935,15 @@ export class FinanceService {
903
935
  return this.approveTitle(id, 'receivable', locale, userId);
904
936
  }
905
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
+
906
947
  async settleAccountsReceivableInstallment(
907
948
  id: number,
908
949
  data: SettleInstallmentDto,
@@ -1248,6 +1289,497 @@ export class FinanceService {
1248
1289
  }));
1249
1290
  }
1250
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
+
1251
1783
  async createBankAccount(data: CreateBankAccountDto, userId?: number) {
1252
1784
  const accountType = this.mapAccountTypeFromPt(data.type);
1253
1785
 
@@ -1785,6 +2317,255 @@ export class FinanceService {
1785
2317
  return this.mapTitleToFront(createdTitle, data.payment_channel);
1786
2318
  }
1787
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
+
1788
2569
  private normalizeAndValidateInstallments(
1789
2570
  data: CreateFinancialTitleDto,
1790
2571
  locale: string,
@@ -1896,7 +2677,7 @@ export class FinanceService {
1896
2677
  await tx.financial_title.update({
1897
2678
  where: { id: title.id },
1898
2679
  data: {
1899
- status: 'approved',
2680
+ status: 'open',
1900
2681
  },
1901
2682
  });
1902
2683
 
@@ -1907,7 +2688,7 @@ export class FinanceService {
1907
2688
  actorUserId: userId,
1908
2689
  summary: `Approved ${titleType} title ${title.id}`,
1909
2690
  beforeData: JSON.stringify({ status: title.status }),
1910
- afterData: JSON.stringify({ status: 'approved' }),
2691
+ afterData: JSON.stringify({ status: 'open' }),
1911
2692
  });
1912
2693
 
1913
2694
  return tx.financial_title.findFirst({
@@ -2002,6 +2783,82 @@ export class FinanceService {
2002
2783
  return this.mapTitleToFront(updatedTitle);
2003
2784
  }
2004
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
+
2005
2862
  private async settleTitleInstallment(
2006
2863
  titleId: number,
2007
2864
  data: SettleInstallmentDto,
@@ -2044,9 +2901,9 @@ export class FinanceService {
2044
2901
  );
2045
2902
  }
2046
2903
 
2047
- if (!['approved', 'open', 'partial'].includes(title.status)) {
2904
+ if (!['open', 'partial'].includes(title.status)) {
2048
2905
  throw new BadRequestException(
2049
- 'Only approved/open/partial titles can be settled',
2906
+ 'Only open/partial titles can be settled',
2050
2907
  );
2051
2908
  }
2052
2909
 
@@ -2863,7 +3720,6 @@ export class FinanceService {
2863
3720
  private mapStatusToPt(status?: string | null) {
2864
3721
  const statusMap = {
2865
3722
  draft: 'rascunho',
2866
- approved: 'aprovado',
2867
3723
  open: 'aberto',
2868
3724
  partial: 'parcial',
2869
3725
  settled: 'liquidado',
@@ -2895,7 +3751,7 @@ export class FinanceService {
2895
3751
 
2896
3752
  const statusMap = {
2897
3753
  rascunho: 'draft',
2898
- aprovado: 'approved',
3754
+ aprovado: 'open',
2899
3755
  aberto: 'open',
2900
3756
  parcial: 'partial',
2901
3757
  liquidado: 'settled',