@hed-hog/finance 0.0.257 → 0.0.261

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 (64) hide show
  1. package/dist/dto/create-bank-statement-adjustment.dto.d.ts +8 -0
  2. package/dist/dto/create-bank-statement-adjustment.dto.d.ts.map +1 -0
  3. package/dist/dto/create-bank-statement-adjustment.dto.js +50 -0
  4. package/dist/dto/create-bank-statement-adjustment.dto.js.map +1 -0
  5. package/dist/dto/create-transfer.dto.d.ts +8 -0
  6. package/dist/dto/create-transfer.dto.d.ts.map +1 -0
  7. package/dist/dto/create-transfer.dto.js +52 -0
  8. package/dist/dto/create-transfer.dto.js.map +1 -0
  9. package/dist/dto/register-collection-agreement.dto.d.ts +7 -0
  10. package/dist/dto/register-collection-agreement.dto.d.ts.map +1 -0
  11. package/dist/dto/register-collection-agreement.dto.js +37 -0
  12. package/dist/dto/register-collection-agreement.dto.js.map +1 -0
  13. package/dist/dto/send-collection.dto.d.ts +5 -0
  14. package/dist/dto/send-collection.dto.d.ts.map +1 -0
  15. package/dist/dto/send-collection.dto.js +29 -0
  16. package/dist/dto/send-collection.dto.js.map +1 -0
  17. package/dist/dto/settle-installment.dto.d.ts +1 -0
  18. package/dist/dto/settle-installment.dto.d.ts.map +1 -1
  19. package/dist/dto/settle-installment.dto.js +6 -0
  20. package/dist/dto/settle-installment.dto.js.map +1 -1
  21. package/dist/finance-collections.controller.d.ts +35 -0
  22. package/dist/finance-collections.controller.d.ts.map +1 -0
  23. package/dist/finance-collections.controller.js +65 -0
  24. package/dist/finance-collections.controller.js.map +1 -0
  25. package/dist/finance-data.controller.d.ts +4 -0
  26. package/dist/finance-data.controller.d.ts.map +1 -1
  27. package/dist/finance-installments.controller.d.ts +44 -0
  28. package/dist/finance-installments.controller.d.ts.map +1 -1
  29. package/dist/finance-statements.controller.d.ts +16 -2
  30. package/dist/finance-statements.controller.d.ts.map +1 -1
  31. package/dist/finance-statements.controller.js +34 -6
  32. package/dist/finance-statements.controller.js.map +1 -1
  33. package/dist/finance-transfers.controller.d.ts +23 -0
  34. package/dist/finance-transfers.controller.d.ts.map +1 -0
  35. package/dist/finance-transfers.controller.js +56 -0
  36. package/dist/finance-transfers.controller.js.map +1 -0
  37. package/dist/finance.module.d.ts.map +1 -1
  38. package/dist/finance.module.js +4 -0
  39. package/dist/finance.module.js.map +1 -1
  40. package/dist/finance.service.d.ts +115 -2
  41. package/dist/finance.service.d.ts.map +1 -1
  42. package/dist/finance.service.js +632 -8
  43. package/dist/finance.service.js.map +1 -1
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/hedhog/data/route.yaml +63 -0
  49. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +643 -440
  50. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +825 -477
  51. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +367 -43
  52. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +315 -75
  53. package/package.json +4 -4
  54. package/src/dto/create-bank-statement-adjustment.dto.ts +38 -0
  55. package/src/dto/create-transfer.dto.ts +46 -0
  56. package/src/dto/register-collection-agreement.dto.ts +27 -0
  57. package/src/dto/send-collection.dto.ts +14 -0
  58. package/src/dto/settle-installment.dto.ts +5 -0
  59. package/src/finance-collections.controller.ts +34 -0
  60. package/src/finance-statements.controller.ts +29 -1
  61. package/src/finance-transfers.controller.ts +26 -0
  62. package/src/finance.module.ts +4 -0
  63. package/src/finance.service.ts +775 -5
  64. package/src/index.ts +2 -0
