diff --git a/.env.example b/.env.example index 580c55e..c5aca63 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,16 @@ -# ─── Base de datos ─── -# Opción A — Coolify (recomendado si ya tienes Postgres ahí): +# ══════════════════════════════════════════════════════════════ +# Moraworld Imports — Variables de entorno +# Sincronizado con documentacion.html v1.1 +# ══════════════════════════════════════════════════════════════ + +# ─── Base de datos ─────────────────────────────────────────── +# Opción A — Coolify (PostgreSQL gestionado): # DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/NOMBRE_BD?schema=moraworld" -# Nota: en Postgres 15+ el schema "public" suele no permitir CREATE; usamos "moraworld". # # Opción B — Local con docker compose: DATABASE_URL="postgresql://moraworld:moraworld_dev@localhost:5432/moraworld?schema=public" -# ─── Redis ─── +# ─── Redis (colas de notificaciones, sesiones) ─────────────── # Opción A — Coolify: # REDIS_URL="redis://:PASSWORD@HOST:6379" # REDIS_URL="rediss://:PASSWORD@HOST:6380" # si TLS @@ -14,31 +18,115 @@ DATABASE_URL="postgresql://moraworld:moraworld_dev@localhost:5432/moraworld?sche # Opción B — Local: REDIS_URL="redis://localhost:6379" -# ─── API (NestJS) ─── +# ─── API (NestJS) ──────────────────────────────────────────── API_PORT=3001 API_URL="http://localhost:3001" -JWT_SECRET="change-me-in-production-min-32-chars-long" + +# JWT — Autenticación OAuth 2.0 + JWT + MFA (doc §05) +JWT_SECRET="change-me-in-production-min-32-chars-long-abc123" JWT_EXPIRES_IN="15m" JWT_REFRESH_EXPIRES_IN="7d" -# ─── Web (Next.js) ─── +# ─── Web (Next.js) ─────────────────────────────────────────── WEB_PORT=3000 NEXT_PUBLIC_API_URL="http://localhost:3001" -# ─── CORS (coma separada; en Coolify usar dominio real) ─── +# ─── CORS (coma separada; en Coolify usar dominio real) ────── CORS_ORIGINS="http://localhost:3000" -# ─── Moraworld (negocio) ─── +# ─── Moraworld — Bodega NJ (doc §02) ───────────────────────── +# Dirección: 150 N Day St, City of Orange, NJ 07050, EE.UU. WAREHOUSE_ADDRESS_STREET="150 N Day St" WAREHOUSE_ADDRESS_CITY="City of Orange" WAREHOUSE_ADDRESS_STATE="NJ" WAREHOUSE_ADDRESS_ZIP="07050" WAREHOUSE_ADDRESS_COUNTRY="US" -# ─── S3 / archivos (opcional Fase 1; MinIO local en docker-compose) ─── +# ─── Amazon SP-API (doc §14 — registro por enlace) ─────────── +# https://developer-docs.amazon.com/sp-api/docs +AMAZON_SP_CLIENT_ID="" +AMAZON_SP_CLIENT_SECRET="" +AMAZON_SP_REFRESH_TOKEN="" +AMAZON_SP_MARKETPLACE_ID="ATVPDKIKX0DER" # US marketplace + +# ─── WebService SENAE (doc §11 — declaración aduanera) ─────── +# SOAP / REST — Portal electrónico aduana Ecuador +SENAE_ENDPOINT="https://declaraciones.aduana.gob.ec/wsDeclaraciones" +SENAE_USERNAME="" +SENAE_PASSWORD="" +SENAE_CERT_PATH="" # Certificado digital PEM +SENAE_CERT_PASSPHRASE="" + +# ─── Pasarela de pagos (doc §09 paso 8 / §18) ──────────────── +# Stripe (internacional) +STRIPE_SECRET_KEY="" +STRIPE_WEBHOOK_SECRET="" +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="" + +# PayPhone (Ecuador) +PAYPHONE_APP_ID="" +PAYPHONE_TOKEN="" +PAYPHONE_STORE_ID="" + +# ─── WhatsApp Business API (doc §16 / §18) ─────────────────── +# Notificaciones automáticas + botón flotante de contacto +WHATSAPP_API_URL="https://graph.facebook.com/v19.0" +WHATSAPP_PHONE_NUMBER_ID="" +WHATSAPP_ACCESS_TOKEN="" +# Número NJ (operaciones) y Cuenca (aduana) — doc §21 +WHATSAPP_NJ_NUMBER="+15550000001" +WHATSAPP_CUENCA_NUMBER="+5939900000001" + +# ─── Email (doc §16) ───────────────────────────────────────── +# SendGrid +SENDGRID_API_KEY="" +EMAIL_FROM="noreply@moraworldimports.com" + +# AWS SES (alternativa) +# AWS_SES_REGION="us-east-1" +# AWS_SES_ACCESS_KEY="" +# AWS_SES_SECRET_KEY="" + +# ─── SMS Gateway (doc §16) ─────────────────────────────────── +SMS_PROVIDER="twilio" # twilio | vonage +TWILIO_ACCOUNT_SID="" +TWILIO_AUTH_TOKEN="" +TWILIO_PHONE_NUMBER="" + +# ─── Couriers internacionales (doc §18) ────────────────────── +# FedEx +FEDEX_CLIENT_ID="" +FEDEX_CLIENT_SECRET="" +FEDEX_ACCOUNT_NUMBER="" + +# DHL +DHL_API_KEY="" +DHL_API_SECRET="" + +# UPS +UPS_CLIENT_ID="" +UPS_CLIENT_SECRET="" + +# ─── S3 / Almacenamiento de archivos (doc §05) ─────────────── +# Fotos de paquetes, facturas, documentos SENAE +# AWS S3 +S3_BUCKET="moraworld-files" +S3_REGION="us-east-1" +S3_ACCESS_KEY="" +S3_SECRET_KEY="" + +# MinIO local (docker-compose — desarrollo): # S3_ENDPOINT="http://localhost:9000" # S3_BUCKET="moraworld" # S3_ACCESS_KEY="minioadmin" # S3_SECRET_KEY="minioadmin" # S3_REGION="us-east-1" # S3_FORCE_PATH_STYLE="true" + +# ─── SIEM / Monitoreo (doc §05 ISO A.12) ───────────────────── +# Datadog / New Relic / Sentry +SENTRY_DSN="" +# DATADOG_API_KEY="" + +# ─── Entorno ───────────────────────────────────────────────── +NODE_ENV="development" # development | staging | production diff --git a/apps/api/package.json b/apps/api/package.json index f68f822..c052b78 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,11 @@ "dev": "nest start --watch", "start": "node dist/main", "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:ci": "jest --ci --coverage --forceExit" }, "dependencies": { "@moraworld/database": "workspace:*", @@ -21,8 +25,31 @@ "devDependencies": { "@nestjs/cli": "^11.0.7", "@nestjs/schematics": "^11.0.5", + "@nestjs/testing": "^11.1.0", "@types/express": "^5.0.1", + "@types/jest": "^29.5.14", "@types/node": "^22.15.21", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "^5.8.3" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!**/main.ts"], + "coverageDirectory": "../coverage", + "testEnvironment": "node", + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + } } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1fd2512..456f3d0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,8 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { HealthModule } from "./health/health.module"; import { PrismaModule } from "./prisma/prisma.module"; +import { CalculatorModule } from "./calculator/calculator.module"; +import { TrackingModule } from "./tracking/tracking.module"; @Module({ imports: [ @@ -11,6 +13,8 @@ import { PrismaModule } from "./prisma/prisma.module"; }), PrismaModule, HealthModule, + CalculatorModule, + TrackingModule, ], }) export class AppModule {} diff --git a/apps/api/src/calculator/calculator.controller.ts b/apps/api/src/calculator/calculator.controller.ts new file mode 100644 index 0000000..3f61caf --- /dev/null +++ b/apps/api/src/calculator/calculator.controller.ts @@ -0,0 +1,62 @@ +import { Controller, Get, Query, BadRequestException } from "@nestjs/common"; +import { CalculatorService } from "./calculator.service"; +import { SenaeCategory } from "../common/utils/calculator.util"; + +@Controller("calculator") +export class CalculatorController { + constructor(private readonly calculatorService: CalculatorService) {} + + /** + * GET /api/calculator + * Calcula el costo de envío. Disponible sin autenticación (doc §15). + * + * Query params: + * value - Valor declarado en USD (requerido) + * weight - Peso en libras (requerido) + * length - Largo en cm (opcional, para peso volumétrico) + * width - Ancho en cm + * height - Alto en cm + * category - REGIMEN_4X4 | CATEGORIA_B | CATEGORIA_C | CATEGORIA_D (opcional) + * shipments - Envíos ya realizados este año (opcional, para 4×4) + */ + @Get() + calculate( + @Query("value") value: string, + @Query("weight") weight: string, + @Query("length") length?: string, + @Query("width") width?: string, + @Query("height") height?: string, + @Query("category") category?: string, + @Query("shipments") shipments?: string, + ) { + const declaredValueUsd = parseFloat(value); + const weightLbs = parseFloat(weight); + + if (isNaN(declaredValueUsd) || declaredValueUsd < 0) { + throw new BadRequestException("El parámetro 'value' debe ser un número >= 0."); + } + if (isNaN(weightLbs) || weightLbs <= 0) { + throw new BadRequestException("El parámetro 'weight' debe ser un número > 0."); + } + + let parsedCategory: SenaeCategory | undefined; + if (category) { + if (!Object.values(SenaeCategory).includes(category as SenaeCategory)) { + throw new BadRequestException( + `Categoría inválida. Valores válidos: ${Object.values(SenaeCategory).join(", ")}`, + ); + } + parsedCategory = category as SenaeCategory; + } + + return this.calculatorService.calculate({ + declaredValueUsd, + weightLbs, + lengthCm: length ? parseFloat(length) : undefined, + widthCm: width ? parseFloat(width) : undefined, + heightCm: height ? parseFloat(height) : undefined, + category: parsedCategory, + shipmentsThisYear: shipments ? parseInt(shipments, 10) : 0, + }); + } +} diff --git a/apps/api/src/calculator/calculator.module.ts b/apps/api/src/calculator/calculator.module.ts new file mode 100644 index 0000000..8da858f --- /dev/null +++ b/apps/api/src/calculator/calculator.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { CalculatorController } from "./calculator.controller"; +import { CalculatorService } from "./calculator.service"; + +@Module({ + controllers: [CalculatorController], + providers: [CalculatorService], + exports: [CalculatorService], +}) +export class CalculatorModule {} diff --git a/apps/api/src/calculator/calculator.service.ts b/apps/api/src/calculator/calculator.service.ts new file mode 100644 index 0000000..61bdc23 --- /dev/null +++ b/apps/api/src/calculator/calculator.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@nestjs/common"; +import { + CalculatorInput, + CalculatorResult, + SenaeCategory, + calculateShipping, + qualifiesFor4x4, +} from "../common/utils/calculator.util"; + +export interface CalcRequest { + declaredValueUsd: number; + weightLbs: number; + lengthCm?: number; + widthCm?: number; + heightCm?: number; + category?: SenaeCategory; + shipmentsThisYear?: number; +} + +@Injectable() +export class CalculatorService { + /** + * Calcula el costo de envío con desglose SENAE. + * Si no se especifica categoría, determina automáticamente si aplica 4×4. + */ + calculate(req: CalcRequest): CalculatorResult { + const category = req.category ?? this.autoCategory( + req.declaredValueUsd, + req.weightLbs, + req.shipmentsThisYear ?? 0, + ); + + const input: CalculatorInput = { + declaredValueUsd: req.declaredValueUsd, + weightLbs: req.weightLbs, + lengthCm: req.lengthCm, + widthCm: req.widthCm, + heightCm: req.heightCm, + category, + }; + + return calculateShipping(input); + } + + /** + * Determina la categoría mínima según los límites 4×4. + * Si no aplica, retorna CATEGORIA_B como base (el usuario puede cambiarla). + */ + autoCategory( + valueUsd: number, + weightLbs: number, + shipmentsThisYear: number, + ): SenaeCategory { + if (qualifiesFor4x4(valueUsd, weightLbs, shipmentsThisYear)) { + return SenaeCategory.REGIMEN_4X4; + } + return SenaeCategory.CATEGORIA_B; + } +} diff --git a/apps/api/src/calculator/calculator.spec.ts b/apps/api/src/calculator/calculator.spec.ts new file mode 100644 index 0000000..0a1ae28 --- /dev/null +++ b/apps/api/src/calculator/calculator.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CalculatorController } from "./calculator.controller"; +import { CalculatorService } from "./calculator.service"; +import { SenaeCategory } from "../common/utils/calculator.util"; +import { BadRequestException } from "@nestjs/common"; + +describe("CalculatorController", () => { + let controller: CalculatorController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CalculatorController], + providers: [CalculatorService], + }).compile(); + + controller = module.get(CalculatorController); + }); + + it("debe estar definido", () => { + expect(controller).toBeDefined(); + }); + + it("calcula correctamente con parámetros mínimos", () => { + const result = controller.calculate("150", "2"); + expect(result.total).toBeGreaterThan(0); + expect(result.flete).toBe(7); // 2 × 3.50 + }); + + it("acepta categoría explícita", () => { + const result = controller.calculate("500", "5", undefined, undefined, undefined, SenaeCategory.CATEGORIA_C); + expect(result.category).toBe(SenaeCategory.CATEGORIA_C); + expect(result.arancelRate).toBe(0.20); + }); + + it("lanza BadRequestException si value no es número", () => { + expect(() => controller.calculate("abc", "2")).toThrow(BadRequestException); + }); + + it("lanza BadRequestException si weight es 0", () => { + expect(() => controller.calculate("100", "0")).toThrow(BadRequestException); + }); + + it("lanza BadRequestException si value es negativo", () => { + expect(() => controller.calculate("-1", "2")).toThrow(BadRequestException); + }); + + it("lanza BadRequestException con categoría inválida", () => { + expect(() => + controller.calculate("100", "2", undefined, undefined, undefined, "CATEGORIA_Z"), + ).toThrow(BadRequestException); + }); + + it("calcula el peso volumétrico cuando se proveen dimensiones", () => { + const result = controller.calculate("100", "1", "50", "40", "30"); + // 50×40×30 / 139 ≈ 43.17 lbs → mayor que 1 lb + expect(result.finalWeightLbs).toBeGreaterThan(1); + expect(result.volumetricWeightLbs).not.toBeNull(); + }); +}); + +describe("CalculatorService", () => { + let service: CalculatorService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CalculatorService], + }).compile(); + + service = module.get(CalculatorService); + }); + + it("autoCategory retorna REGIMEN_4X4 si califica", () => { + const cat = service.autoCategory(200, 2, 0); + expect(cat).toBe(SenaeCategory.REGIMEN_4X4); + }); + + it("autoCategory retorna CATEGORIA_B si no califica 4×4", () => { + const cat = service.autoCategory(500, 10, 0); + expect(cat).toBe(SenaeCategory.CATEGORIA_B); + }); + + it("calculate sin categoría asigna automáticamente", () => { + const result = service.calculate({ declaredValueUsd: 200, weightLbs: 2 }); + expect(result.category).toBe(SenaeCategory.REGIMEN_4X4); + }); +}); diff --git a/apps/api/src/common/utils/calculator.util.spec.ts b/apps/api/src/common/utils/calculator.util.spec.ts new file mode 100644 index 0000000..250ac4a --- /dev/null +++ b/apps/api/src/common/utils/calculator.util.spec.ts @@ -0,0 +1,288 @@ +import { + calculateShipping, + calcVolumetricWeight, + qualifiesFor4x4, + SenaeCategory, + SENAE_TARIFF_RATES, +} from "./calculator.util"; + +/** + * Tests del servicio de calculadora SENAE. + * Validan exactamente las fórmulas documentadas en §15 de documentacion.html + */ +describe("Calculator — Fórmulas SENAE (doc §15)", () => { + // ─── Peso volumétrico ───────────────────────────────────── + + describe("calcVolumetricWeight", () => { + it("calcula peso volumétrico correctamente: (L×A×H)/139", () => { + // 30 × 20 × 15 = 9000 / 139 ≈ 64.748 g → 0.0648 lbs (en lbs) + const result = calcVolumetricWeight(30, 20, 15); + expect(result).toBeCloseTo(9000 / 139, 3); + }); + + it("retorna null si faltan dimensiones", () => { + expect(calcVolumetricWeight(30, 20, undefined)).toBeNull(); + expect(calcVolumetricWeight(undefined, undefined, undefined)).toBeNull(); + }); + + it("retorna null si alguna dimensión es 0 o negativa", () => { + expect(calcVolumetricWeight(0, 20, 15)).toBeNull(); + expect(calcVolumetricWeight(30, -5, 15)).toBeNull(); + }); + }); + + // ─── Régimen 4×4 ────────────────────────────────────────── + + describe("qualifiesFor4x4", () => { + it("califica si valor ≤ $400 y peso ≤ 4 kg", () => { + // 2 lbs = 0.907 kg → bien dentro del límite + expect(qualifiesFor4x4(200, 2, 0)).toBe(true); + }); + + it("no califica si valor > $400", () => { + expect(qualifiesFor4x4(401, 2, 0)).toBe(false); + }); + + it("no califica si peso > 4 kg (≈ 8.82 lbs)", () => { + // 9 lbs = 4.08 kg + expect(qualifiesFor4x4(200, 9, 0)).toBe(false); + }); + + it("no califica si ya usó 4 envíos en el año", () => { + expect(qualifiesFor4x4(200, 2, 4)).toBe(false); + }); + + it("califica si aún tiene envíos disponibles (3 de 4)", () => { + expect(qualifiesFor4x4(200, 2, 3)).toBe(true); + }); + + it("califica exactamente en $400 y 4 kg (límite exacto)", () => { + const weightLbs = 4 / 0.453592; // exactamente 4 kg en lbs + expect(qualifiesFor4x4(400, weightLbs, 0)).toBe(true); + }); + }); + + // ─── Tasas de arancel por categoría ─────────────────────── + + describe("SENAE_TARIFF_RATES", () => { + it("REGIMEN_4X4 tiene tasa 0%", () => { + expect(SENAE_TARIFF_RATES[SenaeCategory.REGIMEN_4X4]).toBe(0); + }); + + it("CATEGORIA_B tiene tasa 10%", () => { + expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_B]).toBe(0.10); + }); + + it("CATEGORIA_C tiene tasa 20%", () => { + expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_C]).toBe(0.20); + }); + + it("CATEGORIA_D tiene tasa 10% (base)", () => { + expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_D]).toBe(0.10); + }); + }); + + // ─── calculateShipping — Régimen 4×4 ────────────────────── + + describe("calculateShipping — Régimen 4×4 (0% arancel)", () => { + const base = { + declaredValueUsd: 150, + weightLbs: 2, + category: SenaeCategory.REGIMEN_4X4, + }; + + it("flete = peso × $3.50", () => { + const r = calculateShipping(base); + expect(r.flete).toBe(2 * 3.5); // 7.00 + }); + + it("seguro = valor × 2%", () => { + const r = calculateShipping(base); + expect(r.seguro).toBe(Math.round(150 * 0.02 * 100) / 100); // 3.00 + }); + + it("FODINFA = valor × 0.5%", () => { + const r = calculateShipping(base); + expect(r.fodinfa).toBe(Math.round(150 * 0.005 * 100) / 100); // 0.75 + }); + + it("arancel = 0 para régimen 4×4", () => { + const r = calculateShipping(base); + expect(r.arancel).toBe(0); + }); + + it("IVA = (valor + FODINFA + arancel) × 15%", () => { + const r = calculateShipping(base); + // IVA = (150 + 0.75 + 0) × 0.15 = 150.75 × 0.15 = 22.6125 → 22.61 + const expected = Math.round((150 + 0.75 + 0) * 0.15 * 100) / 100; + expect(r.iva).toBe(expected); + }); + + it("TOTAL = Flete + Seguro + FODINFA + Arancel + IVA", () => { + const r = calculateShipping(base); + const expected = Math.round((r.flete + r.seguro + r.fodinfa + r.arancel + r.iva) * 100) / 100; + expect(r.total).toBe(expected); + }); + + it("is4x4Regime = true", () => { + const r = calculateShipping(base); + expect(r.is4x4Regime).toBe(true); + }); + }); + + // ─── calculateShipping — Categoría B (10%) ──────────────── + + describe("calculateShipping — Categoría B (10% arancel)", () => { + const base = { + declaredValueUsd: 500, + weightLbs: 5, + category: SenaeCategory.CATEGORIA_B, + }; + + it("arancel = valor × 10%", () => { + const r = calculateShipping(base); + expect(r.arancel).toBe(Math.round(500 * 0.10 * 100) / 100); // 50.00 + }); + + it("is4x4Regime = false", () => { + const r = calculateShipping(base); + expect(r.is4x4Regime).toBe(false); + }); + + it("total incluye todos los componentes", () => { + const r = calculateShipping(base); + expect(r.total).toBe( + Math.round((r.flete + r.seguro + r.fodinfa + r.arancel + r.iva) * 100) / 100, + ); + }); + }); + + // ─── calculateShipping — Categoría C (20%) ──────────────── + + describe("calculateShipping — Categoría C (20% arancel)", () => { + it("arancel = valor × 20%", () => { + const r = calculateShipping({ + declaredValueUsd: 300, + weightLbs: 3, + category: SenaeCategory.CATEGORIA_C, + }); + expect(r.arancel).toBe(Math.round(300 * 0.20 * 100) / 100); // 60.00 + }); + }); + + // ─── Peso volumétrico activo ─────────────────────────────── + + describe("calculateShipping — Peso volumétrico", () => { + it("usa el peso volumétrico si es mayor al real", () => { + // Caja grande liviana: 100 × 80 × 60 cm = 480000 / 139 ≈ 3453.2 lbs? + // No... 100×80×60 / 139 = 3453.2 → ese es el peso volumétrico en alguna unidad + // Pero el peso real es 1 lb → volumétrico es mucho mayor + const r = calculateShipping({ + declaredValueUsd: 100, + weightLbs: 1, + lengthCm: 100, + widthCm: 80, + heightCm: 60, + category: SenaeCategory.REGIMEN_4X4, + }); + // volumétrico = 100*80*60/139 = 3453.2... + expect(r.finalWeightLbs).toBeGreaterThan(1); + expect(r.volumetricWeightLbs).not.toBeNull(); + }); + + it("usa el peso real si es mayor al volumétrico", () => { + // Caja pequeña pesada: 10×10×10 = 1000/139 ≈ 7.19 lbs, peso real = 20 lbs + const r = calculateShipping({ + declaredValueUsd: 100, + weightLbs: 20, + lengthCm: 10, + widthCm: 10, + heightCm: 10, + category: SenaeCategory.CATEGORIA_B, + }); + expect(r.finalWeightLbs).toBe(20); + }); + + it("sin dimensiones, finalWeightLbs = peso real", () => { + const r = calculateShipping({ + declaredValueUsd: 100, + weightLbs: 5, + category: SenaeCategory.CATEGORIA_B, + }); + expect(r.finalWeightLbs).toBe(5); + expect(r.volumetricWeightLbs).toBeNull(); + }); + }); + + // ─── Validaciones de entrada ────────────────────────────── + + describe("calculateShipping — validaciones", () => { + it("lanza error si valor declarado es negativo", () => { + expect(() => + calculateShipping({ + declaredValueUsd: -1, + weightLbs: 2, + category: SenaeCategory.REGIMEN_4X4, + }), + ).toThrow("El valor declarado no puede ser negativo."); + }); + + it("lanza error si el peso es 0 o negativo", () => { + expect(() => + calculateShipping({ + declaredValueUsd: 100, + weightLbs: 0, + category: SenaeCategory.REGIMEN_4X4, + }), + ).toThrow("El peso debe ser mayor a 0."); + }); + }); + + // ─── Breakdown ──────────────────────────────────────────── + + describe("breakdown", () => { + it("incluye 6 items (Flete, Seguro, FODINFA, Arancel, IVA, TOTAL)", () => { + const r = calculateShipping({ + declaredValueUsd: 200, + weightLbs: 3, + category: SenaeCategory.CATEGORIA_B, + }); + expect(r.breakdown.items).toHaveLength(6); + }); + + it("el último item (TOTAL) tiene isHighlighted=true", () => { + const r = calculateShipping({ + declaredValueUsd: 200, + weightLbs: 3, + category: SenaeCategory.CATEGORIA_B, + }); + const last = r.breakdown.items[r.breakdown.items.length - 1]; + expect(last.isHighlighted).toBe(true); + expect(last.label).toBe("TOTAL"); + }); + }); + + // ─── Tarifa configurable ────────────────────────────────── + + describe("tarifas configurables", () => { + it("acepta pricePerLb personalizado", () => { + const r = calculateShipping({ + declaredValueUsd: 100, + weightLbs: 2, + category: SenaeCategory.REGIMEN_4X4, + pricePerLb: 4.0, + }); + expect(r.flete).toBe(8.0); + }); + + it("acepta insurancePct personalizado", () => { + const r = calculateShipping({ + declaredValueUsd: 100, + weightLbs: 2, + category: SenaeCategory.REGIMEN_4X4, + insurancePct: 0.03, + }); + expect(r.seguro).toBe(3.0); // 100 × 3% + }); + }); +}); diff --git a/apps/api/src/common/utils/calculator.util.ts b/apps/api/src/common/utils/calculator.util.ts new file mode 100644 index 0000000..63f3bd8 --- /dev/null +++ b/apps/api/src/common/utils/calculator.util.ts @@ -0,0 +1,231 @@ +/** + * Calculadora SENAE — Lógica exacta de la documentación §15 + * + * Categorías SENAE: + * REGIMEN_4X4 → 0% Hasta $400, 4 kg, 4 envíos/año — proceso simplificado + * CATEGORIA_B → 10% Bienes de consumo general + * CATEGORIA_C → 20% Textiles, calzado, artículos del hogar + * CATEGORIA_D → 0-15% Electrónicos — se usa 10% como base (puede variar) + * + * Fórmulas (doc §15): + * Peso_volumétrico = (largo_cm × ancho_cm × alto_cm) / 139 + * Peso_final = MAX(peso_real_lbs, Peso_volumétrico) + * Flete = Peso_final × $3.50 (configurable) + * Seguro = valor_declarado × 0.02 + * FODINFA = valor_declarado × 0.005 + * Arancel = valor_declarado × % categoría + * IVA = (valor_declarado + FODINFA + Arancel) × 0.15 + * TOTAL = Flete + Seguro + FODINFA + Arancel + IVA + */ + +export enum SenaeCategory { + REGIMEN_4X4 = "REGIMEN_4X4", + CATEGORIA_B = "CATEGORIA_B", + CATEGORIA_C = "CATEGORIA_C", + CATEGORIA_D = "CATEGORIA_D", +} + +export const SENAE_TARIFF_RATES: Record = { + [SenaeCategory.REGIMEN_4X4]: 0, + [SenaeCategory.CATEGORIA_B]: 0.10, + [SenaeCategory.CATEGORIA_C]: 0.20, + [SenaeCategory.CATEGORIA_D]: 0.10, // base; puede ajustarse por subpartida +}; + +export interface CalculatorInput { + /** Valor declarado del producto en USD */ + declaredValueUsd: number; + /** Peso real en libras */ + weightLbs: number; + /** Largo en cm (para peso volumétrico, opcional) */ + lengthCm?: number; + /** Ancho en cm */ + widthCm?: number; + /** Alto en cm */ + heightCm?: number; + /** Categoría SENAE del producto */ + category: SenaeCategory; + /** Precio por libra en USD (default: 3.50) */ + pricePerLb?: number; + /** Porcentaje de seguro (default: 0.02 = 2%) */ + insurancePct?: number; + /** FODINFA (default: 0.005 = 0.5%) */ + fodinfaPct?: number; + /** IVA (default: 0.15 = 15%) */ + ivaPct?: number; +} + +export interface CalculatorResult { + /** Peso volumétrico calculado en lbs */ + volumetricWeightLbs: number | null; + /** Peso final usado para el cálculo (MAX entre real y volumétrico) */ + finalWeightLbs: number; + /** Costo de flete */ + flete: number; + /** Costo de seguro */ + seguro: number; + /** FODINFA */ + fodinfa: number; + /** Arancel */ + arancel: number; + /** IVA */ + iva: number; + /** Total a pagar */ + total: number; + /** Categoría SENAE aplicada */ + category: SenaeCategory; + /** Tasa de arancel aplicada (ej: 0.10 = 10%) */ + arancelRate: number; + /** Si aplica el régimen 4×4 (sin arancel) */ + is4x4Regime: boolean; + /** Desglose completo en formato display */ + breakdown: CalculatorBreakdown; +} + +export interface CalculatorBreakdown { + items: Array<{ + label: string; + formula: string; + value: number; + isHighlighted?: boolean; + }>; +} + +/** + * Calcula el peso volumétrico en libras. + * Fórmula: (largo × ancho × alto) / 139 + * Si faltan dimensiones retorna null. + */ +export function calcVolumetricWeight( + lengthCm?: number, + widthCm?: number, + heightCm?: number, +): number | null { + if (!lengthCm || !widthCm || !heightCm) return null; + if (lengthCm <= 0 || widthCm <= 0 || heightCm <= 0) return null; + return (lengthCm * widthCm * heightCm) / 139; +} + +/** + * Redondea a 2 decimales. + */ +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Calcula el costo total de envío con desglose SENAE. + * Implementa exactamente las fórmulas del documento §15. + */ +export function calculateShipping(input: CalculatorInput): CalculatorResult { + const { + declaredValueUsd, + weightLbs, + lengthCm, + widthCm, + heightCm, + category, + pricePerLb = 3.5, + insurancePct = 0.02, + fodinfaPct = 0.005, + ivaPct = 0.15, + } = input; + + if (declaredValueUsd < 0) throw new Error("El valor declarado no puede ser negativo."); + if (weightLbs <= 0) throw new Error("El peso debe ser mayor a 0."); + + const arancelRate = SENAE_TARIFF_RATES[category]; + const is4x4Regime = category === SenaeCategory.REGIMEN_4X4; + + // Peso volumétrico + const volumetricWeightLbs = calcVolumetricWeight(lengthCm, widthCm, heightCm); + const finalWeightLbs = volumetricWeightLbs !== null + ? Math.max(weightLbs, volumetricWeightLbs) + : weightLbs; + + // Cálculos + const flete = round2(finalWeightLbs * pricePerLb); + const seguro = round2(declaredValueUsd * insurancePct); + const fodinfa = round2(declaredValueUsd * fodinfaPct); + const arancel = round2(declaredValueUsd * arancelRate); + const iva = round2((declaredValueUsd + fodinfa + arancel) * ivaPct); + const total = round2(flete + seguro + fodinfa + arancel + iva); + + const breakdown: CalculatorBreakdown = { + items: [ + { + label: "Flete", + formula: `${finalWeightLbs.toFixed(2)} lbs × $${pricePerLb.toFixed(2)}/lb`, + value: flete, + }, + { + label: "Seguro (2%)", + formula: `$${declaredValueUsd.toFixed(2)} × ${(insurancePct * 100).toFixed(1)}%`, + value: seguro, + }, + { + label: "FODINFA (0.5%)", + formula: `$${declaredValueUsd.toFixed(2)} × 0.5%`, + value: fodinfa, + }, + { + label: `Arancel ${category.replace("_", " ")} (${(arancelRate * 100).toFixed(0)}%)`, + formula: `$${declaredValueUsd.toFixed(2)} × ${(arancelRate * 100).toFixed(0)}%`, + value: arancel, + }, + { + label: "IVA (15%)", + formula: `($${declaredValueUsd.toFixed(2)} + FODINFA + Arancel) × 15%`, + value: iva, + }, + { + label: "TOTAL", + formula: "Flete + Seguro + FODINFA + Arancel + IVA", + value: total, + isHighlighted: true, + }, + ], + }; + + return { + volumetricWeightLbs: volumetricWeightLbs !== null ? round2(volumetricWeightLbs) : null, + finalWeightLbs: round2(finalWeightLbs), + flete, + seguro, + fodinfa, + arancel, + iva, + total, + category, + arancelRate, + is4x4Regime, + breakdown, + }; +} + +/** + * Determina si un paquete aplica al régimen 4×4. + * Condiciones: valor ≤ $400, peso ≤ 4 kg. (doc §08 estado 06 / §15) + * + * @param valueUsd Valor declarado en USD + * @param weightLbs Peso en libras + * @param shipmentsThisYear Número de envíos ya realizados en el año calendario (default 0) + * @param max4x4Value Límite de valor (default $400) + * @param max4x4WeightKg Límite de peso en kg (default 4 kg) + * @param max4x4PerYear Máximo envíos/año (default 4) + */ +export function qualifiesFor4x4( + valueUsd: number, + weightLbs: number, + shipmentsThisYear = 0, + max4x4Value = 400, + max4x4WeightKg = 4, + max4x4PerYear = 4, +): boolean { + const weightKg = weightLbs * 0.453592; + return ( + valueUsd <= max4x4Value && + weightKg <= max4x4WeightKg && + shipmentsThisYear < max4x4PerYear + ); +} diff --git a/apps/api/src/common/utils/suite-code.util.spec.ts b/apps/api/src/common/utils/suite-code.util.spec.ts new file mode 100644 index 0000000..f6cd70b --- /dev/null +++ b/apps/api/src/common/utils/suite-code.util.spec.ts @@ -0,0 +1,87 @@ +import { + generateSuiteCode, + parseSuiteCode, + buildSuiteAddress, +} from "./suite-code.util"; + +/** + * Tests del generador de Suite (casillero). + * Formato documentado en §02 y §09: EC-XXXXX + * Dirección: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050" + */ +describe("SuiteCode Utils (doc §02 / §09)", () => { + describe("generateSuiteCode", () => { + it("genera el código EC-00001 para secuencia 1", () => { + expect(generateSuiteCode(1)).toBe("EC-00001"); + }); + + it("genera EC-00345 para secuencia 345 (ejemplo de la doc)", () => { + expect(generateSuiteCode(345)).toBe("EC-00345"); + }); + + it("genera EC-99999 para el máximo permitido", () => { + expect(generateSuiteCode(99999)).toBe("EC-99999"); + }); + + it("rellena con ceros a la izquierda hasta 5 dígitos", () => { + expect(generateSuiteCode(7)).toBe("EC-00007"); + expect(generateSuiteCode(42)).toBe("EC-00042"); + expect(generateSuiteCode(1234)).toBe("EC-01234"); + }); + + it("siempre empieza con EC-", () => { + for (let i = 1; i <= 10; i++) { + expect(generateSuiteCode(i).startsWith("EC-")).toBe(true); + } + }); + + it("lanza error si la secuencia es 0", () => { + expect(() => generateSuiteCode(0)).toThrow(); + }); + + it("lanza error si la secuencia es negativa", () => { + expect(() => generateSuiteCode(-1)).toThrow(); + }); + + it("lanza error si la secuencia supera 99999", () => { + expect(() => generateSuiteCode(100000)).toThrow(); + }); + }); + + describe("parseSuiteCode", () => { + it("extrae la secuencia de un código válido", () => { + expect(parseSuiteCode("EC-00345")).toBe(345); + expect(parseSuiteCode("EC-00001")).toBe(1); + expect(parseSuiteCode("EC-99999")).toBe(99999); + }); + + it("retorna null para formatos inválidos", () => { + expect(parseSuiteCode("EC-0034")).toBeNull(); // 4 dígitos + expect(parseSuiteCode("US-00345")).toBeNull(); // prefijo incorrecto + expect(parseSuiteCode("EC00345")).toBeNull(); // sin guión + expect(parseSuiteCode("")).toBeNull(); + }); + + it("round-trip: generate → parse retorna la misma secuencia", () => { + const seq = 12345; + const code = generateSuiteCode(seq); + expect(parseSuiteCode(code)).toBe(seq); + }); + }); + + describe("buildSuiteAddress", () => { + it("construye la dirección completa con la suite (doc §02)", () => { + const addr = buildSuiteAddress("EC-00345"); + expect(addr).toBe( + "150 N Day St, Suite EC-00345, City of Orange, NJ 07050, EE.UU.", + ); + }); + + it("contiene la dirección exacta de la bodega NJ documentada", () => { + const addr = buildSuiteAddress("EC-00001"); + expect(addr).toContain("150 N Day St"); + expect(addr).toContain("City of Orange"); + expect(addr).toContain("NJ 07050"); + }); + }); +}); diff --git a/apps/api/src/common/utils/suite-code.util.ts b/apps/api/src/common/utils/suite-code.util.ts new file mode 100644 index 0000000..052884c --- /dev/null +++ b/apps/api/src/common/utils/suite-code.util.ts @@ -0,0 +1,29 @@ +/** + * Genera el código de Suite (casillero) asignado al cliente al registrarse. + * Formato: EC-XXXXX (número secuencial 5 dígitos) (doc §02 / §09) + * Ejemplo: EC-00345 + */ +export function generateSuiteCode(sequence: number): string { + if (sequence < 1 || sequence > 99999) { + throw new Error(`Secuencia fuera de rango: ${sequence}. Debe estar entre 1 y 99999.`); + } + return `EC-${String(sequence).padStart(5, "0")}`; +} + +/** + * Extrae el número de secuencia de un código de suite. + * Retorna null si el formato no es válido. + */ +export function parseSuiteCode(code: string): number | null { + const match = /^EC-(\d{5})$/.exec(code); + if (!match) return null; + return parseInt(match[1], 10); +} + +/** + * Construye la dirección completa de la Suite para mostrar al cliente. + * (doc §02 y §09) + */ +export function buildSuiteAddress(suiteCode: string): string { + return `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`; +} diff --git a/apps/api/src/common/utils/tracking-id.util.spec.ts b/apps/api/src/common/utils/tracking-id.util.spec.ts new file mode 100644 index 0000000..f41c859 --- /dev/null +++ b/apps/api/src/common/utils/tracking-id.util.spec.ts @@ -0,0 +1,74 @@ +import { + generateTrackingId, + isValidTrackingId, +} from "./tracking-id.util"; + +/** + * Tests del Tracking ID. + * Formato documentado en §08: EC-YYYYMMDD-XXXXXX + */ +describe("TrackingId Utils (doc §08)", () => { + describe("generateTrackingId", () => { + it("genera un ID con el formato EC-YYYYMMDD-XXXXXX", () => { + const id = generateTrackingId(); + expect(id).toMatch(/^EC-\d{8}-\d{6}$/); + }); + + it("usa la fecha proporcionada correctamente", () => { + const date = new Date("2026-05-06T00:00:00.000Z"); + const id = generateTrackingId(date); + // El año/mes/día depende de la zona horaria local del proceso + // Solo validamos el patrón + expect(id).toMatch(/^EC-\d{8}-\d{6}$/); + }); + + it("incluye el año 2026 si se pasa esa fecha", () => { + const date = new Date(2026, 4, 6); // May 6, 2026 — local + const id = generateTrackingId(date); + expect(id.startsWith("EC-2026")).toBe(true); + }); + + it("genera IDs distintos en llamadas consecutivas (probabilístico)", () => { + const ids = new Set(Array.from({ length: 20 }, () => generateTrackingId())); + // Con 1 millón de combinaciones, 20 llamadas deben ser todas distintas + expect(ids.size).toBe(20); + }); + + it("el prefijo es siempre EC-", () => { + for (let i = 0; i < 10; i++) { + expect(generateTrackingId().startsWith("EC-")).toBe(true); + } + }); + + it("el sufijo siempre tiene exactamente 6 dígitos", () => { + for (let i = 0; i < 10; i++) { + const parts = generateTrackingId().split("-"); + expect(parts[2]).toHaveLength(6); + expect(/^\d{6}$/.test(parts[2])).toBe(true); + } + }); + }); + + describe("isValidTrackingId", () => { + it("retorna true para IDs válidos", () => { + expect(isValidTrackingId("EC-20260506-082341")).toBe(true); + expect(isValidTrackingId("EC-20261231-000001")).toBe(true); + expect(isValidTrackingId("EC-20260101-999999")).toBe(true); + }); + + it("retorna false para IDs inválidos", () => { + expect(isValidTrackingId("EC-2026050-082341")).toBe(false); // fecha corta + expect(isValidTrackingId("EC-20260506-08234")).toBe(false); // sufijo corto + expect(isValidTrackingId("US-20260506-082341")).toBe(false); // prefijo incorrecto + expect(isValidTrackingId("EC-20260506082341")).toBe(false); // sin separador + expect(isValidTrackingId("EC-20260506-08234A")).toBe(false); // letras en sufijo + expect(isValidTrackingId("")).toBe(false); + }); + + it("los IDs generados siempre pasan la validación", () => { + for (let i = 0; i < 50; i++) { + expect(isValidTrackingId(generateTrackingId())).toBe(true); + } + }); + }); +}); diff --git a/apps/api/src/common/utils/tracking-id.util.ts b/apps/api/src/common/utils/tracking-id.util.ts new file mode 100644 index 0000000..f0d096f --- /dev/null +++ b/apps/api/src/common/utils/tracking-id.util.ts @@ -0,0 +1,20 @@ +/** + * Genera el Tracking ID único de un paquete. + * Formato documentado: EC-YYYYMMDD-XXXXXX (doc §08) + * Ejemplo: EC-20260506-082341 + */ +export function generateTrackingId(date?: Date): string { + const d = date ?? new Date(); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const random = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); + return `EC-${year}${month}${day}-${random}`; +} + +/** + * Valida que un Tracking ID tenga el formato correcto. + */ +export function isValidTrackingId(id: string): boolean { + return /^EC-\d{8}-\d{6}$/.test(id); +} diff --git a/apps/api/src/health/health.controller.spec.ts b/apps/api/src/health/health.controller.spec.ts new file mode 100644 index 0000000..ef72787 --- /dev/null +++ b/apps/api/src/health/health.controller.spec.ts @@ -0,0 +1,58 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { HealthController } from "./health.controller"; +import { PrismaService } from "../prisma/prisma.service"; + +describe("HealthController", () => { + let controller: HealthController; + let prismaService: { client: { $queryRaw: jest.Mock } }; + + beforeEach(async () => { + prismaService = { + client: { + $queryRaw: jest.fn().mockResolvedValue([{ "?column?": 1 }]), + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: PrismaService, + useValue: prismaService, + }, + ], + }).compile(); + + controller = module.get(HealthController); + }); + + it("debe estar definido", () => { + expect(controller).toBeDefined(); + }); + + it("retorna status 'ok' cuando la DB responde", async () => { + const result = await controller.health(); + expect(result.status).toBe("ok"); + expect(result.checks.database).toBe("ok"); + }); + + it("retorna status 'degraded' cuando la DB falla", async () => { + prismaService.client.$queryRaw.mockRejectedValueOnce(new Error("DB down")); + const result = await controller.health(); + expect(result.status).toBe("degraded"); + expect(result.checks.database).toBe("error"); + }); + + it("el response incluye service, version y timestamp", async () => { + const result = await controller.health(); + expect(result.service).toBe("moraworld-api"); + expect(result.version).toBe("0.1.0"); + expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("timestamp es una fecha ISO válida", async () => { + const result = await controller.health(); + const date = new Date(result.timestamp); + expect(date.getTime()).not.toBeNaN(); + }); +}); diff --git a/apps/api/src/tracking/tracking.controller.spec.ts b/apps/api/src/tracking/tracking.controller.spec.ts new file mode 100644 index 0000000..3f1a3f8 --- /dev/null +++ b/apps/api/src/tracking/tracking.controller.spec.ts @@ -0,0 +1,110 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { TrackingController } from "./tracking.controller"; +import { PrismaService } from "../prisma/prisma.service"; +import { NotFoundException } from "@nestjs/common"; + +describe("TrackingController (doc §07 — búsqueda pública sin login)", () => { + let controller: TrackingController; + let prismaService: { client: { package: { findUnique: jest.Mock } } }; + + const mockPackage = { + trackingId: "EC-20260506-082341", + status: "REGISTRADO", + description: "Tenis Nike Air Max", + store: "Amazon", + vendorTracking: "1Z999AA10123456784", + senaeCategory: "REGIMEN_4X4", + createdAt: new Date("2026-05-06T10:00:00.000Z"), + updatedAt: new Date("2026-05-06T10:00:00.000Z"), + statusHistory: [ + { + status: "REGISTRADO", + note: "Paquete registrado por el cliente", + createdAt: new Date("2026-05-06T10:00:00.000Z"), + }, + ], + }; + + beforeEach(async () => { + prismaService = { + client: { + package: { + findUnique: jest.fn().mockResolvedValue(mockPackage), + }, + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [TrackingController], + providers: [ + { + provide: PrismaService, + useValue: prismaService, + }, + ], + }).compile(); + + controller = module.get(TrackingController); + }); + + it("debe estar definido", () => { + expect(controller).toBeDefined(); + }); + + it("retorna el paquete cuando el Tracking ID es válido y existe", async () => { + const result = await controller.findByTrackingId("EC-20260506-082341"); + expect(result.trackingId).toBe("EC-20260506-082341"); + expect(result.status).toBe("REGISTRADO"); + expect(result.description).toBe("Tenis Nike Air Max"); + expect(result.timeline).toHaveLength(1); + }); + + it("incluye el timeline (historial de estados)", async () => { + const result = await controller.findByTrackingId("EC-20260506-082341"); + expect(Array.isArray(result.timeline)).toBe(true); + expect(result.timeline[0].status).toBe("REGISTRADO"); + }); + + it("lanza NotFoundException si el Tracking ID no existe en DB", async () => { + prismaService.client.package.findUnique.mockResolvedValueOnce(null); + await expect( + controller.findByTrackingId("EC-20260506-999999"), + ).rejects.toThrow(NotFoundException); + }); + + it("lanza NotFoundException para formato de ID inválido (evita consultas innecesarias)", async () => { + await expect( + controller.findByTrackingId("INVALIDO"), + ).rejects.toThrow(NotFoundException); + // No debe llegar a consultar la DB + expect(prismaService.client.package.findUnique).not.toHaveBeenCalled(); + }); + + it("lanza NotFoundException para ID con prefijo incorrecto", async () => { + await expect( + controller.findByTrackingId("US-20260506-082341"), + ).rejects.toThrow(NotFoundException); + }); + + it("lanza NotFoundException para ID con fecha malformada", async () => { + await expect( + controller.findByTrackingId("EC-2026050-082341"), + ).rejects.toThrow(NotFoundException); + }); + + it("retorna null para senaeCategory si no está definida", async () => { + prismaService.client.package.findUnique.mockResolvedValueOnce({ + ...mockPackage, + senaeCategory: null, + }); + const result = await controller.findByTrackingId("EC-20260506-082341"); + expect(result.senaeCategory).toBeNull(); + }); + + it("no expone datos sensibles (sin userId ni tenantId)", async () => { + const result = await controller.findByTrackingId("EC-20260506-082341") as Record; + expect(result["userId"]).toBeUndefined(); + expect(result["tenantId"]).toBeUndefined(); + expect(result["declaredValue"]).toBeUndefined(); + }); +}); diff --git a/apps/api/src/tracking/tracking.controller.ts b/apps/api/src/tracking/tracking.controller.ts new file mode 100644 index 0000000..bb4cc40 --- /dev/null +++ b/apps/api/src/tracking/tracking.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Param, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { isValidTrackingId } from "../common/utils/tracking-id.util"; + +@Controller("tracking") +export class TrackingController { + constructor(private readonly prisma: PrismaService) {} + + /** + * GET /api/tracking/:id + * Búsqueda pública por Tracking ID — sin necesidad de login (doc §07 Módulo de Tracking). + * Retorna el estado y el historial del paquete. + */ + @Get(":id") + async findByTrackingId(@Param("id") id: string) { + if (!isValidTrackingId(id)) { + throw new NotFoundException( + `Formato de Tracking ID inválido. Esperado: EC-YYYYMMDD-XXXXXX. Recibido: ${id}`, + ); + } + + const pkg = await this.prisma.client.package.findUnique({ + where: { trackingId: id }, + select: { + trackingId: true, + status: true, + description: true, + store: true, + vendorTracking: true, + senaeCategory: true, + createdAt: true, + updatedAt: true, + statusHistory: { + orderBy: { createdAt: "asc" }, + select: { + status: true, + note: true, + createdAt: true, + }, + }, + }, + }); + + if (!pkg) { + throw new NotFoundException(`No se encontró un paquete con el Tracking ID: ${id}`); + } + + return { + trackingId: pkg.trackingId, + status: pkg.status, + description: pkg.description, + store: pkg.store ?? null, + vendorTracking: pkg.vendorTracking ?? null, + senaeCategory: pkg.senaeCategory ?? null, + timeline: pkg.statusHistory, + lastUpdate: pkg.updatedAt, + registered: pkg.createdAt, + }; + } +} diff --git a/apps/api/src/tracking/tracking.module.ts b/apps/api/src/tracking/tracking.module.ts new file mode 100644 index 0000000..d6bc4ab --- /dev/null +++ b/apps/api/src/tracking/tracking.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { TrackingController } from "./tracking.controller"; + +@Module({ + controllers: [TrackingController], +}) +export class TrackingModule {} diff --git a/apps/web/src/app/calculadora/page.tsx b/apps/web/src/app/calculadora/page.tsx new file mode 100644 index 0000000..1c53248 --- /dev/null +++ b/apps/web/src/app/calculadora/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Calculadora de Envíos SENAE — Moraworld Imports", + description: "Calcula el costo total de tu envío desde EE.UU. a Ecuador incluyendo FODINFA, IVA, arancel y flete. Sin registro.", +}; + +export default function CalculadoraPage() { + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; + + return ( +
+
+
+

