Skip to main content

Event Schema Documentation

Event Schema Documentation

Project: Bilko Version: 0.1 Date: 2026-02-23 Author: Platform Architect Status: Draft Reviewers: Tech Lead, Security Reviewer

Document History

Version Date Author Changes
0.1 2026-02-23 Platform Architect Initial draft

1. Event Architecture Overview

Bilko is a modular monolith — there is no message broker (no Kafka, no RabbitMQ). Events are handled in two ways:

  1. Audit events — Every mutating database operation is captured by Prisma middleware and written to the LoggedAction table (append-only, immutable).
  2. Domain events — Status transitions on Invoice and Expense trigger synchronous side effects (GL transactions, PDF generation, email delivery) within the same request/transaction boundary.
  3. Webhooks — Phase 2 feature: HTTP POST callbacks to registered URLs on Invoice status changes.
graph LR
    subgraph "Request Lifecycle"
        Client["Next.js Frontend"]
        Handler["Route Handler"]
        Service["Service Layer"]
        Engine["Accounting Engine"]
    end

    subgraph "Persistence"
        DB["PostgreSQL\n(Prisma)"]
        Audit["LoggedAction\n(Append-Only)"]
    end

    subgraph "Side Effects (Synchronous)"
        GL["GL Transaction\ndebit + credit"]
        PDF["Puppeteer PDF\nR2 storage"]
        Email["SendGrid\nemail delivery"]
    end

    subgraph "Webhooks (Phase 2)"
        WH["HTTP POST\nregistered URLs"]
    end

    Client --> Handler
    Handler --> Service
    Service --> Engine
    Engine --> GL
    Engine --> DB
    Service --> PDF
    Service --> Email
    DB -->|Prisma middleware| Audit
    Service -.->|Phase 2| WH

    style Audit fill:#dc2626,color:#fff
    style DB fill:#336791,color:#fff
    style WH stroke-dasharray:5 5

2. Audit Trail — LoggedAction

Purpose

LoggedAction is the immutable audit trail required for financial compliance (GDPR, Serbian/Bosnian/Croatian accounting regulations). Every INSERT, UPDATE, and DELETE on audited tables is recorded.

Retention: 7 years (regulatory requirement). After 7 years, archived to cold storage. NEVER deleted.

Prisma Schema

model LoggedAction {
  id              String   @id @default(uuid())
  organizationId  String
  tableName       String                        // Which model was mutated
  userId          String?                       // Who performed the action (null = system job)
  action          String                        // INSERT | UPDATE | DELETE
  rowId           String?                       // PK of the affected row
  rowData         Json?                         // Snapshot for DELETE actions
  changedFields   Json?                         // Only the changed fields for UPDATE actions
  clientIp        String?                       // Hashed client IP address
  applicationName String   @default("bilko-api")
  createdAt       DateTime @default(now())

  organization    Organization @relation(fields: [organizationId], references: [id])

  @@index([organizationId, tableName, createdAt])
  @@index([organizationId, userId])
}

Implementation — Prisma Middleware

// src/lib/prisma.ts
import { AsyncLocalStorage } from 'async_hooks'

export const requestContext = new AsyncLocalStorage<{
  userId?: string
  organizationId?: string
  clientIp?: string
}>()

const AUDITED_MODELS = [
  'Invoice',
  'InvoiceItem',
  'Expense',
  'Transaction',
  'Contact',
  'User',
  'Organization',
  'BankAccount',
  'BankTransaction',
  'Account',
]

prisma.$use(async (params, next) => {
  const result = await next(params)

  if (
    AUDITED_MODELS.includes(params.model ?? '') &&
    ['create', 'update', 'delete', 'upsert'].includes(params.action)
  ) {
    const ctx = requestContext.getStore()

    const actionMap: Record<string, string> = {
      create: 'INSERT',
      update: 'UPDATE',
      delete: 'DELETE',
      upsert: 'UPSERT',
    }

    await prisma.loggedAction.create({
      data: {
        organizationId: ctx?.organizationId ?? 'SYSTEM',
        tableName: params.model!,
        userId: ctx?.userId ?? null,
        action: actionMap[params.action],
        rowId: result?.id ?? params.args?.where?.id ?? null,
        rowData: params.action === 'delete' ? params.args.where : null,
        changedFields: params.action === 'update' ? params.args.data : null,
        clientIp: ctx?.clientIp ?? null,
        applicationName: 'bilko-api',
      },
    })
  }

  return result
})

Audited Tables and Actions

