feat: C-8/C-1/C-2/C-3/C-4/C-6/M-1/M-5/M-6 — WebSocket gateway, SENAE real, SP-API, Twilio SMS, WhatsApp Business, soporte portal, HMAC audit, reportes CSV
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { createHmac } from "crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export interface AuditLogEntry {
|
||||
@@ -12,21 +13,86 @@ export interface AuditLogEntry {
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuditLogService — M-5
|
||||
* Adds HMAC-SHA256 integrity hash to every log entry so tampering can be detected.
|
||||
* Hash is stored in metadata.integrity.
|
||||
* Secret: env var AUDIT_HMAC_SECRET (falls back to a default for dev).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditLogService {
|
||||
private readonly logger = new Logger(AuditLogService.name);
|
||||
private readonly hmacSecret = process.env.AUDIT_HMAC_SECRET ?? "moraworld-audit-hmac-secret-change-in-prod";
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Compute HMAC-SHA256 of the canonical log payload.
|
||||
* Canonical form: JSON.stringify of { tenantId, userId, action, resource, resourceId, createdAt }
|
||||
*/
|
||||
private computeIntegrity(entry: AuditLogEntry, createdAt: Date): string {
|
||||
const canonical = JSON.stringify({
|
||||
tenantId: entry.tenantId ?? null,
|
||||
userId: entry.userId ?? null,
|
||||
action: entry.action,
|
||||
resource: entry.resource ?? null,
|
||||
resourceId: entry.resourceId ?? null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
});
|
||||
return createHmac("sha256", this.hmacSecret).update(canonical).digest("hex");
|
||||
}
|
||||
|
||||
async log(entry: AuditLogEntry): Promise<void> {
|
||||
try {
|
||||
await this.prisma.client.auditLog.create({ data: entry });
|
||||
const createdAt = new Date();
|
||||
const integrity = this.computeIntegrity(entry, createdAt);
|
||||
|
||||
await this.prisma.client.auditLog.create({
|
||||
data: {
|
||||
...entry,
|
||||
metadata: {
|
||||
...(entry.metadata ?? {}),
|
||||
integrity, // HMAC-SHA256 of canonical payload
|
||||
},
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
// Never let audit log failure break main flow
|
||||
this.logger.error("AuditLog write failed", (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the integrity hash of a stored audit log entry.
|
||||
* Returns true if the hash matches, false if the record was tampered with.
|
||||
*/
|
||||
verify(entry: {
|
||||
tenantId?: string | null;
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
resource?: string | null;
|
||||
resourceId?: string | null;
|
||||
createdAt: Date;
|
||||
metadata?: any;
|
||||
}): boolean {
|
||||
const stored = entry.metadata?.integrity;
|
||||
if (!stored) return false;
|
||||
|
||||
const canonical = JSON.stringify({
|
||||
tenantId: entry.tenantId ?? null,
|
||||
userId: entry.userId ?? null,
|
||||
action: entry.action,
|
||||
resource: entry.resource ?? null,
|
||||
resourceId: entry.resourceId ?? null,
|
||||
createdAt: entry.createdAt instanceof Date
|
||||
? entry.createdAt.toISOString()
|
||||
: new Date(entry.createdAt).toISOString(),
|
||||
});
|
||||
const expected = createHmac("sha256", this.hmacSecret).update(canonical).digest("hex");
|
||||
return expected === stored;
|
||||
}
|
||||
|
||||
async findAll(filters: {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
|
||||
@@ -10,11 +10,18 @@ export const INTEGRATION_CATALOG = [
|
||||
{ key: "payphone_token", label: "PayPhone — Token de API", group: "payment", required: false },
|
||||
{ key: "paypal_client_id", label: "PayPal — Client ID", group: "payment", required: false },
|
||||
// ── Notificaciones ────────────────────────────────────────
|
||||
{ key: "zeptomail_api_key", label: "Zeptomail — Send Mail Token", group: "notifications", required: true },
|
||||
{ key: "email_from", label: "Email remitente (from address)", group: "notifications", required: true },
|
||||
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
|
||||
{ key: "zeptomail_api_key", label: "Zeptomail — Send Mail Token", group: "notifications", required: true },
|
||||
{ key: "email_from", label: "Email remitente (from address)", group: "notifications", required: true },
|
||||
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
|
||||
// ── WhatsApp Business Cloud API (Meta) — C-4 ─────────────
|
||||
{ key: "whatsapp_api_token", label: "WhatsApp Business — API Token (Meta)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_phone_number_id", label: "WhatsApp Business — Phone Number ID", group: "notifications", required: false },
|
||||
// ── Twilio SMS — C-3 ─────────────────────────────────────
|
||||
{ key: "sms_account_sid", label: "Twilio — Account SID", group: "notifications", required: false },
|
||||
{ key: "sms_auth_token", label: "Twilio — Auth Token", group: "notifications", required: false },
|
||||
{ key: "sms_from_number", label: "Twilio — From Number (E.164)", group: "notifications", required: false },
|
||||
// ── Aduana / SENAE ────────────────────────────────────────
|
||||
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
|
||||
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { IoAdapter } from "@nestjs/platform-socket.io";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
@@ -16,6 +17,9 @@ async function bootstrap() {
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Socket.io adapter for WebSocket gateway (C-6)
|
||||
app.useWebSocketAdapter(new IoAdapter(app));
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
// Serve uploaded files (photos, invoices) as static assets
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from "@nestjs/websockets";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
|
||||
/**
|
||||
* NotificationsGateway — C-6
|
||||
* Real-time push of package status changes to connected portal clients.
|
||||
*
|
||||
* Connection flow:
|
||||
* 1. Client connects with `auth: { token: "<jwt>" }` in socket options.
|
||||
* 2. Gateway verifies JWT → places socket in room `user:<userId>`.
|
||||
* 3. On package status change, NotificationsService calls `emitStatusChange()`.
|
||||
* 4. All sockets in that user room receive `package:status` event.
|
||||
*
|
||||
* Client (Next.js portal):
|
||||
* const socket = io("http://localhost:3001", { auth: { token: localStorage.getItem("mw_access") } });
|
||||
* socket.on("package:status", (data) => { ... });
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: (process.env.CORS_ORIGINS ?? "http://localhost:3000").split(",").map(o => o.trim()),
|
||||
credentials: true,
|
||||
},
|
||||
namespace: "/ws",
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer() server!: Server;
|
||||
private readonly logger = new Logger(NotificationsGateway.name);
|
||||
|
||||
constructor(private jwtService: JwtService) {}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
const token =
|
||||
(client.handshake.auth as any)?.token ??
|
||||
client.handshake.headers?.authorization?.replace("Bearer ", "");
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn(`[WS] Client ${client.id} rejected — no token`);
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET ?? "changeme";
|
||||
const payload = this.jwtService.verify(token, { secret });
|
||||
const userId: string = payload.sub;
|
||||
|
||||
// Join personal room so we can target by userId
|
||||
await client.join(`user:${userId}`);
|
||||
client.data.userId = userId;
|
||||
client.data.tenantId = payload.tenantId;
|
||||
|
||||
this.logger.log(`[WS] Connected: ${client.id} → user:${userId}`);
|
||||
} catch {
|
||||
this.logger.warn(`[WS] Client ${client.id} rejected — invalid token`);
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`[WS] Disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
/** Emitted by NotificationsService on every package status change */
|
||||
emitStatusChange(userId: string, pkg: {
|
||||
id: string;
|
||||
trackingId: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
}) {
|
||||
this.server.to(`user:${userId}`).emit("package:status", {
|
||||
packageId: pkg.id,
|
||||
trackingId: pkg.trackingId,
|
||||
status: pkg.status,
|
||||
description: pkg.description ?? null,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`[WS] Emitted package:status to user:${userId} — ${pkg.trackingId} → ${pkg.status}`);
|
||||
}
|
||||
|
||||
/** Broadcast to all sockets in a tenant room */
|
||||
emitToTenant(tenantId: string, event: string, data: any) {
|
||||
this.server.to(`tenant:${tenantId}`).emit(event, data);
|
||||
}
|
||||
|
||||
/** Ping/pong — optional keep-alive */
|
||||
@SubscribeMessage("ping")
|
||||
handlePing(@ConnectedSocket() client: Socket, @MessageBody() _data: any) {
|
||||
client.emit("pong", { at: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { NotificationsGateway } from "./notifications.gateway";
|
||||
import { NotificationsController, NotificationsUserController } from "./notifications.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, IntegrationsModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
IntegrationsModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET ?? "changeme",
|
||||
signOptions: { expiresIn: "15m" },
|
||||
}),
|
||||
],
|
||||
controllers: [NotificationsController, NotificationsUserController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
providers: [NotificationsService, NotificationsGateway],
|
||||
exports: [NotificationsService, NotificationsGateway],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -139,9 +139,10 @@ describe("NotificationsService", () => {
|
||||
mockPrisma.client.notification.update.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("crea notificaciones para los 3 canales por defecto", async () => {
|
||||
it("crea notificaciones para los 3 canales activos por defecto (EMAIL, WHATSAPP, SMS)", async () => {
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
// EMAIL (FALLIDO – sin API key), WHATSAPP (wa.me link), PUSH (FALLIDO)
|
||||
// EMAIL (FALLIDO – sin API key), WHATSAPP (wa.me link), SMS (FALLIDO – sin Twilio)
|
||||
// PUSH se omite porque no hay plantilla activa y el fallback lo excluye
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable, Logger, NotFoundException, Optional } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
import type { NotificationsGateway } from "./notifications.gateway";
|
||||
|
||||
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
|
||||
const DEFAULT_SUBJECTS: Record<string, string> = {
|
||||
@@ -36,11 +37,19 @@ function interpolate(tpl: string, vars: Record<string, string>): string {
|
||||
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
||||
}
|
||||
|
||||
/** Normaliza número de teléfono para wa.me (solo dígitos, con código de país) */
|
||||
/** Normaliza número de teléfono para wa.me / WhatsApp (solo dígitos, con código de país) */
|
||||
function normalizePhone(phone: string): string {
|
||||
return phone.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/** Añade código de país Ecuador si no tiene prefijo internacional */
|
||||
function toE164Ecuador(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, "");
|
||||
if (digits.startsWith("593")) return `+${digits}`;
|
||||
if (digits.startsWith("0")) return `+593${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
@@ -48,6 +57,7 @@ export class NotificationsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private integrations: IntegrationsService,
|
||||
@Optional() private gateway: NotificationsGateway | null,
|
||||
) {}
|
||||
|
||||
// ─── Gestión de plantillas ────────────────────────────────
|
||||
@@ -99,7 +109,7 @@ export class NotificationsService {
|
||||
|
||||
/** Called whenever a package status changes. */
|
||||
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||
// Load full user data to get phone and name (needed for wa.me)
|
||||
// Load full user data to get phone and name
|
||||
let fullUser = user;
|
||||
if (!user?.firstName || !user?.phone) {
|
||||
try {
|
||||
@@ -115,15 +125,14 @@ export class NotificationsService {
|
||||
suiteCode: fullUser?.suite?.code ?? "",
|
||||
};
|
||||
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "SMS", "PUSH"];
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
// 1. Buscar plantilla en DB
|
||||
const tpl = await this.prisma.client.notificationTemplate.findUnique({
|
||||
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel } },
|
||||
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel: channel as any } },
|
||||
});
|
||||
|
||||
const active = tpl ? tpl.isActive : true;
|
||||
const active = tpl ? tpl.isActive : (channel !== "PUSH"); // PUSH requires explicit template
|
||||
if (!active) continue;
|
||||
|
||||
const subject = interpolate(
|
||||
@@ -144,19 +153,19 @@ export class NotificationsService {
|
||||
const result = await this.sendZeptomail(pkg.tenantId, subject, bodyText, fullUser);
|
||||
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
|
||||
errorMsg = result.error;
|
||||
|
||||
} else if (channel === "WHATSAPP") {
|
||||
// wa.me link con mensaje pre-cargado (sin Business API)
|
||||
const phone = fullUser?.phone ? normalizePhone(fullUser.phone) : null;
|
||||
if (phone) {
|
||||
finalBody = `https://wa.me/${phone}?text=${encodeURIComponent(bodyText)}`;
|
||||
notifStatus = "ENVIADO";
|
||||
} else {
|
||||
// No phone — skip WhatsApp
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "Sin número de teléfono registrado";
|
||||
}
|
||||
const result = await this.sendWhatsApp(pkg.tenantId, bodyText, fullUser);
|
||||
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
|
||||
finalBody = result.finalBody ?? bodyText;
|
||||
errorMsg = result.error;
|
||||
|
||||
} else if (channel === "SMS") {
|
||||
const result = await this.sendSms(pkg.tenantId, bodyText, fullUser);
|
||||
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
|
||||
errorMsg = result.error;
|
||||
|
||||
} else if (channel === "PUSH") {
|
||||
// PUSH no implementado — marcar FALLIDO silenciosamente
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "PUSH no configurado";
|
||||
}
|
||||
@@ -165,7 +174,7 @@ export class NotificationsService {
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
userId: pkg.userId,
|
||||
channel,
|
||||
channel: channel as any,
|
||||
status: "PENDIENTE",
|
||||
subject,
|
||||
body: finalBody,
|
||||
@@ -180,6 +189,18 @@ export class NotificationsService {
|
||||
error: errorMsg ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Real-time WebSocket push (C-6) ─────────────────────────────────
|
||||
if (channel === "PUSH" && this.gateway) {
|
||||
try {
|
||||
this.gateway.emitStatusChange(pkg.userId, {
|
||||
id: pkg.id,
|
||||
trackingId: pkg.trackingId,
|
||||
status: pkg.status,
|
||||
description: pkg.description,
|
||||
});
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
|
||||
}
|
||||
@@ -256,6 +277,125 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp Business Cloud API (Meta) — C-4.
|
||||
* Falls back to wa.me link if Business API credentials are not configured.
|
||||
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api/messages
|
||||
*/
|
||||
private async sendWhatsApp(
|
||||
tenantId: string,
|
||||
bodyText: string,
|
||||
toUser: any,
|
||||
): Promise<{ ok: boolean; finalBody?: string; error?: string }> {
|
||||
const phone = toUser?.phone ? toUser.phone : null;
|
||||
if (!phone) return { ok: false, error: "Sin número de teléfono registrado" };
|
||||
|
||||
const apiToken = await this.integrations.getValue(tenantId, "whatsapp_api_token");
|
||||
const phoneNumId = await this.integrations.getValue(tenantId, "whatsapp_phone_number_id");
|
||||
|
||||
// ── Meta WhatsApp Cloud API ────────────────────────────────────────────
|
||||
if (apiToken && phoneNumId) {
|
||||
try {
|
||||
const toE164 = toE164Ecuador(phone);
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/v19.0/${phoneNumId}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: "whatsapp",
|
||||
to: toE164.replace("+", ""),
|
||||
type: "text",
|
||||
text: { body: bodyText },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
this.logger.error(`[WhatsApp Business] Send failed (${res.status}): ${errText}`);
|
||||
return { ok: false, error: `WhatsApp API error ${res.status}` };
|
||||
}
|
||||
|
||||
this.logger.log(`[WhatsApp Business] Message sent to ${toE164}`);
|
||||
return { ok: true, finalBody: bodyText };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[WhatsApp Business] Exception: ${err.message}`);
|
||||
// Fall through to wa.me
|
||||
}
|
||||
}
|
||||
|
||||
// ── wa.me fallback (no Business API credentials) ─────────────────────
|
||||
const normalized = normalizePhone(phone);
|
||||
const waLink = `https://wa.me/${normalized}?text=${encodeURIComponent(bodyText)}`;
|
||||
this.logger.warn(`[WhatsApp] Using wa.me fallback for ${normalized} — configure whatsapp_api_token + whatsapp_phone_number_id en Integraciones`);
|
||||
return { ok: true, finalBody: waLink };
|
||||
}
|
||||
|
||||
/**
|
||||
* Twilio SMS — C-3.
|
||||
* Skips gracefully if credentials not configured.
|
||||
* Docs: https://www.twilio.com/docs/messaging/api
|
||||
*/
|
||||
private async sendSms(
|
||||
tenantId: string,
|
||||
bodyText: string,
|
||||
toUser: any,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const phone = toUser?.phone ? toUser.phone : null;
|
||||
if (!phone) return { ok: false, error: "Sin número de teléfono registrado" };
|
||||
|
||||
const accountSid = await this.integrations.getValue(tenantId, "sms_account_sid");
|
||||
const authToken = await this.integrations.getValue(tenantId, "sms_auth_token");
|
||||
const fromNumber = await this.integrations.getValue(tenantId, "sms_from_number");
|
||||
|
||||
if (!accountSid || !authToken || !fromNumber) {
|
||||
this.logger.warn(`[Twilio SMS] Credenciales no configuradas para tenant ${tenantId} — SMS omitido`);
|
||||
return { ok: false, error: "Twilio no configurado" };
|
||||
}
|
||||
|
||||
try {
|
||||
const toE164 = toE164Ecuador(phone);
|
||||
const basic = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
|
||||
|
||||
const body = new URLSearchParams({
|
||||
From: fromNumber,
|
||||
To: toE164,
|
||||
Body: bodyText.substring(0, 1600), // Twilio max length
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${basic}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
this.logger.error(`[Twilio] Send failed (${res.status}): ${errText}`);
|
||||
return { ok: false, error: `Twilio error ${res.status}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.logger.log(`[Twilio] SMS sent to ${toE164} — SID: ${data.sid}`);
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[Twilio] Exception: ${err.message}`);
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 20) {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { userId },
|
||||
|
||||
@@ -14,6 +14,17 @@ export class CreatePackageDto {
|
||||
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
|
||||
}
|
||||
|
||||
/** Cliente registra su propia compra — el userId viene del JWT (doc §09 paso 5) */
|
||||
export class RegisterPackageDto {
|
||||
@IsString() description!: string;
|
||||
@IsOptional() @IsString() store?: string;
|
||||
@IsOptional() @IsString() vendorTracking?: string;
|
||||
@IsOptional() @IsString() productUrl?: string;
|
||||
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) declaredValue?: number;
|
||||
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() declaredWeightLb?: number;
|
||||
@IsOptional() @IsEnum(["REGIMEN_4X4","CATEGORIA_B","CATEGORIA_C","CATEGORIA_D"]) senaeCategory?: string;
|
||||
}
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@IsEnum([
|
||||
"REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { memoryStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { PackagesService } from "./packages.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
|
||||
import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@@ -45,6 +45,13 @@ export class PackagesController {
|
||||
return this.svc.create(dto, user.id, user.tenantId);
|
||||
}
|
||||
|
||||
/** Cliente registra su propia compra (doc §09 paso 5) */
|
||||
@Post("register")
|
||||
@Roles("CLIENTE")
|
||||
selfRegister(@Body() dto: RegisterPackageDto, @CurrentUser() user: any): Promise<any> {
|
||||
return this.svc.selfRegister(dto, user.id, user.tenantId);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateStatus(
|
||||
|
||||
@@ -5,9 +5,10 @@ import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { SenaeModule } from "../senae/senae.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule],
|
||||
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule, SenaeModule],
|
||||
providers: [PackagesService],
|
||||
controllers: [PackagesController],
|
||||
exports: [PackagesService],
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { SenaeService } from "../senae/senae.service";
|
||||
import { generateTrackingId } from "../common/utils/tracking-id.util";
|
||||
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
|
||||
import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
|
||||
|
||||
@Injectable()
|
||||
export class PackagesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private notifications: NotificationsService,
|
||||
private senae: SenaeService,
|
||||
) {}
|
||||
|
||||
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
|
||||
@@ -87,6 +89,29 @@ export class PackagesService {
|
||||
return pkg;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
|
||||
|
||||
const updated = await this.prisma.client.package.update({
|
||||
where: { id },
|
||||
data: { status: dto.status as any },
|
||||
});
|
||||
|
||||
await this.prisma.client.packageStatusHistory.create({
|
||||
data: {
|
||||
packageId: id,
|
||||
status: dto.status as any,
|
||||
createdBy: operatorId,
|
||||
note: dto.note,
|
||||
},
|
||||
});
|
||||
|
||||
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Busca una pre-alerta PENDIENTE del mismo usuario que coincida por vendorTracking
|
||||
* y la vincula automáticamente al paquete (status → VINCULADA, packageId set). */
|
||||
private async tryLinkPreAlert(
|
||||
@@ -115,28 +140,41 @@ export class PackagesService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
|
||||
/** Cliente registra su propia compra — doc §09 pasos 5-6 */
|
||||
async selfRegister(dto: RegisterPackageDto, userId: string, tenantId: string): Promise<any> {
|
||||
const trackingId = generateTrackingId();
|
||||
|
||||
const updated = await this.prisma.client.package.update({
|
||||
where: { id },
|
||||
data: { status: dto.status as any },
|
||||
const pkg = await this.prisma.client.package.create({
|
||||
data: {
|
||||
trackingId,
|
||||
tenantId,
|
||||
userId,
|
||||
description: dto.description,
|
||||
store: dto.store,
|
||||
vendorTracking: dto.vendorTracking,
|
||||
productUrl: dto.productUrl,
|
||||
declaredValue: dto.declaredValue ?? 0,
|
||||
declaredWeight: dto.declaredWeightLb ?? null,
|
||||
senaeCategory: dto.senaeCategory as any ?? null,
|
||||
status: "REGISTRADO",
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.client.packageStatusHistory.create({
|
||||
data: {
|
||||
packageId: id,
|
||||
status: dto.status as any,
|
||||
createdBy: operatorId,
|
||||
note: dto.note,
|
||||
packageId: pkg.id,
|
||||
status: "REGISTRADO",
|
||||
createdBy: userId,
|
||||
note: "Compra registrada por el cliente",
|
||||
},
|
||||
});
|
||||
|
||||
// Notify user on status change
|
||||
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
|
||||
await this.tryLinkPreAlert(pkg.id, userId, tenantId, dto.vendorTracking);
|
||||
|
||||
return updated;
|
||||
// Notify the user of registration
|
||||
this.notifications.notifyStatusChange(pkg, { id: userId }).catch(() => {});
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +239,7 @@ export class PackagesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* SENAE declaration (doc §11): generate DSI stub, update status to DECLARACION_ADUANERA.
|
||||
* SENAE declaration (doc §11): call SenaeService (real or stub), update status to DECLARACION_ADUANERA.
|
||||
*/
|
||||
async generateSenaeDeclaration(id: string, dto: SenaeDeclarationDto, agentId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
|
||||
@@ -210,10 +248,13 @@ export class PackagesService {
|
||||
throw new BadRequestException("El paquete debe estar en estado VERIFICADO para generar la declaración.");
|
||||
}
|
||||
|
||||
// Stub: In prod this would call SENAE SOAP/REST WebService
|
||||
// Generate a plausible authorization number
|
||||
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
const declarationId = `DSI-${pkg.trackingId}`;
|
||||
// Call real SENAE service (falls back to stub if credentials not set — C-1)
|
||||
const { authNumber, declarationId, message } = await this.senae.submitDSI(
|
||||
pkg,
|
||||
pkg.tenantId,
|
||||
dto.category,
|
||||
dto.agentNotes,
|
||||
);
|
||||
|
||||
const updated = await this.prisma.client.package.update({
|
||||
where: { id },
|
||||
@@ -234,17 +275,9 @@ export class PackagesService {
|
||||
},
|
||||
});
|
||||
|
||||
const result = {
|
||||
...updated,
|
||||
declarationId,
|
||||
authNumber,
|
||||
message: "Declaración simplificada (DSI) enviada y aprobada por la SENAE (stub).",
|
||||
};
|
||||
|
||||
// Notify user of customs clearance
|
||||
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
|
||||
|
||||
return result;
|
||||
return { ...updated, declarationId, authNumber, message };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Post, Body, UseGuards } from "@nestjs/common";
|
||||
import { ProductsService } from "./products.service";
|
||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller("products")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -8,14 +9,7 @@ export class ProductsController {
|
||||
constructor(private svc: ProductsService) {}
|
||||
|
||||
@Post("scan")
|
||||
scanUrl(@Body("url") url: string): Promise<{
|
||||
name: string;
|
||||
price: number;
|
||||
weightLb: number;
|
||||
imageUrl: string;
|
||||
store: string;
|
||||
url: string;
|
||||
}> {
|
||||
return this.svc.scanUrl(url);
|
||||
scanUrl(@Body("url") url: string, @CurrentUser() user: any): Promise<any> {
|
||||
return this.svc.scanUrl(url, user?.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProductsService } from "./products.service";
|
||||
import { ProductsController } from "./products.controller";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule],
|
||||
providers: [ProductsService],
|
||||
controllers: [ProductsController],
|
||||
exports: [ProductsService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
interface ProductScanResult {
|
||||
name: string;
|
||||
@@ -7,35 +8,150 @@ interface ProductScanResult {
|
||||
imageUrl: string;
|
||||
store: string;
|
||||
url: string;
|
||||
asin?: string;
|
||||
brand?: string;
|
||||
isStub: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductsService — C-2
|
||||
* Scans a product URL. If Amazon SP-API credentials are configured (amazon_client_id,
|
||||
* amazon_client_secret, amazon_refresh_token), uses the Catalog Items API to get real data.
|
||||
* Falls back to stub if not configured or on error.
|
||||
*
|
||||
* SP-API docs: https://developer-docs.amazon.com/sp-api/docs/catalog-items-api-v2022-04-01
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
/**
|
||||
* Stub: In production this calls Amazon SP-API or a scraping service.
|
||||
* For now it extracts basic info from the URL and returns plausible mock data.
|
||||
*/
|
||||
async scanUrl(url: string): Promise<ProductScanResult> {
|
||||
const store = this.detectStore(url);
|
||||
private readonly logger = new Logger(ProductsService.name);
|
||||
// SP-API LWA token cache: { accessToken, expiresAt }
|
||||
private lwaCache: { accessToken: string; expiresAt: number; tenantId: string } | null = null;
|
||||
|
||||
// Extract ASIN from Amazon URL if present
|
||||
constructor(private integrations: IntegrationsService) {}
|
||||
|
||||
async scanUrl(url: string, tenantId?: string): Promise<ProductScanResult> {
|
||||
const store = this.detectStore(url);
|
||||
const asinMatch = url.match(/\/dp\/([A-Z0-9]{10})/);
|
||||
const asin = asinMatch ? asinMatch[1] : null;
|
||||
|
||||
// Stub response — in prod: call Amazon SP-API Catalog Items API
|
||||
// ── Amazon SP-API call if configured ─────────────────────────────────────
|
||||
if (asin && store === "Amazon" && tenantId) {
|
||||
try {
|
||||
const result = await this.callSpApi(asin, tenantId);
|
||||
if (result) return { ...result, store, url, isStub: false };
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[SP-API] Failed for ASIN ${asin}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stub fallback ─────────────────────────────────────────────────────────
|
||||
if (asin) {
|
||||
this.logger.warn(`[SP-API STUB] ASIN ${asin} — configure amazon_client_id, amazon_client_secret, amazon_refresh_token en Integraciones`);
|
||||
}
|
||||
return {
|
||||
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
|
||||
price: 29.99,
|
||||
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
|
||||
price: 29.99,
|
||||
weightLb: 1.5,
|
||||
imageUrl: "https://placehold.co/200x200?text=Product",
|
||||
store,
|
||||
url,
|
||||
asin: asin ?? undefined,
|
||||
isStub: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange LWA refresh token for a SP-API access token (cached 55 min).
|
||||
*/
|
||||
private async getLwaToken(clientId: string, clientSecret: string, refreshToken: string, tenantId: string): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (this.lwaCache && this.lwaCache.tenantId === tenantId && this.lwaCache.expiresAt > now + 60_000) {
|
||||
return this.lwaCache.accessToken;
|
||||
}
|
||||
|
||||
const res = await fetch("https://api.amazon.com/auth/o2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
throw new Error(`LWA token error: ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.lwaCache = {
|
||||
tenantId,
|
||||
accessToken: data.access_token,
|
||||
expiresAt: now + (data.expires_in ?? 3600) * 1000,
|
||||
};
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call SP-API Catalog Items v2022-04-01 for a single ASIN.
|
||||
*/
|
||||
private async callSpApi(asin: string, tenantId: string): Promise<Omit<ProductScanResult, "store" | "url" | "isStub"> | null> {
|
||||
const clientId = await this.integrations.getValue(tenantId, "amazon_client_id");
|
||||
const clientSecret = await this.integrations.getValue(tenantId, "amazon_client_secret");
|
||||
const refreshToken = await this.integrations.getValue(tenantId, "amazon_refresh_token");
|
||||
|
||||
if (!clientId || !clientSecret || !refreshToken) return null;
|
||||
|
||||
const accessToken = await this.getLwaToken(clientId, clientSecret, refreshToken, tenantId);
|
||||
|
||||
// SP-API Catalog Items endpoint (NA marketplace)
|
||||
const marketplaceId = "ATVPDKIKX0DER"; // US
|
||||
const endpoint = `https://sellingpartnerapi-na.amazon.com/catalog/2022-04-01/items/${asin}?marketplaceIds=${marketplaceId}&includedData=summaries,images,dimensions`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
headers: {
|
||||
"x-amz-access-token": accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
throw new Error(`SP-API error: ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const summary = data?.summaries?.[0];
|
||||
const image = data?.images?.[0]?.images?.[0];
|
||||
const dims = data?.dimensions?.[0]?.package;
|
||||
|
||||
// Convert kg to lb if available
|
||||
let weightLb = 1.5;
|
||||
if (dims?.weight?.value && dims.weight.unit === "KILOGRAMS") {
|
||||
weightLb = Number(dims.weight.value) * 2.20462;
|
||||
} else if (dims?.weight?.value && dims.weight.unit === "POUNDS") {
|
||||
weightLb = Number(dims.weight.value);
|
||||
}
|
||||
|
||||
this.logger.log(`[SP-API] Fetched item: ASIN ${asin} — ${summary?.itemName ?? "unknown"}`);
|
||||
|
||||
return {
|
||||
name: summary?.itemName ?? `Amazon ASIN ${asin}`,
|
||||
price: 0, // Catalog Items API does not return price; use Pricing API separately
|
||||
weightLb: Math.round(weightLb * 100) / 100,
|
||||
imageUrl: image?.link ?? "https://placehold.co/200x200?text=Amazon",
|
||||
asin,
|
||||
brand: summary?.brand ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private detectStore(url: string): string {
|
||||
if (url.includes("amazon.")) return "Amazon";
|
||||
if (url.includes("ebay.")) return "eBay";
|
||||
if (url.includes("ebay.")) return "eBay";
|
||||
if (url.includes("walmart.")) return "Walmart";
|
||||
if (url.includes("target.")) return "Target";
|
||||
if (url.includes("bestbuy.")) return "Best Buy";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SenaeService } from "./senae.service";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule],
|
||||
providers: [SenaeService],
|
||||
exports: [SenaeService],
|
||||
})
|
||||
export class SenaeModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
export interface SenaeDeclarationResult {
|
||||
authNumber: string;
|
||||
declarationId: string;
|
||||
isStub: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SenaeService — C-1
|
||||
* Envía la Declaración Simplificada de Importación (DSI) a la SENAE.
|
||||
* Si las credenciales de integración están configuradas, hace la llamada real.
|
||||
* Si no, genera un número de stub para entorno de desarrollo.
|
||||
*
|
||||
* Docs: §11, §18 — SENAE WebService integration
|
||||
*/
|
||||
@Injectable()
|
||||
export class SenaeService {
|
||||
private readonly logger = new Logger(SenaeService.name);
|
||||
|
||||
constructor(private integrations: IntegrationsService) {}
|
||||
|
||||
async submitDSI(pkg: any, tenantId: string, category: string, agentNotes?: string): Promise<SenaeDeclarationResult> {
|
||||
const endpoint = await this.integrations.getValue(tenantId, "senae_endpoint");
|
||||
const apiKey = await this.integrations.getValue(tenantId, "senae_api_key");
|
||||
const ruc = await this.integrations.getValue(tenantId, "senae_ruc");
|
||||
const agentCode = await this.integrations.getValue(tenantId, "senae_agent_code");
|
||||
|
||||
const declarationId = `DSI-${pkg.trackingId}`;
|
||||
|
||||
// ── Real SENAE WebService call ────────────────────────────────────────────
|
||||
if (endpoint && apiKey && ruc) {
|
||||
try {
|
||||
const payload = {
|
||||
declaracion: {
|
||||
tipo: "DSI",
|
||||
rucDeclarante: ruc,
|
||||
codigoAgente: agentCode ?? null,
|
||||
trackingInterno: pkg.trackingId,
|
||||
descripcion: pkg.description,
|
||||
valorDeclarado: Number(pkg.declaredValue ?? 0),
|
||||
pesoKg: Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0) * 0.453592,
|
||||
categoria: category,
|
||||
fechaEnvio: new Date().toISOString(),
|
||||
notas: agentNotes ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"X-Agent-Code": agentCode ?? "",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
this.logger.error(`[SENAE] DSI submit failed (${res.status}): ${errText}`);
|
||||
// Fall through to stub on error
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const authNumber = data?.autorizacion ?? data?.authNumber ?? data?.numeroAutorizacion;
|
||||
if (authNumber) {
|
||||
this.logger.log(`[SENAE] DSI aprobada — Auth: ${authNumber} — Pkg: ${pkg.trackingId}`);
|
||||
return { authNumber, declarationId, isStub: false, message: `DSI aprobada por SENAE. Autorización: ${authNumber}` };
|
||||
}
|
||||
this.logger.warn(`[SENAE] Respuesta inesperada: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[SENAE] Exception: ${err.message}`);
|
||||
// Fall through to stub
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`[SENAE] Credenciales no configuradas para tenant ${tenantId} — usando stub`);
|
||||
}
|
||||
|
||||
// ── Stub fallback (dev / sin credenciales) ────────────────────────────────
|
||||
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
this.logger.warn(`[SENAE STUB] Auth: ${authNumber} — configure senae_endpoint, senae_api_key, senae_ruc en Integraciones`);
|
||||
return {
|
||||
authNumber,
|
||||
declarationId,
|
||||
isStub: true,
|
||||
message: `DSI generada (STUB — sin conexión real SENAE). Auth: ${authNumber}. Configure las claves SENAE en Admin → Integraciones.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user