@hed-hog/finance 0.0.238 → 0.0.240

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 (53) hide show
  1. package/README.md +1 -22
  2. package/dist/dto/reject-title.dto.d.ts +4 -0
  3. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  4. package/dist/dto/reject-title.dto.js +22 -0
  5. package/dist/dto/reject-title.dto.js.map +1 -0
  6. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  7. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  8. package/dist/dto/reverse-settlement.dto.js +22 -0
  9. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  10. package/dist/dto/settle-installment.dto.d.ts +12 -0
  11. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  12. package/dist/dto/settle-installment.dto.js +71 -0
  13. package/dist/dto/settle-installment.dto.js.map +1 -0
  14. package/dist/finance-data.controller.d.ts +13 -5
  15. package/dist/finance-data.controller.d.ts.map +1 -1
  16. package/dist/finance-installments.controller.d.ts +380 -12
  17. package/dist/finance-installments.controller.d.ts.map +1 -1
  18. package/dist/finance-installments.controller.js +144 -0
  19. package/dist/finance-installments.controller.js.map +1 -1
  20. package/dist/finance-statements.controller.d.ts +8 -0
  21. package/dist/finance-statements.controller.d.ts.map +1 -1
  22. package/dist/finance-statements.controller.js +40 -0
  23. package/dist/finance-statements.controller.js.map +1 -1
  24. package/dist/finance.module.d.ts.map +1 -1
  25. package/dist/finance.module.js +1 -0
  26. package/dist/finance.module.js.map +1 -1
  27. package/dist/finance.service.d.ts +435 -19
  28. package/dist/finance.service.d.ts.map +1 -1
  29. package/dist/finance.service.js +1286 -80
  30. package/dist/finance.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +117 -0
  32. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  33. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +434 -7
  34. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1172 -25
  35. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
  36. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +430 -14
  37. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +212 -60
  38. package/hedhog/frontend/messages/en.json +1 -0
  39. package/hedhog/frontend/messages/pt.json +1 -0
  40. package/hedhog/query/0_constraints.sql +2 -0
  41. package/hedhog/query/constraints.sql +86 -0
  42. package/hedhog/table/bank_account.yaml +0 -8
  43. package/hedhog/table/financial_title.yaml +1 -9
  44. package/hedhog/table/settlement.yaml +0 -8
  45. package/package.json +6 -6
  46. package/src/dto/reject-title.dto.ts +7 -0
  47. package/src/dto/reverse-settlement.dto.ts +7 -0
  48. package/src/dto/settle-installment.dto.ts +55 -0
  49. package/src/finance-installments.controller.ts +172 -10
  50. package/src/finance-statements.controller.ts +61 -2
  51. package/src/finance.module.ts +2 -1
  52. package/src/finance.service.ts +1887 -106
  53. package/hedhog/table/branch.yaml +0 -18
@@ -8,6 +8,9 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
11
14
  var FinanceService_1;
12
15
  Object.defineProperty(exports, "__esModule", { value: true });
13
16
  exports.FinanceService = void 0;
@@ -16,11 +19,14 @@ const api_pagination_1 = require("@hed-hog/api-pagination");
16
19
  const api_prisma_1 = require("@hed-hog/api-prisma");
17
20
  const core_1 = require("@hed-hog/core");
18
21
  const common_1 = require("@nestjs/common");
