Ir al contenido

3. Hooks de negocio

Con los datos preparados, toca la lógica de negocio. Usaremos tres hooks:

Hook Qué hace
salesInvoice.posted Acumula puntos al asentar una factura
documents.onBeforeCreate('SalesInvoice') Valida el canje antes de crear la factura
partners.list.afterFetch Marca clientes VIP en el listado

Necesitamos drizzle-orm para las queries (el template ya lo trae):

import type { PluginContext, HookContext, ListFetchContext } from '@openfactu/plugin-sdk';
import { eq, sql } from 'drizzle-orm';

El evento salesInvoice.posted se dispara al asentar la factura (no al crear el borrador). Regla: 1 punto por cada 10 € de total.

export const init = async ({ migration, hooks, documents, db }: PluginContext) => {
// ... migraciones de la parte 2 ...
hooks.register('salesInvoice.posted', async (ctx: HookContext) => {
const invoice = ctx.data;
const points = Math.floor(Number(invoice.total ?? 0) / 10);
if (points <= 0) return;
const tenantDb = await db.forTenant(ctx.tenantId);
// Historial: nuestra tabla no está en db.schema → SQL parametrizado
await tenantDb.execute(sql`
INSERT INTO fidelizacion_movimientos (id, partner_id, invoice_id, points, reason)
VALUES (gen_random_uuid(), ${invoice.partnerId}, ${invoice.id}, ${points}, 'Compra')
`);
// Saldo: tabla core → query Drizzle con db.schema
await tenantDb
.update(db.schema.businessPartners)
.set({ loyalty_points: sql`COALESCE(loyalty_points, 0) + ${points}` })
.where(eq(db.schema.businessPartners.id, invoice.partnerId));
});
};

Todo handler recibe un HookContext: ctx.tenantId (la empresa donde ocurre), ctx.db (cliente Drizzle del tenant), ctx.data (la factura) y ctx.user.

El segundo estilo de hook: documents.onBeforeCreate recibe el nombre de tabla en PascalCase. Si el handler lanza una excepción, la operación se aborta y el usuario ve el error:

documents.onBeforeCreate('SalesInvoice', async (ctx: HookContext) => {
const canje = Number(ctx.data.customFields?.puntos_canjeados ?? 0);
if (canje <= 0) return;
const tenantDb = await db.forTenant(ctx.tenantId);
const [partner] = await tenantDb
.select()
.from(db.schema.businessPartners)
.where(eq(db.schema.businessPartners.id, ctx.data.partnerId));
const saldo = Number(partner?.loyalty_points ?? 0);
if (canje > saldo) {
throw new Error(`El cliente solo tiene ${saldo} puntos (intenta canjear ${canje})`);
}
});
hooks.register('salesInvoice.beforeCreate', handler);
  • Nombre de evento en camelCase
  • Vale para todo el catálogo: documentos, listados, contabilidad, pagos, RRHH, logística

Ambas líneas registran exactamente el mismo hook. Para la fase posted y el resto de eventos (pagos, períodos, envíos…) solo existe hooks.register. Catálogo completo en Catálogo de hooks.

Los eventos *.list.afterFetch permiten modificar las filas de un listado justo antes de devolverlas al frontend. El contexto es un ListFetchContext, cuyo campo rows se puede mutar:

hooks.register('partners.list.afterFetch', async (ctx: HookContext) => {
const { rows } = ctx as ListFetchContext<any>;
for (const row of rows) {
row.vip = Number(row.loyalty_points ?? 0) >= 100;
}
});

Solo existen items.list.afterFetch y partners.list.afterFetch.

  1. Crea una factura de venta de 250 € a un cliente y asiéntala.
  2. Abre la ficha del cliente: Puntos de fidelidad debería mostrar 25.
  3. Crea otra factura al mismo cliente con Puntos canjeados = 9999: el ERP debería rechazarla con el mensaje de saldo insuficiente.