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:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"project": "moraworld-imports"
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user