@hed-hog/finance 0.0.251 → 0.0.253
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dto/reverse-settlement.dto.d.ts +1 -0
- package/dist/dto/reverse-settlement.dto.d.ts.map +1 -1
- package/dist/dto/reverse-settlement.dto.js +5 -0
- package/dist/dto/reverse-settlement.dto.js.map +1 -1
- package/dist/finance-installments.controller.d.ts +40 -2
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.js +38 -2
- package/dist/finance-installments.controller.js.map +1 -1
- package/dist/finance.service.d.ts +39 -0
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +476 -219
- package/dist/finance.service.js.map +1 -1
- package/hedhog/data/route.yaml +27 -0
- package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +457 -142
- package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +100 -5
- package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +19 -20
- package/hedhog/frontend/app/page.tsx.ejs +11 -4
- package/hedhog/frontend/messages/en.json +44 -0
- package/hedhog/frontend/messages/pt.json +44 -0
- package/hedhog/query/constraints.sql +3 -1
- package/hedhog/query/settlement-auditability.sql +175 -0
- package/hedhog/table/bank_reconciliation.yaml +11 -0
- package/hedhog/table/settlement.yaml +17 -1
- package/hedhog/table/settlement_allocation.yaml +3 -0
- package/package.json +2 -2
- package/src/dto/reverse-settlement.dto.ts +4 -0
- package/src/finance-installments.controller.ts +45 -12
- package/src/finance.service.ts +498 -114
package/src/finance.service.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
|
|
|
3
3
|
import { PrismaService } from '@hed-hog/api-prisma';
|
|
4
4
|
import { AiService, FileService } from '@hed-hog/core';
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
BadRequestException,
|
|
7
|
+
ConflictException,
|
|
8
|
+
forwardRef,
|
|
9
|
+
Inject,
|
|
10
|
+
Injectable,
|
|
11
|
+
Logger,
|
|
12
|
+
NotFoundException,
|
|
12
13
|
} from '@nestjs/common';
|
|
13
14
|
import { createHash } from 'node:crypto';
|
|
14
15
|
import { readFile } from 'node:fs/promises';
|
|
@@ -833,7 +834,9 @@ export class FinanceService {
|
|
|
833
834
|
const day7 = this.addDays(today, 7);
|
|
834
835
|
const day30 = this.addDays(today, 30);
|
|
835
836
|
|
|
836
|
-
const payableInstallments = this.extractOpenInstallments(
|
|
837
|
+
const payableInstallments = this.extractOpenInstallments(
|
|
838
|
+
(payables || []).filter((title) => this.isPayableTitleApproved(title)),
|
|
839
|
+
);
|
|
837
840
|
const receivableInstallments = this.extractOpenInstallments(receivables);
|
|
838
841
|
|
|
839
842
|
const saldoCaixa = (bankAccounts || [])
|
|
@@ -887,6 +890,11 @@ export class FinanceService {
|
|
|
887
890
|
);
|
|
888
891
|
}
|
|
889
892
|
|
|
893
|
+
private isPayableTitleApproved(title: any) {
|
|
894
|
+
const status = String(title?.status || '').toLowerCase();
|
|
895
|
+
return status !== 'rascunho' && status !== 'cancelado';
|
|
896
|
+
}
|
|
897
|
+
|
|
890
898
|
private sumInstallmentsDueBetween(
|
|
891
899
|
installments: any[],
|
|
892
900
|
startDate: Date,
|
|
@@ -1059,6 +1067,192 @@ export class FinanceService {
|
|
|
1059
1067
|
);
|
|
1060
1068
|
}
|
|
1061
1069
|
|
|
1070
|
+
async getTitleSettlementsHistory(titleId: number, locale: string) {
|
|
1071
|
+
const title = await this.prisma.financial_title.findUnique({
|
|
1072
|
+
where: { id: titleId },
|
|
1073
|
+
select: { id: true },
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
if (!title) {
|
|
1077
|
+
throw new NotFoundException(
|
|
1078
|
+
getLocaleText(
|
|
1079
|
+
'itemNotFound',
|
|
1080
|
+
locale,
|
|
1081
|
+
`Financial title with ID ${titleId} not found`,
|
|
1082
|
+
).replace('{{item}}', 'Financial title'),
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const rows = await this.prisma.$queryRaw<
|
|
1087
|
+
Array<{
|
|
1088
|
+
normal_id: number;
|
|
1089
|
+
normal_paid_at: Date;
|
|
1090
|
+
normal_amount_cents: number;
|
|
1091
|
+
normal_method: string | null;
|
|
1092
|
+
normal_account_id: number | null;
|
|
1093
|
+
normal_account_name: string | null;
|
|
1094
|
+
normal_created_at: Date;
|
|
1095
|
+
normal_created_by: string | null;
|
|
1096
|
+
normal_memo: string | null;
|
|
1097
|
+
reconciliation_id: number | null;
|
|
1098
|
+
reconciliation_status: string | null;
|
|
1099
|
+
installment_id: number;
|
|
1100
|
+
installment_seq: number;
|
|
1101
|
+
allocation_amount_cents: number;
|
|
1102
|
+
reversal_id: number | null;
|
|
1103
|
+
reversal_paid_at: Date | null;
|
|
1104
|
+
reversal_amount_cents: number | null;
|
|
1105
|
+
reversal_created_at: Date | null;
|
|
1106
|
+
reversal_created_by: string | null;
|
|
1107
|
+
reversal_memo: string | null;
|
|
1108
|
+
}>
|
|
1109
|
+
>`
|
|
1110
|
+
SELECT
|
|
1111
|
+
s.id AS normal_id,
|
|
1112
|
+
s.settled_at AS normal_paid_at,
|
|
1113
|
+
s.amount_cents AS normal_amount_cents,
|
|
1114
|
+
pm.type::text AS normal_method,
|
|
1115
|
+
s.bank_account_id AS normal_account_id,
|
|
1116
|
+
ba.name AS normal_account_name,
|
|
1117
|
+
s.created_at AS normal_created_at,
|
|
1118
|
+
u.name AS normal_created_by,
|
|
1119
|
+
s.description AS normal_memo,
|
|
1120
|
+
br.id AS reconciliation_id,
|
|
1121
|
+
br.status::text AS reconciliation_status,
|
|
1122
|
+
fi.id AS installment_id,
|
|
1123
|
+
fi.installment_number AS installment_seq,
|
|
1124
|
+
COALESCE(sa.amount_cents, sa.allocated_amount_cents) AS allocation_amount_cents,
|
|
1125
|
+
r.id AS reversal_id,
|
|
1126
|
+
r.settled_at AS reversal_paid_at,
|
|
1127
|
+
r.amount_cents AS reversal_amount_cents,
|
|
1128
|
+
r.created_at AS reversal_created_at,
|
|
1129
|
+
ur.name AS reversal_created_by,
|
|
1130
|
+
r.description AS reversal_memo
|
|
1131
|
+
FROM settlement s
|
|
1132
|
+
INNER JOIN settlement_allocation sa ON sa.settlement_id = s.id
|
|
1133
|
+
INNER JOIN financial_installment fi ON fi.id = sa.installment_id
|
|
1134
|
+
LEFT JOIN payment_method pm ON pm.id = s.payment_method_id
|
|
1135
|
+
LEFT JOIN bank_account ba ON ba.id = s.bank_account_id
|
|
1136
|
+
LEFT JOIN "user" u ON u.id = s.created_by_user_id
|
|
1137
|
+
LEFT JOIN bank_reconciliation br ON br.settlement_id = s.id
|
|
1138
|
+
LEFT JOIN settlement r ON r.reverses_settlement_id = s.id
|
|
1139
|
+
LEFT JOIN "user" ur ON ur.id = r.created_by_user_id
|
|
1140
|
+
WHERE fi.title_id = ${titleId}
|
|
1141
|
+
AND COALESCE(s.entry_type::text, 'normal') = 'normal'
|
|
1142
|
+
ORDER BY s.settled_at DESC, s.id DESC, fi.installment_number ASC
|
|
1143
|
+
`;
|
|
1144
|
+
|
|
1145
|
+
const groups = new Map<string, any>();
|
|
1146
|
+
|
|
1147
|
+
for (const row of rows) {
|
|
1148
|
+
const key = String(row.normal_id);
|
|
1149
|
+
const existing = groups.get(key);
|
|
1150
|
+
|
|
1151
|
+
if (!existing) {
|
|
1152
|
+
groups.set(key, {
|
|
1153
|
+
normal: {
|
|
1154
|
+
id: key,
|
|
1155
|
+
paidAt: row.normal_paid_at?.toISOString?.() || null,
|
|
1156
|
+
amountCents: Number(row.normal_amount_cents || 0),
|
|
1157
|
+
type: 'NORMAL',
|
|
1158
|
+
method: this.mapPaymentMethodToPt(row.normal_method) || row.normal_method,
|
|
1159
|
+
account: row.normal_account_name || null,
|
|
1160
|
+
accountId: row.normal_account_id
|
|
1161
|
+
? String(row.normal_account_id)
|
|
1162
|
+
: null,
|
|
1163
|
+
createdAt: row.normal_created_at?.toISOString?.() || null,
|
|
1164
|
+
createdBy: row.normal_created_by || null,
|
|
1165
|
+
memo: row.normal_memo || null,
|
|
1166
|
+
reconciled: row.reconciliation_status === 'reconciled',
|
|
1167
|
+
reconciliationId: row.reconciliation_id
|
|
1168
|
+
? String(row.reconciliation_id)
|
|
1169
|
+
: null,
|
|
1170
|
+
},
|
|
1171
|
+
reversal: row.reversal_id
|
|
1172
|
+
? {
|
|
1173
|
+
id: String(row.reversal_id),
|
|
1174
|
+
paidAt: row.reversal_paid_at?.toISOString?.() || null,
|
|
1175
|
+
amountCents: Number(row.reversal_amount_cents || 0),
|
|
1176
|
+
type: 'REVERSAL',
|
|
1177
|
+
createdAt: row.reversal_created_at?.toISOString?.() || null,
|
|
1178
|
+
createdBy: row.reversal_created_by || null,
|
|
1179
|
+
memo: row.reversal_memo || null,
|
|
1180
|
+
}
|
|
1181
|
+
: null,
|
|
1182
|
+
allocations: [],
|
|
1183
|
+
statusLabel: row.reversal_id ? 'ESTORNADO' : 'ATIVO',
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
groups.get(key).allocations.push({
|
|
1188
|
+
installmentId: String(row.installment_id),
|
|
1189
|
+
installmentSeq: Number(row.installment_seq || 0),
|
|
1190
|
+
amountCents: Number(row.allocation_amount_cents || 0),
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return Array.from(groups.values());
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async reverseSettlementById(
|
|
1198
|
+
settlementId: number,
|
|
1199
|
+
data: ReverseSettlementDto,
|
|
1200
|
+
locale: string,
|
|
1201
|
+
userId?: number,
|
|
1202
|
+
) {
|
|
1203
|
+
const updatedTitle = await this.reverseSettlementInternal(
|
|
1204
|
+
settlementId,
|
|
1205
|
+
data,
|
|
1206
|
+
locale,
|
|
1207
|
+
userId,
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
return this.mapTitleToFront(updatedTitle);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async unreconcileBankReconciliation(id: number, userId?: number) {
|
|
1214
|
+
const reconciliation = await this.prisma.bank_reconciliation.findUnique({
|
|
1215
|
+
where: { id },
|
|
1216
|
+
select: {
|
|
1217
|
+
id: true,
|
|
1218
|
+
settlement_id: true,
|
|
1219
|
+
bank_statement_line_id: true,
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
if (!reconciliation) {
|
|
1224
|
+
throw new NotFoundException('Conciliação bancária não encontrada');
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
await this.prisma.$transaction(async (tx) => {
|
|
1228
|
+
await tx.bank_reconciliation.delete({
|
|
1229
|
+
where: { id: reconciliation.id },
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
await tx.bank_statement_line.updateMany({
|
|
1233
|
+
where: {
|
|
1234
|
+
id: reconciliation.bank_statement_line_id,
|
|
1235
|
+
status: {
|
|
1236
|
+
in: ['reconciled', 'adjusted'],
|
|
1237
|
+
},
|
|
1238
|
+
},
|
|
1239
|
+
data: {
|
|
1240
|
+
status: 'pending',
|
|
1241
|
+
},
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
await this.createAuditLog(tx, {
|
|
1245
|
+
action: 'UNRECONCILE_SETTLEMENT',
|
|
1246
|
+
entityTable: 'bank_reconciliation',
|
|
1247
|
+
entityId: String(reconciliation.id),
|
|
1248
|
+
actorUserId: userId,
|
|
1249
|
+
summary: `Unreconciled settlement ${reconciliation.settlement_id}`,
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
return { success: true };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1062
1256
|
async createTag(data: CreateFinanceTagDto) {
|
|
1063
1257
|
const slug = this.normalizeTagSlug(data.name);
|
|
1064
1258
|
|
|
@@ -2906,12 +3100,45 @@ export class FinanceService {
|
|
|
2906
3100
|
throw new BadRequestException('Title cannot be canceled in current status');
|
|
2907
3101
|
}
|
|
2908
3102
|
|
|
3103
|
+
const activeSettlements = await tx.$queryRaw<Array<{ has_active: boolean }>>`
|
|
3104
|
+
SELECT EXISTS (
|
|
3105
|
+
SELECT 1
|
|
3106
|
+
FROM settlement_allocation sa
|
|
3107
|
+
INNER JOIN financial_installment fi ON fi.id = sa.installment_id
|
|
3108
|
+
INNER JOIN settlement s ON s.id = sa.settlement_id
|
|
3109
|
+
LEFT JOIN settlement r ON r.reverses_settlement_id = s.id
|
|
3110
|
+
WHERE fi.title_id = ${title.id}
|
|
3111
|
+
AND COALESCE(s.entry_type::text, 'normal') = 'normal'
|
|
3112
|
+
AND r.id IS NULL
|
|
3113
|
+
) AS has_active
|
|
3114
|
+
`;
|
|
3115
|
+
|
|
3116
|
+
const hasActiveSettlements = activeSettlements[0]?.has_active;
|
|
3117
|
+
|
|
3118
|
+
if (hasActiveSettlements) {
|
|
3119
|
+
throw new ConflictException(
|
|
3120
|
+
'Não é possível cancelar enquanto houver liquidações ativas. Estorne primeiro.',
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
|
|
2909
3124
|
await this.assertDateNotInClosedPeriod(
|
|
2910
3125
|
tx,
|
|
2911
3126
|
title.competence_date,
|
|
2912
3127
|
'cancel title',
|
|
2913
3128
|
);
|
|
2914
3129
|
|
|
3130
|
+
await tx.financial_installment.updateMany({
|
|
3131
|
+
where: {
|
|
3132
|
+
title_id: title.id,
|
|
3133
|
+
status: {
|
|
3134
|
+
not: 'canceled',
|
|
3135
|
+
},
|
|
3136
|
+
},
|
|
3137
|
+
data: {
|
|
3138
|
+
status: 'canceled',
|
|
3139
|
+
},
|
|
3140
|
+
});
|
|
3141
|
+
|
|
2915
3142
|
await tx.financial_title.update({
|
|
2916
3143
|
where: { id: title.id },
|
|
2917
3144
|
data: {
|
|
@@ -2966,8 +3193,9 @@ export class FinanceService {
|
|
|
2966
3193
|
throw new BadRequestException('Invalid settlement date');
|
|
2967
3194
|
}
|
|
2968
3195
|
|
|
2969
|
-
|
|
2970
|
-
const
|
|
3196
|
+
try {
|
|
3197
|
+
const result = await this.prisma.$transaction(async (tx) => {
|
|
3198
|
+
const title = await tx.financial_title.findFirst({
|
|
2971
3199
|
where: {
|
|
2972
3200
|
id: titleId,
|
|
2973
3201
|
title_type: titleType,
|
|
@@ -2991,6 +3219,10 @@ export class FinanceService {
|
|
|
2991
3219
|
}
|
|
2992
3220
|
|
|
2993
3221
|
if (!['open', 'partial'].includes(title.status)) {
|
|
3222
|
+
if (title.status === 'canceled') {
|
|
3223
|
+
throw new ConflictException('Título cancelado não pode receber baixa');
|
|
3224
|
+
}
|
|
3225
|
+
|
|
2994
3226
|
throw new BadRequestException(
|
|
2995
3227
|
'Only open/partial titles can be settled',
|
|
2996
3228
|
);
|
|
@@ -2998,7 +3230,7 @@ export class FinanceService {
|
|
|
2998
3230
|
|
|
2999
3231
|
await this.assertDateNotInClosedPeriod(
|
|
3000
3232
|
tx,
|
|
3001
|
-
|
|
3233
|
+
settledAt,
|
|
3002
3234
|
'settle installment',
|
|
3003
3235
|
);
|
|
3004
3236
|
|
|
@@ -3022,11 +3254,15 @@ export class FinanceService {
|
|
|
3022
3254
|
}
|
|
3023
3255
|
|
|
3024
3256
|
if (installment.status === 'settled' || installment.status === 'canceled') {
|
|
3025
|
-
|
|
3257
|
+
if (installment.status === 'settled') {
|
|
3258
|
+
throw new ConflictException('Parcela já liquidada');
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
throw new ConflictException('Parcela cancelada não pode receber baixa');
|
|
3026
3262
|
}
|
|
3027
3263
|
|
|
3028
3264
|
if (amountCents > installment.open_amount_cents) {
|
|
3029
|
-
throw new
|
|
3265
|
+
throw new ConflictException('Settlement amount exceeds open amount');
|
|
3030
3266
|
}
|
|
3031
3267
|
|
|
3032
3268
|
const paymentMethodId = await this.resolvePaymentMethodId(
|
|
@@ -3145,16 +3381,27 @@ export class FinanceService {
|
|
|
3145
3381
|
throw new NotFoundException('Financial title not found');
|
|
3146
3382
|
}
|
|
3147
3383
|
|
|
3384
|
+
return {
|
|
3385
|
+
title: updatedTitle,
|
|
3386
|
+
settlementId: settlement.id,
|
|
3387
|
+
};
|
|
3388
|
+
});
|
|
3389
|
+
|
|
3148
3390
|
return {
|
|
3149
|
-
title
|
|
3150
|
-
settlementId:
|
|
3391
|
+
...this.mapTitleToFront(result.title),
|
|
3392
|
+
settlementId: String(result.settlementId),
|
|
3151
3393
|
};
|
|
3152
|
-
})
|
|
3394
|
+
} catch (error: any) {
|
|
3395
|
+
const message = String(error?.message || '');
|
|
3153
3396
|
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3397
|
+
if (message.includes('Soma de settlement_allocation')) {
|
|
3398
|
+
throw new ConflictException(
|
|
3399
|
+
'Não foi possível registrar a baixa. Existe alocação ativa acima do limite da parcela.',
|
|
3400
|
+
);
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
throw error;
|
|
3404
|
+
}
|
|
3158
3405
|
}
|
|
3159
3406
|
|
|
3160
3407
|
private async reverseTitleSettlement(
|
|
@@ -3165,97 +3412,241 @@ export class FinanceService {
|
|
|
3165
3412
|
locale: string,
|
|
3166
3413
|
userId?: number,
|
|
3167
3414
|
) {
|
|
3168
|
-
const updatedTitle = await this.
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
},
|
|
3179
|
-
});
|
|
3415
|
+
const updatedTitle = await this.reverseSettlementInternal(
|
|
3416
|
+
settlementId,
|
|
3417
|
+
data,
|
|
3418
|
+
locale,
|
|
3419
|
+
userId,
|
|
3420
|
+
{
|
|
3421
|
+
titleId,
|
|
3422
|
+
titleType,
|
|
3423
|
+
},
|
|
3424
|
+
);
|
|
3180
3425
|
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3426
|
+
if (!updatedTitle) {
|
|
3427
|
+
throw new NotFoundException('Financial title not found');
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
return this.mapTitleToFront(updatedTitle);
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
private async reverseSettlementInternal(
|
|
3434
|
+
settlementId: number,
|
|
3435
|
+
data: ReverseSettlementDto,
|
|
3436
|
+
locale: string,
|
|
3437
|
+
userId?: number,
|
|
3438
|
+
scope?: {
|
|
3439
|
+
titleId?: number;
|
|
3440
|
+
titleType?: TitleType;
|
|
3441
|
+
},
|
|
3442
|
+
) {
|
|
3443
|
+
const { title } = await this.prisma.$transaction(async (tx) => {
|
|
3444
|
+
const settlementRows = await tx.$queryRaw<
|
|
3445
|
+
Array<{
|
|
3446
|
+
id: number;
|
|
3447
|
+
settlement_type: string;
|
|
3448
|
+
settled_at: Date;
|
|
3449
|
+
amount_cents: number;
|
|
3450
|
+
description: string | null;
|
|
3451
|
+
person_id: number | null;
|
|
3452
|
+
bank_account_id: number | null;
|
|
3453
|
+
payment_method_id: number | null;
|
|
3454
|
+
created_by_user_id: number | null;
|
|
3455
|
+
entry_type: string | null;
|
|
3456
|
+
title_id: number;
|
|
3457
|
+
title_type: string;
|
|
3458
|
+
title_status: string;
|
|
3459
|
+
title_competence_date: Date | null;
|
|
3460
|
+
}>
|
|
3461
|
+
>`
|
|
3462
|
+
SELECT DISTINCT
|
|
3463
|
+
s.id,
|
|
3464
|
+
s.settlement_type::text,
|
|
3465
|
+
s.settled_at,
|
|
3466
|
+
s.amount_cents,
|
|
3467
|
+
s.description,
|
|
3468
|
+
s.person_id,
|
|
3469
|
+
s.bank_account_id,
|
|
3470
|
+
s.payment_method_id,
|
|
3471
|
+
s.created_by_user_id,
|
|
3472
|
+
COALESCE(s.entry_type::text, 'normal') AS entry_type,
|
|
3473
|
+
ft.id AS title_id,
|
|
3474
|
+
ft.title_type::text AS title_type,
|
|
3475
|
+
ft.status::text AS title_status,
|
|
3476
|
+
ft.competence_date AS title_competence_date
|
|
3477
|
+
FROM settlement s
|
|
3478
|
+
INNER JOIN settlement_allocation sa ON sa.settlement_id = s.id
|
|
3479
|
+
INNER JOIN financial_installment fi ON fi.id = sa.installment_id
|
|
3480
|
+
INNER JOIN financial_title ft ON ft.id = fi.title_id
|
|
3481
|
+
WHERE s.id = ${settlementId}
|
|
3482
|
+
FOR UPDATE OF s
|
|
3483
|
+
`;
|
|
3484
|
+
|
|
3485
|
+
const settlement = settlementRows[0];
|
|
3486
|
+
|
|
3487
|
+
if (!settlement) {
|
|
3488
|
+
throw new NotFoundException('Settlement not found');
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
if (scope?.titleId && settlement.title_id !== scope.titleId) {
|
|
3492
|
+
throw new NotFoundException('Settlement not found for this title');
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
if (scope?.titleType && settlement.title_type !== scope.titleType) {
|
|
3496
|
+
throw new NotFoundException('Settlement not found for this title type');
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
if (settlement.entry_type !== 'normal') {
|
|
3500
|
+
throw new BadRequestException('Somente liquidações normais podem ser estornadas');
|
|
3189
3501
|
}
|
|
3190
3502
|
|
|
3191
3503
|
await this.assertDateNotInClosedPeriod(
|
|
3192
3504
|
tx,
|
|
3193
|
-
|
|
3505
|
+
settlement.title_competence_date,
|
|
3194
3506
|
'reverse settlement',
|
|
3195
3507
|
);
|
|
3196
3508
|
|
|
3197
|
-
const
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
financial_installment: {
|
|
3204
|
-
title_id: title.id,
|
|
3205
|
-
},
|
|
3206
|
-
},
|
|
3207
|
-
},
|
|
3208
|
-
},
|
|
3209
|
-
include: {
|
|
3210
|
-
settlement_allocation: {
|
|
3211
|
-
include: {
|
|
3212
|
-
financial_installment: {
|
|
3213
|
-
select: {
|
|
3214
|
-
id: true,
|
|
3215
|
-
amount_cents: true,
|
|
3216
|
-
open_amount_cents: true,
|
|
3217
|
-
due_date: true,
|
|
3218
|
-
status: true,
|
|
3219
|
-
},
|
|
3220
|
-
},
|
|
3221
|
-
},
|
|
3222
|
-
},
|
|
3223
|
-
},
|
|
3224
|
-
});
|
|
3509
|
+
const alreadyReversed = await tx.$queryRaw<Array<{ id: number }>>`
|
|
3510
|
+
SELECT id
|
|
3511
|
+
FROM settlement
|
|
3512
|
+
WHERE reverses_settlement_id = ${settlement.id}
|
|
3513
|
+
LIMIT 1
|
|
3514
|
+
`;
|
|
3225
3515
|
|
|
3226
|
-
if (
|
|
3227
|
-
throw new
|
|
3516
|
+
if (alreadyReversed.length > 0) {
|
|
3517
|
+
throw new ConflictException('Liquidação já estornada.');
|
|
3228
3518
|
}
|
|
3229
3519
|
|
|
3230
|
-
|
|
3231
|
-
|
|
3520
|
+
const isReconciled = await tx.$queryRaw<Array<{ id: number }>>`
|
|
3521
|
+
SELECT id
|
|
3522
|
+
FROM bank_reconciliation
|
|
3523
|
+
WHERE settlement_id = ${settlement.id}
|
|
3524
|
+
AND status = 'reconciled'
|
|
3525
|
+
LIMIT 1
|
|
3526
|
+
`;
|
|
3527
|
+
|
|
3528
|
+
if (isReconciled.length > 0) {
|
|
3529
|
+
throw new ConflictException('Desconciliar primeiro');
|
|
3232
3530
|
}
|
|
3233
3531
|
|
|
3234
|
-
|
|
3235
|
-
|
|
3532
|
+
const allocations = await tx.$queryRaw<
|
|
3533
|
+
Array<{
|
|
3534
|
+
id: number;
|
|
3535
|
+
installment_id: number;
|
|
3536
|
+
allocated_amount_cents: number;
|
|
3537
|
+
amount_cents: number | null;
|
|
3538
|
+
discount_cents: number;
|
|
3539
|
+
interest_cents: number;
|
|
3540
|
+
penalty_cents: number;
|
|
3541
|
+
installment_amount_cents: number;
|
|
3542
|
+
installment_open_amount_cents: number;
|
|
3543
|
+
installment_due_date: Date;
|
|
3544
|
+
installment_status: string;
|
|
3545
|
+
}>
|
|
3546
|
+
>`
|
|
3547
|
+
SELECT
|
|
3548
|
+
sa.id,
|
|
3549
|
+
sa.installment_id,
|
|
3550
|
+
sa.allocated_amount_cents,
|
|
3551
|
+
sa.amount_cents,
|
|
3552
|
+
sa.discount_cents,
|
|
3553
|
+
sa.interest_cents,
|
|
3554
|
+
sa.penalty_cents,
|
|
3555
|
+
fi.amount_cents AS installment_amount_cents,
|
|
3556
|
+
fi.open_amount_cents AS installment_open_amount_cents,
|
|
3557
|
+
fi.due_date AS installment_due_date,
|
|
3558
|
+
fi.status::text AS installment_status
|
|
3559
|
+
FROM settlement_allocation sa
|
|
3560
|
+
INNER JOIN financial_installment fi ON fi.id = sa.installment_id
|
|
3561
|
+
WHERE sa.settlement_id = ${settlement.id}
|
|
3562
|
+
FOR UPDATE OF fi
|
|
3563
|
+
`;
|
|
3564
|
+
|
|
3565
|
+
if (allocations.length === 0) {
|
|
3566
|
+
throw new BadRequestException('Settlement has no allocations to reverse');
|
|
3567
|
+
}
|
|
3236
3568
|
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3569
|
+
const reversalMemo = data.reason?.trim() || data.memo?.trim() || 'Estorno';
|
|
3570
|
+
const reversalAmountCents = -Math.abs(Number(settlement.amount_cents || 0));
|
|
3571
|
+
|
|
3572
|
+
const reversalResult = await tx.$queryRaw<Array<{ id: number }>>`
|
|
3573
|
+
INSERT INTO settlement (
|
|
3574
|
+
person_id,
|
|
3575
|
+
bank_account_id,
|
|
3576
|
+
payment_method_id,
|
|
3577
|
+
settlement_type,
|
|
3578
|
+
entry_type,
|
|
3579
|
+
status,
|
|
3580
|
+
settled_at,
|
|
3581
|
+
amount_cents,
|
|
3582
|
+
description,
|
|
3583
|
+
external_reference,
|
|
3584
|
+
created_by_user_id,
|
|
3585
|
+
reverses_settlement_id,
|
|
3586
|
+
created_at,
|
|
3587
|
+
updated_at
|
|
3588
|
+
)
|
|
3589
|
+
VALUES (
|
|
3590
|
+
${settlement.person_id},
|
|
3591
|
+
${settlement.bank_account_id},
|
|
3592
|
+
${settlement.payment_method_id},
|
|
3593
|
+
${settlement.settlement_type}::settlement_settlement_type_enum,
|
|
3594
|
+
'reversal'::settlement_entry_type_enum,
|
|
3595
|
+
'confirmed'::settlement_status_enum,
|
|
3596
|
+
NOW(),
|
|
3597
|
+
${reversalAmountCents},
|
|
3598
|
+
${reversalMemo},
|
|
3599
|
+
NULL,
|
|
3600
|
+
${userId || settlement.created_by_user_id || null},
|
|
3601
|
+
${settlement.id},
|
|
3602
|
+
NOW(),
|
|
3603
|
+
NOW()
|
|
3604
|
+
)
|
|
3605
|
+
RETURNING id
|
|
3606
|
+
`;
|
|
3607
|
+
|
|
3608
|
+
const reversalId = reversalResult[0]?.id;
|
|
3609
|
+
|
|
3610
|
+
if (!reversalId) {
|
|
3611
|
+
throw new BadRequestException('Could not create reversal settlement');
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
for (const allocation of allocations) {
|
|
3615
|
+
const originalAmount = Number(
|
|
3616
|
+
allocation.amount_cents ?? allocation.allocated_amount_cents ?? 0,
|
|
3617
|
+
);
|
|
3618
|
+
|
|
3619
|
+
await tx.settlement_allocation.create({
|
|
3620
|
+
data: {
|
|
3621
|
+
settlement_id: reversalId,
|
|
3622
|
+
installment_id: allocation.installment_id,
|
|
3623
|
+
allocated_amount_cents: -Math.abs(originalAmount),
|
|
3624
|
+
discount_cents: -Math.abs(allocation.discount_cents || 0),
|
|
3625
|
+
interest_cents: -Math.abs(allocation.interest_cents || 0),
|
|
3626
|
+
penalty_cents: -Math.abs(allocation.penalty_cents || 0),
|
|
3627
|
+
},
|
|
3628
|
+
});
|
|
3240
3629
|
|
|
3241
3630
|
const nextOpenAmountCents =
|
|
3242
|
-
|
|
3631
|
+
Number(allocation.installment_open_amount_cents || 0) +
|
|
3632
|
+
Math.abs(originalAmount);
|
|
3243
3633
|
|
|
3244
|
-
if (nextOpenAmountCents >
|
|
3245
|
-
throw new
|
|
3246
|
-
`
|
|
3634
|
+
if (nextOpenAmountCents > Number(allocation.installment_amount_cents || 0)) {
|
|
3635
|
+
throw new ConflictException(
|
|
3636
|
+
`Estorno excederia o valor original da parcela ${allocation.installment_id}`,
|
|
3247
3637
|
);
|
|
3248
3638
|
}
|
|
3249
3639
|
|
|
3250
3640
|
const nextInstallmentStatus = this.resolveInstallmentStatus(
|
|
3251
|
-
|
|
3641
|
+
Number(allocation.installment_amount_cents || 0),
|
|
3252
3642
|
nextOpenAmountCents,
|
|
3253
|
-
|
|
3643
|
+
new Date(allocation.installment_due_date),
|
|
3644
|
+
allocation.installment_status,
|
|
3254
3645
|
);
|
|
3255
3646
|
|
|
3256
3647
|
await tx.financial_installment.update({
|
|
3257
3648
|
where: {
|
|
3258
|
-
id:
|
|
3649
|
+
id: allocation.installment_id,
|
|
3259
3650
|
},
|
|
3260
3651
|
data: {
|
|
3261
3652
|
open_amount_cents: nextOpenAmountCents,
|
|
@@ -3264,54 +3655,47 @@ export class FinanceService {
|
|
|
3264
3655
|
});
|
|
3265
3656
|
}
|
|
3266
3657
|
|
|
3267
|
-
await
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
data: {
|
|
3272
|
-
status: 'reversed',
|
|
3273
|
-
description: [
|
|
3274
|
-
settlement.description,
|
|
3275
|
-
data.reason ? `Reversed: ${data.reason.trim()}` : 'Reversed',
|
|
3276
|
-
]
|
|
3277
|
-
.filter(Boolean)
|
|
3278
|
-
.join(' | '),
|
|
3279
|
-
},
|
|
3280
|
-
});
|
|
3281
|
-
|
|
3282
|
-
const previousTitleStatus = title.status;
|
|
3283
|
-
const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
|
|
3658
|
+
const nextTitleStatus = await this.recalculateTitleStatus(
|
|
3659
|
+
tx,
|
|
3660
|
+
settlement.title_id,
|
|
3661
|
+
);
|
|
3284
3662
|
|
|
3285
3663
|
await this.createAuditLog(tx, {
|
|
3286
3664
|
action: 'REVERSE_SETTLEMENT',
|
|
3287
3665
|
entityTable: 'financial_title',
|
|
3288
|
-
entityId: String(
|
|
3666
|
+
entityId: String(settlement.title_id),
|
|
3289
3667
|
actorUserId: userId,
|
|
3290
|
-
summary: `
|
|
3668
|
+
summary: `Created reversal ${reversalId} for settlement ${settlement.id}`,
|
|
3291
3669
|
beforeData: JSON.stringify({
|
|
3292
|
-
title_status:
|
|
3293
|
-
|
|
3670
|
+
title_status: settlement.title_status,
|
|
3671
|
+
settlement_id: settlement.id,
|
|
3672
|
+
settlement_entry_type: settlement.entry_type,
|
|
3294
3673
|
}),
|
|
3295
3674
|
afterData: JSON.stringify({
|
|
3296
3675
|
title_status: nextTitleStatus,
|
|
3297
|
-
|
|
3676
|
+
settlement_id: settlement.id,
|
|
3677
|
+
reversal_settlement_id: reversalId,
|
|
3298
3678
|
}),
|
|
3299
3679
|
});
|
|
3300
3680
|
|
|
3301
|
-
|
|
3681
|
+
const updatedTitle = await tx.financial_title.findFirst({
|
|
3302
3682
|
where: {
|
|
3303
|
-
id:
|
|
3304
|
-
title_type:
|
|
3683
|
+
id: settlement.title_id,
|
|
3684
|
+
title_type: settlement.title_type as TitleType,
|
|
3305
3685
|
},
|
|
3306
3686
|
include: this.defaultTitleInclude(),
|
|
3307
3687
|
});
|
|
3308
|
-
});
|
|
3309
3688
|
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3689
|
+
if (!updatedTitle) {
|
|
3690
|
+
throw new NotFoundException('Financial title not found');
|
|
3691
|
+
}
|
|
3313
3692
|
|
|
3314
|
-
|
|
3693
|
+
return {
|
|
3694
|
+
title: updatedTitle,
|
|
3695
|
+
};
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
return title;
|
|
3315
3699
|
}
|
|
3316
3700
|
|
|
3317
3701
|
private async updateTitleTags(
|