22
+ const node_crypto_1 = require("node:crypto");
23
+ const promises_1 = require("node:fs/promises");
19
24
  let FinanceService = FinanceService_1 = class FinanceService {
20
- constructor(prisma, paginationService, ai) {
25
+ constructor(prisma, paginationService, ai, fileService) {
21
26
  this.prisma = prisma;
22
27
  this.paginationService = paginationService;
23
28
  this.ai = ai;
29
+ this.fileService = fileService;
24
30
  this.logger = new common_1.Logger(FinanceService_1.name);
25
31
  }
26
32
  async getAgentExtractInfoFromFile(file, fileId, titleType = 'payable') {
@@ -540,6 +546,17 @@ let FinanceService = FinanceService_1 = class FinanceService {
540
546
  if (auditLogsResult.status === 'rejected') {
541
547
  this.logger.error('Failed to load finance audit logs', auditLogsResult.reason);
542
548
  }
549
+ const aprovacoesPendentes = payables
550
+ .filter((title) => title.status === 'rascunho')
551
+ .map((title) => ({
552
+ id: String(title.id),
553
+ tituloId: String(title.id),
554
+ solicitante: '-',
555
+ valor: Number(title.valorTotal || 0),
556
+ politica: 'Aprovação financeira',
557
+ urgencia: 'media',
558
+ dataSolicitacao: title.criadoEm,
559
+ }));
543
560
  return {
544
561
  kpis: {
545
562
  saldoCaixa: 0,
@@ -557,7 +574,7 @@ let FinanceService = FinanceService_1 = class FinanceService {
557
574
  pessoas: people,
558
575
  categorias: categories,
559
576
  centrosCusto: costCenters,
560
- aprovacoesPendentes: [],
577
+ aprovacoesPendentes,
561
578
  agingInadimplencia: [],
562
579
  cenarios: [],
563
580
  transferencias: [],
@@ -587,9 +604,42 @@ let FinanceService = FinanceService_1 = class FinanceService {
587
604
  async createAccountsPayableTitle(data, locale, userId) {
588
605
  return this.createTitle(data, 'payable', locale, userId);
589
606
  }
607
+ async updateAccountsPayableTitle(id, data, locale, userId) {
608
+ return this.updateDraftTitle(id, data, 'payable', locale, userId);
609
+ }
610
+ async approveAccountsPayableTitle(id, locale, userId) {
611
+ return this.approveTitle(id, 'payable', locale, userId);
612
+ }
613
+ async rejectAccountsPayableTitle(id, data, locale, userId) {
614
+ return this.rejectTitle(id, data, 'payable', locale, userId);
615
+ }
616
+ async cancelAccountsPayableTitle(id, data, locale, userId) {
617
+ return this.cancelTitle(id, data, 'payable', locale, userId);
618
+ }
619
+ async settleAccountsPayableInstallment(id, data, locale, userId) {
620
+ return this.settleTitleInstallment(id, data, 'payable', locale, userId);
621
+ }
622
+ async reverseAccountsPayableSettlement(id, settlementId, data, locale, userId) {
623
+ return this.reverseTitleSettlement(id, settlementId, data, 'payable', locale, userId);
624
+ }
590
625
  async createAccountsReceivableTitle(data, locale, userId) {
591
626
  return this.createTitle(data, 'receivable', locale, userId);
592
627
  }
628
+ async updateAccountsReceivableTitle(id, data, locale, userId) {
629
+ return this.updateDraftTitle(id, data, 'receivable', locale, userId);
630
+ }
631
+ async approveAccountsReceivableTitle(id, locale, userId) {
632
+ return this.approveTitle(id, 'receivable', locale, userId);
633
+ }
634
+ async cancelAccountsReceivableTitle(id, data, locale, userId) {
635
+ return this.cancelTitle(id, data, 'receivable', locale, userId);
636
+ }
637
+ async settleAccountsReceivableInstallment(id, data, locale, userId) {
638
+ return this.settleTitleInstallment(id, data, 'receivable', locale, userId);
639
+ }
640
+ async reverseAccountsReceivableSettlement(id, settlementId, data, locale, userId) {
641
+ return this.reverseTitleSettlement(id, settlementId, data, 'receivable', locale, userId);
642
+ }
593
643
  async createTag(data) {
594
644
  const slug = this.normalizeTagSlug(data.name);
595
645
  if (!slug) {
@@ -825,6 +875,368 @@ let FinanceService = FinanceService_1 = class FinanceService {
825
875
  statusConciliacao: this.mapStatementStatusToPt(statement.status),
826
876
  }));
827
877
  }
878
+ async exportBankStatementsCsv(bankAccountId) {
879
+ const statements = await this.listBankStatements(bankAccountId);
880
+ const headers = [
881
+ 'id',
882
+ 'data',
883
+ 'descricao',
884
+ 'valor',
885
+ 'tipo',
886
+ 'status_conciliacao',
887
+ 'conta_bancaria_id',
888
+ ];
889
+ const lines = statements.map((statement) => {
890
+ const columns = [
891
+ statement.id,
892
+ statement.data,
893
+ statement.descricao,
894
+ String(statement.valor),
895
+ statement.tipo,
896
+ statement.statusConciliacao,
897
+ statement.contaBancariaId,
898
+ ];
899
+ return columns.map((column) => this.escapeCsvCell(column)).join(';');
900
+ });
901
+ const csv = ['\uFEFF' + headers.join(';'), ...lines].join('\n');
902
+ const fileName = `extrato-bancario-${bankAccountId}-${new Date()
903
+ .toISOString()
904
+ .slice(0, 10)}.csv`;
905
+ return {
906
+ csv,
907
+ fileName,
908
+ };
909
+ }
910
+ async importBankStatements(bankAccountId, file, locale, userId) {
911
+ if (!file) {
912
+ throw new common_1.BadRequestException('File is required');
913
+ }
914
+ const bankAccount = await this.prisma.bank_account.findUnique({
915
+ where: { id: bankAccountId },
916
+ select: { id: true },
917
+ });
918
+ if (!bankAccount) {
919
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Bank account not found').replace('{{item}}', 'Bank account'));
920
+ }
921
+ const sourceType = this.detectStatementSourceType(file);
922
+ const uploadedFile = await this.fileService.upload('finance/statements', file);
923
+ const rawContent = await this.getUploadedFileText(file);
924
+ const parsedEntries = sourceType === 'ofx'
925
+ ? this.parseOfxStatements(rawContent)
926
+ : this.parseCsvStatements(rawContent);
927
+ if (parsedEntries.length === 0) {
928
+ throw new common_1.BadRequestException('No valid statement rows were found in the uploaded file');
929
+ }
930
+ const normalizedEntries = parsedEntries
931
+ .filter((entry) => entry.postedDate && Number.isFinite(entry.amount))
932
+ .map((entry, index) => {
933
+ const postedDate = new Date(entry.postedDate);
934
+ if (Number.isNaN(postedDate.getTime())) {
935
+ return null;
936
+ }
937
+ const amountCents = this.toCents(entry.amount);
938
+ if (!Number.isFinite(amountCents) || amountCents === 0) {
939
+ return null;
940
+ }
941
+ const dedupeBase = [
942
+ bankAccountId,
943
+ postedDate.toISOString().slice(0, 10),
944
+ amountCents,
945
+ entry.externalId || '',
946
+ entry.description || '',
947
+ index,
948
+ ].join('|');
949
+ return {
950
+ postedDate,
951
+ amountCents,
952
+ externalId: entry.externalId || null,
953
+ description: (entry.description || 'Lançamento importado').slice(0, 1000),
954
+ dedupeKey: this.createShortHash(`statement-line:${dedupeBase}`, 64),
955
+ };
956
+ })
957
+ .filter(Boolean);
958
+ if (normalizedEntries.length === 0) {
959
+ throw new common_1.BadRequestException('No valid financial entries were parsed');
960
+ }
961
+ const statementFingerprint = this.createShortHash([
962
+ bankAccountId,
963
+ sourceType,
964
+ uploadedFile.id,
965
+ file.originalname,
966
+ file.size,
967
+ normalizedEntries.length,
968
+ ].join('|'), 24);
969
+ const statement = await this.prisma.bank_statement.create({
970
+ data: {
971
+ bank_account_id: bankAccountId,
972
+ source_type: sourceType,
973
+ idempotency_key: `import-${uploadedFile.id}-${statementFingerprint}`,
974
+ period_start: normalizedEntries
975
+ .map((entry) => entry.postedDate)
976
+ .sort((a, b) => a.getTime() - b.getTime())[0],
977
+ period_end: normalizedEntries
978
+ .map((entry) => entry.postedDate)
979
+ .sort((a, b) => b.getTime() - a.getTime())[0],
980
+ imported_at: new Date(),
981
+ imported_by_user_id: userId || null,
982
+ },
983
+ select: { id: true },
984
+ });
985
+ await this.prisma.bank_statement_line.createMany({
986
+ data: normalizedEntries.map((entry) => ({
987
+ bank_statement_id: statement.id,
988
+ bank_account_id: bankAccountId,
989
+ external_id: entry.externalId,
990
+ posted_date: entry.postedDate,
991
+ amount_cents: entry.amountCents,
992
+ description: entry.description,
993
+ status: 'imported',
994
+ dedupe_key: entry.dedupeKey,
995
+ })),
996
+ skipDuplicates: true,
997
+ });
998
+ return {
999
+ statementId: String(statement.id),
1000
+ fileId: String(uploadedFile.id),
1001
+ importedRows: normalizedEntries.length,
1002
+ sourceType,
1003
+ };
1004
+ }
1005
+ detectStatementSourceType(file) {
1006
+ const filename = (file.originalname || '').toLowerCase();
1007
+ const mimetype = (file.mimetype || '').toLowerCase();
1008
+ if (filename.endsWith('.ofx') ||
1009
+ mimetype.includes('ofx') ||
1010
+ mimetype.includes('x-ofx')) {
1011
+ return 'ofx';
1012
+ }
1013
+ if (filename.endsWith('.csv') ||
1014
+ mimetype.includes('csv') ||
1015
+ mimetype.includes('comma-separated-values')) {
1016
+ return 'csv';
1017
+ }
1018
+ throw new common_1.BadRequestException('Only CSV and OFX files are supported');
1019
+ }
1020
+ async getUploadedFileText(file) {
1021
+ let buffer = file.buffer;
1022
+ if (!buffer && file.path) {
1023
+ buffer = await (0, promises_1.readFile)(file.path);
1024
+ }
1025
+ if (!buffer || buffer.length === 0) {
1026
+ throw new common_1.BadRequestException('Uploaded file is empty');
1027
+ }
1028
+ const utf8 = buffer.toString('utf8');
1029
+ if (utf8.includes('�')) {
1030
+ return buffer.toString('latin1');
1031
+ }
1032
+ return utf8;
1033
+ }
1034
+ parseCsvStatements(content) {
1035
+ const normalizedContent = content.replace(/^\uFEFF/, '').trim();
1036
+ if (!normalizedContent) {
1037
+ return [];
1038
+ }
1039
+ const rows = normalizedContent
1040
+ .split(/\r?\n/)
1041
+ .map((line) => line.trim())
1042
+ .filter((line) => !!line);
1043
+ if (rows.length === 0) {
1044
+ return [];
1045
+ }
1046
+ const delimiter = this.detectCsvDelimiter(rows[0]);
1047
+ const headerCells = this.parseCsvLine(rows[0], delimiter);
1048
+ const normalizedHeaders = headerCells.map((cell) => this.normalizeCsvHeader(cell));
1049
+ const headerIndexes = {
1050
+ date: this.findFirstHeaderIndex(normalizedHeaders, [
1051
+ 'data',
1052
+ 'dt',
1053
+ 'posteddate',
1054
+ 'lancamento',
1055
+ 'movimento',
1056
+ ]),
1057
+ description: this.findFirstHeaderIndex(normalizedHeaders, [
1058
+ 'descricao',
1059
+ 'historico',
1060
+ 'memo',
1061
+ 'complemento',
1062
+ ]),
1063
+ amount: this.findFirstHeaderIndex(normalizedHeaders, [
1064
+ 'valor',
1065
+ 'amount',
1066
+ 'montante',
1067
+ ]),
1068
+ debit: this.findFirstHeaderIndex(normalizedHeaders, ['debito', 'debit']),
1069
+ credit: this.findFirstHeaderIndex(normalizedHeaders, [
1070
+ 'credito',
1071
+ 'credit',
1072
+ ]),
1073
+ externalId: this.findFirstHeaderIndex(normalizedHeaders, [
1074
+ 'id',
1075
+ 'documento',
1076
+ 'numero',
1077
+ 'fitid',
1078
+ ]),
1079
+ };
1080
+ const hasHeader = headerIndexes.date >= 0 ||
1081
+ headerIndexes.description >= 0 ||
1082
+ headerIndexes.amount >= 0 ||
1083
+ headerIndexes.debit >= 0 ||
1084
+ headerIndexes.credit >= 0;
1085
+ const dataRows = hasHeader ? rows.slice(1) : rows;
1086
+ return dataRows
1087
+ .map((line) => this.parseCsvLine(line, delimiter))
1088
+ .map((columns) => {
1089
+ const dateRaw = (headerIndexes.date >= 0
1090
+ ? columns[headerIndexes.date]
1091
+ : columns[0]) || '';
1092
+ const descriptionRaw = (headerIndexes.description >= 0
1093
+ ? columns[headerIndexes.description]
1094
+ : columns[1]) || '';
1095
+ const amountRaw = headerIndexes.amount >= 0 ? columns[headerIndexes.amount] : '';
1096
+ const debitRaw = headerIndexes.debit >= 0 ? columns[headerIndexes.debit] : '';
1097
+ const creditRaw = headerIndexes.credit >= 0 ? columns[headerIndexes.credit] : '';
1098
+ const fallbackAmountRaw = columns[2] || '';
1099
+ const externalIdRaw = headerIndexes.externalId >= 0 ? columns[headerIndexes.externalId] : '';
1100
+ const postedDate = this.parseStatementDate(dateRaw);
1101
+ if (!postedDate) {
1102
+ return null;
1103
+ }
1104
+ const amount = this.resolveCsvAmount(amountRaw || fallbackAmountRaw, debitRaw, creditRaw);
1105
+ if (amount === null) {
1106
+ return null;
1107
+ }
1108
+ return {
1109
+ postedDate,
1110
+ amount,
1111
+ description: (descriptionRaw === null || descriptionRaw === void 0 ? void 0 : descriptionRaw.trim()) || 'Movimentação importada',
1112
+ externalId: (externalIdRaw === null || externalIdRaw === void 0 ? void 0 : externalIdRaw.trim()) || null,
1113
+ };
1114
+ })
1115
+ .filter(Boolean);
1116
+ }
1117
+ parseOfxStatements(content) {
1118
+ const normalized = content.replace(/\r/g, '');
1119
+ const segments = normalized
1120
+ .split(/<STMTTRN>/i)
1121
+ .slice(1)
1122
+ .map((segment) => segment.split(/<\/STMTTRN>/i)[0]);
1123
+ return segments
1124
+ .map((segment) => {
1125
+ var _a;
1126
+ const dateRaw = this.extractOfxTag(segment, 'DTPOSTED');
1127
+ const amountRaw = this.extractOfxTag(segment, 'TRNAMT');
1128
+ const fitId = this.extractOfxTag(segment, 'FITID');
1129
+ const memo = this.extractOfxTag(segment, 'MEMO');
1130
+ const name = this.extractOfxTag(segment, 'NAME');
1131
+ const trnType = (_a = this.extractOfxTag(segment, 'TRNTYPE')) === null || _a === void 0 ? void 0 : _a.toLowerCase();
1132
+ const postedDate = this.parseStatementDate(dateRaw);
1133
+ const amount = this.toNullableNumber(amountRaw);
1134
+ if (!postedDate || amount === null) {
1135
+ return null;
1136
+ }
1137
+ let normalizedAmount = amount;
1138
+ if (['debit', 'payment', 'withdrawal'].includes(trnType || '') &&
1139
+ normalizedAmount > 0) {
1140
+ normalizedAmount *= -1;
1141
+ }
1142
+ const description = [memo, name].filter(Boolean).join(' - ').trim();
1143
+ return {
1144
+ postedDate,
1145
+ amount: normalizedAmount,
1146
+ description: description || 'Movimentação OFX',
1147
+ externalId: fitId || null,
1148
+ };
1149
+ })
1150
+ .filter(Boolean);
1151
+ }
1152
+ detectCsvDelimiter(line) {
1153
+ var _a;
1154
+ const delimiters = [';', ',', '\t'];
1155
+ const counts = delimiters.map((delimiter) => ({
1156
+ delimiter,
1157
+ count: (line.match(new RegExp(`\\${delimiter}`, 'g')) || []).length,
1158
+ }));
1159
+ return ((_a = counts.sort((a, b) => b.count - a.count)[0]) === null || _a === void 0 ? void 0 : _a.delimiter) || ';';
1160
+ }
1161
+ parseCsvLine(line, delimiter) {
1162
+ const result = [];
1163
+ let current = '';
1164
+ let inQuotes = false;
1165
+ for (let i = 0; i < line.length; i++) {
1166
+ const char = line[i];
1167
+ const next = line[i + 1];
1168
+ if (char === '"') {
1169
+ if (inQuotes && next === '"') {
1170
+ current += '"';
1171
+ i++;
1172
+ }
1173
+ else {
1174
+ inQuotes = !inQuotes;
1175
+ }
1176
+ continue;
1177
+ }
1178
+ if (char === delimiter && !inQuotes) {
1179
+ result.push(current.trim());
1180
+ current = '';
1181
+ continue;
1182
+ }
1183
+ current += char;
1184
+ }
1185
+ result.push(current.trim());
1186
+ return result;
1187
+ }
1188
+ normalizeCsvHeader(value) {
1189
+ return String(value || '')
1190
+ .normalize('NFD')
1191
+ .replace(/[\u0300-\u036f]/g, '')
1192
+ .toLowerCase()
1193
+ .replace(/[^a-z0-9]/g, '');
1194
+ }
1195
+ findFirstHeaderIndex(headers, candidates) {
1196
+ return headers.findIndex((header) => candidates.some((candidate) => header.includes(candidate)));
1197
+ }
1198
+ parseStatementDate(value) {
1199
+ const raw = String(value || '').trim();
1200
+ if (!raw) {
1201
+ return '';
1202
+ }
1203
+ const onlyDateDigits = raw.match(/^(\d{8})/);
1204
+ if (onlyDateDigits === null || onlyDateDigits === void 0 ? void 0 : onlyDateDigits[1]) {
1205
+ const v = onlyDateDigits[1];
1206
+ return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
1207
+ }
1208
+ const normalized = this.normalizeDate(raw);
1209
+ if (normalized) {
1210
+ return normalized;
1211
+ }
1212
+ return '';
1213
+ }
1214
+ resolveCsvAmount(amountRaw, debitRaw, creditRaw) {
1215
+ const amount = this.toNullableNumber(amountRaw);
1216
+ const debit = this.toNullableNumber(debitRaw);
1217
+ const credit = this.toNullableNumber(creditRaw);
1218
+ if (credit !== null || debit !== null) {
1219
+ return (credit || 0) - (debit || 0);
1220
+ }
1221
+ if (amount === null) {
1222
+ return null;
1223
+ }
1224
+ return amount;
1225
+ }
1226
+ extractOfxTag(segment, tag) {
1227
+ var _a;
1228
+ const regex = new RegExp(`<${tag}>([^\n\r<]*)`, 'i');
1229
+ const match = segment.match(regex);
1230
+ return ((_a = match === null || match === void 0 ? void 0 : match[1]) === null || _a === void 0 ? void 0 : _a.trim()) || '';
1231
+ }
1232
+ createShortHash(input, length = 40) {
1233
+ return (0, node_crypto_1.createHash)('sha256').update(input).digest('hex').slice(0, length);
1234
+ }
1235
+ escapeCsvCell(value) {
1236
+ const normalizedValue = String(value !== null && value !== void 0 ? value : '');
1237
+ const escapedValue = normalizedValue.replace(/"/g, '""');
1238
+ return `"${escapedValue}"`;
1239
+ }
828
1240
  async createBankAccount(data, userId) {
829
1241
  var _a;
830
1242
  const accountType = this.mapAccountTypeFromPt(data.type);
@@ -1124,110 +1536,759 @@ let FinanceService = FinanceService_1 = class FinanceService {
1124
1536
  return title;
1125
1537
  }
1126
1538
  async createTitle(data, titleType, locale, userId) {
1127
- const person = await this.prisma.person.findUnique({
1128
- where: { id: data.person_id },
1129
- select: { id: true },
1130
- });
1131
- if (!person) {
1132
- throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, 'Person not found'));
1133
- }
1134
- if (data.finance_category_id) {
1135
- const category = await this.prisma.finance_category.findUnique({
1136
- where: { id: data.finance_category_id },
1539
+ const installments = this.normalizeAndValidateInstallments(data, locale);
1540
+ const createdTitleId = await this.prisma.$transaction(async (tx) => {
1541
+ const person = await tx.person.findUnique({
1542
+ where: { id: data.person_id },
1137
1543
  select: { id: true },
1138
1544
  });
1139
- if (!category) {
1140
- throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('categoryNotFound', locale, 'Category not found'));
1545
+ if (!person) {
1546
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, 'Person not found'));
1141
1547
  }
1142
- }
1143
- if (data.cost_center_id) {
1144
- const costCenter = await this.prisma.cost_center.findUnique({
1145
- where: { id: data.cost_center_id },
1548
+ if (data.finance_category_id) {
1549
+ const category = await tx.finance_category.findUnique({
1550
+ where: { id: data.finance_category_id },
1551
+ select: { id: true },
1552
+ });
1553
+ if (!category) {
1554
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('categoryNotFound', locale, 'Category not found'));
1555
+ }
1556
+ }
1557
+ if (data.cost_center_id) {
1558
+ const costCenter = await tx.cost_center.findUnique({
1559
+ where: { id: data.cost_center_id },
1560
+ select: { id: true },
1561
+ });
1562
+ if (!costCenter) {
1563
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('costCenterNotFound', locale, 'Cost center not found'));
1564
+ }
1565
+ }
1566
+ const attachmentFileIds = [
1567
+ ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
1568
+ ];
1569
+ if (attachmentFileIds.length > 0) {
1570
+ const existingFiles = await tx.file.findMany({
1571
+ where: {
1572
+ id: { in: attachmentFileIds },
1573
+ },
1574
+ select: {
1575
+ id: true,
1576
+ },
1577
+ });
1578
+ const existingFileIds = new Set(existingFiles.map((file) => file.id));
1579
+ const invalidFileIds = attachmentFileIds.filter((fileId) => !existingFileIds.has(fileId));
1580
+ if (invalidFileIds.length > 0) {
1581
+ throw new common_1.BadRequestException(`Invalid attachment file IDs: ${invalidFileIds.join(', ')}`);
1582
+ }
1583
+ }
1584
+ await this.assertDateNotInClosedPeriod(tx, data.competence_date
1585
+ ? new Date(data.competence_date)
1586
+ : new Date(installments[0].due_date), 'create title');
1587
+ const title = await tx.financial_title.create({
1588
+ data: {
1589
+ person_id: data.person_id,
1590
+ title_type: titleType,
1591
+ status: 'draft',
1592
+ document_number: data.document_number,
1593
+ description: data.description,
1594
+ competence_date: data.competence_date
1595
+ ? new Date(data.competence_date)
1596
+ : null,
1597
+ issue_date: data.issue_date ? new Date(data.issue_date) : null,
1598
+ total_amount_cents: this.toCents(data.total_amount),
1599
+ finance_category_id: data.finance_category_id,
1600
+ created_by_user_id: userId,
1601
+ },
1602
+ });
1603
+ if (attachmentFileIds.length > 0) {
1604
+ await tx.financial_title_attachment.createMany({
1605
+ data: attachmentFileIds.map((fileId) => ({
1606
+ title_id: title.id,
1607
+ file_id: fileId,
1608
+ uploaded_by_user_id: userId,
1609
+ })),
1610
+ });
1611
+ }
1612
+ for (let index = 0; index < installments.length; index++) {
1613
+ const installment = installments[index];
1614
+ const amountCents = installment.amount_cents;
1615
+ const createdInstallment = await tx.financial_installment.create({
1616
+ data: {
1617
+ title_id: title.id,
1618
+ installment_number: installment.installment_number,
1619
+ competence_date: data.competence_date
1620
+ ? new Date(data.competence_date)
1621
+ : new Date(installment.due_date),
1622
+ due_date: new Date(installment.due_date),
1623
+ amount_cents: amountCents,
1624
+ open_amount_cents: amountCents,
1625
+ status: this.resolveInstallmentStatus(amountCents, amountCents, new Date(installment.due_date)),
1626
+ notes: data.description,
1627
+ },
1628
+ });
1629
+ if (data.cost_center_id) {
1630
+ await tx.installment_allocation.create({
1631
+ data: {
1632
+ installment_id: createdInstallment.id,
1633
+ cost_center_id: data.cost_center_id,
1634
+ allocated_amount_cents: amountCents,
1635
+ },
1636
+ });
1637
+ }
1638
+ }
1639
+ await this.createAuditLog(tx, {
1640
+ action: 'CREATE_TITLE',
1641
+ entityTable: 'financial_title',
1642
+ entityId: String(title.id),
1643
+ actorUserId: userId,
1644
+ summary: `Created ${titleType} title ${title.id} in draft`,
1645
+ afterData: JSON.stringify({
1646
+ status: 'draft',
1647
+ total_amount_cents: this.toCents(data.total_amount),
1648
+ }),
1649
+ });
1650
+ return title.id;
1651
+ });
1652
+ const createdTitle = await this.getTitleById(createdTitleId, titleType, locale);
1653
+ return this.mapTitleToFront(createdTitle, data.payment_channel);
1654
+ }
1655
+ async updateDraftTitle(titleId, data, titleType, locale, userId) {
1656
+ const installments = this.normalizeAndValidateInstallments(data, locale);
1657
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
1658
+ const title = await tx.financial_title.findFirst({
1659
+ where: {
1660
+ id: titleId,
1661
+ title_type: titleType,
1662
+ },
1663
+ include: {
1664
+ financial_installment: {
1665
+ select: {
1666
+ id: true,
1667
+ },
1668
+ },
1669
+ },
1670
+ });
1671
+ if (!title) {
1672
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, `Financial title with ID ${titleId} not found`).replace('{{item}}', 'Financial title'));
1673
+ }
1674
+ if (title.status !== 'draft') {
1675
+ throw new common_1.BadRequestException('Only draft titles can be edited');
1676
+ }
1677
+ const person = await tx.person.findUnique({
1678
+ where: { id: data.person_id },
1146
1679
  select: { id: true },
1147
1680
  });
1148
- if (!costCenter) {
1149
- throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('costCenterNotFound', locale, 'Cost center not found'));
1681
+ if (!person) {
1682
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, 'Person not found'));
1683
+ }
1684
+ if (data.finance_category_id) {
1685
+ const category = await tx.finance_category.findUnique({
1686
+ where: { id: data.finance_category_id },
1687
+ select: { id: true },
1688
+ });
1689
+ if (!category) {
1690
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('categoryNotFound', locale, 'Category not found'));
1691
+ }
1692
+ }
1693
+ if (data.cost_center_id) {
1694
+ const costCenter = await tx.cost_center.findUnique({
1695
+ where: { id: data.cost_center_id },
1696
+ select: { id: true },
1697
+ });
1698
+ if (!costCenter) {
1699
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('costCenterNotFound', locale, 'Cost center not found'));
1700
+ }
1701
+ }
1702
+ const attachmentFileIds = [
1703
+ ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
1704
+ ];
1705
+ if (attachmentFileIds.length > 0) {
1706
+ const existingFiles = await tx.file.findMany({
1707
+ where: {
1708
+ id: { in: attachmentFileIds },
1709
+ },
1710
+ select: {
1711
+ id: true,
1712
+ },
1713
+ });
1714
+ const existingFileIds = new Set(existingFiles.map((file) => file.id));
1715
+ const invalidFileIds = attachmentFileIds.filter((fileId) => !existingFileIds.has(fileId));
1716
+ if (invalidFileIds.length > 0) {
1717
+ throw new common_1.BadRequestException(`Invalid attachment file IDs: ${invalidFileIds.join(', ')}`);
1718
+ }
1719
+ }
1720
+ await this.assertDateNotInClosedPeriod(tx, data.competence_date
1721
+ ? new Date(data.competence_date)
1722
+ : new Date(installments[0].due_date), 'update title');
1723
+ const installmentIds = title.financial_installment.map((item) => item.id);
1724
+ if (installmentIds.length > 0) {
1725
+ const hasSettlements = await tx.settlement_allocation.findFirst({
1726
+ where: {
1727
+ installment_id: {
1728
+ in: installmentIds,
1729
+ },
1730
+ },
1731
+ select: {
1732
+ id: true,
1733
+ },
1734
+ });
1735
+ if (hasSettlements) {
1736
+ throw new common_1.BadRequestException('Cannot edit title with settled installments');
1737
+ }
1738
+ }
1739
+ await tx.financial_title.update({
1740
+ where: { id: title.id },
1741
+ data: {
1742
+ person_id: data.person_id,
1743
+ document_number: data.document_number,
1744
+ description: data.description,
1745
+ competence_date: data.competence_date
1746
+ ? new Date(data.competence_date)
1747
+ : null,
1748
+ issue_date: data.issue_date ? new Date(data.issue_date) : null,
1749
+ total_amount_cents: this.toCents(data.total_amount),
1750
+ finance_category_id: data.finance_category_id,
1751
+ },
1752
+ });
1753
+ if (installmentIds.length > 0) {
1754
+ await tx.installment_allocation.deleteMany({
1755
+ where: {
1756
+ installment_id: {
1757
+ in: installmentIds,
1758
+ },
1759
+ },
1760
+ });
1761
+ await tx.financial_installment_tag.deleteMany({
1762
+ where: {
1763
+ installment_id: {
1764
+ in: installmentIds,
1765
+ },
1766
+ },
1767
+ });
1768
+ await tx.financial_installment.deleteMany({
1769
+ where: {
1770
+ title_id: title.id,
1771
+ },
1772
+ });
1773
+ }
1774
+ for (const installment of installments) {
1775
+ const amountCents = installment.amount_cents;
1776
+ const createdInstallment = await tx.financial_installment.create({
1777
+ data: {
1778
+ title_id: title.id,
1779
+ installment_number: installment.installment_number,
1780
+ competence_date: data.competence_date
1781
+ ? new Date(data.competence_date)
1782
+ : new Date(installment.due_date),
1783
+ due_date: new Date(installment.due_date),
1784
+ amount_cents: amountCents,
1785
+ open_amount_cents: amountCents,
1786
+ status: this.resolveInstallmentStatus(amountCents, amountCents, new Date(installment.due_date)),
1787
+ notes: data.description,
1788
+ },
1789
+ });
1790
+ if (data.cost_center_id) {
1791
+ await tx.installment_allocation.create({
1792
+ data: {
1793
+ installment_id: createdInstallment.id,
1794
+ cost_center_id: data.cost_center_id,
1795
+ allocated_amount_cents: amountCents,
1796
+ },
1797
+ });
1798
+ }
1799
+ }
1800
+ if (data.attachment_file_ids) {
1801
+ await tx.financial_title_attachment.deleteMany({
1802
+ where: {
1803
+ title_id: title.id,
1804
+ },
1805
+ });
1806
+ if (attachmentFileIds.length > 0) {
1807
+ await tx.financial_title_attachment.createMany({
1808
+ data: attachmentFileIds.map((fileId) => ({
1809
+ title_id: title.id,
1810
+ file_id: fileId,
1811
+ uploaded_by_user_id: userId,
1812
+ })),
1813
+ });
1814
+ }
1150
1815
  }
1816
+ await this.createAuditLog(tx, {
1817
+ action: 'UPDATE_TITLE',
1818
+ entityTable: 'financial_title',
1819
+ entityId: String(title.id),
1820
+ actorUserId: userId,
1821
+ summary: `Updated draft ${titleType} title ${title.id}`,
1822
+ beforeData: JSON.stringify({ status: title.status }),
1823
+ afterData: JSON.stringify({
1824
+ status: title.status,
1825
+ total_amount_cents: this.toCents(data.total_amount),
1826
+ }),
1827
+ });
1828
+ return tx.financial_title.findFirst({
1829
+ where: {
1830
+ id: title.id,
1831
+ title_type: titleType,
1832
+ },
1833
+ include: this.defaultTitleInclude(),
1834
+ });
1835
+ });
1836
+ if (!updatedTitle) {
1837
+ throw new common_1.NotFoundException('Financial title not found');
1151
1838
  }
1152
- const installments = data.installments && data.installments.length > 0
1839
+ return this.mapTitleToFront(updatedTitle, data.payment_channel);
1840
+ }
1841
+ normalizeAndValidateInstallments(data, locale) {
1842
+ const fallbackDueDate = data.due_date;
1843
+ const totalAmountCents = this.toCents(data.total_amount);
1844
+ const sourceInstallments = data.installments && data.installments.length > 0
1153
1845
  ? data.installments
1154
1846
  : [
1155
1847
  {
1156
1848
  installment_number: 1,
1157
- due_date: data.due_date,
1849
+ due_date: fallbackDueDate,
1158
1850
  amount: data.total_amount,
1159
1851
  },
1160
1852
  ];
1161
- const title = await this.prisma.financial_title.create({
1162
- data: {
1163
- person_id: data.person_id,
1164
- title_type: titleType,
1165
- status: 'open',
1166
- document_number: data.document_number,
1167
- description: data.description,
1168
- competence_date: data.competence_date
1169
- ? new Date(data.competence_date)
1170
- : null,
1171
- issue_date: data.issue_date ? new Date(data.issue_date) : null,
1172
- total_amount_cents: this.toCents(data.total_amount),
1173
- finance_category_id: data.finance_category_id,
1174
- created_by_user_id: userId,
1175
- },
1853
+ const normalizedInstallments = sourceInstallments.map((installment, index) => {
1854
+ const installmentDueDate = installment.due_date || fallbackDueDate;
1855
+ if (!installmentDueDate) {
1856
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('installmentDueDateRequired', locale, 'Installment due date is required'));
1857
+ }
1858
+ const amountCents = this.toCents(installment.amount);
1859
+ if (amountCents <= 0) {
1860
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('installmentAmountInvalid', locale, 'Installment amount must be greater than zero'));
1861
+ }
1862
+ return {
1863
+ installment_number: installment.installment_number || index + 1,
1864
+ due_date: installmentDueDate,
1865
+ amount_cents: amountCents,
1866
+ };
1176
1867
  });
1177
- const attachmentFileIds = [
1178
- ...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
1179
- ];
1180
- if (attachmentFileIds.length > 0) {
1181
- const existingFiles = await this.prisma.file.findMany({
1868
+ const installmentsTotalCents = normalizedInstallments.reduce((acc, installment) => acc + installment.amount_cents, 0);
1869
+ if (installmentsTotalCents !== totalAmountCents) {
1870
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('installmentsTotalMismatch', locale, 'Installments total must be equal to title total amount'));
1871
+ }
1872
+ return normalizedInstallments;
1873
+ }
1874
+ async approveTitle(titleId, titleType, locale, userId) {
1875
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
1876
+ const title = await tx.financial_title.findFirst({
1182
1877
  where: {
1183
- id: { in: attachmentFileIds },
1878
+ id: titleId,
1879
+ title_type: titleType,
1184
1880
  },
1185
1881
  select: {
1186
1882
  id: true,
1883
+ status: true,
1884
+ competence_date: true,
1187
1885
  },
1188
1886
  });
1189
- const existingFileIds = new Set(existingFiles.map((file) => file.id));
1190
- const invalidFileIds = attachmentFileIds.filter((fileId) => !existingFileIds.has(fileId));
1191
- if (invalidFileIds.length > 0) {
1192
- throw new common_1.BadRequestException(`Invalid attachment file IDs: ${invalidFileIds.join(', ')}`);
1887
+ if (!title) {
1888
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, `Financial title with ID ${titleId} not found`).replace('{{item}}', 'Financial title'));
1193
1889
  }
1194
- await this.prisma.financial_title_attachment.createMany({
1195
- data: attachmentFileIds.map((fileId) => ({
1196
- title_id: title.id,
1197
- file_id: fileId,
1198
- uploaded_by_user_id: userId,
1199
- })),
1890
+ await this.assertDateNotInClosedPeriod(tx, title.competence_date, 'approve title');
1891
+ if (title.status !== 'draft') {
1892
+ throw new common_1.BadRequestException('Only draft titles can be approved');
1893
+ }
1894
+ await tx.financial_title.update({
1895
+ where: { id: title.id },
1896
+ data: {
1897
+ status: 'open',
1898
+ },
1200
1899
  });
1900
+ await this.createAuditLog(tx, {
1901
+ action: 'APPROVE_TITLE',
1902
+ entityTable: 'financial_title',
1903
+ entityId: String(title.id),
1904
+ actorUserId: userId,
1905
+ summary: `Approved ${titleType} title ${title.id}`,
1906
+ beforeData: JSON.stringify({ status: title.status }),
1907
+ afterData: JSON.stringify({ status: 'open' }),
1908
+ });
1909
+ return tx.financial_title.findFirst({
1910
+ where: {
1911
+ id: title.id,
1912
+ title_type: titleType,
1913
+ },
1914
+ include: this.defaultTitleInclude(),
1915
+ });
1916
+ });
1917
+ if (!updatedTitle) {
1918
+ throw new common_1.NotFoundException('Financial title not found');
1201
1919
  }
1202
- for (let index = 0; index < installments.length; index++) {
1203
- const installment = installments[index];
1204
- const amountCents = this.toCents(installment.amount);
1205
- const createdInstallment = await this.prisma.financial_installment.create({
1920
+ return this.mapTitleToFront(updatedTitle);
1921
+ }
1922
+ async rejectTitle(titleId, data, titleType, locale, userId) {
1923
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
1924
+ const title = await tx.financial_title.findFirst({
1925
+ where: {
1926
+ id: titleId,
1927
+ title_type: titleType,
1928
+ },
1929
+ select: {
1930
+ id: true,
1931
+ status: true,
1932
+ competence_date: true,
1933
+ },
1934
+ });
1935
+ if (!title) {
1936
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, `Financial title with ID ${titleId} not found`).replace('{{item}}', 'Financial title'));
1937
+ }
1938
+ await this.assertDateNotInClosedPeriod(tx, title.competence_date, 'reject title');
1939
+ if (title.status !== 'draft') {
1940
+ throw new common_1.BadRequestException('Only draft titles can be rejected');
1941
+ }
1942
+ await tx.financial_title.update({
1943
+ where: { id: title.id },
1944
+ data: {
1945
+ status: 'canceled',
1946
+ },
1947
+ });
1948
+ await this.createAuditLog(tx, {
1949
+ action: 'REJECT_TITLE',
1950
+ entityTable: 'financial_title',
1951
+ entityId: String(title.id),
1952
+ actorUserId: userId,
1953
+ summary: `Rejected ${titleType} title ${title.id}`,
1954
+ beforeData: JSON.stringify({ status: title.status }),
1955
+ afterData: JSON.stringify({
1956
+ status: 'canceled',
1957
+ reason: (data === null || data === void 0 ? void 0 : data.reason) || null,
1958
+ }),
1959
+ });
1960
+ return tx.financial_title.findFirst({
1961
+ where: {
1962
+ id: title.id,
1963
+ title_type: titleType,
1964
+ },
1965
+ include: this.defaultTitleInclude(),
1966
+ });
1967
+ });
1968
+ if (!updatedTitle) {
1969
+ throw new common_1.NotFoundException('Financial title not found');
1970
+ }
1971
+ return this.mapTitleToFront(updatedTitle);
1972
+ }
1973
+ async cancelTitle(titleId, data, titleType, locale, userId) {
1974
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
1975
+ const title = await tx.financial_title.findFirst({
1976
+ where: {
1977
+ id: titleId,
1978
+ title_type: titleType,
1979
+ },
1980
+ select: {
1981
+ id: true,
1982
+ status: true,
1983
+ competence_date: true,
1984
+ },
1985
+ });
1986
+ if (!title) {
1987
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, `Financial title with ID ${titleId} not found`).replace('{{item}}', 'Financial title'));
1988
+ }
1989
+ if (title.status === 'settled' || title.status === 'canceled') {
1990
+ throw new common_1.BadRequestException('Title cannot be canceled in current status');
1991
+ }
1992
+ await this.assertDateNotInClosedPeriod(tx, title.competence_date, 'cancel title');
1993
+ await tx.financial_title.update({
1994
+ where: { id: title.id },
1206
1995
  data: {
1996
+ status: 'canceled',
1997
+ },
1998
+ });
1999
+ await this.createAuditLog(tx, {
2000
+ action: 'CANCEL_TITLE',
2001
+ entityTable: 'financial_title',
2002
+ entityId: String(title.id),
2003
+ actorUserId: userId,
2004
+ summary: `Canceled ${titleType} title ${title.id}`,
2005
+ beforeData: JSON.stringify({ status: title.status }),
2006
+ afterData: JSON.stringify({
2007
+ status: 'canceled',
2008
+ reason: (data === null || data === void 0 ? void 0 : data.reason) || null,
2009
+ }),
2010
+ });
2011
+ return tx.financial_title.findFirst({
2012
+ where: {
2013
+ id: title.id,
2014
+ title_type: titleType,
2015
+ },
2016
+ include: this.defaultTitleInclude(),
2017
+ });
2018
+ });
2019
+ if (!updatedTitle) {
2020
+ throw new common_1.NotFoundException('Financial title not found');
2021
+ }
2022
+ return this.mapTitleToFront(updatedTitle);
2023
+ }
2024
+ async settleTitleInstallment(titleId, data, titleType, locale, userId) {
2025
+ const amountCents = this.toCents(data.amount);
2026
+ if (amountCents <= 0) {
2027
+ throw new common_1.BadRequestException('Settlement amount must be greater than zero');
2028
+ }
2029
+ const settledAt = data.settled_at ? new Date(data.settled_at) : new Date();
2030
+ if (Number.isNaN(settledAt.getTime())) {
2031
+ throw new common_1.BadRequestException('Invalid settlement date');
2032
+ }
2033
+ const result = await this.prisma.$transaction(async (tx) => {
2034
+ var _a;
2035
+ const title = await tx.financial_title.findFirst({
2036
+ where: {
2037
+ id: titleId,
2038
+ title_type: titleType,
2039
+ },
2040
+ select: {
2041
+ id: true,
2042
+ person_id: true,
2043
+ status: true,
2044
+ competence_date: true,
2045
+ },
2046
+ });
2047
+ if (!title) {
2048
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, `Financial title with ID ${titleId} not found`).replace('{{item}}', 'Financial title'));
2049
+ }
2050
+ if (!['open', 'partial'].includes(title.status)) {
2051
+ throw new common_1.BadRequestException('Only open/partial titles can be settled');
2052
+ }
2053
+ await this.assertDateNotInClosedPeriod(tx, title.competence_date, 'settle installment');
2054
+ const installment = await tx.financial_installment.findFirst({
2055
+ where: {
2056
+ id: data.installment_id,
1207
2057
  title_id: title.id,
1208
- installment_number: installment.installment_number || index + 1,
1209
- competence_date: data.competence_date
1210
- ? new Date(data.competence_date)
1211
- : new Date(installment.due_date),
1212
- due_date: new Date(installment.due_date),
2058
+ },
2059
+ select: {
2060
+ id: true,
2061
+ title_id: true,
2062
+ amount_cents: true,
2063
+ open_amount_cents: true,
2064
+ due_date: true,
2065
+ status: true,
2066
+ },
2067
+ });
2068
+ if (!installment) {
2069
+ throw new common_1.BadRequestException('Installment not found for this title');
2070
+ }
2071
+ if (installment.status === 'settled' || installment.status === 'canceled') {
2072
+ throw new common_1.BadRequestException('This installment cannot be settled');
2073
+ }
2074
+ if (amountCents > installment.open_amount_cents) {
2075
+ throw new common_1.BadRequestException('Settlement amount exceeds open amount');
2076
+ }
2077
+ const paymentMethodId = await this.resolvePaymentMethodId(tx, data.payment_channel);
2078
+ const settlement = await tx.settlement.create({
2079
+ data: {
2080
+ person_id: title.person_id,
2081
+ bank_account_id: data.bank_account_id || null,
2082
+ payment_method_id: paymentMethodId,
2083
+ settlement_type: titleType,
2084
+ status: 'confirmed',
2085
+ settled_at: settledAt,
1213
2086
  amount_cents: amountCents,
1214
- open_amount_cents: amountCents,
1215
- status: 'open',
1216
- notes: data.description,
2087
+ description: ((_a = data.description) === null || _a === void 0 ? void 0 : _a.trim()) || null,
2088
+ created_by_user_id: userId,
1217
2089
  },
1218
2090
  });
1219
- if (data.cost_center_id) {
1220
- await this.prisma.installment_allocation.create({
2091
+ await tx.settlement_allocation.create({
2092
+ data: {
2093
+ settlement_id: settlement.id,
2094
+ installment_id: installment.id,
2095
+ allocated_amount_cents: amountCents,
2096
+ discount_cents: this.toCents(data.discount || 0),
2097
+ interest_cents: this.toCents(data.interest || 0),
2098
+ penalty_cents: this.toCents(data.penalty || 0),
2099
+ },
2100
+ });
2101
+ const decrementResult = await tx.financial_installment.updateMany({
2102
+ where: {
2103
+ id: installment.id,
2104
+ open_amount_cents: {
2105
+ gte: amountCents,
2106
+ },
2107
+ },
2108
+ data: {
2109
+ open_amount_cents: {
2110
+ decrement: amountCents,
2111
+ },
2112
+ },
2113
+ });
2114
+ if (decrementResult.count !== 1) {
2115
+ throw new common_1.BadRequestException('Installment was updated concurrently, please try again');
2116
+ }
2117
+ const updatedInstallment = await tx.financial_installment.findUnique({
2118
+ where: {
2119
+ id: installment.id,
2120
+ },
2121
+ select: {
2122
+ id: true,
2123
+ amount_cents: true,
2124
+ open_amount_cents: true,
2125
+ due_date: true,
2126
+ status: true,
2127
+ },
2128
+ });
2129
+ if (!updatedInstallment) {
2130
+ throw new common_1.NotFoundException('Installment not found');
2131
+ }
2132
+ const nextInstallmentStatus = this.resolveInstallmentStatus(updatedInstallment.amount_cents, updatedInstallment.open_amount_cents, updatedInstallment.due_date);
2133
+ if (updatedInstallment.status !== nextInstallmentStatus) {
2134
+ await tx.financial_installment.update({
2135
+ where: {
2136
+ id: updatedInstallment.id,
2137
+ },
2138
+ data: {
2139
+ status: nextInstallmentStatus,
2140
+ },
2141
+ });
2142
+ }
2143
+ const previousTitleStatus = title.status;
2144
+ const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
2145
+ await this.createAuditLog(tx, {
2146
+ action: 'SETTLE_INSTALLMENT',
2147
+ entityTable: 'financial_title',
2148
+ entityId: String(title.id),
2149
+ actorUserId: userId,
2150
+ summary: `Settled installment ${installment.id} of title ${title.id}`,
2151
+ beforeData: JSON.stringify({
2152
+ title_status: previousTitleStatus,
2153
+ installment_open_amount_cents: installment.open_amount_cents,
2154
+ }),
2155
+ afterData: JSON.stringify({
2156
+ title_status: nextTitleStatus,
2157
+ installment_open_amount_cents: updatedInstallment.open_amount_cents,
2158
+ settlement_id: settlement.id,
2159
+ }),
2160
+ });
2161
+ const updatedTitle = await tx.financial_title.findFirst({
2162
+ where: {
2163
+ id: title.id,
2164
+ title_type: titleType,
2165
+ },
2166
+ include: this.defaultTitleInclude(),
2167
+ });
2168
+ if (!updatedTitle) {
2169
+ throw new common_1.NotFoundException('Financial title not found');
2170
+ }
2171
+ return {
2172
+ title: updatedTitle,
2173
+ settlementId: settlement.id,
2174
+ };
2175
+ });
2176
+ return Object.assign(Object.assign({}, this.mapTitleToFront(result.title)), { settlementId: String(result.settlementId) });
2177
+ }
2178
+ async reverseTitleSettlement(titleId, settlementId, data, titleType, locale, userId) {
2179
+ const updatedTitle = await this.prisma.$transaction(async (tx) => {
2180
+ const title = await tx.financial_title.findFirst({
2181
+ where: {
2182
+ id: titleId,
2183
+ title_type: titleType,
2184
+ },
2185
+ select: {
2186
+ id: true,
2187
+ status: true,
2188
+ competence_date: true,
2189
+ },
2190
+ });
2191
+ if (!title) {
2192
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, `Financial title with ID ${titleId} not found`).replace('{{item}}', 'Financial title'));
2193
+ }
2194
+ await this.assertDateNotInClosedPeriod(tx, title.competence_date, 'reverse settlement');
2195
+ const settlement = await tx.settlement.findFirst({
2196
+ where: {
2197
+ id: settlementId,
2198
+ settlement_type: titleType,
2199
+ settlement_allocation: {
2200
+ some: {
2201
+ financial_installment: {
2202
+ title_id: title.id,
2203
+ },
2204
+ },
2205
+ },
2206
+ },
2207
+ include: {
2208
+ settlement_allocation: {
2209
+ include: {
2210
+ financial_installment: {
2211
+ select: {
2212
+ id: true,
2213
+ amount_cents: true,
2214
+ open_amount_cents: true,
2215
+ due_date: true,
2216
+ status: true,
2217
+ },
2218
+ },
2219
+ },
2220
+ },
2221
+ },
2222
+ });
2223
+ if (!settlement) {
2224
+ throw new common_1.NotFoundException('Settlement not found for this title');
2225
+ }
2226
+ if (settlement.status === 'reversed') {
2227
+ throw new common_1.BadRequestException('This settlement is already reversed');
2228
+ }
2229
+ for (const allocation of settlement.settlement_allocation) {
2230
+ const installment = allocation.financial_installment;
2231
+ if (!installment) {
2232
+ continue;
2233
+ }
2234
+ const nextOpenAmountCents = installment.open_amount_cents + allocation.allocated_amount_cents;
2235
+ if (nextOpenAmountCents > installment.amount_cents) {
2236
+ throw new common_1.BadRequestException(`Reverse would exceed installment amount for installment ${installment.id}`);
2237
+ }
2238
+ const nextInstallmentStatus = this.resolveInstallmentStatus(installment.amount_cents, nextOpenAmountCents, installment.due_date);
2239
+ await tx.financial_installment.update({
2240
+ where: {
2241
+ id: installment.id,
2242
+ },
1221
2243
  data: {
1222
- installment_id: createdInstallment.id,
1223
- cost_center_id: data.cost_center_id,
1224
- allocated_amount_cents: amountCents,
2244
+ open_amount_cents: nextOpenAmountCents,
2245
+ status: nextInstallmentStatus,
1225
2246
  },
1226
2247
  });
1227
2248
  }
2249
+ await tx.settlement.update({
2250
+ where: {
2251
+ id: settlement.id,
2252
+ },
2253
+ data: {
2254
+ status: 'reversed',
2255
+ description: [
2256
+ settlement.description,
2257
+ data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
2258
+ ]
2259
+ .filter(Boolean)
2260
+ .join(' | '),
2261
+ },
2262
+ });
2263
+ const previousTitleStatus = title.status;
2264
+ const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
2265
+ await this.createAuditLog(tx, {
2266
+ action: 'REVERSE_SETTLEMENT',
2267
+ entityTable: 'financial_title',
2268
+ entityId: String(title.id),
2269
+ actorUserId: userId,
2270
+ summary: `Reversed settlement ${settlement.id} from title ${title.id}`,
2271
+ beforeData: JSON.stringify({
2272
+ title_status: previousTitleStatus,
2273
+ settlement_status: settlement.status,
2274
+ }),
2275
+ afterData: JSON.stringify({
2276
+ title_status: nextTitleStatus,
2277
+ settlement_status: 'reversed',
2278
+ }),
2279
+ });
2280
+ return tx.financial_title.findFirst({
2281
+ where: {
2282
+ id: title.id,
2283
+ title_type: titleType,
2284
+ },
2285
+ include: this.defaultTitleInclude(),
2286
+ });
2287
+ });
2288
+ if (!updatedTitle) {
2289
+ throw new common_1.NotFoundException('Financial title not found');
1228
2290
  }
