@hed-hog/finance 0.0.238 → 0.0.239

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/dto/reject-title.dto.d.ts +4 -0
  2. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  3. package/dist/dto/reject-title.dto.js +22 -0
  4. package/dist/dto/reject-title.dto.js.map +1 -0
  5. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  6. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  7. package/dist/dto/reverse-settlement.dto.js +22 -0
  8. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  9. package/dist/dto/settle-installment.dto.d.ts +12 -0
  10. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  11. package/dist/dto/settle-installment.dto.js +71 -0
  12. package/dist/dto/settle-installment.dto.js.map +1 -0
  13. package/dist/finance-data.controller.d.ts +13 -5
  14. package/dist/finance-data.controller.d.ts.map +1 -1
  15. package/dist/finance-installments.controller.d.ts +248 -12
  16. package/dist/finance-installments.controller.d.ts.map +1 -1
  17. package/dist/finance-installments.controller.js +92 -0
  18. package/dist/finance-installments.controller.js.map +1 -1
  19. package/dist/finance.service.d.ts +275 -17
  20. package/dist/finance.service.d.ts.map +1 -1
  21. package/dist/finance.service.js +666 -78
  22. package/dist/finance.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +63 -0
  24. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  25. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +355 -4
  26. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +440 -16
  27. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
  28. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +432 -14
  29. package/package.json +5 -5
  30. package/src/dto/reject-title.dto.ts +7 -0
  31. package/src/dto/reverse-settlement.dto.ts +7 -0
  32. package/src/dto/settle-installment.dto.ts +55 -0
  33. package/src/finance-installments.controller.ts +102 -0
  34. package/src/finance.service.ts +1007 -82
@@ -4,6 +4,7 @@ import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/finance/_componen
4
4
  import { Page, PageHeader } from '@/components/entity-list';
5
5
  import { Badge } from '@/components/ui/badge';
6
6
  import { Button } from '@/components/ui/button';
