feat: Stripe Checkout, Zeptomail, pre-alert linking, perfil 3-tab, carga-pesada landing
- payments: real Stripe Checkout Session (redirect flow), confirmBySession, webhook handler - notifications: Zeptomail REST email, wa.me WhatsApp links, IntegrationsService injection - auth: changePassword, updateProfile, disableMfa (TOTP-verified) endpoints - packages: tryLinkPreAlert() auto-links on create (non-blocking) - integrations: catalog updated (zeptomail, stripe_webhook_secret; removed sendgrid/whatsapp-api) - web/portal/perfil: 3-tab layout (datos / contraseña / MFA) - web/portal/pago: Stripe redirect + ?success=1&session_id= callback, cancelled banner - web/admin/b2b: fix enum values (EN_COTIZACION, ACEPTADO) - web/carga-pesada: rich marketing landing (FCL/LCL, 7-step process, sectors, INEN callout) - tests: fix payments.service.spec (IntegrationsService+ConfigService mocks) - tests: fix notifications.service.spec (IntegrationsService mock, phone in mockUser) - all 104 tests passing, API + web builds clean
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
Controller, Post, Get, Body, Req, UseGuards, HttpCode, HttpStatus,
|
||||
Controller, Post, Get, Patch, Body, Req, UseGuards, HttpCode, HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto } from "./dto/auth.dto";
|
||||
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto, ChangePasswordDto, UpdateProfileDto } from "./dto/auth.dto";
|
||||
import { JwtAuthGuard } from "./guards/auth.guard";
|
||||
import { CurrentUser } from "./decorators/current-user.decorator";
|
||||
|
||||
@@ -45,6 +45,22 @@ export class AuthController {
|
||||
return this.auth.getProfile(user.id);
|
||||
}
|
||||
|
||||
/** PATCH /api/auth/me — Actualizar nombre / teléfono */
|
||||
@Patch("me")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateProfile(@Body() dto: UpdateProfileDto, @CurrentUser() user: any) {
|
||||
return this.auth.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
/** PATCH /api/auth/password — Cambiar contraseña */
|
||||
@Patch("password")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async changePassword(@Body() dto: ChangePasswordDto, @CurrentUser() user: any) {
|
||||
await this.auth.changePassword(user.id, dto.oldPassword, dto.newPassword);
|
||||
return { message: "Contraseña actualizada correctamente." };
|
||||
}
|
||||
|
||||
/** POST /api/auth/mfa/setup — Genera QR para TOTP */
|
||||
@Post("mfa/setup")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -58,4 +74,12 @@ export class AuthController {
|
||||
verifyMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
|
||||
return this.auth.verifyMfa(user.id, dto.totpCode);
|
||||
}
|
||||
|
||||
/** POST /api/auth/mfa/disable — Desactiva MFA (requiere código TOTP) */
|
||||
@Post("mfa/disable")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
disableMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
|
||||
return this.auth.disableMfa(user.id, dto.totpCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,51 @@ export class AuthService {
|
||||
return { mfaEnabled: true };
|
||||
}
|
||||
|
||||
// ─── Change Password ─────────────────────────────────────────
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!valid) throw new BadRequestException("La contraseña actual es incorrecta.");
|
||||
|
||||
if (oldPassword === newPassword) throw new BadRequestException("La nueva contraseña debe ser diferente.");
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
await this.prisma.client.user.update({ where: { id: userId }, data: { passwordHash } });
|
||||
await this.audit(user.tenantId, userId, "PASSWORD_CHANGED", "User", userId);
|
||||
}
|
||||
|
||||
// ─── Update Profile ───────────────────────────────────────────
|
||||
async updateProfile(userId: string, data: { firstName?: string; lastName?: string; phone?: string }): Promise<any> {
|
||||
const user = await this.prisma.client.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(data.firstName ? { firstName: data.firstName } : {}),
|
||||
...(data.lastName ? { lastName: data.lastName } : {}),
|
||||
...(data.phone !== undefined ? { phone: data.phone || null } : {}),
|
||||
},
|
||||
});
|
||||
return this.sanitizeUser(user);
|
||||
}
|
||||
|
||||
// ─── Disable MFA ─────────────────────────────────────────────
|
||||
async disableMfa(userId: string, totpCode: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
if (!user.mfaEnabled) throw new BadRequestException("MFA no está activada.");
|
||||
|
||||
const ok = totpVerify({ token: totpCode, secret: user.mfaSecret! });
|
||||
if (!ok) throw new BadRequestException("Código TOTP inválido.");
|
||||
|
||||
await this.prisma.client.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: false, mfaSecret: null },
|
||||
});
|
||||
await this.audit(user.tenantId, userId, "MFA_DISABLED", "User", userId);
|
||||
return { mfaEnabled: false };
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────
|
||||
async getProfile(userId: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
|
||||
@@ -42,3 +42,29 @@ export class SetupMfaDto {
|
||||
@IsString()
|
||||
totpCode!: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
oldPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,15 @@ export const INTEGRATION_CATALOG = [
|
||||
// ── Pasarela de Pagos ──────────────────────────────────────
|
||||
{ key: "stripe_public_key", label: "Stripe — Clave pública", group: "payment", required: false },
|
||||
{ key: "stripe_secret_key", label: "Stripe — Clave secreta", group: "payment", required: false },
|
||||
{ key: "stripe_webhook_secret", label: "Stripe — Webhook Secret", group: "payment", required: false },
|
||||
{ key: "payphone_token", label: "PayPhone — Token de API", group: "payment", required: false },
|
||||
{ key: "paypal_client_id", label: "PayPal — Client ID", group: "payment", required: false },
|
||||
// ── Notificaciones ────────────────────────────────────────
|
||||
{ key: "whatsapp_token", label: "WhatsApp Business — Token", group: "notifications", required: true },
|
||||
{ key: "whatsapp_phone_id", label: "WhatsApp Business — Phone ID", group: "notifications", required: true },
|
||||
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
|
||||
{ key: "sendgrid_api_key", label: "SendGrid — API Key", group: "notifications", required: false },
|
||||
{ key: "email_from", label: "Email remitente (from)", group: "notifications", required: false },
|
||||
{ key: "sms_provider", label: "SMS — Proveedor (ej: Twilio)", group: "notifications", required: false },
|
||||
{ key: "sms_api_key", label: "SMS — API Key", group: "notifications", required: false },
|
||||
{ key: "zeptomail_api_key", label: "Zeptomail — Send Mail Token", group: "notifications", required: true },
|
||||
{ key: "email_from", label: "Email remitente (from address)", group: "notifications", required: true },
|
||||
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
|
||||
// ── Aduana / SENAE ────────────────────────────────────────
|
||||
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
|
||||
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, IntegrationsModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
const mockTemplate = {
|
||||
id: "tpl-1",
|
||||
@@ -22,7 +23,15 @@ const mockPackage = {
|
||||
status: "REGISTRADO",
|
||||
};
|
||||
|
||||
const mockUser = { id: "user-1", firstName: "Juan", lastName: "Pérez", suite: { code: "EC-00001" } };
|
||||
// Include phone + email so notifyStatusChange skips the DB user lookup
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
firstName: "Juan",
|
||||
lastName: "Pérez",
|
||||
email: "juan@test.com",
|
||||
phone: "+5930987654321",
|
||||
suite: { code: "EC-00001" },
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
client: {
|
||||
@@ -38,18 +47,28 @@ const mockPrisma = {
|
||||
updateMany: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// IntegrationsService mock — getValue returns null so Zeptomail is skipped gracefully
|
||||
const mockIntegrations = {
|
||||
getValue: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
describe("NotificationsService", () => {
|
||||
let service: NotificationsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockIntegrations.getValue.mockResolvedValue(null);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
NotificationsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: IntegrationsService, useValue: mockIntegrations },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<NotificationsService>(NotificationsService);
|
||||
@@ -122,7 +141,8 @@ describe("NotificationsService", () => {
|
||||
|
||||
it("crea notificaciones para los 3 canales por defecto", async () => {
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3); // EMAIL, WHATSAPP, PUSH
|
||||
// EMAIL (FALLIDO – sin API key), WHATSAPP (wa.me link), PUSH (FALLIDO)
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("interpola {{trackingId}} y {{firstName}} en el body", async () => {
|
||||
@@ -139,8 +159,7 @@ describe("NotificationsService", () => {
|
||||
it("no envía notificación si la plantilla está inactiva", async () => {
|
||||
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, isActive: false });
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
// Solo EMAIL está deshabilitado, WHATSAPP y PUSH usan fallback (activo)
|
||||
// El mock devuelve el template inactivo para todos
|
||||
// El mock devuelve el template inactivo para todos los canales
|
||||
expect(mockPrisma.client.notification.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
|
||||
const DEFAULT_SUBJECTS: Record<string, string> = {
|
||||
@@ -35,11 +36,19 @@ function interpolate(tpl: string, vars: Record<string, string>): string {
|
||||
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
||||
}
|
||||
|
||||
/** Normaliza número de teléfono para wa.me (solo dígitos, con código de país) */
|
||||
function normalizePhone(phone: string): string {
|
||||
return phone.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private integrations: IntegrationsService,
|
||||
) {}
|
||||
|
||||
// ─── Gestión de plantillas ────────────────────────────────
|
||||
|
||||
@@ -90,11 +99,20 @@ export class NotificationsService {
|
||||
|
||||
/** Called whenever a package status changes. */
|
||||
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||
// Load full user data to get phone and name (needed for wa.me)
|
||||
let fullUser = user;
|
||||
if (!user?.firstName || !user?.phone) {
|
||||
try {
|
||||
fullUser = await this.prisma.client.user.findUnique({ where: { id: user.id } }) ?? user;
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
trackingId: pkg.trackingId ?? "",
|
||||
firstName: user?.firstName ?? "Cliente",
|
||||
firstName: fullUser?.firstName ?? "Cliente",
|
||||
lastName: fullUser?.lastName ?? "",
|
||||
status: pkg.status ?? "",
|
||||
suiteCode: user?.suite?.code ?? "",
|
||||
suiteCode: fullUser?.suite?.code ?? "",
|
||||
};
|
||||
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
@@ -117,6 +135,32 @@ export class NotificationsService {
|
||||
vars
|
||||
);
|
||||
|
||||
let finalBody = bodyText;
|
||||
let notifStatus: "ENVIADO" | "FALLIDO" | "PENDIENTE" = "PENDIENTE";
|
||||
let errorMsg: string | undefined;
|
||||
|
||||
// ── Dispatch por canal ──────────────────────────────
|
||||
if (channel === "EMAIL") {
|
||||
const result = await this.sendZeptomail(pkg.tenantId, subject, bodyText, fullUser);
|
||||
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
|
||||
errorMsg = result.error;
|
||||
} else if (channel === "WHATSAPP") {
|
||||
// wa.me link con mensaje pre-cargado (sin Business API)
|
||||
const phone = fullUser?.phone ? normalizePhone(fullUser.phone) : null;
|
||||
if (phone) {
|
||||
finalBody = `https://wa.me/${phone}?text=${encodeURIComponent(bodyText)}`;
|
||||
notifStatus = "ENVIADO";
|
||||
} else {
|
||||
// No phone — skip WhatsApp
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "Sin número de teléfono registrado";
|
||||
}
|
||||
} else if (channel === "PUSH") {
|
||||
// PUSH no implementado — marcar FALLIDO silenciosamente
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "PUSH no configurado";
|
||||
}
|
||||
|
||||
const record = await this.prisma.client.notification.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
@@ -124,16 +168,17 @@ export class NotificationsService {
|
||||
channel,
|
||||
status: "PENDIENTE",
|
||||
subject,
|
||||
body: bodyText,
|
||||
body: finalBody,
|
||||
},
|
||||
});
|
||||
|
||||
// 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() },
|
||||
data: {
|
||||
status: notifStatus,
|
||||
sentAt: notifStatus === "ENVIADO" ? new Date() : null,
|
||||
error: errorMsg ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
|
||||
@@ -141,6 +186,76 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un email via Zeptomail REST API.
|
||||
* Docs: https://www.zoho.com/zeptomail/help/api/email-sending.html
|
||||
*/
|
||||
private async sendZeptomail(
|
||||
tenantId: string,
|
||||
subject: string,
|
||||
bodyText: string,
|
||||
toUser: any,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const apiKey = await this.integrations.getValue(tenantId, "zeptomail_api_key");
|
||||
const fromAddr = await this.integrations.getValue(tenantId, "email_from") ?? "noreply@moraworld.com";
|
||||
const fromName = await this.integrations.getValue(tenantId, "email_from_name") ?? "Moraworld Imports";
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.warn(`[ZEPTOMAIL] No API key configured for tenant ${tenantId}. Email not sent.`);
|
||||
return { ok: false, error: "Zeptomail API key no configurada" };
|
||||
}
|
||||
|
||||
const toEmail = toUser?.email;
|
||||
if (!toEmail) return { ok: false, error: "Sin email del destinatario" };
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
||||
<div style="background:#001F5B;padding:20px;border-radius:8px 8px 0 0">
|
||||
<h2 style="color:#fff;margin:0;font-size:1.2rem">Moraworld<span style="color:#FF6B00">.</span>Imports</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #e5e7eb;border-top:none">
|
||||
<p style="font-size:1rem;color:#111827;margin:0 0 16px">${bodyText.replace(/\n/g, "<br>")}</p>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0">
|
||||
<p style="font-size:.75rem;color:#6b7280;margin:0">Moraworld Imports S.A.S. · Mora Global Import LLC</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const payload = {
|
||||
from: { address: fromAddr, name: fromName },
|
||||
to: [{
|
||||
email_address: {
|
||||
address: toEmail,
|
||||
name: `${toUser?.firstName ?? ""} ${toUser?.lastName ?? ""}`.trim() || toEmail,
|
||||
},
|
||||
}],
|
||||
subject,
|
||||
htmlbody: htmlBody,
|
||||
};
|
||||
|
||||
const res = await fetch("https://api.zeptomail.com/v1.1/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Zoho-enczapikey ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
this.logger.error(`[ZEPTOMAIL] Send failed (${res.status}): ${errText}`);
|
||||
return { ok: false, error: `Zeptomail error ${res.status}: ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
this.logger.log(`[ZEPTOMAIL] Email sent to ${toEmail} — subject: ${subject}`);
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[ZEPTOMAIL] Exception: ${err.message}`);
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 20) {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { userId },
|
||||
|
||||
@@ -81,9 +81,40 @@ export class PackagesService {
|
||||
},
|
||||
});
|
||||
|
||||
// Intentar vincular con pre-alerta pendiente del mismo usuario (§09)
|
||||
await this.tryLinkPreAlert(pkg.id, dto.userId, tenantId, dto.vendorTracking);
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/** Busca una pre-alerta PENDIENTE del mismo usuario que coincida por vendorTracking
|
||||
* y la vincula automáticamente al paquete (status → VINCULADA, packageId set). */
|
||||
private async tryLinkPreAlert(
|
||||
packageId: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
vendorTracking?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const where: any = { tenantId, userId, status: "PENDIENTE", packageId: null };
|
||||
if (vendorTracking) where.vendorTracking = vendorTracking;
|
||||
|
||||
const alert = await this.prisma.client.preAlert.findFirst({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!alert) return;
|
||||
|
||||
await this.prisma.client.preAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: { packageId, status: "VINCULADA" },
|
||||
});
|
||||
} catch {
|
||||
// Non-blocking — linking failure must not block package creation
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Controller, Get, Post, Body, Param, Query,
|
||||
UseGuards, Request, BadRequestException,
|
||||
UseGuards, Request, BadRequestException, Headers, RawBodyRequest,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
@@ -10,39 +11,76 @@ class CreateIntentDto {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
class ConfirmSessionDto {
|
||||
sessionId!: string;
|
||||
}
|
||||
|
||||
@Controller("payments")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PaymentsController {
|
||||
constructor(private readonly svc: PaymentsService) {}
|
||||
|
||||
/** GET /payments — lista todos los pagos del tenant (admin) */
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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 /payments/intent — crea o recupera un PaymentIntent / Checkout Session */
|
||||
@Post("intent")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
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 /payments/confirm-session — confirma pago verificando Stripe session.
|
||||
* Llamado desde el frontend al volver de la página de Stripe (success_url).
|
||||
*/
|
||||
@Post("confirm-session")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
confirmSession(@Body() dto: ConfirmSessionDto, @Request() req: any): Promise<any> {
|
||||
if (!dto.sessionId) throw new BadRequestException("sessionId es requerido");
|
||||
return this.svc.confirmBySession(dto.sessionId, req.user.tenantId);
|
||||
}
|
||||
|
||||
/** POST /payments/:id/confirm — confirma pago por paymentId (dev / fallback) */
|
||||
@Post(":id/confirm")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
confirm(@Param("id") id: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.confirm(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/webhook — Stripe webhook (sin auth JWT — raw body).
|
||||
* El tenant se pasa como query param: ?tenant=moraworld
|
||||
*/
|
||||
@Post("webhook")
|
||||
async stripeWebhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
@Query("tenant") tenant = "moraworld",
|
||||
): Promise<{ received: boolean }> {
|
||||
const rawBody = (req as any).rawBody as Buffer;
|
||||
if (!rawBody || !signature) throw new BadRequestException("Missing body or signature");
|
||||
|
||||
// Resolve tenantId from slug
|
||||
await this.svc.handleStripeWebhook(rawBody, signature, tenant);
|
||||
return { received: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Module } from "@nestjs/common";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, IntegrationsModule, ConfigModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
// Mock del PrismaService
|
||||
const mockPayment = {
|
||||
@@ -58,15 +60,28 @@ const mockPrisma = {
|
||||
},
|
||||
};
|
||||
|
||||
// IntegrationsService mock — getValue returns null so Stripe is never instantiated (stub path)
|
||||
const mockIntegrations = {
|
||||
getValue: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
// ConfigService mock
|
||||
const mockConfig = {
|
||||
get: jest.fn().mockReturnValue("http://localhost:3000"),
|
||||
};
|
||||
|
||||
describe("PaymentsService", () => {
|
||||
let service: PaymentsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockIntegrations.getValue.mockResolvedValue(null);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PaymentsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: IntegrationsService, useValue: mockIntegrations },
|
||||
{ provide: ConfigService, useValue: mockConfig },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<PaymentsService>(PaymentsService);
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
import Stripe from "stripe";
|
||||
|
||||
type StripeClient = InstanceType<typeof Stripe>;
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private integrations: IntegrationsService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Inicializa el cliente Stripe con la key del tenant */
|
||||
private async getStripe(tenantId: string): Promise<StripeClient | null> {
|
||||
const secretKey = await this.integrations.getValue(tenantId, "stripe_secret_key");
|
||||
if (!secretKey) return null;
|
||||
return new Stripe(secretKey, { apiVersion: "2026-05-27.dahlia" });
|
||||
}
|
||||
|
||||
/** Calcula el monto a cobrar desde el Package (peso real × tarifa) */
|
||||
private async calcAmount(pkg: any, tenantId: string): Promise<number> {
|
||||
@@ -27,7 +43,11 @@ export class PaymentsService {
|
||||
return { package: pkg, payment: pkg.payment };
|
||||
}
|
||||
|
||||
/** Crea o recupera un intento de pago para el paquete */
|
||||
/**
|
||||
* Crea o recupera un intento de pago para el paquete.
|
||||
* Si Stripe está configurado → crea Checkout Session y devuelve checkoutUrl.
|
||||
* Si no → stub para desarrollo.
|
||||
*/
|
||||
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");
|
||||
@@ -36,14 +56,63 @@ export class PaymentsService {
|
||||
// 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)) {
|
||||
// Si hay Stripe, reconstruir checkoutUrl si es una session
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (stripe && existing.providerRef?.startsWith("cs_")) {
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(existing.providerRef);
|
||||
return { ...existing, checkoutUrl: session.url };
|
||||
} catch { /* session expired, fall through to create new */ }
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const amount = await this.calcAmount(pkg, tenantId);
|
||||
|
||||
// STUB: En producción → Stripe.paymentIntents.create(...)
|
||||
// ── Stripe Checkout Session ──────────────────────────────────
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (stripe && provider === "stripe") {
|
||||
const appUrl = this.config.get("APP_URL", "http://localhost:3000");
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ["card"],
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: `Envío ${pkg.trackingId}`,
|
||||
description: (pkg.description ?? "Paquete Moraworld Imports").substring(0, 250),
|
||||
},
|
||||
unit_amount: Math.round(amount * 100), // cents
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: "payment",
|
||||
success_url: `${appUrl}/portal/pago?success=1&session_id={CHECKOUT_SESSION_ID}&packageId=${packageId}`,
|
||||
cancel_url: `${appUrl}/portal/pago?cancelled=1&packageId=${packageId}`,
|
||||
metadata: { packageId, tenantId, userId },
|
||||
});
|
||||
|
||||
const payment = await this.prisma.client.payment.create({
|
||||
data: {
|
||||
tenantId,
|
||||
packageId,
|
||||
userId,
|
||||
amount,
|
||||
currency: "USD",
|
||||
provider: "stripe",
|
||||
providerRef: session.id,
|
||||
status: "PENDIENTE",
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[STRIPE] Checkout session ${session.id} created for ${pkg.trackingId}`);
|
||||
return { ...payment, checkoutUrl: session.url };
|
||||
}
|
||||
|
||||
// ── Fallback stub ────────────────────────────────────────────
|
||||
const providerRef = `pi_stub_${Date.now()}`;
|
||||
this.logger.log(`[PAYMENT STUB] Creating ${provider} intent for ${pkg.trackingId} — $${amount}`);
|
||||
this.logger.warn(`[PAYMENT STUB] No Stripe key configured for tenant ${tenantId}. Using stub for ${pkg.trackingId}`);
|
||||
|
||||
return this.prisma.client.payment.create({
|
||||
data: {
|
||||
@@ -59,11 +128,41 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirma un pago (webhook de Stripe o confirmación manual en dev) */
|
||||
/**
|
||||
* Confirma pago por Stripe session ID (callback de success_url).
|
||||
* Verifica con Stripe que payment_status === 'paid'.
|
||||
*/
|
||||
async confirmBySession(sessionId: string, tenantId: string): Promise<any> {
|
||||
// Find payment by providerRef
|
||||
const payment = await this.prisma.client.payment.findFirst({
|
||||
where: { providerRef: sessionId, tenantId },
|
||||
});
|
||||
if (!payment) throw new NotFoundException("Pago no encontrado para esta sesión");
|
||||
if (payment.status === "COMPLETADO") return payment;
|
||||
|
||||
// Verify with Stripe
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (stripe) {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
if (session.payment_status !== "paid") {
|
||||
throw new BadRequestException(`Pago no completado en Stripe (estado: ${session.payment_status})`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.confirmPayment(payment.id, tenantId);
|
||||
}
|
||||
|
||||
/** Confirma un pago por ID (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");
|
||||
return this.confirmPayment(paymentId, tenantId);
|
||||
}
|
||||
|
||||
private async confirmPayment(paymentId: string, tenantId: string): Promise<any> {
|
||||
const payment = await this.prisma.client.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!payment) throw new NotFoundException("Pago no encontrado");
|
||||
|
||||
const [updatedPayment] = await this.prisma.client.$transaction([
|
||||
this.prisma.client.payment.update({
|
||||
@@ -80,6 +179,35 @@ export class PaymentsService {
|
||||
return updatedPayment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook de Stripe — `checkout.session.completed`.
|
||||
* El controller debe recibir el raw body para verificar la firma.
|
||||
*/
|
||||
async handleStripeWebhook(payload: Buffer, signature: string, tenantId: string): Promise<void> {
|
||||
const webhookSecret = await this.integrations.getValue(tenantId, "stripe_webhook_secret");
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (!stripe || !webhookSecret) return;
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Stripe webhook signature failed: ${err.message}`);
|
||||
throw new BadRequestException("Webhook signature inválida");
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as any;
|
||||
if (session.payment_status === "paid") {
|
||||
try {
|
||||
await this.confirmBySession(session.id, tenantId);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Webhook confirm failed for session ${session.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lista pagos del tenant con filtros opcionales */
|
||||
async list(tenantId: string, status?: string): Promise<any[]> {
|
||||
return this.prisma.client.payment.findMany({
|
||||
|
||||
Reference in New Issue
Block a user