Accounting Integration Backend Documentation
Backend architecture, data model, credentials, webhooks, the change feed, and operational behavior for the accounting integration module.
Accounting Integration Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/integration/{integration,integration-core,integration-worker}.module.ts | Composition |
| Schema | packages/db/src/schema/integration.ts | Every table, read in full |
| Keyring | apps/api/src/modules/integration/keyring/integration-keyring.service.ts, apps/api/src/utils/integration/integration-keyring.util.ts | Key derivation |
| Auth | apps/api/src/modules/integration/auth/*.ts | Guard, keys service, rate limiter, decorator |
| Webhooks | apps/api/src/modules/integration/webhooks/*.ts | Endpoints, dispatch, delivery, fan-out |
| Feed | apps/api/src/modules/integration/feed/integration-feed.service.ts | Cursor and epoch mechanics |
| Restore | apps/api/src/modules/integration/restore/integration-restore-reconciler.service.ts | Full file read |
| Queue | packages/jobs/src/index.ts (IntegrationWebhookJob, QueueName.INTEGRATION_WEBHOOKS) | Job contract |
| Env | apps/api/src/config/env.validation.ts | INTEGRATION_SECRET_KEYS, INTEGRATION_WEBHOOK_ALLOW_PRIVATE_TARGETS, ACCOUNTING_INTEGRATION_ENABLED |
| Installation switch | apps/api/src/common/feature-flags/accounting-integration.ts | Full file read — the flag helper, the guard, and the decorator |
2. Backend Scope and Boundaries
Owns
- Accounting client registration, status, keys, and webhook endpoints (
clients,auth,webhooks). - The keyring: deriving every secret this module keeps from
INTEGRATION_SECRET_KEYSvia HKDF (keyring). - The change feed's cursor and epoch semantics (
feed). - Post-restore credential invalidation (
restore). - The accounting application's own read/write API — fee catalogue, structures, modifiers, students, obligations, payments, mappings, change feed (
fees-api). - The accounting application's read/write API for salary — catalogue, structures, employees, periods, payslips, payments (
payroll-api), gated by thepayroll:*scopes. - The accounting application's read/write API for assets — types, categories, the register, accounting status (
assets-api), gated by theassets:*scopes. - Outbound webhook delivery, retry, and recovery, on
QueueName.INTEGRATION_WEBHOOKS. - The installation switch (
ACCOUNTING_INTEGRATION_ENABLED, defaulttrue): whether this school uses the accounting application at all. It is a product decision, orthogonal toINTEGRATION_SECRET_KEYSbeing configured (a technical state,INTEGRATION_NOT_CONFIGURED) — the two compose, and the switch is checked first. Off answers 404 (ACCOUNTING_INTEGRATION_DISABLED) on the integration API, the fees, salary, and asset sync/mapping/inbound-events admin routes, and the client/key/webhook-creating admin actions, and stops the change feed and webhook fan-out from producing anything new. It never stops credential revocation or the restore reconciler.
Does Not Own
- Fee calculation, obligations, payments themselves — owned by the
feesmodule;fees-apireads/writes through it, never around it (payment intake dispatches intoFeeSyncIntakeService, owned byfees). - Salary calculation, assignments, periods, payslips themselves — owned by the
salarymodule;payroll-apireads through it and writes payment state only throughsalary's own inbound event path. Granting or revoking apayroll:*scope on a client needsSalarySync_UPDATE(identity/bank scopes additionally needStaffSalary_READ) on the granting admin —assertScopeGrantinIntegrationClientsAdminServicechecks every scope added or removed in the same request, and every scope a client carries when an API key is issued for it or a suspended client is re-activated (holding a key to a scope set is holding those scopes);INTEGRATION_SCOPE_GRANT_FORBIDDENotherwise, superadmin exempt. - The asset register and its lifecycle — owned by the
assetsmodule;assets-apireads through it and writes only the accounting-owned fields (accountingStatus,accountingReference,accountingBookValuePaisa) throughassets's own inbound event path. Granting or revoking anassets:*scope needsAssetSync_UPDATEon the granting admin, checked the same way. - Notification delivery mechanics — uses the shared
NotificationService/OutboxService. - Backup and restore mechanics —
IntegrationRestoreReconcileris CALLED byBackupRestoreProcessor.finish(), owned bybackup.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Whether a key authenticates | integration_client_keys.status/expires_at + integration_clients.status | Checked together in usable() on every request |
| A webhook's current signing secret | integration_webhook_endpoints.secret_ciphertext, sealed under the keyring | Decrypted only at send time, never returned by any read route |
| The change feed's current position | integration_feed_state.epoch | A restore or a detected transaction-id regression starts a new one |
| What the accounting app last reported | fee_external_transactions.latest_* (Fees module) | — |
3. Module Composition
| Module | Type | Controllers | Providers | Responsibility |
|---|---|---|---|---|
IntegrationModule | Aggregate | — | Imports core + admin + fees-api | Root composition, ADMIN_MODULES entry for IntegrationClientsAdminModule, mounts fees-api |
IntegrationCoreModule | Shared services | None | Keyring, Keys, RateLimiter, ApiKeyGuard, ClientsService, FeedService, NoticesService, WebhookDispatch/Endpoints/Fanout/Delivery, RestoreReconciler | Declares no controller; imported by admin, integration (fees-api), and worker leaves |
IntegrationClientsAdminModule | Leaf, admin | IntegrationClientsController, IntegrationClientKeysController, IntegrationWebhooksController | Read/admin services, mapper | /api/integration-clients, FeeIntegrations_*, superadmin-granted |
IntegrationFeesApiModule | Leaf, external | IntegrationFeesCatalogController, ...MappingsController, ...MetaController, ...ObligationsController, ...PaymentsController, ...StudentsController | Per-area read/write services | /api/integration/fees/**, API-key authenticated, @Public() to the staff guard |
IntegrationWorkerModule | Worker-only | None | Webhook delivery processor | Composed OUTSIDE the fast runtime profile — importing it starts a BullMQ worker on QueueName.INTEGRATION_WEBHOOKS |
4. File and Directory Map
apps/api/src/modules/integration/
integration.module.ts, integration-core.module.ts, integration-worker.module.ts
clients/ IntegrationClientsService (the shared client CRUD/status service),
the admin read/admin services, mapper, three controllers
auth/ integration-api-key.guard, integration-keys.service,
integration-rate-limiter.service, integration-endpoint.decorator, integration-principal
keyring/ integration-keyring.service
webhooks/ integration-webhook-endpoints/dispatch/delivery/fanout.service,
integration-notices.service, integration-webhooks.processor, pinned-webhook-transport
feed/ integration-feed.service
restore/ integration-restore-reconciler.service
fees-api/ integration-fees-{catalog,students,structures,obligations,payments,mappings,meta}*.ts,
integration-fees.mapper, integration-fees-keyset.util (+dto)
apps/api/src/utils/integration/
api-key.util.ts token generation, hashing, constant-time comparison
secret-box.util.ts AES-256-GCM seal/open for webhook secrets
integration-keyring.util.ts HKDF derivation, keyring parsing
webhook-signature.util.ts HMAC-SHA256 sign/verify
webhook-target.util.ts URL validation
change-cursor.util.ts the change-feed cursor codec
canonical-json.util.ts deterministic JSON for payload hashing5. Data Model
5.1 Schema Source
packages/db/src/schema/integration.ts
integrationClients, integrationClientKeys, integrationWebhookEndpoints,
integrationChangeEvents, integrationFeedState, integrationWebhookDeliveries5.2 Tables
integration_clients
| Column | Type | Notes |
|---|---|---|
name | text | unique lower(name), trimmed 1–120 |
status | text | active | suspended | revoked |
scopes | text[] | CHECK: subset of INTEGRATION_SCOPES, cardinality ≥ 1 |
rate_limit_per_minute | integer | 1–6000, default 120 |
last_used_at | timestamptz | Written at most once/minute by any authenticated key of this client |
revoked_at/revoked_by_user_id | — | CHECK: pair with status = 'revoked' |
version | integer | Optimistic concurrency |
integration_client_keys
| Column | Type | Notes |
|---|---|---|
client_id | FK, RESTRICT | — |
key_prefix | text | unique, CHECK ^[0-9a-f]{16}$ — the lookup id |
secret_hash | text | CHECK ^[0-9a-f]{64}$ — HMAC-SHA256 hex, never the secret itself |
key_version | text | Which keyring kid produced the hash |
status | text | active | revoked |
expires_at | timestamptz | 365 days from issue by default |
revoked_at/revoked_by_user_id/revoke_reason | — | CHECK: all three present together with status = 'revoked' |
At most INTEGRATION_MAX_ACTIVE_KEYS = 2 active, unexpired keys per client (service-enforced under the row lock).
integration_webhook_endpoints
| Column | Type | Notes |
|---|---|---|
url | text | CHECK 10–2048 chars, ^https?:// |
secret_ciphertext/key_version | — | AES-256-GCM sealed under the keyring's webhook key |
previous_secret_ciphertext/previous_key_version/previous_valid_until | — | All three null together (CHECK); populated by a rotation, valid 24h |
event_types | text[] | Subset of INTEGRATION_EVENT_TYPES, ≥1, further limited to what the client's scopes allow (service-enforced) |
status | text | active | paused | disabled; disabled_reason required exactly when disabled |
consecutive_failures | integer | ≥0; 50 in a row auto-disables |
dispatch_generation | integer | Bumped on re-enable, part of the delivery dedupe key |
At most INTEGRATION_MAX_WEBHOOKS = 5 non-disabled endpoints per client.
integration_change_events
| Column | Type | Notes |
|---|---|---|
xact_id | xid8 | Postgres transaction id, default pg_current_xact_id() — the cursor orders by (xact_id, id) |
resource_type/resource_public_id | — | CHECK: resource_type IN (INTEGRATION_RESOURCE_TYPES) |
integration_client_id | nullable FK | Set only for events private to one client (its own mappings) |
occurred_at | timestamptz | default clock_timestamp() |
Index (xact_id, id) is the cursor's own index. Append-only by construction (no update/delete path exists in the service layer; nothing needs a trigger because nothing writes to it but FeeChangeFeedService.record, owned by fees).
integration_feed_state
Singleton (singleton BOOLEAN, partial unique index WHERE singleton), epoch UUID, reason TEXT. Written by newEpoch() — on a restore (restored_from_backup), a detected transaction-id regression at boot (xid_regression_at_boot), or the accounting-integration switch flipping since the last boot in either direction (integration_disabled / integration_reenabled, detected in IntegrationFeedService.onApplicationBootstrap). reason is a plain text column every one of those causes overwrites, so it can only prove "this transition was already recorded", never reconstruct the full history of the switch.
integration_webhook_deliveries
| Column | Type | Notes |
|---|---|---|
kind | text | event | test; CHECK (kind='event') = (change_event_id IS NOT NULL) |
status | text | pending → delivering → succeeded/failed → (retry) → dead |
attempts | smallint | 0–20 |
dispatch_generation | integer | Matched against the row's current generation on claim — a stale job is dropped |
next_attempt_at | timestamptz | Drives the due-retry index |
unique (endpoint_id, change_event_id) WHERE change_event_id IS NOT NULL | — | One delivery per (endpoint, event) — fan-out is naturally idempotent |
5.3 Relationship Diagram
6. Services and Responsibilities
| Service | Owns | Key Methods |
|---|---|---|
IntegrationClientsService | Client CRUD, status transitions, cascading revoke | create, update, setStatus, lock |
IntegrationKeysService | Key issue/revoke/authenticate | issue, revoke, authenticate (constant-time, dummy-hash-on-unknown-prefix) |
IntegrationRateLimiterService | Per-client, per-minute budget | consume |
IntegrationApiKeyGuard | The one door on every /api/integration/* route | canActivate — installation switch (404), scope check, configured check, body-size check, auth, scope match, rate limit, in that order |
AccountingIntegrationGuard | Refuses a fees/salary/asset admin route or handler with 404 while the installation switch is off, via @RequiresAccountingIntegration() | canActivate — calls assertAccountingIntegrationEnabled |
IntegrationKeyringService | Derives every secret from INTEGRATION_SECRET_KEYS via HKDF, read once in onModuleInit | activeKid, apiKeyPepper, webhookSecretKey, assertServing (503), assertCanIssue (409) |
IntegrationWebhookEndpointsService | Endpoint CRUD, secret sealing/rotation, test/retry enqueue | create, update, rotateSecret, queueTest, retryDelivery, signingSecrets |
IntegrationWebhookDispatchService | Enqueues a delivery job via the outbox | enqueue |
IntegrationWebhookFanoutService | Turns one change event into N delivery rows | fanOut |
IntegrationWebhookDeliveryService | Claims, sends, records one attempt; recovers overdue ones | deliver, recover |
IntegrationFeedService | Cursor read, epoch management, feed status | read, status, currentEpoch, newEpoch |
IntegrationRestoreReconciler | Post-restore credential invalidation | reconcile |
IntegrationFeesApiModule services | Read-only mirrors of fee data for the accounting app, plus payment/mapping writes that call INTO the Fees module | — |
IntegrationFeesMetaReadService | ping, academic-context, changes (pass-through to IntegrationFeedService), and school — a read-only projection of the school_profile singleton via its own IntegrationSchoolDto, gating bankName/bankAccountNumber together behind payroll:bank:read in addition to the class's fees:read | school |
7. Runtime Flows
7.1 Authentication (every /api/integration/* request)
integrationBodyCap is mounted on /api/integration in main.ts ahead of the global body parser,
so an oversized or chunked-without-Content-Length body is refused before anything reads it. Node's
HTTP parser only ever reads the declared Content-Length, so the declared figure is the most that
can be read either way. The guard's own check re-applies the same 32KB cap once more before
authentication, against whatever Content-Length the request still carries at that point.
Every refusal is logged at WARN with the key prefix (never the secret), the client if known, and the IP. Every response carries Cache-Control: no-store.
7.2 Webhook delivery, one attempt
Backoff (WEBHOOK_BACKOFF_SECONDS): 60, 300, 1800, 7200, 21600, 43200, 86400, 86400 — attempts 1 through 8; the 9th failure is terminal (WEBHOOK_MAX_ATTEMPTS = 9). Only an infrastructure failure that wrote NOTHING is thrown from deliver(); that job is re-emitted by the recovery pass without spending an attempt.
The endpoint host resolves inside the overall 10-second send budget for the attempt. An IP literal
needs no lookup. A host name resolves through a c-ares Resolver (2-second timeout, 2 tries), with
the IPv4 and IPv6 lookups issued in parallel — bounded and off the libuv threadpool, unlike
dns.lookup, which would otherwise block one of that pool's four threads per slow receiver and with
it every file, zlib and crypto call the API makes. Development and test private-target mode uses the
system resolver instead, so localhost and /etc/hosts names still work.
Only <timestamp>.<raw body> is signed into X-Skoolsewa-Signature. The X-Skoolsewa-Event-Id,
X-Skoolsewa-Event-Type and X-Skoolsewa-Delivery-Id headers are not part of what is signed, so a
receiver must de-duplicate retried or re-delivered webhooks on the id field inside the signed
body, not on any of the id headers.
7.3 Change feed read (accounting app polling)
The xact_id < pg_snapshot_xmin(...) filter is load-bearing: a transaction still open when a page is read can commit LATER with a SMALLER (xact_id, id) than rows already served, so an id-only cursor would skip it forever. Filtering below the snapshot's xmin guarantees every transaction that could still insert a smaller key has already settled.
7.4 The installation switch
ACCOUNTING_INTEGRATION_ENABLED (default true) decides whether this school's data is exposed to an
accounting application at all. Off:
IntegrationApiKeyGuardanswers 404 (ACCOUNTING_INTEGRATION_DISABLED) on every/api/integration/*route, as the very first check, ahead of even the scope-metadata check.@RequiresAccountingIntegration()answers the same 404 on the fees mappings, fee sync events, salary inbound events, and asset sync admin routes, and on the client-, key-, and webhook-creating admin actions (IntegrationClientsController's create/update/activate,IntegrationClientKeysController's issue,IntegrationWebhooksController's create/rotate-secret/test/retry). Listing, suspending, and revoking a client, revoking a key, listing webhooks and their deliveries, and reading feed status all stay reachable —IntegrationClientsController.suspend/.revoke,IntegrationClientKeysController's revoke handler, and the webhook list/deliveriesGETs carry no@RequiresAccountingIntegration().IntegrationChangeRecorderService.recordno-ops instead of writing a change-feed row.IntegrationWebhookFanoutService.fanOutreturns0without creating delivery rows, andIntegrationWebhookDeliveryService.deliver/.recoverkill every outstanding delivery (status: dead,last_error: "integration_disabled") rather than sending or re-emitting them.IntegrationFeedService.onApplicationBootstrapstarts a new feed epoch on a transition either way (integration_disabled/integration_reenabled), so a cursor issued under the old state cannot silently keep reading into the new one.
What stays reachable regardless of the switch: IntegrationRestoreReconciler.reconcile — on every
restore it revokes every active key, pauses every active webhook, and bumps the feed epoch inside one
transaction, unconditionally. Only the admin office notice (integration.restored.admin) that follows is
skipped while the switch is off; the revocation, pause, and epoch bump already committed by then. A key
that could not be revoked while the switch was off would authenticate again the day it is turned back on.
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation |
|---|---|---|---|---|
integration:feed-poll:client:<clientPublicId> | CacheKeyUtil.build | {xactId, id, at} JSON | 30 days | Overwritten on every poll; read failures are swallowed (best-effort, feed-status screen only) |
integration:revoked | literal key | Redis SET of key:<public id> / client:<public id> members | No TTL, held until the row is restored | IntegrationRevocationsService.remember adds a member inside the revoking transaction (a failed add is logged at ERROR; the revocation itself still commits) |
integration:feed-served | literal key | Redis sorted set, member = feed epoch, score = highest transaction id served in that epoch | No TTL | Raised only (ZADD GT) on every feed read that serves rows; a write failure is logged at WARN |
The database row is still the primary record for a key or client's status. Authentication treats
integration:revoked as an ADDITIONAL check, not a replacement: authenticate refuses any key or
client the set names, whatever its row says, because a database restore brings the row back exactly
as it read when the dump was taken — status = 'active' for something revoked afterwards — while
Redis is not part of that restore and keeps the truth. A read of the set that fails (Redis
unreachable) fails OPEN, logged at ERROR: the row check already ran by then, and refusing every
accounting-system request over a Redis outage would lose payment information the row check would
otherwise have let through correctly.
integration:feed-served backs the same detection the other direction: at boot,
IntegrationRestoreReconciler treats either an active key/client the revoked set says was revoked,
or a feed epoch whose recorded served position is ahead of the table's own maximum transaction id,
as proof the database was rolled back under the running process (a manual pg_restore onto the same
cluster, which the feed's own xid check on its own cannot see) — and reconciles exactly as it would
for a BackupRestoreProcessor-driven restore.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry |
|---|---|---|---|---|---|
QueueName.INTEGRATION_WEBHOOKS ("integration_webhooks") | integration.webhook_deliver | IntegrationWebhookDispatchService, via outbox | The webhooks processor (IntegrationWorkerModule) | {v, requestId, deliveryPublicId, dispatchGeneration, attempt} | BullMQ runs each job ONCE (attempts: 1); the delivery ROW owns the retry budget via a new outbox row per backoff step |
QueueName.FEES | fees.change_fanout | FeeChangeFeedService, via outbox (Fees module) | FeeQueueProcessor | {v, requestId, changeEventPublicId} | Standard job retry |
FeeQueueProcessor (Fees module) also calls IntegrationWebhookDeliveryService.recover() on every fees.sweep tick — an overdue or stuck delivery (>5 minutes past next_attempt_at) is re-emitted, up to 200 per tick, without spending an attempt.
11. Security, Auth, and Abuse Controls
ACCOUNTING_INTEGRATION_ENABLEDgates the whole surface with a 404 that discloses nothing beyond "not found." On an admin route,@RequiresAccountingIntegration()is written ABOVE@UseGuards(JwtAuthGuard, RoleGuard)on the class — class decorators apply bottom-up andUseGuardsappends, soJwtAuthGuard/RoleGuardregister first and run first; an unauthenticated or unauthorised caller gets 401/403 and learns nothing about whether the integration is installed. OnIntegrationApiKeyGuard, the switch check is the guard's own first statement, ahead of the scope-metadata check.- Every
/api/integration/*route is@Public()to the STAFFJwtAuthGuard— its actual authentication isIntegrationApiKeyGuard, entirely separate from the staff session system. - IP throttling (600/min) runs AHEAD of key authentication, bounding unauthenticated noise before a single database row is touched.
- A route with no declared scope is refused outright and logged as an integration bug — nothing is "open by omission."
- Secrets are never stored in the clear: an API key secret is an HMAC under a server pepper (one-way); a webhook secret is AES-256-GCM sealed (reversible, because the server must sign with it) under a key derived from
INTEGRATION_SECRET_KEYS, never the raw env value itself. INTEGRATION_SECRET_KEYSis read ONCE atonModuleInit, never at module load (before.envis read). Every secret derives from it via HKDF under a purpose string, keyed bykid, so adding a new first key rotates what is issued next while older credentials still verify.- Unset, the whole surface fails CLOSED:
/api/integration/*answers 503, and any admin write that would mint a credential answers 409 — the rest of the application is unaffected. - Webhook URLs are validated at creation AND at every send; private/loopback targets and plain HTTP are refused unless
INTEGRATION_WEBHOOK_ALLOW_PRIVATE_TARGETS=true(dev/test only — refused in production). - Every webhook body is signed HMAC-SHA256, timestamped, with a 300-second replay tolerance;
timingSafeEqualon verification.
13. Error Handling
19 error codes, INTEGRATION_*, in apps/api/src/common/types/error-codes.ts. Full list in api.mdx §13.
The installation switch's own refusal is a separate code, ACCOUNTING_INTEGRATION_DISABLED (404),
thrown by assertAccountingIntegrationEnabled.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | IntegrationApiKeyGuard.refusal | Every 401/403/429, logged at WARN with key prefix (never secret), client, IP — a refusal never reaches the audit trail below, only the log |
| Audit | Global ActivityAuditInterceptor | Every mutating /api/integration/* request that authenticates, recorded with userId: "integration:<client public id>", userName: "<client name> (key <prefix>)", role: "integration" — set by the guard on request.auditActor once a principal is established |
| Log | IntegrationKeyringService (boot) | Whether the integration is configured at all |
| Log | IntegrationFeedService.onApplicationBootstrap | A detected transaction-id regression (restored onto a fresher cluster), and, at every boot, the current state of ACCOUNTING_INTEGRATION_ENABLED |
| Notice | integration.webhook_dead.admin, integration.webhook_disabled.admin, integration.restored.admin | Office-facing events for delivery death, auto-disable, and restore |
job_failures | Queue name integration_webhooks | The terminal failure record for a dead delivery |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | apps/api/src/utils/integration/*.spec.ts | api-key hashing, canonical JSON, cursor codec, keyring derivation, secret sealing, webhook signature, URL validation |
| Integration | apps/api/src/modules/integration/__tests__/*.integration.spec.ts | Auth+feed end to end, webhooks end to end against a real local HTTP receiver, client administration |
cd apps/api && pnpm exec jest --testRegex 'utils/integration'
cd apps/api && flock <lock> pnpm exec jest --runInBand --testRegex 'modules/integration/__tests__'See Also
- Features and flows doc:
/docs/developer/integration/feature - API doc:
/docs/developer/integration/api
Accounting Integration Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the accounting integration module.
Accounting Integration API Reference
Complete API contracts for the accounting integration — client administration and the accounting application's own developer guide.