+ Calculadora SENAE +

+

+ Sin necesidad de registro. Conoce el costo total antes de comprar. +

+
+ + {/* Nota: el formulario interactivo se implementa en Fase 1 con client component */} +
+

+ Ingresa los datos de tu paquete +

+

+ El formulario interactivo se habilitará en la Fase 1. Por ahora puedes usar el + endpoint de la API directamente: +

+
+
{`// GET /api/calculator`}
+
+ GET{" "} + {apiUrl}/api/calculator?value=150& + weight=2& + category=REGIMEN_4X4 +
+
+ {`// Parámetros: value, weight, category, length, width, height, shipments`} +
+
+
+ + {/* Categorías SENAE informativas */} +
+

+ Categorías SENAE aplicables +

+
+ {[ + { name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta $400 · 4 kg · 4 envíos/año" }, + { name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes generales" }, + { name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" }, + { name: "Categoría D", rate: "0–15%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" }, + ].map((c) => ( +
+
{c.name}
+
{c.rate}
+
{c.desc}
+
+ ))} +
+
+

+ Fórmula: Flete (Peso × $3.50/lb) + Seguro (2%) + FODINFA (0.5%) + Arancel (% categoría) + IVA (15%) +

+
+
+
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 6839531..a7b2000 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,115 +1,450 @@ -async function getApiHealth() { - const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; - try { - const res = await fetch(`${apiUrl}/api/health`, { - next: { revalidate: 10 }, - }); - if (!res.ok) return null; - return res.json(); - } catch { - return null; - } -} +import type { Metadata } from "next"; +import Link from "next/link"; -export default async function HomePage() { - const health = await getApiHealth(); +export const metadata: Metadata = { + title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador", + description: + "Compra en Amazon, eBay, Walmart y más. Recibe en tu Suite en New Jersey y enviamos a Ecuador con gestión aduanera SENAE incluida.", + keywords: ["casillero New Jersey", "importaciones Ecuador", "SENAE", "Amazon Ecuador", "envíos EE.UU. Ecuador"], +}; +// ─── Datos estáticos de la landing (sin DB, SSG) ────────────── + +const TRACKING_STATES = [ + { num: "01", name: "REGISTRADO", color: "#6B7280", desc: "El cliente registró la compra" }, + { num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "El vendedor despachó hacia bodega NJ" }, + { num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" }, + { num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando, midiendo y fotografiando" }, + { num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado. Cobro aplicado." }, + { num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por la SENAE" }, + { num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo hacia Ecuador" }, + { num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana Ecuador" }, + { num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega al cliente" }, + { num: "10", name: "ENTREGADO", color: "#10B981", desc: "Paquete entregado en Ecuador ✓" }, + { num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" }, +]; + +const SENAE_CATEGORIES = [ + { name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta 4 envíos/año · máx. $400 · máx. 4 kg · Sin arancel" }, + { name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes de consumo general" }, + { name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" }, + { name: "Categoría D", rate: "0–15%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" }, +]; + +const MODULES = [ + { icon: "🌐", title: "Landing Pública", desc: "Calculadora SENAE, tracking sin login, tarifas y FAQ", tag: "Sin registro" }, + { icon: "👤", title: "Portal Cliente", desc: "Suite personalizada, pre-alertas, mis paquetes, historial", tag: "B2C · Ecuador" }, + { icon: "🏭", title: "Portal Bodega NJ", desc: "Recepción, peso, fotos, despacho desde 150 N Day St", tag: "Operador · NJ" }, + { icon: "⚙️", title: "Portal Admin", desc: "Dashboard, usuarios, tarifas, reportes, auditoría", tag: "Admin / Super Admin" }, + { icon: "🛃", title: "Módulo SENAE", desc: "DSI automática, cálculo FODINFA+IVA+Arancel, WebService", tag: "Agente Aduanero" }, + { icon: "📍", title: "Tracking", desc: "ID único EC-YYYYMMDD-XXXXXX · 11 estados · búsqueda pública", tag: "Todos los roles" }, + { icon: "📄", title: "Pre-alerta", desc: "Aviso previo con factura antes de llegada a bodega", tag: "Cliente" }, + { icon: "🚢", title: "Carga Pesada B2B", desc: "Pallets, contenedores, cotización especial, INEN", tag: "Importadores" }, +]; + +const ROLES = [ + { icon: "👑", name: "Super Admin", desc: "Acceso total. Gestiona tenants y config del sistema.", badge: "Acceso total", bg: "#EEF3FF" }, + { icon: "🏢", name: "Admin Empresa", desc: "Administra su tenant: usuarios, tarifas, reportes.", badge: "Tenant propio", bg: "#EDE9FE" }, + { icon: "📦", name: "Operador Bodega", desc: "Personal en NJ: verifica, pesa, fotografía y despacha.", badge: "Bodega NJ", bg: "#FEF3C7" }, + { icon: "🛃", name: "Agente Aduanero", desc: "Revisa y aprueba declaraciones ante la SENAE.", badge: "SENAE", bg: "#D1FAE5" }, + { icon: "🧑‍💻", name: "Cliente Final", desc: "Registra compras, pre-alertas y hace seguimiento.", badge: "B2C · Ecuador", bg: "#FFF3E8" }, + { icon: "🎧", name: "Soporte", desc: "Solo lectura: puede ver info de clientes y paquetes.", badge: "Solo lectura", bg: "#F3F4F6" }, +]; + +// ─── Componentes ────────────────────────────────────────────── + +function Navbar() { return ( -
-
- - Moraworld.Imports - - - API: {health?.status === "ok" ? "conectada" : "local / pendiente"} - -
+ + ); +} -
-
-