1229
- const createdTitle = await this.getTitleById(title.id, titleType, locale);
1230
- return this.mapTitleToFront(createdTitle, data.payment_channel);
2291
+ return this.mapTitleToFront(updatedTitle);
1231
2292
  }
1232
2293
  async updateTitleTags(titleId, titleType, tagIds, locale) {
1233
2294
  const title = await this.getTitleById(titleId, titleType, locale);
@@ -1377,6 +2438,124 @@ let FinanceService = FinanceService_1 = class FinanceService {
1377
2438
  data: log.created_at.toISOString(),
1378
2439
  }));
1379
2440
  }
2441
+ async resolvePaymentMethodId(tx, paymentChannel) {
2442
+ const paymentType = this.mapPaymentMethodFromPt(paymentChannel);
2443
+ if (!paymentType) {
2444
+ return null;
2445
+ }
2446
+ const paymentMethod = await tx.payment_method.findFirst({
2447
+ where: {
2448
+ type: paymentType,
2449
+ status: 'active',
2450
+ },
2451
+ select: {
2452
+ id: true,
2453
+ },
2454
+ });
2455
+ return (paymentMethod === null || paymentMethod === void 0 ? void 0 : paymentMethod.id) || null;
2456
+ }
2457
+ async assertDateNotInClosedPeriod(tx, competenceDate, operation) {
2458
+ if (!competenceDate) {
2459
+ return;
2460
+ }
2461
+ const closedPeriod = await tx.period_close.findFirst({
2462
+ where: {
2463
+ status: 'closed',
2464
+ period_start: {
2465
+ lte: competenceDate,
2466
+ },
2467
+ period_end: {
2468
+ gte: competenceDate,
2469
+ },
2470
+ },
2471
+ select: {
2472
+ id: true,
2473
+ },
2474
+ });
2475
+ if (closedPeriod) {
2476
+ throw new common_1.BadRequestException(`Cannot ${operation}: competence is in a closed period`);
2477
+ }
2478
+ }
2479
+ resolveInstallmentStatus(amountCents, openAmountCents, dueDate, currentStatus) {
2480
+ if (currentStatus === 'canceled') {
2481
+ return 'canceled';
2482
+ }
2483
+ if (openAmountCents <= 0) {
2484
+ return 'settled';
2485
+ }
2486
+ if (openAmountCents < amountCents) {
2487
+ return 'partial';
2488
+ }
2489
+ const today = new Date();
2490
+ today.setHours(0, 0, 0, 0);
2491
+ const dueDateOnly = new Date(dueDate);
2492
+ dueDateOnly.setHours(0, 0, 0, 0);
2493
+ if (dueDateOnly < today) {
2494
+ return 'overdue';
2495
+ }
2496
+ return 'open';
2497
+ }
2498
+ deriveTitleStatusFromInstallments(installments) {
2499
+ if (installments.length === 0) {
2500
+ return 'open';
2501
+ }
2502
+ const effectiveStatuses = installments.map((installment) => this.resolveInstallmentStatus(installment.amount_cents, installment.open_amount_cents, installment.due_date, installment.status));
2503
+ if (effectiveStatuses.every((status) => status === 'settled')) {
2504
+ return 'settled';
2505
+ }
2506
+ const hasPayment = installments.some((installment) => installment.open_amount_cents < installment.amount_cents);
2507
+ if (hasPayment) {
2508
+ return 'partial';
2509
+ }
2510
+ return 'open';
2511
+ }
2512
+ async recalculateTitleStatus(tx, titleId) {
2513
+ const title = await tx.financial_title.findUnique({
2514
+ where: {
2515
+ id: titleId,
2516
+ },
2517
+ select: {
2518
+ id: true,
2519
+ status: true,
2520
+ financial_installment: {
2521
+ select: {
2522
+ amount_cents: true,
2523
+ open_amount_cents: true,
2524
+ due_date: true,
2525
+ status: true,
2526
+ },
2527
+ },
2528
+ },
2529
+ });
2530
+ if (!title) {
2531
+ throw new common_1.NotFoundException('Financial title not found');
2532
+ }
2533
+ const nextStatus = this.deriveTitleStatusFromInstallments(title.financial_installment);
2534
+ if (title.status !== nextStatus) {
2535
+ await tx.financial_title.update({
2536
+ where: {
2537
+ id: title.id,
2538
+ },
2539
+ data: {
2540
+ status: nextStatus,
2541
+ },
2542
+ });
2543
+ }
2544
+ return nextStatus;
2545
+ }
2546
+ async createAuditLog(tx, data) {
2547
+ await tx.audit_log.create({
2548
+ data: {
2549
+ actor_user_id: data.actorUserId || null,
2550
+ action: data.action,
2551
+ entity_table: data.entityTable,
2552
+ entity_id: data.entityId,
2553
+ summary: data.summary || null,
2554
+ before_data: data.beforeData || null,
2555
+ after_data: data.afterData || null,
2556
+ },
2557
+ });
2558
+ }
1380
2559
  mapTitleToFront(title, paymentChannelOverride) {
1381
2560
  var _a;
1382
2561
  const allocations = title.financial_installment.flatMap((installment) => installment.installment_allocation);
@@ -1397,21 +2576,26 @@ let FinanceService = FinanceService_1 = class FinanceService {
1397
2576
  numero: installment.installment_number,
1398
2577
  vencimento: installment.due_date.toISOString(),
1399
2578
  valor: this.fromCents(installment.amount_cents),
1400
- status: this.mapStatusToPt(installment.status),
2579
+ valorAberto: this.fromCents(installment.open_amount_cents),
2580
+ status: this.mapStatusToPt(this.resolveInstallmentStatus(installment.amount_cents, installment.open_amount_cents, installment.due_date, installment.status)),
1401
2581
  metodoPagamento: this.mapPaymentMethodToPt((_c = (_b = (_a = installment.settlement_allocation[0]) === null || _a === void 0 ? void 0 : _a.settlement) === null || _b === void 0 ? void 0 : _b.payment_method) === null || _c === void 0 ? void 0 : _c.type) || paymentChannelOverride || 'transferencia',
1402
2582
  liquidacoes: installment.settlement_allocation.map((allocation) => {
1403
- var _a, _b, _c, _d, _e;
2583
+ var _a, _b, _c, _d, _e, _f, _g;
1404
2584
  return ({
1405
2585
  id: String(allocation.id),
1406
- data: (_b = (_a = allocation.settlement) === null || _a === void 0 ? void 0 : _a.settled_at) === null || _b === void 0 ? void 0 : _b.toISOString(),
2586
+ settlementId: ((_a = allocation.settlement) === null || _a === void 0 ? void 0 : _a.id)
2587
+ ? String(allocation.settlement.id)
2588
+ : null,
2589
+ data: (_c = (_b = allocation.settlement) === null || _b === void 0 ? void 0 : _b.settled_at) === null || _c === void 0 ? void 0 : _c.toISOString(),
1407
2590
  valor: this.fromCents(allocation.allocated_amount_cents),
1408
2591
  juros: this.fromCents(allocation.interest_cents || 0),
1409
2592
  desconto: this.fromCents(allocation.discount_cents || 0),
1410
2593
  multa: this.fromCents(allocation.penalty_cents || 0),
1411
- contaBancariaId: ((_c = allocation.settlement) === null || _c === void 0 ? void 0 : _c.bank_account_id)
2594
+ contaBancariaId: ((_d = allocation.settlement) === null || _d === void 0 ? void 0 : _d.bank_account_id)
1412
2595
  ? String(allocation.settlement.bank_account_id)
1413
2596
  : null,
1414
- metodo: this.mapPaymentMethodToPt((_e = (_d = allocation.settlement) === null || _d === void 0 ? void 0 : _d.payment_method) === null || _e === void 0 ? void 0 : _e.type) || 'transferencia',
2597
+ status: ((_e = allocation.settlement) === null || _e === void 0 ? void 0 : _e.status) || null,
2598
+ metodo: this.mapPaymentMethodToPt((_g = (_f = allocation.settlement) === null || _f === void 0 ? void 0 : _f.payment_method) === null || _g === void 0 ? void 0 : _g.type) || 'transferencia',
1415
2599
  });
1416
2600
  }),
1417
2601
  });
