@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.
- package/dist/dto/reject-title.dto.d.ts +4 -0
- package/dist/dto/reject-title.dto.d.ts.map +1 -0
- package/dist/dto/reject-title.dto.js +22 -0
- package/dist/dto/reject-title.dto.js.map +1 -0
- package/dist/dto/reverse-settlement.dto.d.ts +4 -0
- package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
- package/dist/dto/reverse-settlement.dto.js +22 -0
- package/dist/dto/reverse-settlement.dto.js.map +1 -0
- package/dist/dto/settle-installment.dto.d.ts +12 -0
- package/dist/dto/settle-installment.dto.d.ts.map +1 -0
- package/dist/dto/settle-installment.dto.js +71 -0
- package/dist/dto/settle-installment.dto.js.map +1 -0
- package/dist/finance-data.controller.d.ts +13 -5
- package/dist/finance-data.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.d.ts +248 -12
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.js +92 -0
- package/dist/finance-installments.controller.js.map +1 -1
- package/dist/finance.service.d.ts +275 -17
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +666 -78
- package/dist/finance.service.js.map +1 -1
- package/hedhog/data/route.yaml +63 -0
- package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
- package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +355 -4
- package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +440 -16
- package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
- package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +432 -14
- package/package.json +5 -5
- package/src/dto/reject-title.dto.ts +7 -0
- package/src/dto/reverse-settlement.dto.ts +7 -0
- package/src/dto/settle-installment.dto.ts +55 -0
- package/src/finance-installments.controller.ts +102 -0
- package/src/finance.service.ts +1007 -82
package/src/finance.service.ts
CHANGED
|
@@ -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
|
|
1533
|
-
where: { id: data.person_id },
|
|
1534
|
-
select: { id: true },
|
|
1535
|
-
});
|
|
1634
|
+
const installments = this.normalizeAndValidateInstallments(data, locale);
|
|
1536
1635
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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 (!
|
|
1642
|
+
if (!person) {
|
|
1550
1643
|
throw new BadRequestException(
|
|
1551
|
-
getLocaleText('
|
|
1644
|
+
getLocaleText('personNotFound', locale, 'Person not found'),
|
|
1552
1645
|
);
|
|
1553
1646
|
}
|
|
1554
|
-
}
|
|
1555
1647
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
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:
|
|
1800
|
+
due_date: fallbackDueDate,
|
|
1576
1801
|
amount: data.total_amount,
|
|
1577
1802
|
},
|
|
1578
1803
|
];
|
|
1579
1804
|
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1922
|
+
if (!updatedTitle) {
|
|
1923
|
+
throw new NotFoundException('Financial title not found');
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
return this.mapTitleToFront(updatedTitle);
|
|
1927
|
+
}
|
|
1600
1928
|
|
|
1601
|
-
|
|
1602
|
-
|
|
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:
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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 (
|
|
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
|
-
|
|
2049
|
+
'Only approved/open/partial titles can be settled',
|
|
1619
2050
|
);
|
|
1620
2051
|
}
|
|
1621
2052
|
|
|
1622
|
-
await this.
|
|
1623
|
-
|
|
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
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
|
2091
|
+
const settlement = await tx.settlement.create({
|
|
1636
2092
|
data: {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1645
|
-
|
|
1646
|
-
notes: data.description,
|
|
2100
|
+
description: data.description?.trim() || null,
|
|
2101
|
+
created_by_user_id: userId,
|
|
1647
2102
|
},
|
|
1648
2103
|
});
|
|
1649
2104
|
|
|
1650
|
-
|
|
1651
|
-
|
|
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
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|