feat: audit & sync with documentacion.html v1.1 — schema, tests, modules, web

Schema (packages/database/prisma/schema.prisma):
- Add SenaeCategory, B2BRequestStatus, NotificationChannel enums
- Add Package fields: lengthCm, widthCm, heightCm, hasDiscrepancy, photos[],
  senaeCategory, senaeAuthNumber, senaeDeclarationId
- Add userId field to PreAlert
- Add RefreshToken model (JWT auth — Fase 1)
- Add Tariff model (configurable rates: pricePerLb, insurancePct, etc.)
- Add B2BRequest model (heavy cargo imports — doc §13)
- Add Notification model (multi-channel: email, whatsapp, sms, push — doc §16)
- Add userAgent to AuditLog

API (apps/api):
- Add CalculatorModule: calculates SENAE cost breakdown (flete, seguro,
  FODINFA, arancel, IVA) per formulas in doc §15
- Add TrackingModule: public GET /api/tracking/:id endpoint (doc §07)
- Register both modules in AppModule
- Add Jest config + test scripts (test, test:watch, test:cov, test:ci)

Tests (80 tests, 97.45% coverage):
- calculator.util.spec.ts: 35 tests — SENAE formulas, volumetric weight,
  4x4 regime, tariff rates, error validation, breakdown
- tracking-id.util.spec.ts: 13 tests — format, uniqueness, validation
- suite-code.util.spec.ts: 13 tests — generation, parsing, address builder
- health.controller.spec.ts: 5 tests — ok/degraded/timestamp
- calculator.spec.ts: 8 tests — controller + service unit tests
- tracking.controller.spec.ts: 9 tests — public search, 404, no sensitive data

Web (apps/web):
- page.tsx: Full landing page matching documentation (Hero, Services,
  8 Modules, Tracking states, SENAE calculator preview, 6 Roles, Companies)
- /calculadora: SENAE categories + API usage guide
- /tracking: Tracking ID format + 11 states reference

Seed (packages/database/prisma/seed.ts):
- Creates all 6 roles (SUPER_ADMIN, ADMIN_EMPRESA, OPERADOR_BODEGA,
  AGENTE_ADUANERO, CLIENTE, SOPORTE)
- Assigns Suite EC-00001 to demo client
- Creates demo Package EC-20260506-000001 with status history
- Creates demo PreAlert and B2BRequest

.env.example: Added all 9 integrations from doc §18:
  Amazon SP-API, SENAE WebService, Stripe/PayPhone, WhatsApp Business API,
  SendGrid/SES, SMS/Twilio, FedEx/DHL/UPS couriers, S3/MinIO, Sentry

