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
@@ -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);
});
});