Table INSERT UPDATE DELETE Notes
Invoice Yes Yes No Invoices are never hard-deleted
InvoiceItem Yes Yes Yes Only while invoice is draft
Expense Yes Yes No Expenses are never hard-deleted
Transaction Yes No No GL entries are immutable once created
Contact Yes Yes Yes Soft-delete preferred
User Yes Yes No Users deactivated, not deleted
Organization Yes Yes No Orgs deactivated, not deleted
Account Yes Yes No Chart of accounts entries
BankAccount Yes Yes No Bank account registrations
BankTransaction Yes Yes No Imported bank statement rows

LoggedAction TypeScript Interface

interface LoggedAction {
  id: string                    // UUID
  organizationId: string        // Tenant scope
  tableName: string             // Model name (e.g. "Invoice")
  userId: string | null         // null = system cron job
  action: 'INSERT' | 'UPDATE' | 'DELETE' | 'UPSERT'
  rowId: string | null          // UUID of affected row
  rowData: Record<string, unknown> | null   // Full snapshot on DELETE
  changedFields: Record<string, unknown> | null  // Only changed fields on UPDATE
  clientIp: string | null       // SHA-256 hashed IP — NEVER plain IP
  applicationName: string       // Always "bilko-api"
  createdAt: string             // ISO 8601 UTC
}

Example: Invoice Status Update

{
  "id": "9f3a1bc2-...",
  "organizationId": "org_abc123",
  "tableName": "Invoice",
  "userId": "usr_def456",
  "action": "UPDATE",
  "rowId": "inv_xyz789",
  "rowData": null,
  "changedFields": {
    "status": "sent",
    "sentAt": "2026-02-23T10:30:00.000Z",
    "pdfUrl": "https://r2.bilko.io/invoices/org_abc123/INV-2026-001.pdf"
  },
  "clientIp": "a3f5c8d1e2b4...",
  "applicationName": "bilko-api",
  "createdAt": "2026-02-23T10:30:01.123Z"
}

Example: Transaction Created (Invoice Sent)

{
  "id": "2e8b4f1a-...",
  "organizationId": "org_abc123",
  "tableName": "Transaction",
  "userId": "usr_def456",
  "action": "INSERT",
  "rowId": "txn_ghi012",
  "rowData": null,
  "changedFields": null,
  "clientIp": "a3f5c8d1e2b4...",
  "applicationName": "bilko-api",
  "createdAt": "2026-02-23T10:30:01.456Z"
}

Example: System Job (Overdue Invoice)

{
  "id": "7c2d9e4b-...",
  "organizationId": "org_abc123",
  "tableName": "Invoice",
  "userId": null,
  "action": "UPDATE",
  "rowId": "inv_abc001",
  "rowData": null,
  "changedFields": {
    "status": "overdue"
  },
  "clientIp": null,
  "applicationName": "bilko-api",
  "createdAt": "2026-02-23T00:05:01.789Z"
}

3. Domain Events — Invoice Status Transitions

Invoice status transitions trigger synchronous side effects in the same request. These are not queued — they execute within the handler or are awaited before response.

Status Machine

stateDiagram-v2
    [*] --> draft : POST /invoices
    draft --> sent : POST /invoices/:id/send
    sent --> viewed : Tracking pixel fired
    sent --> paid : POST /invoices/:id/mark-paid
    viewed --> paid : POST /invoices/:id/mark-paid
    sent --> overdue : Cron job (dueDate < today)
    viewed --> overdue : Cron job (dueDate < today)
    overdue --> paid : POST /invoices/:id/mark-paid
    draft --> cancelled : DELETE /invoices/:id
    sent --> cancelled : POST /invoices/:id/cancel
    viewed --> cancelled : POST /invoices/:id/cancel
    overdue --> cancelled : POST /invoices/:id/cancel

Status Transition Event Payloads

invoice.created (draft)

Triggered: POST /api/v1/invoices

Side Effect Action
LoggedAction INSERT on Invoice
Invoice number Auto-generated INV-YYYY-NNN
GL Transaction None
interface InvoiceCreatedEvent {
  invoiceId: string
  organizationId: string
  invoiceNumber: string     // e.g. "INV-2026-001"
  contactId: string
  totalAmount: string       // NUMERIC(19,4) as string
  currency: string          // ISO 4217 e.g. "RSD"
  dueDate: string           // ISO 8601 date
  status: 'draft'
  createdBy: string         // userId
  createdAt: string         // ISO 8601 UTC
}

invoice.sent

Triggered: POST /api/v1/invoices/:id/send

