Skip to main content

Accounting System Integration

Moonlight integrates with external accounting systems through a pluggable provider architecture. It also supports a fully manual mode where an accountant works directly within Moonlight without any external system.

Supported Providers

ProviderStatusDescription
ManualBuilt-inNo external system, accountant reviews in Moonlight
Intuit (QuickBooks)ImplementedFull push/pull sync, OAuth2, webhooks
Custom providersExtensibleImplement the AccountingProvider interface

Sync Modes

Each connection is configured with a syncMode that controls how data flows:

ModeDescription
MANUALNo external system. Accountant reviews and approves entries entirely in Moonlight
MANUAL_PUSHAccountant manually triggers push to external system per entry
AUTO_PUSHAutomatically pushes to external system when review is approved
BIDIRECTIONALPush + scheduled pull from external system

Integration Patterns

Pattern A: Manual Review (no external system)

Pattern B: SDK → Moonlight → Accounting → Moonlight

Pattern C: Full Auto (no review)

Accounting Review Workflow

When requireReview = true on a connection, entries go through an accounting review queue before sync:

How Entries Enter the Review Queue

Entries are automatically queued for review when:

  1. Created via API (POST /entries) — if a connection with requireReview = true exists for the workspace, a review is automatically created
  2. Ingested via SDK (POST /api/v1/ingest) — same auto-queuing logic after the ingest pipeline processes the entry
  3. Backfill — when a new connection is created, use POST /accounting/connections/{id}/backfill-reviews to queue all existing entries for review (or click "Queue Entries for Review" in the UI)
  4. Manual — create a review via POST /accounting/reviews for a specific entry

Review Features

  • Assignment: Accountant takes a review from the queue
  • Category assignment: Assign a chart of accounts category during approval
  • Document verification: Optionally require isComplete (approved BASIS + PAYMENT_PROOF docs + transaction) before review approval
  • Batch operations: Approve or reject multiple reviews at once
  • Invalidation: If an entry is modified while in review, the review is automatically reset to QUEUED
  • Backfill: Queue all existing entries for a connection's review queue in one action

Chart of Accounts

Moonlight maintains an internal chart of accounts (accounting_category) with:

  • Hierarchical categories (parent/child)
  • Account types: ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE
  • External mappings per provider (e.g., QuickBooks account ID)

Categories can be:

  • Created manually in Moonlight
  • Pulled from an external system via POST /accounting/sync/pull-chart-of-accounts

Party & Tax Mapping

Party Mapping

Map Moonlight parties to external system vendors/customers:

Moonlight PartyExternal Entity
Party #5 (Vendor)QB Vendor ID 123
Party #8 (Customer)QB Customer ID 456

When pushing an entry, the mapped external vendor/customer is automatically set on the Invoice/Bill.

Tax Mapping

Map Moonlight tax rates to external tax codes for accurate tax handling during sync.

Connection-Project Routing

A workspace may have multiple connections (e.g., QB US entity + QB UK entity). Use project routing to control which entries sync to which connection:

  • If a connection has specific projects assigned, only entries from those projects sync through it
  • If no projects are assigned, the connection handles all projects in the workspace

Sync Lifecycle

Sync Statuses

StatusDescription
PENDINGSync initiated, not yet completed
SYNCEDSuccessfully synced with external system
FAILEDSync failed (error message stored, auto-retry with backoff)
STALEEntry was modified after sync — needs re-push
CONFLICTData conflict between systems — requires manual resolution

Re-sync After Edit

When a synced entry is modified (status change, amount change), the sync record is automatically marked STALE. The accountant can then trigger a re-push which updates the existing Invoice/Bill in the external system.

Void/Refund Propagation

When an entry is voided or refunded in Moonlight:

  1. The corresponding Invoice/Bill in the external system is automatically voided
  2. A VOIDED_EXTERNAL event is recorded in the audit trail

Conflict Resolution

When a conflict is detected:

  • Choose KEEP_LOCAL to re-push Moonlight's version
  • Choose KEEP_EXTERNAL to accept the external system's version

Retry Mechanism

Failed syncs are reprocessed by a periodic Cloud Scheduler tick (*/5 * * * *) that publishes a RETRY_FAILED message to the scheduler-tick topic. The scheduler handler then republishes a { type: "RETRY_FAILED" } message to accounting-sync, which the AccountingSyncWorker consumes to retry up to 5 failed sync rows per tick. In addition, every accounting-sync subscription has a Pub/Sub retry policy (minBackoff=5s, maxBackoff=20s) and a dead-letter topic (accounting-sync-dlq) wired with maxDeliveryAttempts=5, so transient infrastructure failures are retried automatically and poison messages are quarantined for inspection.

Scheduled Pulls

For connections with syncMode = BIDIRECTIONAL and a syncScheduleCron, a Cloud Scheduler tick (* * * * *) publishes SCHEDULED_PULL_TICK to scheduler-tick. The scheduler handler iterates active connections, evaluates each cron expression against last_scheduled_pull_check_at (persisted in accounting_connection, so the schedule survives Cloud Run restarts), and publishes { type: "SCHEDULED_PULL", connectionId, since } to accounting-sync for each due connection.

Accounting Periods

Define accounting periods (e.g., Q1 2026, FY 2025) and close them to prevent modifications:

  • Entries within a closed period cannot be synced or modified
  • Periods can be reopened if needed
  • Overlapping periods are not allowed

Setup

1. Create a Connection

POST /accounting/connections
{
"workspaceId": 1,
"providerType": "INTUIT",
"name": "QuickBooks Production",
"syncMode": "AUTO_PUSH",
"requireReview": true,
"requireCompletenessForSync": true,
"credentials": {
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"realmId": "your-realm-id",
"accessToken": "...",
"refreshToken": "...",
"tokenExpiresAt": "2026-12-31T00:00:00Z"
},
"projectIds": [1, 2]
}

2. Set Up Mappings

POST /accounting/mappings/parties
{
"connectionId": "uuid",
"partyId": 5,
"externalId": "123",
"externalType": "Vendor",
"externalName": "Acme Corp"
}

3. Pull Chart of Accounts

POST /accounting/sync/pull-chart-of-accounts
{
"connectionId": "uuid"
}

4. Create Review and Approve

POST /accounting/reviews
{ "entryId": 42, "connectionId": "uuid" }

PATCH /accounting/reviews/{reviewId}/approve
{ "categoryId": "category-uuid", "notes": "Verified" }

5. Push Entry (manual)

POST /accounting/sync/push
{ "entryId": 42, "connectionId": "uuid" }

6. Configure Webhook

POST /webhooks/accounting/{connectionId}

Custom Provider Implementation

To add a new accounting provider, implement the AccountingProvider interface:

interface AccountingProvider {
readonly providerType: string;

connect(config): Promise<void>;
disconnect(): Promise<void>;
isConnected(): boolean;

pushEntry(context: PushEntryContext): Promise<ExternalReference>;
updateEntry(externalId, externalType, context): Promise<ExternalReference>;
voidEntry(externalId, externalType): Promise<void>;
pullEntries(since: Date): Promise<ExternalEntryData[]>;

pullChartOfAccounts(): Promise<ExternalAccount[]>;
pullParties(): Promise<ExternalParty[]>;
pullTaxCodes(): Promise<ExternalTaxCode[]>;

handleWebhook(payload, headers): Promise<WebhookResult>;
refreshCredentials(current): Promise<Record<string, unknown>>;
}

Register the provider in AccountingModule.onModuleInit().