diff --git a/.task-project b/.task-project new file mode 100644 index 0000000..02ca458 --- /dev/null +++ b/.task-project @@ -0,0 +1,3 @@ +{ + "project": "moraworld-imports" +} \ No newline at end of file diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 4844e9d..eb5e291 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -6,6 +6,8 @@ import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { JwtStrategy } from "./jwt.strategy"; import { WarehousesModule } from "../warehouses/warehouses.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { IntegrationsModule } from "../integrations/integrations.module"; @Module({ imports: [ @@ -19,6 +21,8 @@ import { WarehousesModule } from "../warehouses/warehouses.module"; }), }), WarehousesModule, + NotificationsModule, + IntegrationsModule, ], controllers: [AuthController], providers: [AuthService, JwtStrategy], diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 5735e10..1679b7d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -5,11 +5,13 @@ import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import { PrismaService } from "../prisma/prisma.service"; import { WarehousesService } from "../warehouses/warehouses.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { IntegrationsService, INTEGRATION_CATALOG } from "../integrations/integrations.service"; import { generateSuiteCode } from "../common/utils/suite-code.util"; import { RegisterDto, LoginDto } from "./dto/auth.dto"; import * as bcrypt from "bcrypt"; import * as crypto from "crypto"; -import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib"; +import { generateSecret, generateURI, verify as totpVerify } from "otplib"; const TENANT_SLUG = "moraworld"; const BCRYPT_ROUNDS = 10; @@ -21,6 +23,8 @@ export class AuthService { private jwt: JwtService, private config: ConfigService, private warehouses: WarehousesService, + private notifications: NotificationsService, + private integrations: IntegrationsService, ) {} /** Builds the suite address from the default warehouse in DB, falls back to env vars */ @@ -70,6 +74,24 @@ export class AuthService { await this.audit(tenant.id, user.id, "USER_REGISTER", "User", user.id); + // L-7: Auto-seed integration keys vacías (idempotente) + try { + await Promise.all( + INTEGRATION_CATALOG.map(cat => + this.prisma.client.integration.upsert({ + where: { tenantId_key: { tenantId: tenant.id, key: cat.key } }, + create: { tenantId: tenant.id, key: cat.key, label: cat.label, group: cat.group ?? null, isActive: false }, + update: {}, + }) + ) + ); + } catch { /* non-blocking */ } + + // L-8: Auto-seed notification templates (idempotente) + try { + await this.notifications.seedDefaultTemplates(tenant.id); + } catch { /* non-blocking */ } + const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id); return { user: this.sanitizeUser(user), @@ -100,6 +122,7 @@ export class AuthService { } // MFA + const PRIVILEGED_ROLES = ["ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO"]; if (user.mfaEnabled) { if (!dto.totpCode) return { requiresMfa: true, userId: user.id }; const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! }); @@ -107,6 +130,9 @@ export class AuthService { await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id); throw new UnauthorizedException("Código MFA inválido."); } + } else if (PRIVILEGED_ROLES.includes(user.role)) { + // M-8: MFA obligatorio para roles privilegiados — forzar setup antes del primer acceso + return { requiresMfaSetup: true, userId: user.id }; } await this.prisma.client.user.update({ diff --git a/apps/api/src/b2b/b2b.service.ts b/apps/api/src/b2b/b2b.service.ts index 81c2233..f33182b 100644 --- a/apps/api/src/b2b/b2b.service.ts +++ b/apps/api/src/b2b/b2b.service.ts @@ -1,16 +1,19 @@ -import { IsString, IsOptional, IsNumber, Min } from "class-validator"; +import { IsString, IsOptional, IsNumber, IsBoolean, Min } from "class-validator"; import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; -import { generateTrackingId } from "../common/utils/tracking-id.util"; export class CreateB2BDto { @IsString() contactName!: string; @IsString() contactEmail!: string; - @IsOptional() @IsString() contactPhone?: string; - @IsOptional() @IsString() companyName?: string; + @IsOptional() @IsString() contactPhone?: string; + @IsOptional() @IsString() companyName?: string; @IsString() merchandiseType!: string; @IsString() description!: string; @IsOptional() @IsNumber() @Min(0) commercialValue?: number; + @IsOptional() @IsNumber() @Min(0) estimatedWeightKg?: number; + @IsOptional() @IsNumber() @Min(0) pallets?: number; + @IsOptional() @IsString() originCity?: string; + @IsOptional() @IsBoolean() requiresInen?: boolean; } export class UpdateB2BStatusDto { @@ -37,13 +40,17 @@ export class B2BService { data: { tenantId, trackingId, - contactName: dto.contactName, - contactEmail: dto.contactEmail, - contactPhone: dto.contactPhone, - companyName: dto.companyName, - merchandiseType: dto.merchandiseType, - description: dto.description, - commercialValue: dto.commercialValue ?? null, + contactName: dto.contactName, + contactEmail: dto.contactEmail, + contactPhone: dto.contactPhone, + companyName: dto.companyName, + merchandiseType: dto.merchandiseType, + description: dto.description, + commercialValue: dto.commercialValue ?? null, + estimatedWeightKg: dto.estimatedWeightKg ?? null, + pallets: dto.pallets ?? null, + originCity: dto.originCity ?? null, + requiresInen: dto.requiresInen ?? false, status: "PENDIENTE", }, }); diff --git a/apps/api/src/consolidations/consolidations.module.ts b/apps/api/src/consolidations/consolidations.module.ts index 47239f8..c48f8b2 100644 --- a/apps/api/src/consolidations/consolidations.module.ts +++ b/apps/api/src/consolidations/consolidations.module.ts @@ -2,9 +2,10 @@ import { Module } from "@nestjs/common"; import { ConsolidationsService } from "./consolidations.service"; import { ConsolidationsController } from "./consolidations.controller"; import { PrismaModule } from "../prisma/prisma.module"; +import { NotificationsModule } from "../notifications/notifications.module"; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, NotificationsModule], controllers: [ConsolidationsController], providers: [ConsolidationsService], exports: [ConsolidationsService], diff --git a/apps/api/src/consolidations/consolidations.service.ts b/apps/api/src/consolidations/consolidations.service.ts index d9915c3..c13dad2 100644 --- a/apps/api/src/consolidations/consolidations.service.ts +++ b/apps/api/src/consolidations/consolidations.service.ts @@ -5,6 +5,7 @@ import { Logger, } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; +import { NotificationsService } from "../notifications/notifications.service"; function genCode(): string { const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); @@ -16,7 +17,10 @@ function genCode(): string { export class ConsolidationsService { private readonly logger = new Logger(ConsolidationsService.name); - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private notifications: NotificationsService, + ) {} /** Lista consolidaciones del tenant. Cliente solo ve las suyas. */ async list(tenantId: string, userId?: string): Promise { @@ -115,24 +119,38 @@ export class ConsolidationsService { if (c.status !== "CERRADA") throw new BadRequestException("La consolidación debe estar CERRADA para despachar"); // Actualizar todos los paquetes a EN_TRANSITO_ECUADOR - const pkgIds = await this.prisma.client.consolidationPackage.findMany({ + const pkgLinks = await this.prisma.client.consolidationPackage.findMany({ where: { consolidationId: id }, select: { packageId: true }, }); await this.prisma.client.package.updateMany({ - where: { id: { in: pkgIds.map(p => p.packageId) } }, + where: { id: { in: pkgLinks.map(p => p.packageId) } }, data: { status: "EN_TRANSITO_ECUADOR" }, }); - this.logger.log(`[CONSOLIDATION] Dispatched ${id} — ${pkgIds.length} packages → EN_TRANSITO_ECUADOR`); + // Notificar a cada cliente cuyo paquete fue despachado + for (const { packageId } of pkgLinks) { + try { + const pkg = await this.prisma.client.package.findUnique({ + where: { id: packageId }, + include: { user: true }, + }); + if (pkg) { + await this.notifications.notifyStatusChange(pkg, pkg.user); + } + } catch (err: any) { + this.logger.warn(`[CONSOLIDATION] Notif failed for pkg ${packageId}: ${err.message}`); + } + } + + this.logger.log(`[CONSOLIDATION] Dispatched ${id} — ${pkgLinks.length} packages → EN_TRANSITO_ECUADOR`); return this.prisma.client.consolidation.update({ where: { id }, data: { status: "DESPACHADA", courierTracking }, }); } - /** Recalcula totales de peso y valor */ private async recalcTotals(id: string, _tenantId: string): Promise { const cp = await this.prisma.client.consolidationPackage.findMany({ diff --git a/apps/api/src/payments/payments.controller.ts b/apps/api/src/payments/payments.controller.ts index 9892ad2..74f3616 100644 --- a/apps/api/src/payments/payments.controller.ts +++ b/apps/api/src/payments/payments.controller.ts @@ -5,6 +5,7 @@ import { } from "@nestjs/common"; import { JwtAuthGuard } from "../auth/guards/auth.guard"; import { PaymentsService } from "./payments.service"; +import { PrismaService } from "../prisma/prisma.service"; class CreateIntentDto { packageId!: string; @@ -17,7 +18,10 @@ class ConfirmSessionDto { @Controller("payments") export class PaymentsController { - constructor(private readonly svc: PaymentsService) {} + constructor( + private readonly svc: PaymentsService, + private readonly prisma: PrismaService, + ) {} /** GET /payments — lista todos los pagos del tenant (admin) */ @Get() @@ -74,13 +78,16 @@ export class PaymentsController { async stripeWebhook( @Req() req: RawBodyRequest, @Headers("stripe-signature") signature: string, - @Query("tenant") tenant = "moraworld", + @Query("tenant") tenantSlug = "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); + // Resolve slug → real tenantId from DB + const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: tenantSlug } }); + if (!tenant) throw new BadRequestException(`Tenant '${tenantSlug}' no encontrado`); + + await this.svc.handleStripeWebhook(rawBody, signature, tenant.id); return { received: true }; } } diff --git a/apps/api/src/payments/payments.service.spec.ts b/apps/api/src/payments/payments.service.spec.ts index fa54106..4ef72e8 100644 --- a/apps/api/src/payments/payments.service.spec.ts +++ b/apps/api/src/payments/payments.service.spec.ts @@ -132,13 +132,16 @@ describe("PaymentsService", () => { expect(result).toEqual(mockPayment); }); - it("calcula el monto correctamente (flete + seguro)", async () => { - // peso 3.5lb × $3.50 = $12.25 flete + $100 × 2% = $2 seguro = $14.25 + it("calcula el monto completo §15 (flete + seguro + FODINFA + IVA)", async () => { + // REGIMEN_4X4 (default): peso 3.5lb × $3.50 = $12.25 flete + // + $100 × 2% = $2 seguro + $100 × 0.5% = $0.50 FODINFA + // + arancel 0% (4×4) + ($100 + $0.50) × 15% IVA = $15.075 + // total = 12.25 + 2 + 0.5 + 0 + 15.075 = $29.83 mockPrisma.client.payment.create.mockImplementation(({ data }: any) => Promise.resolve({ ...mockPayment, amount: data.amount }) ); const result = await service.createIntent("pkg-1", "user-1", "tenant-1"); - expect(Number(result.amount)).toBeCloseTo(14.25, 1); + expect(Number(result.amount)).toBeCloseTo(29.83, 1); }); }); diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts index 2a08b94..6f66125 100644 --- a/apps/api/src/payments/payments.service.ts +++ b/apps/api/src/payments/payments.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, Logger } from "@nes import { ConfigService } from "@nestjs/config"; import { PrismaService } from "../prisma/prisma.service"; import { IntegrationsService } from "../integrations/integrations.service"; +import { calculateShipping, SenaeCategory } from "../common/utils/calculator.util"; import Stripe from "stripe"; type StripeClient = InstanceType; @@ -23,14 +24,28 @@ export class PaymentsService { return new Stripe(secretKey, { apiVersion: "2026-05-27.dahlia" }); } - /** Calcula el monto a cobrar desde el Package (peso real × tarifa) */ + /** Calcula el monto completo: flete + seguro + FODINFA + arancel + IVA */ private async calcAmount(pkg: any, tenantId: string): Promise { const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } }); - const pricePerLb = Number(tariff?.pricePerLb ?? 3.5); - const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0); - const freight = weight * pricePerLb; - const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02); - return Math.round((freight + insurance) * 100) / 100; + const pricePerLb = Number(tariff?.pricePerLb ?? 3.5); + const insurancePct = Number(tariff?.insurancePct ?? 0.02); + const fodinfaPct = Number(tariff?.fodinfaPct ?? 0.005); + const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 1); + const category = (pkg.senaeCategory as SenaeCategory) ?? SenaeCategory.REGIMEN_4X4; + + const result = calculateShipping({ + declaredValueUsd: Number(pkg.declaredValue ?? 0), + weightLbs: weight, + lengthCm: pkg.lengthCm ? Number(pkg.lengthCm) : undefined, + widthCm: pkg.widthCm ? Number(pkg.widthCm) : undefined, + heightCm: pkg.heightCm ? Number(pkg.heightCm) : undefined, + category, + pricePerLb, + insurancePct, + fodinfaPct, + }); + + return result.total; } /** Obtiene el pago vinculado a un paquete (por trackingId) */ @@ -225,22 +240,37 @@ export class PaymentsService { }); if (!pkg) throw new NotFoundException("Paquete no encontrado"); const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } }); - const pricePerLb = Number(tariff?.pricePerLb ?? 3.5); - const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0); - const freight = weight * pricePerLb; - const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02); - const fodinfa = Number(pkg.declaredValue) * Number(tariff?.fodinfaPct ?? 0.005); - const total = freight + insurance + fodinfa; + const pricePerLb = Number(tariff?.pricePerLb ?? 3.5); + const insurancePct = Number(tariff?.insurancePct ?? 0.02); + const fodinfaPct = Number(tariff?.fodinfaPct ?? 0.005); + const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0); + const category = ((pkg as any).senaeCategory as SenaeCategory) ?? SenaeCategory.REGIMEN_4X4; + + const calc = calculateShipping({ + declaredValueUsd: Number(pkg.declaredValue ?? 0), + weightLbs: weight || 1, + lengthCm: (pkg as any).lengthCm ? Number((pkg as any).lengthCm) : undefined, + widthCm: (pkg as any).widthCm ? Number((pkg as any).widthCm) : undefined, + heightCm: (pkg as any).heightCm ? Number((pkg as any).heightCm) : undefined, + category, + pricePerLb, + insurancePct, + fodinfaPct, + }); + return { package: pkg, payment: pkg.payment, breakdown: { - weightLb: weight, + weightLb: calc.finalWeightLbs, pricePerLb, - freight: Math.round(freight * 100) / 100, - insurance: Math.round(insurance * 100) / 100, - fodinfa: Math.round(fodinfa * 100) / 100, - total: Math.round(total * 100) / 100, + freight: calc.flete, + insurance: calc.seguro, + fodinfa: calc.fodinfa, + arancel: calc.arancel, + iva: calc.iva, + total: calc.total, + senaeCategory: category, }, }; } diff --git a/apps/web/src/app/calculadora/page.tsx b/apps/web/src/app/calculadora/page.tsx index f8dba8b..b74e5ac 100644 --- a/apps/web/src/app/calculadora/page.tsx +++ b/apps/web/src/app/calculadora/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { api } from "@/lib/api"; export default function CalculadoraPage() { - const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "COURIER" }); + const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "REGIMEN_4X4" }); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -61,9 +61,10 @@ export default function CalculadoraPage() {
@@ -125,10 +126,11 @@ export default function CalculadoraPage() { ) : (
{[ - { title: "Mensajería Acelerada", desc: "Hasta $200 USD · No paga impuestos · Ideal para compras pequeñas en Amazon.", badge: "Más popular" }, - { title: "Courier", desc: "Hasta $400 USD · Sin impuestos hasta $200 · Proceso rápido.", badge: "" }, - { title: "Régimen 4×4", desc: "Hasta $2,000 USD · Para compras de mayor valor · Aplican impuestos completos.", badge: "" }, - ].map(r => ( + { title: "Régimen 4×4", desc: "Hasta $400 · hasta 4 kg · sin arancel · máx. 4 envíos/año. Proceso simplificado.", badge: "Más popular" }, + { title: "Categoría B", desc: "Bienes de consumo general. Arancel 10% + FODINFA 0.5% + IVA 15%.", badge: "" }, + { title: "Categoría C", desc: "Textiles, calzado, artículos del hogar. Arancel 20% + FODINFA + IVA.", badge: "" }, + { title: "Categoría D", desc: "Electrónicos y equipos. Arancel 10% base (puede variar por subpartida) + FODINFA + IVA.", badge: "" }, + ].map(r => (
{r.title} diff --git a/apps/web/src/app/tarifas/page.tsx b/apps/web/src/app/tarifas/page.tsx index 24ba3c9..8c6016d 100644 --- a/apps/web/src/app/tarifas/page.tsx +++ b/apps/web/src/app/tarifas/page.tsx @@ -1,10 +1,11 @@ import Link from "next/link"; const TARIFAS = [ - { regime: "Mensajería Acelerada", limit: "Hasta $200 USD", taxes: "Sin impuestos", flete: "$8–$15/lb", ideal: "Compras pequeñas · fast fashion · accesorios", badge: "Más popular" }, - { regime: "Courier", limit: "Hasta $400 USD", taxes: "Sin impuestos hasta $200", flete: "$8–$15/lb", ideal: "Electrónica · ropa · zapatos", badge: "" }, - { regime: "Régimen 4×4", limit: "Hasta $2,000 USD", taxes: "FODINFA + Arancel + IVA", flete: "Según peso/volumen", ideal: "Equipos, repuestos, herramientas", badge: "" }, - { regime: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", badge: "" }, + { regime: "Régimen 4×4", limit: "Hasta $400 · hasta 4 kg · máx. 4/año", taxes: "Sin arancel (FODINFA 0.5% + IVA 15%)", flete: "$3.50/lb (mín. $8)", ideal: "Compras cotidianas · electrónicos pequeños · moda", badge: "Más popular" }, + { regime: "Categoría B", limit: "Sin límite", taxes: "Arancel 10% + FODINFA 0.5% + IVA 15%", flete: "$3.50/lb", ideal: "Bienes de consumo general · cosméticos · alimentos", badge: "" }, + { regime: "Categoría C", limit: "Sin límite", taxes: "Arancel 20% + FODINFA 0.5% + IVA 15%", flete: "$3.50/lb", ideal: "Textiles · calzado · artículos del hogar", badge: "" }, + { regime: "Categoría D", limit: "Sin límite", taxes: "Arancel 10% base + FODINFA 0.5% + IVA 15%", flete: "$3.50/lb", ideal: "Electrónicos · equipos · repuestos", badge: "" }, + { regime: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal DAI", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", badge: "" }, ]; export default function TarifasPublicaPage() {