@hed-hog/finance 0.0.238 → 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 (34) hide show
  1. package/dist/dto/reject-title.dto.d.ts +4 -0
  2. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  3. package/dist/dto/reject-title.dto.js +22 -0
  4. package/dist/dto/reject-title.dto.js.map +1 -0
  5. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  6. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  7. package/dist/dto/reverse-settlement.dto.js +22 -0
  8. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  9. package/dist/dto/settle-installment.dto.d.ts +12 -0
  10. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  11. package/dist/dto/settle-installment.dto.js +71 -0
  12. package/dist/dto/settle-installment.dto.js.map +1 -0
  13. package/dist/finance-data.controller.d.ts +13 -5
  14. package/dist/finance-data.controller.d.ts.map +1 -1
  15. package/dist/finance-installments.controller.d.ts +248 -12
  16. package/dist/finance-installments.controller.d.ts.map +1 -1
  17. package/dist/finance-installments.controller.js +92 -0
  18. package/dist/finance-installments.controller.js.map +1 -1
  19. package/dist/finance.service.d.ts +275 -17
  20. package/dist/finance.service.d.ts.map +1 -1
  21. package/dist/finance.service.js +666 -78
  22. package/dist/finance.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +63 -0
  24. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  25. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +355 -4
  26. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +440 -16
  27. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
  28. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +432 -14
  29. package/package.json +5 -5
  30. package/src/dto/reject-title.dto.ts +7 -0
  31. package/src/dto/reverse-settlement.dto.ts +7 -0
  32. package/src/dto/settle-installment.dto.ts +55 -0
  33. package/src/finance-installments.controller.ts +102 -0
  34. package/src/finance.service.ts +1007 -82
@@ -6,7 +6,7 @@ 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';
@@ -15,11 +15,28 @@ import { CreateFinanceTagDto } from './dto/create-finance-tag.dto';
15
15
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
16
16
  import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
17
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';
18
21
  import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
19
22
  import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
20
23
  import { UpdateFinanceCategoryDto } from './dto/update-finance-category.dto';
21
24
 
22
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';
23
40
 
24
41
  @Injectable()
