@hed-hog/finance 0.0.233 → 0.0.236

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 (76) hide show
  1. package/dist/dto/create-cost-center.dto.d.ts +4 -0
  2. package/dist/dto/create-cost-center.dto.d.ts.map +1 -0
  3. package/dist/dto/create-cost-center.dto.js +24 -0
  4. package/dist/dto/create-cost-center.dto.js.map +1 -0
  5. package/dist/dto/create-finance-category.dto.d.ts +6 -0
  6. package/dist/dto/create-finance-category.dto.d.ts.map +1 -0
  7. package/dist/dto/create-finance-category.dto.js +37 -0
  8. package/dist/dto/create-finance-category.dto.js.map +1 -0
  9. package/dist/dto/create-period-close.dto.d.ts +7 -0
  10. package/dist/dto/create-period-close.dto.d.ts.map +1 -0
  11. package/dist/dto/create-period-close.dto.js +44 -0
  12. package/dist/dto/create-period-close.dto.js.map +1 -0
  13. package/dist/dto/move-finance-category.dto.d.ts +5 -0
  14. package/dist/dto/move-finance-category.dto.d.ts.map +1 -0
  15. package/dist/dto/move-finance-category.dto.js +32 -0
  16. package/dist/dto/move-finance-category.dto.js.map +1 -0
  17. package/dist/dto/update-cost-center.dto.d.ts +5 -0
  18. package/dist/dto/update-cost-center.dto.d.ts.map +1 -0
  19. package/dist/dto/update-cost-center.dto.js +32 -0
  20. package/dist/dto/update-cost-center.dto.js.map +1 -0
  21. package/dist/dto/update-finance-category.dto.d.ts +7 -0
  22. package/dist/dto/update-finance-category.dto.d.ts.map +1 -0
  23. package/dist/dto/update-finance-category.dto.js +46 -0
  24. package/dist/dto/update-finance-category.dto.js.map +1 -0
  25. package/dist/finance-audit-logs.controller.d.ts +13 -0
  26. package/dist/finance-audit-logs.controller.d.ts.map +1 -0
  27. package/dist/finance-audit-logs.controller.js +54 -0
  28. package/dist/finance-audit-logs.controller.js.map +1 -0
  29. package/dist/finance-categories.controller.d.ts +42 -0
  30. package/dist/finance-categories.controller.d.ts.map +1 -0
  31. package/dist/finance-categories.controller.js +84 -0
  32. package/dist/finance-categories.controller.js.map +1 -0
  33. package/dist/finance-cost-centers.controller.d.ts +32 -0
  34. package/dist/finance-cost-centers.controller.d.ts.map +1 -0
  35. package/dist/finance-cost-centers.controller.js +72 -0
  36. package/dist/finance-cost-centers.controller.js.map +1 -0
  37. package/dist/finance-installments.controller.d.ts +4 -0
  38. package/dist/finance-installments.controller.d.ts.map +1 -1
  39. package/dist/finance-period-close.controller.d.ts +27 -0
  40. package/dist/finance-period-close.controller.d.ts.map +1 -0
  41. package/dist/finance-period-close.controller.js +64 -0
  42. package/dist/finance-period-close.controller.js.map +1 -0
  43. package/dist/finance.module.d.ts.map +1 -1
  44. package/dist/finance.module.js +8 -0
  45. package/dist/finance.module.js.map +1 -1
  46. package/dist/finance.service.d.ts +119 -0
  47. package/dist/finance.service.d.ts.map +1 -1
  48. package/dist/finance.service.js +733 -36
  49. package/dist/finance.service.js.map +1 -1
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +4 -0
  53. package/dist/index.js.map +1 -1
  54. package/hedhog/data/route.yaml +108 -0
  55. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +76 -6
  56. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +76 -6
  57. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +309 -0
  58. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +642 -0
  59. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +371 -0
  60. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +502 -0
  61. package/hedhog/frontend/messages/en.json +225 -0
  62. package/hedhog/frontend/messages/pt.json +225 -0
  63. package/package.json +5 -5
  64. package/src/dto/create-cost-center.dto.ts +9 -0
  65. package/src/dto/create-finance-category.dto.ts +21 -0
  66. package/src/dto/create-period-close.dto.ts +34 -0
  67. package/src/dto/move-finance-category.dto.ts +18 -0
  68. package/src/dto/update-cost-center.dto.ts +17 -0
  69. package/src/dto/update-finance-category.dto.ts +30 -0
  70. package/src/finance-audit-logs.controller.ts +30 -0
  71. package/src/finance-categories.controller.ts +52 -0
  72. package/src/finance-cost-centers.controller.ts +43 -0
  73. package/src/finance-period-close.controller.ts +34 -0
  74. package/src/finance.module.ts +8 -0
  75. package/src/finance.service.ts +1020 -29
  76. package/src/index.ts +4 -0
@@ -3,18 +3,27 @@ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
4
  import { AiService } from '@hed-hog/core';
5
5
  import {
6
- BadRequestException,
7
- Injectable,
8
- NotFoundException
6
+ BadRequestException,
7
+ Injectable,
8
+ Logger,
9
+ NotFoundException
9
10
  } from '@nestjs/common';
10
11
  import { CreateBankAccountDto } from './dto/create-bank-account.dto';
12
+ import { CreateCostCenterDto } from './dto/create-cost-center.dto';
13
+ import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
11
14
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
15
+ import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
16
+ import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
12
17
  import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
18
+ import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
19
+ import { UpdateFinanceCategoryDto } from './dto/update-finance-category.dto';
13
20
 
14
21
  type TitleType = 'payable' | 'receivable';
15
22
 
16
23
  @Injectable()
