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
+2 -1
View File
@@ -31,7 +31,8 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"stripe": "^22.2.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.7",
+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({
+15 -15
View File
@@ -5,26 +5,26 @@ import { Timestamp } from "@/app/_components/timestamp";
// §13 — Flujo del Importador Mayorista (B2B)
const STATUS_LABEL: Record<string, string> = {
PENDIENTE: "Pendiente",
CONTACTADO: "Contactado",
COTIZADO: "Cotizado",
APROBADO: "Aprobado",
EN_PROCESO: "En proceso",
COMPLETADO: "Completado",
CANCELADO: "Cancelado",
PENDIENTE: "Pendiente",
EN_COTIZACION: "En cotización",
COTIZADO: "Cotizado",
ACEPTADO: "Aceptado",
EN_PROCESO: "En proceso",
COMPLETADO: "Completado",
CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow",
CONTACTADO: "badge-blue",
COTIZADO: "badge-blue",
APROBADO: "badge-green",
EN_PROCESO: "badge-orange",
COMPLETADO: "badge-green",
CANCELADO: "badge-red",
PENDIENTE: "badge-yellow",
EN_COTIZACION: "badge-blue",
COTIZADO: "badge-blue",
ACEPTADO: "badge-green",
EN_PROCESO: "badge-orange",
COMPLETADO: "badge-green",
CANCELADO: "badge-red",
};
const STATUS_FLOW = ["PENDIENTE", "CONTACTADO", "COTIZADO", "APROBADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
const STATUS_FLOW = ["PENDIENTE", "EN_COTIZACION", "COTIZADO", "ACEPTADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
export default function AdminB2BPage() {
const [requests, setRequests] = useState<any[]>([]);
+181 -22
View File
@@ -1,52 +1,211 @@
import Link from "next/link";
const SERVICES = [
{
icon: "🚢",
title: "Contenedor completo (FCL)",
desc: "Importación de un contenedor completo de 20' o 40'. Ideal para grandes volúmenes de mercancía homogénea.",
details: ["Contenedor 20' (~26 m³)", "Contenedor 40' (~67 m³)", "Coordinación puerta a puerta", "Gestión aduanera DAI completa"],
badge: "B2B",
},
{
icon: "📦",
title: "Carga consolidada (LCL)",
desc: "Comparte el espacio de un contenedor con otros importadores. Paga solo por el m³ que usas.",
details: ["Mínimo 1 CBM", "Frecuencia semanal NJ → Ecuador", "Consolidación en nuestra bodega NJ", "Trazabilidad en tiempo real"],
badge: "Más flexible",
},
{
icon: "🏗️",
title: "Pallets y carga suelta",
desc: "Equipos, maquinaria, repuestos industriales y artículos de gran volumen o peso.",
details: ["Hasta 2,500 kg por pallet", "Flejado y embalaje en NJ", "Seguro de carga incluido", "INEN y permisos SENAE"],
badge: "",
},
{
icon: "🚗",
title: "Vehículos y maquinaria",
desc: "Importación de automóviles, maquinaria agrícola, equipos industriales y vehículos especiales.",
details: ["RO-RO y contenedor flat rack", "Inspección pre-embarque", "Gestión de homologación SENAE", "Coordinación con INEN y ANT"],
badge: "",
},
];
const SECTORS = [
{ icon: "👗", label: "Textil y confección" },
{ icon: "👟", label: "Calzado (INEN obligatorio)" },
{ icon: "⚙️", label: "Maquinaria industrial" },
{ icon: "🏠", label: "Muebles y decoración" },
{ icon: "🍎", label: "Alimentos y bebidas" },
{ icon: "💻", label: "Electrónica y tecnología" },
{ icon: "🔧", label: "Repuestos automotrices" },
{ icon: "🧴", label: "Cosméticos y cuidado personal" },
];
const PROCESS = [
{ n: 1, title: "Solicita cotización", desc: "Completa el formulario con el tipo de mercancía, volumen y ciudad de origen en EE.UU.", color: "var(--primary)" },
{ n: 2, title: "Recibe propuesta", desc: "Nuestro equipo te envía una propuesta detallada con costos de flete, seguro, SENAE e INEN en 2448 horas.", color: "var(--accent)" },
{ n: 3, title: "Acepta y prepara la carga", desc: "Coordinas con tu proveedor en EE.UU. el despacho a nuestra bodega en New Jersey.", color: "var(--primary)" },
{ n: 4, title: "Recibimos y verificamos", desc: "Inspeccionamos, pesamos, fotografiamos y consolidamos tu carga. Tramitamos permisos INEN y certificados.", color: "var(--accent)" },
{ n: 5, title: "Despacho internacional", desc: "Embarcamos tu carga vía aérea (urgente) o marítima (económica) con tracking en tiempo real.", color: "var(--primary)" },
{ n: 6, title: "Gestión aduanera Ecuador", desc: "Nuestros agentes autorizados SENAE tramitan la DAI completa. Sin sorpresas en aduana.", color: "var(--accent)" },
{ n: 7, title: "Entrega en tu destino", desc: "Coordinamos la entrega en bodega o puerta a puerta en cualquier ciudad del Ecuador.", color: "var(--primary)" },
];
export default function CargaPesadaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<div style={{ minHeight: "100vh", background: "var(--gray-50)" }}>
{/* Navbar */}
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-sm">Cotizar B2B</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Carga Pesada y B2B</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 600, margin: "0 auto" }}>
Importaciones de volumen para empresas y emprendedores. Maquinaria, muebles, vehículos y más.
{/* Hero */}
<div style={{ background: "linear-gradient(135deg, #0a0f2e 0%, #001F5B 60%, #0f3a8a 100%)", color: "white", padding: "5rem 0 4rem", position: "relative", overflow: "hidden" }}>
<div style={{ position: "absolute", top: 0, right: 0, width: 400, height: 400, background: "radial-gradient(circle, rgba(255,107,0,.15) 0%, transparent 70%)", pointerEvents: "none" }} />
<div className="container" style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
<div style={{ display: "inline-flex", gap: ".5rem", marginBottom: "1.5rem", flexWrap: "wrap", justifyContent: "center" }}>
{["FCL / LCL", "Pallets", "Maquinaria", "SENAE Autorizado", "INEN"].map(b => (
<span key={b} style={{ background: "rgba(255,107,0,.2)", color: "#FDB87A", fontSize: ".72rem", fontWeight: 700, padding: "4px 12px", borderRadius: 999 }}>{b}</span>
))}
</div>
<h1 style={{ fontSize: "clamp(2rem,5vw,3rem)", fontWeight: 900, lineHeight: 1.15, marginBottom: "1.25rem" }}>
Carga Pesada<br />
<span style={{ color: "#FF6B00" }}>EE.UU. Ecuador</span>
</h1>
<p style={{ color: "rgba(255,255,255,.75)", fontSize: "1.1rem", maxWidth: 580, margin: "0 auto 2.5rem", lineHeight: 1.7 }}>
Importación de volumen para empresas y mayoristas. Contenedores, pallets, maquinaria industrial.
Gestión aduanera DAI completa con agentes SENAE autorizados.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-lg">
Solicitar cotización
</Link>
<Link href="/carga-pesada/como-funciona" className="btn btn-outline btn-lg" style={{ color: "white", borderColor: "rgba(255,255,255,.3)" }}>
Ver proceso
</Link>
</div>
</div>
</div>
<div className="section">
{/* Servicios */}
<div className="section" style={{ background: "white" }}>
<div className="container">
<div className="grid-3" style={{ marginBottom: "3rem" }}>
{[
{ icon: "🚢", title: "Carga marítima FCL/LCL", desc: "Contenedores completos o carga consolidada desde puertos de EE.UU. a Ecuador (Guayaquil / Manta)." },
{ icon: "✈️", title: "Flete aéreo express", desc: "Para carga urgente o mercancía de alto valor. Tiempo de tránsito 35 días." },
{ icon: "🏭", title: "Soluciones B2B", desc: "Gestión de importaciones recurrentes para empresas. Precios preferenciales por volumen." },
].map(s => (
<div key={s.title} className="card" style={{ padding: "2rem" }}>
<div style={{ textAlign: "center", marginBottom: "3rem" }}>
<div style={{ fontSize: ".85rem", fontWeight: 700, color: "var(--primary)", textTransform: "uppercase", letterSpacing: "2px", marginBottom: ".75rem" }}>Nuestros servicios</div>
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 800, marginBottom: "1rem" }}>Soluciones para cada tipo de carga</h2>
<p style={{ color: "var(--gray-500)", maxWidth: 560, margin: "0 auto" }}>
Desde un pallet hasta un contenedor completo. Coordinamos cada detalle logístico y aduanero.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }}>
{SERVICES.map(s => (
<div key={s.title} className="card" style={{ padding: "1.75rem", position: "relative" }}>
{s.badge && (
<span className="badge badge-blue" style={{ position: "absolute", top: "1rem", right: "1rem", fontSize: ".7rem" }}>{s.badge}</span>
)}
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>{s.icon}</div>
<h3 style={{ fontWeight: 700, marginBottom: ".75rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{s.desc}</p>
<h3 style={{ fontWeight: 700, fontSize: "1.05rem", marginBottom: ".6rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem", lineHeight: 1.6, marginBottom: "1rem" }}>{s.desc}</p>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: ".35rem" }}>
{s.details.map(d => (
<li key={d} style={{ fontSize: ".82rem", color: "var(--gray-600)", display: "flex", gap: ".4rem", alignItems: "flex-start" }}>
<span style={{ color: "var(--green)", marginTop: "2px", flexShrink: 0 }}></span>
{d}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
<div style={{ textAlign: "center" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>¿Necesitas importar en volumen?</h2>
<p style={{ color: "var(--gray-500)", marginBottom: "2rem" }}>Completa el formulario y un asesor te contactará en menos de 24 horas.</p>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-xl">Solicitar cotización </Link>
{/* Proceso */}
<div className="section-gray">
<div className="container" style={{ maxWidth: 800 }}>
<div style={{ textAlign: "center", marginBottom: "3rem" }}>
<div style={{ fontSize: ".85rem", fontWeight: 700, color: "var(--primary)", textTransform: "uppercase", letterSpacing: "2px", marginBottom: ".75rem" }}>Proceso</div>
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 800 }}>De NJ a Ecuador en 7 pasos</h2>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{PROCESS.map((p, i) => (
<div key={p.n} style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0 }}>
<div style={{ width: 44, height: 44, borderRadius: "50%", background: p.color, color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: "1rem" }}>{p.n}</div>
{i < PROCESS.length - 1 && <div style={{ width: 2, height: 36, background: "var(--gray-200)", marginTop: ".25rem" }} />}
</div>
<div className="card" style={{ flex: 1, padding: "1rem 1.25rem" }}>
<div style={{ fontWeight: 700, fontSize: ".95rem", marginBottom: ".3rem" }}>{p.title}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", lineHeight: 1.6 }}>{p.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Sectores */}
<div className="section" style={{ background: "white" }}>
<div className="container">
<div style={{ textAlign: "center", marginBottom: "2.5rem" }}>
<h2 style={{ fontSize: "1.75rem", fontWeight: 800, marginBottom: ".75rem" }}>Sectores que atendemos</h2>
<p style={{ color: "var(--gray-500)" }}>Experiencia en los rubros con mayor volumen de importación EcuadorEE.UU.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "1rem" }}>
{SECTORS.map(s => (
<div key={s.label} className="card" style={{ padding: "1.25rem", textAlign: "center" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>{s.icon}</div>
<div style={{ fontSize: ".85rem", fontWeight: 600, color: "var(--gray-700)" }}>{s.label}</div>
</div>
))}
</div>
</div>
</div>
{/* INEN callout */}
<div className="section-gray">
<div className="container" style={{ maxWidth: 720 }}>
<div className="card" style={{ padding: "2rem", borderLeft: "4px solid var(--primary)" }}>
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".75rem" }}>
📋 Certificaciones INEN ¿Tu producto lo requiere?
</h3>
<p style={{ color: "var(--gray-600)", lineHeight: 1.7, marginBottom: "1rem" }}>
Los productos regulados (calzado, textiles, electrónicos, alimentos, cosméticos) deben cumplir las normas técnicas del <strong>INEN</strong> y obtener el Registro de Conformidad antes del embarque.
Nuestro equipo verifica y gestiona este proceso por ti.
</p>
<div style={{ display: "flex", gap: ".75rem", flexWrap: "wrap" }}>
<Link href="/carga-pesada/inen" className="btn btn-outline btn-sm">Ver productos regulados</Link>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-sm">Consultar mi caso</Link>
</div>
</div>
</div>
</div>
{/* CTA Final */}
<div className="section" style={{ background: "linear-gradient(135deg, var(--primary) 0%, #0f3a8a 100%)", color: "white" }}>
<div className="container" style={{ textAlign: "center" }}>
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2.25rem)", fontWeight: 800, marginBottom: "1rem" }}>¿Listo para importar?</h2>
<p style={{ color: "rgba(255,255,255,.8)", maxWidth: 520, margin: "0 auto 2rem", fontSize: "1.05rem" }}>
Obtén una cotización sin costo en menos de 48 horas. Nuestro equipo bilingüe NJEcuador te acompaña en todo el proceso.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<Link href="/carga-pesada/cotizacion" className="btn btn-accent btn-xl">Solicitar cotización gratuita </Link>
<Link href="/quienes-somos" className="btn btn-outline btn-xl" style={{ color: "white", borderColor: "rgba(255,255,255,.4)" }}>Conocer el equipo</Link>
</div>
</div>
</div>
</div>
</div>
);
+37 -6
View File
@@ -19,11 +19,15 @@ function PagoContent() {
const router = useRouter();
const params = useSearchParams();
const packageId = params.get("packageId");
const sessionId = params.get("session_id"); // Stripe success callback
const stripeOk = params.get("success") === "1";
const stripeCancelled = params.get("cancelled") === "1";
const user = getUser();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
@@ -36,17 +40,36 @@ function PagoContent() {
.finally(() => setLoading(false));
}, [packageId, router, user]);
// Auto-confirmar cuando Stripe redirige de vuelta con session_id
useEffect(() => {
if (!stripeOk || !sessionId || !data) return;
setConfirming(true);
api.payments.confirmSession(sessionId)
.then(async () => {
setSuccess(true);
const fresh = await api.payments.packageDetail(packageId!);
setData(fresh);
})
.catch(e => setError(e?.message ?? "Error al confirmar el pago."))
.finally(() => setConfirming(false));
}, [stripeOk, sessionId, data]);
const handlePay = async () => {
if (!data) return;
setPaying(true);
setError(null);
try {
// 1. Crear intent de pago
const intent = await api.payments.createIntent(data.package.id);
// 2. En producción aquí se abre Stripe Checkout/PayPhone; en dev confirmamos directamente
// Si Stripe está configurado, redirigir al checkout hospedado
if (intent.checkoutUrl) {
window.location.href = intent.checkoutUrl;
return;
}
// Fallback dev: confirmar directamente (stub sin Stripe)
await api.payments.confirm(intent.id);
setSuccess(true);
// Recargar datos
const fresh = await api.payments.packageDetail(packageId!);
setData(fresh);
} catch (e: any) {
@@ -56,10 +79,11 @@ function PagoContent() {
}
};
if (loading) {
if (loading || confirming) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", minHeight: "60vh", gap: "1rem" }}>
<div className="spinner" />
{confirming && <p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>Verificando pago con Stripe...</p>}
</div>
);
}
@@ -144,6 +168,13 @@ function PagoContent() {
<strong>Valor declarado:</strong> ${pkg?.declaredValue} USD
</div>
{/* Alerta de cancelación de Stripe */}
{stripeCancelled && !success && (
<div style={{ background: "#FFF7ED", border: "1px solid #FED7AA", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", fontSize: ".875rem", color: "#9A3412" }}>
Cancelaste el proceso de pago. Puedes intentarlo de nuevo cuando quieras.
</div>
)}
{/* Alerta de éxito */}
{(success || alreadyPaid) && (
<div style={{ background: "#F0FDF4", border: "1px solid #86EFAC", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", display: "flex", gap: ".75rem", alignItems: "flex-start" }}>
@@ -170,7 +201,7 @@ function PagoContent() {
onClick={handlePay}
disabled={paying}
>
{paying ? "Procesando pago..." : `Pagar $${breakdown?.total ?? 0} USD`}
{paying ? "Redirigiendo a Stripe..." : `Pagar $${breakdown?.total ?? 0} USD con Stripe`}
</button>
)}
+244 -55
View File
@@ -1,109 +1,298 @@
"use client";
import { useEffect, useState } from "react";
import { api, getUser, setUser } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
type Section = "perfil" | "password" | "mfa";
export default function PerfilPage() {
const [user, setUserState] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [mfaSetup, setMfaSetup] = useState<any>(null);
const [totpCode, setTotpCode] = useState("");
const [mfaMsg, setMfaMsg] = useState("");
const [mfaError, setMfaError] = useState("");
const [user, setUserState] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [section, setSection] = useState<Section>("perfil");
// ── Edit profile ──────────────────────────────────────────
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ firstName: "", lastName: "", phone: "" });
const [editSaving, setEditSaving] = useState(false);
const [editMsg, setEditMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
// ── Change password ───────────────────────────────────────
const [pwForm, setPwForm] = useState({ oldPassword: "", newPassword: "", confirm: "" });
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
// ── MFA ───────────────────────────────────────────────────
const [mfaSetup, setMfaSetup] = useState<any>(null);
const [totpCode, setTotpCode] = useState("");
const [mfaMsg, setMfaMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
api.auth.me().then(me => { setUserState(me); setUser(me); }).catch(() => {}).finally(() => setLoading(false));
api.auth.me()
.then(me => { setUserState(me); setUser(me); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSetupMfa = async () => {
try {
const data = await api.auth.setupMfa();
setMfaSetup(data);
} catch (err: any) { setMfaError(err.message); }
const reloadUser = async () => {
const me = await api.auth.me();
setUserState(me); setUser(me);
};
// ── Profile edit ──────────────────────────────────────────
const startEdit = () => {
setEditForm({ firstName: user.firstName, lastName: user.lastName, phone: user.phone ?? "" });
setEditMode(true); setEditMsg(null);
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault(); setEditMsg(null); setEditSaving(true);
try {
await api.auth.updateProfile(editForm);
await reloadUser();
setEditMode(false);
setEditMsg({ type: "success", text: "Perfil actualizado." });
} catch (err: any) {
setEditMsg({ type: "error", text: err.message ?? "Error al guardar." });
} finally { setEditSaving(false); }
};
// ── Password change ───────────────────────────────────────
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); setPwMsg(null);
if (pwForm.newPassword !== pwForm.confirm) {
setPwMsg({ type: "error", text: "Las contraseñas nuevas no coinciden." }); return;
}
if (pwForm.newPassword.length < 8) {
setPwMsg({ type: "error", text: "La nueva contraseña debe tener al menos 8 caracteres." }); return;
}
setPwSaving(true);
try {
await api.auth.changePassword({ oldPassword: pwForm.oldPassword, newPassword: pwForm.newPassword });
setPwMsg({ type: "success", text: "Contraseña cambiada correctamente." });
setPwForm({ oldPassword: "", newPassword: "", confirm: "" });
} catch (err: any) {
setPwMsg({ type: "error", text: err.message ?? "Error al cambiar contraseña." });
} finally { setPwSaving(false); }
};
// ── MFA setup ─────────────────────────────────────────────
const handleSetupMfa = async () => {
try { const data = await api.auth.setupMfa(); setMfaSetup(data); setMfaMsg(null); }
catch (err: any) { setMfaMsg({ type: "error", text: err.message }); }
};
const handleVerifyMfa = async (e: React.FormEvent) => {
e.preventDefault(); setMfaError(""); setMfaMsg("");
e.preventDefault(); setMfaMsg(null);
try {
await api.auth.verifyMfa(totpCode);
setMfaMsg("MFA activado correctamente.");
setMfaMsg({ type: "success", text: "MFA activado correctamente." });
setMfaSetup(null); setTotpCode("");
const me = await api.auth.me();
setUserState(me); setUser(me);
} catch (err: any) { setMfaError(err.message ?? "Código inválido"); }
await reloadUser();
} catch (err: any) { setMfaMsg({ type: "error", text: err.message ?? "Código inválido" }); }
};
const handleDisableMfa = async (e: React.FormEvent) => {
e.preventDefault(); setMfaMsg(null);
try {
await api.auth.disableMfa(totpCode);
setMfaMsg({ type: "success", text: "MFA desactivado." });
setTotpCode("");
await reloadUser();
} catch (err: any) { setMfaMsg({ type: "error", text: err.message ?? "Código inválido" }); }
};
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return <div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}><div className="spinner" /></div>;
const TABS: Array<{ id: Section; label: string; icon: string }> = [
{ id: "perfil", label: "Datos personales", icon: "👤" },
{ id: "password", label: "Contraseña", icon: "🔑" },
{ id: "mfa", label: "Autenticación MFA", icon: "🔐" },
];
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Mi Perfil</h1>
<p className="dash-page-subtitle">Información de tu cuenta.</p>
<p className="dash-page-subtitle">Gestiona tu cuenta y seguridad.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos personales</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Nombre", `${user.firstName} ${user.lastName}`],
["Email", user.email],
["Teléfono", user.phone ?? "—"],
["Rol", user.role],
["Casillero", user.suite?.code ?? "—"],
["Miembro desde", new Date(user.createdAt).toLocaleDateString("es-EC")],
["Último acceso", user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString("es-EC") : "—"],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>{v as string}</span>
{/* Tabs */}
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.5rem", borderBottom: "2px solid var(--gray-100)", paddingBottom: ".5rem", flexWrap: "wrap" }}>
{TABS.map(t => (
<button
key={t.id}
onClick={() => setSection(t.id)}
style={{
padding: ".5rem 1rem",
borderRadius: "var(--radius)",
fontWeight: section === t.id ? 700 : 500,
fontSize: ".875rem",
background: section === t.id ? "var(--primary)" : "transparent",
color: section === t.id ? "white" : "var(--gray-600)",
border: "none",
cursor: "pointer",
transition: "all .15s",
}}
>
{t.icon} {t.label}
</button>
))}
</div>
{/* ── Datos personales ── */}
{section === "perfil" && (
<div className="card" style={{ maxWidth: 560 }}>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>Datos personales</span>
{!editMode && (
<button className="btn btn-ghost btn-sm" onClick={startEdit}>Editar</button>
)}
</div>
<div className="card-body">
{editMsg && <div className={`alert alert-${editMsg.type} mb-4`}>{editMsg.text}</div>}
{!editMode ? (
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Nombre", `${user.firstName} ${user.lastName}`],
["Email", user.email],
["Teléfono", user.phone ?? "—"],
["Rol", user.role],
["Casillero", user.suite?.code ?? "—"],
["Miembro desde", null],
["Último acceso", null],
].map(([k, v], i) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>
{i === 5 ? <Timestamp value={user.createdAt} dateOnly /> :
i === 6 ? (user.lastLoginAt ? <Timestamp value={user.lastLoginAt} /> : "—") :
v as string}
</span>
</div>
))}
</div>
))}
) : (
<form onSubmit={handleSaveProfile} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label className="form-label">Nombre</label>
<input className="form-input" value={editForm.firstName}
onChange={e => setEditForm(f => ({ ...f, firstName: e.target.value }))} required minLength={2} />
</div>
<div>
<label className="form-label">Apellido</label>
<input className="form-input" value={editForm.lastName}
onChange={e => setEditForm(f => ({ ...f, lastName: e.target.value }))} required minLength={2} />
</div>
<div>
<label className="form-label">Teléfono (con código de país)</label>
<input className="form-input" placeholder="+593 99 123 4567" value={editForm.phone}
onChange={e => setEditForm(f => ({ ...f, phone: e.target.value }))} />
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", marginTop: ".25rem" }}>
Incluye código de país para activar notificaciones WhatsApp (ej: +593912345678)
</p>
</div>
<div style={{ display: "flex", gap: ".75rem", justifyContent: "flex-end" }}>
<button type="button" className="btn btn-ghost" onClick={() => setEditMode(false)} disabled={editSaving}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={editSaving}>
{editSaving ? "Guardando..." : "Guardar cambios"}
</button>
</div>
</form>
)}
</div>
</div>
)}
<div className="card">
<div className="card-header"><span className="font-semibold">Seguridad</span></div>
{/* ── Contraseña ── */}
{section === "password" && (
<div className="card" style={{ maxWidth: 460 }}>
<div className="card-header"><span style={{ fontWeight: 700 }}>Cambiar contraseña</span></div>
<div className="card-body">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
{pwMsg && <div className={`alert alert-${pwMsg.type} mb-4`}>{pwMsg.text}</div>}
<form onSubmit={handleChangePassword} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<div style={{ fontWeight: 600 }}>Autenticación en dos pasos (MFA)</div>
<div style={{ fontSize: ".85rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
{user.mfaEnabled ? "Activada — tu cuenta tiene protección extra." : "No activada — te recomendamos habilitarla."}
</div>
<label className="form-label">Contraseña actual</label>
<input type="password" className="form-input" value={pwForm.oldPassword}
onChange={e => setPwForm(f => ({ ...f, oldPassword: e.target.value }))} required autoComplete="current-password" />
</div>
<div>
<label className="form-label">Nueva contraseña</label>
<input type="password" className="form-input" value={pwForm.newPassword}
onChange={e => setPwForm(f => ({ ...f, newPassword: e.target.value }))} required minLength={8} autoComplete="new-password" />
</div>
<div>
<label className="form-label">Confirmar nueva contraseña</label>
<input type="password" className="form-input" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} required minLength={8} autoComplete="new-password" />
</div>
<button type="submit" className="btn btn-primary" disabled={pwSaving}>
{pwSaving ? "Cambiando..." : "Cambiar contraseña"}
</button>
</form>
</div>
</div>
)}
{/* ── MFA ── */}
{section === "mfa" && (
<div className="card" style={{ maxWidth: 520 }}>
<div className="card-header">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>Autenticación en dos pasos (TOTP)</span>
<span className={`badge ${user.mfaEnabled ? "badge-green" : "badge-gray"}`}>
{user.mfaEnabled ? "Activada" : "Desactivada"}
</span>
</div>
</div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{mfaMsg && <div className={`alert alert-${mfaMsg.type}`}>{mfaMsg.text}</div>}
{mfaMsg && <div className="alert alert-success mb-4">{mfaMsg}</div>}
{mfaError && <div className="alert alert-error mb-4">{mfaError}</div>}
{/* Activar MFA */}
{!user.mfaEnabled && !mfaSetup && (
<button className="btn btn-outline" onClick={handleSetupMfa}>Activar MFA</button>
<div>
<p style={{ fontSize: ".9rem", color: "var(--gray-600)", marginBottom: "1rem" }}>
La autenticación en dos pasos agrega una capa extra de seguridad. Necesitas una app TOTP como Google Authenticator o Authy.
</p>
<button className="btn btn-primary" onClick={handleSetupMfa}>Activar MFA</button>
</div>
)}
{mfaSetup && (
{!user.mfaEnabled && mfaSetup && (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="alert alert-info">
<strong>1.</strong> Escanea este QR con Google Authenticator, Authy, u otra app TOTP.<br />
<strong>2.</strong> Ingresa el código de 6 dígitos para confirmar.
<strong>Paso 1:</strong> Escanea el QR con tu app TOTP o copia el secreto manualmente.<br />
<strong>Paso 2:</strong> Ingresa el código de 6 dígitos para confirmar.
</div>
<div style={{ background: "var(--gray-100)", borderRadius: "var(--radius)", padding: "1rem", wordBreak: "break-all", fontSize: ".8rem", fontFamily: "monospace" }}>
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius)", padding: "1rem", fontSize: ".8rem", fontFamily: "monospace", wordBreak: "break-all", color: "var(--gray-700)" }}>
{mfaSetup.otpAuthUrl}
</div>
<p style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Secret: <code>{mfaSetup.secret}</code></p>
<p style={{ fontSize: ".8rem", color: "var(--gray-500)", margin: 0 }}>
Secreto manual: <code style={{ background: "var(--gray-100)", padding: "2px 6px", borderRadius: 4 }}>{mfaSetup.secret}</code>
</p>
<form onSubmit={handleVerifyMfa} style={{ display: "flex", gap: ".75rem" }}>
<input className="input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
<input className="form-input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
<button type="submit" className="btn btn-primary">Verificar</button>
<button type="submit" className="btn btn-primary">Verificar y activar</button>
</form>
</div>
)}
{/* Desactivar MFA */}
{user.mfaEnabled && (
<div>
<div className="alert alert-warning" style={{ marginBottom: "1rem" }}>
Para desactivar MFA debes confirmar con un código de tu app TOTP.
</div>
<form onSubmit={handleDisableMfa} style={{ display: "flex", gap: ".75rem" }}>
<input className="form-input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
<button type="submit" className="btn btn-outline" style={{ borderColor: "var(--error)", color: "var(--error)" }}>
Desactivar MFA
</button>
</form>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
+9 -2
View File
@@ -85,9 +85,14 @@ export const api = {
register: (body: any) => request<any>("/auth/register", { method: "POST", body: JSON.stringify(body) }),
login: (body: any) => request<any>("/auth/login", { method: "POST", body: JSON.stringify(body) }),
me: () => request<any>("/auth/me"),
updateProfile: (body: { firstName?: string; lastName?: string; phone?: string }) =>
request<any>("/auth/me", { method: "PATCH", body: JSON.stringify(body) }),
changePassword: (body: { oldPassword: string; newPassword: string }) =>
request<any>("/auth/password", { method: "PATCH", body: JSON.stringify(body) }),
logout: (refreshToken: string) => request<any>("/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken }) }),
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
verifyMfa:(totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
verifyMfa: (totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
disableMfa: (totpCode: string) => request<any>("/auth/mfa/disable", { method: "POST", body: JSON.stringify({ totpCode }) }),
},
packages: {
list: (params?: Record<string,string>) => request<any[]>("/packages" + (params ? "?" + new URLSearchParams(params) : "")),
@@ -168,6 +173,8 @@ export const api = {
request<any>("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }),
confirm: (paymentId: string) =>
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
confirmSession: (sessionId: string) =>
request<any>("/payments/confirm-session", { method: "POST", body: JSON.stringify({ sessionId }) }),
},
consolidations: {
list: () => request<any[]>("/consolidations"),
+16
View File
@@ -71,6 +71,9 @@ importers:
rxjs:
specifier: ^7.8.2
version: 7.8.2
stripe:
specifier: ^22.2.0
version: 22.2.0(@types/node@22.19.19)
devDependencies:
'@nestjs/cli':
specifier: ^11.0.7
@@ -2889,6 +2892,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
stripe@22.2.0:
resolution: {integrity: sha512-WFGpMOom9QZqso1kcnSwJsCdC1QHDlMoCOxBZRf3JraMzhkfw7dgSdD2a1CFZrqC+mzAfqeEtYILrZhWKIDruA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
@@ -6230,6 +6242,10 @@ snapshots:
strip-json-comments@3.1.1: {}
stripe@22.2.0(@types/node@22.19.19):
optionalDependencies:
'@types/node': 22.19.19
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0