Side Effect Action
LoggedAction UPDATE on Invoice (status, sentAt, pdfUrl)
GL Transaction INSERT: Debit 1200 Accounts Receivable / Credit 4000 Revenue
PDF Puppeteer renders → uploaded to R2 → URL saved to invoice.pdfUrl
Email SendGrid: invoice PDF to contact.email with tracking pixel
interface InvoiceSentEvent {
  invoiceId: string
  organizationId: string
  invoiceNumber: string
  contactId: string
  contactEmail: string
  totalAmount: string           // NUMERIC(19,4) as string
  baseAmount: string            // totalAmount * exchangeRate (locked at invoiceDate)
  currency: string
  exchangeRate: string          // Locked at invoiceDate — NEVER changes
  pdfUrl: string                // R2 URL: invoices/{orgId}/INV-YYYY-NNN.pdf
  transactionId: string         // GL transaction ID created
  debitAccountId: string        // 1200 Accounts Receivable
  creditAccountId: string       // 4000 Revenue
  sentBy: string                // userId
  sentAt: string                // ISO 8601 UTC
}

GL Transaction created:

{
  "id": "txn_abc123",
  "organizationId": "org_abc123",
  "debitAccountId": "acc_1200",
  "creditAccountId": "acc_4000",
  "amount": "1250.0000",
  "currency": "RSD",
  "baseAmount": "10.6400",
  "exchangeRate": "0.0085",
  "description": "Invoice INV-2026-001 sent",
  "referenceType": "INVOICE",
  "referenceId": "inv_xyz789",
  "date": "2026-02-23",
  "locked": false,
  "createdAt": "2026-02-23T10:30:01.000Z"
}

invoice.viewed

Triggered: GET /api/v1/invoices/track/:trackingToken (tracking pixel)

Side Effect Action
LoggedAction UPDATE on Invoice (status, viewedAt)
GL Transaction None
Email None
interface InvoiceViewedEvent {
  invoiceId: string
  organizationId: string
  invoiceNumber: string
  trackingToken: string
  viewedAt: string    // ISO 8601 UTC
}

invoice.paid

Triggered: PATCH /api/v1/invoices/:id/mark-paid

Side Effect Action
LoggedAction UPDATE on Invoice (status, paidAt, paidAmount, paymentRef)
GL Transaction INSERT: Debit 1000 Bank Account / Credit 1200 Accounts Receivable
Email None (future: payment receipt)
Webhook (Phase 2) POST to registered webhook URL
interface InvoicePaidEvent {
  invoiceId: string
  organizationId: string
  invoiceNumber: string
  paidAmount: string          // NUMERIC(19,4) as string
  currency: string
  paymentReference: string    // Bank reference number
  bankAccountId: string       // Which bank account received payment
  transactionId: string       // GL transaction ID created
  debitAccountId: string      // 1000 Bank Account
  creditAccountId: string     // 1200 Accounts Receivable
  markedPaidBy: string        // userId
  paidAt: string              // ISO 8601 UTC
}

invoice.overdue

Triggered: Cron job overdue-invoices (daily 00:05 UTC)

Side Effect Action
LoggedAction UPDATE on Invoice (status → overdue)
GL Transaction None
Email None (future: overdue reminder)
userId null (system job)
interface InvoiceOverdueEvent {
  invoiceId: string
  organizationId: string
  invoiceNumber: string
  dueDate: string         // Date that has passed
  daysPastDue: number     // dueDate diff from today
  markedOverdueAt: string // ISO 8601 UTC
}

invoice.cancelled

Triggered: POST /api/v1/invoices/:id/cancel

Side Effect Action
LoggedAction UPDATE on Invoice (status → cancelled)
GL Transaction Reversal — if invoice was sent, creates offsetting transaction
PDF Retained in R2 (not deleted)
interface InvoiceCancelledEvent {
  invoiceId: string
  organizationId: string
  invoiceNumber: string
  previousStatus: 'draft' | 'sent' | 'viewed' | 'overdue'
  reversalTransactionId: string | null  // null if cancelled from draft
  cancelledBy: string                    // userId
  cancelledAt: string                    // ISO 8601 UTC
}

Reversal GL Transaction (if invoice was sent):

If previousStatus was 'sent' or 'viewed':
  Debit 4000 Revenue / Credit 1200 Accounts Receivable
  (reversal of the original sent transaction)

4. Domain Events — Expense Status Transitions

Status Machine

stateDiagram-v2
    [*] --> pending : POST /expenses
    pending --> approved : POST /expenses/:id/approve
    pending --> rejected : POST /expenses/:id/reject
    approved --> paid : POST /expenses/:id/mark-paid

Status Transition Event Payloads

expense.created

Triggered: POST /api/v1/expenses

