@hed-hog/finance 0.0.364 → 0.0.365

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.
@@ -26,7 +26,10 @@ import { CreateCostCenterDto } from './dto/create-cost-center.dto';
26
26
  import { CreateCurrencyDto } from './dto/create-currency.dto';
27
27
  import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
28
28
  import { CreateFinanceTagDto } from './dto/create-finance-tag.dto';
29
- import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
29
+ import {
30
+ CreateFinancialInstallmentDto,
31
+ CreateFinancialTitleDto,
32
+ } from './dto/create-financial-title.dto';
30
33
  import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
31
34
  import { CreateTransferDto } from './dto/create-transfer.dto';
32
35
  import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
@@ -5113,8 +5116,29 @@ export class FinanceService {
5113
5116
  );
5114
5117
  }
5115
5118
 
5119
+ if (rule && (data.installments || []).some((installment) => installment.paid)) {
5120
+ throw new BadRequestException(
5121
+ getLocaleText(
5122
+ 'recurringCannotBePaid',
5123
+ locale,
5124
+ 'Recurring titles cannot be created as already paid',
5125
+ ),
5126
+ );
5127
+ }
5128
+
5116
5129
  const isRecurring = Boolean(rule);
5117
- const installments = isRecurring
5130
+ type NormalizedInstallment = {
5131
+ installment_number: number;
5132
+ due_date: string;
5133
+ amount_cents: number;
5134
+ paid?: boolean;
5135
+ paid_at?: string;
5136
+ paid_amount_cents?: number;
5137
+ discount_cents?: number;
5138
+ interest_cents?: number;
5139
+ penalty_cents?: number;
5140
+ };
5141
+ const installments: NormalizedInstallment[] = isRecurring
5118
5142
  ? this.buildRecurrenceInstallments(
5119
5143
  data.due_date,
5120
5144
  rule!.frequency,
@@ -5124,7 +5148,11 @@ export class FinanceService {
5124
5148
  )
5125
5149
  : this.normalizeAndValidateInstallments(data, locale);
5126
5150
 
5127
- const createdTitleId = await this.prisma.$transaction(async (tx) => {
5151
+ const hasPaidInstallments = installments.some(
5152
+ (installment) => installment.paid,
5153
+ );
5154
+
5155
+ const createResult = await this.prisma.$transaction(async (tx) => {
5128
5156
  const person = await tx.person.findUnique({
5129
5157
  where: { id: data.person_id },
5130
5158
  select: { id: true },
@@ -5188,6 +5216,19 @@ export class FinanceService {
5188
5216
  }
5189
5217
  }
5190
5218
 
5219
+ if (hasPaidInstallments && data.bank_account_id) {
5220
+ const bankAccount = await tx.bank_account.findUnique({
5221
+ where: { id: data.bank_account_id },
5222
+ select: { id: true },
5223
+ });
5224
+
5225
+ if (!bankAccount) {
5226
+ throw new BadRequestException(
5227
+ getLocaleText('bankAccountNotFound', locale, 'Bank account not found'),
5228
+ );
5229
+ }
5230
+ }
5231
+
5191
5232
  await this.assertDateNotInClosedPeriod(
5192
5233
  tx,
5193
5234
  data.competence_date
@@ -5205,7 +5246,7 @@ export class FinanceService {
5205
5246
  person_id: data.person_id,
5206
5247
  title_type: titleType,
5207
5248
  status: 'draft',
5208
- document_number: data.document_number,
5249
+ document_number: data.document_number?.trim() || null,
5209
5250
  description: data.description,
5210
5251
  competence_date: data.competence_date
5211
5252
  ? this.parseLocalDate(data.competence_date)
@@ -5230,6 +5271,13 @@ export class FinanceService {
5230
5271
  });
5231
5272
  }
5232
5273
 
5274
+ const appliedSettlements: Array<{
5275
+ settlementId: number;
5276
+ installmentId: number;
5277
+ openAmountCentsBefore: number;
5278
+ openAmountCentsAfter: number;
5279
+ }> = [];
5280
+
5233
5281
  for (let index = 0; index < installments.length; index++) {
5234
5282
  const installment = installments[index];
5235
5283
  const amountCents = installment.amount_cents;
@@ -5262,25 +5310,84 @@ export class FinanceService {
5262
5310
  },
5263
5311
  });
5264
5312
  }
5313
+
5314
+ if (installment.paid) {
5315
+ const appliedSettlement = await this.applyInstallmentSettlement(tx, {
5316
+ title: { id: title.id, person_id: data.person_id },
5317
+ titleType,
5318
+ installmentId: createdInstallment.id,
5319
+ amountCents: installment.paid_amount_cents ?? amountCents,
5320
+ settledAt: this.parseLocalDate(
5321
+ installment.paid_at || installment.due_date,
5322
+ ),
5323
+ bankAccountId: data.bank_account_id ?? null,
5324
+ paymentChannel: data.payment_channel,
5325
+ discountCents: installment.discount_cents,
5326
+ interestCents: installment.interest_cents,
5327
+ penaltyCents: installment.penalty_cents,
5328
+ userId,
5329
+ });
5330
+
5331
+ appliedSettlements.push(appliedSettlement);
5332
+ }
5265
5333
  }
5266
5334
 
5335
+ const finalStatus =
5336
+ appliedSettlements.length > 0
5337
+ ? await this.recalculateTitleStatus(tx, title.id)
5338
+ : 'draft';
5339
+
5267
5340
  await this.createAuditLog(tx, {
5268
5341
  action: 'CREATE_TITLE',
5269
5342
  entityTable: 'financial_title',
5270
5343
  entityId: String(title.id),
5271
5344
  actorUserId: userId,
5272
- summary: `Created ${titleType} title ${title.id} in draft`,
5345
+ summary: `Created ${titleType} title ${title.id} in ${finalStatus}`,
5273
5346
  afterData: JSON.stringify({
5274
- status: 'draft',
5347
+ status: finalStatus,
5275
5348
  total_amount_cents: this.toCents(data.total_amount),
5276
5349
  }),
5277
5350
  });
5278
5351
 
5279
- return title.id;
5352
+ for (const appliedSettlement of appliedSettlements) {
5353
+ await this.createAuditLog(tx, {
5354
+ action: 'SETTLE_INSTALLMENT',
5355
+ entityTable: 'financial_title',
5356
+ entityId: String(title.id),
5357
+ actorUserId: userId,
5358
+ summary: `Settled installment ${appliedSettlement.installmentId} of title ${title.id}`,
5359
+ beforeData: JSON.stringify({
5360
+ title_status: 'draft',
5361
+ installment_open_amount_cents:
5362
+ appliedSettlement.openAmountCentsBefore,
5363
+ }),
5364
+ afterData: JSON.stringify({
5365
+ title_status: finalStatus,
5366
+ installment_open_amount_cents:
5367
+ appliedSettlement.openAmountCentsAfter,
5368
+ settlement_id: appliedSettlement.settlementId,
5369
+ bank_reconciliation_id: null,
5370
+ }),
5371
+ });
5372
+ }
5373
+
5374
+ return {
5375
+ titleId: title.id,
5376
+ settlementIds: appliedSettlements.map(
5377
+ (appliedSettlement) => appliedSettlement.settlementId,
5378
+ ),
5379
+ };
5280
5380
  });
5281
5381
 
5282
- const createdTitle = await this.getTitleById(createdTitleId, titleType, locale);
5283
- this.financeRealtime.publish({ domain: 'installment', type: 'created', entityId: createdTitleId });
5382
+ const createdTitle = await this.getTitleById(
5383
+ createResult.titleId,
5384
+ titleType,
5385
+ locale,
5386
+ );
5387
+ this.financeRealtime.publish({ domain: 'installment', type: 'created', entityId: createResult.titleId });
5388
+ for (const settlementId of createResult.settlementIds) {
5389
+ this.financeRealtime.publish({ domain: 'settlement', type: 'settled', entityId: settlementId });
5390
+ }
5284
5391
  return this.mapTitleToFront(createdTitle, data.payment_channel);
5285
5392
  }
