Files
moraworld/apps/api/src/common/utils/calculator.util.ts
T
Lizandro Guarnizo 93db76897d 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
2026-06-01 08:25:01 -05:00

232 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
);
}