@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.
- package/README.md +1 -22
- package/dist/dto/reject-title.dto.d.ts +4 -0
- package/dist/dto/reject-title.dto.d.ts.map +1 -0
- package/dist/dto/reject-title.dto.js +22 -0
- package/dist/dto/reject-title.dto.js.map +1 -0
- package/dist/dto/reverse-settlement.dto.d.ts +4 -0
- package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
- package/dist/dto/reverse-settlement.dto.js +22 -0
- package/dist/dto/reverse-settlement.dto.js.map +1 -0
- package/dist/dto/settle-installment.dto.d.ts +12 -0
- package/dist/dto/settle-installment.dto.d.ts.map +1 -0
- package/dist/dto/settle-installment.dto.js +71 -0
- package/dist/dto/settle-installment.dto.js.map +1 -0
- package/dist/finance-data.controller.d.ts +13 -5
- package/dist/finance-data.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.d.ts +380 -12
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.js +144 -0
- package/dist/finance-installments.controller.js.map +1 -1
- package/dist/finance-statements.controller.d.ts +8 -0
- package/dist/finance-statements.controller.d.ts.map +1 -1
- package/dist/finance-statements.controller.js +40 -0
- package/dist/finance-statements.controller.js.map +1 -1
- package/dist/finance.module.d.ts.map +1 -1
- package/dist/finance.module.js +1 -0
- package/dist/finance.module.js.map +1 -1
- package/dist/finance.service.d.ts +435 -19
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +1286 -80
- package/dist/finance.service.js.map +1 -1
- package/hedhog/data/route.yaml +117 -0
- package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
- package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +434 -7
- package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1172 -25
- package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
- package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +430 -14
- package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +212 -60
- package/hedhog/frontend/messages/en.json +1 -0
- package/hedhog/frontend/messages/pt.json +1 -0
- package/hedhog/query/0_constraints.sql +2 -0
- package/hedhog/query/constraints.sql +86 -0
- package/hedhog/table/bank_account.yaml +0 -8
- package/hedhog/table/financial_title.yaml +1 -9
- package/hedhog/table/settlement.yaml +0 -8
- package/package.json +6 -6
- package/src/dto/reject-title.dto.ts +7 -0
- package/src/dto/reverse-settlement.dto.ts +7 -0
- package/src/dto/settle-installment.dto.ts +55 -0
- package/src/finance-installments.controller.ts +172 -10
- package/src/finance-statements.controller.ts +61 -2
- package/src/finance.module.ts +2 -1
- package/src/finance.service.ts +1887 -106
- package/hedhog/table/branch.yaml +0 -18
package/src/finance.service.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { getLocaleText } from '@hed-hog/api-locale';
|
|
2
2
|
import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
|
|
3
3
|
import { PrismaService } from '@hed-hog/api-prisma';
|
|
4
|
-
import { AiService } from '@hed-hog/core';
|
|
4
|
+
import { AiService, FileService } from '@hed-hog/core';
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
BadRequestException,
|
|
7
|
+
forwardRef,
|
|
8
|
+
Inject,
|
|
9
|
+
Injectable,
|
|
10
|
+
Logger,
|
|
11
|
+
NotFoundException,
|
|
10
12
|
} from '@nestjs/common';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
11
15
|
import { CreateBankAccountDto } from './dto/create-bank-account.dto';
|
|
12
16
|
import { CreateCostCenterDto } from './dto/create-cost-center.dto';
|
|
13
17
|
import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
|
|
@@ -15,11 +19,27 @@ import { CreateFinanceTagDto } from './dto/create-finance-tag.dto';
|
|
|
15
19
|
import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
|
|
16
20
|
import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
|
|
17
21
|
import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
|
|
22
|
+
import { RejectTitleDto } from './dto/reject-title.dto';
|
|
23
|
+
import { ReverseSettlementDto } from './dto/reverse-settlement.dto';
|
|
24
|
+
import { SettleInstallmentDto } from './dto/settle-installment.dto';
|
|
18
25
|
import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
|
|
19
26
|
import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
|
|
20
27
|
import { UpdateFinanceCategoryDto } from './dto/update-finance-category.dto';
|
|
21
28
|
|
|
22
29
|
type TitleType = 'payable' | 'receivable';
|
|
30
|
+
type InstallmentStatus =
|
|
31
|
+
| 'open'
|
|
32
|
+
| 'partial'
|
|
33
|
+
| 'settled'
|
|
34
|
+
| 'canceled'
|
|
35
|
+
| 'overdue';
|
|
36
|
+
type TitleStatus =
|
|
37
|
+
| 'draft'
|
|
38
|
+
| 'open'
|
|
39
|
+
| 'partial'
|
|
40
|
+
| 'settled'
|
|
41
|
+
| 'canceled'
|
|
42
|
+
| 'overdue';
|
|
23
43
|
|
|
24
44
|
@Injectable()
|
|
25
45
|
export class FinanceService {
|
|
@@ -29,6 +49,8 @@ export class FinanceService {
|
|
|
29
49
|
private readonly prisma: PrismaService,
|
|
30
50
|
private readonly paginationService: PaginationService,
|
|
31
51
|
private readonly ai: AiService,
|
|
52
|
+
@Inject(forwardRef(() => FileService))
|
|
53
|
+
private readonly fileService: FileService,
|
|
32
54
|
) {}
|
|
33
55
|
|
|
34
56
|
async getAgentExtractInfoFromFile(
|
|
@@ -756,6 +778,18 @@ export class FinanceService {
|
|
|
756
778
|
this.logger.error('Failed to load finance audit logs', auditLogsResult.reason);
|
|
757
779
|
}
|
|
758
780
|
|
|
781
|
+
const aprovacoesPendentes = payables
|
|
782
|
+
.filter((title: any) => title.status === 'rascunho')
|
|
783
|
+
.map((title: any) => ({
|
|
784
|
+
id: String(title.id),
|
|
785
|
+
tituloId: String(title.id),
|
|
786
|
+
solicitante: '-',
|
|
787
|
+
valor: Number(title.valorTotal || 0),
|
|
788
|
+
politica: 'Aprovação financeira',
|
|
789
|
+
urgencia: 'media',
|
|
790
|
+
dataSolicitacao: title.criadoEm,
|
|
791
|
+
}));
|
|
792
|
+
|
|
759
793
|
return {
|
|
760
794
|
kpis: {
|
|
761
795
|
saldoCaixa: 0,
|
|
@@ -773,7 +807,7 @@ export class FinanceService {
|
|
|
773
807
|
pessoas: people,
|
|
774
808
|
categorias: categories,
|
|
775
809
|
centrosCusto: costCenters,
|
|
776
|
-
aprovacoesPendentes
|
|
810
|
+
aprovacoesPendentes,
|
|
777
811
|
agingInadimplencia: [],
|
|
778
812
|
cenarios: [],
|
|
779
813
|
transferencias: [],
|
|
@@ -819,6 +853,63 @@ export class FinanceService {
|
|
|
819
853
|
return this.createTitle(data, 'payable', locale, userId);
|
|
820
854
|
}
|
|
821
855
|
|
|
856
|
+
async updateAccountsPayableTitle(
|
|
857
|
+
id: number,
|
|
858
|
+
data: CreateFinancialTitleDto,
|
|
859
|
+
locale: string,
|
|
860
|
+
userId?: number,
|
|
861
|
+
) {
|
|
862
|
+
return this.updateDraftTitle(id, data, 'payable', locale, userId);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async approveAccountsPayableTitle(id: number, locale: string, userId?: number) {
|
|
866
|
+
return this.approveTitle(id, 'payable', locale, userId);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async rejectAccountsPayableTitle(
|
|
870
|
+
id: number,
|
|
871
|
+
data: RejectTitleDto,
|
|
872
|
+
locale: string,
|
|
873
|
+
userId?: number,
|
|
874
|
+
) {
|
|
875
|
+
return this.rejectTitle(id, data, 'payable', locale, userId);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async cancelAccountsPayableTitle(
|
|
879
|
+
id: number,
|
|
880
|
+
data: RejectTitleDto,
|
|
881
|
+
locale: string,
|
|
882
|
+
userId?: number,
|
|
883
|
+
) {
|
|
884
|
+
return this.cancelTitle(id, data, 'payable', locale, userId);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async settleAccountsPayableInstallment(
|
|
888
|
+
id: number,
|
|
889
|
+
data: SettleInstallmentDto,
|
|
890
|
+
locale: string,
|
|
891
|
+
userId?: number,
|
|
892
|
+
) {
|
|
893
|
+
return this.settleTitleInstallment(id, data, 'payable', locale, userId);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async reverseAccountsPayableSettlement(
|
|
897
|
+
id: number,
|
|
898
|
+
settlementId: number,
|
|
899
|
+
data: ReverseSettlementDto,
|
|
900
|
+
locale: string,
|
|
901
|
+
userId?: number,
|
|
902
|
+
) {
|
|
903
|
+
return this.reverseTitleSettlement(
|
|
904
|
+
id,
|
|
905
|
+
settlementId,
|
|
906
|
+
data,
|
|
907
|
+
'payable',
|
|
908
|
+
locale,
|
|
909
|
+
userId,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
822
913
|
async createAccountsReceivableTitle(
|
|
823
914
|
data: CreateFinancialTitleDto,
|
|
824
915
|
locale: string,
|
|
@@ -827,6 +918,58 @@ export class FinanceService {
|
|
|
827
918
|
return this.createTitle(data, 'receivable', locale, userId);
|
|
828
919
|
}
|
|
829
920
|
|
|
921
|
+
async updateAccountsReceivableTitle(
|
|
922
|
+
id: number,
|
|
923
|
+
data: CreateFinancialTitleDto,
|
|
924
|
+
locale: string,
|
|
925
|
+
userId?: number,
|
|
926
|
+
) {
|
|
927
|
+
return this.updateDraftTitle(id, data, 'receivable', locale, userId);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async approveAccountsReceivableTitle(
|
|
931
|
+
id: number,
|
|
932
|
+
locale: string,
|
|
933
|
+
userId?: number,
|
|
934
|
+
) {
|
|
935
|
+
return this.approveTitle(id, 'receivable', locale, userId);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async cancelAccountsReceivableTitle(
|
|
939
|
+
id: number,
|
|
940
|
+
data: RejectTitleDto,
|
|
941
|
+
locale: string,
|
|
942
|
+
userId?: number,
|
|
943
|
+
) {
|
|
944
|
+
return this.cancelTitle(id, data, 'receivable', locale, userId);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async settleAccountsReceivableInstallment(
|
|
948
|
+
id: number,
|
|
949
|
+
data: SettleInstallmentDto,
|
|
950
|
+
locale: string,
|
|
951
|
+
userId?: number,
|
|
952
|
+
) {
|
|
953
|
+
return this.settleTitleInstallment(id, data, 'receivable', locale, userId);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async reverseAccountsReceivableSettlement(
|
|
957
|
+
id: number,
|
|
958
|
+
settlementId: number,
|
|
959
|
+
data: ReverseSettlementDto,
|
|
960
|
+
locale: string,
|
|
961
|
+
userId?: number,
|
|
962
|
+
) {
|
|
963
|
+
return this.reverseTitleSettlement(
|
|
964
|
+
id,
|
|
965
|
+
settlementId,
|
|
966
|
+
data,
|
|
967
|
+
'receivable',
|
|
968
|
+
locale,
|
|
969
|
+
userId,
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
830
973
|
async createTag(data: CreateFinanceTagDto) {
|
|
831
974
|
const slug = this.normalizeTagSlug(data.name);
|
|
832
975
|
|
|
@@ -1146,6 +1289,497 @@ export class FinanceService {
|
|
|
1146
1289
|
}));
|
|
1147
1290
|
}
|
|
1148
1291
|
|
|
1292
|
+
async exportBankStatementsCsv(bankAccountId: number) {
|
|
1293
|
+
const statements = await this.listBankStatements(bankAccountId);
|
|
1294
|
+
|
|
1295
|
+
const headers = [
|
|
1296
|
+
'id',
|
|
1297
|
+
'data',
|
|
1298
|
+
'descricao',
|
|
1299
|
+
'valor',
|
|
1300
|
+
'tipo',
|
|
1301
|
+
'status_conciliacao',
|
|
1302
|
+
'conta_bancaria_id',
|
|
1303
|
+
];
|
|
1304
|
+
|
|
1305
|
+
const lines = statements.map((statement) => {
|
|
1306
|
+
const columns = [
|
|
1307
|
+
statement.id,
|
|
1308
|
+
statement.data,
|
|
1309
|
+
statement.descricao,
|
|
1310
|
+
String(statement.valor),
|
|
1311
|
+
statement.tipo,
|
|
1312
|
+
statement.statusConciliacao,
|
|
1313
|
+
statement.contaBancariaId,
|
|
1314
|
+
];
|
|
1315
|
+
|
|
1316
|
+
return columns.map((column) => this.escapeCsvCell(column)).join(';');
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
const csv = ['\uFEFF' + headers.join(';'), ...lines].join('\n');
|
|
1320
|
+
const fileName = `extrato-bancario-${bankAccountId}-${new Date()
|
|
1321
|
+
.toISOString()
|
|
1322
|
+
.slice(0, 10)}.csv`;
|
|
1323
|
+
|
|
1324
|
+
return {
|
|
1325
|
+
csv,
|
|
1326
|
+
fileName,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async importBankStatements(
|
|
1331
|
+
bankAccountId: number,
|
|
1332
|
+
file: MulterFile,
|
|
1333
|
+
locale: string,
|
|
1334
|
+
userId?: number,
|
|
1335
|
+
) {
|
|
1336
|
+
if (!file) {
|
|
1337
|
+
throw new BadRequestException('File is required');
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const bankAccount = await this.prisma.bank_account.findUnique({
|
|
1341
|
+
where: { id: bankAccountId },
|
|
1342
|
+
select: { id: true },
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
if (!bankAccount) {
|
|
1346
|
+
throw new NotFoundException(
|
|
1347
|
+
getLocaleText('itemNotFound', locale, 'Bank account not found').replace(
|
|
1348
|
+
'{{item}}',
|
|
1349
|
+
'Bank account',
|
|
1350
|
+
),
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const sourceType = this.detectStatementSourceType(file);
|
|
1355
|
+
const uploadedFile = await this.fileService.upload('finance/statements', file);
|
|
1356
|
+
const rawContent = await this.getUploadedFileText(file);
|
|
1357
|
+
const parsedEntries =
|
|
1358
|
+
sourceType === 'ofx'
|
|
1359
|
+
? this.parseOfxStatements(rawContent)
|
|
1360
|
+
: this.parseCsvStatements(rawContent);
|
|
1361
|
+
|
|
1362
|
+
if (parsedEntries.length === 0) {
|
|
1363
|
+
throw new BadRequestException(
|
|
1364
|
+
'No valid statement rows were found in the uploaded file',
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const normalizedEntries = parsedEntries
|
|
1369
|
+
.filter((entry) => entry.postedDate && Number.isFinite(entry.amount))
|
|
1370
|
+
.map((entry, index) => {
|
|
1371
|
+
const postedDate = new Date(entry.postedDate);
|
|
1372
|
+
|
|
1373
|
+
if (Number.isNaN(postedDate.getTime())) {
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const amountCents = this.toCents(entry.amount);
|
|
1378
|
+
|
|
1379
|
+
if (!Number.isFinite(amountCents) || amountCents === 0) {
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const dedupeBase = [
|
|
1384
|
+
bankAccountId,
|
|
1385
|
+
postedDate.toISOString().slice(0, 10),
|
|
1386
|
+
amountCents,
|
|
1387
|
+
entry.externalId || '',
|
|
1388
|
+
entry.description || '',
|
|
1389
|
+
index,
|
|
1390
|
+
].join('|');
|
|
1391
|
+
|
|
1392
|
+
return {
|
|
1393
|
+
postedDate,
|
|
1394
|
+
amountCents,
|
|
1395
|
+
externalId: entry.externalId || null,
|
|
1396
|
+
description: (entry.description || 'Lançamento importado').slice(
|
|
1397
|
+
0,
|
|
1398
|
+
1000,
|
|
1399
|
+
),
|
|
1400
|
+
dedupeKey: this.createShortHash(`statement-line:${dedupeBase}`, 64),
|
|
1401
|
+
};
|
|
1402
|
+
})
|
|
1403
|
+
.filter(Boolean) as Array<{
|
|
1404
|
+
postedDate: Date;
|
|
1405
|
+
amountCents: number;
|
|
1406
|
+
externalId: string | null;
|
|
1407
|
+
description: string;
|
|
1408
|
+
dedupeKey: string;
|
|
1409
|
+
}>;
|
|
1410
|
+
|
|
1411
|
+
if (normalizedEntries.length === 0) {
|
|
1412
|
+
throw new BadRequestException('No valid financial entries were parsed');
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const statementFingerprint = this.createShortHash(
|
|
1416
|
+
[
|
|
1417
|
+
bankAccountId,
|
|
1418
|
+
sourceType,
|
|
1419
|
+
uploadedFile.id,
|
|
1420
|
+
file.originalname,
|
|
1421
|
+
file.size,
|
|
1422
|
+
normalizedEntries.length,
|
|
1423
|
+
].join('|'),
|
|
1424
|
+
24,
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1427
|
+
const statement = await this.prisma.bank_statement.create({
|
|
1428
|
+
data: {
|
|
1429
|
+
bank_account_id: bankAccountId,
|
|
1430
|
+
source_type: sourceType,
|
|
1431
|
+
idempotency_key: `import-${uploadedFile.id}-${statementFingerprint}`,
|
|
1432
|
+
period_start: normalizedEntries
|
|
1433
|
+
.map((entry) => entry.postedDate)
|
|
1434
|
+
.sort((a, b) => a.getTime() - b.getTime())[0],
|
|
1435
|
+
period_end: normalizedEntries
|
|
1436
|
+
.map((entry) => entry.postedDate)
|
|
1437
|
+
.sort((a, b) => b.getTime() - a.getTime())[0],
|
|
1438
|
+
imported_at: new Date(),
|
|
1439
|
+
imported_by_user_id: userId || null,
|
|
1440
|
+
},
|
|
1441
|
+
select: { id: true },
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
await this.prisma.bank_statement_line.createMany({
|
|
1445
|
+
data: normalizedEntries.map((entry) => ({
|
|
1446
|
+
bank_statement_id: statement.id,
|
|
1447
|
+
bank_account_id: bankAccountId,
|
|
1448
|
+
external_id: entry.externalId,
|
|
1449
|
+
posted_date: entry.postedDate,
|
|
1450
|
+
amount_cents: entry.amountCents,
|
|
1451
|
+
description: entry.description,
|
|
1452
|
+
status: 'imported',
|
|
1453
|
+
dedupe_key: entry.dedupeKey,
|
|
1454
|
+
})),
|
|
1455
|
+
skipDuplicates: true,
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
return {
|
|
1459
|
+
statementId: String(statement.id),
|
|
1460
|
+
fileId: String(uploadedFile.id),
|
|
1461
|
+
importedRows: normalizedEntries.length,
|
|
1462
|
+
sourceType,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
private detectStatementSourceType(file: MulterFile): 'csv' | 'ofx' {
|
|
1467
|
+
const filename = (file.originalname || '').toLowerCase();
|
|
1468
|
+
const mimetype = (file.mimetype || '').toLowerCase();
|
|
1469
|
+
|
|
1470
|
+
if (
|
|
1471
|
+
filename.endsWith('.ofx') ||
|
|
1472
|
+
mimetype.includes('ofx') ||
|
|
1473
|
+
mimetype.includes('x-ofx')
|
|
1474
|
+
) {
|
|
1475
|
+
return 'ofx';
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (
|
|
1479
|
+
filename.endsWith('.csv') ||
|
|
1480
|
+
mimetype.includes('csv') ||
|
|
1481
|
+
mimetype.includes('comma-separated-values')
|
|
1482
|
+
) {
|
|
1483
|
+
return 'csv';
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
throw new BadRequestException('Only CSV and OFX files are supported');
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private async getUploadedFileText(file: MulterFile): Promise<string> {
|
|
1490
|
+
let buffer = file.buffer;
|
|
1491
|
+
|
|
1492
|
+
if (!buffer && file.path) {
|
|
1493
|
+
buffer = await readFile(file.path);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (!buffer || buffer.length === 0) {
|
|
1497
|
+
throw new BadRequestException('Uploaded file is empty');
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const utf8 = buffer.toString('utf8');
|
|
1501
|
+
if (utf8.includes('�')) {
|
|
1502
|
+
return buffer.toString('latin1');
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
return utf8;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
private parseCsvStatements(content: string) {
|
|
1509
|
+
const normalizedContent = content.replace(/^\uFEFF/, '').trim();
|
|
1510
|
+
|
|
1511
|
+
if (!normalizedContent) {
|
|
1512
|
+
return [];
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const rows = normalizedContent
|
|
1516
|
+
.split(/\r?\n/)
|
|
1517
|
+
.map((line) => line.trim())
|
|
1518
|
+
.filter((line) => !!line);
|
|
1519
|
+
|
|
1520
|
+
if (rows.length === 0) {
|
|
1521
|
+
return [];
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const delimiter = this.detectCsvDelimiter(rows[0]);
|
|
1525
|
+
const headerCells = this.parseCsvLine(rows[0], delimiter);
|
|
1526
|
+
const normalizedHeaders = headerCells.map((cell) =>
|
|
1527
|
+
this.normalizeCsvHeader(cell),
|
|
1528
|
+
);
|
|
1529
|
+
|
|
1530
|
+
const headerIndexes = {
|
|
1531
|
+
date: this.findFirstHeaderIndex(normalizedHeaders, [
|
|
1532
|
+
'data',
|
|
1533
|
+
'dt',
|
|
1534
|
+
'posteddate',
|
|
1535
|
+
'lancamento',
|
|
1536
|
+
'movimento',
|
|
1537
|
+
]),
|
|
1538
|
+
description: this.findFirstHeaderIndex(normalizedHeaders, [
|
|
1539
|
+
'descricao',
|
|
1540
|
+
'historico',
|
|
1541
|
+
'memo',
|
|
1542
|
+
'complemento',
|
|
1543
|
+
]),
|
|
1544
|
+
amount: this.findFirstHeaderIndex(normalizedHeaders, [
|
|
1545
|
+
'valor',
|
|
1546
|
+
'amount',
|
|
1547
|
+
'montante',
|
|
1548
|
+
]),
|
|
1549
|
+
debit: this.findFirstHeaderIndex(normalizedHeaders, ['debito', 'debit']),
|
|
1550
|
+
credit: this.findFirstHeaderIndex(normalizedHeaders, [
|
|
1551
|
+
'credito',
|
|
1552
|
+
'credit',
|
|
1553
|
+
]),
|
|
1554
|
+
externalId: this.findFirstHeaderIndex(normalizedHeaders, [
|
|
1555
|
+
'id',
|
|
1556
|
+
'documento',
|
|
1557
|
+
'numero',
|
|
1558
|
+
'fitid',
|
|
1559
|
+
]),
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
const hasHeader =
|
|
1563
|
+
headerIndexes.date >= 0 ||
|
|
1564
|
+
headerIndexes.description >= 0 ||
|
|
1565
|
+
headerIndexes.amount >= 0 ||
|
|
1566
|
+
headerIndexes.debit >= 0 ||
|
|
1567
|
+
headerIndexes.credit >= 0;
|
|
1568
|
+
|
|
1569
|
+
const dataRows = hasHeader ? rows.slice(1) : rows;
|
|
1570
|
+
|
|
1571
|
+
return dataRows
|
|
1572
|
+
.map((line) => this.parseCsvLine(line, delimiter))
|
|
1573
|
+
.map((columns) => {
|
|
1574
|
+
const dateRaw =
|
|
1575
|
+
(headerIndexes.date >= 0
|
|
1576
|
+
? columns[headerIndexes.date]
|
|
1577
|
+
: columns[0]) || '';
|
|
1578
|
+
const descriptionRaw =
|
|
1579
|
+
(headerIndexes.description >= 0
|
|
1580
|
+
? columns[headerIndexes.description]
|
|
1581
|
+
: columns[1]) || '';
|
|
1582
|
+
const amountRaw =
|
|
1583
|
+
headerIndexes.amount >= 0 ? columns[headerIndexes.amount] : '';
|
|
1584
|
+
const debitRaw =
|
|
1585
|
+
headerIndexes.debit >= 0 ? columns[headerIndexes.debit] : '';
|
|
1586
|
+
const creditRaw =
|
|
1587
|
+
headerIndexes.credit >= 0 ? columns[headerIndexes.credit] : '';
|
|
1588
|
+
const fallbackAmountRaw = columns[2] || '';
|
|
1589
|
+
const externalIdRaw =
|
|
1590
|
+
headerIndexes.externalId >= 0 ? columns[headerIndexes.externalId] : '';
|
|
1591
|
+
|
|
1592
|
+
const postedDate = this.parseStatementDate(dateRaw);
|
|
1593
|
+
if (!postedDate) {
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
const amount = this.resolveCsvAmount(
|
|
1598
|
+
amountRaw || fallbackAmountRaw,
|
|
1599
|
+
debitRaw,
|
|
1600
|
+
creditRaw,
|
|
1601
|
+
);
|
|
1602
|
+
|
|
1603
|
+
if (amount === null) {
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
return {
|
|
1608
|
+
postedDate,
|
|
1609
|
+
amount,
|
|
1610
|
+
description: descriptionRaw?.trim() || 'Movimentação importada',
|
|
1611
|
+
externalId: externalIdRaw?.trim() || null,
|
|
1612
|
+
};
|
|
1613
|
+
})
|
|
1614
|
+
.filter(Boolean) as Array<{
|
|
1615
|
+
postedDate: string;
|
|
1616
|
+
amount: number;
|
|
1617
|
+
description: string;
|
|
1618
|
+
externalId: string | null;
|
|
1619
|
+
}>;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private parseOfxStatements(content: string) {
|
|
1623
|
+
const normalized = content.replace(/\r/g, '');
|
|
1624
|
+
const segments = normalized
|
|
1625
|
+
.split(/<STMTTRN>/i)
|
|
1626
|
+
.slice(1)
|
|
1627
|
+
.map((segment) => segment.split(/<\/STMTTRN>/i)[0]);
|
|
1628
|
+
|
|
1629
|
+
return segments
|
|
1630
|
+
.map((segment) => {
|
|
1631
|
+
const dateRaw = this.extractOfxTag(segment, 'DTPOSTED');
|
|
1632
|
+
const amountRaw = this.extractOfxTag(segment, 'TRNAMT');
|
|
1633
|
+
const fitId = this.extractOfxTag(segment, 'FITID');
|
|
1634
|
+
const memo = this.extractOfxTag(segment, 'MEMO');
|
|
1635
|
+
const name = this.extractOfxTag(segment, 'NAME');
|
|
1636
|
+
const trnType = this.extractOfxTag(segment, 'TRNTYPE')?.toLowerCase();
|
|
1637
|
+
|
|
1638
|
+
const postedDate = this.parseStatementDate(dateRaw);
|
|
1639
|
+
const amount = this.toNullableNumber(amountRaw);
|
|
1640
|
+
|
|
1641
|
+
if (!postedDate || amount === null) {
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
let normalizedAmount = amount;
|
|
1646
|
+
if (
|
|
1647
|
+
['debit', 'payment', 'withdrawal'].includes(trnType || '') &&
|
|
1648
|
+
normalizedAmount > 0
|
|
1649
|
+
) {
|
|
1650
|
+
normalizedAmount *= -1;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const description = [memo, name].filter(Boolean).join(' - ').trim();
|
|
1654
|
+
|
|
1655
|
+
return {
|
|
1656
|
+
postedDate,
|
|
1657
|
+
amount: normalizedAmount,
|
|
1658
|
+
description: description || 'Movimentação OFX',
|
|
1659
|
+
externalId: fitId || null,
|
|
1660
|
+
};
|
|
1661
|
+
})
|
|
1662
|
+
.filter(Boolean) as Array<{
|
|
1663
|
+
postedDate: string;
|
|
1664
|
+
amount: number;
|
|
1665
|
+
description: string;
|
|
1666
|
+
externalId: string | null;
|
|
1667
|
+
}>;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
private detectCsvDelimiter(line: string) {
|
|
1671
|
+
const delimiters = [';', ',', '\t'];
|
|
1672
|
+
const counts = delimiters.map((delimiter) => ({
|
|
1673
|
+
delimiter,
|
|
1674
|
+
count: (line.match(new RegExp(`\\${delimiter}`, 'g')) || []).length,
|
|
1675
|
+
}));
|
|
1676
|
+
|
|
1677
|
+
return counts.sort((a, b) => b.count - a.count)[0]?.delimiter || ';';
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
private parseCsvLine(line: string, delimiter: string) {
|
|
1681
|
+
const result: string[] = [];
|
|
1682
|
+
let current = '';
|
|
1683
|
+
let inQuotes = false;
|
|
1684
|
+
|
|
1685
|
+
for (let i = 0; i < line.length; i++) {
|
|
1686
|
+
const char = line[i];
|
|
1687
|
+
const next = line[i + 1];
|
|
1688
|
+
|
|
1689
|
+
if (char === '"') {
|
|
1690
|
+
if (inQuotes && next === '"') {
|
|
1691
|
+
current += '"';
|
|
1692
|
+
i++;
|
|
1693
|
+
} else {
|
|
1694
|
+
inQuotes = !inQuotes;
|
|
1695
|
+
}
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
if (char === delimiter && !inQuotes) {
|
|
1700
|
+
result.push(current.trim());
|
|
1701
|
+
current = '';
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
current += char;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
result.push(current.trim());
|
|
1709
|
+
return result;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
private normalizeCsvHeader(value: string) {
|
|
1713
|
+
return String(value || '')
|
|
1714
|
+
.normalize('NFD')
|
|
1715
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
1716
|
+
.toLowerCase()
|
|
1717
|
+
.replace(/[^a-z0-9]/g, '');
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
private findFirstHeaderIndex(headers: string[], candidates: string[]) {
|
|
1721
|
+
return headers.findIndex((header) =>
|
|
1722
|
+
candidates.some((candidate) => header.includes(candidate)),
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
private parseStatementDate(value?: string) {
|
|
1727
|
+
const raw = String(value || '').trim();
|
|
1728
|
+
if (!raw) {
|
|
1729
|
+
return '';
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const onlyDateDigits = raw.match(/^(\d{8})/);
|
|
1733
|
+
if (onlyDateDigits?.[1]) {
|
|
1734
|
+
const v = onlyDateDigits[1];
|
|
1735
|
+
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const normalized = this.normalizeDate(raw);
|
|
1739
|
+
if (normalized) {
|
|
1740
|
+
return normalized;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
return '';
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
private resolveCsvAmount(
|
|
1747
|
+
amountRaw?: string,
|
|
1748
|
+
debitRaw?: string,
|
|
1749
|
+
creditRaw?: string,
|
|
1750
|
+
): number | null {
|
|
1751
|
+
const amount = this.toNullableNumber(amountRaw);
|
|
1752
|
+
const debit = this.toNullableNumber(debitRaw);
|
|
1753
|
+
const credit = this.toNullableNumber(creditRaw);
|
|
1754
|
+
|
|
1755
|
+
if (credit !== null || debit !== null) {
|
|
1756
|
+
return (credit || 0) - (debit || 0);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
if (amount === null) {
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
return amount;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
private extractOfxTag(segment: string, tag: string) {
|
|
1767
|
+
const regex = new RegExp(`<${tag}>([^\n\r<]*)`, 'i');
|
|
1768
|
+
const match = segment.match(regex);
|
|
1769
|
+
return match?.[1]?.trim() || '';
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
private createShortHash(input: string, length = 40) {
|
|
1773
|
+
return createHash('sha256').update(input).digest('hex').slice(0, length);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
private escapeCsvCell(value: string | number | null | undefined) {
|
|
1777
|
+
const normalizedValue = String(value ?? '');
|
|
1778
|
+
const escapedValue = normalizedValue.replace(/"/g, '""');
|
|
1779
|
+
|
|
1780
|
+
return `"${escapedValue}"`;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1149
1783
|
async createBankAccount(data: CreateBankAccountDto, userId?: number) {
|
|
1150
1784
|
const accountType = this.mapAccountTypeFromPt(data.type);
|
|
1151
1785
|
|
|
@@ -1529,137 +2163,1066 @@ export class FinanceService {
|
|
|
1529
2163
|
locale: string,
|
|
1530
2164
|
userId?: number,
|
|
1531
2165
|
) {
|
|
1532
|
-
const
|
|
1533
|
-
where: { id: data.person_id },
|
|
1534
|
-
select: { id: true },
|
|
1535
|
-
});
|
|
2166
|
+
const installments = this.normalizeAndValidateInstallments(data, locale);
|
|
1536
2167
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
);
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
if (data.finance_category_id) {
|
|
1544
|
-
const category = await this.prisma.finance_category.findUnique({
|
|
1545
|
-
where: { id: data.finance_category_id },
|
|
2168
|
+
const createdTitleId = await this.prisma.$transaction(async (tx) => {
|
|
2169
|
+
const person = await tx.person.findUnique({
|
|
2170
|
+
where: { id: data.person_id },
|
|
1546
2171
|
select: { id: true },
|
|
1547
2172
|
});
|
|
1548
2173
|
|
|
1549
|
-
if (!
|
|
2174
|
+
if (!person) {
|
|
1550
2175
|
throw new BadRequestException(
|
|
1551
|
-
getLocaleText('
|
|
2176
|
+
getLocaleText('personNotFound', locale, 'Person not found'),
|
|
1552
2177
|
);
|
|
1553
2178
|
}
|
|
1554
|
-
}
|
|
1555
2179
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
2180
|
+
if (data.finance_category_id) {
|
|
2181
|
+
const category = await tx.finance_category.findUnique({
|
|
2182
|
+
where: { id: data.finance_category_id },
|
|
2183
|
+
select: { id: true },
|
|
2184
|
+
});
|
|
1561
2185
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
2186
|
+
if (!category) {
|
|
2187
|
+
throw new BadRequestException(
|
|
2188
|
+
getLocaleText('categoryNotFound', locale, 'Category not found'),
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
1566
2191
|
}
|
|
1567
|
-
}
|
|
1568
2192
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
installment_number: 1,
|
|
1575
|
-
due_date: data.due_date,
|
|
1576
|
-
amount: data.total_amount,
|
|
1577
|
-
},
|
|
1578
|
-
];
|
|
2193
|
+
if (data.cost_center_id) {
|
|
2194
|
+
const costCenter = await tx.cost_center.findUnique({
|
|
2195
|
+
where: { id: data.cost_center_id },
|
|
2196
|
+
select: { id: true },
|
|
2197
|
+
});
|
|
1579
2198
|
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
description: data.description,
|
|
1587
|
-
competence_date: data.competence_date
|
|
1588
|
-
? new Date(data.competence_date)
|
|
1589
|
-
: null,
|
|
1590
|
-
issue_date: data.issue_date ? new Date(data.issue_date) : null,
|
|
1591
|
-
total_amount_cents: this.toCents(data.total_amount),
|
|
1592
|
-
finance_category_id: data.finance_category_id,
|
|
1593
|
-
created_by_user_id: userId,
|
|
1594
|
-
},
|
|
1595
|
-
});
|
|
2199
|
+
if (!costCenter) {
|
|
2200
|
+
throw new BadRequestException(
|
|
2201
|
+
getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
1596
2205
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
2206
|
+
const attachmentFileIds = [
|
|
2207
|
+
...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
|
|
2208
|
+
];
|
|
1600
2209
|
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2210
|
+
if (attachmentFileIds.length > 0) {
|
|
2211
|
+
const existingFiles = await tx.file.findMany({
|
|
2212
|
+
where: {
|
|
2213
|
+
id: { in: attachmentFileIds },
|
|
2214
|
+
},
|
|
2215
|
+
select: {
|
|
2216
|
+
id: true,
|
|
2217
|
+
},
|
|
2218
|
+
});
|
|
1610
2219
|
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2220
|
+
const existingFileIds = new Set(existingFiles.map((file) => file.id));
|
|
2221
|
+
const invalidFileIds = attachmentFileIds.filter(
|
|
2222
|
+
(fileId) => !existingFileIds.has(fileId),
|
|
2223
|
+
);
|
|
1615
2224
|
|
|
1616
|
-
|
|
2225
|
+
if (invalidFileIds.length > 0) {
|
|
2226
|
+
throw new BadRequestException(
|
|
2227
|
+
`Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
await this.assertDateNotInClosedPeriod(
|
|
2233
|
+
tx,
|
|
2234
|
+
data.competence_date
|
|
2235
|
+
? new Date(data.competence_date)
|
|
2236
|
+
: new Date(installments[0].due_date),
|
|
2237
|
+
'create title',
|
|
2238
|
+
);
|
|
2239
|
+
|
|
2240
|
+
const title = await tx.financial_title.create({
|
|
2241
|
+
data: {
|
|
2242
|
+
person_id: data.person_id,
|
|
2243
|
+
title_type: titleType,
|
|
2244
|
+
status: 'draft',
|
|
2245
|
+
document_number: data.document_number,
|
|
2246
|
+
description: data.description,
|
|
2247
|
+
competence_date: data.competence_date
|
|
2248
|
+
? new Date(data.competence_date)
|
|
2249
|
+
: null,
|
|
2250
|
+
issue_date: data.issue_date ? new Date(data.issue_date) : null,
|
|
2251
|
+
total_amount_cents: this.toCents(data.total_amount),
|
|
2252
|
+
finance_category_id: data.finance_category_id,
|
|
2253
|
+
created_by_user_id: userId,
|
|
2254
|
+
},
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
if (attachmentFileIds.length > 0) {
|
|
2258
|
+
await tx.financial_title_attachment.createMany({
|
|
2259
|
+
data: attachmentFileIds.map((fileId) => ({
|
|
2260
|
+
title_id: title.id,
|
|
2261
|
+
file_id: fileId,
|
|
2262
|
+
uploaded_by_user_id: userId,
|
|
2263
|
+
})),
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
for (let index = 0; index < installments.length; index++) {
|
|
2268
|
+
const installment = installments[index];
|
|
2269
|
+
const amountCents = installment.amount_cents;
|
|
2270
|
+
|
|
2271
|
+
const createdInstallment = await tx.financial_installment.create({
|
|
2272
|
+
data: {
|
|
2273
|
+
title_id: title.id,
|
|
2274
|
+
installment_number: installment.installment_number,
|
|
2275
|
+
competence_date: data.competence_date
|
|
2276
|
+
? new Date(data.competence_date)
|
|
2277
|
+
: new Date(installment.due_date),
|
|
2278
|
+
due_date: new Date(installment.due_date),
|
|
2279
|
+
amount_cents: amountCents,
|
|
2280
|
+
open_amount_cents: amountCents,
|
|
2281
|
+
status: this.resolveInstallmentStatus(
|
|
2282
|
+
amountCents,
|
|
2283
|
+
amountCents,
|
|
2284
|
+
new Date(installment.due_date),
|
|
2285
|
+
),
|
|
2286
|
+
notes: data.description,
|
|
2287
|
+
},
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
if (data.cost_center_id) {
|
|
2291
|
+
await tx.installment_allocation.create({
|
|
2292
|
+
data: {
|
|
2293
|
+
installment_id: createdInstallment.id,
|
|
2294
|
+
cost_center_id: data.cost_center_id,
|
|
2295
|
+
allocated_amount_cents: amountCents,
|
|
2296
|
+
},
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
await this.createAuditLog(tx, {
|
|
2302
|
+
action: 'CREATE_TITLE',
|
|
2303
|
+
entityTable: 'financial_title',
|
|
2304
|
+
entityId: String(title.id),
|
|
2305
|
+
actorUserId: userId,
|
|
2306
|
+
summary: `Created ${titleType} title ${title.id} in draft`,
|
|
2307
|
+
afterData: JSON.stringify({
|
|
2308
|
+
status: 'draft',
|
|
2309
|
+
total_amount_cents: this.toCents(data.total_amount),
|
|
2310
|
+
}),
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
return title.id;
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
const createdTitle = await this.getTitleById(createdTitleId, titleType, locale);
|
|
2317
|
+
return this.mapTitleToFront(createdTitle, data.payment_channel);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
private async updateDraftTitle(
|
|
2321
|
+
titleId: number,
|
|
2322
|
+
data: CreateFinancialTitleDto,
|
|
2323
|
+
titleType: TitleType,
|
|
2324
|
+
locale: string,
|
|
2325
|
+
userId?: number,
|
|
2326
|
+
) {
|
|
2327
|
+
const installments = this.normalizeAndValidateInstallments(data, locale);
|
|
2328
|
+
|
|
2329
|
+
const updatedTitle = await this.prisma.$transaction(async (tx) => {
|
|
2330
|
+
const title = await tx.financial_title.findFirst({
|
|
2331
|
+
where: {
|
|
2332
|
+
id: titleId,
|
|
2333
|
+
title_type: titleType,
|
|
2334
|
+
},
|
|
2335
|
+
include: {
|
|
2336
|
+
financial_installment: {
|
|
2337
|
+
select: {
|
|
2338
|
+
id: true,
|
|
2339
|
+
},
|
|
2340
|
+
},
|
|
2341
|
+
},
|
|
2342
|
+
});
|
|
2343
|
+
|
|
2344
|
+
if (!title) {
|
|
2345
|
+
throw new NotFoundException(
|
|
2346
|
+
getLocaleText(
|
|
2347
|
+
'itemNotFound',
|
|
2348
|
+
locale,
|
|
2349
|
+
`Financial title with ID ${titleId} not found`,
|
|
2350
|
+
).replace('{{item}}', 'Financial title'),
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
if (title.status !== 'draft') {
|
|
2355
|
+
throw new BadRequestException('Only draft titles can be edited');
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const person = await tx.person.findUnique({
|
|
2359
|
+
where: { id: data.person_id },
|
|
2360
|
+
select: { id: true },
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
if (!person) {
|
|
2364
|
+
throw new BadRequestException(
|
|
2365
|
+
getLocaleText('personNotFound', locale, 'Person not found'),
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
if (data.finance_category_id) {
|
|
2370
|
+
const category = await tx.finance_category.findUnique({
|
|
2371
|
+
where: { id: data.finance_category_id },
|
|
2372
|
+
select: { id: true },
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
if (!category) {
|
|
2376
|
+
throw new BadRequestException(
|
|
2377
|
+
getLocaleText('categoryNotFound', locale, 'Category not found'),
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
if (data.cost_center_id) {
|
|
2383
|
+
const costCenter = await tx.cost_center.findUnique({
|
|
2384
|
+
where: { id: data.cost_center_id },
|
|
2385
|
+
select: { id: true },
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
if (!costCenter) {
|
|
2389
|
+
throw new BadRequestException(
|
|
2390
|
+
getLocaleText('costCenterNotFound', locale, 'Cost center not found'),
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const attachmentFileIds = [
|
|
2396
|
+
...new Set((data.attachment_file_ids || []).filter((fileId) => fileId > 0)),
|
|
2397
|
+
];
|
|
2398
|
+
|
|
2399
|
+
if (attachmentFileIds.length > 0) {
|
|
2400
|
+
const existingFiles = await tx.file.findMany({
|
|
2401
|
+
where: {
|
|
2402
|
+
id: { in: attachmentFileIds },
|
|
2403
|
+
},
|
|
2404
|
+
select: {
|
|
2405
|
+
id: true,
|
|
2406
|
+
},
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
const existingFileIds = new Set(existingFiles.map((file) => file.id));
|
|
2410
|
+
const invalidFileIds = attachmentFileIds.filter(
|
|
2411
|
+
(fileId) => !existingFileIds.has(fileId),
|
|
2412
|
+
);
|
|
2413
|
+
|
|
2414
|
+
if (invalidFileIds.length > 0) {
|
|
2415
|
+
throw new BadRequestException(
|
|
2416
|
+
`Invalid attachment file IDs: ${invalidFileIds.join(', ')}`,
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
await this.assertDateNotInClosedPeriod(
|
|
2422
|
+
tx,
|
|
2423
|
+
data.competence_date
|
|
2424
|
+
? new Date(data.competence_date)
|
|
2425
|
+
: new Date(installments[0].due_date),
|
|
2426
|
+
'update title',
|
|
2427
|
+
);
|
|
2428
|
+
|
|
2429
|
+
const installmentIds = title.financial_installment.map((item) => item.id);
|
|
2430
|
+
|
|
2431
|
+
if (installmentIds.length > 0) {
|
|
2432
|
+
const hasSettlements = await tx.settlement_allocation.findFirst({
|
|
2433
|
+
where: {
|
|
2434
|
+
installment_id: {
|
|
2435
|
+
in: installmentIds,
|
|
2436
|
+
},
|
|
2437
|
+
},
|
|
2438
|
+
select: {
|
|
2439
|
+
id: true,
|
|
2440
|
+
},
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
if (hasSettlements) {
|
|
2444
|
+
throw new BadRequestException(
|
|
2445
|
+
'Cannot edit title with settled installments',
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
await tx.financial_title.update({
|
|
2451
|
+
where: { id: title.id },
|
|
2452
|
+
data: {
|
|
2453
|
+
person_id: data.person_id,
|
|
2454
|
+
document_number: data.document_number,
|
|
2455
|
+
description: data.description,
|
|
2456
|
+
competence_date: data.competence_date
|
|
2457
|
+
? new Date(data.competence_date)
|
|
2458
|
+
: null,
|
|
2459
|
+
issue_date: data.issue_date ? new Date(data.issue_date) : null,
|
|
2460
|
+
total_amount_cents: this.toCents(data.total_amount),
|
|
2461
|
+
finance_category_id: data.finance_category_id,
|
|
2462
|
+
},
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
if (installmentIds.length > 0) {
|
|
2466
|
+
await tx.installment_allocation.deleteMany({
|
|
2467
|
+
where: {
|
|
2468
|
+
installment_id: {
|
|
2469
|
+
in: installmentIds,
|
|
2470
|
+
},
|
|
2471
|
+
},
|
|
2472
|
+
});
|
|
2473
|
+
|
|
2474
|
+
await tx.financial_installment_tag.deleteMany({
|
|
2475
|
+
where: {
|
|
2476
|
+
installment_id: {
|
|
2477
|
+
in: installmentIds,
|
|
2478
|
+
},
|
|
2479
|
+
},
|
|
2480
|
+
});
|
|
2481
|
+
|
|
2482
|
+
await tx.financial_installment.deleteMany({
|
|
2483
|
+
where: {
|
|
2484
|
+
title_id: title.id,
|
|
2485
|
+
},
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
for (const installment of installments) {
|
|
2490
|
+
const amountCents = installment.amount_cents;
|
|
2491
|
+
|
|
2492
|
+
const createdInstallment = await tx.financial_installment.create({
|
|
2493
|
+
data: {
|
|
2494
|
+
title_id: title.id,
|
|
2495
|
+
installment_number: installment.installment_number,
|
|
2496
|
+
competence_date: data.competence_date
|
|
2497
|
+
? new Date(data.competence_date)
|
|
2498
|
+
: new Date(installment.due_date),
|
|
2499
|
+
due_date: new Date(installment.due_date),
|
|
2500
|
+
amount_cents: amountCents,
|
|
2501
|
+
open_amount_cents: amountCents,
|
|
2502
|
+
status: this.resolveInstallmentStatus(
|
|
2503
|
+
amountCents,
|
|
2504
|
+
amountCents,
|
|
2505
|
+
new Date(installment.due_date),
|
|
2506
|
+
),
|
|
2507
|
+
notes: data.description,
|
|
2508
|
+
},
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
if (data.cost_center_id) {
|
|
2512
|
+
await tx.installment_allocation.create({
|
|
2513
|
+
data: {
|
|
2514
|
+
installment_id: createdInstallment.id,
|
|
2515
|
+
cost_center_id: data.cost_center_id,
|
|
2516
|
+
allocated_amount_cents: amountCents,
|
|
2517
|
+
},
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
if (data.attachment_file_ids) {
|
|
2523
|
+
await tx.financial_title_attachment.deleteMany({
|
|
2524
|
+
where: {
|
|
2525
|
+
title_id: title.id,
|
|
2526
|
+
},
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
if (attachmentFileIds.length > 0) {
|
|
2530
|
+
await tx.financial_title_attachment.createMany({
|
|
2531
|
+
data: attachmentFileIds.map((fileId) => ({
|
|
2532
|
+
title_id: title.id,
|
|
2533
|
+
file_id: fileId,
|
|
2534
|
+
uploaded_by_user_id: userId,
|
|
2535
|
+
})),
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
await this.createAuditLog(tx, {
|
|
2541
|
+
action: 'UPDATE_TITLE',
|
|
2542
|
+
entityTable: 'financial_title',
|
|
2543
|
+
entityId: String(title.id),
|
|
2544
|
+
actorUserId: userId,
|
|
2545
|
+
summary: `Updated draft ${titleType} title ${title.id}`,
|
|
2546
|
+
beforeData: JSON.stringify({ status: title.status }),
|
|
2547
|
+
afterData: JSON.stringify({
|
|
2548
|
+
status: title.status,
|
|
2549
|
+
total_amount_cents: this.toCents(data.total_amount),
|
|
2550
|
+
}),
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
return tx.financial_title.findFirst({
|
|
2554
|
+
where: {
|
|
2555
|
+
id: title.id,
|
|
2556
|
+
title_type: titleType,
|
|
2557
|
+
},
|
|
2558
|
+
include: this.defaultTitleInclude(),
|
|
2559
|
+
});
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2562
|
+
if (!updatedTitle) {
|
|
2563
|
+
throw new NotFoundException('Financial title not found');
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
return this.mapTitleToFront(updatedTitle, data.payment_channel);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
private normalizeAndValidateInstallments(
|
|
2570
|
+
data: CreateFinancialTitleDto,
|
|
2571
|
+
locale: string,
|
|
2572
|
+
) {
|
|
2573
|
+
const fallbackDueDate = data.due_date;
|
|
2574
|
+
const totalAmountCents = this.toCents(data.total_amount);
|
|
2575
|
+
const sourceInstallments =
|
|
2576
|
+
data.installments && data.installments.length > 0
|
|
2577
|
+
? data.installments
|
|
2578
|
+
: [
|
|
2579
|
+
{
|
|
2580
|
+
installment_number: 1,
|
|
2581
|
+
due_date: fallbackDueDate,
|
|
2582
|
+
amount: data.total_amount,
|
|
2583
|
+
},
|
|
2584
|
+
];
|
|
2585
|
+
|
|
2586
|
+
const normalizedInstallments = sourceInstallments.map(
|
|
2587
|
+
(installment, index) => {
|
|
2588
|
+
const installmentDueDate = installment.due_date || fallbackDueDate;
|
|
2589
|
+
|
|
2590
|
+
if (!installmentDueDate) {
|
|
2591
|
+
throw new BadRequestException(
|
|
2592
|
+
getLocaleText(
|
|
2593
|
+
'installmentDueDateRequired',
|
|
2594
|
+
locale,
|
|
2595
|
+
'Installment due date is required',
|
|
2596
|
+
),
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
const amountCents = this.toCents(installment.amount);
|
|
2601
|
+
|
|
2602
|
+
if (amountCents <= 0) {
|
|
2603
|
+
throw new BadRequestException(
|
|
2604
|
+
getLocaleText(
|
|
2605
|
+
'installmentAmountInvalid',
|
|
2606
|
+
locale,
|
|
2607
|
+
'Installment amount must be greater than zero',
|
|
2608
|
+
),
|
|
2609
|
+
);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
return {
|
|
2613
|
+
installment_number: installment.installment_number || index + 1,
|
|
2614
|
+
due_date: installmentDueDate,
|
|
2615
|
+
amount_cents: amountCents,
|
|
2616
|
+
};
|
|
2617
|
+
},
|
|
2618
|
+
);
|
|
2619
|
+
|
|
2620
|
+
const installmentsTotalCents = normalizedInstallments.reduce(
|
|
2621
|
+
(acc, installment) => acc + installment.amount_cents,
|
|
2622
|
+
0,
|
|
2623
|
+
);
|
|
2624
|
+
|
|
2625
|
+
if (installmentsTotalCents !== totalAmountCents) {
|
|
2626
|
+
throw new BadRequestException(
|
|
2627
|
+
getLocaleText(
|
|
2628
|
+
'installmentsTotalMismatch',
|
|
2629
|
+
locale,
|
|
2630
|
+
'Installments total must be equal to title total amount',
|
|
2631
|
+
),
|
|
2632
|
+
);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
return normalizedInstallments;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
private async approveTitle(
|
|
2639
|
+
titleId: number,
|
|
2640
|
+
titleType: TitleType,
|
|
2641
|
+
locale: string,
|
|
2642
|
+
userId?: number,
|
|
2643
|
+
) {
|
|
2644
|
+
const updatedTitle = await this.prisma.$transaction(async (tx) => {
|
|
2645
|
+
const title = await tx.financial_title.findFirst({
|
|
2646
|
+
where: {
|
|
2647
|
+
id: titleId,
|
|
2648
|
+
title_type: titleType,
|
|
2649
|
+
},
|
|
2650
|
+
select: {
|
|
2651
|
+
id: true,
|
|
2652
|
+
status: true,
|
|
2653
|
+
competence_date: true,
|
|
2654
|
+
},
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
if (!title) {
|
|
2658
|
+
throw new NotFoundException(
|
|
2659
|
+
getLocaleText(
|
|
2660
|
+
'itemNotFound',
|
|
2661
|
+
locale,
|
|
2662
|
+
`Financial title with ID ${titleId} not found`,
|
|
2663
|
+
).replace('{{item}}', 'Financial title'),
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
await this.assertDateNotInClosedPeriod(
|
|
2668
|
+
tx,
|
|
2669
|
+
title.competence_date,
|
|
2670
|
+
'approve title',
|
|
2671
|
+
);
|
|
2672
|
+
|
|
2673
|
+
if (title.status !== 'draft') {
|
|
2674
|
+
throw new BadRequestException('Only draft titles can be approved');
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
await tx.financial_title.update({
|
|
2678
|
+
where: { id: title.id },
|
|
2679
|
+
data: {
|
|
2680
|
+
status: 'open',
|
|
2681
|
+
},
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
await this.createAuditLog(tx, {
|
|
2685
|
+
action: 'APPROVE_TITLE',
|
|
2686
|
+
entityTable: 'financial_title',
|
|
2687
|
+
entityId: String(title.id),
|
|
2688
|
+
actorUserId: userId,
|
|
2689
|
+
summary: `Approved ${titleType} title ${title.id}`,
|
|
2690
|
+
beforeData: JSON.stringify({ status: title.status }),
|
|
2691
|
+
afterData: JSON.stringify({ status: 'open' }),
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
return tx.financial_title.findFirst({
|
|
2695
|
+
where: {
|
|
2696
|
+
id: title.id,
|
|
2697
|
+
title_type: titleType,
|
|
2698
|
+
},
|
|
2699
|
+
include: this.defaultTitleInclude(),
|
|
2700
|
+
});
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
if (!updatedTitle) {
|
|
2704
|
+
throw new NotFoundException('Financial title not found');
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
return this.mapTitleToFront(updatedTitle);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
private async rejectTitle(
|
|
2711
|
+
titleId: number,
|
|
2712
|
+
data: RejectTitleDto,
|
|
2713
|
+
titleType: TitleType,
|
|
2714
|
+
locale: string,
|
|
2715
|
+
userId?: number,
|
|
2716
|
+
) {
|
|
2717
|
+
const updatedTitle = await this.prisma.$transaction(async (tx) => {
|
|
2718
|
+
const title = await tx.financial_title.findFirst({
|
|
2719
|
+
where: {
|
|
2720
|
+
id: titleId,
|
|
2721
|
+
title_type: titleType,
|
|
2722
|
+
},
|
|
2723
|
+
select: {
|
|
2724
|
+
id: true,
|
|
2725
|
+
status: true,
|
|
2726
|
+
competence_date: true,
|
|
2727
|
+
},
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
if (!title) {
|
|
2731
|
+
throw new NotFoundException(
|
|
2732
|
+
getLocaleText(
|
|
2733
|
+
'itemNotFound',
|
|
2734
|
+
locale,
|
|
2735
|
+
`Financial title with ID ${titleId} not found`,
|
|
2736
|
+
).replace('{{item}}', 'Financial title'),
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
await this.assertDateNotInClosedPeriod(
|
|
2741
|
+
tx,
|
|
2742
|
+
title.competence_date,
|
|
2743
|
+
'reject title',
|
|
2744
|
+
);
|
|
2745
|
+
|
|
2746
|
+
if (title.status !== 'draft') {
|
|
2747
|
+
throw new BadRequestException('Only draft titles can be rejected');
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
await tx.financial_title.update({
|
|
2751
|
+
where: { id: title.id },
|
|
2752
|
+
data: {
|
|
2753
|
+
status: 'canceled',
|
|
2754
|
+
},
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
await this.createAuditLog(tx, {
|
|
2758
|
+
action: 'REJECT_TITLE',
|
|
2759
|
+
entityTable: 'financial_title',
|
|
2760
|
+
entityId: String(title.id),
|
|
2761
|
+
actorUserId: userId,
|
|
2762
|
+
summary: `Rejected ${titleType} title ${title.id}`,
|
|
2763
|
+
beforeData: JSON.stringify({ status: title.status }),
|
|
2764
|
+
afterData: JSON.stringify({
|
|
2765
|
+
status: 'canceled',
|
|
2766
|
+
reason: data?.reason || null,
|
|
2767
|
+
}),
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
return tx.financial_title.findFirst({
|
|
2771
|
+
where: {
|
|
2772
|
+
id: title.id,
|
|
2773
|
+
title_type: titleType,
|
|
2774
|
+
},
|
|
2775
|
+
include: this.defaultTitleInclude(),
|
|
2776
|
+
});
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
if (!updatedTitle) {
|
|
2780
|
+
throw new NotFoundException('Financial title not found');
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
return this.mapTitleToFront(updatedTitle);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
private async cancelTitle(
|
|
2787
|
+
titleId: number,
|
|
2788
|
+
data: RejectTitleDto,
|
|
2789
|
+
titleType: TitleType,
|
|
2790
|
+
locale: string,
|
|
2791
|
+
userId?: number,
|
|
2792
|
+
) {
|
|
2793
|
+
const updatedTitle = await this.prisma.$transaction(async (tx) => {
|
|
2794
|
+
const title = await tx.financial_title.findFirst({
|
|
2795
|
+
where: {
|
|
2796
|
+
id: titleId,
|
|
2797
|
+
title_type: titleType,
|
|
2798
|
+
},
|
|
2799
|
+
select: {
|
|
2800
|
+
id: true,
|
|
2801
|
+
status: true,
|
|
2802
|
+
competence_date: true,
|
|
2803
|
+
},
|
|
2804
|
+
});
|
|
2805
|
+
|
|
2806
|
+
if (!title) {
|
|
2807
|
+
throw new NotFoundException(
|
|
2808
|
+
getLocaleText(
|
|
2809
|
+
'itemNotFound',
|
|
2810
|
+
locale,
|
|
2811
|
+
`Financial title with ID ${titleId} not found`,
|
|
2812
|
+
).replace('{{item}}', 'Financial title'),
|
|
2813
|
+
);
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
if (title.status === 'settled' || title.status === 'canceled') {
|
|
2817
|
+
throw new BadRequestException('Title cannot be canceled in current status');
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
await this.assertDateNotInClosedPeriod(
|
|
2821
|
+
tx,
|
|
2822
|
+
title.competence_date,
|
|
2823
|
+
'cancel title',
|
|
2824
|
+
);
|
|
2825
|
+
|
|
2826
|
+
await tx.financial_title.update({
|
|
2827
|
+
where: { id: title.id },
|
|
2828
|
+
data: {
|
|
2829
|
+
status: 'canceled',
|
|
2830
|
+
},
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2833
|
+
await this.createAuditLog(tx, {
|
|
2834
|
+
action: 'CANCEL_TITLE',
|
|
2835
|
+
entityTable: 'financial_title',
|
|
2836
|
+
entityId: String(title.id),
|
|
2837
|
+
actorUserId: userId,
|
|
2838
|
+
summary: `Canceled ${titleType} title ${title.id}`,
|
|
2839
|
+
beforeData: JSON.stringify({ status: title.status }),
|
|
2840
|
+
afterData: JSON.stringify({
|
|
2841
|
+
status: 'canceled',
|
|
2842
|
+
reason: data?.reason || null,
|
|
2843
|
+
}),
|
|
2844
|
+
});
|
|
2845
|
+
|
|
2846
|
+
return tx.financial_title.findFirst({
|
|
2847
|
+
where: {
|
|
2848
|
+
id: title.id,
|
|
2849
|
+
title_type: titleType,
|
|
2850
|
+
},
|
|
2851
|
+
include: this.defaultTitleInclude(),
|
|
2852
|
+
});
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
if (!updatedTitle) {
|
|
2856
|
+
throw new NotFoundException('Financial title not found');
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
return this.mapTitleToFront(updatedTitle);
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
private async settleTitleInstallment(
|
|
2863
|
+
titleId: number,
|
|
2864
|
+
data: SettleInstallmentDto,
|
|
2865
|
+
titleType: TitleType,
|
|
2866
|
+
locale: string,
|
|
2867
|
+
userId?: number,
|
|
2868
|
+
) {
|
|
2869
|
+
const amountCents = this.toCents(data.amount);
|
|
2870
|
+
|
|
2871
|
+
if (amountCents <= 0) {
|
|
2872
|
+
throw new BadRequestException('Settlement amount must be greater than zero');
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
const settledAt = data.settled_at ? new Date(data.settled_at) : new Date();
|
|
2876
|
+
if (Number.isNaN(settledAt.getTime())) {
|
|
2877
|
+
throw new BadRequestException('Invalid settlement date');
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
const result = await this.prisma.$transaction(async (tx) => {
|
|
2881
|
+
const title = await tx.financial_title.findFirst({
|
|
2882
|
+
where: {
|
|
2883
|
+
id: titleId,
|
|
2884
|
+
title_type: titleType,
|
|
2885
|
+
},
|
|
2886
|
+
select: {
|
|
2887
|
+
id: true,
|
|
2888
|
+
person_id: true,
|
|
2889
|
+
status: true,
|
|
2890
|
+
competence_date: true,
|
|
2891
|
+
},
|
|
2892
|
+
});
|
|
2893
|
+
|
|
2894
|
+
if (!title) {
|
|
2895
|
+
throw new NotFoundException(
|
|
2896
|
+
getLocaleText(
|
|
2897
|
+
'itemNotFound',
|
|
2898
|
+
locale,
|
|
2899
|
+
`Financial title with ID ${titleId} not found`,
|
|
2900
|
+
).replace('{{item}}', 'Financial title'),
|
|
2901
|
+
);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
if (!['open', 'partial'].includes(title.status)) {
|
|
1617
2905
|
throw new BadRequestException(
|
|
1618
|
-
|
|
2906
|
+
'Only open/partial titles can be settled',
|
|
1619
2907
|
);
|
|
1620
2908
|
}
|
|
1621
2909
|
|
|
1622
|
-
await this.
|
|
1623
|
-
|
|
2910
|
+
await this.assertDateNotInClosedPeriod(
|
|
2911
|
+
tx,
|
|
2912
|
+
title.competence_date,
|
|
2913
|
+
'settle installment',
|
|
2914
|
+
);
|
|
2915
|
+
|
|
2916
|
+
const installment = await tx.financial_installment.findFirst({
|
|
2917
|
+
where: {
|
|
2918
|
+
id: data.installment_id,
|
|
1624
2919
|
title_id: title.id,
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2920
|
+
},
|
|
2921
|
+
select: {
|
|
2922
|
+
id: true,
|
|
2923
|
+
title_id: true,
|
|
2924
|
+
amount_cents: true,
|
|
2925
|
+
open_amount_cents: true,
|
|
2926
|
+
due_date: true,
|
|
2927
|
+
status: true,
|
|
2928
|
+
},
|
|
1628
2929
|
});
|
|
1629
|
-
}
|
|
1630
2930
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2931
|
+
if (!installment) {
|
|
2932
|
+
throw new BadRequestException('Installment not found for this title');
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
if (installment.status === 'settled' || installment.status === 'canceled') {
|
|
2936
|
+
throw new BadRequestException('This installment cannot be settled');
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
if (amountCents > installment.open_amount_cents) {
|
|
2940
|
+
throw new BadRequestException('Settlement amount exceeds open amount');
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
const paymentMethodId = await this.resolvePaymentMethodId(
|
|
2944
|
+
tx,
|
|
2945
|
+
data.payment_channel,
|
|
2946
|
+
);
|
|
1634
2947
|
|
|
1635
|
-
const
|
|
2948
|
+
const settlement = await tx.settlement.create({
|
|
1636
2949
|
data: {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2950
|
+
person_id: title.person_id,
|
|
2951
|
+
bank_account_id: data.bank_account_id || null,
|
|
2952
|
+
payment_method_id: paymentMethodId,
|
|
2953
|
+
settlement_type: titleType,
|
|
2954
|
+
status: 'confirmed',
|
|
2955
|
+
settled_at: settledAt,
|
|
1643
2956
|
amount_cents: amountCents,
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
notes: data.description,
|
|
2957
|
+
description: data.description?.trim() || null,
|
|
2958
|
+
created_by_user_id: userId,
|
|
1647
2959
|
},
|
|
1648
2960
|
});
|
|
1649
2961
|
|
|
1650
|
-
|
|
1651
|
-
|
|
2962
|
+
await tx.settlement_allocation.create({
|
|
2963
|
+
data: {
|
|
2964
|
+
settlement_id: settlement.id,
|
|
2965
|
+
installment_id: installment.id,
|
|
2966
|
+
allocated_amount_cents: amountCents,
|
|
2967
|
+
discount_cents: this.toCents(data.discount || 0),
|
|
2968
|
+
interest_cents: this.toCents(data.interest || 0),
|
|
2969
|
+
penalty_cents: this.toCents(data.penalty || 0),
|
|
2970
|
+
},
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
const decrementResult = await tx.financial_installment.updateMany({
|
|
2974
|
+
where: {
|
|
2975
|
+
id: installment.id,
|
|
2976
|
+
open_amount_cents: {
|
|
2977
|
+
gte: amountCents,
|
|
2978
|
+
},
|
|
2979
|
+
},
|
|
2980
|
+
data: {
|
|
2981
|
+
open_amount_cents: {
|
|
2982
|
+
decrement: amountCents,
|
|
2983
|
+
},
|
|
2984
|
+
},
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
if (decrementResult.count !== 1) {
|
|
2988
|
+
throw new BadRequestException(
|
|
2989
|
+
'Installment was updated concurrently, please try again',
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
const updatedInstallment = await tx.financial_installment.findUnique({
|
|
2994
|
+
where: {
|
|
2995
|
+
id: installment.id,
|
|
2996
|
+
},
|
|
2997
|
+
select: {
|
|
2998
|
+
id: true,
|
|
2999
|
+
amount_cents: true,
|
|
3000
|
+
open_amount_cents: true,
|
|
3001
|
+
due_date: true,
|
|
3002
|
+
status: true,
|
|
3003
|
+
},
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
if (!updatedInstallment) {
|
|
3007
|
+
throw new NotFoundException('Installment not found');
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
const nextInstallmentStatus = this.resolveInstallmentStatus(
|
|
3011
|
+
updatedInstallment.amount_cents,
|
|
3012
|
+
updatedInstallment.open_amount_cents,
|
|
3013
|
+
updatedInstallment.due_date,
|
|
3014
|
+
);
|
|
3015
|
+
|
|
3016
|
+
if (updatedInstallment.status !== nextInstallmentStatus) {
|
|
3017
|
+
await tx.financial_installment.update({
|
|
3018
|
+
where: {
|
|
3019
|
+
id: updatedInstallment.id,
|
|
3020
|
+
},
|
|
3021
|
+
data: {
|
|
3022
|
+
status: nextInstallmentStatus,
|
|
3023
|
+
},
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
const previousTitleStatus = title.status;
|
|
3028
|
+
const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
|
|
3029
|
+
|
|
3030
|
+
await this.createAuditLog(tx, {
|
|
3031
|
+
action: 'SETTLE_INSTALLMENT',
|
|
3032
|
+
entityTable: 'financial_title',
|
|
3033
|
+
entityId: String(title.id),
|
|
3034
|
+
actorUserId: userId,
|
|
3035
|
+
summary: `Settled installment ${installment.id} of title ${title.id}`,
|
|
3036
|
+
beforeData: JSON.stringify({
|
|
3037
|
+
title_status: previousTitleStatus,
|
|
3038
|
+
installment_open_amount_cents: installment.open_amount_cents,
|
|
3039
|
+
}),
|
|
3040
|
+
afterData: JSON.stringify({
|
|
3041
|
+
title_status: nextTitleStatus,
|
|
3042
|
+
installment_open_amount_cents: updatedInstallment.open_amount_cents,
|
|
3043
|
+
settlement_id: settlement.id,
|
|
3044
|
+
}),
|
|
3045
|
+
});
|
|
3046
|
+
|
|
3047
|
+
const updatedTitle = await tx.financial_title.findFirst({
|
|
3048
|
+
where: {
|
|
3049
|
+
id: title.id,
|
|
3050
|
+
title_type: titleType,
|
|
3051
|
+
},
|
|
3052
|
+
include: this.defaultTitleInclude(),
|
|
3053
|
+
});
|
|
3054
|
+
|
|
3055
|
+
if (!updatedTitle) {
|
|
3056
|
+
throw new NotFoundException('Financial title not found');
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
return {
|
|
3060
|
+
title: updatedTitle,
|
|
3061
|
+
settlementId: settlement.id,
|
|
3062
|
+
};
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
return {
|
|
3066
|
+
...this.mapTitleToFront(result.title),
|
|
3067
|
+
settlementId: String(result.settlementId),
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
private async reverseTitleSettlement(
|
|
3072
|
+
titleId: number,
|
|
3073
|
+
settlementId: number,
|
|
3074
|
+
data: ReverseSettlementDto,
|
|
3075
|
+
titleType: TitleType,
|
|
3076
|
+
locale: string,
|
|
3077
|
+
userId?: number,
|
|
3078
|
+
) {
|
|
3079
|
+
const updatedTitle = await this.prisma.$transaction(async (tx) => {
|
|
3080
|
+
const title = await tx.financial_title.findFirst({
|
|
3081
|
+
where: {
|
|
3082
|
+
id: titleId,
|
|
3083
|
+
title_type: titleType,
|
|
3084
|
+
},
|
|
3085
|
+
select: {
|
|
3086
|
+
id: true,
|
|
3087
|
+
status: true,
|
|
3088
|
+
competence_date: true,
|
|
3089
|
+
},
|
|
3090
|
+
});
|
|
3091
|
+
|
|
3092
|
+
if (!title) {
|
|
3093
|
+
throw new NotFoundException(
|
|
3094
|
+
getLocaleText(
|
|
3095
|
+
'itemNotFound',
|
|
3096
|
+
locale,
|
|
3097
|
+
`Financial title with ID ${titleId} not found`,
|
|
3098
|
+
).replace('{{item}}', 'Financial title'),
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
await this.assertDateNotInClosedPeriod(
|
|
3103
|
+
tx,
|
|
3104
|
+
title.competence_date,
|
|
3105
|
+
'reverse settlement',
|
|
3106
|
+
);
|
|
3107
|
+
|
|
3108
|
+
const settlement = await tx.settlement.findFirst({
|
|
3109
|
+
where: {
|
|
3110
|
+
id: settlementId,
|
|
3111
|
+
settlement_type: titleType,
|
|
3112
|
+
settlement_allocation: {
|
|
3113
|
+
some: {
|
|
3114
|
+
financial_installment: {
|
|
3115
|
+
title_id: title.id,
|
|
3116
|
+
},
|
|
3117
|
+
},
|
|
3118
|
+
},
|
|
3119
|
+
},
|
|
3120
|
+
include: {
|
|
3121
|
+
settlement_allocation: {
|
|
3122
|
+
include: {
|
|
3123
|
+
financial_installment: {
|
|
3124
|
+
select: {
|
|
3125
|
+
id: true,
|
|
3126
|
+
amount_cents: true,
|
|
3127
|
+
open_amount_cents: true,
|
|
3128
|
+
due_date: true,
|
|
3129
|
+
status: true,
|
|
3130
|
+
},
|
|
3131
|
+
},
|
|
3132
|
+
},
|
|
3133
|
+
},
|
|
3134
|
+
},
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
if (!settlement) {
|
|
3138
|
+
throw new NotFoundException('Settlement not found for this title');
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
if (settlement.status === 'reversed') {
|
|
3142
|
+
throw new BadRequestException('This settlement is already reversed');
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
for (const allocation of settlement.settlement_allocation) {
|
|
3146
|
+
const installment = allocation.financial_installment;
|
|
3147
|
+
|
|
3148
|
+
if (!installment) {
|
|
3149
|
+
continue;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
const nextOpenAmountCents =
|
|
3153
|
+
installment.open_amount_cents + allocation.allocated_amount_cents;
|
|
3154
|
+
|
|
3155
|
+
if (nextOpenAmountCents > installment.amount_cents) {
|
|
3156
|
+
throw new BadRequestException(
|
|
3157
|
+
`Reverse would exceed installment amount for installment ${installment.id}`,
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
const nextInstallmentStatus = this.resolveInstallmentStatus(
|
|
3162
|
+
installment.amount_cents,
|
|
3163
|
+
nextOpenAmountCents,
|
|
3164
|
+
installment.due_date,
|
|
3165
|
+
);
|
|
3166
|
+
|
|
3167
|
+
await tx.financial_installment.update({
|
|
3168
|
+
where: {
|
|
3169
|
+
id: installment.id,
|
|
3170
|
+
},
|
|
1652
3171
|
data: {
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
allocated_amount_cents: amountCents,
|
|
3172
|
+
open_amount_cents: nextOpenAmountCents,
|
|
3173
|
+
status: nextInstallmentStatus,
|
|
1656
3174
|
},
|
|
1657
3175
|
});
|
|
1658
3176
|
}
|
|
3177
|
+
|
|
3178
|
+
await tx.settlement.update({
|
|
3179
|
+
where: {
|
|
3180
|
+
id: settlement.id,
|
|
3181
|
+
},
|
|
3182
|
+
data: {
|
|
3183
|
+
status: 'reversed',
|
|
3184
|
+
description: [
|
|
3185
|
+
settlement.description,
|
|
3186
|
+
data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
|
|
3187
|
+
]
|
|
3188
|
+
.filter(Boolean)
|
|
3189
|
+
.join(' | '),
|
|
3190
|
+
},
|
|
3191
|
+
});
|
|
3192
|
+
|
|
3193
|
+
const previousTitleStatus = title.status;
|
|
3194
|
+
const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
|
|
3195
|
+
|
|
3196
|
+
await this.createAuditLog(tx, {
|
|
3197
|
+
action: 'REVERSE_SETTLEMENT',
|
|
3198
|
+
entityTable: 'financial_title',
|
|
3199
|
+
entityId: String(title.id),
|
|
3200
|
+
actorUserId: userId,
|
|
3201
|
+
summary: `Reversed settlement ${settlement.id} from title ${title.id}`,
|
|
3202
|
+
beforeData: JSON.stringify({
|
|
3203
|
+
title_status: previousTitleStatus,
|
|
3204
|
+
settlement_status: settlement.status,
|
|
3205
|
+
}),
|
|
3206
|
+
afterData: JSON.stringify({
|
|
3207
|
+
title_status: nextTitleStatus,
|
|
3208
|
+
settlement_status: 'reversed',
|
|
3209
|
+
}),
|
|
3210
|
+
});
|
|
3211
|
+
|
|
3212
|
+
return tx.financial_title.findFirst({
|
|
3213
|
+
where: {
|
|
3214
|
+
id: title.id,
|
|
3215
|
+
title_type: titleType,
|
|
3216
|
+
},
|
|
3217
|
+
include: this.defaultTitleInclude(),
|
|
3218
|
+
});
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
if (!updatedTitle) {
|
|
3222
|
+
throw new NotFoundException('Financial title not found');
|
|
1659
3223
|
}
|
|
1660
3224
|
|
|
1661
|
-
|
|
1662
|
-
return this.mapTitleToFront(createdTitle, data.payment_channel);
|
|
3225
|
+
return this.mapTitleToFront(updatedTitle);
|
|
1663
3226
|
}
|
|
1664
3227
|
|
|
1665
3228
|
private async updateTitleTags(
|
|
@@ -1853,6 +3416,189 @@ export class FinanceService {
|
|
|
1853
3416
|
}));
|
|
1854
3417
|
}
|
|
1855
3418
|
|
|
3419
|
+
private async resolvePaymentMethodId(tx: any, paymentChannel?: string) {
|
|
3420
|
+
const paymentType = this.mapPaymentMethodFromPt(paymentChannel);
|
|
3421
|
+
|
|
3422
|
+
if (!paymentType) {
|
|
3423
|
+
return null;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
const paymentMethod = await tx.payment_method.findFirst({
|
|
3427
|
+
where: {
|
|
3428
|
+
type: paymentType,
|
|
3429
|
+
status: 'active',
|
|
3430
|
+
},
|
|
3431
|
+
select: {
|
|
3432
|
+
id: true,
|
|
3433
|
+
},
|
|
3434
|
+
});
|
|
3435
|
+
|
|
3436
|
+
return paymentMethod?.id || null;
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
private async assertDateNotInClosedPeriod(
|
|
3440
|
+
tx: any,
|
|
3441
|
+
competenceDate: Date | null | undefined,
|
|
3442
|
+
operation: string,
|
|
3443
|
+
) {
|
|
3444
|
+
if (!competenceDate) {
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
const closedPeriod = await tx.period_close.findFirst({
|
|
3449
|
+
where: {
|
|
3450
|
+
status: 'closed',
|
|
3451
|
+
period_start: {
|
|
3452
|
+
lte: competenceDate,
|
|
3453
|
+
},
|
|
3454
|
+
period_end: {
|
|
3455
|
+
gte: competenceDate,
|
|
3456
|
+
},
|
|
3457
|
+
},
|
|
3458
|
+
select: {
|
|
3459
|
+
id: true,
|
|
3460
|
+
},
|
|
3461
|
+
});
|
|
3462
|
+
|
|
3463
|
+
if (closedPeriod) {
|
|
3464
|
+
throw new BadRequestException(
|
|
3465
|
+
`Cannot ${operation}: competence is in a closed period`,
|
|
3466
|
+
);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
private resolveInstallmentStatus(
|
|
3471
|
+
amountCents: number,
|
|
3472
|
+
openAmountCents: number,
|
|
3473
|
+
dueDate: Date,
|
|
3474
|
+
currentStatus?: string,
|
|
3475
|
+
): InstallmentStatus {
|
|
3476
|
+
if (currentStatus === 'canceled') {
|
|
3477
|
+
return 'canceled';
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
if (openAmountCents <= 0) {
|
|
3481
|
+
return 'settled';
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
if (openAmountCents < amountCents) {
|
|
3485
|
+
return 'partial';
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
const today = new Date();
|
|
3489
|
+
today.setHours(0, 0, 0, 0);
|
|
3490
|
+
const dueDateOnly = new Date(dueDate);
|
|
3491
|
+
dueDateOnly.setHours(0, 0, 0, 0);
|
|
3492
|
+
|
|
3493
|
+
if (dueDateOnly < today) {
|
|
3494
|
+
return 'overdue';
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
return 'open';
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
private deriveTitleStatusFromInstallments(
|
|
3501
|
+
installments: Array<{
|
|
3502
|
+
amount_cents: number;
|
|
3503
|
+
open_amount_cents: number;
|
|
3504
|
+
due_date: Date;
|
|
3505
|
+
status?: string;
|
|
3506
|
+
}>,
|
|
3507
|
+
): TitleStatus {
|
|
3508
|
+
if (installments.length === 0) {
|
|
3509
|
+
return 'open';
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
const effectiveStatuses = installments.map((installment) =>
|
|
3513
|
+
this.resolveInstallmentStatus(
|
|
3514
|
+
installment.amount_cents,
|
|
3515
|
+
installment.open_amount_cents,
|
|
3516
|
+
installment.due_date,
|
|
3517
|
+
installment.status,
|
|
3518
|
+
),
|
|
3519
|
+
);
|
|
3520
|
+
|
|
3521
|
+
if (effectiveStatuses.every((status) => status === 'settled')) {
|
|
3522
|
+
return 'settled';
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
const hasPayment = installments.some(
|
|
3526
|
+
(installment) => installment.open_amount_cents < installment.amount_cents,
|
|
3527
|
+
);
|
|
3528
|
+
|
|
3529
|
+
if (hasPayment) {
|
|
3530
|
+
return 'partial';
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
return 'open';
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
private async recalculateTitleStatus(tx: any, titleId: number): Promise<TitleStatus> {
|
|
3537
|
+
const title = await tx.financial_title.findUnique({
|
|
3538
|
+
where: {
|
|
3539
|
+
id: titleId,
|
|
3540
|
+
},
|
|
3541
|
+
select: {
|
|
3542
|
+
id: true,
|
|
3543
|
+
status: true,
|
|
3544
|
+
financial_installment: {
|
|
3545
|
+
select: {
|
|
3546
|
+
amount_cents: true,
|
|
3547
|
+
open_amount_cents: true,
|
|
3548
|
+
due_date: true,
|
|
3549
|
+
status: true,
|
|
3550
|
+
},
|
|
3551
|
+
},
|
|
3552
|
+
},
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
if (!title) {
|
|
3556
|
+
throw new NotFoundException('Financial title not found');
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
const nextStatus = this.deriveTitleStatusFromInstallments(
|
|
3560
|
+
title.financial_installment,
|
|
3561
|
+
);
|
|
3562
|
+
|
|
3563
|
+
if (title.status !== nextStatus) {
|
|
3564
|
+
await tx.financial_title.update({
|
|
3565
|
+
where: {
|
|
3566
|
+
id: title.id,
|
|
3567
|
+
},
|
|
3568
|
+
data: {
|
|
3569
|
+
status: nextStatus,
|
|
3570
|
+
},
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
return nextStatus;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
private async createAuditLog(
|
|
3578
|
+
tx: any,
|
|
3579
|
+
data: {
|
|
3580
|
+
actorUserId?: number;
|
|
3581
|
+
action: string;
|
|
3582
|
+
entityTable: string;
|
|
3583
|
+
entityId: string;
|
|
3584
|
+
summary?: string;
|
|
3585
|
+
beforeData?: string;
|
|
3586
|
+
afterData?: string;
|
|
3587
|
+
},
|
|
3588
|
+
) {
|
|
3589
|
+
await tx.audit_log.create({
|
|
3590
|
+
data: {
|
|
3591
|
+
actor_user_id: data.actorUserId || null,
|
|
3592
|
+
action: data.action,
|
|
3593
|
+
entity_table: data.entityTable,
|
|
3594
|
+
entity_id: data.entityId,
|
|
3595
|
+
summary: data.summary || null,
|
|
3596
|
+
before_data: data.beforeData || null,
|
|
3597
|
+
after_data: data.afterData || null,
|
|
3598
|
+
},
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
|
|
1856
3602
|
private mapTitleToFront(title: any, paymentChannelOverride?: string) {
|
|
1857
3603
|
const allocations = title.financial_installment.flatMap(
|
|
1858
3604
|
(installment) => installment.installment_allocation,
|
|
@@ -1877,13 +3623,24 @@ export class FinanceService {
|
|
|
1877
3623
|
numero: installment.installment_number,
|
|
1878
3624
|
vencimento: installment.due_date.toISOString(),
|
|
1879
3625
|
valor: this.fromCents(installment.amount_cents),
|
|
1880
|
-
|
|
3626
|
+
valorAberto: this.fromCents(installment.open_amount_cents),
|
|
3627
|
+
status: this.mapStatusToPt(
|
|
3628
|
+
this.resolveInstallmentStatus(
|
|
3629
|
+
installment.amount_cents,
|
|
3630
|
+
installment.open_amount_cents,
|
|
3631
|
+
installment.due_date,
|
|
3632
|
+
installment.status,
|
|
3633
|
+
),
|
|
3634
|
+
),
|
|
1881
3635
|
metodoPagamento:
|
|
1882
3636
|
this.mapPaymentMethodToPt(
|
|
1883
3637
|
installment.settlement_allocation[0]?.settlement?.payment_method?.type,
|
|
1884
3638
|
) || paymentChannelOverride || 'transferencia',
|
|
1885
3639
|
liquidacoes: installment.settlement_allocation.map((allocation) => ({
|
|
1886
3640
|
id: String(allocation.id),
|
|
3641
|
+
settlementId: allocation.settlement?.id
|
|
3642
|
+
? String(allocation.settlement.id)
|
|
3643
|
+
: null,
|
|
1887
3644
|
data: allocation.settlement?.settled_at?.toISOString(),
|
|
1888
3645
|
valor: this.fromCents(allocation.allocated_amount_cents),
|
|
1889
3646
|
juros: this.fromCents(allocation.interest_cents || 0),
|
|
@@ -1892,6 +3649,7 @@ export class FinanceService {
|
|
|
1892
3649
|
contaBancariaId: allocation.settlement?.bank_account_id
|
|
1893
3650
|
? String(allocation.settlement.bank_account_id)
|
|
1894
3651
|
: null,
|
|
3652
|
+
status: allocation.settlement?.status || null,
|
|
1895
3653
|
metodo:
|
|
1896
3654
|
this.mapPaymentMethodToPt(
|
|
1897
3655
|
allocation.settlement?.payment_method?.type,
|
|
@@ -1962,7 +3720,6 @@ export class FinanceService {
|
|
|
1962
3720
|
private mapStatusToPt(status?: string | null) {
|
|
1963
3721
|
const statusMap = {
|
|
1964
3722
|
draft: 'rascunho',
|
|
1965
|
-
approved: 'aprovado',
|
|
1966
3723
|
open: 'aberto',
|
|
1967
3724
|
partial: 'parcial',
|
|
1968
3725
|
settled: 'liquidado',
|
|
@@ -1994,7 +3751,7 @@ export class FinanceService {
|
|
|
1994
3751
|
|
|
1995
3752
|
const statusMap = {
|
|
1996
3753
|
rascunho: 'draft',
|
|
1997
|
-
aprovado: '
|
|
3754
|
+
aprovado: 'open',
|
|
1998
3755
|
aberto: 'open',
|
|
1999
3756
|
parcial: 'partial',
|
|
2000
3757
|
liquidado: 'settled',
|
|
@@ -2006,7 +3763,7 @@ export class FinanceService {
|
|
|
2006
3763
|
}
|
|
2007
3764
|
|
|
2008
3765
|
private mapPaymentMethodToPt(paymentMethodType?: string | null) {
|
|
2009
|
-
const paymentMethodMap = {
|
|
3766
|
+
const paymentMethodMap: Record<string, string> = {
|
|
2010
3767
|
boleto: 'boleto',
|
|
2011
3768
|
pix: 'pix',
|
|
2012
3769
|
ted: 'transferencia',
|
|
@@ -2019,6 +3776,30 @@ export class FinanceService {
|
|
|
2019
3776
|
return paymentMethodMap[paymentMethodType] || undefined;
|
|
2020
3777
|
}
|
|
2021
3778
|
|
|
3779
|
+
private mapPaymentMethodFromPt(paymentMethodType?: string | null) {
|
|
3780
|
+
if (!paymentMethodType) {
|
|
3781
|
+
return undefined;
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
const paymentMethodMap = {
|
|
3785
|
+
boleto: 'boleto',
|
|
3786
|
+
pix: 'pix',
|
|
3787
|
+
transferencia: 'ted',
|
|
3788
|
+
transferência: 'ted',
|
|
3789
|
+
ted: 'ted',
|
|
3790
|
+
doc: 'doc',
|
|
3791
|
+
cartao: 'card',
|
|
3792
|
+
cartão: 'card',
|
|
3793
|
+
dinheiro: 'cash',
|
|
3794
|
+
cheque: 'other',
|
|
3795
|
+
cash: 'cash',
|
|
3796
|
+
card: 'card',
|
|
3797
|
+
other: 'other',
|
|
3798
|
+
};
|
|
3799
|
+
|
|
3800
|
+
return paymentMethodMap[(paymentMethodType || '').toLowerCase()];
|
|
3801
|
+
}
|
|
3802
|
+
|
|
2022
3803
|
private mapAccountTypeToPt(accountType?: string | null) {
|
|
2023
3804
|
const accountTypeMap = {
|
|
2024
3805
|
checking: 'corrente',
|