feat: payments module, notification templates, WA float, route fixes
- Add PaymentsModule: POST /payments/intent, POST /:id/confirm, GET /payments, GET /payments/package/:id, GET /payments/track/:trackingId
- Add Payment model to Prisma schema (PaymentStatus enum, Payment table)
- Add NotificationTemplate model + NotificationsController (GET/PUT /notification-templates, POST /notification-templates/seed)
- Update NotificationsService: DB-backed templates with variable interpolation {{trackingId}} {{firstName}} {{status}} {{suiteCode}}
- Fix /bodega/paquetes: replace 11 wrong status strings with correct §08 enum values
- Fix /admin/reportes: replace EN_CAMINO_A_ECUADOR with correct §08 statuses, rewrite report page with proper KPIs and bar charts
- Fix /portal/mis-paquetes: correct §08 statuses, add 'Pagar envío' button for VERIFICADO/DECLARACION_ADUANERA packages
- Add WhatsApp float component (_components/whatsapp-float.tsx, 2 contacts: NJ ops + Cuenca aduana)
- Add /casillero/calculadora and /casillero/registro redirects (§20)
- Add /portal/pago payment page with cost breakdown (§09/§14/§15)
- Add /admin/notificaciones page: view/edit/toggle templates per event×channel
- Admin nav: add Notificaciones link
- api.ts: add notificationTemplates.* and payments.* client methods
- schema.prisma v0.4: PaymentStatus enum, Payment model, NotificationTemplate model
- db push applied to remote DB (46.202.93.92)
- All builds pass (API nest build + Next.js build)
This commit is contained in:
@@ -16,6 +16,7 @@ import { TariffsModule } from "./tariffs/tariffs.module";
|
||||
import { ProductsModule } from "./products/products.module";
|
||||
import { WarehousesModule } from "./warehouses/warehouses.module";
|
||||
import { IntegrationsModule } from "./integrations/integrations.module";
|
||||
import { PaymentsModule } from "./payments/payments.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -39,6 +40,7 @@ import { IntegrationsModule } from "./integrations/integrations.module";
|
||||
ProductsModule,
|
||||
WarehousesModule,
|
||||
IntegrationsModule,
|
||||
PaymentsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from "@nestjs/common";
|
||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
|
||||
class UpdateTemplateDto {
|
||||
body!: string;
|
||||
subject?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@Controller("notification-templates")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationsController {
|
||||
constructor(private readonly svc: NotificationsService) {}
|
||||
|
||||
/** GET /notification-templates — lista plantillas del tenant */
|
||||
@Get()
|
||||
list(@Request() req: any) {
|
||||
return this.svc.getTemplates(req.user.tenantId);
|
||||
}
|
||||
|
||||
/** PUT /notification-templates/:id — actualiza asunto/cuerpo/estado */
|
||||
@Put(":id")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateTemplateDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.svc.updateTemplate(id, req.user.tenantId, dto.body, dto.subject, dto.isActive);
|
||||
}
|
||||
|
||||
/** POST /notification-templates/seed — crea plantillas por defecto (idempotente) */
|
||||
@Post("seed")
|
||||
seed(@Request() req: any) {
|
||||
return this.svc.seedDefaultTemplates(req.user.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
|
||||
@@ -1,50 +1,138 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
|
||||
const DEFAULT_SUBJECTS: Record<string, string> = {
|
||||
REGISTRADO: "Tu paquete fue registrado — {{trackingId}}",
|
||||
EN_TRANSITO_BODEGA: "Tu paquete está en camino a NJ — {{trackingId}}",
|
||||
RECIBIDO_BODEGA: "Tu paquete llegó a bodega NJ — {{trackingId}}",
|
||||
EN_VERIFICACION: "Tu paquete está siendo verificado — {{trackingId}}",
|
||||
VERIFICADO: "Tu paquete fue verificado — {{trackingId}}",
|
||||
DECLARACION_ADUANERA: "Declaración aduanera aprobada — {{trackingId}}",
|
||||
EN_TRANSITO_ECUADOR: "Tu paquete viaja hacia Ecuador — {{trackingId}}",
|
||||
EN_ADUANA_ECUADOR: "Tu paquete está en aduana Ecuador — {{trackingId}}",
|
||||
LISTO_ENTREGA: "Tu paquete está listo para entrega — {{trackingId}}",
|
||||
ENTREGADO: "Tu paquete fue entregado — {{trackingId}}",
|
||||
INCIDENCIA: "Incidencia en tu paquete — {{trackingId}}",
|
||||
};
|
||||
|
||||
const DEFAULT_BODIES: Record<string, string> = {
|
||||
REGISTRADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue registrado en el sistema. Te notificaremos cada avance.",
|
||||
EN_TRANSITO_BODEGA: "Hola {{firstName}}, tu paquete {{trackingId}} está en tránsito hacia nuestra bodega en New Jersey.",
|
||||
RECIBIDO_BODEGA: "Hola {{firstName}}, tu paquete {{trackingId}} llegó a nuestra bodega en NJ. Estamos procesándolo.",
|
||||
EN_VERIFICACION: "Hola {{firstName}}, tu paquete {{trackingId}} está siendo verificado por nuestro equipo.",
|
||||
VERIFICADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue verificado. El cobro final fue aplicado.",
|
||||
DECLARACION_ADUANERA: "Hola {{firstName}}, la declaración aduanera SENAE de tu paquete {{trackingId}} fue aprobada.",
|
||||
EN_TRANSITO_ECUADOR: "Hola {{firstName}}, tu paquete {{trackingId}} está en tránsito hacia Ecuador. ¡Ya viene en camino!",
|
||||
EN_ADUANA_ECUADOR: "Hola {{firstName}}, tu paquete {{trackingId}} está en inspección aduanera en Ecuador.",
|
||||
LISTO_ENTREGA: "Hola {{firstName}}, tu paquete {{trackingId}} está listo para ser retirado o entregado.",
|
||||
ENTREGADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue entregado exitosamente. ¡Gracias por confiar en Moraworld Imports!",
|
||||
INCIDENCIA: "Hola {{firstName}}, hay una incidencia con tu paquete {{trackingId}}. Nuestro equipo te contactará pronto.",
|
||||
};
|
||||
|
||||
/** Sustituye variables {{key}} en una plantilla */
|
||||
function interpolate(tpl: string, vars: Record<string, string>): string {
|
||||
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** Called whenever a package status changes. Creates Notification records and stubs dispatch. */
|
||||
// ─── Gestión de plantillas ────────────────────────────────
|
||||
|
||||
async getTemplates(tenantId: string) {
|
||||
return this.prisma.client.notificationTemplate.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ event: "asc" }, { channel: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, tenantId: string, body: string, subject?: string, isActive?: boolean) {
|
||||
const tpl = await this.prisma.client.notificationTemplate.findUnique({ where: { id } });
|
||||
if (!tpl || tpl.tenantId !== tenantId) throw new NotFoundException("Plantilla no encontrada");
|
||||
return this.prisma.client.notificationTemplate.update({
|
||||
where: { id },
|
||||
data: { body, subject: subject ?? tpl.subject, isActive: isActive ?? tpl.isActive, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Crea las plantillas por defecto para un tenant (upsert — idempotente). */
|
||||
async seedDefaultTemplates(tenantId: string) {
|
||||
const events = Object.keys(DEFAULT_BODIES);
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
const ops = [];
|
||||
for (const event of events) {
|
||||
for (const channel of channels) {
|
||||
ops.push(
|
||||
this.prisma.client.notificationTemplate.upsert({
|
||||
where: { tenantId_event_channel: { tenantId, event, channel } },
|
||||
create: {
|
||||
tenantId,
|
||||
event,
|
||||
channel,
|
||||
subject: channel === "EMAIL" ? DEFAULT_SUBJECTS[event] : undefined,
|
||||
body: DEFAULT_BODIES[event],
|
||||
isActive: true,
|
||||
},
|
||||
update: {}, // no sobreescribir si ya existe
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(ops);
|
||||
return { seeded: ops.length };
|
||||
}
|
||||
|
||||
// ─── Envío de notificaciones ─────────────────────────────
|
||||
|
||||
/** Called whenever a package status changes. */
|
||||
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||
const statusLabels: Record<string, string> = {
|
||||
REGISTRADO: "fue registrado en el sistema",
|
||||
EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ",
|
||||
RECIBIDO_BODEGA: "fue recibido en la bodega de NJ",
|
||||
EN_VERIFICACION: "está siendo verificado en bodega",
|
||||
VERIFICADO: "fue verificado. El cobro final fue aplicado.",
|
||||
DECLARACION_ADUANERA: "tiene su declaración aduanera aprobada (SENAE)",
|
||||
EN_TRANSITO_ECUADOR: "está en tránsito hacia Ecuador",
|
||||
EN_ADUANA_ECUADOR: "está en inspección aduanera en Ecuador",
|
||||
LISTO_ENTREGA: "está listo para entrega",
|
||||
ENTREGADO: "fue entregado exitosamente",
|
||||
INCIDENCIA: "tiene una incidencia reportada",
|
||||
const vars: Record<string, string> = {
|
||||
trackingId: pkg.trackingId ?? "",
|
||||
firstName: user?.firstName ?? "Cliente",
|
||||
status: pkg.status ?? "",
|
||||
suiteCode: user?.suite?.code ?? "",
|
||||
};
|
||||
|
||||
const label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`;
|
||||
const body = `Tu paquete ${pkg.trackingId} ${label}.`;
|
||||
const subject = `Estado de tu paquete: ${pkg.trackingId}`;
|
||||
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
await this.prisma.client.notification.create({
|
||||
// 1. Buscar plantilla en DB
|
||||
const tpl = await this.prisma.client.notificationTemplate.findUnique({
|
||||
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel } },
|
||||
});
|
||||
|
||||
const active = tpl ? tpl.isActive : true;
|
||||
if (!active) continue;
|
||||
|
||||
const subject = interpolate(
|
||||
tpl?.subject ?? DEFAULT_SUBJECTS[pkg.status] ?? `Estado de tu paquete: ${pkg.trackingId}`,
|
||||
vars
|
||||
);
|
||||
const bodyText = interpolate(
|
||||
tpl?.body ?? DEFAULT_BODIES[pkg.status] ?? `Tu paquete ${pkg.trackingId} cambió a ${pkg.status}.`,
|
||||
vars
|
||||
);
|
||||
|
||||
const record = await this.prisma.client.notification.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
userId: pkg.userId,
|
||||
userId: pkg.userId,
|
||||
channel,
|
||||
status: "PENDIENTE",
|
||||
status: "PENDIENTE",
|
||||
subject,
|
||||
body,
|
||||
body: bodyText,
|
||||
},
|
||||
});
|
||||
// STUB: In production, dispatch via SendGrid (EMAIL), WhatsApp Business API (WHATSAPP), etc.
|
||||
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${body}`);
|
||||
// Mark as sent (stub — in prod this would be async)
|
||||
await this.prisma.client.notification.updateMany({
|
||||
where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" },
|
||||
|
||||
// STUB: En producción → SendGrid (EMAIL), WhatsApp Business API, etc.
|
||||
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${bodyText}`);
|
||||
|
||||
await this.prisma.client.notification.update({
|
||||
where: { id: record.id },
|
||||
data: { status: "ENVIADO", sentAt: new Date() },
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
@@ -53,7 +141,7 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 20): Promise<any[]> {
|
||||
async findByUser(userId: string, limit = 20) {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -61,7 +149,7 @@ export class NotificationsService {
|
||||
});
|
||||
}
|
||||
|
||||
async findByPackage(packageId: string): Promise<any[]> {
|
||||
async findByPackage(packageId: string) {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { packageId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Controller, Get, Post, Body, Param, Query,
|
||||
UseGuards, Request, BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
class CreateIntentDto {
|
||||
packageId!: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
@Controller("payments")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PaymentsController {
|
||||
constructor(private readonly svc: PaymentsService) {}
|
||||
|
||||
/** GET /payments — lista todos los pagos del tenant (admin) */
|
||||
@Get()
|
||||
list(@Request() req: any, @Query("status") status?: string): Promise<any[]> {
|
||||
return this.svc.list(req.user.tenantId, status);
|
||||
}
|
||||
|
||||
/** GET /payments/package/:packageId — detalle + desglose para el cliente */
|
||||
@Get("package/:packageId")
|
||||
detail(@Param("packageId") packageId: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.findByPackageForUser(packageId, req.user.id, req.user.tenantId);
|
||||
}
|
||||
|
||||
/** GET /payments/track/:trackingId — por tracking ID (cliente o admin) */
|
||||
@Get("track/:trackingId")
|
||||
byTracking(@Param("trackingId") trackingId: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.findByTracking(trackingId, req.user.tenantId);
|
||||
}
|
||||
|
||||
/** POST /payments/intent — crea o recupera un PaymentIntent */
|
||||
@Post("intent")
|
||||
createIntent(@Body() dto: CreateIntentDto, @Request() req: any): Promise<any> {
|
||||
if (!dto.packageId) throw new BadRequestException("packageId es requerido");
|
||||
return this.svc.createIntent(dto.packageId, req.user.id, req.user.tenantId, dto.provider);
|
||||
}
|
||||
|
||||
/** POST /payments/:id/confirm — confirma pago (dev/stub) */
|
||||
@Post(":id/confirm")
|
||||
confirm(@Param("id") id: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.confirm(id, req.user.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** Calcula el monto a cobrar desde el Package (peso real × tarifa) */
|
||||
private async calcAmount(pkg: any, tenantId: string): Promise<number> {
|
||||
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
||||
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
|
||||
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
|
||||
const freight = weight * pricePerLb;
|
||||
const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02);
|
||||
return Math.round((freight + insurance) * 100) / 100;
|
||||
}
|
||||
|
||||
/** Obtiene el pago vinculado a un paquete (por trackingId) */
|
||||
async findByTracking(trackingId: string, tenantId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findFirst({
|
||||
where: { trackingId, tenantId },
|
||||
include: { payment: true },
|
||||
});
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado");
|
||||
return { package: pkg, payment: pkg.payment };
|
||||
}
|
||||
|
||||
/** Crea o recupera un intento de pago para el paquete */
|
||||
async createIntent(packageId: string, userId: string, tenantId: string, provider = "stripe"): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findFirst({ where: { id: packageId, tenantId } });
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado");
|
||||
if (pkg.paidAt) throw new BadRequestException("El paquete ya fue pagado");
|
||||
|
||||
// Reusar intent existente si está PENDIENTE o PROCESANDO
|
||||
const existing = await this.prisma.client.payment.findUnique({ where: { packageId } });
|
||||
if (existing && ["PENDIENTE", "PROCESANDO"].includes(existing.status)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const amount = await this.calcAmount(pkg, tenantId);
|
||||
|
||||
// STUB: En producción → Stripe.paymentIntents.create(...)
|
||||
const providerRef = `pi_stub_${Date.now()}`;
|
||||
this.logger.log(`[PAYMENT STUB] Creating ${provider} intent for ${pkg.trackingId} — $${amount}`);
|
||||
|
||||
return this.prisma.client.payment.create({
|
||||
data: {
|
||||
tenantId,
|
||||
packageId,
|
||||
userId,
|
||||
amount,
|
||||
currency: "USD",
|
||||
provider,
|
||||
providerRef,
|
||||
status: "PENDIENTE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirma un pago (webhook de Stripe o confirmación manual en dev) */
|
||||
async confirm(paymentId: string, tenantId: string): Promise<any> {
|
||||
const payment = await this.prisma.client.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!payment || payment.tenantId !== tenantId) throw new NotFoundException("Pago no encontrado");
|
||||
if (payment.status === "COMPLETADO") throw new BadRequestException("El pago ya fue completado");
|
||||
|
||||
const [updatedPayment] = await this.prisma.client.$transaction([
|
||||
this.prisma.client.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { status: "COMPLETADO", paidAt: new Date() },
|
||||
}),
|
||||
this.prisma.client.package.update({
|
||||
where: { id: payment.packageId },
|
||||
data: { paidAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
this.logger.log(`[PAYMENT] Confirmed ${paymentId} for package ${payment.packageId}`);
|
||||
return updatedPayment;
|
||||
}
|
||||
|
||||
/** Lista pagos del tenant con filtros opcionales */
|
||||
async list(tenantId: string, status?: string): Promise<any[]> {
|
||||
return this.prisma.client.payment.findMany({
|
||||
where: { tenantId, ...(status ? { status: status as any } : {}) },
|
||||
include: { package: { select: { trackingId: true, description: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Obtiene el pago de un package para el usuario autenticado */
|
||||
async findByPackageForUser(packageId: string, userId: string, tenantId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findFirst({
|
||||
where: { id: packageId, userId, tenantId },
|
||||
include: { payment: true },
|
||||
});
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado");
|
||||
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
||||
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
|
||||
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
|
||||
const freight = weight * pricePerLb;
|
||||
const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02);
|
||||
const fodinfa = Number(pkg.declaredValue) * Number(tariff?.fodinfaPct ?? 0.005);
|
||||
const total = freight + insurance + fodinfa;
|
||||
return {
|
||||
package: pkg,
|
||||
payment: pkg.payment,
|
||||
breakdown: {
|
||||
weightLb: weight,
|
||||
pricePerLb,
|
||||
freight: Math.round(freight * 100) / 100,
|
||||
insurance: Math.round(insurance * 100) / 100,
|
||||
fodinfa: Math.round(fodinfa * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user