fix: C-7 webhook tenant lookup, C-5 full tax calc §15, M-2/M-3/M-4/M-8/L-7/L-8/L-9 gap fixes

This commit is contained in:
Lizandro Guarnizo
2026-06-01 21:10:43 -05:00
parent a047a8b032
commit a5842278fb
11 changed files with 156 additions and 54 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"project": "moraworld-imports"
}
+4
View File
@@ -6,6 +6,8 @@ import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { WarehousesModule } from "../warehouses/warehouses.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [
@@ -19,6 +21,8 @@ import { WarehousesModule } from "../warehouses/warehouses.module";
}),
}),
WarehousesModule,
NotificationsModule,
IntegrationsModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
+27 -1
View File
@@ -5,11 +5,13 @@ import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
import { WarehousesService } from "../warehouses/warehouses.service";
import { NotificationsService } from "../notifications/notifications.service";
import { IntegrationsService, INTEGRATION_CATALOG } from "../integrations/integrations.service";
import { generateSuiteCode } from "../common/utils/suite-code.util";
import { RegisterDto, LoginDto } from "./dto/auth.dto";
import * as bcrypt from "bcrypt";
import * as crypto from "crypto";
import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib";
import { generateSecret, generateURI, verify as totpVerify } from "otplib";
const TENANT_SLUG = "moraworld";
const BCRYPT_ROUNDS = 10;
@@ -21,6 +23,8 @@ export class AuthService {
private jwt: JwtService,
private config: ConfigService,
private warehouses: WarehousesService,
private notifications: NotificationsService,
private integrations: IntegrationsService,
) {}
/** Builds the suite address from the default warehouse in DB, falls back to env vars */
@@ -70,6 +74,24 @@ export class AuthService {
await this.audit(tenant.id, user.id, "USER_REGISTER", "User", user.id);
// L-7: Auto-seed integration keys vacías (idempotente)
try {
await Promise.all(
INTEGRATION_CATALOG.map(cat =>
this.prisma.client.integration.upsert({
where: { tenantId_key: { tenantId: tenant.id, key: cat.key } },
create: { tenantId: tenant.id, key: cat.key, label: cat.label, group: cat.group ?? null, isActive: false },
update: {},
})
)
);
} catch { /* non-blocking */ }
// L-8: Auto-seed notification templates (idempotente)
try {
await this.notifications.seedDefaultTemplates(tenant.id);
} catch { /* non-blocking */ }
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
return {
user: this.sanitizeUser(user),
@@ -100,6 +122,7 @@ export class AuthService {
}
// MFA
const PRIVILEGED_ROLES = ["ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO"];
if (user.mfaEnabled) {
if (!dto.totpCode) return { requiresMfa: true, userId: user.id };
const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! });
@@ -107,6 +130,9 @@ export class AuthService {
await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id);
throw new UnauthorizedException("Código MFA inválido.");
}
} else if (PRIVILEGED_ROLES.includes(user.role)) {
// M-8: MFA obligatorio para roles privilegiados — forzar setup antes del primer acceso
return { requiresMfaSetup: true, userId: user.id };
}
await this.prisma.client.user.update({
+9 -2
View File
@@ -1,7 +1,6 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
import { IsString, IsOptional, IsNumber, IsBoolean, Min } from "class-validator";
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
export class CreateB2BDto {
@IsString() contactName!: string;
@@ -11,6 +10,10 @@ export class CreateB2BDto {
@IsString() merchandiseType!: string;
@IsString() description!: string;
@IsOptional() @IsNumber() @Min(0) commercialValue?: number;
@IsOptional() @IsNumber() @Min(0) estimatedWeightKg?: number;
@IsOptional() @IsNumber() @Min(0) pallets?: number;
@IsOptional() @IsString() originCity?: string;
@IsOptional() @IsBoolean() requiresInen?: boolean;
}
export class UpdateB2BStatusDto {
@@ -44,6 +47,10 @@ export class B2BService {
merchandiseType: dto.merchandiseType,
description: dto.description,
commercialValue: dto.commercialValue ?? null,
estimatedWeightKg: dto.estimatedWeightKg ?? null,
pallets: dto.pallets ?? null,
originCity: dto.originCity ?? null,
requiresInen: dto.requiresInen ?? false,
status: "PENDIENTE",
},
});
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { ConsolidationsService } from "./consolidations.service";
import { ConsolidationsController } from "./consolidations.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { NotificationsModule } from "../notifications/notifications.module";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, NotificationsModule],
controllers: [ConsolidationsController],
providers: [ConsolidationsService],
exports: [ConsolidationsService],
@@ -5,6 +5,7 @@ import {
Logger,
} from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { NotificationsService } from "../notifications/notifications.service";
function genCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
@@ -16,7 +17,10 @@ function genCode(): string {
export class ConsolidationsService {
private readonly logger = new Logger(ConsolidationsService.name);
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private notifications: NotificationsService,
) {}
/** Lista consolidaciones del tenant. Cliente solo ve las suyas. */
async list(tenantId: string, userId?: string): Promise<any[]> {
@@ -115,24 +119,38 @@ export class ConsolidationsService {
if (c.status !== "CERRADA") throw new BadRequestException("La consolidación debe estar CERRADA para despachar");
// Actualizar todos los paquetes a EN_TRANSITO_ECUADOR
const pkgIds = await this.prisma.client.consolidationPackage.findMany({
const pkgLinks = await this.prisma.client.consolidationPackage.findMany({
where: { consolidationId: id },
select: { packageId: true },
});
await this.prisma.client.package.updateMany({
where: { id: { in: pkgIds.map(p => p.packageId) } },
where: { id: { in: pkgLinks.map(p => p.packageId) } },
data: { status: "EN_TRANSITO_ECUADOR" },
});
this.logger.log(`[CONSOLIDATION] Dispatched ${id}${pkgIds.length} packages → EN_TRANSITO_ECUADOR`);
// Notificar a cada cliente cuyo paquete fue despachado
for (const { packageId } of pkgLinks) {
try {
const pkg = await this.prisma.client.package.findUnique({
where: { id: packageId },
include: { user: true },
});
if (pkg) {
await this.notifications.notifyStatusChange(pkg, pkg.user);
}
} catch (err: any) {
this.logger.warn(`[CONSOLIDATION] Notif failed for pkg ${packageId}: ${err.message}`);
}
}
this.logger.log(`[CONSOLIDATION] Dispatched ${id}${pkgLinks.length} packages → EN_TRANSITO_ECUADOR`);
return this.prisma.client.consolidation.update({
where: { id },
data: { status: "DESPACHADA", courierTracking },
});
}
/** Recalcula totales de peso y valor */
private async recalcTotals(id: string, _tenantId: string): Promise<any> {
const cp = await this.prisma.client.consolidationPackage.findMany({
+11 -4
View File
@@ -5,6 +5,7 @@ import {
} from "@nestjs/common";
import { JwtAuthGuard } from "../auth/guards/auth.guard";
import { PaymentsService } from "./payments.service";
import { PrismaService } from "../prisma/prisma.service";
class CreateIntentDto {
packageId!: string;
@@ -17,7 +18,10 @@ class ConfirmSessionDto {
@Controller("payments")
export class PaymentsController {
constructor(private readonly svc: PaymentsService) {}
constructor(
private readonly svc: PaymentsService,
private readonly prisma: PrismaService,
) {}
/** GET /payments — lista todos los pagos del tenant (admin) */
@Get()
@@ -74,13 +78,16 @@ export class PaymentsController {
async stripeWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers("stripe-signature") signature: string,
@Query("tenant") tenant = "moraworld",
@Query("tenant") tenantSlug = "moraworld",
): Promise<{ received: boolean }> {
const rawBody = (req as any).rawBody as Buffer;
if (!rawBody || !signature) throw new BadRequestException("Missing body or signature");
// Resolve tenantId from slug
await this.svc.handleStripeWebhook(rawBody, signature, tenant);
// Resolve slug → real tenantId from DB
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: tenantSlug } });
if (!tenant) throw new BadRequestException(`Tenant '${tenantSlug}' no encontrado`);
await this.svc.handleStripeWebhook(rawBody, signature, tenant.id);
return { received: true };
}
}
@@ -132,13 +132,16 @@ describe("PaymentsService", () => {
expect(result).toEqual(mockPayment);
});
it("calcula el monto correctamente (flete + seguro)", async () => {
// peso 3.5lb × $3.50 = $12.25 flete + $100 × 2% = $2 seguro = $14.25
it("calcula el monto completo §15 (flete + seguro + FODINFA + IVA)", async () => {
// REGIMEN_4X4 (default): peso 3.5lb × $3.50 = $12.25 flete
// + $100 × 2% = $2 seguro + $100 × 0.5% = $0.50 FODINFA
// + arancel 0% (4×4) + ($100 + $0.50) × 15% IVA = $15.075
// total = 12.25 + 2 + 0.5 + 0 + 15.075 = $29.83
mockPrisma.client.payment.create.mockImplementation(({ data }: any) =>
Promise.resolve({ ...mockPayment, amount: data.amount })
);
const result = await service.createIntent("pkg-1", "user-1", "tenant-1");
expect(Number(result.amount)).toBeCloseTo(14.25, 1);
expect(Number(result.amount)).toBeCloseTo(29.83, 1);
});
});
+44 -14
View File
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, Logger } from "@nes
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
import { IntegrationsService } from "../integrations/integrations.service";
import { calculateShipping, SenaeCategory } from "../common/utils/calculator.util";
import Stripe from "stripe";
type StripeClient = InstanceType<typeof Stripe>;
@@ -23,14 +24,28 @@ export class PaymentsService {
return new Stripe(secretKey, { apiVersion: "2026-05-27.dahlia" });
}
/** Calcula el monto a cobrar desde el Package (peso real × tarifa) */
/** Calcula el monto completo: flete + seguro + FODINFA + arancel + IVA */
private async calcAmount(pkg: any, tenantId: string): Promise<number> {
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
const freight = weight * pricePerLb;
const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02);
return Math.round((freight + insurance) * 100) / 100;
const insurancePct = Number(tariff?.insurancePct ?? 0.02);
const fodinfaPct = Number(tariff?.fodinfaPct ?? 0.005);
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 1);
const category = (pkg.senaeCategory as SenaeCategory) ?? SenaeCategory.REGIMEN_4X4;
const result = calculateShipping({
declaredValueUsd: Number(pkg.declaredValue ?? 0),
weightLbs: weight,
lengthCm: pkg.lengthCm ? Number(pkg.lengthCm) : undefined,
widthCm: pkg.widthCm ? Number(pkg.widthCm) : undefined,
heightCm: pkg.heightCm ? Number(pkg.heightCm) : undefined,
category,
pricePerLb,
insurancePct,
fodinfaPct,
});
return result.total;
}
/** Obtiene el pago vinculado a un paquete (por trackingId) */
@@ -226,21 +241,36 @@ export class PaymentsService {
if (!pkg) throw new NotFoundException("Paquete no encontrado");
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
const insurancePct = Number(tariff?.insurancePct ?? 0.02);
const fodinfaPct = Number(tariff?.fodinfaPct ?? 0.005);
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
const freight = weight * pricePerLb;
const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02);
const fodinfa = Number(pkg.declaredValue) * Number(tariff?.fodinfaPct ?? 0.005);
const total = freight + insurance + fodinfa;
const category = ((pkg as any).senaeCategory as SenaeCategory) ?? SenaeCategory.REGIMEN_4X4;
const calc = calculateShipping({
declaredValueUsd: Number(pkg.declaredValue ?? 0),
weightLbs: weight || 1,
lengthCm: (pkg as any).lengthCm ? Number((pkg as any).lengthCm) : undefined,
widthCm: (pkg as any).widthCm ? Number((pkg as any).widthCm) : undefined,
heightCm: (pkg as any).heightCm ? Number((pkg as any).heightCm) : undefined,
category,
pricePerLb,
insurancePct,
fodinfaPct,
});
return {
package: pkg,
payment: pkg.payment,
breakdown: {
weightLb: weight,
weightLb: calc.finalWeightLbs,
pricePerLb,
freight: Math.round(freight * 100) / 100,
insurance: Math.round(insurance * 100) / 100,
fodinfa: Math.round(fodinfa * 100) / 100,
total: Math.round(total * 100) / 100,
freight: calc.flete,
insurance: calc.seguro,
fodinfa: calc.fodinfa,
arancel: calc.arancel,
iva: calc.iva,
total: calc.total,
senaeCategory: category,
},
};
}
+9 -7
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { api } from "@/lib/api";
export default function CalculadoraPage() {
const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "COURIER" });
const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "REGIMEN_4X4" });
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -61,9 +61,10 @@ export default function CalculadoraPage() {
<div className="form-group">
<label className="label">Régimen aduanero</label>
<select className="select" value={form.category} onChange={set("category")}>
<option value="MENSAJERIA_ACELERADA">Mensajería Acelerada ( $200 · sin impuestos)</option>
<option value="COURIER">Courier ( $400 · impuestos desde $200.01)</option>
<option value="REGIMEN_4X4">Régimen 4×4 ( $2,000)</option>
<option value="REGIMEN_4X4">Régimen 4×4 (hasta $400 · sin arancel)</option>
<option value="CATEGORIA_B">Categoría B Consumo general (arancel 10%)</option>
<option value="CATEGORIA_C">Categoría C Textiles/calzado/hogar (arancel 20%)</option>
<option value="CATEGORIA_D">Categoría D Electrónicos (arancel 10%)</option>
</select>
</div>
<div className="form-group">
@@ -125,9 +126,10 @@ export default function CalculadoraPage() {
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{[
{ title: "Mensajería Acelerada", desc: "Hasta $200 USD · No paga impuestos · Ideal para compras pequeñas en Amazon.", badge: "Más popular" },
{ title: "Courier", desc: "Hasta $400 USD · Sin impuestos hasta $200 · Proceso rápido.", badge: "" },
{ title: "Régimen 4×4", desc: "Hasta $2,000 USD · Para compras de mayor valor · Aplican impuestos completos.", badge: "" },
{ title: "Régimen 4×4", desc: "Hasta $400 · hasta 4 kg · sin arancel · máx. 4 envíos/año. Proceso simplificado.", badge: "Más popular" },
{ title: "Categoría B", desc: "Bienes de consumo general. Arancel 10% + FODINFA 0.5% + IVA 15%.", badge: "" },
{ title: "Categoría C", desc: "Textiles, calzado, artículos del hogar. Arancel 20% + FODINFA + IVA.", badge: "" },
{ title: "Categoría D", desc: "Electrónicos y equipos. Arancel 10% base (puede variar por subpartida) + FODINFA + IVA.", badge: "" },
].map(r => (
<div key={r.title} className="card" style={{ padding: "1.25rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
+5 -4
View File
@@ -1,10 +1,11 @@
import Link from "next/link";
const TARIFAS = [
{ regime: "Mensajería Acelerada", limit: "Hasta $200 USD", taxes: "Sin impuestos", flete: "$8$15/lb", ideal: "Compras pequeñas · fast fashion · accesorios", badge: "Más popular" },
{ regime: "Courier", limit: "Hasta $400 USD", taxes: "Sin impuestos hasta $200", flete: "$8$15/lb", ideal: "Electrónica · ropa · zapatos", badge: "" },
{ regime: "Régimen 4×4", limit: "Hasta $2,000 USD", taxes: "FODINFA + Arancel + IVA", flete: "Según peso/volumen", ideal: "Equipos, repuestos, herramientas", badge: "" },
{ regime: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", badge: "" },
{ regime: "Régimen 4×4", limit: "Hasta $400 · hasta 4 kg · máx. 4/año", taxes: "Sin arancel (FODINFA 0.5% + IVA 15%)", flete: "$3.50/lb (mín. $8)", ideal: "Compras cotidianas · electrónicos pequeños · moda", badge: "Más popular" },
{ regime: "Categoría B", limit: "Sin límite", taxes: "Arancel 10% + FODINFA 0.5% + IVA 15%", flete: "$3.50/lb", ideal: "Bienes de consumo general · cosméticos · alimentos", badge: "" },
{ regime: "Categoría C", limit: "Sin límite", taxes: "Arancel 20% + FODINFA 0.5% + IVA 15%", flete: "$3.50/lb", ideal: "Textiles · calzado · artículos del hogar", badge: "" },
{ regime: "Categoría D", limit: "Sin límite", taxes: "Arancel 10% base + FODINFA 0.5% + IVA 15%", flete: "$3.50/lb", ideal: "Electrónicos · equipos · repuestos", badge: "" },
{ regime: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal DAI", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", badge: "" },
];
export default function TarifasPublicaPage() {