Skoolsewa - School Management System Docs
Developer ResourcesAccounting integration

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

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/integration/{integration,integration-core,integration-worker}.module.tsComposition
Schemapackages/db/src/schema/integration.tsEvery table, read in full
Keyringapps/api/src/modules/integration/keyring/integration-keyring.service.ts, apps/api/src/utils/integration/integration-keyring.util.tsKey derivation
Authapps/api/src/modules/integration/auth/*.tsGuard, keys service, rate limiter, decorator
Webhooksapps/api/src/modules/integration/webhooks/*.tsEndpoints, dispatch, delivery, fan-out
Feedapps/api/src/modules/integration/feed/integration-feed.service.tsCursor and epoch mechanics
Restoreapps/api/src/modules/integration/restore/integration-restore-reconciler.service.tsFull file read
Queuepackages/jobs/src/index.ts (IntegrationWebhookJob, QueueName.INTEGRATION_WEBHOOKS)Job contract
Envapps/api/src/config/env.validation.tsINTEGRATION_SECRET_KEYS, INTEGRATION_WEBHOOK_ALLOW_PRIVATE_TARGETS, ACCOUNTING_INTEGRATION_ENABLED
Installation switchapps/api/src/common/feature-flags/accounting-integration.tsFull 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_KEYS via 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 the payroll:* scopes.
  • The accounting application's read/write API for assets — types, categories, the register, accounting status (assets-api), gated by the assets:* scopes.
  • Outbound webhook delivery, retry, and recovery, on QueueName.INTEGRATION_WEBHOOKS.
  • The installation switch (ACCOUNTING_INTEGRATION_ENABLED, default true): whether this school uses the accounting application at all. It is a product decision, orthogonal to INTEGRATION_SECRET_KEYS being 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 fees module; fees-api reads/writes through it, never around it (payment intake dispatches into FeeSyncIntakeService, owned by fees).
  • Salary calculation, assignments, periods, payslips themselves — owned by the salary module; payroll-api reads through it and writes payment state only through salary's own inbound event path. Granting or revoking a payroll:* scope on a client needs SalarySync_UPDATE (identity/bank scopes additionally need StaffSalary_READ) on the granting admin — assertScopeGrant in IntegrationClientsAdminService checks 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_FORBIDDEN otherwise, superadmin exempt.
  • The asset register and its lifecycle — owned by the assets module; assets-api reads through it and writes only the accounting-owned fields (accountingStatus, accountingReference, accountingBookValuePaisa) through assets's own inbound event path. Granting or revoking an assets:* scope needs AssetSync_UPDATE on the granting admin, checked the same way.
  • Notification delivery mechanics — uses the shared NotificationService/OutboxService.
  • Backup and restore mechanics — IntegrationRestoreReconciler is CALLED by BackupRestoreProcessor.finish(), owned by backup.

Source of Truth

ConcernSource of TruthNotes
Whether a key authenticatesintegration_client_keys.status/expires_at + integration_clients.statusChecked together in usable() on every request
A webhook's current signing secretintegration_webhook_endpoints.secret_ciphertext, sealed under the keyringDecrypted only at send time, never returned by any read route
The change feed's current positionintegration_feed_state.epochA restore or a detected transaction-id regression starts a new one
What the accounting app last reportedfee_external_transactions.latest_* (Fees module)—

3. Module Composition

ModuleTypeControllersProvidersResponsibility
IntegrationModuleAggregate—Imports core + admin + fees-apiRoot composition, ADMIN_MODULES entry for IntegrationClientsAdminModule, mounts fees-api
IntegrationCoreModuleShared servicesNoneKeyring, Keys, RateLimiter, ApiKeyGuard, ClientsService, FeedService, NoticesService, WebhookDispatch/Endpoints/Fanout/Delivery, RestoreReconcilerDeclares no controller; imported by admin, integration (fees-api), and worker leaves
IntegrationClientsAdminModuleLeaf, adminIntegrationClientsController, IntegrationClientKeysController, IntegrationWebhooksControllerRead/admin services, mapper/api/integration-clients, FeeIntegrations_*, superadmin-granted
IntegrationFeesApiModuleLeaf, externalIntegrationFeesCatalogController, ...MappingsController, ...MetaController, ...ObligationsController, ...PaymentsController, ...StudentsControllerPer-area read/write services/api/integration/fees/**, API-key authenticated, @Public() to the staff guard
IntegrationWorkerModuleWorker-onlyNoneWebhook delivery processorComposed 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 hashing

5. Data Model

5.1 Schema Source

packages/db/src/schema/integration.ts
  integrationClients, integrationClientKeys, integrationWebhookEndpoints,
  integrationChangeEvents, integrationFeedState, integrationWebhookDeliveries

5.2 Tables

integration_clients

ColumnTypeNotes
nametextunique lower(name), trimmed 1–120
statustextactive | suspended | revoked
scopestext[]CHECK: subset of INTEGRATION_SCOPES, cardinality ≥ 1
rate_limit_per_minuteinteger1–6000, default 120
last_used_attimestamptzWritten at most once/minute by any authenticated key of this client
revoked_at/revoked_by_user_id—CHECK: pair with status = 'revoked'
versionintegerOptimistic concurrency

integration_client_keys

ColumnTypeNotes
client_idFK, RESTRICT—
key_prefixtextunique, CHECK ^[0-9a-f]{16}$ — the lookup id
secret_hashtextCHECK ^[0-9a-f]{64}$ — HMAC-SHA256 hex, never the secret itself
key_versiontextWhich keyring kid produced the hash
statustextactive | revoked
expires_attimestamptz365 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

ColumnTypeNotes
urltextCHECK 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_typestext[]Subset of INTEGRATION_EVENT_TYPES, ≥1, further limited to what the client's scopes allow (service-enforced)
statustextactive | paused | disabled; disabled_reason required exactly when disabled
consecutive_failuresinteger≥0; 50 in a row auto-disables
dispatch_generationintegerBumped on re-enable, part of the delivery dedupe key

At most INTEGRATION_MAX_WEBHOOKS = 5 non-disabled endpoints per client.

integration_change_events

ColumnTypeNotes
xact_idxid8Postgres 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_idnullable FKSet only for events private to one client (its own mappings)
occurred_attimestamptzdefault 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

ColumnTypeNotes
kindtextevent | test; CHECK (kind='event') = (change_event_id IS NOT NULL)
statustextpending → delivering → succeeded/failed → (retry) → dead
attemptssmallint0–20
dispatch_generationintegerMatched against the row's current generation on claim — a stale job is dropped
next_attempt_attimestamptzDrives 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

ServiceOwnsKey Methods
IntegrationClientsServiceClient CRUD, status transitions, cascading revokecreate, update, setStatus, lock
IntegrationKeysServiceKey issue/revoke/authenticateissue, revoke, authenticate (constant-time, dummy-hash-on-unknown-prefix)
IntegrationRateLimiterServicePer-client, per-minute budgetconsume
IntegrationApiKeyGuardThe one door on every /api/integration/* routecanActivate — installation switch (404), scope check, configured check, body-size check, auth, scope match, rate limit, in that order
AccountingIntegrationGuardRefuses a fees/salary/asset admin route or handler with 404 while the installation switch is off, via @RequiresAccountingIntegration()canActivate — calls assertAccountingIntegrationEnabled
IntegrationKeyringServiceDerives every secret from INTEGRATION_SECRET_KEYS via HKDF, read once in onModuleInitactiveKid, apiKeyPepper, webhookSecretKey, assertServing (503), assertCanIssue (409)
IntegrationWebhookEndpointsServiceEndpoint CRUD, secret sealing/rotation, test/retry enqueuecreate, update, rotateSecret, queueTest, retryDelivery, signingSecrets
IntegrationWebhookDispatchServiceEnqueues a delivery job via the outboxenqueue
IntegrationWebhookFanoutServiceTurns one change event into N delivery rowsfanOut
IntegrationWebhookDeliveryServiceClaims, sends, records one attempt; recovers overdue onesdeliver, recover
IntegrationFeedServiceCursor read, epoch management, feed statusread, status, currentEpoch, newEpoch
IntegrationRestoreReconcilerPost-restore credential invalidationreconcile
IntegrationFeesApiModule servicesRead-only mirrors of fee data for the accounting app, plus payment/mapping writes that call INTO the Fees module—
IntegrationFeesMetaReadServiceping, 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:readschool

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:

  • IntegrationApiKeyGuard answers 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/deliveries GETs carry no @RequiresAccountingIntegration().
  • IntegrationChangeRecorderService.record no-ops instead of writing a change-feed row.
  • IntegrationWebhookFanoutService.fanOut returns 0 without creating delivery rows, and IntegrationWebhookDeliveryService.deliver/.recover kill every outstanding delivery (status: dead, last_error: "integration_disabled") rather than sending or re-emitting them.
  • IntegrationFeedService.onApplicationBootstrap starts 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 PatternBuilderValueTTLInvalidation
integration:feed-poll:client:<clientPublicId>CacheKeyUtil.build{xactId, id, at} JSON30 daysOverwritten on every poll; read failures are swallowed (best-effort, feed-status screen only)
integration:revokedliteral keyRedis SET of key:<public id> / client:<public id> membersNo TTL, held until the row is restoredIntegrationRevocationsService.remember adds a member inside the revoking transaction (a failed add is logged at ERROR; the revocation itself still commits)
integration:feed-servedliteral keyRedis sorted set, member = feed epoch, score = highest transaction id served in that epochNo TTLRaised 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

QueueJobProducerProcessorPayloadRetry
QueueName.INTEGRATION_WEBHOOKS ("integration_webhooks")integration.webhook_deliverIntegrationWebhookDispatchService, via outboxThe 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.FEESfees.change_fanoutFeeChangeFeedService, 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_ENABLED gates 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 and UseGuards appends, so JwtAuthGuard/RoleGuard register first and run first; an unauthenticated or unauthorised caller gets 401/403 and learns nothing about whether the integration is installed. On IntegrationApiKeyGuard, the switch check is the guard's own first statement, ahead of the scope-metadata check.
  • Every /api/integration/* route is @Public() to the STAFF JwtAuthGuard — its actual authentication is IntegrationApiKeyGuard, 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_KEYS is read ONCE at onModuleInit, never at module load (before .env is read). Every secret derives from it via HKDF under a purpose string, keyed by kid, 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; timingSafeEqual on 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

SignalLocationPurpose
LogIntegrationApiKeyGuard.refusalEvery 401/403/429, logged at WARN with key prefix (never secret), client, IP — a refusal never reaches the audit trail below, only the log
AuditGlobal ActivityAuditInterceptorEvery 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
LogIntegrationKeyringService (boot)Whether the integration is configured at all
LogIntegrationFeedService.onApplicationBootstrapA detected transaction-id regression (restored onto a fresher cluster), and, at every boot, the current state of ACCOUNTING_INTEGRATION_ENABLED
Noticeintegration.webhook_dead.admin, integration.webhook_disabled.admin, integration.restored.adminOffice-facing events for delivery death, auto-disable, and restore
job_failuresQueue name integration_webhooksThe terminal failure record for a dead delivery

15. Testing and Validation

Test TypeFilesCoverage
Unitapps/api/src/utils/integration/*.spec.tsapi-key hashing, canonical JSON, cursor codec, keyring derivation, secret sealing, webhook signature, URL validation
Integrationapps/api/src/modules/integration/__tests__/*.integration.spec.tsAuth+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