@hed-hog/finance 0.0.237 → 0.0.239

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 (47) hide show
  1. package/dist/dto/create-finance-tag.dto.d.ts +5 -0
  2. package/dist/dto/create-finance-tag.dto.d.ts.map +1 -0
  3. package/dist/dto/create-finance-tag.dto.js +29 -0
  4. package/dist/dto/create-finance-tag.dto.js.map +1 -0
  5. package/dist/dto/reject-title.dto.d.ts +4 -0
  6. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  7. package/dist/dto/reject-title.dto.js +22 -0
  8. package/dist/dto/reject-title.dto.js.map +1 -0
  9. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  10. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  11. package/dist/dto/reverse-settlement.dto.js +22 -0
  12. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  13. package/dist/dto/settle-installment.dto.d.ts +12 -0
  14. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  15. package/dist/dto/settle-installment.dto.js +71 -0
  16. package/dist/dto/settle-installment.dto.js.map +1 -0
  17. package/dist/dto/update-installment-tags.dto.d.ts +4 -0
  18. package/dist/dto/update-installment-tags.dto.d.ts.map +1 -0
  19. package/dist/dto/update-installment-tags.dto.js +27 -0
  20. package/dist/dto/update-installment-tags.dto.js.map +1 -0
  21. package/dist/finance-data.controller.d.ts +17 -5
  22. package/dist/finance-data.controller.d.ts.map +1 -1
  23. package/dist/finance-installments.controller.d.ts +325 -8
  24. package/dist/finance-installments.controller.d.ts.map +1 -1
  25. package/dist/finance-installments.controller.js +128 -0
  26. package/dist/finance-installments.controller.js.map +1 -1
  27. package/dist/finance.service.d.ts +357 -13
  28. package/dist/finance.service.d.ts.map +1 -1
  29. package/dist/finance.service.js +835 -64
  30. package/dist/finance.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +90 -0
  32. package/hedhog/frontend/app/_lib/use-finance-data.ts.ejs +2 -0
  33. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  34. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +601 -79
  35. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +481 -19
  36. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +598 -69
  37. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +472 -15
  38. package/hedhog/frontend/messages/en.json +38 -0
  39. package/hedhog/frontend/messages/pt.json +38 -0
  40. package/package.json +5 -5
  41. package/src/dto/create-finance-tag.dto.ts +15 -0
  42. package/src/dto/reject-title.dto.ts +7 -0
  43. package/src/dto/reverse-settlement.dto.ts +7 -0
  44. package/src/dto/settle-installment.dto.ts +55 -0
  45. package/src/dto/update-installment-tags.dto.ts +12 -0
  46. package/src/finance-installments.controller.ts +145 -9
  47. package/src/finance.service.ts +1333 -165
@@ -6,19 +6,37 @@ import {
6
6
  BadRequestException,
7
7
  Injectable,
8
8
  Logger,
9
- NotFoundException
9
+ NotFoundException,
10
10
  } from '@nestjs/common';
11
11
  import { CreateBankAccountDto } from './dto/create-bank-account.dto';
12
12
  import { CreateCostCenterDto } from './dto/create-cost-center.dto';
13
13
  import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
14
+ import { CreateFinanceTagDto } from './dto/create-finance-tag.dto';
14
15
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
15
16
  import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
16
17
  import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
18
+ import { RejectTitleDto } from './dto/reject-title.dto';
19
+ import { ReverseSettlementDto } from './dto/reverse-settlement.dto';
20
+ import { SettleInstallmentDto } from './dto/settle-installment.dto';
17
21
  import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
18
22
  import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
19
23
  import { UpdateFinanceCategoryDto } from './dto/update-finance-category.dto';
20
24
 
21
25
  type TitleType = 'payable' | 'receivable';
26
+ type InstallmentStatus =
27
+ | 'open'
28
+ | 'partial'
29
+ | 'settled'
30
+ | 'canceled'
31
+ | 'overdue';
32
+ type TitleStatus =
33
+ | 'draft'
34
+ | 'approved'
35
+ | 'open'
36
+ | 'partial'
37
+ | 'settled'
38
+ | 'canceled'
39
+ | 'overdue';
22
40
 
23
41
  @Injectable()
