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 { AuthService } from "./auth.service";
|
||||||
import { JwtStrategy } from "./jwt.strategy";
|
import { JwtStrategy } from "./jwt.strategy";
|
||||||
import { WarehousesModule } from "../warehouses/warehouses.module";
|
import { WarehousesModule } from "../warehouses/warehouses.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,6 +21,8 @@ import { WarehousesModule } from "../warehouses/warehouses.module";
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
|
NotificationsModule,
|
||||||
|
IntegrationsModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy],
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { WarehousesService } from "../warehouses/warehouses.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 { generateSuiteCode } from "../common/utils/suite-code.util";
|
||||||
import { RegisterDto, LoginDto } from "./dto/auth.dto";
|
import { RegisterDto, LoginDto } from "./dto/auth.dto";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import * as crypto from "crypto";
|
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 TENANT_SLUG = "moraworld";
|
||||||
const BCRYPT_ROUNDS = 10;
|
const BCRYPT_ROUNDS = 10;
|
||||||
@@ -21,6 +23,8 @@ export class AuthService {
|
|||||||
private jwt: JwtService,
|
private jwt: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private warehouses: WarehousesService,
|
private warehouses: WarehousesService,
|
||||||
|
private notifications: NotificationsService,
|
||||||
|
private integrations: IntegrationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Builds the suite address from the default warehouse in DB, falls back to env vars */
|
/** 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);
|
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);
|
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
|
||||||
return {
|
return {
|
||||||
user: this.sanitizeUser(user),
|
user: this.sanitizeUser(user),
|
||||||
@@ -100,6 +122,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MFA
|
// MFA
|
||||||
|
const PRIVILEGED_ROLES = ["ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO"];
|
||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
if (!dto.totpCode) return { requiresMfa: true, userId: user.id };
|
if (!dto.totpCode) return { requiresMfa: true, userId: user.id };
|
||||||
const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! });
|
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);
|
await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id);
|
||||||
throw new UnauthorizedException("Código MFA inválido.");
|
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({
|
await this.prisma.client.user.update({
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
|
import { IsString, IsOptional, IsNumber, IsBoolean, Min } from "class-validator";
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { generateTrackingId } from "../common/utils/tracking-id.util";
|
|
||||||
|
|
||||||
export class CreateB2BDto {
|
export class CreateB2BDto {
|
||||||
@IsString() contactName!: string;
|
@IsString() contactName!: string;
|
||||||
@IsString() contactEmail!: string;
|
@IsString() contactEmail!: string;
|
||||||
@IsOptional() @IsString() contactPhone?: string;
|
@IsOptional() @IsString() contactPhone?: string;
|
||||||
@IsOptional() @IsString() companyName?: string;
|
@IsOptional() @IsString() companyName?: string;
|
||||||
@IsString() merchandiseType!: string;
|
@IsString() merchandiseType!: string;
|
||||||
@IsString() description!: string;
|
@IsString() description!: string;
|
||||||
@IsOptional() @IsNumber() @Min(0) commercialValue?: number;
|
@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 {
|
export class UpdateB2BStatusDto {
|
||||||
@@ -37,13 +40,17 @@ export class B2BService {
|
|||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
trackingId,
|
trackingId,
|
||||||
contactName: dto.contactName,
|
contactName: dto.contactName,
|
||||||
contactEmail: dto.contactEmail,
|
contactEmail: dto.contactEmail,
|
||||||
contactPhone: dto.contactPhone,
|
contactPhone: dto.contactPhone,
|
||||||
companyName: dto.companyName,
|
companyName: dto.companyName,
|
||||||
merchandiseType: dto.merchandiseType,
|
merchandiseType: dto.merchandiseType,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
commercialValue: dto.commercialValue ?? null,
|
commercialValue: dto.commercialValue ?? null,
|
||||||
|
estimatedWeightKg: dto.estimatedWeightKg ?? null,
|
||||||
|
pallets: dto.pallets ?? null,
|
||||||
|
originCity: dto.originCity ?? null,
|
||||||
|
requiresInen: dto.requiresInen ?? false,
|
||||||
status: "PENDIENTE",
|
status: "PENDIENTE",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
|
|||||||
import { ConsolidationsService } from "./consolidations.service";
|
import { ConsolidationsService } from "./consolidations.service";
|
||||||
import { ConsolidationsController } from "./consolidations.controller";
|
import { ConsolidationsController } from "./consolidations.controller";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, NotificationsModule],
|
||||||
controllers: [ConsolidationsController],
|
controllers: [ConsolidationsController],
|
||||||
providers: [ConsolidationsService],
|
providers: [ConsolidationsService],
|
||||||
exports: [ConsolidationsService],
|
exports: [ConsolidationsService],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
|
||||||
function genCode(): string {
|
function genCode(): string {
|
||||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
@@ -16,7 +17,10 @@ function genCode(): string {
|
|||||||
export class ConsolidationsService {
|
export class ConsolidationsService {
|
||||||
private readonly logger = new Logger(ConsolidationsService.name);
|
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. */
|
/** Lista consolidaciones del tenant. Cliente solo ve las suyas. */
|
||||||
async list(tenantId: string, userId?: string): Promise<any[]> {
|
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");
|
if (c.status !== "CERRADA") throw new BadRequestException("La consolidación debe estar CERRADA para despachar");
|
||||||
|
|
||||||
// Actualizar todos los paquetes a EN_TRANSITO_ECUADOR
|
// 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 },
|
where: { consolidationId: id },
|
||||||
select: { packageId: true },
|
select: { packageId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.client.package.updateMany({
|
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" },
|
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({
|
return this.prisma.client.consolidation.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: "DESPACHADA", courierTracking },
|
data: { status: "DESPACHADA", courierTracking },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recalcula totales de peso y valor */
|
/** Recalcula totales de peso y valor */
|
||||||
private async recalcTotals(id: string, _tenantId: string): Promise<any> {
|
private async recalcTotals(id: string, _tenantId: string): Promise<any> {
|
||||||
const cp = await this.prisma.client.consolidationPackage.findMany({
|
const cp = await this.prisma.client.consolidationPackage.findMany({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
class CreateIntentDto {
|
class CreateIntentDto {
|
||||||
packageId!: string;
|
packageId!: string;
|
||||||
@@ -17,7 +18,10 @@ class ConfirmSessionDto {
|
|||||||
|
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
export class PaymentsController {
|
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 /payments — lista todos los pagos del tenant (admin) */
|
||||||
@Get()
|
@Get()
|
||||||
@@ -74,13 +78,16 @@ export class PaymentsController {
|
|||||||
async stripeWebhook(
|
async stripeWebhook(
|
||||||
@Req() req: RawBodyRequest<Request>,
|
@Req() req: RawBodyRequest<Request>,
|
||||||
@Headers("stripe-signature") signature: string,
|
@Headers("stripe-signature") signature: string,
|
||||||
@Query("tenant") tenant = "moraworld",
|
@Query("tenant") tenantSlug = "moraworld",
|
||||||
): Promise<{ received: boolean }> {
|
): Promise<{ received: boolean }> {
|
||||||
const rawBody = (req as any).rawBody as Buffer;
|
const rawBody = (req as any).rawBody as Buffer;
|
||||||
if (!rawBody || !signature) throw new BadRequestException("Missing body or signature");
|
if (!rawBody || !signature) throw new BadRequestException("Missing body or signature");
|
||||||
|
|
||||||
// Resolve tenantId from slug
|
// Resolve slug → real tenantId from DB
|
||||||
await this.svc.handleStripeWebhook(rawBody, signature, tenant);
|
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 };
|
return { received: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,13 +132,16 @@ describe("PaymentsService", () => {
|
|||||||
expect(result).toEqual(mockPayment);
|
expect(result).toEqual(mockPayment);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calcula el monto correctamente (flete + seguro)", async () => {
|
it("calcula el monto completo §15 (flete + seguro + FODINFA + IVA)", async () => {
|
||||||
// peso 3.5lb × $3.50 = $12.25 flete + $100 × 2% = $2 seguro = $14.25
|
// 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) =>
|
mockPrisma.client.payment.create.mockImplementation(({ data }: any) =>
|
||||||
Promise.resolve({ ...mockPayment, amount: data.amount })
|
Promise.resolve({ ...mockPayment, amount: data.amount })
|
||||||
);
|
);
|
||||||
const result = await service.createIntent("pkg-1", "user-1", "tenant-1");
|
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 { ConfigService } from "@nestjs/config";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { IntegrationsService } from "../integrations/integrations.service";
|
import { IntegrationsService } from "../integrations/integrations.service";
|
||||||
|
import { calculateShipping, SenaeCategory } from "../common/utils/calculator.util";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
type StripeClient = InstanceType<typeof Stripe>;
|
type StripeClient = InstanceType<typeof Stripe>;
|
||||||
@@ -23,14 +24,28 @@ export class PaymentsService {
|
|||||||
return new Stripe(secretKey, { apiVersion: "2026-05-27.dahlia" });
|
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> {
|
private async calcAmount(pkg: any, tenantId: string): Promise<number> {
|
||||||
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
||||||
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
|
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
|
||||||
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
|
const insurancePct = Number(tariff?.insurancePct ?? 0.02);
|
||||||
const freight = weight * pricePerLb;
|
const fodinfaPct = Number(tariff?.fodinfaPct ?? 0.005);
|
||||||
const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02);
|
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 1);
|
||||||
return Math.round((freight + insurance) * 100) / 100;
|
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) */
|
/** Obtiene el pago vinculado a un paquete (por trackingId) */
|
||||||
@@ -225,22 +240,37 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
if (!pkg) throw new NotFoundException("Paquete no encontrado");
|
if (!pkg) throw new NotFoundException("Paquete no encontrado");
|
||||||
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
||||||
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
|
const pricePerLb = Number(tariff?.pricePerLb ?? 3.5);
|
||||||
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
|
const insurancePct = Number(tariff?.insurancePct ?? 0.02);
|
||||||
const freight = weight * pricePerLb;
|
const fodinfaPct = Number(tariff?.fodinfaPct ?? 0.005);
|
||||||
const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02);
|
const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0);
|
||||||
const fodinfa = Number(pkg.declaredValue) * Number(tariff?.fodinfaPct ?? 0.005);
|
const category = ((pkg as any).senaeCategory as SenaeCategory) ?? SenaeCategory.REGIMEN_4X4;
|
||||||
const total = freight + insurance + fodinfa;
|
|
||||||
|
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 {
|
return {
|
||||||
package: pkg,
|
package: pkg,
|
||||||
payment: pkg.payment,
|
payment: pkg.payment,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
weightLb: weight,
|
weightLb: calc.finalWeightLbs,
|
||||||
pricePerLb,
|
pricePerLb,
|
||||||
freight: Math.round(freight * 100) / 100,
|
freight: calc.flete,
|
||||||
insurance: Math.round(insurance * 100) / 100,
|
insurance: calc.seguro,
|
||||||
fodinfa: Math.round(fodinfa * 100) / 100,
|
fodinfa: calc.fodinfa,
|
||||||
total: Math.round(total * 100) / 100,
|
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";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
export default function CalculadoraPage() {
|
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 [result, setResult] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -61,9 +61,10 @@ export default function CalculadoraPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Régimen aduanero</label>
|
<label className="label">Régimen aduanero</label>
|
||||||
<select className="select" value={form.category} onChange={set("category")}>
|
<select className="select" value={form.category} onChange={set("category")}>
|
||||||
<option value="MENSAJERIA_ACELERADA">Mensajería Acelerada (≤ $200 · sin impuestos)</option>
|
<option value="REGIMEN_4X4">Régimen 4×4 (hasta $400 · sin arancel)</option>
|
||||||
<option value="COURIER">Courier (≤ $400 · impuestos desde $200.01)</option>
|
<option value="CATEGORIA_B">Categoría B — Consumo general (arancel 10%)</option>
|
||||||
<option value="REGIMEN_4X4">Régimen 4×4 (≤ $2,000)</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -125,10 +126,11 @@ export default function CalculadoraPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
|
<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: "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: "Courier", desc: "Hasta $400 USD · Sin impuestos hasta $200 · Proceso rápido.", badge: "" },
|
{ title: "Categoría B", desc: "Bienes de consumo general. Arancel 10% + FODINFA 0.5% + IVA 15%.", badge: "" },
|
||||||
{ title: "Régimen 4×4", desc: "Hasta $2,000 USD · Para compras de mayor valor · Aplican impuestos completos.", badge: "" },
|
{ title: "Categoría C", desc: "Textiles, calzado, artículos del hogar. Arancel 20% + FODINFA + IVA.", badge: "" },
|
||||||
].map(r => (
|
{ 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 key={r.title} className="card" style={{ padding: "1.25rem" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
|
||||||
<span style={{ fontWeight: 700 }}>{r.title}</span>
|
<span style={{ fontWeight: 700 }}>{r.title}</span>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const TARIFAS = [
|
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: "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: "Courier", limit: "Hasta $400 USD", taxes: "Sin impuestos hasta $200", flete: "$8–$15/lb", ideal: "Electrónica · ropa · zapatos", badge: "" },
|
{ 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: "Régimen 4×4", limit: "Hasta $2,000 USD", taxes: "FODINFA + Arancel + IVA", flete: "Según peso/volumen", ideal: "Equipos, repuestos, herramientas", 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: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", 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() {
|
export default function TarifasPublicaPage() {
|
||||||
|
|||||||
Reference in New Issue
Block a user