25
42
  export class FinanceService {
@@ -756,6 +773,18 @@ export class FinanceService {
756
773
  this.logger.error('Failed to load finance audit logs', auditLogsResult.reason);
757
774
  }
758
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
+
759
788
  return {
760
789
  kpis: {
761
790
  saldoCaixa: 0,
@@ -773,7 +802,7 @@ export class FinanceService {
773
802
  pessoas: people,
774
803
  categorias: categories,
775
804
  centrosCusto: costCenters,
776
- aprovacoesPendentes: [],
805
+ aprovacoesPendentes,
777
806
  agingInadimplencia: [],
778
807
  cenarios: [],
779
808
  transferencias: [],
@@ -819,6 +848,45 @@ export class FinanceService {
819
848
  return this.createTitle(data, 'payable', locale, userId);
820
849
  }
821
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
+
822
890
  async createAccountsReceivableTitle(
823
891
  data: CreateFinancialTitleDto,
824
892
  locale: string,
@@ -827,6 +895,40 @@ export class FinanceService {
827
895
  return this.createTitle(data, 'receivable', locale, userId);
828
896
  }
829
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
+
830
932
  async createTag(data: CreateFinanceTagDto) {
831
933
  const slug = this.normalizeTagSlug(data.name);
832
934
 
@@ -1529,137 +1631,741 @@ export class FinanceService {
1529
1631
  locale: string,
1530
1632
  userId?: number,
1531
1633
  ) {
1532
- const person = await this.prisma.person.findUnique({
1533
- where: { id: data.person_id },
1534
- select: { id: true },
1535
- });
1634
+ const installments = this.normalizeAndValidateInstallments(data, locale);
1536
1635
 
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 },
1636
+ const createdTitleId = await this.prisma.$transaction(async (tx) => {
1637
+ const person = await tx.person.findUnique({
1638
+ where: { id: data.person_id },
1546
1639
  select: { id: true },
1547
1640
  });
1548
1641
 
1549
- if (!category) {
1642
+ if (!person) {
1550
1643
  throw new BadRequestException(
1551
- getLocaleText('categoryNotFound', locale, 'Category not found'),
1644
+ getLocaleText('personNotFound', locale, 'Person not found'),
1552
1645
  );
1553
1646
  }
1554
- }
1555
1647
 
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
- });
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
+ });
1561
1653
 
1562
- if (!costCenter) {
1563
- throw new BadRequestException(
1564
- getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
1654
+ if (!category) {
1655
+ throw new BadRequestException(
1656
+ getLocaleText('categoryNotFound', locale, 'Category not found'),
1657
+ );
1658
+ }
1659
+ }
1660
+
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
+ });
1666
+
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),
1565
1691
  );
1692
+
1693
+ if (invalidFileIds.length > 0) {
1694
+ throw new BadRequestException(
1695
+ `Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
1696
+ );
1697
+ }
1566
1698
  }
1567
- }
1568
1699
 
1569
- const installments =
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
+ );
1707
+
1708
+ const title = await tx.financial_title.create({
1709
+ data: {
1710
+ person_id: data.person_id,
1711
+ title_type: titleType,
1712
+ status: 'draft',
1713
+ document_number: data.document_number,
1714
+ description: data.description,
1715
+ competence_date: data.competence_date
1716
+ ? new Date(data.competence_date)
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,
1722
+ },
1723
+ });
1724
+
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({
1740
+ data: {
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,
1755
+ },
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
+ }
1767
+ }
1768
+
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
+ });
1780
+
1781
+ return title.id;
1782
+ });
1783
+
1784
+ const createdTitle = await this.getTitleById(createdTitleId, titleType, locale);
1785
+ return this.mapTitleToFront(createdTitle, data.payment_channel);
1786
+ }
1787
+
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 =
1570
1795
  data.installments && data.installments.length > 0
1571
1796
  ? data.installments
1572
1797
  : [
1573
1798
  {
1574
1799
  installment_number: 1,
1575
- due_date: data.due_date,
1800
+ due_date: fallbackDueDate,
1576
1801
  amount: data.total_amount,
1577
1802
  },
1578
1803
  ];
1579
1804
 
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,
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
+ }
1818
+
1819
+ const amountCents = this.toCents(installment.amount);
1820
+
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
+ }
1830
+
1831
+ return {
1832
+ installment_number: installment.installment_number || index + 1,
1833
+ due_date: installmentDueDate,
1834
+ amount_cents: amountCents,
1835
+ };
1594
1836
  },
1837
+ );
1838
+
1839
+ const installmentsTotalCents = normalizedInstallments.reduce(
1840
+ (acc, installment) => acc + installment.amount_cents,
1841
+ 0,
1842
+ );
1843
+
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
+ }
1853
+
1854
+ return normalizedInstallments;
1855
+ }
1856
+
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
+ });
1875
+
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
+ }
1885
+
1886
+ await this.assertDateNotInClosedPeriod(
1887
+ tx,
1888
+ title.competence_date,
1889
+ 'approve title',
1890
+ );
1891
+
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
+ });
1595
1920
  });
1596
1921
 
1597
- const attachmentFileIds = [
1598
- ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
1599
- ];
1922
+ if (!updatedTitle) {
1923
+ throw new NotFoundException('Financial title not found');
1924
+ }
1925
+
1926
+ return this.mapTitleToFront(updatedTitle);
1927
+ }
1600
1928
 
1601
- if (attachmentFileIds.length > 0) {
1602
- const existingFiles = await this.prisma.file.findMany({
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({
1603
1938
  where: {
1604
- id: { in: attachmentFileIds },
1939
+ id: titleId,
1940
+ title_type: titleType,
1605
1941
  },
1606
1942
  select: {
1607
1943
  id: true,
1944
+ status: true,
1945
+ competence_date: true,
1608
1946
  },
1609
1947
  });
1610
1948
 
1611
- const existingFileIds = new Set(existingFiles.map((file) => file.id));
1612
- const invalidFileIds = attachmentFileIds.filter(
1613
- (fileId) => !existingFileIds.has(fileId),
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',
1614
1963
  );
1615
1964
 
1616
- if (invalidFileIds.length > 0) {
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)) {
1617
2048
  throw new BadRequestException(
1618
- `Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
2049
+ 'Only approved/open/partial titles can be settled',
1619
2050
  );
1620
2051
  }
1621
2052
 
1622
- await this.prisma.financial_title_attachment.createMany({
1623
- data: attachmentFileIds.map((fileId) => ({
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,
1624
2062
  title_id: title.id,
1625
- file_id: fileId,
1626
- uploaded_by_user_id: userId,
1627
- })),
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
+ },
1628
2072
  });