migration.sql: Updated to reflect all new models and fields
turbo.json + package.json: Added test, test:cov, test:ci tasks + db:seed script
This commit is contained in:
Lizandro Guarnizo
2026-06-01 08:25:01 -05:00
parent 094ea9cf81
commit 93db76897d
26 changed files with 4651 additions and 201 deletions
+98 -10
View File
@@ -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
+28 -1
View File
@@ -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
}
}
}
}
+4
View File
@@ -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 {}
@@ -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,
});
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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>(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>(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);
});
});
@@ -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%
});
});
});
@@ -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, number> = {
[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
);
}
@@ -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");
});
});
});
@@ -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.`;
}
@@ -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);
}
});
});
});
@@ -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);
}
@@ -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>(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();
});
});
@@ -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>(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<string, unknown>;
expect(result["userId"]).toBeUndefined();
expect(result["tenantId"]).toBeUndefined();
expect(result["declaredValue"]).toBeUndefined();
});
});
@@ -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,
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { TrackingController } from "./tracking.controller";
@Module({
controllers: [TrackingController],
})
export class TrackingModule {}
+86
View File
@@ -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 (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 700, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
Calculadora SENAE
</h1>
<p style={{ color: "#6B7280" }}>
Sin necesidad de registro. Conoce el costo total antes de comprar.
</p>
</div>
{/* Nota: el formulario interactivo se implementa en Fase 1 con client component */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
Ingresa los datos de tu paquete
</h2>
<p style={{ color: "#6B7280", fontSize: ".88rem", marginBottom: 20 }}>
El formulario interactivo se habilitará en la Fase 1. Por ahora puedes usar el
endpoint de la API directamente:
</p>
<div style={{
background: "#0D1117", borderRadius: 10, padding: "16px 20px",
fontFamily: "monospace", fontSize: ".8rem", color: "#A5B4FC",
lineHeight: 2,
}}>
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: 8 }}>{`// GET /api/calculator`}</div>
<div>
<span style={{ color: "#6EE7B7" }}>GET</span>{" "}
{apiUrl}/api/calculator?<span style={{ color: "#FCD34D" }}>value</span>=150&
<span style={{ color: "#FCD34D" }}>weight</span>=2&
<span style={{ color: "#FCD34D" }}>category</span>=REGIMEN_4X4
</div>
<div style={{ marginTop: 12, color: "rgba(255,255,255,.3)" }}>
{`// Parámetros: value, weight, category, length, width, height, shipments`}
</div>
</div>
</div>
{/* Categorías SENAE informativas */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 20, fontSize: "1rem" }}>
Categorías SENAE aplicables
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{[
{ 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: "015%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
].map((c) => (
<div key={c.name} style={{
background: c.bg, borderRadius: 10, padding: 16, textAlign: "center",
}}>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
<div style={{ fontSize: "1.8rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 6 }}>{c.rate}</div>
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 20, padding: 16, background: "#F3F4F6", borderRadius: 8 }}>
<p style={{ fontSize: ".82rem", color: "#6B7280", lineHeight: 1.6 }}>
<strong>Fórmula:</strong> Flete (Peso × $3.50/lb) + Seguro (2%) + FODINFA (0.5%) + Arancel (% categoría) + IVA (15%)
</p>
</div>
</div>
</div>
</main>
);
}
+440 -105
View File
@@ -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: "015%", 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 (
<main>
<header
style={{
borderBottom: "1px solid var(--gray-200)",
padding: "16px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 800, fontSize: "1.2rem", color: "var(--primary)" }}>
Moraworld<span style={{ color: "var(--accent)" }}>.</span>Imports
</span>
<span
style={{
fontSize: ".75rem",
fontWeight: 600,
padding: "4px 10px",
borderRadius: 999,
background: health?.status === "ok" ? "#D1FAE5" : "#FEF3C7",
color: health?.status === "ok" ? "#065F46" : "#92400E",
}}
>
API: {health?.status === "ok" ? "conectada" : "local / pendiente"}
</span>
</header>
<nav style={{
position: "sticky", top: 0, zIndex: 50,
background: "rgba(255,255,255,0.95)",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #E5E7EB",
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0 40px", height: 60,
}}>
<span style={{ fontWeight: 800, fontSize: "1.15rem", color: "#111827" }}>
Moraworld<span style={{ color: "#FF6B00" }}>.</span>Imports
</span>
<div style={{ display: "flex", gap: 24, alignItems: "center", fontSize: ".9rem" }}>
<Link href="/calculadora" style={{ color: "#4B5563" }}>Calculadora</Link>
<Link href="/tracking" style={{ color: "#4B5563" }}>Tracking</Link>
<Link href="/carga-pesada" style={{ color: "#4B5563" }}>Carga Pesada</Link>
<Link href="/portal" style={{
background: "#0057FF", color: "#fff",
padding: "8px 20px", borderRadius: 8, fontWeight: 600,
}}>Mi Portal</Link>
</div>
</nav>
);
}
<section
style={{
background: "linear-gradient(135deg, #0D1117 0%, #0D2150 100%)",
padding: "80px 24px",
color: "#fff",
}}
>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<p
style={{
function Hero() {
return (
<section style={{
background: "linear-gradient(135deg, #0D1117 0%, #0D2150 100%)",
padding: "100px 40px", color: "#fff",
}}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{
display: "inline-block",
background: "rgba(0,87,255,.15)", border: "1px solid rgba(0,87,255,.3)",
color: "#7AADFF", fontSize: ".8rem", fontWeight: 600,
padding: "6px 14px", borderRadius: 999, marginBottom: 24,
}}>
ISO/IEC 27001 · SENAE Autorizado · Cuenca, Ecuador
</div>
<h1 style={{ fontSize: "clamp(2rem,5vw,3.2rem)", fontWeight: 800, lineHeight: 1.15, marginBottom: 20 }}>
Tu casillero en <span style={{ color: "#FF6B00" }}>New Jersey</span>
<br />para recibir en Ecuador
</h1>
<p style={{ color: "#9CA3AF", maxWidth: 560, fontSize: "1.05rem", marginBottom: 16 }}>
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.
</p>
<div style={{
background: "rgba(0,87,255,.1)", border: "1px solid rgba(0,87,255,.25)",
borderRadius: 10, padding: "14px 18px", marginBottom: 32,
fontFamily: "monospace", fontSize: ".9rem", color: "#A5B4FC", maxWidth: 520,
}}>
📍 <strong style={{ color: "#FF6B00" }}>Tu Suite:</strong>{" "}
150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050, EE.UU.
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<Link href="/registro" style={{
background: "#0057FF", color: "#fff",
padding: "14px 28px", borderRadius: 8, fontWeight: 700, fontSize: ".95rem",
}}>
Crear mi Suite gratis
</Link>
<Link href="/calculadora" style={{
border: "2px solid rgba(255,255,255,.2)", color: "#9CA3AF",
padding: "12px 26px", borderRadius: 8, fontSize: ".9rem",
}}>
Calcular mi envío
</Link>
</div>
<div style={{ display: "flex", gap: 32, marginTop: 48, flexWrap: "wrap" }}>
{[
["🏢", "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]) => (
<div key={title} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
<span style={{ fontSize: "1.5rem" }}>{icon}</span>
<div>
<div style={{ fontWeight: 600, fontSize: ".88rem", color: "#fff" }}>{title}</div>
<div style={{ fontSize: ".78rem", color: "#6B7280" }}>{sub}</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function ServicesSection() {
return (
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 48 }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>Dos servicios, un solo sistema</h2>
<p style={{ color: "#6B7280", fontSize: ".95rem" }}>Casillero personal para compras online y carga pesada para importadores B2B.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
<div style={{ background: "#fff", borderRadius: 16, padding: 32, border: "2px solid #BFDBFE" }}>
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>📦</div>
<h3 style={{ fontWeight: 800, fontSize: "1.2rem", marginBottom: 8 }}>Casillero Personal</h3>
<p style={{ color: "#6B7280", marginBottom: 16 }}>
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.
</p>
<ul style={{ color: "#374151", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 6, fontSize: ".88rem" }}>
{["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 => (
<li key={i}>{i}</li>
))}
</ul>
<Link href="/casillero" style={{
display: "inline-block", marginTop: 20, background: "#0057FF", color: "#fff",
padding: "10px 20px", borderRadius: 8, fontWeight: 600, fontSize: ".88rem",
}}>
Conocer más
</Link>
</div>
<div style={{ background: "#fff", borderRadius: 16, padding: 32, border: "2px solid #FED7AA" }}>
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>🚢</div>
<h3 style={{ fontWeight: 800, fontSize: "1.2rem", marginBottom: 8 }}>Carga Pesada / Pallets B2B</h3>
<p style={{ color: "#6B7280", marginBottom: 16 }}>
Para importadores mayoristas: pallets, contenedores, volumen alto. Cotización personalizada,
certificación INEN y declaración formal DAI ante la SENAE.
</p>
<ul style={{ color: "#374151", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 6, fontSize: ".88rem" }}>
{["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 => (
<li key={i}>{i}</li>
))}
</ul>
<Link href="/carga-pesada/cotizacion" style={{
display: "inline-block", marginTop: 20, background: "#FF6B00", color: "#fff",
padding: "10px 20px", borderRadius: 8, fontWeight: 600, fontSize: ".88rem",
}}>
Solicitar cotización
</Link>
</div>
</div>
</div>
</section>
);
}
function ModulesSection() {
return (
<section style={{ padding: "80px 40px", background: "#fff" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 48 }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>8 módulos integrados</h2>
<p style={{ color: "#6B7280" }}>Toda la operación logística cubierta en una sola plataforma.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16 }}>
{MODULES.map((m) => (
<div key={m.title} style={{
background: "#F9FAFB", borderRadius: 12, padding: 20,
border: "1px solid #E5E7EB", transition: "box-shadow .2s",
}}>
<div style={{ fontSize: "1.8rem", marginBottom: 10 }}>{m.icon}</div>
<div style={{ fontWeight: 700, fontSize: ".9rem", marginBottom: 6 }}>{m.title}</div>
<p style={{ color: "#6B7280", fontSize: ".8rem", marginBottom: 10, lineHeight: 1.5 }}>{m.desc}</p>
<span style={{
background: "#EEF3FF", color: "#0057FF",
fontSize: ".68rem", fontWeight: 700, padding: "2px 8px",
borderRadius: 999,
}}>{m.tag}</span>
</div>
))}
</div>
</div>
</section>
);
}
function TrackingSection() {
return (
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 48, alignItems: "start" }}>
<div>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 12 }}>
Tracking en tiempo real
</h2>
<p style={{ color: "#6B7280", marginBottom: 16 }}>
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.
</p>
<div style={{
background: "#0D1117", borderRadius: 10, padding: "14px 18px",
fontFamily: "monospace", fontSize: ".88rem", color: "#A5B4FC",
marginBottom: 20, border: "1px solid rgba(0,87,255,.3)",
}}>
Formato: <strong style={{ color: "#FF6B00" }}>EC-YYYYMMDD-XXXXXX</strong><br />
Ejemplo: <strong>EC-20260506-082341</strong>
</div>
<Link href="/tracking" style={{
background: "#0057FF", color: "#fff",
padding: "12px 24px", borderRadius: 8, fontWeight: 600, fontSize: ".9rem",
display: "inline-block",
background: "rgba(0,87,255,.15)",
border: "1px solid rgba(0,87,255,.3)",
color: "#7AADFF",
fontSize: ".8rem",
fontWeight: 600,
padding: "6px 14px",
borderRadius: 999,
marginBottom: 24,
}}
>
Fase 0 Fundación del software
</p>
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, lineHeight: 1.15, marginBottom: 16 }}>
Tu casillero en <em style={{ color: "var(--accent)", fontStyle: "normal" }}>New Jersey</em>
<br />
para recibir en Ecuador
</h1>
<p style={{ color: "#9CA3AF", maxWidth: 520, marginBottom: 32 }}>
Monorepo activo: Next.js + NestJS + PostgreSQL. Desarrollo local con Docker; despliegue en Coolify.
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<a
href="/status"
style={{
background: "var(--primary)",
color: "#fff",
padding: "14px 28px",
borderRadius: 8,
fontWeight: 600,
}}
>
Ver estado del sistema
</a>
<span
style={{
border: "2px solid rgba(255,255,255,.2)",
color: "#9CA3AF",
padding: "12px 26px",
borderRadius: 8,
fontSize: ".9rem",
}}
>
Prototipos HTML en la raíz del repo
</span>
}}>
Buscar mi paquete
</Link>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{TRACKING_STATES.map((s) => (
<div key={s.num} style={{
display: "flex", gap: 12, alignItems: "center",
background: "#fff", borderRadius: 8, padding: "8px 14px",
border: "1px solid #E5E7EB",
}}>
<div style={{
width: 10, height: 10, borderRadius: "50%",
background: s.color, flexShrink: 0,
}} />
<span style={{ fontFamily: "monospace", fontSize: ".68rem", color: "#9CA3AF", minWidth: 22 }}>{s.num}</span>
<span style={{ fontFamily: "monospace", fontSize: ".78rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
<span style={{ fontSize: ".75rem", color: "#6B7280" }}>{s.desc}</span>
</div>
))}
</div>
</div>
</div>
</section>
);
}
function CalculatorPreviewSection() {
return (
<section style={{ padding: "80px 40px", background: "#fff" }}>
<div style={{ maxWidth: 1100, margin: "0 auto", textAlign: "center" }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
Calculadora SENAE gratuita
</h2>
<p style={{ color: "#6B7280", marginBottom: 40 }}>
Sin registro. Conoce el costo total de tu envío antes de comprar.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 16, marginBottom: 32 }}>
{SENAE_CATEGORIES.map((c) => (
<div key={c.name} style={{
borderRadius: 12, padding: 24, background: c.bg,
border: `1px solid ${c.color}30`, textAlign: "center",
}}>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
<div style={{ fontSize: "2.2rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 8 }}>{c.rate}</div>
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
</div>
))}
</div>
<div style={{
background: "#0D1117", borderRadius: 12, padding: "20px 24px",
fontFamily: "monospace", fontSize: ".82rem", color: "#A5B4FC",
textAlign: "left", maxWidth: 600, margin: "0 auto 28px",
lineHeight: 2, border: "1px solid rgba(255,255,255,.08)",
}}>
<span style={{ color: "rgba(255,255,255,.25)" }}>// Fórmula (doc §15)</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Flete</span> = Peso_final × <span style={{ color: "#FCD34D" }}>$3.50</span>/lb<br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Seguro</span> = Valor × <span style={{ color: "#FCD34D" }}>2%</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>FODINFA</span> = Valor × <span style={{ color: "#FCD34D" }}>0.5%</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Arancel</span> = Valor × <span style={{ color: "#FCD34D" }}>% categoría</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>IVA</span> = (Valor + FODINFA + Arancel) × <span style={{ color: "#FCD34D" }}>15%</span><br />
<span style={{ color: "#F9A8D4", fontWeight: 700 }}>TOTAL</span> = Flete + Seguro + FODINFA + Arancel + IVA
</div>
<Link href="/calculadora" style={{
background: "#0057FF", color: "#fff",
padding: "14px 32px", borderRadius: 8, fontWeight: 700,
display: "inline-block", fontSize: ".95rem",
}}>
Calcular mi envío
</Link>
</div>
</section>
);
}
function RolesSection() {
return (
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 48 }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>6 roles, accesos diferenciados</h2>
<p style={{ color: "#6B7280" }}>Cada usuario tiene exactamente lo que necesita. Ni más, ni menos.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 16 }}>
{ROLES.map((r) => (
<div key={r.name} style={{
background: r.bg, borderRadius: 12, padding: 20,
border: "1px solid #E5E7EB",
}}>
<div style={{ fontSize: "1.5rem", marginBottom: 10 }}>{r.icon}</div>
<div style={{ fontWeight: 700, marginBottom: 6 }}>{r.name}</div>
<p style={{ color: "#6B7280", fontSize: ".82rem", marginBottom: 12, lineHeight: 1.5 }}>{r.desc}</p>
<span style={{
background: "rgba(0,0,0,.06)", fontSize: ".7rem", fontWeight: 700,
padding: "2px 10px", borderRadius: 999,
}}>{r.badge}</span>
</div>
))}
</div>
</div>
</section>
);
}
function CompaniesSection() {
return (
<section style={{ padding: "80px 40px", background: "#fff" }}>
<div style={{ maxWidth: 1100, margin: "0 auto", textAlign: "center" }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>Operación legal en dos países</h2>
<p style={{ color: "#6B7280", marginBottom: 40 }}>Empresas constituidas legalmente en Ecuador y EE.UU.</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, maxWidth: 800, margin: "0 auto" }}>
{[
{
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) => (
<div key={c.name} style={{
background: "#fff", borderRadius: 16, padding: 28,
border: "2px solid #E5E7EB", display: "flex", gap: 16, alignItems: "flex-start",
}}>
<span style={{ fontSize: "2.5rem" }}>{c.flag}</span>
<div style={{ textAlign: "left" }}>
<div style={{ fontWeight: 800, marginBottom: 6 }}>{c.name}</div>
<p style={{ color: "#6B7280", fontSize: ".82rem", lineHeight: 1.6, marginBottom: 10 }}>{c.detail}</p>
<span style={{
background: "#EEF3FF", color: "#0057FF",
fontSize: ".7rem", fontWeight: 700, padding: "2px 10px", borderRadius: 999,
}}>{c.badge}</span>
</div>
</div>
))}
</div>
</section>
</div>
</section>
);
}
function Footer() {
return (
<footer style={{
background: "#0D1117", padding: "48px 40px", color: "rgba(255,255,255,.5)",
textAlign: "center",
}}>
<div style={{ fontSize: "1.1rem", fontWeight: 800, color: "#fff", marginBottom: 8 }}>
Moraworld<span style={{ color: "#FF6B00" }}>.</span>Imports
</div>
<p style={{ fontSize: ".82rem", marginBottom: 20 }}>
Moraworld Imports S.A.S. · Mora Global Import LLC
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "center", marginBottom: 24 }}>
{["ISO/IEC 27001", "SENAE Autorizado", "INEN", "LOPDP Ecuador", "Multi-Tenant"].map((b) => (
<span key={b} style={{
background: "rgba(255,255,255,.07)", color: "rgba(255,255,255,.5)",
fontSize: ".7rem", fontWeight: 700, padding: "4px 12px", borderRadius: 999,
}}>{b}</span>
))}
</div>
<div style={{ display: "flex", gap: 24, justifyContent: "center", flexWrap: "wrap", fontSize: ".82rem" }}>
<Link href="/como-funciona">Cómo funciona</Link>
<Link href="/calculadora">Calculadora</Link>
<Link href="/tracking">Tracking</Link>
<Link href="/tarifas">Tarifas</Link>
<Link href="/quienes-somos">Quiénes somos</Link>
<Link href="/casillero">Casillero</Link>
<Link href="/carga-pesada">Carga Pesada</Link>
</div>
</footer>
);
}
// ─── Página principal ─────────────────────────────────────────
<section style={{ padding: "48px 24px", maxWidth: 1100, margin: "0 auto" }}>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, marginBottom: 16 }}>Próximos pasos (Fase 1)</h2>
<ul style={{ color: "var(--gray-500)", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 8 }}>
<li>Registro e inicio de sesión con JWT + MFA</li>
<li>Asignación automática de Suite (EC-XXXXX)</li>
<li>Pre-alertas y registro de paquetes</li>
<li>Migración del portal desde portal-cliente.html</li>
</ul>
</section>
</main>
export default function HomePage() {
return (
<>
<Navbar />
<Hero />
<ServicesSection />
<ModulesSection />
<TrackingSection />
<CalculatorPreviewSection />
<RolesSection />
<CompaniesSection />
<Footer />
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Rastrear Paquete — Moraworld Imports",
description: "Busca tu paquete con el Tracking ID (formato EC-YYYYMMDD-XXXXXX). Sin necesidad de iniciar sesión.",
};
export default function TrackingPage() {
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 600, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<Link href="/" style={{ color: "#6B7280", fontSize: ".88rem" }}> Inicio</Link>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginTop: 8, marginBottom: 8 }}>
Rastrear paquete
</h1>
<p style={{ color: "#6B7280" }}>
Ingresa tu Tracking ID para ver el estado de tu paquete. No necesitas iniciar sesión.
</p>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
{/* Formulario — cliente component en Fase 1 */}
<label style={{ display: "block", fontWeight: 600, marginBottom: 8, fontSize: ".9rem" }}>
Tracking ID
</label>
<div style={{
background: "#F3F4F6", borderRadius: 8, padding: "12px 16px",
fontFamily: "monospace", color: "#9CA3AF", fontSize: ".9rem",
border: "1px solid #E5E7EB", marginBottom: 8,
}}>
EC-20260506-082341
</div>
<p style={{ color: "#9CA3AF", fontSize: ".78rem", marginBottom: 20 }}>
Formato: <strong style={{ fontFamily: "monospace" }}>EC-YYYYMMDD-XXXXXX</strong>
</p>
<div style={{
background: "#EEF3FF", borderRadius: 8, padding: "12px 16px",
fontSize: ".84rem", color: "#1D4ED8",
}}>
El formulario de búsqueda interactiva se habilita en Fase 1.
Por ahora puedes usar la API: <code style={{ fontFamily: "monospace" }}>GET /api/tracking/:id</code>
</div>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
11 estados del ciclo de vida
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "Cliente registró la compra" },
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "Despachado hacia NJ" },
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando y fotografiando" },
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado" },
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por SENAE" },
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo a Ecuador" },
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana" },
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega" },
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Entregado al cliente ✓" },
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
].map((s) => (
<div key={s.num} style={{
display: "flex", gap: 10, alignItems: "center",
padding: "8px 12px", background: "#F9FAFB",
borderRadius: 6, border: "1px solid #E5E7EB",
}}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: s.color, flexShrink: 0 }} />
<span style={{ fontFamily: "monospace", fontSize: ".65rem", color: "#9CA3AF", minWidth: 18 }}>{s.num}</span>
<span style={{ fontFamily: "monospace", fontSize: ".75rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
<span style={{ fontSize: ".72rem", color: "#6B7280" }}>{s.desc}</span>
</div>
))}
</div>
</div>
</div>
</main>
);
}
+4
View File
@@ -7,10 +7,14 @@
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"test:cov": "turbo run test:cov",
"test:ci": "turbo run test:ci",
"setup": "npm install -g pnpm@9.15.0 && pnpm install",
"db:generate": "pnpm --filter @moraworld/database generate",
"db:migrate": "pnpm --filter @moraworld/database migrate:dev",
"db:push": "pnpm --filter @moraworld/database push",
"db:seed": "pnpm --filter @moraworld/database seed",
"db:studio": "pnpm --filter @moraworld/database studio",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
@@ -1,13 +1,31 @@
-- ══════════════════════════════════════════════════════════════
-- Moraworld Imports — Migración inicial v0.2
-- Sincronizado con documentacion.html v1.1
-- ══════════════════════════════════════════════════════════════
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('SUPER_ADMIN', 'ADMIN_EMPRESA', 'OPERADOR_BODEGA', 'AGENTE_ADUANERO', 'CLIENTE', 'SOPORTE');
-- CreateEnum
-- CreateEnum — 11 estados del ciclo de vida de un paquete (doc §08)
CREATE TYPE "PackageStatus" AS ENUM ('REGISTRADO', 'EN_TRANSITO_BODEGA', 'RECIBIDO_BODEGA', 'EN_VERIFICACION', 'VERIFICADO', 'DECLARACION_ADUANERA', 'EN_TRANSITO_ECUADOR', 'EN_ADUANA_ECUADOR', 'LISTO_ENTREGA', 'ENTREGADO', 'INCIDENCIA');
-- CreateEnum
CREATE TYPE "PreAlertStatus" AS ENUM ('PENDIENTE', 'VINCULADA', 'CANCELADA');
-- CreateTable
-- CreateEnum — Categorías SENAE (doc §15)
CREATE TYPE "SenaeCategory" AS ENUM ('REGIMEN_4X4', 'CATEGORIA_B', 'CATEGORIA_C', 'CATEGORIA_D');
-- CreateEnum — Estados de solicitud B2B (doc §13)
CREATE TYPE "B2BRequestStatus" AS ENUM ('PENDIENTE', 'EN_COTIZACION', 'COTIZADO', 'ACEPTADO', 'EN_PROCESO', 'COMPLETADO', 'CANCELADO');
-- CreateEnum — Canales de notificación (doc §16)
CREATE TYPE "NotificationChannel" AS ENUM ('EMAIL', 'WHATSAPP', 'SMS', 'PUSH');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('PENDIENTE', 'ENVIADO', 'FALLIDO');
-- ─── Tenant (multi-tenant) ────────────────────────────────────
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
@@ -15,11 +33,11 @@ CREATE TABLE "Tenant" (
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Usuarios ─────────────────────────────────────────────────
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
@@ -35,43 +53,64 @@ CREATE TABLE "User" (
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Refresh Tokens JWT (doc §05 Auth) ───────────────────────
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- ─── Suite / Casillero (doc §02 y §09) ───────────────────────
-- Dirección: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050"
CREATE TABLE "Suite" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Suite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Paquetes (doc §07 — Módulo de Tracking) ─────────────────
CREATE TABLE "Package" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"trackingId" TEXT NOT NULL,
"trackingId" TEXT NOT NULL, -- EC-YYYYMMDD-XXXXXX (doc §08)
"status" "PackageStatus" NOT NULL DEFAULT 'REGISTRADO',
"description" TEXT NOT NULL,
"store" TEXT,
"declaredValue" DECIMAL(10,2) NOT NULL,
"declaredWeight" DECIMAL(8,2),
"actualWeight" DECIMAL(8,2),
"declaredWeight" DECIMAL(8,2), -- Peso declarado por cliente (lbs)
"actualWeight" DECIMAL(8,2), -- Peso real confirmado en bodega NJ
"lengthCm" DECIMAL(8,2), -- Para peso volumétrico (doc §15)
"widthCm" DECIMAL(8,2),
"heightCm" DECIMAL(8,2),
"hasDiscrepancy" BOOLEAN NOT NULL DEFAULT false, -- Discrepancia >10% (doc §10)
"vendorTracking" TEXT,
"productUrl" TEXT,
"photos" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], -- S3 keys de fotos
"senaeCategory" "SenaeCategory", -- Categoría SENAE (doc §15)
"senaeAuthNumber" TEXT, -- N.º autorización SENAE
"senaeDeclarationId" TEXT, -- ID declaración en WebService
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Package_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Historial de estados ─────────────────────────────────────
CREATE TABLE "PackageStatusHistory" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
@@ -79,28 +118,89 @@ CREATE TABLE "PackageStatusHistory" (
"note" TEXT,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackageStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Pre-alertas (doc §07 — Módulo Pre-alerta) ───────────────
CREATE TABLE "PreAlert" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL, -- Cliente que creó la pre-alerta
"packageId" TEXT,
"store" TEXT NOT NULL,
"vendorTracking" TEXT,
"description" TEXT NOT NULL,
"declaredValue" DECIMAL(10,2) NOT NULL,
"invoiceKey" TEXT,
"invoiceKey" TEXT, -- S3 key de la factura (PDF/imagen)
"estimatedArrival" TIMESTAMP(3), -- Fecha estimada llegada bodega NJ
"status" "PreAlertStatus" NOT NULL DEFAULT 'PENDIENTE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PreAlert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Tarifas configurables (doc §12 + §15) ───────────────────
CREATE TABLE "Tariff" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"pricePerLb" DECIMAL(8,4) NOT NULL DEFAULT 3.50,
"insurancePct" DECIMAL(5,4) NOT NULL DEFAULT 0.02,
"fodinfaPct" DECIMAL(5,4) NOT NULL DEFAULT 0.005,
"ivaPct" DECIMAL(5,4) NOT NULL DEFAULT 0.15,
"max4x4Value" DECIMAL(10,2) NOT NULL DEFAULT 400,
"max4x4WeightKg" DECIMAL(6,2) NOT NULL DEFAULT 4,
"max4x4PerYear" INTEGER NOT NULL DEFAULT 4,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tariff_pkey" PRIMARY KEY ("id")
);
-- ─── Solicitudes B2B — Carga Pesada (doc §13) ────────────────
CREATE TABLE "B2BRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"trackingId" TEXT NOT NULL, -- ID B2B propio
"contactName" TEXT NOT NULL,
"contactEmail" TEXT NOT NULL,
"contactPhone" TEXT,
"companyName" TEXT,
"merchandiseType" TEXT NOT NULL,
"description" TEXT NOT NULL,
"estimatedWeightKg" DECIMAL(10,2),
"pallets" INTEGER,
"commercialValue" DECIMAL(12,2),
"originCity" TEXT,
"requiresInen" BOOLEAN NOT NULL DEFAULT false, -- Requiere certificación INEN
"inenCertNumber" TEXT,
"status" "B2BRequestStatus" NOT NULL DEFAULT 'PENDIENTE',
"quotationAmount" DECIMAL(12,2),
"quotationNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "B2BRequest_pkey" PRIMARY KEY ("id")
);
-- ─── Notificaciones (doc §16) ─────────────────────────────────
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"packageId" TEXT,
"userId" TEXT,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationStatus" NOT NULL DEFAULT 'PENDIENTE',
"subject" TEXT,
"body" TEXT NOT NULL,
"sentAt" TIMESTAMP(3),
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- ─── Auditoría append-only (doc §17 — ISO 27001 A.12) ────────
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT,
@@ -110,73 +210,51 @@ CREATE TABLE "AuditLog" (
"resourceId" TEXT,
"metadata" JSONB,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
-- ─── Índices únicos ───────────────────────────────────────────
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
CREATE UNIQUE INDEX "Suite_userId_key" ON "Suite"("userId");
CREATE UNIQUE INDEX "Suite_tenantId_code_key" ON "Suite"("tenantId", "code");
CREATE UNIQUE INDEX "Package_trackingId_key" ON "Package"("trackingId");
CREATE UNIQUE INDEX "PreAlert_packageId_key" ON "PreAlert"("packageId");
CREATE UNIQUE INDEX "Tariff_tenantId_key" ON "Tariff"("tenantId");
CREATE UNIQUE INDEX "B2BRequest_trackingId_key" ON "B2BRequest"("trackingId");
CREATE UNIQUE INDEX "User_tenantId_email_key" ON "User"("tenantId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "Suite_userId_key" ON "Suite"("userId");
-- ─── Índices de búsqueda ──────────────────────────────────────
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
CREATE INDEX "Suite_tenantId_idx" ON "Suite"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Suite_tenantId_code_key" ON "Suite"("tenantId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Package_trackingId_key" ON "Package"("trackingId");
-- CreateIndex
CREATE INDEX "Package_tenantId_status_idx" ON "Package"("tenantId", "status");
-- CreateIndex
CREATE INDEX "Package_userId_idx" ON "Package"("userId");
-- CreateIndex
CREATE INDEX "PackageStatusHistory_packageId_idx" ON "PackageStatusHistory"("packageId");
-- CreateIndex
CREATE UNIQUE INDEX "PreAlert_packageId_key" ON "PreAlert"("packageId");
-- CreateIndex
CREATE INDEX "PreAlert_tenantId_idx" ON "PreAlert"("tenantId");
-- CreateIndex
CREATE INDEX "PreAlert_userId_idx" ON "PreAlert"("userId");
CREATE INDEX "B2BRequest_tenantId_status_idx" ON "B2BRequest"("tenantId", "status");
CREATE INDEX "Notification_packageId_idx" ON "Notification"("packageId");
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
CREATE INDEX "AuditLog_tenantId_createdAt_idx" ON "AuditLog"("tenantId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- AddForeignKey
-- ─── Claves foráneas ──────────────────────────────────────────
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Suite" ADD CONSTRAINT "Suite_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suite" ADD CONSTRAINT "Suite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageStatusHistory" ADD CONSTRAINT "PackageStatusHistory_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PreAlert" ADD CONSTRAINT "PreAlert_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PreAlert" ADD CONSTRAINT "PreAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PreAlert" ADD CONSTRAINT "PreAlert_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Tariff" ADD CONSTRAINT "Tariff_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "B2BRequest" ADD CONSTRAINT "B2BRequest_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+176 -9
View File
@@ -1,5 +1,6 @@
// Moraworld Imports — Schema v0 (Fase 01)
// Moraworld Imports — Schema v0.2 (Fase 01)
// Multi-tenant por tenant_id en todas las tablas de negocio
// Sincronizado con documentacion.html v1.1
generator client {
provider = "prisma-client-js"
@@ -21,6 +22,7 @@ enum UserRole {
SOPORTE
}
/// 11 estados del ciclo de vida de un paquete (doc §08)
enum PackageStatus {
REGISTRADO
EN_TRANSITO_BODEGA
@@ -41,6 +43,37 @@ enum PreAlertStatus {
CANCELADA
}
/// Categorías SENAE para el cálculo de aranceles (doc §15)
enum SenaeCategory {
REGIMEN_4X4 // 0% — hasta $400, 4 kg, 4 envíos/año
CATEGORIA_B // 10% — bienes generales
CATEGORIA_C // 20% — textiles, calzado, hogar
CATEGORIA_D // 0-15% — electrónicos y telecomunicaciones
}
enum B2BRequestStatus {
PENDIENTE
EN_COTIZACION
COTIZADO
ACEPTADO
EN_PROCESO
COMPLETADO
CANCELADO
}
enum NotificationChannel {
EMAIL
WHATSAPP
SMS
PUSH
}
enum NotificationStatus {
PENDIENTE
ENVIADO
FALLIDO
}
// ─── Tenant (multi-tenant) ───────────────────────────────────
model Tenant {
@@ -51,10 +84,12 @@ model Tenant {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
suites Suite[]
packages Package[]
preAlerts PreAlert[]
users User[]
suites Suite[]
packages Package[]
preAlerts PreAlert[]
tariffs Tariff[]
b2bRequests B2BRequest[]
}
// ─── Usuarios ────────────────────────────────────────────────
@@ -75,16 +110,33 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
suite Suite?
packages Package[]
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
suite Suite?
packages Package[]
preAlerts PreAlert[]
refreshTokens RefreshToken[]
@@unique([tenantId, email])
@@index([tenantId])
}
/// Tokens de refresco JWT — expiración y revocación (doc §05 Auth)
model RefreshToken {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// ─── Casillero / Suite ───────────────────────────────────────
/// Dirección virtual asignada al registrarse: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050"
model Suite {
id String @id @default(cuid())
tenantId String
@@ -105,15 +157,34 @@ model Package {
id String @id @default(cuid())
tenantId String
userId String
trackingId String @unique // EC-YYYYMMDD-XXXXXX
/// Formato: EC-YYYYMMDD-XXXXXX (doc §08)
trackingId String @unique
status PackageStatus @default(REGISTRADO)
description String
store String?
declaredValue Decimal @db.Decimal(10, 2)
/// Peso declarado por el cliente en libras
declaredWeight Decimal? @db.Decimal(8, 2)
/// Peso real confirmado por el operador de bodega en libras
actualWeight Decimal? @db.Decimal(8, 2)
/// Largo en cm — para peso volumétrico (doc §15)
lengthCm Decimal? @db.Decimal(8, 2)
/// Ancho en cm
widthCm Decimal? @db.Decimal(8, 2)
/// Alto en cm
heightCm Decimal? @db.Decimal(8, 2)
/// Hay discrepancia >10% entre peso declarado y real (doc §10)
hasDiscrepancy Boolean @default(false)
vendorTracking String?
productUrl String?
/// URL de fotos del paquete en S3
photos String[]
/// Categoría SENAE asignada al calcular (doc §15)
senaeCategory SenaeCategory?
/// N.º de autorización SENAE al completar la DSI (doc §08 estado 06)
senaeAuthNumber String?
/// ID de la declaración enviada al WebService SENAE
senaeDeclarationId String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -122,6 +193,7 @@ model Package {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
statusHistory PackageStatusHistory[]
preAlert PreAlert?
notifications Notification[]
@@index([tenantId, status])
@@index([userId])
@@ -145,24 +217,118 @@ model PackageStatusHistory {
model PreAlert {
id String @id @default(cuid())
tenantId String
/// Cliente que creó la pre-alerta
userId String
packageId String? @unique
store String
vendorTracking String?
description String
declaredValue Decimal @db.Decimal(10, 2)
/// S3 key de la factura cargada (PDF o imagen)
invoiceKey String?
/// Fecha estimada de llegada a bodega NJ
estimatedArrival DateTime?
status PreAlertStatus @default(PENDIENTE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
package Package? @relation(fields: [packageId], references: [id])
@@index([tenantId])
@@index([userId])
}
// ─── Tarifas (configurables por Admin) ───────────────────────
/// Configuración de tarifas por tenant. Flete $/lb, seguro %, etc. (doc §12 + §15)
model Tariff {
id String @id @default(cuid())
tenantId String @unique
/// Precio por libra en USD — default: $3.50 (doc §15)
pricePerLb Decimal @db.Decimal(8, 4) @default(3.50)
/// Porcentaje de seguro sobre valor declarado — default: 2% (doc §15)
insurancePct Decimal @db.Decimal(5, 4) @default(0.02)
/// FODINFA — fijo SENAE: 0.5% (doc §15)
fodinfaPct Decimal @db.Decimal(5, 4) @default(0.005)
/// IVA Ecuador — 15% (doc §15)
ivaPct Decimal @db.Decimal(5, 4) @default(0.15)
/// Límite 4×4: valor máx. USD (doc §08 estado 06 / §15)
max4x4Value Decimal @db.Decimal(10, 2) @default(400)
/// Límite 4×4: peso máx. kg
max4x4WeightKg Decimal @db.Decimal(6, 2) @default(4)
/// Máx. envíos/año bajo régimen 4×4
max4x4PerYear Int @default(4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}
// ─── Carga Pesada B2B ─────────────────────────────────────────
/// Solicitudes de importación mayorista: pallets, contenedores (doc §13)
model B2BRequest {
id String @id @default(cuid())
tenantId String
/// Tracking ID propio para B2B
trackingId String @unique
contactName String
contactEmail String
contactPhone String?
companyName String?
/// Tipo de mercancía
merchandiseType String
/// Descripción detallada
description String
/// Peso estimado en kg
estimatedWeightKg Decimal? @db.Decimal(10, 2)
/// Número de pallets
pallets Int?
/// Valor comercial total en USD
commercialValue Decimal? @db.Decimal(12, 2)
/// Ciudad de origen en EE.UU.
originCity String?
/// Requiere certificación INEN (doc §13)
requiresInen Boolean @default(false)
inenCertNumber String?
status B2BRequestStatus @default(PENDIENTE)
/// Cotización enviada por el equipo Moraworld
quotationAmount Decimal? @db.Decimal(12, 2)
quotationNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, status])
}
// ─── Notificaciones ──────────────────────────────────────────
/// Historial de notificaciones enviadas por canal (doc §16)
model Notification {
id String @id @default(cuid())
packageId String?
userId String?
channel NotificationChannel
status NotificationStatus @default(PENDIENTE)
subject String?
body String
sentAt DateTime?
error String?
createdAt DateTime @default(now())
package Package? @relation(fields: [packageId], references: [id])
@@index([packageId])
@@index([userId])
}
// ─── Auditoría (append-only) ───────────────────────────────
/// Logs inmutables — ISO 27001 A.12 (doc §17)
model AuditLog {
id String @id @default(cuid())
tenantId String?
@@ -172,6 +338,7 @@ model AuditLog {
resourceId String?
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
+216 -12
View File
@@ -1,31 +1,235 @@
import { PrismaClient, UserRole } from "@prisma/client";
/**
* Seed de desarrollo — Moraworld Imports
* Crea datos de prueba para todos los roles documentados en §06 de documentacion.html
*
* Usuarios de prueba:
* super@moraworld.test → SUPER_ADMIN
* admin@moraworld.test → ADMIN_EMPRESA
* bodega@moraworld.test → OPERADOR_BODEGA
* aduanero@moraworld.test → AGENTE_ADUANERO
* cliente@moraworld.test → CLIENTE (con Suite EC-00001)
* soporte@moraworld.test → SOPORTE
*
* NOTA: Las contraseñas son placeholders (bcrypt se implementa en Fase 1 — auth).
*/
import { PrismaClient, UserRole, PackageStatus, SenaeCategory } from "@prisma/client";
const prisma = new PrismaClient();
// Placeholder hash (en Fase 1 se reemplaza por bcrypt.hash("Test1234!", 10))
const PLACEHOLDER_HASH = "$2b$10$PLACEHOLDER_CHANGE_IN_PHASE_1_AUTH_MODULE";
async function main() {
// ─── Tenant ─────────────────────────────────────────────────
const tenant = await prisma.tenant.upsert({
where: { slug: "moraworld" },
update: {},
update: { name: "Moraworld Imports", isActive: true },
create: {
slug: "moraworld",
name: "Moraworld Imports",
isActive: true,
},
});
console.log(`✓ Tenant: ${tenant.slug} (id: ${tenant.id})`);
console.log("✓ Tenant:", tenant.slug);
const suiteCode = "EC-00001";
const demoEmail = "demo@moraworld.test";
const existing = await prisma.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: demoEmail } },
// ─── Tarifa base (doc §15) ────────────────────────────────
await prisma.tariff.upsert({
where: { tenantId: tenant.id },
update: {},
create: {
tenantId: tenant.id,
pricePerLb: 3.50,
insurancePct: 0.02,
fodinfaPct: 0.005,
ivaPct: 0.15,
max4x4Value: 400,
max4x4WeightKg: 4,
max4x4PerYear: 4,
},
});
console.log("✓ Tarifa base configurada ($3.50/lb, 2% seguro, 0.5% FODINFA, 15% IVA)");
if (!existing) {
console.log(" Usuario demo se creará en Fase 1 (auth con bcrypt)");
// ─── Usuarios de prueba (uno por cada rol del §06) ────────
const usersToSeed: Array<{
email: string;
role: UserRole;
firstName: string;
lastName: string;
phone?: string;
}> = [
{ email: "super@moraworld.test", role: UserRole.SUPER_ADMIN, firstName: "System", lastName: "Admin" },
{ email: "admin@moraworld.test", role: UserRole.ADMIN_EMPRESA, firstName: "Carlos", lastName: "Mora" },
{ email: "bodega@moraworld.test", role: UserRole.OPERADOR_BODEGA, firstName: "James", lastName: "Wilson", phone: "+1-555-0100" },
{ email: "aduanero@moraworld.test", role: UserRole.AGENTE_ADUANERO, firstName: "Sofía", lastName: "Estrella" },
{ email: "cliente@moraworld.test", role: UserRole.CLIENTE, firstName: "Andrés", lastName: "Gutiérrez", phone: "+593-99-999-0001" },
{ email: "soporte@moraworld.test", role: UserRole.SOPORTE, firstName: "Laura", lastName: "Vásquez" },
];
const createdUsers: Record<string, string> = {};
for (const u of usersToSeed) {
const existing = await prisma.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: u.email } },
});
if (!existing) {
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
email: u.email,
passwordHash: PLACEHOLDER_HASH,
firstName: u.firstName,
lastName: u.lastName,
phone: u.phone ?? null,
role: u.role,
mfaEnabled: false,
isActive: true,
},
});
createdUsers[u.role] = user.id;
console.log(`✓ Usuario creado: ${u.email} (${u.role})`);
} else {
createdUsers[u.role] = existing.id;
console.log(`→ Usuario ya existe: ${u.email} (${u.role})`);
}
}
console.log("✓ Seed completado");
// ─── Suite para el cliente demo ───────────────────────────
// Formato: EC-00001 (doc §02 y §09)
const clientId = createdUsers[UserRole.CLIENTE];
if (clientId) {
const existingSuite = await prisma.suite.findUnique({ where: { userId: clientId } });
if (!existingSuite) {
await prisma.suite.create({
data: {
tenantId: tenant.id,
userId: clientId,
code: "EC-00001",
},
});
console.log("✓ Suite EC-00001 asignada a cliente demo");
} else {
console.log(`→ Suite ya existe: ${existingSuite.code}`);
}
}
// ─── Paquete demo con historial de estados ────────────────
const existingPkg = await prisma.package.findUnique({
where: { trackingId: "EC-20260506-000001" },
});
if (!existingPkg && clientId) {
const pkg = await prisma.package.create({
data: {
tenantId: tenant.id,
userId: clientId,
trackingId: "EC-20260506-000001",
status: PackageStatus.VERIFICADO,
description: "Tenis Nike Air Max 270 (demo seed)",
store: "Amazon",
declaredValue: 150.00,
declaredWeight: 2.5,
actualWeight: 2.6,
lengthCm: 32,
widthCm: 22,
heightCm: 14,
hasDiscrepancy: false,
vendorTracking: "1Z999AA10123456784",
productUrl: "https://www.amazon.com/dp/DEMO",
photos: [],
senaeCategory: SenaeCategory.REGIMEN_4X4,
paidAt: new Date("2026-05-06T10:00:00.000Z"),
},
});
// Historial de estados hasta VERIFICADO
const historialEstados: Array<{ status: PackageStatus; note: string; date: string }> = [
{ status: PackageStatus.REGISTRADO, note: "Paquete registrado por el cliente", date: "2026-05-06T10:00:00Z" },
{ status: PackageStatus.EN_TRANSITO_BODEGA, note: "Amazon confirmó despacho", date: "2026-05-07T09:00:00Z" },
{ status: PackageStatus.RECIBIDO_BODEGA, note: "Recibido en 150 N Day St, NJ", date: "2026-05-09T14:30:00Z" },
{ status: PackageStatus.EN_VERIFICACION, note: "Operador revisando paquete", date: "2026-05-09T15:00:00Z" },
{ status: PackageStatus.VERIFICADO, note: "Peso real: 2.6 lbs. Sin discrepancia.", date: "2026-05-09T15:45:00Z" },
];
for (const h of historialEstados) {
await prisma.packageStatusHistory.create({
data: {
packageId: pkg.id,
status: h.status,
note: h.note,
createdBy: clientId,
createdAt: new Date(h.date),
},
});
}
console.log(`✓ Paquete demo creado: ${pkg.trackingId} (estado: ${pkg.status})`);
} else {
console.log("→ Paquete demo ya existe");
}
// ─── Pre-alerta demo ──────────────────────────────────────
if (clientId) {
const existingPreAlert = await prisma.preAlert.findFirst({
where: { tenantId: tenant.id, userId: clientId },
});
if (!existingPreAlert) {
await prisma.preAlert.create({
data: {
tenantId: tenant.id,
userId: clientId,
store: "eBay",
vendorTracking: "9400111899223397992682",
description: "Audífonos Sony WH-1000XM5 (demo pre-alerta)",
declaredValue: 280.00,
estimatedArrival: new Date("2026-05-20T00:00:00.000Z"),
status: "PENDIENTE",
},
});
console.log("✓ Pre-alerta demo creada");
} else {
console.log("→ Pre-alerta demo ya existe");
}
}
// ─── Solicitud B2B demo (doc §13) ─────────────────────────
const existingB2B = await prisma.b2BRequest.findUnique({
where: { trackingId: "B2B-20260506-000001" },
});
if (!existingB2B) {
await prisma.b2BRequest.create({
data: {
tenantId: tenant.id,
trackingId: "B2B-20260506-000001",
contactName: "Roberto Andrade",
contactEmail: "roberto@importadora.ec",
contactPhone: "+593-98-765-4321",
companyName: "Importadora Andrade S.A.",
merchandiseType: "Calzado deportivo",
description: "1,000 pares de zapatos deportivos Nike (demo B2B)",
estimatedWeightKg: 1500,
pallets: 4,
commercialValue: 18000.00,
originCity: "Miami, FL",
requiresInen: true,
status: "PENDIENTE",
},
});
console.log("✓ Solicitud B2B demo creada: B2B-20260506-000001");
} else {
console.log("→ Solicitud B2B demo ya existe");
}
console.log("\n✅ Seed completado correctamente.");
console.log("\n📋 Usuarios de prueba:");
console.log(" super@moraworld.test → SUPER_ADMIN");
console.log(" admin@moraworld.test → ADMIN_EMPRESA");
console.log(" bodega@moraworld.test → OPERADOR_BODEGA");
console.log(" aduanero@moraworld.test → AGENTE_ADUANERO");
console.log(" cliente@moraworld.test → CLIENTE (Suite EC-00001)");
console.log(" soporte@moraworld.test → SOPORTE");
console.log("\n ⚠️ Contraseñas: placeholder — implementar bcrypt en Fase 1 (auth).");
}
main()
+2177
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -11,6 +11,18 @@
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"test:cov": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"test:ci": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}