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';Acumular puntos al asentar una factura
Sección titulada «Acumular puntos al asentar una factura»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.
Validar antes de crear
Sección titulada «Validar antes de crear»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})`); }});Dos estilos de hook, un solo sistema
Sección titulada «Dos estilos de hook, un solo sistema»hooks.register('salesInvoice.beforeCreate', handler);- Nombre de evento en camelCase
- Vale para todo el catálogo: documentos, listados, contabilidad, pagos, RRHH, logística
documents.onBeforeCreate('SalesInvoice', handler);- Nombre de tabla en PascalCase
- Solo cubre
beforeCreate/afterCreatede documentos - Atajo cómodo cuando solo interceptas creación de documentos
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.
Enriquecer el listado de clientes
Sección titulada «Enriquecer el listado de clientes»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.
Checkpoint
Sección titulada «Checkpoint»- Crea una factura de venta de 250 € a un cliente y asiéntala.
- Abre la ficha del cliente: Puntos de fidelidad debería mostrar 25.
- Crea otra factura al mismo cliente con Puntos canjeados = 9999: el ERP debería rechazarla con el mensaje de saldo insuficiente.