Side Effect Action
LoggedAction INSERT on Expense
GL Transaction None
Receipt scan ClamAV virus scan on attachment
interface ExpenseCreatedEvent {
  expenseId: string
  organizationId: string
  expenseNumber: string       // EXP-YYYY-NNN
  amount: string              // NUMERIC(19,4) as string
  currency: string
  categoryAccountId: string   // 5xxx Expense account
  receiptUrl: string | null
  submittedBy: string         // userId
  createdAt: string
}

expense.approved

Triggered: POST /api/v1/expenses/:id/approve Requires role: admin or owner

Side Effect Action
LoggedAction UPDATE on Expense (status, approvedBy, approvedAt)
GL Transaction INSERT: Debit 5xxx Expense / Credit 2000 Accounts Payable
interface ExpenseApprovedEvent {
  expenseId: string
  organizationId: string
  expenseNumber: string
  amount: string              // NUMERIC(19,4) as string
  currency: string
  transactionId: string       // GL transaction ID
  debitAccountId: string      // 5xxx Expense account (category-specific)
  creditAccountId: string     // 2000 Accounts Payable
  approvedBy: string          // userId (admin or owner)
  approvedAt: string
}

expense.rejected

Triggered: POST /api/v1/expenses/:id/reject Requires role: admin or owner

Side Effect Action
LoggedAction UPDATE on Expense (status, rejectedBy, rejectedAt, rejectReason)
GL Transaction None
interface ExpenseRejectedEvent {
  expenseId: string
  organizationId: string
  expenseNumber: string
  rejectReason: string
  rejectedBy: string
  rejectedAt: string
}

expense.paid

Triggered: POST /api/v1/expenses/:id/mark-paid Requires role: admin or owner

Side Effect Action
LoggedAction UPDATE on Expense (status, paidAt)
GL Transaction INSERT: Debit 2000 Accounts Payable / Credit 1000 Bank Account
interface ExpensePaidEvent {
  expenseId: string
  organizationId: string
  expenseNumber: string
  amount: string
  currency: string
  bankAccountId: string       // Bank account that paid
  transactionId: string       // GL transaction ID
  debitAccountId: string      // 2000 Accounts Payable
  creditAccountId: string     // 1000 Bank Account
  paidBy: string
  paidAt: string
}

5. Domain Events — Banking

bank.transaction.imported

Triggered: POST /api/v1/banking/accounts/:id/import (CSV upload)

Side Effect Action
LoggedAction INSERT on BankTransaction (per row)
ClamAV scan Virus check on uploaded CSV
Auto-match Attempt to match against open GL transactions
interface BankTransactionImportedEvent {
  organizationId: string
  bankAccountId: string
  importedCount: number
  matchedCount: number       // Auto-matched to GL transactions
  unmatchedCount: number     // Require manual reconciliation
  importedBy: string
  importedAt: string
}

bank.transaction.reconciled

Triggered: POST /api/v1/banking/transactions/:id/reconcile

Side Effect Action
LoggedAction UPDATE on BankTransaction (reconciled, glTransactionId)
LoggedAction UPDATE on Transaction (locked = true)
interface BankTransactionReconciledEvent {
  bankTransactionId: string
  organizationId: string
  glTransactionId: string     // Linked GL Transaction
  matchScore: number          // 0-100 confidence score
  matchType: 'AUTO' | 'MANUAL'
  reconciledBy: string        // userId (SYSTEM for auto-match)
  reconciledAt: string
}

6. Webhook Events (Phase 2)

Webhooks are outbound HTTP POST callbacks to URLs registered per organization. They are not implemented in Phase 1 — documented here for future implementation.

Registration

POST /api/v1/organizations/webhooks
{
  "url": "https://partner.example.com/bilko-webhook",
  "secret": "whsec_...",
  "events": ["invoice.sent", "invoice.paid", "invoice.cancelled"]
}

Delivery

  • Method: HTTP POST
  • Content-Type: application/json
  • Signature header: X-Bilko-Signature: sha256=<HMAC-SHA256(payload, secret)>
  • Timeout: 10 seconds
  • Retries: 3× exponential backoff (1s, 4s, 16s) then DLQ
  • Retry trigger: Non-2xx response or timeout

Webhook Envelope

{
  "id": "wh_01HX7M2K5N3P4Q5R6S7T8V9W0",
  "type": "invoice.paid",
  "organizationId": "org_abc123",
  "apiVersion": "2026-02-23",
  "createdAt": "2026-02-23T10:30:01.000Z",
  "data": {
    "invoiceId": "inv_xyz789",
    "invoiceNumber": "INV-2026-001",
    "paidAmount": "1250.0000",
    "currency": "RSD",
    "paidAt": "2026-02-23T10:30:00.000Z"
  }
}

