@hed-hog/finance 0.0.260 → 0.0.262

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 (28) hide show
  1. package/dist/dto/update-finance-scenario-settings.dto.d.ts +7 -0
  2. package/dist/dto/update-finance-scenario-settings.dto.d.ts.map +1 -0
  3. package/dist/dto/update-finance-scenario-settings.dto.js +39 -0
  4. package/dist/dto/update-finance-scenario-settings.dto.js.map +1 -0
  5. package/dist/finance-data.controller.d.ts +61 -7
  6. package/dist/finance-data.controller.d.ts.map +1 -1
  7. package/dist/finance-data.controller.js +23 -3
  8. package/dist/finance-data.controller.js.map +1 -1
  9. package/dist/finance.service.d.ts +79 -9
  10. package/dist/finance.service.d.ts.map +1 -1
  11. package/dist/finance.service.js +471 -70
  12. package/dist/finance.service.js.map +1 -1
  13. package/hedhog/data/route.yaml +9 -0
  14. package/hedhog/data/setting_group.yaml +152 -0
  15. package/hedhog/frontend/app/_lib/use-finance-data.ts.ejs +31 -3
  16. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +38 -7
  17. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +3 -1
  18. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +74 -4
  19. package/hedhog/frontend/app/reports/actual-vs-forecast/page.tsx.ejs +361 -0
  20. package/hedhog/frontend/app/reports/aging-default/page.tsx.ejs +368 -0
  21. package/hedhog/frontend/app/reports/cash-position/page.tsx.ejs +432 -0
  22. package/hedhog/frontend/messages/en.json +182 -0
  23. package/hedhog/frontend/messages/pt.json +182 -0
  24. package/hedhog/query/triggers-period-close.sql +361 -0
  25. package/package.json +4 -4
  26. package/src/dto/update-finance-scenario-settings.dto.ts +21 -0
  27. package/src/finance-data.controller.ts +18 -3
  28. package/src/finance.service.ts +781 -79
@@ -1,19 +1,19 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import {
3
- PageOrderDirection,
4
- PaginationDTO,
5
- PaginationService,
3
+ PageOrderDirection,
4
+ PaginationDTO,
5
+ PaginationService,
6
6
  } from '@hed-hog/api-pagination';
7
7
  import { PrismaService } from '@hed-hog/api-prisma';
8
- import { AiService, FileService } from '@hed-hog/core';
8
+ import { AiService, FileService, SettingService } from '@hed-hog/core';
9
9
  import {
10
- BadRequestException,
11
- ConflictException,
12
- forwardRef,
13
- Inject,
14
- Injectable,
15
- Logger,
16
- NotFoundException,
10
+ BadRequestException,
11
+ ConflictException,
12
+ forwardRef,
13
+ Inject,
14
+ Injectable,
15
+ Logger,
16
+ NotFoundException,
17
17
  } from '@nestjs/common';
18
18
  import { createHash } from 'node:crypto';
19
19
  import { readFile } from 'node:fs/promises';
@@ -50,6 +50,25 @@ type TitleStatus =
50
50
  | 'canceled'
51
51
  | 'overdue';
52
52
 
53
+ type ForecastScenario = 'base' | 'pessimista' | 'otimista';
54
+
55
+ type FinancialScenarioConfig = {
56
+ id: ForecastScenario;
57
+ nome: string;
58
+ descricao: string;
59
+ atrasoMedio: number;
60
+ taxaInadimplencia: number;
61
+ crescimentoReceita: number;
62
+ padrao: boolean;
63
+ };
64
+
65
+ type FinancialScenarioSettings = {
66
+ defaultScenario: ForecastScenario;
67
+ defaultHorizonDays: number;
68
+ cenarios: FinancialScenarioConfig[];
69
+ map: Record<ForecastScenario, FinancialScenarioConfig>;
70
+ };
71
+
53
72
  @Injectable()