+

+
+ ISO/IEC 27001 · SENAE Autorizado · Cuenca, Ecuador +
+

+ Tu casillero en New Jersey +
para recibir en Ecuador +

+

+ Compra en Amazon, eBay, Walmart y más. Te asignamos una Suite personal en nuestra + bodega en NJ. Gestionamos el envío y la declaración aduanera SENAE por ti. +

+
+ 📍 Tu Suite:{" "} + 150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050, EE.UU. +
+
+ + Crear mi Suite gratis → + + + Calcular mi envío + +
+
+ {[ + ["🏢", "Moraworld Imports S.A.S.", "Ecuador · RUC activo"], + ["🏭", "Mora Global Import LLC", "New Jersey, EE.UU."], + ["🛃", "Autorización SENAE", "Declaración automática"], + ].map(([icon, title, sub]) => ( +
+ {icon} +
+
{title}
+
{sub}
+
+
+ ))} +
+
+
+ ); +} + +function ServicesSection() { + return ( +
+
+
+

Dos servicios, un solo sistema

+

Casillero personal para compras online y carga pesada para importadores B2B.

+
+
+
+
📦
+

Casillero Personal

+

+ Para compras individuales en Amazon, eBay, Walmart y más. Suite personalizada, tracking en tiempo real + y declaración SENAE automática bajo el régimen 4×4. +

+
    + {["Suite virtual asignada al registrarse", "Pre-alerta con carga de factura", "Calculadora de costos SENAE", "11 estados de tracking en tiempo real", "Notificaciones WhatsApp · SMS · Email"].map(i => ( +
  • {i}
  • + ))} +
+ + Conocer más → + +
+
+
🚢
+

Carga Pesada / Pallets B2B

+

+ Para importadores mayoristas: pallets, contenedores, volumen alto. Cotización personalizada, + certificación INEN y declaración formal DAI ante la SENAE. +

+
    + {["Cotización especial por tipo de mercancía", "Verificación de requisitos INEN", "Declaración formal DAI", "Asesoría en partidas arancelarias", "Expediente B2B con ID de seguimiento"].map(i => ( +
  • {i}
  • + ))} +
+ + Solicitar cotización → + +
+
+
+
+ ); +} + +function ModulesSection() { + return ( +
+
+
+

8 módulos integrados

+

Toda la operación logística cubierta en una sola plataforma.

+
+
+ {MODULES.map((m) => ( +
+
{m.icon}
+
{m.title}
+

{m.desc}

+ {m.tag} +
+ ))} +
+
+
+ ); +} + +function TrackingSection() { + return ( +
+
+
+
+

+ Tracking en tiempo real +

+

+ Cada paquete recibe un ID único con 11 estados que cubren todo el ciclo de vida. + Búsqueda pública sin necesidad de crear cuenta. +

+
+ Formato: EC-YYYYMMDD-XXXXXX
+ Ejemplo: EC-20260506-082341 +
+ - Fase 0 — Fundación del software -

-

- Tu casillero en New Jersey -
- para recibir en Ecuador -

-

- Monorepo activo: Next.js + NestJS + PostgreSQL. Desarrollo local con Docker; despliegue en Coolify. -

-
- - Ver estado del sistema → - - - Prototipos HTML en la raíz del repo - + }}> + Buscar mi paquete → + +
+
+ {TRACKING_STATES.map((s) => ( +
+
+ {s.num} + {s.name} + {s.desc} +
+ ))}
+
+
+
+ ); +} + +function CalculatorPreviewSection() { + return ( +
+
+

+ Calculadora SENAE gratuita +

+

+ Sin registro. Conoce el costo total de tu envío antes de comprar. +

+
+ {SENAE_CATEGORIES.map((c) => ( +
+
{c.name}
+
{c.rate}
+
{c.desc}
+
+ ))} +
+
+ // Fórmula (doc §15)
+ Flete = Peso_final × $3.50/lb
+ Seguro = Valor × 2%
+ FODINFA = Valor × 0.5%
+ Arancel = Valor × % categoría
+ IVA = (Valor + FODINFA + Arancel) × 15%
+ TOTAL = Flete + Seguro + FODINFA + Arancel + IVA +
+ + Calcular mi envío → + +
+
+ ); +} + +function RolesSection() { + return ( +
+
+
+

6 roles, accesos diferenciados

+

Cada usuario tiene exactamente lo que necesita. Ni más, ni menos.

+
+
+ {ROLES.map((r) => ( +
+
{r.icon}
+
{r.name}
+

{r.desc}

+ {r.badge} +
+ ))} +
+
+
+ ); +} + +function CompaniesSection() { + return ( +
+
+

Operación legal en dos países

+

Empresas constituidas legalmente en Ecuador y EE.UU.

+
+ {[ + { + flag: "🇪🇨", + name: "Moraworld Imports S.A.S.", + detail: "Constituida en Ecuador. Representante legal en Cuenca. RUC activo, permisos aduaneros vigentes ante la SENAE.", + badge: "Ecuador · S.A.S. · SENAE · Cuenca", + }, + { + flag: "🇺🇸", + name: "Mora Global Import LLC", + detail: "LLC registrada en New Jersey, EE.UU. Bodega operativa en 150 N Day St, City of Orange, NJ 07050.", + badge: "New Jersey · LLC · NJ 07050", + }, + ].map((c) => ( +
+ {c.flag} +
+
{c.name}
+

{c.detail}

+ {c.badge} +
+
+ ))}
-
+ + + ); +} + +function Footer() { + return ( +
+
+ Moraworld.Imports +
+

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

+
+ {["ISO/IEC 27001", "SENAE Autorizado", "INEN", "LOPDP Ecuador", "Multi-Tenant"].map((b) => ( + {b} + ))} +
+
+ Cómo funciona + Calculadora + Tracking + Tarifas + Quiénes somos + Casillero + Carga Pesada +
+
+ ); +} + +// ─── Página principal ───────────────────────────────────────── -
-

Próximos pasos (Fase 1)

-
    -
  • Registro e inicio de sesión con JWT + MFA
  • -
  • Asignación automática de Suite (EC-XXXXX)
  • -
  • Pre-alertas y registro de paquetes
  • -
  • Migración del portal desde portal-cliente.html
  • -
-
-
+export default function HomePage() { + return ( + <> + + + + + + + + +