1629
- }
1630
2073
 
1631
- for (let index = 0; index < installments.length; index++) {
1632
- const installment = installments[index];
1633
- const amountCents = this.toCents(installment.amount);
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
+ );
1634
2090
 
1635
- const createdInstallment = await this.prisma.financial_installment.create({
2091
+ const settlement = await tx.settlement.create({
1636
2092
  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),
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,
1643
2099
  amount_cents: amountCents,
1644
- open_amount_cents: amountCents,
1645
- status: 'open',
1646
- notes: data.description,
2100
+ description: data.description?.trim() || null,
2101
+ created_by_user_id: userId,
1647
2102
  },
1648
2103
  });
1649
2104
 
1650
- if (data.cost_center_id) {
1651
- await this.prisma.installment_allocation.create({
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
+ },
1652
2314
  data: {
1653
- installment_id: createdInstallment.id,
1654
- cost_center_id: data.cost_center_id,
1655
- allocated_amount_cents: amountCents,
2315
+ open_amount_cents: nextOpenAmountCents,
2316
+ status: nextInstallmentStatus,
1656
2317
  },
1657
2318
  });
1658
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');
1659
2366
  }
1660
2367
 
1661
- const createdTitle = await this.getTitleById(title.id, titleType, locale);
1662
- return this.mapTitleToFront(createdTitle, data.payment_channel);
2368
+ return this.mapTitleToFront(updatedTitle);
1663
2369
  }
1664
2370
 
1665
2371
  private async updateTitleTags(
@@ -1853,6 +2559,189 @@ export class FinanceService {
1853
2559
  }));
1854
2560
  }
1855
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
+
1856
2745
  private mapTitleToFront(title: any, paymentChannelOverride?: string) {
1857
2746
  const allocations = title.financial_installment.flatMap(
1858
2747
  (installment) => installment.installment_allocation,
@@ -1877,13 +2766,24 @@ export class FinanceService {
1877
2766
  numero: installment.installment_number,
1878
2767
  vencimento: installment.due_date.toISOString(),
1879
2768
  valor: this.fromCents(installment.amount_cents),
1880
- 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
+ ),
1881
2778
  metodoPagamento:
1882
2779
  this.mapPaymentMethodToPt(
1883
2780
  installment.settlement_allocation[0]?.settlement?.payment_method?.type,
1884
2781
  ) || paymentChannelOverride || 'transferencia',
1885
2782
  liquidacoes: installment.settlement_allocation.map((allocation) => ({
1886
2783
  id: String(allocation.id),
2784
+ settlementId: allocation.settlement?.id
2785
+ ? String(allocation.settlement.id)
2786
+ : null,
1887
2787
  data: allocation.settlement?.settled_at?.toISOString(),
1888
2788
  valor: this.fromCents(allocation.allocated_amount_cents),
1889
2789
  juros: this.fromCents(allocation.interest_cents || 0),
@@ -1892,6 +2792,7 @@ export class FinanceService {
1892
2792
  contaBancariaId: allocation.settlement?.bank_account_id
1893
2793
  ? String(allocation.settlement.bank_account_id)
1894
2794
  : null,
2795
+ status: allocation.settlement?.status || null,
1895
2796
  metodo:
1896
2797
  this.mapPaymentMethodToPt(
1897
2798
  allocation.settlement?.payment_method?.type,
@@ -2006,7 +2907,7 @@ export class FinanceService {
2006
2907
  }
2007
2908
 
2008
2909
  private mapPaymentMethodToPt(paymentMethodType?: string | null) {
2009
- const paymentMethodMap = {
2910
+ const paymentMethodMap: Record<string, string> = {
2010
2911
  boleto: 'boleto',
2011
2912
  pix: 'pix',
2012
2913
  ted: 'transferencia',
@@ -2019,6 +2920,30 @@ export class FinanceService {
2019
2920
  return paymentMethodMap[paymentMethodType] || undefined;
2020
2921
  }
2021
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
+
2022
2947
  private mapAccountTypeToPt(accountType?: string | null) {
2023
2948
  const accountTypeMap = {
2024
2949
  checking: 'corrente',