@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.
- package/dist/dto/create-bank-statement-adjustment.dto.d.ts +8 -0
- package/dist/dto/create-bank-statement-adjustment.dto.d.ts.map +1 -0
- package/dist/dto/create-bank-statement-adjustment.dto.js +50 -0
- package/dist/dto/create-bank-statement-adjustment.dto.js.map +1 -0
- package/dist/dto/create-transfer.dto.d.ts +8 -0
- package/dist/dto/create-transfer.dto.d.ts.map +1 -0
- package/dist/dto/create-transfer.dto.js +52 -0
- package/dist/dto/create-transfer.dto.js.map +1 -0
- package/dist/dto/register-collection-agreement.dto.d.ts +7 -0
- package/dist/dto/register-collection-agreement.dto.d.ts.map +1 -0
- package/dist/dto/register-collection-agreement.dto.js +37 -0
- package/dist/dto/register-collection-agreement.dto.js.map +1 -0
- package/dist/dto/send-collection.dto.d.ts +5 -0
- package/dist/dto/send-collection.dto.d.ts.map +1 -0
- package/dist/dto/send-collection.dto.js +29 -0
- package/dist/dto/send-collection.dto.js.map +1 -0
- package/dist/dto/settle-installment.dto.d.ts +1 -0
- package/dist/dto/settle-installment.dto.d.ts.map +1 -1
- package/dist/dto/settle-installment.dto.js +6 -0
- package/dist/dto/settle-installment.dto.js.map +1 -1
- package/dist/finance-collections.controller.d.ts +35 -0
- package/dist/finance-collections.controller.d.ts.map +1 -0
- package/dist/finance-collections.controller.js +65 -0
- package/dist/finance-collections.controller.js.map +1 -0
- package/dist/finance-data.controller.d.ts +4 -0
- package/dist/finance-data.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.d.ts +44 -0
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance-statements.controller.d.ts +16 -2
- package/dist/finance-statements.controller.d.ts.map +1 -1
- package/dist/finance-statements.controller.js +34 -6
- package/dist/finance-statements.controller.js.map +1 -1
- package/dist/finance-transfers.controller.d.ts +23 -0
- package/dist/finance-transfers.controller.d.ts.map +1 -0
- package/dist/finance-transfers.controller.js +56 -0
- package/dist/finance-transfers.controller.js.map +1 -0
- package/dist/finance.module.d.ts.map +1 -1
- package/dist/finance.module.js +4 -0
- package/dist/finance.module.js.map +1 -1
- package/dist/finance.service.d.ts +115 -2
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +632 -8
- package/dist/finance.service.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/hedhog/data/route.yaml +63 -0
- package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +643 -440
- package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +825 -477
- package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +367 -43
- package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +315 -75
- package/package.json +4 -4
- package/src/dto/create-bank-statement-adjustment.dto.ts +38 -0
- package/src/dto/create-transfer.dto.ts +46 -0
- package/src/dto/register-collection-agreement.dto.ts +27 -0
- package/src/dto/send-collection.dto.ts +14 -0
- package/src/dto/settle-installment.dto.ts +5 -0
- package/src/finance-collections.controller.ts +34 -0
- package/src/finance-statements.controller.ts +29 -1
- package/src/finance-transfers.controller.ts +26 -0
- package/src/finance.module.ts +4 -0
- package/src/finance.service.ts +775 -5
- package/src/index.ts +2 -0
package/src/finance.service.ts
CHANGED
|
@@ -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
|
|
1580
|
-
const
|
|
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
|
-
? {
|
|
4238
|
-
|
|
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
|
|