@@ -1462,7 +2646,6 @@ let FinanceService = FinanceService_1 = class FinanceService {
1462
2646
  mapStatusToPt(status) {
1463
2647
  const statusMap = {
1464
2648
  draft: 'rascunho',
1465
- approved: 'aprovado',
1466
2649
  open: 'aberto',
1467
2650
  partial: 'parcial',
1468
2651
  settled: 'liquidado',
@@ -1489,7 +2672,7 @@ let FinanceService = FinanceService_1 = class FinanceService {
1489
2672
  }
1490
2673
  const statusMap = {
1491
2674
  rascunho: 'draft',
1492
- aprovado: 'approved',
2675
+ aprovado: 'open',
1493
2676
  aberto: 'open',
1494
2677
  parcial: 'partial',
1495
2678
  liquidado: 'settled',
@@ -1510,6 +2693,27 @@ let FinanceService = FinanceService_1 = class FinanceService {
1510
2693
  };
1511
2694
  return paymentMethodMap[paymentMethodType] || undefined;
1512
2695
  }
2696
+ mapPaymentMethodFromPt(paymentMethodType) {
2697
+ if (!paymentMethodType) {
2698
+ return undefined;
2699
+ }
2700
+ const paymentMethodMap = {
2701
+ boleto: 'boleto',
2702
+ pix: 'pix',
2703
+ transferencia: 'ted',
2704
+ transferência: 'ted',
2705
+ ted: 'ted',
2706
+ doc: 'doc',
2707
+ cartao: 'card',
2708
+ cartão: 'card',
2709
+ dinheiro: 'cash',
2710
+ cheque: 'other',
2711
+ cash: 'cash',
2712
+ card: 'card',
2713
+ other: 'other',
2714
+ };
2715
+ return paymentMethodMap[(paymentMethodType || '').toLowerCase()];
2716
+ }
1513
2717
  mapAccountTypeToPt(accountType) {
1514
2718
  const accountTypeMap = {
1515
2719
  checking: 'corrente',
@@ -1689,8 +2893,10 @@ let FinanceService = FinanceService_1 = class FinanceService {
1689
2893
  exports.FinanceService = FinanceService;
1690
2894
  exports.FinanceService = FinanceService = FinanceService_1 = __decorate([
1691
2895
  (0, common_1.Injectable)(),
2896
+ __param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.FileService))),
1692
2897
  __metadata("design:paramtypes", [api_prisma_1.PrismaService,
1693
2898
  api_pagination_1.PaginationService,
1694
- core_1.AiService])
2899
+ core_1.AiService,
2900
+ core_1.FileService])
1695
2901
  ], FinanceService);
1696
2902
  //# sourceMappingURL=finance.service.js.map