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
232 lines
6.7 KiB
TypeScript
232 lines
6.7 KiB
TypeScript
/**
|
||
* 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
|
||
);
|
||
}
|