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:
- Audit events — Every mutating database operation is captured by Prisma middleware and written to the
LoggedActiontable (append-only, immutable). - Domain events — Status transitions on Invoice and Expense trigger synchronous side effects (GL transactions, PDF generation, email delivery) within the same request/transaction boundary.
- 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 |
| Puppeteer renders → uploaded to R2 → URL saved to invoice.pdfUrl | |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 LoggedActionrecords 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)