54
73
  export class FinanceService {
55
74
  private readonly logger = new Logger(FinanceService.name);
@@ -58,6 +77,8 @@ export class FinanceService {
58
77
  private readonly prisma: PrismaService,
59
78
  private readonly paginationService: PaginationService,
60
79
  private readonly ai: AiService,
80
+ @Inject(forwardRef(() => SettingService))
81
+ private readonly settingService: SettingService,
61
82
  @Inject(forwardRef(() => FileService))
62
83
  private readonly fileService: FileService,
63
84
  ) {}
@@ -727,7 +748,9 @@ export class FinanceService {
727
748
  return Number.isFinite(num) ? num : null;
728
749
  }
729
750
 
730
- async getData() {
751
+ async getData(filters?: { horizonteDias?: string | number; cenario?: string }) {
752
+ const today = this.startOfDay(new Date());
753
+
731
754
  const [
732
755
  payablesResult,
733
756
  receivablesResult,
@@ -735,9 +758,13 @@ export class FinanceService {
735
758
  categoriesResult,
736
759
  costCentersResult,
737
760
  bankAccountsResult,
761
+ bankStatementsResult,
762
+ collectionsDefaultResult,
738
763
  tagsResult,
739
764
  auditLogsResult,
740
765
  openPeriodResult,
766
+ scenarioSettingsResult,
767
+ confirmedSettlementsResult,
741
768
  ] = await Promise.allSettled([
742
769
  this.loadTitles('payable'),
743
770
  this.loadTitles('receivable'),
@@ -745,9 +772,13 @@ export class FinanceService {
745
772
  this.loadCategories(),
746
773
  this.loadCostCenters(),
747
774
  this.loadBankAccounts(),
775
+ this.listBankStatements(),
776
+ this.getAccountsReceivableCollectionsDefault(),
748
777
  this.loadTags(),
749
778
  this.loadAuditLogs(),
750
779
  this.loadOpenPeriod(),
780
+ this.loadFinancialScenarioSettings(),
781
+ this.loadConfirmedSettlements(today, this.addDays(today, 365)),
751
782
  ]);
752
783
 
753
784
  const payables = payablesResult.status === 'fulfilled' ? payablesResult.value : [];
@@ -760,11 +791,40 @@ export class FinanceService {
760
791
  costCentersResult.status === 'fulfilled' ? costCentersResult.value : [];
761
792
  const bankAccounts =
762
793
  bankAccountsResult.status === 'fulfilled' ? bankAccountsResult.value : [];
794
+ const bankStatements =
795
+ bankStatementsResult.status === 'fulfilled'
796
+ ? bankStatementsResult.value
797
+ : [];
798
+ const collectionsDefault =
799
+ collectionsDefaultResult.status === 'fulfilled'
800
+ ? collectionsDefaultResult.value
801
+ : {
802
+ agingInadimplencia: [],
803
+ historicoContatos: [],
804
+ };
763
805
  const tags = tagsResult.status === 'fulfilled' ? tagsResult.value : [];
764
806
  const auditLogs =
765
807
  auditLogsResult.status === 'fulfilled' ? auditLogsResult.value : [];
766
808
  const openPeriod =
767
809
  openPeriodResult.status === 'fulfilled' ? openPeriodResult.value : null;
810
+ const confirmedSettlements =
811
+ confirmedSettlementsResult.status === 'fulfilled'
812
+ ? confirmedSettlementsResult.value
813
+ : [];
814
+ const scenarioSettings =
815
+ scenarioSettingsResult.status === 'fulfilled'
816
+ ? scenarioSettingsResult.value
817
+ : this.getDefaultFinancialScenarioSettings();
818
+
819
+ const horizonteDias = this.resolveHorizonDays(
820
+ filters?.horizonteDias,
821
+ scenarioSettings.defaultHorizonDays,
822
+ );
823
+ const cenario = this.resolveForecastScenario(
824
+ filters?.cenario,
825
+ scenarioSettings.defaultScenario,
826
+ );
827
+ const forecastEnd = this.addDays(today, horizonteDias);
768
828
 
769
829
  if (payablesResult.status === 'rejected') {
770
830
  this.logger.error('Failed to load finance payables', payablesResult.reason);
@@ -784,6 +844,18 @@ export class FinanceService {
784
844
  if (bankAccountsResult.status === 'rejected') {
785
845
  this.logger.error('Failed to load finance bank accounts', bankAccountsResult.reason);
786
846
  }
847
+ if (bankStatementsResult.status === 'rejected') {
848
+ this.logger.error(
849
+ 'Failed to load finance bank statements',
850
+ bankStatementsResult.reason,
851
+ );
852
+ }
853
+ if (collectionsDefaultResult.status === 'rejected') {
854
+ this.logger.error(
855
+ 'Failed to load finance collections default',
856
+ collectionsDefaultResult.reason,
857
+ );
858
+ }
787
859
  if (tagsResult.status === 'rejected') {
788
860
  this.logger.error('Failed to load finance tags', tagsResult.reason);
789
861
  }
@@ -793,6 +865,18 @@ export class FinanceService {
793
865
  if (openPeriodResult.status === 'rejected') {
794
866
  this.logger.error('Failed to load finance open period', openPeriodResult.reason);
795
867
  }
868
+ if (confirmedSettlementsResult.status === 'rejected') {
869
+ this.logger.error(
870
+ 'Failed to load finance confirmed settlements',
871
+ confirmedSettlementsResult.reason,
872
+ );
873
+ }
874
+ if (scenarioSettingsResult.status === 'rejected') {
875
+ this.logger.error(
876
+ 'Failed to load finance scenario settings',
877
+ scenarioSettingsResult.reason,
878
+ );
879
+ }
796
880
 
797
881
  const aprovacoesPendentes = payables
798
882
  .filter((title: any) => title.status === 'rascunho')
@@ -807,32 +891,581 @@ export class FinanceService {
807
891
  }));
808
892
 
809
893
  const kpis = this.calculateDashboardKpis(payables, receivables, bankAccounts);
894
+ const { recebiveis, adquirentes } =
895
+ this.buildReceivablesCalendarData(receivables);
896
+ const { fluxoCaixaPrevisto, entradasPrevistas, saidasPrevistas } =
897
+ this.buildCashFlowForecastData(
898
+ payables,
899
+ receivables,
900
+ confirmedSettlements,
901
+ kpis.saldoCaixa,
902
+ today,
903
+ forecastEnd,
904
+ scenarioSettings.map[cenario],
905
+ );
810
906
 
811
907
  return {
812
908
  kpis,
813
- fluxoCaixaPrevisto: [],
909
+ defaultScenario: scenarioSettings.defaultScenario,
910
+ defaultHorizonDays: scenarioSettings.defaultHorizonDays,
911
+ fluxoCaixaPrevisto,
814
912
  titulosPagar: payables,
815
913
  titulosReceber: receivables,
816
- extratos: [],
914
+ extratos: bankStatements,
817
915
  contasBancarias: bankAccounts,
818
916
  pessoas: people,
819
917
  categorias: categories,
820
918
  centrosCusto: costCenters,
821
919
  aprovacoesPendentes,
822
- agingInadimplencia: [],
823
- cenarios: [],
920
+ agingInadimplencia: collectionsDefault.agingInadimplencia,
921
+ cenarios: scenarioSettings.cenarios,
824
922
  transferencias: [],
825
923
  tags,
826
924
  logsAuditoria: auditLogs,
827
- recebiveis: [],
828
- adquirentes: [],
829
- historicoContatos: [],
830
- entradasPrevistas: [],
831
- saidasPrevistas: [],
925
+ recebiveis,
926
+ adquirentes,
927
+ historicoContatos: collectionsDefault.historicoContatos,
928
+ entradasPrevistas,
929
+ saidasPrevistas,
832
930
  periodoAberto: openPeriod,
833
931
  };
834
932
  }
835
933
 
934
+ async updateScenarioSettings(
935
+ scenario: string,
936
+ data: {
937
+ atrasoMedio: number;
938
+ taxaInadimplencia: number;
939
+ crescimentoReceita: number;
940
+ setAsDefault?: boolean;
941
+ },
942
+ ) {
943
+ const scenarioSlug = this.resolveForecastScenarioStrict(scenario);
944
+
945
+ if (data.atrasoMedio < 0 || data.atrasoMedio > 365) {
946
+ throw new BadRequestException('Invalid average delay value');
947
+ }
948
+
949
+ if (data.taxaInadimplencia < 0 || data.taxaInadimplencia > 100) {
950
+ throw new BadRequestException('Invalid default rate value');
951
+ }
952
+
953
+ if (data.crescimentoReceita < -100 || data.crescimentoReceita > 1000) {
954
+ throw new BadRequestException('Invalid revenue growth value');
955
+ }
956
+
957
+ const prefix = `finance-scenario-${
958
+ scenarioSlug === 'pessimista'
959
+ ? 'pessimistic'
960
+ : scenarioSlug === 'otimista'
961
+ ? 'optimistic'
962
+ : 'base'
963
+ }`;
964
+
965
+ const settingUpdates: Array<{ slug: string; value: string }> = [
966
+ {
967
+ slug: `${prefix}-average-delay-days`,
968
+ value: String(data.atrasoMedio),
969
+ },
970
+ {
971
+ slug: `${prefix}-default-rate-percent`,
972
+ value: String(data.taxaInadimplencia),
973
+ },
974
+ {
975
+ slug: `${prefix}-revenue-growth-percent`,
976
+ value: String(data.crescimentoReceita),
977
+ },
978
+ ];
979
+
980
+ if (data.setAsDefault !== false) {
981
+ settingUpdates.push({
982
+ slug: 'finance-default-scenario',
983
+ value: scenarioSlug,
984
+ });
985
+ }
986
+
987
+ await this.settingService.setManySettings({
988
+ setting: settingUpdates,
989
+ } as any);
990
+
991
+ const settings = await this.loadFinancialScenarioSettings();
992
+ return {
993
+ success: true,
994
+ cenarios: settings.cenarios,
995
+ defaultScenario: settings.defaultScenario,
996
+ };
997
+ }
998
+
999
+ private resolveHorizonDays(value?: string | number, fallback = 90) {
1000
+ const parsed = Number(value);
1001
+ const allowed = new Set([30, 60, 90, 180, 365]);
1002
+
1003
+ if (!Number.isFinite(parsed)) {
1004
+ return allowed.has(fallback) ? fallback : 90;
1005
+ }
1006
+
1007
+ return allowed.has(parsed) ? parsed : allowed.has(fallback) ? fallback : 90;
1008
+ }
1009
+
1010
+ private resolveForecastScenario(
1011
+ value?: string,
1012
+ fallback: ForecastScenario = 'base',
1013
+ ): ForecastScenario {
1014
+ if (value === 'pessimista') {
1015
+ return 'pessimista';
1016
+ }
1017
+
1018
+ if (value === 'otimista') {
1019
+ return 'otimista';
1020
+ }
1021
+
1022
+ return fallback;
1023
+ }
1024
+
1025
+ private resolveForecastScenarioStrict(value?: string): ForecastScenario {
1026
+ if (value === 'base' || value === 'pessimista' || value === 'otimista') {
1027
+ return value;
1028
+ }
1029
+
1030
+ throw new BadRequestException('Invalid scenario');
1031
+ }
1032
+
1033
+ private buildCashFlowForecastData(
1034
+ payables: any[],
1035
+ receivables: any[],
1036
+ confirmedSettlements: Array<{
1037
+ settled_at: Date;
1038
+ amount_cents: bigint;
1039
+ settlement_type: string;
1040
+ }>,
1041
+ initialBalance: number,
1042
+ fromDate: Date,
1043
+ toDate: Date,
1044
+ scenarioConfig: FinancialScenarioConfig,
1045
+ ) {
1046
+ const scenarioMultiplier = this.resolveScenarioMultiplier(scenarioConfig);
1047
+
1048
+ const entradasPrevistas = this.buildExpectedCashFlowEntries(
1049
+ receivables,
1050
+ fromDate,
1051
+ toDate,
1052
+ 'inflow',
1053
+ ).map((item) => ({
1054
+ ...item,
1055
+ valor: Number((item.valor * scenarioMultiplier.inflow).toFixed(2)),
1056
+ }));
1057
+
1058
+ const saidasPrevistas = this.buildExpectedCashFlowEntries(
1059
+ payables,
1060
+ fromDate,
1061
+ toDate,
1062
+ 'outflow',
1063
+ ).map((item) => ({
1064
+ ...item,
1065
+ valor: Number((item.valor * scenarioMultiplier.outflow).toFixed(2)),
1066
+ }));
1067
+
1068
+ const expectedByDate = new Map<
1069
+ string,
1070
+ { entradas: number; saidas: number; realizado: number }
1071
+ >();
1072
+
1073
+ for (const entry of entradasPrevistas) {
1074
+ const dateKey = entry.vencimento.slice(0, 10);
1075
+ const current = expectedByDate.get(dateKey) || {
1076
+ entradas: 0,
1077
+ saidas: 0,
1078
+ realizado: 0,
1079
+ };
1080
+ current.entradas = Number((current.entradas + Number(entry.valor || 0)).toFixed(2));
1081
+ expectedByDate.set(dateKey, current);
1082
+ }
1083
+
1084
+ for (const outflow of saidasPrevistas) {
1085
+ const dateKey = outflow.vencimento.slice(0, 10);
1086
+ const current = expectedByDate.get(dateKey) || {
1087
+ entradas: 0,
1088
+ saidas: 0,
1089
+ realizado: 0,
1090
+ };
1091
+ current.saidas = Number((current.saidas + Number(outflow.valor || 0)).toFixed(2));
1092
+ expectedByDate.set(dateKey, current);
1093
+ }
1094
+
1095
+ for (const settlement of confirmedSettlements) {
1096
+ const dateKey = settlement.settled_at.toISOString().slice(0, 10);
1097
+ const current = expectedByDate.get(dateKey) || {
1098
+ entradas: 0,
1099
+ saidas: 0,
1100
+ realizado: 0,
1101
+ };
1102
+
1103
+ const amount = this.fromCents(settlement.amount_cents);
1104
+ if (settlement.settlement_type === 'payable') {
1105
+ current.realizado = Number((current.realizado - amount).toFixed(2));
1106
+ } else if (settlement.settlement_type === 'receivable') {
1107
+ current.realizado = Number((current.realizado + amount).toFixed(2));
1108
+ }
1109
+
1110
+ expectedByDate.set(dateKey, current);
1111
+ }
1112
+
1113
+ const fluxoCaixaPrevisto: Array<{
1114
+ data: string;
1115
+ saldoPrevisto: number;
1116
+ saldoRealizado: number;
1117
+ }> = [];
1118
+
1119
+ let runningProjectedBalance = Number(initialBalance || 0);
1120
+ let runningActualBalance = Number(initialBalance || 0);
1121
+
1122
+ for (
1123
+ let cursor = this.startOfDay(fromDate);
1124
+ cursor <= toDate;
1125
+ cursor = this.addDays(cursor, 1)
1126
+ ) {
1127
+ const key = cursor.toISOString().slice(0, 10);
1128
+ const dayData = expectedByDate.get(key) || {
1129
+ entradas: 0,
1130
+ saidas: 0,
1131
+ realizado: 0,
1132
+ };
1133
+
1134
+ runningProjectedBalance = Number(
1135
+ (runningProjectedBalance + dayData.entradas - dayData.saidas).toFixed(2),
1136
+ );
1137
+ runningActualBalance = Number(
1138
+ (runningActualBalance + dayData.realizado).toFixed(2),
1139
+ );
1140
+
1141
+ fluxoCaixaPrevisto.push({
1142
+ data: cursor.toISOString(),
1143
+ saldoPrevisto: runningProjectedBalance,
1144
+ saldoRealizado: runningActualBalance,
1145
+ });
1146
+ }
1147
+
1148
+ return {
1149
+ fluxoCaixaPrevisto,
1150
+ entradasPrevistas,
1151
+ saidasPrevistas,
1152
+ };
1153
+ }
1154
+
1155
+ private resolveScenarioMultiplier(scenarioConfig: FinancialScenarioConfig) {
1156
+ const growthRate = scenarioConfig.crescimentoReceita / 100;
1157
+ const defaultRate = scenarioConfig.taxaInadimplencia / 100;
1158
+ const delayImpact = Math.max(0.5, 1 - scenarioConfig.atrasoMedio / 100);
1159
+
1160
+ const inflow = this.clampNumber(
1161
+ (1 + growthRate) * (1 - defaultRate) * delayImpact,
1162
+ 0.1,
1163
+ 3,
1164
+ );
1165
+
1166
+ const outflow = this.clampNumber(
1167
+ 1 + Math.max(0, -growthRate) * 0.4 + defaultRate * 0.2,
1168
+ 0.1,
1169
+ 3,
1170
+ );
1171
+
1172
+ return {
1173
+ inflow,
1174
+ outflow,
1175
+ };
1176
+ }
1177
+
1178
+ private clampNumber(value: number, min: number, max: number) {
1179
+ if (!Number.isFinite(value)) {
1180
+ return min;
1181
+ }
1182
+
1183
+ return Math.min(max, Math.max(min, value));
1184
+ }
1185
+
1186
+ private async loadFinancialScenarioSettings(): Promise<FinancialScenarioSettings> {
1187
+ const defaultSettings = this.getDefaultFinancialScenarioSettings();
1188
+ const values = await this.settingService.getSettingValues([
1189
+ 'finance-default-scenario',
1190
+ 'finance-default-horizon-days',
1191
+ 'finance-scenario-base-average-delay-days',
1192
+ 'finance-scenario-base-default-rate-percent',
1193
+ 'finance-scenario-base-revenue-growth-percent',
1194
+ 'finance-scenario-pessimistic-average-delay-days',
1195
+ 'finance-scenario-pessimistic-default-rate-percent',
1196
+ 'finance-scenario-pessimistic-revenue-growth-percent',
1197
+ 'finance-scenario-optimistic-average-delay-days',
1198
+ 'finance-scenario-optimistic-default-rate-percent',
1199
+ 'finance-scenario-optimistic-revenue-growth-percent',
1200
+ ]);
1201
+
1202
+ const base: FinancialScenarioConfig = {
1203
+ ...defaultSettings.map.base,
1204
+ atrasoMedio: this.numberSetting(
1205
+ values['finance-scenario-base-average-delay-days'],
1206
+ defaultSettings.map.base.atrasoMedio,
1207
+ ),
1208
+ taxaInadimplencia: this.numberSetting(
1209
+ values['finance-scenario-base-default-rate-percent'],
1210
+ defaultSettings.map.base.taxaInadimplencia,
1211
+ ),
1212
+ crescimentoReceita: this.numberSetting(
1213
+ values['finance-scenario-base-revenue-growth-percent'],
1214
+ defaultSettings.map.base.crescimentoReceita,
1215
+ ),
1216
+ padrao: false,
1217
+ };
1218
+
1219
+ const pessimista: FinancialScenarioConfig = {
1220
+ ...defaultSettings.map.pessimista,
1221
+ atrasoMedio: this.numberSetting(
1222
+ values['finance-scenario-pessimistic-average-delay-days'],
1223
+ defaultSettings.map.pessimista.atrasoMedio,
1224
+ ),
1225
+ taxaInadimplencia: this.numberSetting(
1226
+ values['finance-scenario-pessimistic-default-rate-percent'],
1227
+ defaultSettings.map.pessimista.taxaInadimplencia,
1228
+ ),
1229
+ crescimentoReceita: this.numberSetting(
1230
+ values['finance-scenario-pessimistic-revenue-growth-percent'],
1231
+ defaultSettings.map.pessimista.crescimentoReceita,
1232
+ ),
1233
+ padrao: false,
1234
+ };
1235
+
1236
+ const otimista: FinancialScenarioConfig = {
1237
+ ...defaultSettings.map.otimista,
1238
+ atrasoMedio: this.numberSetting(
1239
+ values['finance-scenario-optimistic-average-delay-days'],
1240
+ defaultSettings.map.otimista.atrasoMedio,
1241
+ ),
1242
+ taxaInadimplencia: this.numberSetting(
1243
+ values['finance-scenario-optimistic-default-rate-percent'],
1244
+ defaultSettings.map.otimista.taxaInadimplencia,
1245
+ ),
1246
+ crescimentoReceita: this.numberSetting(
1247
+ values['finance-scenario-optimistic-revenue-growth-percent'],
1248
+ defaultSettings.map.otimista.crescimentoReceita,
1249
+ ),
1250
+ padrao: false,
1251
+ };
1252
+
1253
+ const defaultScenario = this.resolveForecastScenario(
1254
+ String(values['finance-default-scenario'] || ''),
1255
+ defaultSettings.defaultScenario,
1256
+ );
1257
+
1258
+ const cenarios = [base, pessimista, otimista].map((item) => ({
1259
+ ...item,
1260
+ padrao: item.id === defaultScenario,
1261
+ }));
1262
+
1263
+ const map = {
1264
+ base: cenarios.find((item) => item.id === 'base') || defaultSettings.map.base,
1265
+ pessimista:
1266
+ cenarios.find((item) => item.id === 'pessimista') ||
1267
+ defaultSettings.map.pessimista,
1268
+ otimista:
1269
+ cenarios.find((item) => item.id === 'otimista') ||
1270
+ defaultSettings.map.otimista,
1271
+ } as Record<ForecastScenario, FinancialScenarioConfig>;
1272
+
1273
+ return {
1274
+ defaultScenario,
1275
+ defaultHorizonDays: this.resolveHorizonDays(
1276
+ values['finance-default-horizon-days'],
1277
+ defaultSettings.defaultHorizonDays,
1278
+ ),
1279
+ cenarios,
1280
+ map,
1281
+ };
1282
+ }
1283
+
1284
+ private getDefaultFinancialScenarioSettings(): FinancialScenarioSettings {
1285
+ const base: FinancialScenarioConfig = {
1286
+ id: 'base',
1287
+ nome: 'Base',
1288
+ descricao: 'Cenário de referência para projeções',
1289
+ atrasoMedio: 5,
1290
+ taxaInadimplencia: 3,
1291
+ crescimentoReceita: 5,
1292
+ padrao: true,
1293
+ };
1294
+
1295
+ const pessimista: FinancialScenarioConfig = {
1296
+ id: 'pessimista',
1297
+ nome: 'Pessimista',
1298
+ descricao: 'Premissas conservadoras com maior risco',
1299
+ atrasoMedio: 12,
1300
+ taxaInadimplencia: 6,
1301
+ crescimentoReceita: -5,
1302
+ padrao: false,
1303
+ };
1304
+
1305
+ const otimista: FinancialScenarioConfig = {
1306
+ id: 'otimista',
1307
+ nome: 'Otimista',
1308
+ descricao: 'Premissas favoráveis de crescimento',
1309
+ atrasoMedio: 2,
1310
+ taxaInadimplencia: 1.5,
1311
+ crescimentoReceita: 10,
1312
+ padrao: false,
1313
+ };
1314
+
1315
+ const cenarios = [base, pessimista, otimista];
1316
+
1317
+ return {
1318
+ defaultScenario: 'base',
1319
+ defaultHorizonDays: 90,
1320
+ cenarios,
1321
+ map: {
1322
+ base,
1323
+ pessimista,
1324
+ otimista,
1325
+ },
1326
+ };
1327
+ }
1328
+
1329
+ private numberSetting(value: any, fallback: number) {
1330
+ const parsed = Number(value);
1331
+ return Number.isFinite(parsed) ? parsed : fallback;
1332
+ }
1333
+
1334
+ private buildExpectedCashFlowEntries(
1335
+ titles: any[],
1336
+ fromDate: Date,
1337
+ toDate: Date,
1338
+ direction: 'inflow' | 'outflow',
1339
+ ) {
1340
+ return (titles || [])
1341
+ .flatMap((title) =>
1342
+ (title?.parcelas || [])
1343
+ .filter((installment) => {
1344
+ const dueDate = this.startOfDay(new Date(installment.vencimento));
1345
+ if (Number.isNaN(dueDate.getTime())) return false;
1346
+
1347
+ const openAmount = Number(installment.valorAberto || 0);
1348
+ const status = String(installment.status || '').toLowerCase();
1349
+ const isOpen =
1350
+ status === 'aberto' || status === 'vencido' || status === 'parcial';
1351
+
1352
+ return (
1353
+ dueDate >= fromDate &&
1354
+ dueDate <= toDate &&
1355
+ isOpen &&
1356
+ openAmount > 0
1357
+ );
1358
+ })
1359
+ .map((installment) => ({
1360
+ categoria:
1361
+ title?.descricao ||
1362
+ (direction === 'inflow' ? title?.cliente : title?.fornecedor) ||
1363
+ 'Sem categoria',
1364
+ vencimento: installment.vencimento,
1365
+ valor: Number(installment.valorAberto || installment.valor || 0),
1366
+ })),
1367
+ )
1368
+ .sort(
1369
+ (a, b) =>
1370
+ new Date(a.vencimento).getTime() - new Date(b.vencimento).getTime(),
1371
+ );
1372
+ }
1373
+
1374
+ private buildReceivablesCalendarData(receivables: any[]) {
1375
+ const recebiveis = receivables
1376
+ .flatMap((title: any) =>
1377
+ (title.parcelas || []).map((installment: any) => {
1378
+ const bruto = Number(installment.valor || 0);
1379
+ const canal = this.mapReceivableChannelLabel(
1380
+ installment.metodoPagamento || title.canal,
1381
+ );
1382
+ const taxaPercentual =
1383
+ canal === 'Cartão'
1384
+ ? 0.03
1385
+ : canal === 'Boleto'
1386
+ ? 0.015
1387
+ : canal === 'Pix'
1388
+ ? 0.005
1389
+ : 0.01;
1390
+ const taxas = Number((bruto * taxaPercentual).toFixed(2));
1391
+ const liquido = Number((bruto - taxas).toFixed(2));
1392
+
1393
+ return {
1394
+ id: `${title.id}-${installment.id}`,
1395
+ canal,
1396
+ adquirente: title.cliente || '-',
1397
+ dataPrevista: installment.vencimento,
1398
+ bruto,
1399
+ taxas,
1400
+ liquido,
1401
+ status: this.mapReceivableCalendarStatus(installment.status),
1402
+ };
1403
+ }),
1404
+ )
1405
+ .sort(
1406
+ (a, b) =>
1407
+ new Date(a.dataPrevista).getTime() - new Date(b.dataPrevista).getTime(),
1408
+ );
1409
+
1410
+ const adquirenteMap = new Map<
1411
+ string,
1412
+ { nome: string; transacoes: number; total: number }
1413
+ >();
1414
+
1415
+ for (const recebivel of recebiveis) {
1416
+ const current = adquirenteMap.get(recebivel.adquirente) || {
1417
+ nome: recebivel.adquirente,
1418
+ transacoes: 0,
1419
+ total: 0,
1420
+ };
1421
+
1422
+ current.transacoes += 1;
1423
+ current.total = Number((current.total + recebivel.liquido).toFixed(2));
1424
+ adquirenteMap.set(recebivel.adquirente, current);
1425
+ }
1426
+
1427
+ const adquirentes = Array.from(adquirenteMap.values()).sort(
1428
+ (a, b) => b.total - a.total,
1429
+ );
1430
+
1431
+ return {
1432
+ recebiveis,
1433
+ adquirentes,
1434
+ };
1435
+ }
1436
+
1437
+ private mapReceivableCalendarStatus(status?: string | null) {
1438
+ const normalized = (status || '').toLowerCase();
1439
+
1440
+ if (normalized === 'liquidado') {
1441
+ return 'liquidado';
1442
+ }
1443
+
1444
+ if (['aberto', 'aprovado', 'parcial'].includes(normalized)) {
1445
+ return 'confirmado';
1446
+ }
1447
+
1448
+ return 'pendente';
1449
+ }
1450
+
1451
+ private mapReceivableChannelLabel(channel?: string | null) {
1452
+ const normalized = (channel || '').toLowerCase();
1453
+ const channelMap: Record<string, string> = {
1454
+ cartao: 'Cartão',
1455
+ cartão: 'Cartão',
1456
+ pix: 'Pix',
1457
+ boleto: 'Boleto',
1458
+ transferencia: 'Transferência',
1459
+ transferência: 'Transferência',
1460
+ ted: 'Transferência',
1461
+ doc: 'Transferência',
1462
+ dinheiro: 'Dinheiro',
1463
+ cheque: 'Cheque',
1464
+ };
1465
+
1466
+ return channelMap[normalized] || 'Transferência';
1467
+ }
1468
+
836
1469
  async getAccountsReceivableCollectionsDefault() {
837
1470
  const today = this.startOfDay(new Date());
838
1471
 
@@ -1036,6 +1669,12 @@ export class FinanceService {
1036
1669
  const remainder = totalAmountCents % data.installments;
1037
1670
 
1038
1671
  const created = await this.prisma.$transaction(async (tx) => {
1672
+ await this.assertDateNotInClosedPeriod(
1673
+ tx,
1674
+ firstDueDate,
1675
+ 'register collection agreement',
1676
+ );
1677
+
1039
1678
  const title = await tx.financial_title.create({
1040
1679
  data: {
1041
1680
  person_id: person.id,
@@ -1058,6 +1697,12 @@ export class FinanceService {
1058
1697
  const amountCents =
1059
1698
  baseInstallmentCents + (index === data.installments - 1 ? remainder : 0);
1060
1699
 
1700
+ await this.assertDateNotInClosedPeriod(
1701
+ tx,
1702
+ dueDate,
1703
+ 'register collection agreement installment',
1704
+ );
1705
+
1061
1706
  await tx.financial_installment.create({
1062
1707
  data: {
1063
1708
  title_id: title.id,
@@ -1798,6 +2443,8 @@ export class FinanceService {
1798
2443
  )}`;
1799
2444
 
1800
2445
  await this.prisma.$transaction(async (tx) => {
2446
+ await this.assertDateNotInClosedPeriod(tx, postedDate, 'create transfer');
2447
+
1801
2448
  const sourceStatement = await tx.bank_statement.create({
1802
2449
  data: {
1803
2450
  bank_account_id: sourceAccountId,
@@ -2257,6 +2904,12 @@ export class FinanceService {
2257
2904
  const reference = `adjustment:${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
2258
2905
 
2259
2906
  const created = await this.prisma.$transaction(async (tx) => {
2907
+ await this.assertDateNotInClosedPeriod(
2908
+ tx,
2909
+ postedAt,
2910
+ 'create bank statement adjustment',
2911
+ );
2912
+
2260
2913
  const statement = await tx.bank_statement.create({
2261
2914
  data: {
2262
2915
  bank_account_id: bankAccountId,
@@ -2393,35 +3046,53 @@ export class FinanceService {
2393
3046
  24,
2394
3047
  );
2395
3048
 
2396
- const statement = await this.prisma.bank_statement.create({
2397
- data: {
2398
- bank_account_id: bankAccountId,
2399
- source_type: sourceType,
2400
- idempotency_key: `import-${uploadedFile.id}-${statementFingerprint}`,
2401
- period_start: normalizedEntries
2402
- .map((entry) => entry.postedDate)
2403
- .sort((a, b) => a.getTime() - b.getTime())[0],
2404
- period_end: normalizedEntries
2405
- .map((entry) => entry.postedDate)
2406
- .sort((a, b) => b.getTime() - a.getTime())[0],
2407
- imported_at: new Date(),
2408
- imported_by_user_id: userId || null,
2409
- },
2410
- select: { id: true },
2411
- });
3049
+ const statement = await this.prisma.$transaction(async (tx) => {
3050
+ const uniquePostedDates = [
3051
+ ...new Set(
3052
+ normalizedEntries.map((entry) => entry.postedDate.toISOString().slice(0, 10)),
3053
+ ),
3054
+ ];
2412
3055
 
2413
- await this.prisma.bank_statement_line.createMany({
2414
- data: normalizedEntries.map((entry) => ({
2415
- bank_statement_id: statement.id,
2416
- bank_account_id: bankAccountId,
2417
- external_id: entry.externalId,
2418
- posted_date: entry.postedDate,
2419
- amount_cents: entry.amountCents,
2420
- description: entry.description,
2421
- status: 'imported',
2422
- dedupe_key: entry.dedupeKey,
2423
- })),
2424
- skipDuplicates: true,
3056
+ for (const postedDateText of uniquePostedDates) {
3057
+ await this.assertDateNotInClosedPeriod(
3058
+ tx,
3059
+ new Date(`${postedDateText}T00:00:00.000Z`),
3060
+ 'import bank statements',
3061
+ );
3062
+ }
3063
+
3064
+ const createdStatement = await tx.bank_statement.create({
3065
+ data: {
3066
+ bank_account_id: bankAccountId,
3067
+ source_type: sourceType,
3068
+ idempotency_key: `import-${uploadedFile.id}-${statementFingerprint}`,
3069
+ period_start: normalizedEntries
3070
+ .map((entry) => entry.postedDate)
3071
+ .sort((a, b) => a.getTime() - b.getTime())[0],
3072
+ period_end: normalizedEntries
3073
+ .map((entry) => entry.postedDate)
3074
+ .sort((a, b) => b.getTime() - a.getTime())[0],
3075
+ imported_at: new Date(),
3076
+ imported_by_user_id: userId || null,
3077
+ },
3078
+ select: { id: true },
3079
+ });
3080
+
3081
+ await tx.bank_statement_line.createMany({
3082
+ data: normalizedEntries.map((entry) => ({
3083
+ bank_statement_id: createdStatement.id,
3084
+ bank_account_id: bankAccountId,
3085
+ external_id: entry.externalId,
3086
+ posted_date: entry.postedDate,
3087
+ amount_cents: entry.amountCents,
3088
+ description: entry.description,
3089
+ status: 'imported',
3090
+ dedupe_key: entry.dedupeKey,
3091
+ })),
3092
+ skipDuplicates: true,
3093
+ });
3094
+
3095
+ return createdStatement;
2425
3096
  });
2426
3097
 
2427
3098
  return {
@@ -2755,40 +3426,51 @@ export class FinanceService {
2755
3426
  const code = this.generateBankAccountCode(data.bank, data.account);
2756
3427
  const name = data.description?.trim() || data.bank;
2757
3428
 
2758
- const createdAccount = await this.prisma.bank_account.create({
2759
- data: {
2760
- code,
2761
- name,
2762
- bank_name: data.bank,
2763
- agency: data.branch || null,
2764
- account_number: data.account || null,
2765
- account_type: accountType,
2766
- status: 'active',
2767
- },
2768
- });
2769
-
2770
- if (data.initial_balance && data.initial_balance > 0) {
2771
- const statement = await this.prisma.bank_statement.create({
3429
+ const createdAccount = await this.prisma.$transaction(async (tx) => {
3430
+ const account = await tx.bank_account.create({
2772
3431
  data: {
2773
- bank_account_id: createdAccount.id,
2774
- source_type: 'csv',
2775
- imported_at: new Date(),
2776
- imported_by_user_id: userId,
3432
+ code,
3433
+ name,
3434
+ bank_name: data.bank,
3435
+ agency: data.branch || null,
3436
+ account_number: data.account || null,
3437
+ account_type: accountType,
3438
+ status: 'active',
2777
3439
  },
2778
3440
  });
2779
3441
 
2780
- await this.prisma.bank_statement_line.create({
2781
- data: {
2782
- bank_statement_id: statement.id,
2783
- bank_account_id: createdAccount.id,
2784
- posted_date: new Date(),
2785
- amount_cents: this.toCents(data.initial_balance),
2786
- description: 'Saldo inicial',
2787
- status: 'reconciled',
2788
- dedupe_key: `initial-balance-${createdAccount.id}-${Date.now()}`,
2789
- },
2790
- });
2791
- }
3442
+ if (data.initial_balance && data.initial_balance > 0) {
3443
+ const postedDate = new Date();
3444
+ await this.assertDateNotInClosedPeriod(
3445
+ tx,
3446
+ postedDate,
3447
+ 'create bank account initial balance',
3448
+ );
3449
+
3450
+ const statement = await tx.bank_statement.create({
3451
+ data: {
3452
+ bank_account_id: account.id,
3453
+ source_type: 'csv',
3454
+ imported_at: postedDate,
3455
+ imported_by_user_id: userId,
3456
+ },
3457
+ });
3458
+
3459
+ await tx.bank_statement_line.create({
3460
+ data: {
3461
+ bank_statement_id: statement.id,
3462
+ bank_account_id: account.id,
3463
+ posted_date: postedDate,
3464
+ amount_cents: this.toCents(data.initial_balance),
3465
+ description: 'Saldo inicial',
3466
+ status: 'reconciled',
3467
+ dedupe_key: `initial-balance-${account.id}-${Date.now()}`,
3468
+ },
3469
+ });
3470
+ }
3471
+
3472
+ return account;
3473
+ });
2792
3474
 
2793
3475
  const account = await this.prisma.bank_account.findUnique({
2794
3476
  where: { id: createdAccount.id },
@@ -4707,6 +5389,26 @@ export class FinanceService {
4707
5389
  }));
4708
5390
  }
4709
5391
 
5392
+ private async loadConfirmedSettlements(fromDate: Date, toDate: Date) {
5393
+ return this.prisma.settlement.findMany({
5394
+ where: {
5395
+ status: 'confirmed',
5396
+ settled_at: {
5397
+ gte: fromDate,
5398
+ lte: toDate,
5399
+ },
5400
+ },
5401
+ select: {
5402
+ settled_at: true,
5403
+ amount_cents: true,
5404
+ settlement_type: true,
5405
+ },
5406
+ orderBy: {
5407
+ settled_at: 'asc',
5408
+ },
5409
+ });
5410
+ }
5411
+
4710
5412
  private async loadOpenPeriod() {
4711
5413
  const openPeriod = await this.prisma.period_close.findFirst({
4712
5414
  where: {