Files
accounted/lib/webhooks/public-events.ts
T
Jakob Wennberg d3869e6694 fix(api): register the v1 stamp endpoint scope and derive the webhook event catalogue from one source (#1930)
POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp registered itself
with scope documents:write but had no V1_ENDPOINT_SCOPES entry, and the
wrapper resolves the required scope from that map before it validates the
bearer token, so the route answered NOT_FOUND to every caller. Add the entry,
drop the three phantom entries that had no route (GET openapi.yaml, GET
companies/:companyId, GET companies/:companyId/events), and add a parity test
that pins the scope map to the endpoint registry in both directions, checks
every pattern against an existing route file, and checks every v1 route file
is imported by load-routes.ts.

The webhook event catalogue was hand-copied in three places and had drifted:
the fan-out handler delivered 28 events while the v1 create enum, the OpenAPI
spec, the generated agent skill and the docs page listed 24, so the four
reconciliation.* events could not be subscribed to. lib/webhooks/public-events.ts
is now the single source; the handler set, the Zod enum and the docs section
derive from it, with tests that pin each surface to the catalogue. The PATCH
webhook docs no longer tell agents to delete and recreate a webhook to rotate
its secret: POST .../rotate-secret exists.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:34:27 +02:00

130 lines
4.9 KiB
TypeScript

/**
* The public webhook event catalogue: the single source of truth for which
* CoreEventType values the v1 webhook surface delivers.
*
* Everything that enumerates webhook events derives from this module, so the
* lists cannot drift apart again (they did: the handler delivered 28 events
* while the create schema and the docs page listed 24, so nobody could
* subscribe to the four reconciliation.* events):
*
* 1. lib/webhooks/handler.ts subscribes the fan-out handler to every event.
* 2. app/api/v1/companies/[companyId]/webhooks/route.ts builds the create
* schema's `event_type` enum from it, which is what the OpenAPI spec and
* the generated agent skill (skills/accounted-api) advertise.
* 3. lib/docs/content/webhooks.ts renders its "Event types" section from
* the groups below.
*
* Restricted to the resource-state-change events that are useful to external
* integrations; MCP telemetry events and internal-only flows (event_log
* writes, drafts, deletes) are deliberately excluded.
*
* Adding an event type here is a public-API change: add a changelog entry in
* lib/docs/content/changelog.ts. Additive changes do not bump API_V1_VERSION.
*
* This module must stay a leaf (type-only imports): it is imported by docs
* pages, route modules and the event-bus handler alike.
*/
import type { CoreEventType } from '@/lib/events/types'
export interface PublicWebhookEventEntry {
type: CoreEventType
/** Short docs prose, rendered after the event name on the docs page. */
description?: string
}
export interface PublicWebhookEventGroup {
/** Docs section heading, e.g. "Invoicing". */
title: string
/** Optional note rendered next to the heading (e.g. an extra scope requirement). */
note?: string
events: ReadonlyArray<PublicWebhookEventEntry>
}
export const PUBLIC_WEBHOOK_EVENT_GROUPS = [
{
title: 'Invoicing',
events: [
{ type: 'invoice.created', description: 'draft invoice created' },
{ type: 'invoice.sent', description: 'invoice marked sent (email delivered or external)' },
{ type: 'invoice.paid', description: 'invoice fully paid' },
{ type: 'credit_note.created', description: 'credit note issued' },
],
},
{
title: 'AP / suppliers',
events: [
{ type: 'supplier.created' },
{ type: 'supplier_invoice.registered' },
{ type: 'supplier_invoice.approved' },
{ type: 'supplier_invoice.paid' },
{ type: 'supplier_invoice.credited' },
{ type: 'supplier_invoice.uncredited', description: 'credit reversal' },
],
},
{
title: 'Customers',
events: [{ type: 'customer.created' }],
},
{
title: 'Bookkeeping',
events: [
{ type: 'journal_entry.committed', description: 'voucher posted (immutable from this point)' },
{ type: 'journal_entry.reversed', description: 'storno entry posted' },
{ type: 'journal_entry.corrected', description: 'rättelse via `correctEntry` (BFL 5 kap 5 §)' },
],
},
{
title: 'Transactions',
events: [
{ type: 'transaction.categorized', description: 'bank transaction assigned an account + tax code' },
{ type: 'transaction.reconciled', description: 'transaction matched to a posted entry' },
],
},
{
title: 'Reconciliation',
events: [
{
type: 'reconciliation.matched',
description: 'an outside item (bank row or skattekonto row) linked to a journal entry',
},
{ type: 'reconciliation.unmatched', description: 'a reconciliation link removed' },
{ type: 'reconciliation.signed_off', description: 'an account signed off as reconciled through a date' },
{ type: 'reconciliation.reopened', description: 'a sign-off reopened' },
],
},
{
title: 'Periods',
events: [
{ type: 'period.locked', description: 'fiscal period closed for writes' },
{ type: 'period.unlocked', description: 'fiscal period reopened' },
{ type: 'period.year_closed', description: 'full year-end procedure complete' },
],
},
{
title: 'Payroll',
note: 'requires `payroll:read` scope alongside `webhooks:manage`',
events: [
{ type: 'salary_run.created' },
{ type: 'salary_run.approved' },
{ type: 'salary_run.booked', description: 'journal entries posted' },
{ type: 'agi.generated', description: 'AGI XML produced' },
],
},
{
title: 'Documents',
events: [{ type: 'document.uploaded' }],
},
] as const satisfies ReadonlyArray<PublicWebhookEventGroup>
/** Union of every deliverable event type, narrowed from the catalogue. */
export type PublicWebhookEventType = (typeof PUBLIC_WEBHOOK_EVENT_GROUPS)[number]['events'][number]['type']
/**
* Flat, ordered list of every deliverable event type. Pass straight to
* `z.enum(...)` or `new Set(...)`.
*/
export const PUBLIC_WEBHOOK_EVENTS: ReadonlyArray<PublicWebhookEventType> = PUBLIC_WEBHOOK_EVENT_GROUPS.flatMap(
(group) => group.events.map((event) => event.type),
)