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:
Lizandro Guarnizo
2026-06-01 20:24:35 -05:00
parent b2f03e654f
commit 98ab5a309c
19 changed files with 979 additions and 134 deletions
+26 -2
View File
@@ -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);
}
}
+45
View File
@@ -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 } });
+26
View File
@@ -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 },
+31
View File
@@ -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.");
+42 -4
View File
@@ -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 };
}
}
+3 -1
View File
@@ -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);
+133 -5
View File
@@ -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({