24
42
  export class FinanceService {
@@ -697,15 +715,15 @@ export class FinanceService {
697
715
 
698
716
  async getData() {
699
717
  const [
700
- payables,
701
- receivables,
702
- people,
703
- categories,
704
- costCenters,
705
- bankAccounts,
706
- tags,
707
- auditLogs,
708
- ] = await Promise.all([
718
+ payablesResult,
719
+ receivablesResult,
720
+ peopleResult,
721
+ categoriesResult,
722
+ costCentersResult,
723
+ bankAccountsResult,
724
+ tagsResult,
725
+ auditLogsResult,
726
+ ] = await Promise.allSettled([
709
727
  this.loadTitles('payable'),
710
728
  this.loadTitles('receivable'),
711
729
  this.loadPeople(),
@@ -716,6 +734,57 @@ export class FinanceService {
716
734
  this.loadAuditLogs(),
717
735
  ]);
718
736
 
737
+ const payables = payablesResult.status === 'fulfilled' ? payablesResult.value : [];
738
+ const receivables =
739
+ receivablesResult.status === 'fulfilled' ? receivablesResult.value : [];
740
+ const people = peopleResult.status === 'fulfilled' ? peopleResult.value : [];
741
+ const categories =
742
+ categoriesResult.status === 'fulfilled' ? categoriesResult.value : [];
743
+ const costCenters =
744
+ costCentersResult.status === 'fulfilled' ? costCentersResult.value : [];
745
+ const bankAccounts =
746
+ bankAccountsResult.status === 'fulfilled' ? bankAccountsResult.value : [];
747
+ const tags = tagsResult.status === 'fulfilled' ? tagsResult.value : [];
748
+ const auditLogs =
749
+ auditLogsResult.status === 'fulfilled' ? auditLogsResult.value : [];
750
+
751
+ if (payablesResult.status === 'rejected') {
752
+ this.logger.error('Failed to load finance payables', payablesResult.reason);
753
+ }
754
+ if (receivablesResult.status === 'rejected') {
755
+ this.logger.error('Failed to load finance receivables', receivablesResult.reason);
756
+ }
757
+ if (peopleResult.status === 'rejected') {
758
+ this.logger.error('Failed to load finance people', peopleResult.reason);
759
+ }
760
+ if (categoriesResult.status === 'rejected') {
761
+ this.logger.error('Failed to load finance categories', categoriesResult.reason);
762
+ }
763
+ if (costCentersResult.status === 'rejected') {
764
+ this.logger.error('Failed to load finance cost centers', costCentersResult.reason);
765
+ }
766
+ if (bankAccountsResult.status === 'rejected') {
767
+ this.logger.error('Failed to load finance bank accounts', bankAccountsResult.reason);
768
+ }
769
+ if (tagsResult.status === 'rejected') {
770
+ this.logger.error('Failed to load finance tags', tagsResult.reason);
771
+ }
772
+ if (auditLogsResult.status === 'rejected') {
773
+ this.logger.error('Failed to load finance audit logs', auditLogsResult.reason);
774
+ }
775
+
776
+ const aprovacoesPendentes = payables
777
+ .filter((title: any) => title.status === 'rascunho')
778
+ .map((title: any) => ({
779
+ id: String(title.id),
780
+ tituloId: String(title.id),
781
+ solicitante: '-',
782
+ valor: Number(title.valorTotal || 0),
783
+ politica: 'Aprovação financeira',
784
+ urgencia: 'media',
785
+ dataSolicitacao: title.criadoEm,
786
+ }));
787
+
719
788
  return {
720
789
  kpis: {
721
790
  saldoCaixa: 0,
@@ -733,7 +802,7 @@ export class FinanceService {
733
802
  pessoas: people,
734
803
  categorias: categories,
735
804
  centrosCusto: costCenters,
736
- aprovacoesPendentes: [],
805
+ aprovacoesPendentes,
737
806
  agingInadimplencia: [],
738
807
  cenarios: [],
739
808
  transferencias: [],
@@ -779,6 +848,45 @@ export class FinanceService {
779
848
  return this.createTitle(data, 'payable', locale, userId);
780
849
  }
781
850
 
851
+ async approveAccountsPayableTitle(id: number, locale: string, userId?: number) {
852
+ return this.approveTitle(id, 'payable', locale, userId);
853
+ }
854
+
855
+ async rejectAccountsPayableTitle(
856
+ id: number,
857
+ data: RejectTitleDto,
858
+ locale: string,
859
+ userId?: number,
860
+ ) {
861
+ return this.rejectTitle(id, data, 'payable', locale, userId);
862
+ }
863
+
864
+ async settleAccountsPayableInstallment(
865
+ id: number,
866
+ data: SettleInstallmentDto,
867
+ locale: string,
868
+ userId?: number,
869
+ ) {
870
+ return this.settleTitleInstallment(id, data, 'payable', locale, userId);
871
+ }
872
+
873
+ async reverseAccountsPayableSettlement(
874
+ id: number,
875
+ settlementId: number,
876
+ data: ReverseSettlementDto,
877
+ locale: string,
878
+ userId?: number,
879
+ ) {
880
+ return this.reverseTitleSettlement(
881
+ id,
882
+ settlementId,
883
+ data,
884
+ 'payable',
885
+ locale,
886
+ userId,
887
+ );
888
+ }
889
+
782
890
  async createAccountsReceivableTitle(
783
891
  data: CreateFinancialTitleDto,
784
892
  locale: string,
@@ -787,6 +895,102 @@ export class FinanceService {
787
895
  return this.createTitle(data, 'receivable', locale, userId);
788
896
  }
789
897
 
898
+ async approveAccountsReceivableTitle(
899
+ id: number,
900
+ locale: string,
901
+ userId?: number,
902
+ ) {
903
+ return this.approveTitle(id, 'receivable', locale, userId);
904
+ }
905
+
906
+ async settleAccountsReceivableInstallment(
907
+ id: number,
908
+ data: SettleInstallmentDto,
909
+ locale: string,
910
+ userId?: number,
911
+ ) {
912
+ return this.settleTitleInstallment(id, data, 'receivable', locale, userId);
913
+ }
914
+
915
+ async reverseAccountsReceivableSettlement(
916
+ id: number,
917
+ settlementId: number,
918
+ data: ReverseSettlementDto,
919
+ locale: string,
920
+ userId?: number,
921
+ ) {
922
+ return this.reverseTitleSettlement(
923
+ id,
924
+ settlementId,
925
+ data,
926
+ 'receivable',
927
+ locale,
928
+ userId,
929
+ );
930
+ }
931
+
932
+ async createTag(data: CreateFinanceTagDto) {
933
+ const slug = this.normalizeTagSlug(data.name);
934
+
935
+ if (!slug) {
936
+ throw new BadRequestException('Tag name is required');
937
+ }
938
+
939
+ const existingTag = await this.prisma.tag.findFirst({
940
+ where: {
941
+ slug,
942
+ },
943
+ select: {
944
+ id: true,
945
+ slug: true,
946
+ color: true,
947
+ },
948
+ });
949
+
950
+ if (existingTag) {
951
+ return {
952
+ id: String(existingTag.id),
953
+ nome: existingTag.slug,
954
+ cor: existingTag.color,
955
+ };
956
+ }
957
+
958
+ const createdTag = await this.prisma.tag.create({
959
+ data: {
960
+ slug,
961
+ color: data.color || '#000000',
962
+ status: 'active',
963
+ },
964
+ select: {
965
+ id: true,
966
+ slug: true,
967
+ color: true,
968
+ },
969
+ });
970
+
971
+ return {
972
+ id: String(createdTag.id),
973
+ nome: createdTag.slug,
974
+ cor: createdTag.color,
975
+ };
976
+ }
977
+
978
+ async updateAccountsPayableInstallmentTags(
979
+ id: number,
980
+ tagIds: number[],
981
+ locale: string,
982
+ ) {
983
+ return this.updateTitleTags(id, 'payable', tagIds, locale);
984
+ }
985
+
986
+ async updateAccountsReceivableInstallmentTags(
987
+ id: number,
988
+ tagIds: number[],
989
+ locale: string,
990
+ ) {
991
+ return this.updateTitleTags(id, 'receivable', tagIds, locale);
992
+ }
993
+
790
994
  async listBankAccounts() {
791
995
  const bankAccounts = await this.prisma.bank_account.findMany({
792
996
  include: {
@@ -1427,197 +1631,924 @@ export class FinanceService {
1427
1631
  locale: string,
1428
1632
  userId?: number,
1429
1633
  ) {
1430
- const person = await this.prisma.person.findUnique({
1431
- where: { id: data.person_id },
1432
- select: { id: true },
1433
- });
1434
-
1435
- if (!person) {
1436
- throw new BadRequestException(
1437
- getLocaleText('personNotFound', locale, 'Person not found'),
1438
- );
1439
- }
1634
+ const installments = this.normalizeAndValidateInstallments(data, locale);
1440
1635
 
1441
- if (data.finance_category_id) {
1442
- const category = await this.prisma.finance_category.findUnique({
1443
- where: { id: data.finance_category_id },
1636
+ const createdTitleId = await this.prisma.$transaction(async (tx) => {
1637
+ const person = await tx.person.findUnique({
1638
+ where: { id: data.person_id },
1444
1639
  select: { id: true },
1445
1640
  });
1446
1641
 
1447
- if (!category) {
1642
+ if (!person) {
1448
1643
  throw new BadRequestException(
1449
- getLocaleText('categoryNotFound', locale, 'Category not found'),
1644
+ getLocaleText('personNotFound', locale, 'Person not found'),
1450
1645
  );
1451
1646
  }
1452
- }
1453
1647
 
1454
- if (data.cost_center_id) {
1455
- const costCenter = await this.prisma.cost_center.findUnique({
1456
- where: { id: data.cost_center_id },
1457
- select: { id: true },
1458
- });
1648
+ if (data.finance_category_id) {
1649
+ const category = await tx.finance_category.findUnique({
1650
+ where: { id: data.finance_category_id },
1651
+ select: { id: true },
1652
+ });
1459
1653
 
1460
- if (!costCenter) {
1461
- throw new BadRequestException(
1462
- getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
1463
- );
1654
+ if (!category) {
1655
+ throw new BadRequestException(
1656
+ getLocaleText('categoryNotFound', locale, 'Category not found'),
1657
+ );
1658
+ }
1464
1659
  }
1465
- }
1466
1660
 
1467
- const installments =
1468
- data.installments && data.installments.length > 0
1469
- ? data.installments
1470
- : [
1471
- {
1472
- installment_number: 1,
1473
- due_date: data.due_date,
1474
- amount: data.total_amount,
1475
- },
1476
- ];
1661
+ if (data.cost_center_id) {
1662
+ const costCenter = await tx.cost_center.findUnique({
1663
+ where: { id: data.cost_center_id },
1664
+ select: { id: true },
1665
+ });
1477
1666
 
1478
- const title = await this.prisma.financial_title.create({
1479
- data: {
1480
- person_id: data.person_id,
1481
- title_type: titleType,
1482
- status: 'open',
1483
- document_number: data.document_number,
1484
- description: data.description,
1485
- competence_date: data.competence_date
1486
- ? new Date(data.competence_date)
1487
- : null,
1488
- issue_date: data.issue_date ? new Date(data.issue_date) : null,
1489
- total_amount_cents: this.toCents(data.total_amount),
1490
- finance_category_id: data.finance_category_id,
1491
- created_by_user_id: userId,
1492
- },
1493
- });
1667
+ if (!costCenter) {
1668
+ throw new BadRequestException(
1669
+ getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
1670
+ );
1671
+ }
1672
+ }
1673
+
1674
+ const attachmentFileIds = [
1675
+ ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
1676
+ ];
1677
+
1678
+ if (attachmentFileIds.length > 0) {
1679
+ const existingFiles = await tx.file.findMany({
1680
+ where: {
1681
+ id: { in: attachmentFileIds },
1682
+ },
1683
+ select: {
1684
+ id: true,
1685
+ },
1686
+ });
1687
+
1688
+ const existingFileIds = new Set(existingFiles.map((file) => file.id));
1689
+ const invalidFileIds = attachmentFileIds.filter(
1690
+ (fileId) => !existingFileIds.has(fileId),
1691
+ );
1692
+
1693
+ if (invalidFileIds.length > 0) {
1694
+ throw new BadRequestException(
1695
+ `Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
1696
+ );
1697
+ }
1698
+ }
1494
1699
 
1495
- for (let index = 0; index < installments.length; index++) {
1496
- const installment = installments[index];
1497
- const amountCents = this.toCents(installment.amount);
1700
+ await this.assertDateNotInClosedPeriod(
1701
+ tx,
1702
+ data.competence_date
1703
+ ? new Date(data.competence_date)
1704
+ : new Date(installments[0].due_date),
1705
+ 'create title',
1706
+ );
1498
1707
 
1499
- const createdInstallment = await this.prisma.financial_installment.create({
1708
+ const title = await tx.financial_title.create({
1500
1709
  data: {
1501
- title_id: title.id,
1502
- installment_number: installment.installment_number || index + 1,
1710
+ person_id: data.person_id,
1711
+ title_type: titleType,
1712
+ status: 'draft',
1713
+ document_number: data.document_number,
1714
+ description: data.description,
1503
1715
  competence_date: data.competence_date
1504
1716
  ? new Date(data.competence_date)
1505
- : new Date(installment.due_date),
1506
- due_date: new Date(installment.due_date),
1507
- amount_cents: amountCents,
1508
- open_amount_cents: amountCents,
1509
- status: 'open',
1510
- notes: data.description,
1717
+ : null,
1718
+ issue_date: data.issue_date ? new Date(data.issue_date) : null,
1719
+ total_amount_cents: this.toCents(data.total_amount),
1720
+ finance_category_id: data.finance_category_id,
1721
+ created_by_user_id: userId,
1511
1722
  },
1512
1723
  });
1513
1724
 
1514
- if (data.cost_center_id) {
1515
- await this.prisma.installment_allocation.create({
1725
+ if (attachmentFileIds.length > 0) {
1726
+ await tx.financial_title_attachment.createMany({
1727
+ data: attachmentFileIds.map((fileId) => ({
1728
+ title_id: title.id,
1729
+ file_id: fileId,
1730
+ uploaded_by_user_id: userId,
1731
+ })),
1732
+ });
1733
+ }
1734
+
1735
+ for (let index = 0; index < installments.length; index++) {
1736
+ const installment = installments[index];
1737
+ const amountCents = installment.amount_cents;
1738
+
1739
+ const createdInstallment = await tx.financial_installment.create({
1516
1740
  data: {
1517
- installment_id: createdInstallment.id,
1518
- cost_center_id: data.cost_center_id,
1519
- allocated_amount_cents: amountCents,
1741
+ title_id: title.id,
1742
+ installment_number: installment.installment_number,
1743
+ competence_date: data.competence_date
1744
+ ? new Date(data.competence_date)
1745
+ : new Date(installment.due_date),
1746
+ due_date: new Date(installment.due_date),
1747
+ amount_cents: amountCents,
1748
+ open_amount_cents: amountCents,
1749
+ status: this.resolveInstallmentStatus(
1750
+ amountCents,
1751
+ amountCents,
1752
+ new Date(installment.due_date),
1753
+ ),
1754
+ notes: data.description,
1520
1755
  },
1521
1756
  });
1757
+
1758
+ if (data.cost_center_id) {
1759
+ await tx.installment_allocation.create({
1760
+ data: {
1761
+ installment_id: createdInstallment.id,
1762
+ cost_center_id: data.cost_center_id,
1763
+ allocated_amount_cents: amountCents,
1764
+ },
1765
+ });
1766
+ }
1522
1767
  }
1523
- }
1524
1768
 
1525
- const createdTitle = await this.getTitleById(title.id, titleType, locale);
1526
- return this.mapTitleToFront(createdTitle, data.payment_channel);
1527
- }
1769
+ await this.createAuditLog(tx, {
1770
+ action: 'CREATE_TITLE',
1771
+ entityTable: 'financial_title',
1772
+ entityId: String(title.id),
1773
+ actorUserId: userId,
1774
+ summary: `Created ${titleType} title ${title.id} in draft`,
1775
+ afterData: JSON.stringify({
1776
+ status: 'draft',
1777
+ total_amount_cents: this.toCents(data.total_amount),
1778
+ }),
1779
+ });
1528
1780
 
1529
- private async loadTitles(type: TitleType) {
1530
- const titles = await this.prisma.financial_title.findMany({
1531
- where: { title_type: type },
1532
- include: this.defaultTitleInclude(),
1533
- orderBy: { created_at: 'desc' },
1781
+ return title.id;
1534
1782
  });
1535
1783
 
1536
- return titles.map((title) => this.mapTitleToFront(title));
1784
+ const createdTitle = await this.getTitleById(createdTitleId, titleType, locale);
1785
+ return this.mapTitleToFront(createdTitle, data.payment_channel);
1537
1786
  }
1538
1787
 
1539
- private async loadPeople() {
1540
- const people = await this.prisma.person.findMany({
1541
- orderBy: { name: 'asc' },
1542
- });
1788
+ private normalizeAndValidateInstallments(
1789
+ data: CreateFinancialTitleDto,
1790
+ locale: string,
1791
+ ) {
1792
+ const fallbackDueDate = data.due_date;
1793
+ const totalAmountCents = this.toCents(data.total_amount);
1794
+ const sourceInstallments =
1795
+ data.installments && data.installments.length > 0
1796
+ ? data.installments
1797
+ : [
1798
+ {
1799
+ installment_number: 1,
1800
+ due_date: fallbackDueDate,
1801
+ amount: data.total_amount,
1802
+ },
1803
+ ];
1543
1804
 
1544
- return people.map((person) => ({
1545
- id: String(person.id),
1546
- nome: person.name,
1547
- tipo: 'ambos',
1548
- documento: '',
1549
- }));
1550
- }
1805
+ const normalizedInstallments = sourceInstallments.map(
1806
+ (installment, index) => {
1807
+ const installmentDueDate = installment.due_date || fallbackDueDate;
1808
+
1809
+ if (!installmentDueDate) {
1810
+ throw new BadRequestException(
1811
+ getLocaleText(
1812
+ 'installmentDueDateRequired',
1813
+ locale,
1814
+ 'Installment due date is required',
1815
+ ),
1816
+ );
1817
+ }
1551
1818
 
1552
- private async loadCategories() {
1553
- const categories = await this.prisma.finance_category.findMany({
1554
- orderBy: [{ code: 'asc' }, { name: 'asc' }],
1555
- });
1819
+ const amountCents = this.toCents(installment.amount);
1556
1820
 
1557
- return categories.map((category) => ({
1558
- id: String(category.id),
1559
- codigo: category.code,
1560
- nome: category.name,
1561
- natureza:
1562
- category.kind === 'revenue'
1563
- ? 'receita'
1564
- : category.kind === 'expense'
1565
- ? 'despesa'
1566
- : category.kind,
1567
- status: category.status,
1568
- }));
1569
- }
1821
+ if (amountCents <= 0) {
1822
+ throw new BadRequestException(
1823
+ getLocaleText(
1824
+ 'installmentAmountInvalid',
1825
+ locale,
1826
+ 'Installment amount must be greater than zero',
1827
+ ),
1828
+ );
1829
+ }
1570
1830
 
1571
- private async loadCostCenters() {
1572
- const costCenters = await this.prisma.cost_center.findMany({
1573
- orderBy: [{ code: 'asc' }, { name: 'asc' }],
1574
- });
1831
+ return {
1832
+ installment_number: installment.installment_number || index + 1,
1833
+ due_date: installmentDueDate,
1834
+ amount_cents: amountCents,
1835
+ };
1836
+ },
1837
+ );
1575
1838
 
1576
- return costCenters.map((costCenter) => ({
1577
- id: String(costCenter.id),
1578
- codigo: costCenter.code,
1579
- nome: costCenter.name,
1580
- status: costCenter.status,
1581
- }));
1582
- }
1839
+ const installmentsTotalCents = normalizedInstallments.reduce(
1840
+ (acc, installment) => acc + installment.amount_cents,
1841
+ 0,
1842
+ );
1583
1843
 
1584
- private async loadBankAccounts() {
1585
- const bankAccounts = await this.prisma.bank_account.findMany({
1586
- include: {
1587
- bank_statement_line: {
1588
- select: {
1589
- amount_cents: true,
1590
- status: true,
1591
- },
1592
- },
1593
- },
1594
- orderBy: [{ code: 'asc' }, { name: 'asc' }],
1595
- });
1844
+ if (installmentsTotalCents !== totalAmountCents) {
1845
+ throw new BadRequestException(
1846
+ getLocaleText(
1847
+ 'installmentsTotalMismatch',
1848
+ locale,
1849
+ 'Installments total must be equal to title total amount',
1850
+ ),
1851
+ );
1852
+ }
1596
1853
 
1597
- return bankAccounts.map((bankAccount) => this.mapBankAccountToFront(bankAccount));
1854
+ return normalizedInstallments;
1598
1855
  }
1599
1856
 
1600
- private async loadTags() {
1601
- const tags = await this.prisma.tag.findMany({
1602
- orderBy: { slug: 'asc' },
1603
- });
1857
+ private async approveTitle(
1858
+ titleId: number,
1859
+ titleType: TitleType,
1860
+ locale: string,
1861
+ userId?: number,
1862
+ ) {
1863
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
1864
+ const title = await tx.financial_title.findFirst({
1865
+ where: {
1866
+ id: titleId,
1867
+ title_type: titleType,
1868
+ },
1869
+ select: {
1870
+ id: true,
1871
+ status: true,
1872
+ competence_date: true,
1873
+ },
1874
+ });
1604
1875
 
1605
- return tags.map((tag) => ({
1606
- id: String(tag.id),
1607
- nome: tag.slug,
1608
- cor: tag.color,
1609
- }));
1610
- }
1876
+ if (!title) {
1877
+ throw new NotFoundException(
1878
+ getLocaleText(
1879
+ 'itemNotFound',
1880
+ locale,
1881
+ `Financial title with ID ${titleId} not found`,
1882
+ ).replace('{{item}}', 'Financial title'),
1883
+ );
1884
+ }
1611
1885
 
1612
- private async loadAuditLogs() {
1613
- const logs = await this.prisma.audit_log.findMany({
1614
- orderBy: { created_at: 'desc' },
1615
- take: 500,
1616
- });
1886
+ await this.assertDateNotInClosedPeriod(
1887
+ tx,
1888
+ title.competence_date,
1889
+ 'approve title',
1890
+ );
1617
1891
 
1618
- return logs.map((log) => ({
1619
- id: String(log.id),
1620
- entidade: log.entity_table === 'financial_title' ? 'TituloPagar' : log.entity_table,
1892
+ if (title.status !== 'draft') {
1893
+ throw new BadRequestException('Only draft titles can be approved');
1894
+ }
1895
+
1896
+ await tx.financial_title.update({
1897
+ where: { id: title.id },
1898
+ data: {
1899
+ status: 'approved',
1900
+ },
1901
+ });
1902
+
1903
+ await this.createAuditLog(tx, {
1904
+ action: 'APPROVE_TITLE',
1905
+ entityTable: 'financial_title',
1906
+ entityId: String(title.id),
1907
+ actorUserId: userId,
1908
+ summary: `Approved ${titleType} title ${title.id}`,
1909
+ beforeData: JSON.stringify({ status: title.status }),
1910
+ afterData: JSON.stringify({ status: 'approved' }),
1911
+ });
1912
+
1913
+ return tx.financial_title.findFirst({
1914
+ where: {
1915
+ id: title.id,
1916
+ title_type: titleType,
1917
+ },
1918
+ include: this.defaultTitleInclude(),
1919
+ });
1920
+ });
1921
+
1922
+ if (!updatedTitle) {
1923
+ throw new NotFoundException('Financial title not found');
1924
+ }
1925
+
1926
+ return this.mapTitleToFront(updatedTitle);
1927
+ }
1928
+
1929
+ private async rejectTitle(
1930
+ titleId: number,
1931
+ data: RejectTitleDto,
1932
+ titleType: TitleType,
1933
+ locale: string,
1934
+ userId?: number,
1935
+ ) {
1936
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
1937
+ const title = await tx.financial_title.findFirst({
1938
+ where: {
1939
+ id: titleId,
1940
+ title_type: titleType,
1941
+ },
1942
+ select: {
1943
+ id: true,
1944
+ status: true,
1945
+ competence_date: true,
1946
+ },
1947
+ });
1948
+
1949
+ if (!title) {
1950
+ throw new NotFoundException(
1951
+ getLocaleText(
1952
+ 'itemNotFound',
1953
+ locale,
1954
+ `Financial title with ID ${titleId} not found`,
1955
+ ).replace('{{item}}', 'Financial title'),
1956
+ );
1957
+ }
1958
+
1959
+ await this.assertDateNotInClosedPeriod(
1960
+ tx,
1961
+ title.competence_date,
1962
+ 'reject title',
1963
+ );
1964
+
1965
+ if (title.status !== 'draft') {
1966
+ throw new BadRequestException('Only draft titles can be rejected');
1967
+ }
1968
+
1969
+ await tx.financial_title.update({
1970
+ where: { id: title.id },
1971
+ data: {
1972
+ status: 'canceled',
1973
+ },
1974
+ });
1975
+
1976
+ await this.createAuditLog(tx, {
1977
+ action: 'REJECT_TITLE',
1978
+ entityTable: 'financial_title',
1979
+ entityId: String(title.id),
1980
+ actorUserId: userId,
1981
+ summary: `Rejected ${titleType} title ${title.id}`,
1982
+ beforeData: JSON.stringify({ status: title.status }),
1983
+ afterData: JSON.stringify({
1984
+ status: 'canceled',
1985
+ reason: data?.reason || null,
1986
+ }),
1987
+ });
1988
+
1989
+ return tx.financial_title.findFirst({
1990
+ where: {
1991
+ id: title.id,
1992
+ title_type: titleType,
1993
+ },
1994
+ include: this.defaultTitleInclude(),
1995
+ });
1996
+ });
1997
+
1998
+ if (!updatedTitle) {
1999
+ throw new NotFoundException('Financial title not found');
2000
+ }
2001
+
2002
+ return this.mapTitleToFront(updatedTitle);
2003
+ }
2004
+
2005
+ private async settleTitleInstallment(
2006
+ titleId: number,
2007
+ data: SettleInstallmentDto,
2008
+ titleType: TitleType,
2009
+ locale: string,
2010
+ userId?: number,
2011
+ ) {
2012
+ const amountCents = this.toCents(data.amount);
2013
+
2014
+ if (amountCents <= 0) {
2015
+ throw new BadRequestException('Settlement amount must be greater than zero');
2016
+ }
2017
+
2018
+ const settledAt = data.settled_at ? new Date(data.settled_at) : new Date();
2019
+ if (Number.isNaN(settledAt.getTime())) {
2020
+ throw new BadRequestException('Invalid settlement date');
2021
+ }
2022
+
2023
+ const result = await this.prisma.$transaction(async (tx) => {
2024
+ const title = await tx.financial_title.findFirst({
2025
+ where: {
2026
+ id: titleId,
2027
+ title_type: titleType,
2028
+ },
2029
+ select: {
2030
+ id: true,
2031
+ person_id: true,
2032
+ status: true,
2033
+ competence_date: true,
2034
+ },
2035
+ });
2036
+
2037
+ if (!title) {
2038
+ throw new NotFoundException(
2039
+ getLocaleText(
2040
+ 'itemNotFound',
2041
+ locale,
2042
+ `Financial title with ID ${titleId} not found`,
2043
+ ).replace('{{item}}', 'Financial title'),
2044
+ );
2045
+ }
2046
+
2047
+ if (!['approved', 'open', 'partial'].includes(title.status)) {
2048
+ throw new BadRequestException(
2049
+ 'Only approved/open/partial titles can be settled',
2050
+ );
2051
+ }
2052
+
2053
+ await this.assertDateNotInClosedPeriod(
2054
+ tx,
2055
+ title.competence_date,
2056
+ 'settle installment',
2057
+ );
2058
+
2059
+ const installment = await tx.financial_installment.findFirst({
2060
+ where: {
2061
+ id: data.installment_id,
2062
+ title_id: title.id,
2063
+ },
2064
+ select: {
2065
+ id: true,
2066
+ title_id: true,
2067
+ amount_cents: true,
2068
+ open_amount_cents: true,
2069
+ due_date: true,
2070
+ status: true,
2071
+ },
2072
+ });
2073
+
2074
+ if (!installment) {
2075
+ throw new BadRequestException('Installment not found for this title');
2076
+ }
2077
+
2078
+ if (installment.status === 'settled' || installment.status === 'canceled') {
2079
+ throw new BadRequestException('This installment cannot be settled');
2080
+ }
2081
+
2082
+ if (amountCents > installment.open_amount_cents) {
2083
+ throw new BadRequestException('Settlement amount exceeds open amount');
2084
+ }
2085
+
2086
+ const paymentMethodId = await this.resolvePaymentMethodId(
2087
+ tx,
2088
+ data.payment_channel,
2089
+ );
2090
+
2091
+ const settlement = await tx.settlement.create({
2092
+ data: {
2093
+ person_id: title.person_id,
2094
+ bank_account_id: data.bank_account_id || null,
2095
+ payment_method_id: paymentMethodId,
2096
+ settlement_type: titleType,
2097
+ status: 'confirmed',
2098
+ settled_at: settledAt,
2099
+ amount_cents: amountCents,
2100
+ description: data.description?.trim() || null,
2101
+ created_by_user_id: userId,
2102
+ },
2103
+ });
2104
+
2105
+ await tx.settlement_allocation.create({
2106
+ data: {
2107
+ settlement_id: settlement.id,
2108
+ installment_id: installment.id,
2109
+ allocated_amount_cents: amountCents,
2110
+ discount_cents: this.toCents(data.discount || 0),
2111
+ interest_cents: this.toCents(data.interest || 0),
2112
+ penalty_cents: this.toCents(data.penalty || 0),
2113
+ },
2114
+ });
2115
+
2116
+ const decrementResult = await tx.financial_installment.updateMany({
2117
+ where: {
2118
+ id: installment.id,
2119
+ open_amount_cents: {
2120
+ gte: amountCents,
2121
+ },
2122
+ },
2123
+ data: {
2124
+ open_amount_cents: {
2125
+ decrement: amountCents,
2126
+ },
2127
+ },
2128
+ });
2129
+
2130
+ if (decrementResult.count !== 1) {
2131
+ throw new BadRequestException(
2132
+ 'Installment was updated concurrently, please try again',
2133
+ );
2134
+ }
2135
+
2136
+ const updatedInstallment = await tx.financial_installment.findUnique({
2137
+ where: {
2138
+ id: installment.id,
2139
+ },
2140
+ select: {
2141
+ id: true,
2142
+ amount_cents: true,
2143
+ open_amount_cents: true,
2144
+ due_date: true,
2145
+ status: true,
2146
+ },
2147
+ });
2148
+
2149
+ if (!updatedInstallment) {
2150
+ throw new NotFoundException('Installment not found');
2151
+ }
2152
+
2153
+ const nextInstallmentStatus = this.resolveInstallmentStatus(
2154
+ updatedInstallment.amount_cents,
2155
+ updatedInstallment.open_amount_cents,
2156
+ updatedInstallment.due_date,
2157
+ );
2158
+
2159
+ if (updatedInstallment.status !== nextInstallmentStatus) {
2160
+ await tx.financial_installment.update({
2161
+ where: {
2162
+ id: updatedInstallment.id,
2163
+ },
2164
+ data: {
2165
+ status: nextInstallmentStatus,
2166
+ },
2167
+ });
2168
+ }
2169
+
2170
+ const previousTitleStatus = title.status;
2171
+ const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
2172
+
2173
+ await this.createAuditLog(tx, {
2174
+ action: 'SETTLE_INSTALLMENT',
2175
+ entityTable: 'financial_title',
2176
+ entityId: String(title.id),
2177
+ actorUserId: userId,
2178
+ summary: `Settled installment ${installment.id} of title ${title.id}`,
2179
+ beforeData: JSON.stringify({
2180
+ title_status: previousTitleStatus,
2181
+ installment_open_amount_cents: installment.open_amount_cents,
2182
+ }),
2183
+ afterData: JSON.stringify({
2184
+ title_status: nextTitleStatus,
2185
+ installment_open_amount_cents: updatedInstallment.open_amount_cents,
2186
+ settlement_id: settlement.id,
2187
+ }),
2188
+ });
2189
+
2190
+ const updatedTitle = await tx.financial_title.findFirst({
2191
+ where: {
2192
+ id: title.id,
2193
+ title_type: titleType,
2194
+ },
2195
+ include: this.defaultTitleInclude(),
2196
+ });
2197
+
2198
+ if (!updatedTitle) {
2199
+ throw new NotFoundException('Financial title not found');
2200
+ }
2201
+
2202
+ return {
2203
+ title: updatedTitle,
2204
+ settlementId: settlement.id,
2205
+ };
2206
+ });
2207
+
2208
+ return {
2209
+ ...this.mapTitleToFront(result.title),
2210
+ settlementId: String(result.settlementId),
2211
+ };
2212
+ }
2213
+
2214
+ private async reverseTitleSettlement(
2215
+ titleId: number,
2216
+ settlementId: number,
2217
+ data: ReverseSettlementDto,
2218
+ titleType: TitleType,
2219
+ locale: string,
2220
+ userId?: number,
2221
+ ) {
2222
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
2223
+ const title = await tx.financial_title.findFirst({
2224
+ where: {
2225
+ id: titleId,
2226
+ title_type: titleType,
2227
+ },
2228
+ select: {
2229
+ id: true,
2230
+ status: true,
2231
+ competence_date: true,
2232
+ },
2233
+ });
2234
+
2235
+ if (!title) {
2236
+ throw new NotFoundException(
2237
+ getLocaleText(
2238
+ 'itemNotFound',
2239
+ locale,
2240
+ `Financial title with ID ${titleId} not found`,
2241
+ ).replace('{{item}}', 'Financial title'),
2242
+ );
2243
+ }
2244
+
2245
+ await this.assertDateNotInClosedPeriod(
2246
+ tx,
2247
+ title.competence_date,
2248
+ 'reverse settlement',
2249
+ );
2250
+
2251
+ const settlement = await tx.settlement.findFirst({
2252
+ where: {
2253
+ id: settlementId,
2254
+ settlement_type: titleType,
2255
+ settlement_allocation: {
2256
+ some: {
2257
+ financial_installment: {
2258
+ title_id: title.id,
2259
+ },
2260
+ },
2261
+ },
2262
+ },
2263
+ include: {
2264
+ settlement_allocation: {
2265
+ include: {
2266
+ financial_installment: {
2267
+ select: {
2268
+ id: true,
2269
+ amount_cents: true,
2270
+ open_amount_cents: true,
2271
+ due_date: true,
2272
+ status: true,
2273
+ },
2274
+ },
2275
+ },
2276
+ },
2277
+ },
2278
+ });
2279
+
2280
+ if (!settlement) {
2281
+ throw new NotFoundException('Settlement not found for this title');
2282
+ }
2283
+
2284
+ if (settlement.status === 'reversed') {
2285
+ throw new BadRequestException('This settlement is already reversed');
2286
+ }
2287
+
2288
+ for (const allocation of settlement.settlement_allocation) {
2289
+ const installment = allocation.financial_installment;
2290
+
2291
+ if (!installment) {
2292
+ continue;
2293
+ }
2294
+
2295
+ const nextOpenAmountCents =
2296
+ installment.open_amount_cents + allocation.allocated_amount_cents;
2297
+
2298
+ if (nextOpenAmountCents > installment.amount_cents) {
2299
+ throw new BadRequestException(
2300
+ `Reverse would exceed installment amount for installment ${installment.id}`,
2301
+ );
2302
+ }
2303
+
2304
+ const nextInstallmentStatus = this.resolveInstallmentStatus(
2305
+ installment.amount_cents,
2306
+ nextOpenAmountCents,
2307
+ installment.due_date,
2308
+ );
2309
+
2310
+ await tx.financial_installment.update({
2311
+ where: {
2312
+ id: installment.id,
2313
+ },
2314
+ data: {
2315
+ open_amount_cents: nextOpenAmountCents,
2316
+ status: nextInstallmentStatus,
2317
+ },
2318
+ });
2319
+ }
2320
+
2321
+ await tx.settlement.update({
2322
+ where: {
2323
+ id: settlement.id,
2324
+ },
2325
+ data: {
2326
+ status: 'reversed',
2327
+ description: [
2328
+ settlement.description,
2329
+ data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
2330
+ ]
2331
+ .filter(Boolean)
2332
+ .join(' | '),
2333
+ },
2334
+ });
2335
+
2336
+ const previousTitleStatus = title.status;
2337
+ const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
2338
+
2339
+ await this.createAuditLog(tx, {
2340
+ action: 'REVERSE_SETTLEMENT',
2341
+ entityTable: 'financial_title',
2342
+ entityId: String(title.id),
2343
+ actorUserId: userId,
2344
+ summary: `Reversed settlement ${settlement.id} from title ${title.id}`,
2345
+ beforeData: JSON.stringify({
2346
+ title_status: previousTitleStatus,
2347
+ settlement_status: settlement.status,
2348
+ }),
2349
+ afterData: JSON.stringify({
2350
+ title_status: nextTitleStatus,
2351
+ settlement_status: 'reversed',
2352
+ }),
2353
+ });
2354
+
2355
+ return tx.financial_title.findFirst({
2356
+ where: {
2357
+ id: title.id,
2358
+ title_type: titleType,
2359
+ },
2360
+ include: this.defaultTitleInclude(),
2361
+ });
2362
+ });
2363
+
2364
+ if (!updatedTitle) {
2365
+ throw new NotFoundException('Financial title not found');
2366
+ }
2367
+
2368
+ return this.mapTitleToFront(updatedTitle);
2369
+ }
2370
+
2371
+ private async updateTitleTags(
2372
+ titleId: number,
2373
+ titleType: TitleType,
2374
+ tagIds: number[],
2375
+ locale: string,
2376
+ ) {
2377
+ const title = await this.getTitleById(titleId, titleType, locale);
2378
+
2379
+ const installmentIds = (title.financial_installment || []).map(
2380
+ (installment) => installment.id,
2381
+ );
2382
+
2383
+ if (installmentIds.length === 0) {
2384
+ throw new BadRequestException('Financial title has no installments');
2385
+ }
2386
+
2387
+ const normalizedTagIds = [
2388
+ ...new Set(
2389
+ (tagIds || [])
2390
+ .map((tagId) => Number(tagId))
2391
+ .filter((tagId) => Number.isInteger(tagId) && tagId > 0),
2392
+ ),
2393
+ ];
2394
+
2395
+ if (normalizedTagIds.length > 0) {
2396
+ const existingTags = await this.prisma.tag.findMany({
2397
+ where: {
2398
+ id: {
2399
+ in: normalizedTagIds,
2400
+ },
2401
+ },
2402
+ select: {
2403
+ id: true,
2404
+ },
2405
+ });
2406
+
2407
+ const existingTagIds = new Set(existingTags.map((tag) => tag.id));
2408
+ const invalidTagIds = normalizedTagIds.filter(
2409
+ (tagId) => !existingTagIds.has(tagId),
2410
+ );
2411
+
2412
+ if (invalidTagIds.length > 0) {
2413
+ throw new BadRequestException(
2414
+ `Invalid tag IDs: ${invalidTagIds.join(', ')}`,
2415
+ );
2416
+ }
2417
+ }
2418
+
2419
+ await this.prisma.$transaction(async (tx) => {
2420
+ if (normalizedTagIds.length === 0) {
2421
+ await tx.financial_installment_tag.deleteMany({
2422
+ where: {
2423
+ installment_id: {
2424
+ in: installmentIds,
2425
+ },
2426
+ },
2427
+ });
2428
+
2429
+ return;
2430
+ }
2431
+
2432
+ await tx.financial_installment_tag.deleteMany({
2433
+ where: {
2434
+ installment_id: {
2435
+ in: installmentIds,
2436
+ },
2437
+ tag_id: {
2438
+ notIn: normalizedTagIds,
2439
+ },
2440
+ },
2441
+ });
2442
+
2443
+ const newRelations = installmentIds.flatMap((installmentId) =>
2444
+ normalizedTagIds.map((tagId) => ({
2445
+ installment_id: installmentId,
2446
+ tag_id: tagId,
2447
+ })),
2448
+ );
2449
+
2450
+ await tx.financial_installment_tag.createMany({
2451
+ data: newRelations,
2452
+ skipDuplicates: true,
2453
+ });
2454
+ });
2455
+
2456
+ const updatedTitle = await this.getTitleById(titleId, titleType, locale);
2457
+ return this.mapTitleToFront(updatedTitle);
2458
+ }
2459
+
2460
+ private async loadTitles(type: TitleType) {
2461
+ const titles = await this.prisma.financial_title.findMany({
2462
+ where: { title_type: type },
2463
+ include: this.defaultTitleInclude(),
2464
+ orderBy: { created_at: 'desc' },
2465
+ });
2466
+
2467
+ return titles.map((title) => this.mapTitleToFront(title));
2468
+ }
2469
+
2470
+ private async loadPeople() {
2471
+ const people = await this.prisma.person.findMany({
2472
+ orderBy: { name: 'asc' },
2473
+ });
2474
+
2475
+ return people.map((person) => ({
2476
+ id: String(person.id),
2477
+ nome: person.name,
2478
+ tipo: 'ambos',
2479
+ documento: '',
2480
+ }));
2481
+ }
2482
+
2483
+ private async loadCategories() {
2484
+ const categories = await this.prisma.finance_category.findMany({
2485
+ orderBy: [{ code: 'asc' }, { name: 'asc' }],
2486
+ });
2487
+
2488
+ return categories.map((category) => ({
2489
+ id: String(category.id),
2490
+ codigo: category.code,
2491
+ nome: category.name,
2492
+ natureza:
2493
+ category.kind === 'revenue'
2494
+ ? 'receita'
2495
+ : category.kind === 'expense'
2496
+ ? 'despesa'
2497
+ : category.kind,
2498
+ status: category.status,
2499
+ }));
2500
+ }
2501
+
2502
+ private async loadCostCenters() {
2503
+ const costCenters = await this.prisma.cost_center.findMany({
2504
+ orderBy: [{ code: 'asc' }, { name: 'asc' }],
2505
+ });
2506
+
2507
+ return costCenters.map((costCenter) => ({
2508
+ id: String(costCenter.id),
2509
+ codigo: costCenter.code,
2510
+ nome: costCenter.name,
2511
+ status: costCenter.status,
2512
+ }));
2513
+ }
2514
+
2515
+ private async loadBankAccounts() {
2516
+ const bankAccounts = await this.prisma.bank_account.findMany({
2517
+ include: {
2518
+ bank_statement_line: {
2519
+ select: {
2520
+ amount_cents: true,
2521
+ status: true,
2522
+ },
2523
+ },
2524
+ },
2525
+ orderBy: [{ code: 'asc' }, { name: 'asc' }],
2526
+ });
2527
+
2528
+ return bankAccounts.map((bankAccount) => this.mapBankAccountToFront(bankAccount));
2529
+ }
2530
+
2531
+ private async loadTags() {
2532
+ const tags = await this.prisma.tag.findMany({
2533
+ orderBy: { slug: 'asc' },
2534
+ });
2535
+
2536
+ return tags.map((tag) => ({
2537
+ id: String(tag.id),
2538
+ nome: tag.slug,
2539
+ cor: tag.color,
2540
+ }));
2541
+ }
2542
+
2543
+ private async loadAuditLogs() {
2544
+ const logs = await this.prisma.audit_log.findMany({
2545
+ orderBy: { created_at: 'desc' },
2546
+ take: 500,
2547
+ });
2548
+
2549
+ return logs.map((log) => ({
2550
+ id: String(log.id),
2551
+ entidade: log.entity_table === 'financial_title' ? 'TituloPagar' : log.entity_table,
1621
2552
  entidadeId: log.entity_id,
1622
2553
  usuarioId: log.actor_user_id ? String(log.actor_user_id) : null,
1623
2554
  acao: log.action,
@@ -1628,6 +2559,189 @@ export class FinanceService {
1628
2559
  }));
1629
2560
  }
1630
2561
 
2562
+ private async resolvePaymentMethodId(tx: any, paymentChannel?: string) {
2563
+ const paymentType = this.mapPaymentMethodFromPt(paymentChannel);
2564
+
2565
+ if (!paymentType) {
2566
+ return null;
2567
+ }
2568
+
2569
+ const paymentMethod = await tx.payment_method.findFirst({
2570
+ where: {
2571
+ type: paymentType,
2572
+ status: 'active',
2573
+ },
2574
+ select: {
2575
+ id: true,
2576
+ },
2577
+ });
2578
+
2579
+ return paymentMethod?.id || null;
2580
+ }
2581
+
2582
+ private async assertDateNotInClosedPeriod(
2583
+ tx: any,
2584
+ competenceDate: Date | null | undefined,
2585
+ operation: string,
2586
+ ) {
2587
+ if (!competenceDate) {
2588
+ return;
2589
+ }
2590
+
2591
+ const closedPeriod = await tx.period_close.findFirst({
2592
+ where: {
2593
+ status: 'closed',
2594
+ period_start: {
2595
+ lte: competenceDate,
2596
+ },
2597
+ period_end: {
2598
+ gte: competenceDate,
2599
+ },
2600
+ },
2601
+ select: {
2602
+ id: true,
2603
+ },
2604
+ });
2605
+
2606
+ if (closedPeriod) {
2607
+ throw new BadRequestException(
2608
+ `Cannot ${operation}: competence is in a closed period`,
2609
+ );
2610
+ }
2611
+ }
2612
+
2613
+ private resolveInstallmentStatus(
2614
+ amountCents: number,
2615
+ openAmountCents: number,
2616
+ dueDate: Date,
2617
+ currentStatus?: string,
2618
+ ): InstallmentStatus {
2619
+ if (currentStatus === 'canceled') {
2620
+ return 'canceled';
2621
+ }
2622
+
2623
+ if (openAmountCents <= 0) {
2624
+ return 'settled';
2625
+ }
2626
+
2627
+ if (openAmountCents < amountCents) {
2628
+ return 'partial';
2629
+ }
2630
+
2631
+ const today = new Date();
2632
+ today.setHours(0, 0, 0, 0);
2633
+ const dueDateOnly = new Date(dueDate);
2634
+ dueDateOnly.setHours(0, 0, 0, 0);
2635
+
2636
+ if (dueDateOnly < today) {
2637
+ return 'overdue';
2638
+ }
2639
+
2640
+ return 'open';
2641
+ }
2642
+
2643
+ private deriveTitleStatusFromInstallments(
2644
+ installments: Array<{
2645
+ amount_cents: number;
2646
+ open_amount_cents: number;
2647
+ due_date: Date;
2648
+ status?: string;
2649
+ }>,
2650
+ ): TitleStatus {
2651
+ if (installments.length === 0) {
2652
+ return 'open';
2653
+ }
2654
+
2655
+ const effectiveStatuses = installments.map((installment) =>
2656
+ this.resolveInstallmentStatus(
2657
+ installment.amount_cents,
2658
+ installment.open_amount_cents,
2659
+ installment.due_date,
2660
+ installment.status,
2661
+ ),
2662
+ );
2663
+
2664
+ if (effectiveStatuses.every((status) => status === 'settled')) {
2665
+ return 'settled';
2666
+ }
2667
+
2668
+ const hasPayment = installments.some(
2669
+ (installment) => installment.open_amount_cents < installment.amount_cents,
2670
+ );
2671
+
2672
+ if (hasPayment) {
2673
+ return 'partial';
2674
+ }
2675
+
2676
+ return 'open';
2677
+ }
2678
+
2679
+ private async recalculateTitleStatus(tx: any, titleId: number): Promise<TitleStatus> {
2680
+ const title = await tx.financial_title.findUnique({
2681
+ where: {
2682
+ id: titleId,
2683
+ },
2684
+ select: {
2685
+ id: true,
2686
+ status: true,
2687
+ financial_installment: {
2688
+ select: {
2689
+ amount_cents: true,
2690
+ open_amount_cents: true,
2691
+ due_date: true,
2692
+ status: true,
2693
+ },
2694
+ },
2695
+ },
2696
+ });
2697
+
2698
+ if (!title) {
2699
+ throw new NotFoundException('Financial title not found');
2700
+ }
2701
+
2702
+ const nextStatus = this.deriveTitleStatusFromInstallments(
2703
+ title.financial_installment,
2704
+ );
2705
+
2706
+ if (title.status !== nextStatus) {
2707
+ await tx.financial_title.update({
2708
+ where: {
2709
+ id: title.id,
2710
+ },
2711
+ data: {
2712
+ status: nextStatus,
2713
+ },
2714
+ });
2715
+ }
2716
+
2717
+ return nextStatus;
2718
+ }
2719
+
2720
+ private async createAuditLog(
2721
+ tx: any,
2722
+ data: {
2723
+ actorUserId?: number;
2724
+ action: string;
2725
+ entityTable: string;
2726
+ entityId: string;
2727
+ summary?: string;
2728
+ beforeData?: string;
2729
+ afterData?: string;
2730
+ },
2731
+ ) {
2732
+ await tx.audit_log.create({
2733
+ data: {
2734
+ actor_user_id: data.actorUserId || null,
2735
+ action: data.action,
2736
+ entity_table: data.entityTable,
2737
+ entity_id: data.entityId,
2738
+ summary: data.summary || null,
2739
+ before_data: data.beforeData || null,
2740
+ after_data: data.afterData || null,
2741
+ },
2742
+ });
2743
+ }
2744
+
1631
2745
  private mapTitleToFront(title: any, paymentChannelOverride?: string) {
1632
2746
  const allocations = title.financial_installment.flatMap(
1633
2747
  (installment) => installment.installment_allocation,
@@ -1652,13 +2766,24 @@ export class FinanceService {
1652
2766
  numero: installment.installment_number,
1653
2767
  vencimento: installment.due_date.toISOString(),
1654
2768
  valor: this.fromCents(installment.amount_cents),
1655
- status: this.mapStatusToPt(installment.status),
2769
+ valorAberto: this.fromCents(installment.open_amount_cents),
2770
+ status: this.mapStatusToPt(
2771
+ this.resolveInstallmentStatus(
2772
+ installment.amount_cents,
2773
+ installment.open_amount_cents,
2774
+ installment.due_date,
2775
+ installment.status,
2776
+ ),
2777
+ ),
1656
2778
  metodoPagamento:
1657
2779
  this.mapPaymentMethodToPt(
1658
2780
  installment.settlement_allocation[0]?.settlement?.payment_method?.type,
1659
2781
  ) || paymentChannelOverride || 'transferencia',
1660
2782
  liquidacoes: installment.settlement_allocation.map((allocation) => ({
1661
2783
  id: String(allocation.id),
2784
+ settlementId: allocation.settlement?.id
2785
+ ? String(allocation.settlement.id)
2786
+ : null,
1662
2787
  data: allocation.settlement?.settled_at?.toISOString(),
1663
2788
  valor: this.fromCents(allocation.allocated_amount_cents),
1664
2789
  juros: this.fromCents(allocation.interest_cents || 0),
@@ -1667,6 +2792,7 @@ export class FinanceService {
1667
2792
  contaBancariaId: allocation.settlement?.bank_account_id
1668
2793
  ? String(allocation.settlement.bank_account_id)
1669
2794
  : null,
2795
+ status: allocation.settlement?.status || null,
1670
2796
  metodo:
1671
2797
  this.mapPaymentMethodToPt(
1672
2798
  allocation.settlement?.payment_method?.type,
@@ -1674,6 +2800,11 @@ export class FinanceService {
1674
2800
  })),
1675
2801
  }));
1676
2802
 
2803
+ const attachmentDetails = title.financial_title_attachment.map((attachment) => ({
2804
+ id: String(attachment.file_id),
2805
+ nome: attachment.file?.filename || attachment.file?.path,
2806
+ }));
2807
+
1677
2808
  return {
1678
2809
  id: String(title.id),
1679
2810
  documento: title.document_number || `TIT-${title.id}`,
@@ -1686,9 +2817,8 @@ export class FinanceService {
1686
2817
  criadoEm: title.created_at.toISOString(),
1687
2818
  categoriaId: title.finance_category_id ? String(title.finance_category_id) : null,
1688
2819
  centroCustoId: firstCostCenter ? String(firstCostCenter) : null,
1689
- anexos: title.financial_title_attachment.map(
1690
- (attachment) => attachment.file?.filename || attachment.file?.path,
1691
- ),
2820
+ anexos: attachmentDetails.map((attachment) => attachment.nome),
2821
+ anexosDetalhes: attachmentDetails,
1692
2822
  tags,
1693
2823
  parcelas: mappedInstallments,
1694
2824
  canal:
@@ -1744,6 +2874,20 @@ export class FinanceService {
1744
2874
  return statusMap[status] || 'aberto';
1745
2875
  }
1746
2876
 
2877
+ private normalizeTagSlug(value?: string | null) {
2878
+ if (!value) {
2879
+ return '';
2880
+ }
2881
+
2882
+ return value
2883
+ .normalize('NFD')
2884
+ .replace(/[\u0300-\u036f]/g, '')
2885
+ .toLowerCase()
2886
+ .trim()
2887
+ .replace(/[^a-z0-9]+/g, '-')
2888
+ .replace(/(^-|-$)+/g, '');
2889
+ }
2890
+
1747
2891
  private mapStatusFromPt(status?: string) {
1748
2892
  if (!status || status === 'all') {
1749
2893
  return undefined;
@@ -1763,7 +2907,7 @@ export class FinanceService {
1763
2907
  }
1764
2908
 
1765
2909
  private mapPaymentMethodToPt(paymentMethodType?: string | null) {
1766
- const paymentMethodMap = {
2910
+ const paymentMethodMap: Record<string, string> = {
1767
2911
  boleto: 'boleto',
1768
2912
  pix: 'pix',
1769
2913
  ted: 'transferencia',
@@ -1776,6 +2920,30 @@ export class FinanceService {
1776
2920
  return paymentMethodMap[paymentMethodType] || undefined;
1777
2921
  }
1778
2922
 
2923
+ private mapPaymentMethodFromPt(paymentMethodType?: string | null) {
2924
+ if (!paymentMethodType) {
2925
+ return undefined;
2926
+ }
2927
+
2928
+ const paymentMethodMap = {
2929
+ boleto: 'boleto',
2930
+ pix: 'pix',
2931
+ transferencia: 'ted',
2932
+ transferência: 'ted',
2933
+ ted: 'ted',
2934
+ doc: 'doc',
2935
+ cartao: 'card',
2936
+ cartão: 'card',
2937
+ dinheiro: 'cash',
2938
+ cheque: 'other',
2939
+ cash: 'cash',
2940
+ card: 'card',
2941
+ other: 'other',
2942
+ };
2943
+
2944
+ return paymentMethodMap[(paymentMethodType || '').toLowerCase()];
2945
+ }
2946
+
1779
2947
  private mapAccountTypeToPt(accountType?: string | null) {
1780
2948
  const accountTypeMap = {
1781
2949
  checking: 'corrente',