@@ -18,14 +18,18 @@ import {
18
18
  import { createHash } from 'node:crypto';
19
19
  import { readFile } from 'node:fs/promises';
20
20
  import { CreateBankAccountDto } from './dto/create-bank-account.dto';
21
+ import { CreateBankStatementAdjustmentDto } from './dto/create-bank-statement-adjustment.dto';
21
22
  import { CreateCostCenterDto } from './dto/create-cost-center.dto';
22
23
  import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
23
24
  import { CreateFinanceTagDto } from './dto/create-finance-tag.dto';
24
25
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
25
26
  import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
27
+ import { CreateTransferDto } from './dto/create-transfer.dto';
26
28
  import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
29
+ import { RegisterCollectionAgreementDto } from './dto/register-collection-agreement.dto';
27
30
  import { RejectTitleDto } from './dto/reject-title.dto';
28
31
  import { ReverseSettlementDto } from './dto/reverse-settlement.dto';
32
+ import { SendCollectionDto } from './dto/send-collection.dto';
29
33
  import { SettleInstallmentDto } from './dto/settle-installment.dto';
30
34
  import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
31
35
  import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
@@ -829,6 +833,298 @@ export class FinanceService {
829
833
  };
830
834
  }
831
835
 
836
+ async getAccountsReceivableCollectionsDefault() {
837
+ const today = this.startOfDay(new Date());
838
+
839
+ const overdueInstallments = await this.prisma.financial_installment.findMany({
840
+ where: {
841
+ open_amount_cents: {
842
+ gt: 0,
843
+ },
844
+ due_date: {
845
+ lt: today,
846
+ },
847
+ financial_title: {
848
+ title_type: 'receivable',
849
+ },
850
+ },
851
+ select: {
852
+ due_date: true,
853
+ open_amount_cents: true,
854
+ amount_cents: true,
855
+ financial_title: {
856
+ select: {
857
+ person_id: true,
858
+ person: {
859
+ select: {
860
+ name: true,
861
+ },
862
+ },
863
+ },
864
+ },
865
+ },
866
+ });
867
+
868
+ const byCustomer = new Map<
869
+ number,
870
+ {
871
+ clienteId: string;
872
+ cliente: string;
873
+ bucket0_30: number;
874
+ bucket31_60: number;
875
+ bucket61_90: number;
876
+ bucket90plus: number;
877
+ total: number;
878
+ }
879
+ >();
880
+
881
+ for (const installment of overdueInstallments) {
882
+ const personId = installment.financial_title.person_id;
883
+ const personName = installment.financial_title.person?.name || `Cliente ${personId}`;
884
+ const openAmount =
885
+ installment.open_amount_cents > 0
886
+ ? installment.open_amount_cents
887
+ : installment.amount_cents;
888
+ const amount = this.fromCents(openAmount);
889
+ const diffDays = Math.floor(
890
+ (today.getTime() - this.startOfDay(installment.due_date).getTime()) /
891
+ 86_400_000,
892
+ );
893
+
894
+ if (!byCustomer.has(personId)) {
895
+ byCustomer.set(personId, {
896
+ clienteId: String(personId),
897
+ cliente: personName,
898
+ bucket0_30: 0,
899
+ bucket31_60: 0,
900
+ bucket61_90: 0,
901
+ bucket90plus: 0,
902
+ total: 0,
903
+ });
904
+ }
905
+
906
+ const customer = byCustomer.get(personId)!;
907
+
908
+ if (diffDays <= 30) {
909
+ customer.bucket0_30 += amount;
910
+ } else if (diffDays <= 60) {
911
+ customer.bucket31_60 += amount;
912
+ } else if (diffDays <= 90) {
913
+ customer.bucket61_90 += amount;
914
+ } else {
915
+ customer.bucket90plus += amount;
916
+ }
917
+
918
+ customer.total += amount;
919
+ }
920
+
921
+ const agingInadimplencia = Array.from(byCustomer.values())
922
+ .map((item) => ({
923
+ ...item,
924
+ bucket0_30: Number(item.bucket0_30.toFixed(2)),
925
+ bucket31_60: Number(item.bucket31_60.toFixed(2)),
926
+ bucket61_90: Number(item.bucket61_90.toFixed(2)),
927
+ bucket90plus: Number(item.bucket90plus.toFixed(2)),
928
+ total: Number(item.total.toFixed(2)),
929
+ }))
930
+ .sort((a, b) => b.total - a.total);
931
+
932
+ const contactHistory = await this.prisma.audit_log.findMany({
933
+ where: {
934
+ entity_table: {
935
+ in: ['financial_collection_contact', 'financial_collection_agreement'],
936
+ },
937
+ },
938
+ include: {
939
+ user: {
940
+ select: {
941
+ name: true,
942
+ },
943
+ },
944
+ },
945
+ orderBy: {
946
+ created_at: 'desc',
947
+ },
948
+ take: 500,
949
+ });
950
+
951
+ const historicoContatos = contactHistory.map((log) => {
952
+ const afterData = this.parseAiJson(log.after_data || '{}');
953
+
954
+ return {
955
+ clienteId: log.entity_id,
956
+ tipo: this.mapCollectionActionToType(log.action, afterData?.channel),
957
+ data: log.created_at.toISOString(),
958
+ descricao: log.summary || '',
959
+ responsavel: log.user?.name || 'Sistema',
960
+ };
961
+ });
962
+
963
+ return {
964
+ agingInadimplencia,
965
+ historicoContatos,
966
+ };
967
+ }
968
+
969
+ async sendCollection(personId: number, data: SendCollectionDto, actorUserId?: number) {
970
+ const person = await this.prisma.person.findUnique({
971
+ where: {
972
+ id: personId,
973
+ },
974
+ select: {
975
+ id: true,
976
+ name: true,
977
+ },
978
+ });
979
+
980
+ if (!person) {
981
+ throw new NotFoundException('Person not found');
982
+ }
983
+
984
+ const log = await this.prisma.audit_log.create({
985
+ data: {
986
+ actor_user_id: actorUserId || null,
987
+ action: 'collection_sent',
988
+ entity_table: 'financial_collection_contact',
989
+ entity_id: String(person.id),
990
+ summary: `Cobrança enviada via e-mail para ${person.name}`,
991
+ after_data: JSON.stringify({
992
+ message: data.message,
993
+ subject: data.subject || null,
994
+ }),
995
+ },
996
+ });
997
+
998
+ return {
999
+ id: String(log.id),
1000
+ success: true,
1001
+ };
1002
+ }
1003
+
1004
+ async registerCollectionAgreement(
1005
+ personId: number,
1006
+ data: RegisterCollectionAgreementDto,
1007
+ actorUserId?: number,
1008
+ ) {
1009
+ const person = await this.prisma.person.findUnique({
1010
+ where: {
1011
+ id: personId,
1012
+ },
1013
+ select: {
1014
+ id: true,
1015
+ name: true,
1016
+ },
1017
+ });
1018
+
1019
+ if (!person) {
1020
+ throw new NotFoundException('Person not found');
1021
+ }
1022
+
1023
+ const firstDueDate = new Date(data.first_due_date);
1024
+
1025
+ if (Number.isNaN(firstDueDate.getTime())) {
1026
+ throw new BadRequestException('Invalid first due date');
1027
+ }
1028
+
1029
+ const totalAmountCents = this.toCents(Number(data.amount));
1030
+
1031
+ if (totalAmountCents <= 0) {
1032
+ throw new BadRequestException('Invalid agreement amount');
1033
+ }
1034
+
1035
+ const baseInstallmentCents = Math.floor(totalAmountCents / data.installments);
1036
+ const remainder = totalAmountCents % data.installments;
1037
+
1038
+ const created = await this.prisma.$transaction(async (tx) => {
1039
+ const title = await tx.financial_title.create({
1040
+ data: {
1041
+ person_id: person.id,
1042
+ title_type: 'receivable',
1043
+ status: 'open',
1044
+ document_number: `ACD-${Date.now()}`,
1045
+ description: data.notes || 'Acordo de cobrança',
1046
+ competence_date: firstDueDate,
1047
+ issue_date: new Date(),
1048
+ total_amount_cents: totalAmountCents,
1049
+ created_by_user_id: actorUserId || null,
1050
+ },
1051
+ select: {
1052
+ id: true,
1053
+ },
1054
+ });
1055
+
1056
+ for (let index = 0; index < data.installments; index += 1) {
1057
+ const dueDate = this.addMonths(firstDueDate, index);
1058
+ const amountCents =
1059
+ baseInstallmentCents + (index === data.installments - 1 ? remainder : 0);
1060
+
1061
+ await tx.financial_installment.create({
1062
+ data: {
1063
+ title_id: title.id,
1064
+ installment_number: index + 1,
1065
+ competence_date: dueDate,
1066
+ due_date: dueDate,
1067
+ amount_cents: amountCents,
1068
+ open_amount_cents: amountCents,
1069
+ status: this.resolveInstallmentStatus(
1070
+ amountCents,
1071
+ amountCents,
1072
+ dueDate,
1073
+ 'open',
1074
+ ),
1075
+ notes: data.notes || null,
1076
+ },
1077
+ });
1078
+ }
1079
+
1080
+ const log = await tx.audit_log.create({
1081
+ data: {
1082
+ actor_user_id: actorUserId || null,
1083
+ action: 'collection_agreement_registered',
1084
+ entity_table: 'financial_collection_agreement',
1085
+ entity_id: String(person.id),
1086
+ summary: `Acordo registrado para ${person.name}: ${data.installments}x`,
1087
+ after_data: JSON.stringify({
1088
+ title_id: title.id,
1089
+ installments: data.installments,
1090
+ amount: data.amount,
1091
+ first_due_date: data.first_due_date,
1092
+ notes: data.notes || null,
1093
+ }),
1094
+ },
1095
+ select: {
1096
+ id: true,
1097
+ },
1098
+ });
1099
+
1100
+ return {
1101
+ titleId: title.id,
1102
+ auditLogId: log.id,
1103
+ };
1104
+ });
1105
+
1106
+ return {
1107
+ success: true,
1108
+ titleId: String(created.titleId),
1109
+ auditLogId: String(created.auditLogId),
1110
+ };
1111
+ }
1112
+
1113
+ private mapCollectionActionToType(action?: string | null, channel?: string | null) {
1114
+ if (action === 'collection_agreement_registered') {
1115
+ return 'Acordo';
1116
+ }
1117
+
1118
+ if (channel) {
1119
+ const normalized = String(channel).toLowerCase();
1120
+ if (normalized === 'email') return 'E-mail';
1121
+ if (normalized === 'whatsapp') return 'WhatsApp';
1122
+ if (normalized === 'sms') return 'SMS';
1123
+ }
1124
+
1125
+ return 'Contato';
1126
+ }
1127
+
832
1128
  private calculateDashboardKpis(
833
1129
  payables: any[],
834
1130
  receivables: any[],
@@ -922,6 +1218,12 @@ export class FinanceService {
922
1218
  return next;
923
1219
  }
924
1220
 
1221
+ private addMonths(date: Date, months: number) {
1222
+ const next = new Date(date);
1223
+ next.setMonth(next.getMonth() + months);
1224
+ return next;
1225
+ }
1226
+
925
1227
  async listAccountsPayableInstallments(
926
1228
  paginationParams: PaginationDTO,
927
1229
  status?: string,
@@ -1335,6 +1637,228 @@ export class FinanceService {
1335
1637
  return bankAccounts.map((bankAccount) => this.mapBankAccountToFront(bankAccount));
1336
1638
  }
1337
1639
 
1640
+ async listTransfers(filters?: {
1641
+ search?: string;
1642
+ bank_account_id?: string;
1643
+ }) {
1644
+ const search = filters?.search?.trim();
1645
+ const parsedBankAccountId = filters?.bank_account_id
1646
+ ? Number.parseInt(filters.bank_account_id, 10)
1647
+ : undefined;
1648
+ const bankAccountId =
1649
+ parsedBankAccountId && !Number.isNaN(parsedBankAccountId)
1650
+ ? parsedBankAccountId
1651
+ : undefined;
1652
+
1653
+ let transferKeys: string[] | undefined;
1654
+
1655
+ if (search || bankAccountId) {
1656
+ const filteredLines = await this.prisma.bank_statement_line.findMany({
1657
+ where: {
1658
+ external_id: {
1659
+ startsWith: 'transfer:',
1660
+ },
1661
+ ...(search
1662
+ ? {
1663
+ description: {
1664
+ contains: search,
1665
+ mode: 'insensitive',
1666
+ },
1667
+ }
1668
+ : {}),
1669
+ ...(bankAccountId ? { bank_account_id: bankAccountId } : {}),
1670
+ },
1671
+ select: {
1672
+ external_id: true,
1673
+ },
1674
+ });
1675
+
1676
+ transferKeys = Array.from(
1677
+ new Set(
1678
+ filteredLines
1679
+ .map((line) => line.external_id)
1680
+ .filter((externalId): externalId is string => !!externalId),
1681
+ ),
1682
+ );
1683
+
1684
+ if (transferKeys.length === 0) {
1685
+ return [];
1686
+ }
1687
+ }
1688
+
1689
+ const transferLines = await this.prisma.bank_statement_line.findMany({
1690
+ where: {
1691
+ ...(transferKeys
1692
+ ? {
1693
+ external_id: {
1694
+ in: transferKeys,
1695
+ },
1696
+ }
1697
+ : {
1698
+ external_id: {
1699
+ startsWith: 'transfer:',
1700
+ },
1701
+ }),
1702
+ },
1703
+ select: {
1704
+ id: true,
1705
+ external_id: true,
1706
+ bank_account_id: true,
1707
+ posted_date: true,
1708
+ amount_cents: true,
1709
+ description: true,
1710
+ },
1711
+ orderBy: [{ posted_date: 'desc' }, { id: 'desc' }],
1712
+ });
1713
+
1714
+ const groupedByTransfer = new Map<string, (typeof transferLines)[number][]>();
1715
+
1716
+ for (const line of transferLines) {
1717
+ const transferKey = line.external_id;
1718
+ if (!transferKey) {
1719
+ continue;
1720
+ }
1721
+
1722
+ const current = groupedByTransfer.get(transferKey) || [];
1723
+ current.push(line);
1724
+ groupedByTransfer.set(transferKey, current);
1725
+ }
1726
+
1727
+ const transfers = Array.from(groupedByTransfer.entries())
1728
+ .map(([transferKey, lines]) => {
1729
+ const sourceLine = lines.find((line) => line.amount_cents < 0);
1730
+ const destinationLine = lines.find((line) => line.amount_cents > 0);
1731
+
1732
+ if (!sourceLine || !destinationLine) {
1733
+ return null;
1734
+ }
1735
+
1736
+ return {
1737
+ id: transferKey.replace('transfer:', ''),
1738
+ contaOrigemId: String(sourceLine.bank_account_id),
1739
+ contaDestinoId: String(destinationLine.bank_account_id),
1740
+ data: sourceLine.posted_date.toISOString(),
1741
+ valor: this.fromCents(Math.abs(sourceLine.amount_cents)),
1742
+ descricao: sourceLine.description || destinationLine.description || '',
1743
+ };
1744
+ })
1745
+ .filter(Boolean);
1746
+
1747
+ return transfers;
1748
+ }
1749
+
1750
+ async createTransfer(data: CreateTransferDto, userId?: number) {
1751
+ const sourceAccountId = Number(data.source_account_id);
1752
+ const destinationAccountId = Number(data.destination_account_id);
1753
+ const amount = Number(data.amount);
1754
+ const postedDate = new Date(data.date);
1755
+
1756
+ if (
1757
+ Number.isNaN(sourceAccountId) ||
1758
+ Number.isNaN(destinationAccountId) ||
1759
+ sourceAccountId <= 0 ||
1760
+ destinationAccountId <= 0
1761
+ ) {
1762
+ throw new BadRequestException('Invalid bank account ids');
1763
+ }
1764
+
1765
+ if (sourceAccountId === destinationAccountId) {
1766
+ throw new BadRequestException(
1767
+ 'Source and destination accounts must be different',
1768
+ );
1769
+ }
1770
+
1771
+ if (Number.isNaN(amount) || amount <= 0) {
1772
+ throw new BadRequestException('amount must be greater than zero');
1773
+ }
1774
+
1775
+ if (Number.isNaN(postedDate.getTime())) {
1776
+ throw new BadRequestException('Invalid transfer date');
1777
+ }
1778
+
1779
+ const accounts = await this.prisma.bank_account.findMany({
1780
+ where: {
1781
+ id: {
1782
+ in: [sourceAccountId, destinationAccountId],
1783
+ },
1784
+ },
1785
+ select: {
1786
+ id: true,
1787
+ },
1788
+ });
1789
+
1790
+ if (accounts.length !== 2) {
1791
+ throw new NotFoundException('Bank account not found');
1792
+ }
1793
+
1794
+ const amountCents = this.toCents(amount);
1795
+ const description = data.description?.trim() || 'Transferência bancária';
1796
+ const transferReference = `transfer:${Date.now()}-${Math.round(
1797
+ Math.random() * 1_000_000,
1798
+ )}`;
1799
+
1800
+ await this.prisma.$transaction(async (tx) => {
1801
+ const sourceStatement = await tx.bank_statement.create({
1802
+ data: {
1803
+ bank_account_id: sourceAccountId,
1804
+ source_type: 'manual',
1805
+ imported_at: new Date(),
1806
+ imported_by_user_id: userId,
1807
+ idempotency_key: `${transferReference}:source`,
1808
+ period_start: postedDate,
1809
+ period_end: postedDate,
1810
+ },
1811
+ });
1812
+
1813
+ const destinationStatement = await tx.bank_statement.create({
1814
+ data: {
1815
+ bank_account_id: destinationAccountId,
1816
+ source_type: 'manual',
1817
+ imported_at: new Date(),
1818
+ imported_by_user_id: userId,
1819
+ idempotency_key: `${transferReference}:destination`,
1820
+ period_start: postedDate,
1821
+ period_end: postedDate,
1822
+ },
1823
+ });
1824
+
1825
+ await tx.bank_statement_line.create({
1826
+ data: {
1827
+ bank_statement_id: sourceStatement.id,
1828
+ bank_account_id: sourceAccountId,
1829
+ external_id: transferReference,
1830
+ posted_date: postedDate,
1831
+ amount_cents: -Math.abs(amountCents),
1832
+ description,
1833
+ status: 'reconciled',
1834
+ dedupe_key: `${transferReference}:source`,
1835
+ },
1836
+ });
1837
+
1838
+ await tx.bank_statement_line.create({
1839
+ data: {
1840
+ bank_statement_id: destinationStatement.id,
1841
+ bank_account_id: destinationAccountId,
1842
+ external_id: transferReference,
1843
+ posted_date: postedDate,
1844
+ amount_cents: Math.abs(amountCents),
1845
+ description,
1846
+ status: 'reconciled',
1847
+ dedupe_key: `${transferReference}:destination`,
1848
+ },
1849
+ });
1850
+ });
1851
+
1852
+ return {
1853
+ id: transferReference.replace('transfer:', ''),
1854
+ contaOrigemId: String(sourceAccountId),
1855
+ contaDestinoId: String(destinationAccountId),
1856
+ data: postedDate.toISOString(),
1857
+ valor: amount,
1858
+ descricao: description,
1859
+ };
1860
+ }
1861
+
1338
1862
  async listCostCenters() {
1339
1863
  const costCenters = await this.prisma.cost_center.findMany({
1340
1864
  orderBy: [{ code: 'asc' }, { name: 'asc' }],
@@ -1550,10 +2074,20 @@ export class FinanceService {
1550
2074
  };
1551
2075
  }
1552
2076
 
1553
- async listBankStatements(bankAccountId?: number) {
2077
+ async listBankStatements(bankAccountId?: number, search?: string) {
2078
+ const trimmedSearch = search?.trim();
2079
+
1554
2080
  const statements = await this.prisma.bank_statement_line.findMany({
1555
2081
  where: {
1556
2082
  ...(bankAccountId ? { bank_account_id: bankAccountId } : {}),
2083
+ ...(trimmedSearch
2084
+ ? {
2085
+ description: {
2086
+ contains: trimmedSearch,
2087
+ mode: 'insensitive',
2088
+ },
2089
+ }
2090
+ : {}),
1557
2091
  },
1558
2092
  include: {
1559
2093
  bank_account: {
@@ -1576,8 +2110,83 @@ export class FinanceService {
1576
2110
  }));
1577
2111
  }
1578
2112
 
1579
- async exportBankStatementsCsv(bankAccountId: number) {
1580
- const statements = await this.listBankStatements(bankAccountId);
2113
+ async getBankReconciliationSummary(bankAccountId: number) {
2114
+ const pendingStatements = await this.prisma.bank_statement_line.findMany({
2115
+ where: {
2116
+ bank_account_id: bankAccountId,
2117
+ status: {
2118
+ in: ['pending', 'imported'],
2119
+ },
2120
+ },
2121
+ select: {
2122
+ amount_cents: true,
2123
+ },
2124
+ });
2125
+
2126
+ const openInstallments = await this.prisma.financial_installment.findMany({
2127
+ where: {
2128
+ open_amount_cents: {
2129
+ gt: 0,
2130
+ },
2131
+ status: {
2132
+ in: ['open', 'partial', 'overdue'],
2133
+ },
2134
+ financial_title: {
2135
+ status: {
2136
+ in: ['open', 'partial', 'overdue'],
2137
+ },
2138
+ title_type: {
2139
+ in: ['payable', 'receivable'],
2140
+ },
2141
+ },
2142
+ },
2143
+ select: {
2144
+ open_amount_cents: true,
2145
+ financial_title: {
2146
+ select: {
2147
+ title_type: true,
2148
+ },
2149
+ },
2150
+ },
2151
+ });
2152
+
2153
+ const payableAmounts = new Set<number>();
2154
+ const receivableAmounts = new Set<number>();
2155
+
2156
+ for (const installment of openInstallments) {
2157
+ const amount = Math.abs(installment.open_amount_cents);
2158
+ if (installment.financial_title.title_type === 'payable') {
2159
+ payableAmounts.add(amount);
2160
+ } else if (installment.financial_title.title_type === 'receivable') {
2161
+ receivableAmounts.add(amount);
2162
+ }
2163
+ }
2164
+
2165
+ let discrepancyCount = 0;
2166
+ let differenceCents = 0;
2167
+
2168
+ for (const statement of pendingStatements) {
2169
+ const normalizedAmount = Math.abs(statement.amount_cents);
2170
+ differenceCents += statement.amount_cents;
2171
+
2172
+ const hasPossibleMatch =
2173
+ statement.amount_cents < 0
2174
+ ? payableAmounts.has(normalizedAmount)
2175
+ : receivableAmounts.has(normalizedAmount);
2176
+
2177
+ if (!hasPossibleMatch) {
2178
+ discrepancyCount += 1;
2179
+ }
2180
+ }
2181
+
2182
+ return {
2183
+ discrepancies: discrepancyCount,
2184
+ difference: this.fromCents(differenceCents),
2185
+ };
2186
+ }
2187
+
2188
+ async exportBankStatementsCsv(bankAccountId: number, search?: string) {
2189
+ const statements = await this.listBankStatements(bankAccountId, search);
1581
2190
 
1582
2191
  const headers = [
1583
2192
  'id',
@@ -1614,6 +2223,79 @@ export class FinanceService {
1614
2223
  };
1615
2224
  }
1616
2225
 
2226
+ async createBankStatementAdjustment(
2227
+ data: CreateBankStatementAdjustmentDto,
2228
+ userId?: number,
2229
+ ) {
2230
+ const bankAccountId = Number(data.bank_account_id);
2231
+ const amount = Number(data.amount);
2232
+ const postedAt = data.date ? new Date(data.date) : new Date();
2233
+
2234
+ if (Number.isNaN(bankAccountId) || bankAccountId <= 0) {
2235
+ throw new BadRequestException('bank_account_id is required');
2236
+ }
2237
+
2238
+ if (Number.isNaN(amount) || amount <= 0) {
2239
+ throw new BadRequestException('amount must be greater than zero');
2240
+ }
2241
+
2242
+ if (Number.isNaN(postedAt.getTime())) {
2243
+ throw new BadRequestException('Invalid adjustment date');
2244
+ }
2245
+
2246
+ const bankAccount = await this.prisma.bank_account.findUnique({
2247
+ where: { id: bankAccountId },
2248
+ select: { id: true },
2249
+ });
2250
+
2251
+ if (!bankAccount) {
2252
+ throw new NotFoundException('Bank account not found');
2253
+ }
2254
+
2255
+ const adjustedAmountCents = -Math.abs(this.toCents(amount));
2256
+ const description = data.description?.trim() || `Ajuste: ${data.type}`;
2257
+ const reference = `adjustment:${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
2258
+
2259
+ const created = await this.prisma.$transaction(async (tx) => {
2260
+ const statement = await tx.bank_statement.create({
2261
+ data: {
2262
+ bank_account_id: bankAccountId,
2263
+ source_type: 'manual',
2264
+ imported_at: new Date(),
2265
+ imported_by_user_id: userId,
2266
+ idempotency_key: reference,
2267
+ period_start: postedAt,
2268
+ period_end: postedAt,
2269
+ },
2270
+ });
2271
+
2272
+ const line = await tx.bank_statement_line.create({
2273
+ data: {
2274
+ bank_statement_id: statement.id,
2275
+ bank_account_id: bankAccountId,
2276
+ external_id: reference,
2277
+ posted_date: postedAt,
2278
+ amount_cents: adjustedAmountCents,
2279
+ description,
2280
+ status: 'adjusted',
2281
+ dedupe_key: reference,
2282
+ },
2283
+ });
2284
+
2285
+ return line;
2286
+ });
2287
+
2288
+ return {
2289
+ id: String(created.id),
2290
+ contaBancariaId: String(created.bank_account_id),
2291
+ data: created.posted_date.toISOString(),
2292
+ descricao: created.description,
2293
+ valor: this.fromCents(created.amount_cents),
2294
+ tipo: created.amount_cents >= 0 ? 'entrada' : 'saida',
2295
+ statusConciliacao: this.mapStatementStatusToPt(created.status),
2296
+ };
2297
+ }
2298
+
1617
2299
  async importBankStatements(
1618
2300
  bankAccountId: number,
1619
2301
  file: MulterFile,
@@ -3319,6 +4001,83 @@ export class FinanceService {
3319
4001
  },
3320
4002
  });
3321
4003
 
4004
+ let reconciliationId: number | null = null;
4005
+
4006
+ if (data.bank_statement_line_id) {
4007
+ const statementLine = await tx.bank_statement_line.findUnique({
4008
+ where: {
4009
+ id: data.bank_statement_line_id,
4010
+ },
4011
+ select: {
4012
+ id: true,
4013
+ bank_account_id: true,
4014
+ amount_cents: true,
4015
+ status: true,
4016
+ },
4017
+ });
4018
+
4019
+ if (!statementLine) {
4020
+ throw new NotFoundException('Bank statement line not found');
4021
+ }
4022
+
4023
+ if (
4024
+ data.bank_account_id &&
4025
+ statementLine.bank_account_id !== data.bank_account_id
4026
+ ) {
4027
+ throw new ConflictException(
4028
+ 'Bank statement line does not belong to informed bank account',
4029
+ );
4030
+ }
4031
+
4032
+ if (Math.abs(statementLine.amount_cents) !== amountCents) {
4033
+ throw new ConflictException(
4034
+ 'Bank statement amount and settlement amount must match',
4035
+ );
4036
+ }
4037
+
4038
+ const hasReconciliation = await tx.bank_reconciliation.findFirst({
4039
+ where: {
4040
+ bank_statement_line_id: statementLine.id,
4041
+ status: 'reconciled',
4042
+ },
4043
+ select: {
4044
+ id: true,
4045
+ },
4046
+ });
4047
+
4048
+ if (hasReconciliation) {
4049
+ throw new ConflictException('Bank statement line already reconciled');
4050
+ }
4051
+
4052
+ const createdReconciliation = await tx.bank_reconciliation.create({
4053
+ data: {
4054
+ bank_statement_line_id: statementLine.id,
4055
+ settlement_id: settlement.id,
4056
+ status: 'reconciled',
4057
+ matched_at: settledAt,
4058
+ reconciled_at: settledAt,
4059
+ matched_by_user_id: userId || null,
4060
+ reconciled_by_user_id: userId || null,
4061
+ },
4062
+ select: {
4063
+ id: true,
4064
+ },
4065
+ });
4066
+
4067
+ reconciliationId = createdReconciliation.id;
4068
+
4069
+ if (statementLine.status !== 'reconciled') {
4070
+ await tx.bank_statement_line.update({
4071
+ where: {
4072
+ id: statementLine.id,
4073
+ },
4074
+ data: {
4075
+ status: 'reconciled',
4076
+ },
4077
+ });
4078
+ }
4079
+ }
4080
+
3322
4081
  await tx.settlement_allocation.create({
3323
4082
  data: {
3324
4083
  settlement: {
@@ -3410,6 +4169,7 @@ export class FinanceService {
3410
4169
  title_status: nextTitleStatus,
3411
4170
  installment_open_amount_cents: updatedInstallment.open_amount_cents,
3412
4171
  settlement_id: settlement.id,
4172
+ bank_reconciliation_id: reconciliationId,
3413
4173
  }),
3414
4174
  });
3415
4175
 
@@ -3428,12 +4188,16 @@ export class FinanceService {
3428
4188
  return {
3429
4189
  title: updatedTitle,
3430
4190
  settlementId: settlement.id,
4191
+ reconciliationId,
3431
4192
  };
3432
4193
  });
3433
4194
 
3434
4195
  return {
3435
4196
  ...this.mapTitleToFront(result.title),
3436
4197
  settlementId: String(result.settlementId),
4198
+ reconciliationId: result.reconciliationId
4199
+ ? String(result.reconciliationId)
4200
+ : null,
3437
4201
  };
3438
4202
  } catch (error: any) {
3439
4203
  const message = String(error?.message || '');
@@ -4234,8 +4998,14 @@ export class FinanceService {
4234
4998
  paymentChannelOverride ||
4235
4999
  'transferencia',
4236
5000
  ...(title.title_type === 'payable'
4237
- ? { fornecedorId: String(title.person_id) }
4238
- : { clienteId: String(title.person_id) }),
5001
+ ? {
5002
+ fornecedorId: String(title.person_id),
5003
+ fornecedor: title.person?.name || '',
5004
+ }
5005
+ : {
5006
+ clienteId: String(title.person_id),
5007
+ cliente: title.person?.name || '',
5008
+ }),
4239
5009
  };
4240
5010
  }
4241
5011