Supported Webhook Events (Phase 2 Scope)

Event Type Trigger
invoice.sent Invoice status → sent
invoice.viewed Invoice tracking pixel fired
invoice.paid Invoice marked paid
invoice.overdue Invoice auto-marked overdue by cron
invoice.cancelled Invoice cancelled
expense.approved Expense approved
expense.paid Expense marked paid

Signature Verification (Consumer Code)

import { createHmac } from 'crypto'

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected = createHmac('sha256', secret)
    .update(payload)
    .digest('hex')
  return `sha256=${expected}` === signature
}

7. GL Transaction Event Mapping

Every financial state transition that creates a GL transaction follows this pattern:

Event Debit Account Credit Account Reference Type
Invoice sent 1200 Accounts Receivable 4000 Revenue INVOICE
Invoice paid 1000 Bank Account 1200 Accounts Receivable INVOICE
Invoice cancelled (was sent) 4000 Revenue 1200 Accounts Receivable INVOICE_REVERSAL
Expense approved 5xxx Expense Account 2000 Accounts Payable EXPENSE
Expense paid 2000 Accounts Payable 1000 Bank Account EXPENSE

Transaction immutability rules:

  • Once a Transaction is created, it is never updated or deleted
  • After reconciliation (locked = true), even corrections require a new offsetting transaction
  • LoggedAction records INSERT on every Transaction creation

8. Event Naming Conventions

Component Rule Bilko Examples
Domain lowercase noun invoice, expense, bank
Entity singular, lowercase invoice, expense, transaction
Action past-tense verb created, sent, paid, approved, reconciled
Full event {domain}.{action} invoice.sent, expense.approved

9. Monitoring & Observability

Signal What to Watch Alert Threshold
LoggedAction INSERT failures Prisma middleware error Any failure → P1
GL Transaction creation failures Accounting engine error Any failure → P1
PDF generation failures Puppeteer error > 3 failures / 5min → P2
Email delivery failures SendGrid 4xx/5xx > 5 failures / 5min → P2
Webhook delivery failures (Phase 2) Non-2xx after retries → DLQ DLQ depth > 10 → P3

Log pattern for every domain event:

logger.info('invoice.sent', {
  invoiceId,
  organizationId,
  transactionId,
  pdfUrl,
  durationMs: Date.now() - startTime,
})

10. Testing Domain Events

Unit Test Pattern

it('should create GL transaction when invoice is sent', async () => {
  const invoice = await createDraftInvoice(orgId)

  await invoicesService.sendInvoice(invoice.id, userId)

  const transaction = await prisma.transaction.findFirst({
    where: { referenceId: invoice.id, referenceType: 'INVOICE' }
  })

  expect(transaction).toBeTruthy()
  expect(transaction!.debitAccountId).toBe(ACCOUNTS_RECEIVABLE_ID)
  expect(transaction!.creditAccountId).toBe(REVENUE_ACCOUNT_ID)
  expect(transaction!.amount).toBe(invoice.totalAmount)
})

it('should write LoggedAction on invoice status change', async () => {
  const invoice = await createDraftInvoice(orgId)

  await invoicesService.sendInvoice(invoice.id, userId)

  const auditEntry = await prisma.loggedAction.findFirst({
    where: { tableName: 'Invoice', rowId: invoice.id, action: 'UPDATE' },
    orderBy: { createdAt: 'desc' },
  })

  expect(auditEntry).toBeTruthy()
  expect(auditEntry!.changedFields).toMatchObject({ status: 'sent' })
  expect(auditEntry!.userId).toBe(userId)
})

Integration Test: Full Invoice Lifecycle

POST /api/v1/invoices → 201 draft created
POST /api/v1/invoices/:id/send → 200 sent
  ↳ Assert: LoggedAction INSERT (Invoice, status=sent)
  ↳ Assert: Transaction created (Debit 1200, Credit 4000)
  ↳ Assert: PDF URL set on invoice
  ↳ Assert: SendGrid sandbox received email

GET /api/v1/invoices/track/:token → 200 viewed
  ↳ Assert: invoice.status = 'viewed'

PATCH /api/v1/invoices/:id/mark-paid → 200 paid
  ↳ Assert: LoggedAction INSERT (Invoice, status=paid)
  ↳ Assert: Second Transaction (Debit 1000, Credit 1200)
  ↳ Assert: Total LoggedAction rows for invoice = 3 (created + sent + paid)