17
24
  export class FinanceService {
25
+ private readonly logger = new Logger(FinanceService.name);
26
+
18
27
  constructor(
19
28
  private readonly prisma: PrismaService,
20
29
  private readonly paginationService: PaginationService,
@@ -34,20 +43,25 @@ export class FinanceService {
34
43
  const slug = isPayable
35
44
  ? 'finance-payable-extractor'
36
45
  : 'finance-receivable-extractor';
46
+ const llmAttachmentDebug = await this.ai.debugAttachmentForLlm(file, fileId);
47
+ this.logger.warn(
48
+ `[FINANCE-EXTRACT] start type=${titleType} fileId=${fileId || 'upload'} mode=${llmAttachmentDebug?.mode || 'none'} mime=${llmAttachmentDebug?.mimeType || 'n/a'} textLength=${llmAttachmentDebug?.textLength || 0}`,
49
+ );
37
50
 
38
51
  const schema = isPayable
39
52
  ? '{"documento":"string|null","fornecedor_nome":"string|null","categoria_nome":"string|null","centro_custo_nome":"string|null","competencia":"YYYY-MM|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"metodo":"boleto|pix|transferencia|cartao|dinheiro|cheque|null","descricao":"string|null","confidence":number|null}'
40
53
  : '{"documento":"string|null","cliente_nome":"string|null","categoria_nome":"string|null","centro_custo_nome":"string|null","competencia":"YYYY-MM|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"canal":"boleto|pix|transferencia|cartao|null","descricao":"string|null","confidence":number|null}';
41
54
 
42
55
  const extractionMessage = isPayable
43
- ? 'Extract the payable form fields from the attached file. If uncertain, return null for that field. Response must be JSON only.'
44
- : 'Extract the receivable form fields from the attached file. If uncertain, return null for that field. Response must be JSON only.';
56
+ ? 'Extract the payable form fields from the attached file. IMPORTANT: prioritize Brazilian invoice formats (pt-BR). Monetary values may come as 1.900,00 or 1900.00. Always return numeric value in JSON (example: 1900). Prefer the field explicitly labeled as valor total / total da nota / total a pagar. Never guess; if uncertain, return null. Response must be JSON only.'
57
+ : 'Extract the receivable form fields from the attached file. IMPORTANT: prioritize Brazilian invoice formats (pt-BR). Monetary values may come as 1.900,00 or 1900.00. Always return numeric value in JSON (example: 1900). Prefer the field explicitly labeled as valor total / total da nota / total a receber. Never guess; if uncertain, return null. Response must be JSON only.';
45
58
 
46
59
  await this.ai.createAgent({
47
60
  slug,
48
61
  provider: 'openai',
62
+ model: 'gpt-4o',
49
63
  instructions:
50
- `You are a financial OCR extraction assistant. Extract only ${titleType} title fields from invoice/bill files. Always return valid JSON only, no markdown. Use this schema: ${schema}.`,
64
+ `You are a financial OCR extraction assistant. Extract only ${titleType} title fields from invoice/bill files. Be strict with monetary values in pt-BR format: 1.900,00 means 1900.00. Never confuse punctuation separators. Prefer explicit labels in this order: "valor dos serviços" > "valor total da nota" > "valor líquido". For vencimento, use fields like "vencimento" or "data de vencimento" only; do not use data de emissão as vencimento. If confidence is low, return null. Always return valid JSON only, no markdown. Use this schema: ${schema}.`,
51
65
  });
52
66
 
53
67
  const result = await this.ai.chatWithAgent(
@@ -60,35 +74,415 @@ export class FinanceService {
60
74
  );
61
75
 
62
76
  const parsed = this.parseAiJson(result?.content || '{}');
77
+ this.logger.warn(
78
+ `[FINANCE-EXTRACT] pass1 type=${titleType} hasDocumento=${!!this.toNullableString(parsed?.documento)} hasValor=${this.toNullableNumber(parsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(parsed?.vencimento)}`,
79
+ );
80
+ const mergedParsed = await this.enhanceParsedExtraction(
81
+ parsed,
82
+ slug,
83
+ isPayable,
84
+ llmAttachmentDebug,
85
+ fileId,
86
+ file,
87
+ );
63
88
 
64
- const personName = isPayable ? parsed.fornecedor_nome : parsed.cliente_nome;
89
+ const personName = isPayable
90
+ ? mergedParsed.fornecedor_nome
91
+ : mergedParsed.cliente_nome;
65
92
  const personId = await this.matchPersonId(personName);
66
- const categoriaId = await this.matchCategoryId(parsed.categoria_nome);
67
- const centroCustoId = await this.matchCostCenterId(parsed.centro_custo_nome);
93
+ const categoriaId = await this.matchCategoryId(mergedParsed.categoria_nome);
94
+ const centroCustoId = await this.matchCostCenterId(
95
+ mergedParsed.centro_custo_nome,
96
+ );
97
+
98
+ const metodo = this.normalizePaymentMethod(
99
+ mergedParsed.metodo || mergedParsed.canal,
100
+ );
101
+ const documento = this.toNullableString(mergedParsed.documento);
102
+ const categoriaNome = this.toNullableString(mergedParsed.categoria_nome);
103
+ const centroCustoNome = this.toNullableString(mergedParsed.centro_custo_nome);
104
+ const competencia = this.normalizeMonth(mergedParsed.competencia);
105
+ const vencimento = this.normalizeDate(mergedParsed.vencimento);
106
+ const valor = this.toNullableNumber(mergedParsed.valor);
107
+ const descricao = this.toNullableString(mergedParsed.descricao);
108
+ const confidence = this.resolveExtractionConfidence(
109
+ this.toNullableNumber(mergedParsed.confidence),
110
+ {
111
+ documento,
112
+ personId,
113
+ categoriaId,
114
+ centroCustoId,
115
+ vencimento,
116
+ valor,
117
+ },
118
+ );
119
+ const warnings = this.buildExtractionWarnings(confidence, {
120
+ valor,
121
+ vencimento,
122
+ });
68
123
 
69
- const metodo = this.normalizePaymentMethod(parsed.metodo || parsed.canal);
124
+ this.logger.warn(
125
+ `[FINANCE-EXTRACT] done type=${titleType} confidence=${confidence ?? 'null'} valor=${valor ?? 'null'} vencimento=${vencimento || 'null'} fallback=${mergedParsed?.__fallbackSource || 'none'}`,
126
+ );
70
127
 
71
128
  return {
72
- documento: this.toNullableString(parsed.documento),
129
+ documento,
73
130
  fornecedorId: isPayable ? personId : '',
74
131
  fornecedorNome: isPayable ? this.toNullableString(personName) : null,
75
132
  clienteId: !isPayable ? personId : '',
76
133
  clienteNome: !isPayable ? this.toNullableString(personName) : null,
77
134
  categoriaId,
78
- categoriaNome: this.toNullableString(parsed.categoria_nome),
135
+ categoriaNome,
79
136
  centroCustoId,
80
- centroCustoNome: this.toNullableString(parsed.centro_custo_nome),
81
- competencia: this.normalizeMonth(parsed.competencia),
82
- vencimento: this.normalizeDate(parsed.vencimento),
83
- valor: this.toNullableNumber(parsed.valor),
137
+ centroCustoNome,
138
+ competencia,
139
+ vencimento,
140
+ valor,
84
141
  metodo,
85
142
  canal: metodo,
86
- descricao: this.toNullableString(parsed.descricao),
87
- confidence: this.toNullableNumber(parsed.confidence),
88
- raw: parsed,
143
+ descricao,
144
+ confidence,
145
+ confidenceLevel:
146
+ confidence === null ? null : confidence < 70 ? 'low' : 'high',
147
+ warnings,
148
+ raw: {
149
+ ...mergedParsed,
150
+ __llmAttachmentDebug: llmAttachmentDebug,
151
+ },
152
+ };
153
+ }
154
+
155
+ private async enhanceParsedExtraction(
156
+ parsed: any,
157
+ slug: string,
158
+ isPayable: boolean,
159
+ llmAttachmentDebug?: any,
160
+ fileId?: number,
161
+ file?: MulterFile,
162
+ ) {
163
+ const base = parsed || {};
164
+
165
+ const hasDocumento = !!this.toNullableString(base.documento);
166
+ const hasValor = this.toNullableNumber(base.valor) !== null;
167
+ const hasVencimento = !!this.normalizeDate(base.vencimento);
168
+ const hasPessoa = !!this.toNullableString(
169
+ isPayable ? base.fornecedor_nome : base.cliente_nome,
170
+ );
171
+
172
+ if (hasDocumento || hasValor || hasVencimento || hasPessoa) {
173
+ this.logger.warn('[FINANCE-EXTRACT] pass2 skipped: pass1 already has key fields');
174
+ return base;
175
+ }
176
+
177
+ const fallbackSchema = isPayable
178
+ ? '{"documento":"string|null","fornecedor_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}'
179
+ : '{"documento":"string|null","cliente_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}';
180
+
181
+ const fallbackMessage = isPayable
182
+ ? `Second pass. Extract ONLY documento, fornecedor_nome, vencimento, valor. Prefer "valor dos serviços" then "valor total" then "valor líquido". Return JSON only with this schema: ${fallbackSchema}`
183
+ : `Second pass. Extract ONLY documento, cliente_nome, vencimento, valor. Prefer "valor dos serviços" then "valor total" then "valor líquido". Return JSON only with this schema: ${fallbackSchema}`;
184
+
185
+ const retry = await this.ai.chatWithAgent(
186
+ slug,
187
+ {
188
+ message: fallbackMessage,
189
+ file_id: fileId,
190
+ },
191
+ file,
192
+ );
193
+
194
+ const retryParsed = this.parseAiJson(retry?.content || '{}');
195
+ this.logger.warn(
196
+ `[FINANCE-EXTRACT] pass2 hasDocumento=${!!this.toNullableString(retryParsed?.documento)} hasValor=${this.toNullableNumber(retryParsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(retryParsed?.vencimento)}`,
197
+ );
198
+
199
+ const mergedAfterRetry = {
200
+ ...retryParsed,
201
+ ...base,
202
+ documento: this.toNullableString(base.documento)
203
+ ? base.documento
204
+ : retryParsed.documento,
205
+ valor:
206
+ this.toNullableNumber(base.valor) !== null ? base.valor : retryParsed.valor,
207
+ vencimento: this.normalizeDate(base.vencimento)
208
+ ? base.vencimento
209
+ : retryParsed.vencimento,
210
+ fornecedor_nome: isPayable
211
+ ? this.toNullableString(base.fornecedor_nome)
212
+ ? base.fornecedor_nome
213
+ : retryParsed.fornecedor_nome
214
+ : base.fornecedor_nome,
215
+ cliente_nome: !isPayable
216
+ ? this.toNullableString(base.cliente_nome)
217
+ ? base.cliente_nome
218
+ : retryParsed.cliente_nome
219
+ : base.cliente_nome,
220
+ confidence:
221
+ this.toNullableNumber(base.confidence) !== null
222
+ ? base.confidence
223
+ : retryParsed.confidence,
224
+ };
225
+
226
+ const stillEmpty =
227
+ !this.toNullableString(mergedAfterRetry.documento) &&
228
+ this.toNullableNumber(mergedAfterRetry.valor) === null &&
229
+ !this.normalizeDate(mergedAfterRetry.vencimento) &&
230
+ !this.toNullableString(
231
+ isPayable
232
+ ? mergedAfterRetry.fornecedor_nome
233
+ : mergedAfterRetry.cliente_nome,
234
+ );
235
+
236
+ if (!stillEmpty) {
237
+ this.logger.warn('[FINANCE-EXTRACT] regex fallback skipped: pass2 produced data');
238
+ return mergedAfterRetry;
239
+ }
240
+
241
+ const rawText = await this.ai.extractAttachmentText(file, fileId);
242
+ const regexFallback = this.extractFinancialFieldsFromRawText(rawText, isPayable);
243
+ this.logger.warn(
244
+ `[FINANCE-EXTRACT] regex fallback activated hasText=${!!rawText} hasDocumento=${!!this.toNullableString(regexFallback?.documento)} hasValor=${this.toNullableNumber(regexFallback?.valor) !== null} hasVencimento=${!!this.normalizeDate(regexFallback?.vencimento)}`,
245
+ );
246
+
247
+ const stillEmptyAfterRegex =
248
+ !this.toNullableString(regexFallback.documento) &&
249
+ this.toNullableNumber(regexFallback.valor) === null &&
250
+ !this.normalizeDate(regexFallback.vencimento);
251
+
252
+ if (
253
+ stillEmptyAfterRegex &&
254
+ llmAttachmentDebug?.mode === 'pdf-text' &&
255
+ Number(llmAttachmentDebug?.textLength || 0) === 0
256
+ ) {
257
+ this.logger.warn(
258
+ '[FINANCE-EXTRACT] gemini fallback activated for scanned PDF (no text layer)',
259
+ );
260
+
261
+ const geminiSchema = isPayable
262
+ ? '{"documento":"string|null","fornecedor_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}'
263
+ : '{"documento":"string|null","cliente_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}';
264
+
265
+ const geminiPrompt = isPayable
266
+ ? `Extract documento, fornecedor_nome, vencimento and valor from this invoice PDF. For valor, prefer 'valor dos serviços', then 'valor total', then 'valor líquido'. Return JSON only using schema: ${geminiSchema}`
267
+ : `Extract documento, cliente_nome, vencimento and valor from this invoice PDF. For valor, prefer 'valor dos serviços', then 'valor total', then 'valor líquido'. Return JSON only using schema: ${geminiSchema}`;
268
+
269
+ const geminiModels = [
270
+ 'gemini-2.5-flash',
271
+ 'gemini-2.5-pro',
272
+ 'gemini-2.0-flash',
273
+ 'gemini-2.0-flash-lite',
274
+ 'gemini-1.5-flash',
275
+ 'gemini-1.5-pro',
276
+ 'gemini-1.5-flash-latest',
277
+ 'gemini-1.5-pro-latest',
278
+ ];
279
+
280
+ for (const model of geminiModels) {
281
+ try {
282
+ this.logger.warn(
283
+ `[FINANCE-EXTRACT] gemini fallback trying model=${model}`,
284
+ );
285
+
286
+ const geminiResult = await this.ai.chat(
287
+ {
288
+ provider: 'gemini',
289
+ model,
290
+ message: geminiPrompt,
291
+ file_id: fileId,
292
+ },
293
+ file,
294
+ );
295
+
296
+ const geminiParsed = this.parseAiJson(geminiResult?.content || '{}');
297
+ this.logger.warn(
298
+ `[FINANCE-EXTRACT] gemini fallback result model=${model} hasDocumento=${!!this.toNullableString(geminiParsed?.documento)} hasValor=${this.toNullableNumber(geminiParsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(geminiParsed?.vencimento)}`,
299
+ );
300
+
301
+ return {
302
+ ...geminiParsed,
303
+ ...regexFallback,
304
+ documento: this.toNullableString(geminiParsed?.documento)
305
+ ? geminiParsed.documento
306
+ : regexFallback.documento,
307
+ valor:
308
+ this.toNullableNumber(geminiParsed?.valor) !== null
309
+ ? geminiParsed.valor
310
+ : regexFallback.valor,
311
+ vencimento: this.normalizeDate(geminiParsed?.vencimento)
312
+ ? geminiParsed.vencimento
313
+ : regexFallback.vencimento,
314
+ competencia: this.normalizeMonth(geminiParsed?.competencia)
315
+ ? geminiParsed.competencia
316
+ : regexFallback.competencia,
317
+ fornecedor_nome: isPayable
318
+ ? this.toNullableString(geminiParsed?.fornecedor_nome)
319
+ ? geminiParsed.fornecedor_nome
320
+ : regexFallback.fornecedor_nome
321
+ : null,
322
+ cliente_nome: !isPayable
323
+ ? this.toNullableString(geminiParsed?.cliente_nome)
324
+ ? geminiParsed.cliente_nome
325
+ : regexFallback.cliente_nome
326
+ : null,
327
+ confidence:
328
+ this.toNullableNumber(geminiParsed?.confidence) ?? regexFallback.confidence,
329
+ __fallbackSource: 'gemini-pdf-binary',
330
+ __fallbackModel: model,
331
+ __fallbackHasText: !!rawText,
332
+ };
333
+ } catch (error) {
334
+ const statusCode = (error as any)?.response?.status;
335
+ const message = (error as any)?.message || 'unknown error';
336
+ const apiError =
337
+ (error as any)?.response?.data?.error?.message ||
338
+ JSON.stringify((error as any)?.response?.data || {});
339
+ this.logger.warn(
340
+ `[FINANCE-EXTRACT] gemini fallback failed model=${model} status=${statusCode ?? 'n/a'} message=${message} apiError=${apiError}`,
341
+ );
342
+ }
343
+ }
344
+ }
345
+
346
+ return {
347
+ ...regexFallback,
348
+ ...mergedAfterRetry,
349
+ documento: this.toNullableString(mergedAfterRetry.documento)
350
+ ? mergedAfterRetry.documento
351
+ : regexFallback.documento,
352
+ valor:
353
+ this.toNullableNumber(mergedAfterRetry.valor) !== null
354
+ ? mergedAfterRetry.valor
355
+ : regexFallback.valor,
356
+ vencimento: this.normalizeDate(mergedAfterRetry.vencimento)
357
+ ? mergedAfterRetry.vencimento
358
+ : regexFallback.vencimento,
359
+ competencia: this.normalizeMonth(mergedAfterRetry.competencia)
360
+ ? mergedAfterRetry.competencia
361
+ : regexFallback.competencia,
362
+ fornecedor_nome: isPayable
363
+ ? this.toNullableString(mergedAfterRetry.fornecedor_nome)
364
+ ? mergedAfterRetry.fornecedor_nome
365
+ : regexFallback.fornecedor_nome
366
+ : mergedAfterRetry.fornecedor_nome,
367
+ cliente_nome: !isPayable
368
+ ? this.toNullableString(mergedAfterRetry.cliente_nome)
369
+ ? mergedAfterRetry.cliente_nome
370
+ : regexFallback.cliente_nome
371
+ : mergedAfterRetry.cliente_nome,
372
+ __fallbackSource: 'regex-pdf-text',
373
+ __fallbackHasText: !!rawText,
89
374
  };
90
375
  }
91
376
 
377
+ private extractFinancialFieldsFromRawText(text: string, isPayable: boolean) {
378
+ const source = text || '';
379
+
380
+ const documento =
381
+ this.firstMatch(
382
+ source,
383
+ [
384
+ /\b(?:n[úu]mero\s+da\s+nota|nota\s+fiscal|nfs-?e|nf-?e|nf)\s*[:#-]?\s*([a-z0-9._\/-]{4,})/i,
385
+ ],
386
+ ) || null;
387
+
388
+ const emissao = this.firstMatch(source, [
389
+ /\b(?:data\s+de\s+emiss[aã]o|emiss[aã]o)\s*[:#-]?\s*(\d{2}\/\d{2}\/\d{4})/i,
390
+ ]);
391
+
392
+ const vencimento = this.firstMatch(source, [
393
+ /\b(?:data\s+de\s+vencimento|vencimento)\s*[:#-]?\s*(\d{2}\/\d{2}\/\d{4})/i,
394
+ ]);
395
+
396
+ const valorLabeled = this.firstMatch(source, [
397
+ /\bvalor\s+dos\s+servi[cç]os\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
398
+ /\bvalor\s+total\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
399
+ /\btotal\s+da\s+nota\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
400
+ /\bvalor\s+l[ií]quido\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
401
+ ]);
402
+
403
+ const fallbackAnyMoney = this.firstMatch(source, [
404
+ /(?:r\$\s*)([\d]{1,3}(?:\.[\d]{3})*,\d{2})/i,
405
+ ]);
406
+
407
+ const valor = this.toNullableNumber(valorLabeled || fallbackAnyMoney);
408
+
409
+ const personName = this.firstMatch(source, [
410
+ /\b(?:tomador|cliente|fornecedor|prestador)\b\s*[:#-]?\s*([^\n\r]{3,80})/i,
411
+ ]);
412
+
413
+ return {
414
+ documento,
415
+ fornecedor_nome: isPayable ? this.toNullableString(personName) : null,
416
+ cliente_nome: !isPayable ? this.toNullableString(personName) : null,
417
+ competencia: emissao ? this.normalizeMonth(emissao) : null,
418
+ vencimento: vencimento ? this.normalizeDate(vencimento) : null,
419
+ valor,
420
+ confidence: valor !== null ? 68 : 52,
421
+ };
422
+ }
423
+
424
+ private firstMatch(text: string, patterns: RegExp[]): string {
425
+ for (const pattern of patterns) {
426
+ const match = text.match(pattern);
427
+ if (match?.[1]) {
428
+ return match[1].trim();
429
+ }
430
+ }
431
+
432
+ return '';
433
+ }
434
+
435
+ private resolveExtractionConfidence(
436
+ aiConfidence: number | null,
437
+ data: {
438
+ documento: string | null;
439
+ personId: string;
440
+ categoriaId: string;
441
+ centroCustoId: string;
442
+ vencimento: string;
443
+ valor: number | null;
444
+ },
445
+ ): number | null {
446
+ if (aiConfidence !== null) {
447
+ const normalized = aiConfidence <= 1 ? aiConfidence * 100 : aiConfidence;
448
+ return Math.max(0, Math.min(100, Math.round(normalized)));
449
+ }
450
+
451
+ let score = 35;
452
+ if (data.documento) score += 15;
453
+ if (data.vencimento) score += 10;
454
+ if (data.valor !== null && data.valor > 0) score += 20;
455
+ if (data.personId) score += 10;
456
+ if (data.categoriaId) score += 5;
457
+ if (data.centroCustoId) score += 5;
458
+
459
+ return Math.max(0, Math.min(100, score));
460
+ }
461
+
462
+ private buildExtractionWarnings(
463
+ confidence: number | null,
464
+ data: {
465
+ valor: number | null;
466
+ vencimento: string;
467
+ },
468
+ ): string[] {
469
+ const warnings: string[] = [];
470
+
471
+ if (confidence !== null && confidence < 70) {
472
+ warnings.push('Baixa confiança na extração. Revise os campos antes de salvar.');
473
+ }
474
+
475
+ if (data.valor === null || data.valor <= 0) {
476
+ warnings.push('Valor não identificado com segurança no documento.');
477
+ }
478
+
479
+ if (!data.vencimento) {
480
+ warnings.push('Data de vencimento não identificada com segurança.');
481
+ }
482
+
483
+ return warnings;
484
+ }
485
+
92
486
  private parseAiJson(content: string): any {
93
487
  const trimmed = (content || '').trim();
94
488
  if (!trimmed) {
@@ -263,7 +657,41 @@ export class FinanceService {
263
657
 
264
658
  private toNullableNumber(value: any): number | null {
265
659
  if (value === null || value === undefined || value === '') return null;
266
- const num = Number(value);
660
+ if (typeof value === 'number') {
661
+ return Number.isFinite(value) ? value : null;
662
+ }
663
+
664
+ const raw = String(value).trim();
665
+ if (!raw) return null;
666
+
667
+ const cleaned = raw.replace(/[^\d,.-]/g, '');
668
+ if (!cleaned) return null;
669
+
670
+ const lastComma = cleaned.lastIndexOf(',');
671
+ const lastDot = cleaned.lastIndexOf('.');
672
+
673
+ let normalized = cleaned;
674
+
675
+ if (lastComma >= 0 && lastDot >= 0) {
676
+ if (lastComma > lastDot) {
677
+ normalized = cleaned.replace(/\./g, '').replace(',', '.');
678
+ } else {
679
+ normalized = cleaned.replace(/,/g, '');
680
+ }
681
+ } else if (lastComma >= 0) {
682
+ normalized = cleaned.replace(/\./g, '').replace(',', '.');
683
+ } else {
684
+ const dotParts = cleaned.split('.');
685
+ if (
686
+ dotParts.length > 1 &&
687
+ dotParts[dotParts.length - 1].length === 3 &&
688
+ dotParts.length <= 4
689
+ ) {
690
+ normalized = cleaned.replace(/\./g, '');
691
+ }
692
+ }
693
+
694
+ const num = Number(normalized);
267
695
  return Number.isFinite(num) ? num : null;
268
696
  }
269
697
 
@@ -375,6 +803,221 @@ export class FinanceService {
375
803
  return bankAccounts.map((bankAccount) => this.mapBankAccountToFront(bankAccount));
376
804
  }
377
805
 
806
+ async listCostCenters() {
807
+ const costCenters = await this.prisma.cost_center.findMany({
808
+ orderBy: [{ code: 'asc' }, { name: 'asc' }],
809
+ });
810
+
811
+ return costCenters.map((costCenter) => this.mapCostCenterToFront(costCenter));
812
+ }
813
+
814
+ async listAuditLogs(
815
+ paginationParams,
816
+ filters?: {
817
+ search?: string;
818
+ action?: string;
819
+ entity_table?: string;
820
+ actor_user_id?: string;
821
+ from?: string;
822
+ to?: string;
823
+ },
824
+ ) {
825
+ const actorUserId = filters?.actor_user_id
826
+ ? Number.parseInt(filters.actor_user_id, 10)
827
+ : undefined;
828
+
829
+ const fromDate = filters?.from ? new Date(filters.from) : undefined;
830
+ const toDate = filters?.to ? new Date(filters.to) : undefined;
831
+
832
+ const where: any = {
833
+ ...(filters?.action ? { action: filters.action } : {}),
834
+ ...(filters?.entity_table ? { entity_table: filters.entity_table } : {}),
835
+ ...(Number.isNaN(actorUserId) || !actorUserId
836
+ ? {}
837
+ : { actor_user_id: actorUserId }),
838
+ ...((fromDate || toDate) &&
839
+ !(fromDate && Number.isNaN(fromDate.getTime())) &&
840
+ !(toDate && Number.isNaN(toDate.getTime()))
841
+ ? {
842
+ created_at: {
843
+ ...(fromDate ? { gte: fromDate } : {}),
844
+ ...(toDate ? { lte: toDate } : {}),
845
+ },
846
+ }
847
+ : {}),
848
+ };
849
+
850
+ const search = filters?.search?.trim();
851
+ if (search) {
852
+ where.OR = [
853
+ { action: { contains: search, mode: 'insensitive' } },
854
+ { entity_table: { contains: search, mode: 'insensitive' } },
855
+ { entity_id: { contains: search, mode: 'insensitive' } },
856
+ { summary: { contains: search, mode: 'insensitive' } },
857
+ { ip_address: { contains: search, mode: 'insensitive' } },
858
+ ];
859
+ }
860
+
861
+ const paginated = await this.paginationService.paginatePrismaModel(
862
+ this.prisma.audit_log,
863
+ {
864
+ page: paginationParams?.page,
865
+ pageSize: paginationParams?.pageSize,
866
+ sortField: paginationParams?.sortField || 'created_at',
867
+ sortOrder: paginationParams?.sortOrder || 'desc',
868
+ validSortFields: [
869
+ 'id',
870
+ 'created_at',
871
+ 'action',
872
+ 'entity_table',
873
+ 'entity_id',
874
+ ],
875
+ where,
876
+ include: {
877
+ user: {
878
+ select: {
879
+ id: true,
880
+ name: true,
881
+ },
882
+ },
883
+ },
884
+ },
885
+ );
886
+
887
+ return {
888
+ ...paginated,
889
+ data: (paginated.data || []).map((log) => ({
890
+ id: String(log.id),
891
+ actorUserId: log.actor_user_id ? String(log.actor_user_id) : null,
892
+ actorName: log.user?.name || '-',
893
+ actorEmail: '',
894
+ action: log.action,
895
+ entityTable: log.entity_table,
896
+ entityId: log.entity_id,
897
+ summary: log.summary || '',
898
+ ipAddress: log.ip_address || '',
899
+ beforeData: log.before_data || '',
900
+ afterData: log.after_data || '',
901
+ createdAt: log.created_at?.toISOString?.() || null,
902
+ })),
903
+ };
904
+ }
905
+
906
+ async listFinanceCategories() {
907
+ const categories = await this.prisma.finance_category.findMany({
908
+ orderBy: [{ parent_id: 'asc' }, { updated_at: 'asc' }, { name: 'asc' }],
909
+ });
910
+
911
+ return categories.map((category) => this.mapFinanceCategoryToFront(category));
912
+ }
913
+
914
+ async listPeriodClose(
915
+ paginationParams: PaginationDTO,
916
+ filters?: {
917
+ search?: string;
918
+ status?: string;
919
+ user?: string;
920
+ from?: string;
921
+ to?: string;
922
+ },
923
+ ) {
924
+ const fromDate = filters?.from ? new Date(filters.from) : undefined;
925
+ const toDate = filters?.to ? new Date(filters.to) : undefined;
926
+ const userNumericId = filters?.user
927
+ ? Number.parseInt(filters.user, 10)
928
+ : undefined;
929
+
930
+ const andConditions: any[] = [];
931
+
932
+ if (filters?.status && filters.status !== 'all') {
933
+ andConditions.push({ status: filters.status });
934
+ }
935
+
936
+ if (
937
+ (fromDate && !Number.isNaN(fromDate.getTime())) ||
938
+ (toDate && !Number.isNaN(toDate.getTime()))
939
+ ) {
940
+ andConditions.push({
941
+ period_start: {
942
+ ...(fromDate && !Number.isNaN(fromDate.getTime())
943
+ ? { gte: fromDate }
944
+ : {}),
945
+ },
946
+ });
947
+
948
+ andConditions.push({
949
+ period_end: {
950
+ ...(toDate && !Number.isNaN(toDate.getTime()) ? { lte: toDate } : {}),
951
+ },
952
+ });
953
+ }
954
+
955
+ const search = filters?.search?.trim();
956
+ if (search) {
957
+ andConditions.push({
958
+ OR: [
959
+ { notes: { contains: search, mode: 'insensitive' } },
960
+ { user: { is: { name: { contains: search, mode: 'insensitive' } } } },
961
+ {
962
+ user: { is: { email: { contains: search, mode: 'insensitive' } } },
963
+ },
964
+ ],
965
+ });
966
+ }
967
+
968
+ if (filters?.user?.trim()) {
969
+ andConditions.push({
970
+ OR: [
971
+ ...(Number.isNaN(userNumericId) || !userNumericId
972
+ ? []
973
+ : [{ closed_by_user_id: userNumericId }]),
974
+ { user: { is: { name: { contains: filters.user, mode: 'insensitive' } } } },
975
+ {
976
+ user: {
977
+ is: { email: { contains: filters.user, mode: 'insensitive' } },
978
+ },
979
+ },
980
+ ],
981
+ });
982
+ }
983
+
984
+ const where = andConditions.length > 0 ? { AND: andConditions } : {};
985
+
986
+ const paginated = await this.paginationService.paginatePrismaModel(
987
+ this.prisma.period_close,
988
+ {
989
+ page: paginationParams?.page,
990
+ pageSize: paginationParams?.pageSize,
991
+ sortField: paginationParams?.sortField || 'period_start',
992
+ sortOrder: paginationParams?.sortOrder || 'desc',
993
+ validSortFields: [
994
+ 'id',
995
+ 'period_start',
996
+ 'period_end',
997
+ 'status',
998
+ 'closed_at',
999
+ 'created_at',
1000
+ ],
1001
+ where,
1002
+ include: {
1003
+ user: {
1004
+ select: {
1005
+ id: true,
1006
+ name: true,
1007
+ },
1008
+ },
1009
+ },
1010
+ },
1011
+ );
1012
+
1013
+ return {
1014
+ ...paginated,
1015
+ data: (paginated.data || []).map((period) =>
1016
+ this.mapPeriodCloseToFront(period),
1017
+ ),
1018
+ };
1019
+ }
1020
+
378
1021
  async listBankStatements(bankAccountId?: number) {
379
1022
  const statements = await this.prisma.bank_statement_line.findMany({
380
1023
  where: {
@@ -457,6 +1100,89 @@ export class FinanceService {
457
1100
  return this.mapBankAccountToFront(account);
458
1101
  }
459
1102
 
1103
+ async createCostCenter(data: CreateCostCenterDto) {
1104
+ const code = await this.generateCostCenterCode(data.name);
1105
+
1106
+ const created = await this.prisma.cost_center.create({
1107
+ data: {
1108
+ code,
1109
+ name: data.name.trim(),
1110
+ status: 'active',
1111
+ },
1112
+ });
1113
+
1114
+ return this.mapCostCenterToFront(created);
1115
+ }
1116
+
1117
+ async createFinanceCategory(data: CreateFinanceCategoryDto) {
1118
+ if (data.parent_id) {
1119
+ await this.ensureFinanceCategoryExists(data.parent_id);
1120
+ }
1121
+
1122
+ const created = await this.prisma.finance_category.create({
1123
+ data: {
1124
+ code: await this.generateFinanceCategoryCode(),
1125
+ name: data.name.trim(),
1126
+ kind: this.mapCategoryKindFromPt(data.kind),
1127
+ status: 'active',
1128
+ parent_id: data.parent_id || null,
1129
+ },
1130
+ });
1131
+
1132
+ return this.mapFinanceCategoryToFront(created);
1133
+ }
1134
+
1135
+ async createPeriodClose(data: CreatePeriodCloseDto, userId?: number) {
1136
+ const periodStart = new Date(data.period_start);
1137
+ const periodEnd = new Date(data.period_end);
1138
+
1139
+ if (
1140
+ Number.isNaN(periodStart.getTime()) ||
1141
+ Number.isNaN(periodEnd.getTime())
1142
+ ) {
1143
+ throw new BadRequestException('Invalid period dates');
1144
+ }
1145
+
1146
+ if (periodStart > periodEnd) {
1147
+ throw new BadRequestException('period_start must be before period_end');
1148
+ }
1149
+
1150
+ const overlapped = await this.prisma.period_close.findFirst({
1151
+ where: {
1152
+ period_start: { lte: periodEnd },
1153
+ period_end: { gte: periodStart },
1154
+ },
1155
+ select: { id: true },
1156
+ });
1157
+
1158
+ if (overlapped) {
1159
+ throw new BadRequestException('There is already a period in this range');
1160
+ }
1161
+
1162
+ const status = data.status || 'closed';
1163
+
1164
+ const created = await this.prisma.period_close.create({
1165
+ data: {
1166
+ period_start: periodStart,
1167
+ period_end: periodEnd,
1168
+ status,
1169
+ closed_at: status === 'closed' ? new Date() : null,
1170
+ closed_by_user_id: status === 'closed' ? userId || null : null,
1171
+ notes: data.notes?.trim() || null,
1172
+ },
1173
+ include: {
1174
+ user: {
1175
+ select: {
1176
+ id: true,
1177
+ name: true,
1178
+ },
1179
+ },
1180
+ },
1181
+ });
1182
+
1183
+ return this.mapPeriodCloseToFront(created);
1184
+ }
1185
+
460
1186
  async updateBankAccount(id: number, data: UpdateBankAccountDto) {
461
1187
  const current = await this.prisma.bank_account.findUnique({
462
1188
  where: { id },
@@ -490,6 +1216,104 @@ export class FinanceService {
490
1216
  return this.mapBankAccountToFront(updated);
491
1217
  }
492
1218
 
1219
+ async updateCostCenter(id: number, data: UpdateCostCenterDto) {
1220
+ const current = await this.prisma.cost_center.findUnique({
1221
+ where: { id },
1222
+ select: { id: true },
1223
+ });
1224
+
1225
+ if (!current) {
1226
+ throw new NotFoundException('Cost center not found');
1227
+ }
1228
+
1229
+ const updated = await this.prisma.cost_center.update({
1230
+ where: { id },
1231
+ data: {
1232
+ name: data.name?.trim(),
1233
+ status: data.status,
1234
+ },
1235
+ });
1236
+
1237
+ return this.mapCostCenterToFront(updated);
1238
+ }
1239
+
1240
+ async updateFinanceCategory(id: number, data: UpdateFinanceCategoryDto) {
1241
+ const current = await this.prisma.finance_category.findUnique({
1242
+ where: { id },
1243
+ select: { id: true },
1244
+ });
1245
+
1246
+ if (!current) {
1247
+ throw new NotFoundException('Finance category not found');
1248
+ }
1249
+
1250
+ if (data.parent_id) {
1251
+ await this.ensureFinanceCategoryExists(data.parent_id);
1252
+ await this.ensureNoFinanceCategoryCycle(id, data.parent_id);
1253
+ }
1254
+
1255
+ const updated = await this.prisma.finance_category.update({
1256
+ where: { id },
1257
+ data: {
1258
+ name: data.name?.trim(),
1259
+ kind: data.kind ? this.mapCategoryKindFromPt(data.kind) : undefined,
1260
+ parent_id: data.parent_id,
1261
+ status: data.status,
1262
+ },
1263
+ });
1264
+
1265
+ return this.mapFinanceCategoryToFront(updated);
1266
+ }
1267
+
1268
+ async moveFinanceCategory(id: number, data: MoveFinanceCategoryDto) {
1269
+ const current = await this.prisma.finance_category.findUnique({
1270
+ where: { id },
1271
+ select: { id: true, parent_id: true },
1272
+ });
1273
+
1274
+ if (!current) {
1275
+ throw new NotFoundException('Finance category not found');
1276
+ }
1277
+
1278
+ const targetParentId = data.parent_id || null;
1279
+
1280
+ if (targetParentId) {
1281
+ await this.ensureFinanceCategoryExists(targetParentId);
1282
+ await this.ensureNoFinanceCategoryCycle(id, targetParentId);
1283
+ }
1284
+
1285
+ await this.prisma.finance_category.update({
1286
+ where: { id },
1287
+ data: { parent_id: targetParentId },
1288
+ });
1289
+
1290
+ const siblings = await this.prisma.finance_category.findMany({
1291
+ where: { parent_id: targetParentId },
1292
+ select: { id: true },
1293
+ orderBy: [{ updated_at: 'asc' }, { name: 'asc' }],
1294
+ });
1295
+
1296
+ const siblingIds = siblings.map((item) => item.id).filter((item) => item !== id);
1297
+ const targetPosition = Math.max(
1298
+ 0,
1299
+ Math.min(data.position ?? siblingIds.length, siblingIds.length),
1300
+ );
1301
+ siblingIds.splice(targetPosition, 0, id);
1302
+
1303
+ await this.prisma.$transaction(
1304
+ siblingIds.map((categoryId) =>
1305
+ this.prisma.finance_category.update({
1306
+ where: { id: categoryId },
1307
+ data: {
1308
+ parent_id: targetParentId,
1309
+ },
1310
+ }),
1311
+ ),
1312
+ );
1313
+
1314
+ return { success: true };
1315
+ }
1316
+
493
1317
  async deleteBankAccount(id: number) {
494
1318
  const current = await this.prisma.bank_account.findUnique({
495
1319
  where: { id },
@@ -510,6 +1334,46 @@ export class FinanceService {
510
1334
  return { success: true };
511
1335
  }
512
1336
 
1337
+ async deleteCostCenter(id: number) {
1338
+ const current = await this.prisma.cost_center.findUnique({
1339
+ where: { id },
1340
+ select: { id: true },
1341
+ });
1342
+
1343
+ if (!current) {
1344
+ throw new NotFoundException('Cost center not found');
1345
+ }
1346
+
1347
+ await this.prisma.cost_center.update({
1348
+ where: { id },
1349
+ data: {
1350
+ status: 'inactive',
1351
+ },
1352
+ });
1353
+
1354
+ return { success: true };
1355
+ }
1356
+
1357
+ async deleteFinanceCategory(id: number) {
1358
+ const current = await this.prisma.finance_category.findUnique({
1359
+ where: { id },
1360
+ select: { id: true },
1361
+ });
1362
+
1363
+ if (!current) {
1364
+ throw new NotFoundException('Finance category not found');
1365
+ }
1366
+
1367
+ await this.prisma.finance_category.update({
1368
+ where: { id },
1369
+ data: {
1370
+ status: 'inactive',
1371
+ },
1372
+ });
1373
+
1374
+ return { success: true };
1375
+ }
1376
+
513
1377
  private async listTitles(
514
1378
  titleType: TitleType,
515
1379
  paginationParams: PaginationDTO,
@@ -674,14 +1538,6 @@ export class FinanceService {
674
1538
 
675
1539
  private async loadPeople() {
676
1540
  const people = await this.prisma.person.findMany({
677
- include: {
678
- document: {
679
- select: {
680
- value: true,
681
- },
682
- take: 1,
683
- },
684
- },
685
1541
  orderBy: { name: 'asc' },
686
1542
  });
687
1543
 
@@ -689,7 +1545,7 @@ export class FinanceService {
689
1545
  id: String(person.id),
690
1546
  nome: person.name,
691
1547
  tipo: 'ambos',
692
- documento: person.document[0]?.value || '',
1548
+ documento: '',
693
1549
  }));
694
1550
  }
695
1551
 
@@ -972,6 +1828,76 @@ export class FinanceService {
972
1828
  };
973
1829
  }
974
1830
 
1831
+ private mapCostCenterToFront(costCenter: any) {
1832
+ return {
1833
+ id: String(costCenter.id),
1834
+ codigo: costCenter.code,
1835
+ nome: costCenter.name,
1836
+ status: costCenter.status,
1837
+ ativo: costCenter.status === 'active',
1838
+ };
1839
+ }
1840
+
1841
+ private mapCategoryKindToPt(kind?: string | null) {
1842
+ const kindMap = {
1843
+ revenue: 'receita',
1844
+ expense: 'despesa',
1845
+ transfer: 'transferencia',
1846
+ adjustment: 'ajuste',
1847
+ other: 'outro',
1848
+ };
1849
+
1850
+ return kindMap[kind] || 'outro';
1851
+ }
1852
+
1853
+ private mapCategoryKindFromPt(kind?: string | null) {
1854
+ const normalized = (kind || '').toLowerCase();
1855
+ const kindMap = {
1856
+ receita: 'revenue',
1857
+ revenue: 'revenue',
1858
+ despesa: 'expense',
1859
+ expense: 'expense',
1860
+ transferencia: 'transfer',
1861
+ transferência: 'transfer',
1862
+ transfer: 'transfer',
1863
+ ajuste: 'adjustment',
1864
+ adjustment: 'adjustment',
1865
+ outro: 'other',
1866
+ other: 'other',
1867
+ };
1868
+
1869
+ return kindMap[normalized] || 'other';
1870
+ }
1871
+
1872
+ private mapFinanceCategoryToFront(category: any) {
1873
+ return {
1874
+ id: String(category.id),
1875
+ codigo: category.code,
1876
+ nome: category.name,
1877
+ parentId: category.parent_id ? String(category.parent_id) : null,
1878
+ natureza: this.mapCategoryKindToPt(category.kind),
1879
+ status: category.status,
1880
+ ativo: category.status === 'active',
1881
+ };
1882
+ }
1883
+
1884
+ private mapPeriodCloseToFront(period: any) {
1885
+ return {
1886
+ id: String(period.id),
1887
+ periodStart: period.period_start?.toISOString?.() || null,
1888
+ periodEnd: period.period_end?.toISOString?.() || null,
1889
+ status: period.status,
1890
+ closedAt: period.closed_at?.toISOString?.() || null,
1891
+ closedByUserId: period.closed_by_user_id
1892
+ ? String(period.closed_by_user_id)
1893
+ : null,
1894
+ closedByName: period.user?.name || '-',
1895
+ closedByEmail: '',
1896
+ notes: period.notes || '',
1897
+ createdAt: period.created_at?.toISOString?.() || null,
1898
+ };
1899
+ }
1900
+
975
1901
  private mapStatementStatusToPt(status?: string | null) {
976
1902
  const statusMap = {
977
1903
  pending: 'pendente',
@@ -997,6 +1923,71 @@ export class FinanceService {
997
1923
  return `${bankPrefix || 'ACC'}-${accountSuffix}`;
998
1924
  }
999
1925
 
1926
+ private async generateCostCenterCode(name?: string) {
1927
+ const sanitizedName = (name || 'CENTRO')
1928
+ .trim()
1929
+ .replace(/\s+/g, '-')
1930
+ .replace(/[^A-Za-z0-9-]/g, '')
1931
+ .toUpperCase();
1932
+
1933
+ const basePrefix = sanitizedName.slice(0, 8) || 'CENTRO';
1934
+
1935
+ const lastCostCenter = await this.prisma.cost_center.findFirst({
1936
+ orderBy: { id: 'desc' },
1937
+ select: { id: true },
1938
+ });
1939
+
1940
+ const suffix = String((lastCostCenter?.id || 0) + 1).padStart(4, '0');
1941
+
1942
+ return `${basePrefix}-${suffix}`;
1943
+ }
1944
+
1945
+ private async generateFinanceCategoryCode() {
1946
+ const lastCategory = await this.prisma.finance_category.findFirst({
1947
+ orderBy: { id: 'desc' },
1948
+ select: { id: true },
1949
+ });
1950
+
1951
+ const suffix = String((lastCategory?.id || 0) + 1).padStart(4, '0');
1952
+
1953
+ return `CAT-${suffix}`;
1954
+ }
1955
+
1956
+ private async ensureFinanceCategoryExists(id: number) {
1957
+ const category = await this.prisma.finance_category.findUnique({
1958
+ where: { id },
1959
+ select: { id: true },
1960
+ });
1961
+
1962
+ if (!category) {
1963
+ throw new NotFoundException('Finance category not found');
1964
+ }
1965
+ }
1966
+
1967
+ private async ensureNoFinanceCategoryCycle(
1968
+ categoryId: number,
1969
+ targetParentId: number,
1970
+ ) {
1971
+ if (categoryId === targetParentId) {
1972
+ throw new BadRequestException('Category cannot be parent of itself');
1973
+ }
1974
+
1975
+ let currentParentId: number | null = targetParentId;
1976
+
1977
+ while (currentParentId) {
1978
+ if (currentParentId === categoryId) {
1979
+ throw new BadRequestException('Invalid parent category hierarchy');
1980
+ }
1981
+
1982
+ const parent = await this.prisma.finance_category.findUnique({
1983
+ where: { id: currentParentId },
1984
+ select: { parent_id: true },
1985
+ });
1986
+
1987
+ currentParentId = parent?.parent_id || null;
1988
+ }
1989
+ }
1990
+
1000
1991
  private toCents(value: number) {
1001
1992
  return Math.round(value * 100);
1002
1993
  }