From 98ab5a309c182584f53b3009086be969ae8e0fca Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:24:35 -0500 Subject: [PATCH] feat: Stripe Checkout, Zeptomail, pre-alert linking, perfil 3-tab, carga-pesada landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/package.json | 3 +- apps/api/src/auth/auth.controller.ts | 28 +- apps/api/src/auth/auth.service.ts | 45 +++ apps/api/src/auth/dto/auth.dto.ts | 26 ++ .../src/integrations/integrations.service.ts | 14 +- .../src/notifications/notifications.module.ts | 3 +- .../notifications.service.spec.ts | 27 +- .../notifications/notifications.service.ts | 131 +++++++- apps/api/src/packages/packages.service.ts | 31 ++ apps/api/src/payments/payments.controller.ts | 46 ++- apps/api/src/payments/payments.module.ts | 4 +- .../api/src/payments/payments.service.spec.ts | 15 + apps/api/src/payments/payments.service.ts | 138 +++++++- apps/web/src/app/admin/b2b/page.tsx | 30 +- apps/web/src/app/carga-pesada/page.tsx | 203 ++++++++++-- apps/web/src/app/portal/pago/page.tsx | 43 ++- apps/web/src/app/portal/perfil/page.tsx | 299 ++++++++++++++---- apps/web/src/lib/api.ts | 11 +- pnpm-lock.yaml | 16 + 19 files changed, 979 insertions(+), 134 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 4a2496e..05254bc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 8170c0a..291d68f 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } } + diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index c7a282d..5735e10 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -185,6 +185,51 @@ export class AuthService { return { mfaEnabled: true }; } + // ─── Change Password ───────────────────────────────────────── + async changePassword(userId: string, oldPassword: string, newPassword: string): Promise { + 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 { + 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 { + 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 { const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); diff --git a/apps/api/src/auth/dto/auth.dto.ts b/apps/api/src/auth/dto/auth.dto.ts index c2f7183..d587fc9 100644 --- a/apps/api/src/auth/dto/auth.dto.ts +++ b/apps/api/src/auth/dto/auth.dto.ts @@ -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; +} + diff --git a/apps/api/src/integrations/integrations.service.ts b/apps/api/src/integrations/integrations.service.ts index 5ca851f..15d219b 100644 --- a/apps/api/src/integrations/integrations.service.ts +++ b/apps/api/src/integrations/integrations.service.ts @@ -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 }, diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index 7c7ab03..36ec9d8 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -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], diff --git a/apps/api/src/notifications/notifications.service.spec.ts b/apps/api/src/notifications/notifications.service.spec.ts index e5f9485..46c5830 100644 --- a/apps/api/src/notifications/notifications.service.spec.ts +++ b/apps/api/src/notifications/notifications.service.spec.ts @@ -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); @@ -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(); }); diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index 8d6e447..8cac820 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -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 = { @@ -35,11 +36,19 @@ function interpolate(tpl: string, vars: Record): 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 { + // 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 = { 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 = ` +
+
+

Moraworld.Imports

+
+
+

