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:
Lizandro Guarnizo
2026-06-01 17:09:05 -05:00
parent b2b292c50a
commit 5565eef554
20 changed files with 1326 additions and 97 deletions
+2
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+119
View File
@@ -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,
},
};
}
}