5286
5393
 
@@ -5438,7 +5545,7 @@ export class FinanceService {
5438
5545
  where: { id: title.id },
5439
5546
  data: {
5440
5547
  person_id: data.person_id,
5441
- document_number: data.document_number,
5548
+ document_number: data.document_number?.trim() || null,
5442
5549
  description: data.description,
5443
5550
  competence_date: data.competence_date
5444
5551
  ? this.parseLocalDate(data.competence_date)
@@ -5602,7 +5709,7 @@ export class FinanceService {
5602
5709
  ) {
5603
5710
  const fallbackDueDate = data.due_date;
5604
5711
  const totalAmountCents = this.toCents(data.total_amount);
5605
- const sourceInstallments =
5712
+ const sourceInstallments: CreateFinancialInstallmentDto[] =
5606
5713
  data.installments && data.installments.length > 0
5607
5714
  ? data.installments
5608
5715
  : [
@@ -5639,10 +5746,31 @@ export class FinanceService {
5639
5746
  );
5640
5747
  }
5641
5748
 
5749
+ const paidAmountCents =
5750
+ installment.paid_amount != null
5751
+ ? this.toCents(installment.paid_amount)
5752
+ : amountCents;
5753
+
5754
+ if (installment.paid && paidAmountCents > amountCents) {
5755
+ throw new BadRequestException(
5756
+ getLocaleText(
5757
+ 'paidAmountExceedsInstallment',
5758
+ locale,
5759
+ 'Paid amount cannot exceed installment amount',
5760
+ ),
5761
+ );
5762
+ }
5763
+
5642
5764
  return {
5643
5765
  installment_number: installment.installment_number || index + 1,
5644
5766
  due_date: installmentDueDate,
5645
5767
  amount_cents: amountCents,
5768
+ paid: installment.paid === true,
5769
+ paid_at: installment.paid_at,
5770
+ paid_amount_cents: paidAmountCents,
5771
+ discount_cents: this.toCents(installment.discount || 0),
5772
+ interest_cents: this.toCents(installment.interest || 0),
5773
+ penalty_cents: this.toCents(installment.penalty || 0),
5646
5774
  };
5647
5775
  },
5648
5776
  );
@@ -5925,6 +6053,163 @@ export class FinanceService {
5925
6053
  return this.mapTitleToFront(updatedTitle);
5926
6054
  }
5927
6055
 
6056
+ private async applyInstallmentSettlement(
6057
+ tx: any,
6058
+ params: {
6059
+ title: { id: number; person_id: number };
6060
+ titleType: TitleType;
6061
+ installmentId: number;
6062
+ amountCents: number;
6063
+ settledAt: Date;
6064
+ bankAccountId?: number | null;
6065
+ paymentChannel?: string;
6066
+ discountCents?: number;
6067
+ interestCents?: number;
6068
+ penaltyCents?: number;
6069
+ description?: string | null;
6070
+ userId?: number;
6071
+ },
6072
+ ) {
6073
+ const { title, titleType, installmentId, amountCents, settledAt } = params;
6074
+
6075
+ await this.assertDateNotInClosedPeriod(
6076
+ tx,
6077
+ settledAt,
6078
+ 'settle installment',
6079
+ );
6080
+
6081
+ const installment = await tx.financial_installment.findFirst({
6082
+ where: {
6083
+ id: installmentId,
6084
+ title_id: title.id,
6085
+ },
6086
+ select: {
6087
+ id: true,
6088
+ title_id: true,
6089
+ amount_cents: true,
6090
+ open_amount_cents: true,
6091
+ due_date: true,
6092
+ status: true,
6093
+ },
6094
+ });
6095
+
6096
+ if (!installment) {
6097
+ throw new BadRequestException('Installment not found for this title');
6098
+ }
6099
+
6100
+ if (installment.status === 'settled' || installment.status === 'canceled') {
6101
+ if (installment.status === 'settled') {
6102
+ throw new ConflictException('Parcela já liquidada');
6103
+ }
6104
+
6105
+ throw new ConflictException('Parcela cancelada não pode receber baixa');
6106
+ }
6107
+
6108
+ if (amountCents > installment.open_amount_cents) {
6109
+ throw new ConflictException('Settlement amount exceeds open amount');
6110
+ }
6111
+
6112
+ const paymentMethodId = await this.resolvePaymentMethodId(
6113
+ tx,
6114
+ params.paymentChannel,
6115
+ );
6116
+
6117
+ const settlement = await tx.settlement.create({
6118
+ data: {
6119
+ person_id: title.person_id,
6120
+ bank_account_id: params.bankAccountId || null,
6121
+ payment_method_id: paymentMethodId,
6122
+ settlement_type: titleType,
6123
+ status: 'confirmed',
6124
+ settled_at: settledAt,
6125
+ amount_cents: amountCents,
6126
+ description: params.description?.trim() || null,
6127
+ created_by_user_id: params.userId,
6128
+ },
6129
+ });
6130
+
6131
+ await tx.settlement_allocation.create({
6132
+ data: {
6133
+ settlement: {
6134
+ connect: {
6135
+ id: settlement.id,
6136
+ },
6137
+ },
6138
+ financial_installment: {
6139
+ connect: {
6140
+ id: installment.id,
6141
+ },
6142
+ },
6143
+ allocated_amount_cents: amountCents,
6144
+ amount_cents: amountCents,
6145
+ discount_cents: params.discountCents || 0,
6146
+ interest_cents: params.interestCents || 0,
6147
+ penalty_cents: params.penaltyCents || 0,
6148
+ },
6149
+ });
6150
+
6151
+ const decrementResult = await tx.financial_installment.updateMany({
6152
+ where: {
6153
+ id: installment.id,
6154
+ open_amount_cents: {
6155
+ gte: amountCents,
6156
+ },
6157
+ },
6158
+ data: {
6159
+ open_amount_cents: {
6160
+ decrement: amountCents,
6161
+ },
6162
+ },
6163
+ });
6164
+
6165
+ if (decrementResult.count !== 1) {
6166
+ throw new BadRequestException(
6167
+ 'Installment was updated concurrently, please try again',
6168
+ );
6169
+ }
6170
+
6171
+ const updatedInstallment = await tx.financial_installment.findUnique({
6172
+ where: {
6173
+ id: installment.id,
6174
+ },
6175
+ select: {
6176
+ id: true,
6177
+ amount_cents: true,
6178
+ open_amount_cents: true,
6179
+ due_date: true,
6180
+ status: true,
6181
+ },
6182
+ });
6183
+
6184
+ if (!updatedInstallment) {
6185
+ throw new NotFoundException('Installment not found');
6186
+ }
6187
+
6188
+ const nextInstallmentStatus = this.resolveInstallmentStatus(
6189
+ Number(updatedInstallment.amount_cents),
6190
+ Number(updatedInstallment.open_amount_cents),
6191
+ updatedInstallment.due_date,
6192
+ );
6193
+
6194
+ if (updatedInstallment.status !== nextInstallmentStatus) {
6195
+ await tx.financial_installment.update({
6196
+ where: {
6197
+ id: updatedInstallment.id,
6198
+ },
6199
+ data: {
6200
+ status: nextInstallmentStatus,
6201
+ },
6202
+ });
6203
+ }
6204
+
6205
+ return {
6206
+ settlementId: settlement.id as number,
6207
+ installmentId: installment.id as number,
6208
+ openAmountCentsBefore: Number(installment.open_amount_cents),
6209
+ openAmountCentsAfter: Number(updatedInstallment.open_amount_cents),
6210
+ };
6211
+ }
6212
+
5928
6213
  private async settleTitleInstallment(
5929
6214
  titleId: number,
5930
6215
  data: SettleInstallmentDto,
@@ -5978,60 +6263,19 @@ export class FinanceService {
5978
6263
  );
5979
6264
  }
5980
6265
 
5981
- await this.assertDateNotInClosedPeriod(
5982
- tx,
6266
+ const appliedSettlement = await this.applyInstallmentSettlement(tx, {
6267
+ title: { id: title.id, person_id: title.person_id },
6268
+ titleType,
6269
+ installmentId: data.installment_id,
6270
+ amountCents,
5983
6271
  settledAt,
5984
- 'settle installment',
5985
- );
5986
-
5987
- const installment = await tx.financial_installment.findFirst({
5988
- where: {
5989
- id: data.installment_id,
5990
- title_id: title.id,
5991
- },
5992
- select: {
5993
- id: true,
5994
- title_id: true,
5995
- amount_cents: true,
5996
- open_amount_cents: true,
5997
- due_date: true,
5998
- status: true,
5999
- },
6000
- });
6001
-
6002
- if (!installment) {
6003
- throw new BadRequestException('Installment not found for this title');
6004
- }
6005
-
6006
- if (installment.status === 'settled' || installment.status === 'canceled') {
6007
- if (installment.status === 'settled') {
6008
- throw new ConflictException('Parcela já liquidada');
6009
- }
6010
-
6011
- throw new ConflictException('Parcela cancelada não pode receber baixa');
6012
- }
6013
-
6014
- if (amountCents > installment.open_amount_cents) {
6015
- throw new ConflictException('Settlement amount exceeds open amount');
6016
- }
6017
-
6018
- const paymentMethodId = await this.resolvePaymentMethodId(
6019
- tx,
6020
- data.payment_channel,
6021
- );
6022
-
6023
- const settlement = await tx.settlement.create({
6024
- data: {
6025
- person_id: title.person_id,
6026
- bank_account_id: data.bank_account_id || null,
6027
- payment_method_id: paymentMethodId,
6028
- settlement_type: titleType,
6029
- status: 'confirmed',
6030
- settled_at: settledAt,
6031
- amount_cents: amountCents,
6032
- description: data.description?.trim() || null,
6033
- created_by_user_id: userId,
6034
- },
6272
+ bankAccountId: data.bank_account_id,
6273
+ paymentChannel: data.payment_channel,
6274
+ discountCents: this.toCents(data.discount || 0),
6275
+ interestCents: this.toCents(data.interest || 0),
6276
+ penaltyCents: this.toCents(data.penalty || 0),
6277
+ description: data.description,
6278
+ userId,
6035
6279
  });
6036
6280
 
6037
6281
  let reconciliationId: number | null = null;
@@ -6085,7 +6329,7 @@ export class FinanceService {
6085
6329
  const createdReconciliation = await tx.bank_reconciliation.create({
6086
6330
  data: {
6087
6331
  bank_statement_line_id: statementLine.id,
6088
- settlement_id: settlement.id,
6332
+ settlement_id: appliedSettlement.settlementId,
6089
6333
  status: 'reconciled',
6090
6334
  matched_at: settledAt,
6091
6335
  reconciled_at: settledAt,
@@ -6111,80 +6355,6 @@ export class FinanceService {
6111
6355
  }
6112
6356
  }
6113
6357
 
6114
- await tx.settlement_allocation.create({
6115
- data: {
6116
- settlement: {
6117
- connect: {
6118
- id: settlement.id,
6119
- },
6120
- },
6121
- financial_installment: {
6122
- connect: {
6123
- id: installment.id,
6124
- },
6125
- },
6126
- allocated_amount_cents: amountCents,
6127
- amount_cents: amountCents,
6128
- discount_cents: this.toCents(data.discount || 0),
6129
- interest_cents: this.toCents(data.interest || 0),
6130
- penalty_cents: this.toCents(data.penalty || 0),
6131
- },
6132
- });
6133
-
6134
- const decrementResult = await tx.financial_installment.updateMany({
6135
- where: {
6136
- id: installment.id,
6137
- open_amount_cents: {
6138
- gte: amountCents,
6139
- },
6140
- },
6141
- data: {
6142
- open_amount_cents: {
6143
- decrement: amountCents,
6144
- },
6145
- },
6146
- });
6147
-
6148
- if (decrementResult.count !== 1) {
6149
- throw new BadRequestException(
6150
- 'Installment was updated concurrently, please try again',
6151
- );
6152
- }
6153
-
6154
- const updatedInstallment = await tx.financial_installment.findUnique({
6155
- where: {
6156
- id: installment.id,
6157
- },
6158
- select: {
6159
- id: true,
6160
- amount_cents: true,
6161
- open_amount_cents: true,
6162
- due_date: true,
6163
- status: true,
6164
- },
6165
- });
6166
-
6167
- if (!updatedInstallment) {
6168
- throw new NotFoundException('Installment not found');
6169
- }
6170
-
6171
- const nextInstallmentStatus = this.resolveInstallmentStatus(
6172
- Number(updatedInstallment.amount_cents),
6173
- Number(updatedInstallment.open_amount_cents),
6174
- updatedInstallment.due_date,
6175
- );
6176
-
6177
- if (updatedInstallment.status !== nextInstallmentStatus) {
6178
- await tx.financial_installment.update({
6179
- where: {
6180
- id: updatedInstallment.id,
6181
- },
6182
- data: {
6183
- status: nextInstallmentStatus,
6184
- },
6185
- });
6186
- }
6187
-
6188
6358
  const previousTitleStatus = title.status;
6189
6359
  const nextTitleStatus = await this.recalculateTitleStatus(tx, title.id);
6190
6360
 
@@ -6193,15 +6363,15 @@ export class FinanceService {
6193
6363
  entityTable: 'financial_title',
6194
6364
  entityId: String(title.id),
6195
6365
  actorUserId: userId,
6196
- summary: `Settled installment ${installment.id} of title ${title.id}`,
6366
+ summary: `Settled installment ${appliedSettlement.installmentId} of title ${title.id}`,
6197
6367
  beforeData: JSON.stringify({
6198
6368
  title_status: previousTitleStatus,
6199
- installment_open_amount_cents: installment.open_amount_cents,
6369
+ installment_open_amount_cents: appliedSettlement.openAmountCentsBefore,
6200
6370
  }),
6201
6371
  afterData: JSON.stringify({
6202
6372
  title_status: nextTitleStatus,
6203
- installment_open_amount_cents: updatedInstallment.open_amount_cents,
6204
- settlement_id: settlement.id,
6373
+ installment_open_amount_cents: appliedSettlement.openAmountCentsAfter,
6374
+ settlement_id: appliedSettlement.settlementId,
6205
6375
  bank_reconciliation_id: reconciliationId,
6206
6376
  }),
6207
6377
  });
@@ -6220,7 +6390,7 @@ export class FinanceService {
6220
6390
 
6221
6391
  return {
6222
6392
  title: updatedTitle,
6223
- settlementId: settlement.id,
6393
+ settlementId: appliedSettlement.settlementId,
6224
6394
  reconciliationId,
6225
6395
  };
6226
6396
  });