${bodyText.replace(/\n/g, "
")}

+
+

Moraworld Imports S.A.S. · Mora Global Import LLC

+
+
`; + + 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 }, diff --git a/apps/api/src/packages/packages.service.ts b/apps/api/src/packages/packages.service.ts index 074c3c8..83276af 100644 --- a/apps/api/src/packages/packages.service.ts +++ b/apps/api/src/packages/packages.service.ts @@ -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 { + 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 { const pkg = await this.prisma.client.package.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException("Paquete no encontrado."); diff --git a/apps/api/src/payments/payments.controller.ts b/apps/api/src/payments/payments.controller.ts index 950e56a..9892ad2 100644 --- a/apps/api/src/payments/payments.controller.ts +++ b/apps/api/src/payments/payments.controller.ts @@ -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 { 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 { 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 { 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 { 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 { + 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 { 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, + @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 }; + } } diff --git a/apps/api/src/payments/payments.module.ts b/apps/api/src/payments/payments.module.ts index b4e8992..d66cf4d 100644 --- a/apps/api/src/payments/payments.module.ts +++ b/apps/api/src/payments/payments.module.ts @@ -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], diff --git a/apps/api/src/payments/payments.service.spec.ts b/apps/api/src/payments/payments.service.spec.ts index 04fd3f7..fa54106 100644 --- a/apps/api/src/payments/payments.service.spec.ts +++ b/apps/api/src/payments/payments.service.spec.ts @@ -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); diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts index de5487b..2a08b94 100644 --- a/apps/api/src/payments/payments.service.ts +++ b/apps/api/src/payments/payments.service.ts @@ -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; @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 { + 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 { @@ -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 { 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 { + // 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 { 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 { + 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 { + 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 { return this.prisma.client.payment.findMany({ diff --git a/apps/web/src/app/admin/b2b/page.tsx b/apps/web/src/app/admin/b2b/page.tsx index 7948266..4f979fc 100644 --- a/apps/web/src/app/admin/b2b/page.tsx +++ b/apps/web/src/app/admin/b2b/page.tsx @@ -5,26 +5,26 @@ import { Timestamp } from "@/app/_components/timestamp"; // §13 — Flujo del Importador Mayorista (B2B) const STATUS_LABEL: Record = { - 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 = { - 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([]); diff --git a/apps/web/src/app/carga-pesada/page.tsx b/apps/web/src/app/carga-pesada/page.tsx index c68025d..c9aed37 100644 --- a/apps/web/src/app/carga-pesada/page.tsx +++ b/apps/web/src/app/carga-pesada/page.tsx @@ -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 24–48 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 ( -
+
+ {/* Navbar */}
-
-
-

Carga Pesada y B2B

-

- Importaciones de volumen para empresas y emprendedores. Maquinaria, muebles, vehículos y más. + + {/* Hero */} +

+
+
+
+ {["FCL / LCL", "Pallets", "Maquinaria", "SENAE Autorizado", "INEN"].map(b => ( + {b} + ))} +
+

+ Carga Pesada
+ EE.UU. → Ecuador +

+

+ Importación de volumen para empresas y mayoristas. Contenedores, pallets, maquinaria industrial. + Gestión aduanera DAI completa con agentes SENAE autorizados.

+
+ + Solicitar cotización → + + + Ver proceso + +
-
+ {/* Servicios */} +
-
- {[ - { 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 3–5 días." }, - { icon: "🏭", title: "Soluciones B2B", desc: "Gestión de importaciones recurrentes para empresas. Precios preferenciales por volumen." }, - ].map(s => ( -
+
+
Nuestros servicios
+

Soluciones para cada tipo de carga

+

+ Desde un pallet hasta un contenedor completo. Coordinamos cada detalle logístico y aduanero. +

+
+
+ {SERVICES.map(s => ( +
+ {s.badge && ( + {s.badge} + )}
{s.icon}
-

{s.title}

-

{s.desc}

+

{s.title}

+

{s.desc}

+
    + {s.details.map(d => ( +
  • + + {d} +
  • + ))} +
))}
+
+
-
-

¿Necesitas importar en volumen?

-

Completa el formulario y un asesor te contactará en menos de 24 horas.

- Solicitar cotización → + {/* Proceso */} +
+
+
+
Proceso
+

De NJ a Ecuador en 7 pasos

+
+
+ {PROCESS.map((p, i) => ( +
+
+
{p.n}
+ {i < PROCESS.length - 1 &&
} +
+
+
{p.title}
+
{p.desc}
+
+
+ ))}
+ + {/* Sectores */} +
+
+
+

Sectores que atendemos

+

Experiencia en los rubros con mayor volumen de importación Ecuador–EE.UU.

+
+
+ {SECTORS.map(s => ( +
+
{s.icon}
+
{s.label}
+
+ ))} +
+
+
+ + {/* INEN callout */} +
+
+
+

+ 📋 Certificaciones INEN — ¿Tu producto lo requiere? +

+

+ Los productos regulados (calzado, textiles, electrónicos, alimentos, cosméticos) deben cumplir las normas técnicas del INEN y obtener el Registro de Conformidad antes del embarque. + Nuestro equipo verifica y gestiona este proceso por ti. +

+
+ Ver productos regulados + Consultar mi caso +
+
+
+
+ + {/* CTA Final */} +
+
+

¿Listo para importar?

+

+ Obtén una cotización sin costo en menos de 48 horas. Nuestro equipo bilingüe NJ–Ecuador te acompaña en todo el proceso. +

+
+ Solicitar cotización gratuita → + Conocer el equipo +
+
+
+
); diff --git a/apps/web/src/app/portal/pago/page.tsx b/apps/web/src/app/portal/pago/page.tsx index e990796..7b6e526 100644 --- a/apps/web/src/app/portal/pago/page.tsx +++ b/apps/web/src/app/portal/pago/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [paying, setPaying] = useState(false); + const [confirming, setConfirming] = useState(false); const [error, setError] = useState(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 ( -
+
+ {confirming &&

Verificando pago con Stripe...

}
); } @@ -144,6 +168,13 @@ function PagoContent() { Valor declarado: ${pkg?.declaredValue} USD
+ {/* Alerta de cancelación de Stripe */} + {stripeCancelled && !success && ( +
+ Cancelaste el proceso de pago. Puedes intentarlo de nuevo cuando quieras. +
+ )} + {/* Alerta de éxito */} {(success || alreadyPaid) && (
@@ -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`} )} diff --git a/apps/web/src/app/portal/perfil/page.tsx b/apps/web/src/app/portal/perfil/page.tsx index 14a662b..e26ec7a 100644 --- a/apps/web/src/app/portal/perfil/page.tsx +++ b/apps/web/src/app/portal/perfil/page.tsx @@ -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(null); - const [loading, setLoading] = useState(true); - const [mfaSetup, setMfaSetup] = useState(null); - const [totpCode, setTotpCode] = useState(""); - const [mfaMsg, setMfaMsg] = useState(""); - const [mfaError, setMfaError] = useState(""); + const [user, setUserState] = useState(null); + const [loading, setLoading] = useState(true); + const [section, setSection] = useState
("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(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
; + if (loading) return
; + + 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 (

Mi Perfil

-

Información de tu cuenta.

+

Gestiona tu cuenta y seguridad.

-
-
-
Datos personales
-
- {[ - ["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]) => ( -
- {k} - {v as string} + {/* Tabs */} +
+ {TABS.map(t => ( + + ))} +
+ + {/* ── Datos personales ── */} + {section === "perfil" && ( +
+
+ Datos personales + {!editMode && ( + + )} +
+
+ {editMsg &&
{editMsg.text}
} + + {!editMode ? ( +
+ {[ + ["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) => ( +
+ {k} + + {i === 5 ? : + i === 6 ? (user.lastLoginAt ? : "—") : + v as string} + +
+ ))}
- ))} + ) : ( +
+
+ + setEditForm(f => ({ ...f, firstName: e.target.value }))} required minLength={2} /> +
+
+ + setEditForm(f => ({ ...f, lastName: e.target.value }))} required minLength={2} /> +
+
+ + setEditForm(f => ({ ...f, phone: e.target.value }))} /> +

+ Incluye código de país para activar notificaciones WhatsApp (ej: +593912345678) +

+
+
+ + +
+
+ )}
+ )} -
-
Seguridad
+ {/* ── Contraseña ── */} + {section === "password" && ( +
+
Cambiar contraseña
-
+ {pwMsg &&
{pwMsg.text}
} +
-
Autenticación en dos pasos (MFA)
-
- {user.mfaEnabled ? "Activada — tu cuenta tiene protección extra." : "No activada — te recomendamos habilitarla."} -
+ + setPwForm(f => ({ ...f, oldPassword: e.target.value }))} required autoComplete="current-password" />
+
+ + setPwForm(f => ({ ...f, newPassword: e.target.value }))} required minLength={8} autoComplete="new-password" /> +
+
+ + setPwForm(f => ({ ...f, confirm: e.target.value }))} required minLength={8} autoComplete="new-password" /> +
+ +
+
+
+ )} + + {/* ── MFA ── */} + {section === "mfa" && ( +
+
+
+ Autenticación en dos pasos (TOTP) {user.mfaEnabled ? "Activada" : "Desactivada"}
+
+
+ {mfaMsg &&
{mfaMsg.text}
} - {mfaMsg &&
{mfaMsg}
} - {mfaError &&
{mfaError}
} - + {/* Activar MFA */} {!user.mfaEnabled && !mfaSetup && ( - +
+

+ La autenticación en dos pasos agrega una capa extra de seguridad. Necesitas una app TOTP como Google Authenticator o Authy. +

+ +
)} - {mfaSetup && ( + {!user.mfaEnabled && mfaSetup && (
- 1. Escanea este QR con Google Authenticator, Authy, u otra app TOTP.
- 2. Ingresa el código de 6 dígitos para confirmar. + Paso 1: Escanea el QR con tu app TOTP o copia el secreto manualmente.
+ Paso 2: Ingresa el código de 6 dígitos para confirmar.
-
+
{mfaSetup.otpAuthUrl}
-

Secret: {mfaSetup.secret}

+

+ Secreto manual: {mfaSetup.secret} +

- setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required /> - + +
+
+ )} + + {/* Desactivar MFA */} + {user.mfaEnabled && ( +
+
+ Para desactivar MFA debes confirmar con un código de tu app TOTP. +
+
+ setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required /> +
)}
-
+ )}
); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d8ce789..3eaab63 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -85,9 +85,14 @@ export const api = { register: (body: any) => request("/auth/register", { method: "POST", body: JSON.stringify(body) }), login: (body: any) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }), me: () => request("/auth/me"), + updateProfile: (body: { firstName?: string; lastName?: string; phone?: string }) => + request("/auth/me", { method: "PATCH", body: JSON.stringify(body) }), + changePassword: (body: { oldPassword: string; newPassword: string }) => + request("/auth/password", { method: "PATCH", body: JSON.stringify(body) }), logout: (refreshToken: string) => request("/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken }) }), - setupMfa: () => request("/auth/mfa/setup", { method: "POST" }), - verifyMfa:(totpCode: string) => request("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }), + setupMfa: () => request("/auth/mfa/setup", { method: "POST" }), + verifyMfa: (totpCode: string) => request("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }), + disableMfa: (totpCode: string) => request("/auth/mfa/disable", { method: "POST", body: JSON.stringify({ totpCode }) }), }, packages: { list: (params?: Record) => request("/packages" + (params ? "?" + new URLSearchParams(params) : "")), @@ -168,6 +173,8 @@ export const api = { request("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }), confirm: (paymentId: string) => request(`/payments/${paymentId}/confirm`, { method: "POST" }), + confirmSession: (sessionId: string) => + request("/payments/confirm-session", { method: "POST", body: JSON.stringify({ sessionId }) }), }, consolidations: { list: () => request("/consolidations"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55dc0d6..457236f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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