7
+ import { Checkbox } from '@/components/ui/checkbox';
7
8
  import {
8
9
  DropdownMenu,
9
10
  DropdownMenuContent,
@@ -22,6 +23,7 @@ import {
22
23
  } from '@/components/ui/form';
23
24
  import { Input } from '@/components/ui/input';
24
25
  import { InputMoney } from '@/components/ui/input-money';
26
+ import { Label } from '@/components/ui/label';
25
27
  import { Money } from '@/components/ui/money';
26
28
  import { Progress } from '@/components/ui/progress';
27
29
  import {
@@ -63,23 +65,159 @@ import {
63
65
  } from 'lucide-react';
64
66
  import { useTranslations } from 'next-intl';
65
67
  import Link from 'next/link';
66
- import { useState } from 'react';
67
- import { useForm } from 'react-hook-form';
68
+ import { useEffect, useRef, useState } from 'react';
69
+ import { useFieldArray, useForm } from 'react-hook-form';
68
70
  import { z } from 'zod';
69
71
  import { formatarData } from '../../_lib/formatters';
70
72
  import { useFinanceData } from '../../_lib/use-finance-data';
71
73
 
72
- const newTitleFormSchema = z.object({
73
- documento: z.string().trim().min(1, 'Documento é obrigatório'),
74
- clienteId: z.string().min(1, 'Cliente é obrigatório'),
75
- competencia: z.string().optional(),
76
- vencimento: z.string().min(1, 'Vencimento é obrigatório'),
77
- valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
78
- categoriaId: z.string().optional(),
79
- centroCustoId: z.string().optional(),
80
- canal: z.string().optional(),
81
- descricao: z.string().optional(),
82
- });
74
+ const INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS = 300;
75
+
76
+ const addMonthsToDate = (date: string, monthsToAdd: number) => {
77
+ if (!date) {
78
+ return '';
79
+ }
80
+
81
+ const baseDate = new Date(`${date}T00:00:00`);
82
+ if (Number.isNaN(baseDate.getTime())) {
83
+ return date;
84
+ }
85
+
86
+ const nextDate = new Date(baseDate);
87
+ nextDate.setMonth(nextDate.getMonth() + monthsToAdd);
88
+ return nextDate.toISOString().slice(0, 10);
89
+ };
90
+
91
+ const buildEqualInstallments = (
92
+ installmentsCount: number,
93
+ totalAmount: number,
94
+ firstDueDate: string
95
+ ) => {
96
+ const safeInstallmentsCount = Math.max(1, Math.floor(installmentsCount || 1));
97
+ const totalCents = Math.max(0, Math.round((totalAmount || 0) * 100));
98
+ const baseCents = Math.floor(totalCents / safeInstallmentsCount);
99
+ const remainder = totalCents % safeInstallmentsCount;
100
+
101
+ return Array.from({ length: safeInstallmentsCount }, (_, index) => ({
102
+ dueDate: addMonthsToDate(firstDueDate, index),
103
+ amount: (baseCents + (index < remainder ? 1 : 0)) / 100,
104
+ }));
105
+ };
106
+
107
+ const redistributeRemainingInstallments = (
108
+ installments: Array<{ dueDate: string; amount: number }>,
109
+ editedIndex: number,
110
+ editedAmount: number,
111
+ totalAmount: number
112
+ ) => {
113
+ if (installments.length === 0) {
114
+ return installments;
115
+ }
116
+
117
+ const totalCents = Math.max(0, Math.round((totalAmount || 0) * 100));
118
+
119
+ if (installments.length === 1) {
120
+ return installments.map((installment, index) =>
121
+ index === editedIndex
122
+ ? {
123
+ ...installment,
124
+ amount: totalCents / 100,
125
+ }
126
+ : installment
127
+ );
128
+ }
129
+
130
+ const minimumPerInstallmentCents = totalCents >= installments.length ? 1 : 0;
131
+ const remainingInstallmentsCount = installments.length - 1;
132
+
133
+ let editedCents = Math.round((editedAmount || 0) * 100);
134
+ const minEditedCents = minimumPerInstallmentCents;
135
+ const maxEditedCents =
136
+ totalCents - remainingInstallmentsCount * minimumPerInstallmentCents;
137
+
138
+ if (maxEditedCents < minEditedCents) {
139
+ editedCents = Math.min(Math.max(editedCents, 0), totalCents);
140
+ } else {
141
+ editedCents = Math.min(
142
+ Math.max(editedCents, minEditedCents),
143
+ maxEditedCents
144
+ );
145
+ }
146
+
147
+ const remainingCents = totalCents - editedCents;
148
+ const baseCents = Math.floor(remainingCents / remainingInstallmentsCount);
149
+ let remainderCents = remainingCents % remainingInstallmentsCount;
150
+ let processedInstallments = 0;
151
+
152
+ return installments.map((installment, index) => {
153
+ if (index === editedIndex) {
154
+ return {
155
+ ...installment,
156
+ amount: editedCents / 100,
157
+ };
158
+ }
159
+
160
+ const amountCents =
161
+ baseCents + (processedInstallments < remainderCents ? 1 : 0);
162
+ processedInstallments += 1;
163
+
164
+ return {
165
+ ...installment,
166
+ amount: amountCents / 100,
167
+ };
168
+ });
169
+ };
170
+
171
+ const newTitleFormSchema = z
172
+ .object({
173
+ documento: z.string().trim().min(1, 'Documento é obrigatório'),
174
+ clienteId: z.string().min(1, 'Cliente é obrigatório'),
175
+ competencia: z.string().optional(),
176
+ vencimento: z.string().min(1, 'Vencimento é obrigatório'),
177
+ valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
178
+ installmentsCount: z.coerce
179
+ .number({ invalid_type_error: 'Quantidade de parcelas inválida' })
180
+ .int('Quantidade de parcelas inválida')
181
+ .min(1, 'Mínimo de 1 parcela')
182
+ .max(120, 'Máximo de 120 parcelas'),
183
+ installments: z
184
+ .array(
185
+ z.object({
186
+ dueDate: z.string().min(1, 'Vencimento da parcela é obrigatório'),
187
+ amount: z
188
+ .number()
189
+ .min(0.01, 'Valor da parcela deve ser maior que zero'),
190
+ })
191
+ )
192
+ .min(1, 'Informe ao menos uma parcela'),
193
+ categoriaId: z.string().optional(),
194
+ centroCustoId: z.string().optional(),
195
+ canal: z.string().optional(),
196
+ descricao: z.string().optional(),
197
+ })
198
+ .superRefine((values, ctx) => {
199
+ if (values.installments.length !== values.installmentsCount) {
200
+ ctx.addIssue({
201
+ code: z.ZodIssueCode.custom,
202
+ path: ['installments'],
203
+ message: 'Quantidade de parcelas não confere com o detalhamento',
204
+ });
205
+ }
206
+
207
+ const installmentsTotalCents = values.installments.reduce(
208
+ (acc, installment) => acc + Math.round((installment.amount || 0) * 100),
209
+ 0
210
+ );
211
+ const totalCents = Math.round((values.valor || 0) * 100);
212
+
213
+ if (installmentsTotalCents !== totalCents) {
214
+ ctx.addIssue({
215
+ code: z.ZodIssueCode.custom,
216
+ path: ['installments'],
217
+ message: 'A soma das parcelas deve ser igual ao valor total',
218
+ });
219
+ }
220
+ });
83
221
 
84
222
  type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
85
223
 
@@ -100,6 +238,12 @@ function NovoTituloSheet({
100
238
  const [uploadedFileName, setUploadedFileName] = useState('');
101
239
  const [isUploadingFile, setIsUploadingFile] = useState(false);
102
240
  const [isExtractingFileData, setIsExtractingFileData] = useState(false);
241
+ const [isInstallmentsEdited, setIsInstallmentsEdited] = useState(false);
242
+ const [autoRedistributeInstallments, setAutoRedistributeInstallments] =
243
+ useState(true);
244
+ const redistributionTimeoutRef = useRef<
245
+ Record<number, ReturnType<typeof setTimeout>>
246
+ >({});
103
247
  const [extractionConfidence, setExtractionConfidence] = useState<
104
248
  number | null
105
249
  >(null);
@@ -132,6 +276,8 @@ function NovoTituloSheet({
132
276
  competencia: '',
133
277
  vencimento: '',
134
278
  valor: 0,
279
+ installmentsCount: 1,
280
+ installments: [{ dueDate: '', amount: 0 }],
135
281
  categoriaId: '',
136
282
  centroCustoId: '',
137
283
  canal: '',
@@ -139,6 +285,101 @@ function NovoTituloSheet({
139
285
  },
140
286
  });
141
287
 
288
+ const { fields: installmentFields, replace: replaceInstallments } =
289
+ useFieldArray({
290
+ control: form.control,
291
+ name: 'installments',
292
+ });
293
+
294
+ const watchedInstallmentsCount = form.watch('installmentsCount');
295
+ const watchedTotalValue = form.watch('valor');
296
+ const watchedDueDate = form.watch('vencimento');
297
+ const watchedInstallments = form.watch('installments');
298
+
299
+ useEffect(() => {
300
+ if (isInstallmentsEdited) {
301
+ return;
302
+ }
303
+
304
+ replaceInstallments(
305
+ buildEqualInstallments(
306
+ watchedInstallmentsCount,
307
+ watchedTotalValue,
308
+ watchedDueDate
309
+ )
310
+ );
311
+ }, [
312
+ isInstallmentsEdited,
313
+ replaceInstallments,
314
+ watchedDueDate,
315
+ watchedInstallmentsCount,
316
+ watchedTotalValue,
317
+ ]);
318
+
319
+ const installmentsTotal = (watchedInstallments || []).reduce(
320
+ (acc, installment) => acc + Number(installment?.amount || 0),
321
+ 0
322
+ );
323
+ const installmentsDiffCents = Math.abs(
324
+ Math.round(installmentsTotal * 100) -
325
+ Math.round((watchedTotalValue || 0) * 100)
326
+ );
327
+
328
+ const clearScheduledRedistribution = (index: number) => {
329
+ const timeout = redistributionTimeoutRef.current[index];
330
+ if (!timeout) {
331
+ return;
332
+ }
333
+
334
+ clearTimeout(timeout);
335
+ delete redistributionTimeoutRef.current[index];
336
+ };
337
+
338
+ const runInstallmentRedistribution = (index: number) => {
339
+ if (!autoRedistributeInstallments) {
340
+ return;
341
+ }
342
+
343
+ const currentInstallments = form.getValues('installments');
344
+ const editedInstallmentAmount = Number(
345
+ currentInstallments[index]?.amount || 0
346
+ );
347
+
348
+ const redistributedInstallments = redistributeRemainingInstallments(
349
+ currentInstallments,
350
+ index,
351
+ editedInstallmentAmount,
352
+ form.getValues('valor')
353
+ );
354
+
355
+ replaceInstallments(redistributedInstallments);
356
+ };
357
+
358
+ const scheduleInstallmentRedistribution = (index: number) => {
359
+ clearScheduledRedistribution(index);
360
+
361
+ redistributionTimeoutRef.current[index] = setTimeout(() => {
362
+ runInstallmentRedistribution(index);
363
+ delete redistributionTimeoutRef.current[index];
364
+ }, INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS);
365
+ };
366
+
367
+ useEffect(() => {
368
+ if (autoRedistributeInstallments) {
369
+ return;
370
+ }
371
+
372
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
373
+ redistributionTimeoutRef.current = {};
374
+ }, [autoRedistributeInstallments]);
375
+
376
+ useEffect(() => {
377
+ return () => {
378
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
379
+ redistributionTimeoutRef.current = {};
380
+ };
381
+ }, []);
382
+
142
383
  const handleSubmit = async (values: NewTitleFormValues) => {
143
384
  try {
144
385
  await request({
@@ -160,6 +401,11 @@ function NovoTituloSheet({
160
401
  : undefined,
161
402
  payment_channel: values.canal || undefined,
162
403
  description: values.descricao?.trim() || undefined,
404
+ installments: values.installments.map((installment, index) => ({
405
+ installment_number: index + 1,
406
+ due_date: installment.dueDate || values.vencimento,
407
+ amount: installment.amount,
408
+ })),
163
409
  attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
164
410
  },
165
411
  });
@@ -170,6 +416,8 @@ function NovoTituloSheet({
170
416
  setUploadedFileName('');
171
417
  setExtractionConfidence(null);
172
418
  setExtractionWarnings([]);
419
+ setIsInstallmentsEdited(false);
420
+ setAutoRedistributeInstallments(true);
173
421
  setOpen(false);
174
422
  showToastHandler?.('success', 'Título criado com sucesso');
175
423
  } catch {
@@ -184,6 +432,8 @@ function NovoTituloSheet({
184
432
  setExtractionConfidence(null);
185
433
  setExtractionWarnings([]);
186
434
  setUploadProgress(0);
435
+ setIsInstallmentsEdited(false);
436
+ setAutoRedistributeInstallments(true);
187
437
  setOpen(false);
188
438
  };
189
439
 
@@ -506,6 +756,168 @@ function NovoTituloSheet({
506
756
  )}
507
757
  />
508
758
 
759
+ <FormField
760
+ control={form.control}
761
+ name="installmentsCount"
762
+ render={({ field }) => (
763
+ <FormItem>
764
+ <FormLabel>Quantidade de Parcelas</FormLabel>
765
+ <FormControl>
766
+ <Input
767
+ type="number"
768
+ min={1}
769
+ max={120}
770
+ value={field.value}
771
+ onChange={(event) => {
772
+ const nextValue = Number(event.target.value || 1);
773
+ field.onChange(
774
+ Number.isNaN(nextValue) ? 1 : nextValue
775
+ );
776
+ }}
777
+ />
778
+ </FormControl>
779
+ <FormMessage />
780
+ </FormItem>
781
+ )}
782
+ />
783
+
784
+ <div className="space-y-3 rounded-md border p-3">
785
+ <div className="flex items-center justify-between gap-2">
786
+ <p className="text-sm font-medium">Parcelas</p>
787
+ <Button
788
+ type="button"
789
+ variant="outline"
790
+ size="sm"
791
+ onClick={() => {
792
+ setIsInstallmentsEdited(false);
793
+ replaceInstallments(
794
+ buildEqualInstallments(
795
+ form.getValues('installmentsCount'),
796
+ form.getValues('valor'),
797
+ form.getValues('vencimento')
798
+ )
799
+ );
800
+ }}
801
+ >
802
+ Recalcular automaticamente
803
+ </Button>
804
+ </div>
805
+
806
+ <div className="flex items-center gap-2">
807
+ <Checkbox
808
+ id="auto-redistribute-installments-receivable"
809
+ checked={autoRedistributeInstallments}
810
+ onCheckedChange={(checked) =>
811
+ setAutoRedistributeInstallments(checked === true)
812
+ }
813
+ />
814
+ <Label
815
+ htmlFor="auto-redistribute-installments-receivable"
816
+ className="text-xs text-muted-foreground"
817
+ >
818
+ Redistribuir automaticamente o restante ao editar parcela
819
+ </Label>
820
+ </div>
821
+ {autoRedistributeInstallments && (
822
+ <p className="text-xs text-muted-foreground">
823
+ A redistribuição ocorre ao parar de digitar e ao sair do
824
+ campo.
825
+ </p>
826
+ )}
827
+
828
+ <div className="space-y-2">
829
+ {installmentFields.map((installment, index) => (
830
+ <div
831
+ key={installment.id}
832
+ className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
833
+ >
834
+ <div className="flex items-center text-sm text-muted-foreground">
835
+ #{index + 1}
836
+ </div>
837
+
838
+ <FormField
839
+ control={form.control}
840
+ name={`installments.${index}.dueDate` as const}
841
+ render={({ field }) => (
842
+ <FormItem>
843
+ <FormLabel className="text-xs">
844
+ Vencimento
845
+ </FormLabel>
846
+ <FormControl>
847
+ <Input
848
+ type="date"
849
+ {...field}
850
+ value={field.value || ''}
851
+ onChange={(event) => {
852
+ setIsInstallmentsEdited(true);
853
+ field.onChange(event);
854
+ }}
855
+ />
856
+ </FormControl>
857
+ <FormMessage />
858
+ </FormItem>
859
+ )}
860
+ />
861
+
862
+ <FormField
863
+ control={form.control}
864
+ name={`installments.${index}.amount` as const}
865
+ render={({ field }) => (
866
+ <FormItem>
867
+ <FormLabel className="text-xs">Valor</FormLabel>
868
+ <FormControl>
869
+ <InputMoney
870
+ ref={field.ref}
871
+ name={field.name}
872
+ value={field.value}
873
+ onBlur={() => {
874
+ field.onBlur();
875
+
876
+ if (!autoRedistributeInstallments) {
877
+ return;
878
+ }
879
+
880
+ clearScheduledRedistribution(index);
881
+ runInstallmentRedistribution(index);
882
+ }}
883
+ onValueChange={(value) => {
884
+ setIsInstallmentsEdited(true);
885
+ field.onChange(value ?? 0);
886
+
887
+ if (!autoRedistributeInstallments) {
888
+ return;
889
+ }
890
+
891
+ scheduleInstallmentRedistribution(index);
892
+ }}
893
+ placeholder="0,00"
894
+ />
895
+ </FormControl>
896
+ <FormMessage />
897
+ </FormItem>
898
+ )}
899
+ />
900
+ </div>
901
+ ))}
902
+ </div>
903
+
904
+ <p
905
+ className={`text-xs ${
906
+ installmentsDiffCents === 0
907
+ ? 'text-muted-foreground'
908
+ : 'text-destructive'
909
+ }`}
910
+ >
911
+ Soma das parcelas: {installmentsTotal.toFixed(2)}
912
+ {installmentsDiffCents > 0 && ' (ajuste necessário)'}
913
+ </p>
914
+ {form.formState.errors.installments?.message && (
915
+ <p className="text-xs text-destructive">
916
+ {form.formState.errors.installments.message}
917
+ </p>
918
+ )}
919
+ </div>
920
+
509
921
  <FormField
510
922
  control={form.control}
511
923
  name="categoriaId"
@@ -838,7 +1250,13 @@ export default function TitulosReceberPage() {
838
1250
  {t('table.actions.edit')}
839
1251
  </DropdownMenuItem>
840
1252
  <DropdownMenuSeparator />
841
- <DropdownMenuItem>
1253
+ <DropdownMenuItem
1254
+ disabled={
1255
+ !['aprovado', 'aberto', 'parcial'].includes(
1256
+ titulo.status
1257
+ )
1258
+ }
1259
+ >
842
1260
  <Download className="mr-2 h-4 w-4" />
843
1261
  {t('table.actions.registerReceipt')}
844
1262
  </DropdownMenuItem>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/finance",
3
- "version": "0.0.238",
3
+ "version": "0.0.239",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -10,13 +10,13 @@
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api-prisma": "0.0.4",
13
- "@hed-hog/api-locale": "0.0.11",
14
13
  "@hed-hog/api-pagination": "0.0.5",
15
- "@hed-hog/tag": "0.0.238",
14
+ "@hed-hog/api-locale": "0.0.11",
16
15
  "@hed-hog/api-types": "0.0.1",
16
+ "@hed-hog/tag": "0.0.239",
17
17
  "@hed-hog/api": "0.0.3",
18
- "@hed-hog/contact": "0.0.238",
19
- "@hed-hog/core": "0.0.238"
18
+ "@hed-hog/contact": "0.0.239",
19
+ "@hed-hog/core": "0.0.239"
20
20
  },
21
21
  "exports": {
22
22
  ".": {
@@ -0,0 +1,7 @@
1
+ import { IsOptional, IsString } from 'class-validator';
2
+
3
+ export class RejectTitleDto {
4
+ @IsOptional()
5
+ @IsString()
6
+ reason?: string;
7
+ }
@@ -0,0 +1,7 @@
1
+ import { IsOptional, IsString } from 'class-validator';
2
+
3
+ export class ReverseSettlementDto {
4
+ @IsOptional()
5
+ @IsString()
6
+ reason?: string;
7
+ }
@@ -0,0 +1,55 @@
1
+ import { Type } from 'class-transformer';
2
+ import {
3
+ IsDateString,
4
+ IsInt,
5
+ IsNumber,
6
+ IsOptional,
7
+ IsString,
8
+ Min,
9
+ } from 'class-validator';
10
+
11
+ export class SettleInstallmentDto {
12
+ @Type(() => Number)
13
+ @IsInt()
14
+ installment_id: number;
15
+
16
+ @Type(() => Number)
17
+ @IsNumber()
18
+ @Min(0.01)
19
+ amount: number;
20
+
21
+ @IsOptional()
22
+ @IsDateString()
23
+ settled_at?: string;
24
+
25
+ @IsOptional()
26
+ @Type(() => Number)
27
+ @IsInt()
28
+ bank_account_id?: number;
29
+
30
+ @IsOptional()
31
+ @IsString()
32
+ payment_channel?: string;
33
+
34
+ @IsOptional()
35
+ @Type(() => Number)
36
+ @IsNumber()
37
+ @Min(0)
38
+ discount?: number;
39
+
40
+ @IsOptional()
41
+ @Type(() => Number)
42
+ @IsNumber()
43
+ @Min(0)
44
+ interest?: number;
45
+
46
+ @IsOptional()
47
+ @Type(() => Number)
48
+ @IsNumber()
49
+ @Min(0)
50
+ penalty?: number;
51
+
52
+ @IsOptional()
53
+ @IsString()
54
+ description?: string;
55
+ }