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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user