Architecture
System Overview
Backend Modules
moonlight-server/src/
├── auth/ # JWT authentication
├── user/ # User management
├── role/ # RBAC (roles & permissions)
├── workspace/ # Multi-tenancy
├── project/ # Project grouping
├── party/ # Counterparties
├── tag/ # Entry categorization
├── entry/ # Core financial entries
│ ├── entry.entity.ts
│ ├── entry-document.* # Document management
│ ├── entry-event.* # Audit trail
│ ├── entry-completeness.* # Completeness calculation
│ └── entry-item.entity.ts
├── accounting/ # Accounting integration & review workflow
│ ├── providers/
│ │ └── intuit/ # QuickBooks provider
│ ├── accounting-connection.* # Connection config (sync mode, review, routing)
│ ├── accounting-sync.* # Per-entry sync tracking
│ ├── accounting-review.* # Accountant review queue
│ ├── accounting-category.* # Chart of accounts
│ ├── accounting-party-mapping.* # Party ↔ external vendor/customer
│ ├── accounting-tax-mapping.* # Tax rate ↔ external tax code
│ ├── accounting-period.* # Accounting period closing
│ ├── accounting-mapping.* # Mapping service (parties, taxes, projects)
│ ├── accounting-sync.worker.* # Pub/Sub worker (retry, auto-push, void, scheduled pull)
│ └── accounting-webhook.*
├── integration/ # API key management
├── ingest/ # Data ingestion pipeline
│ ├── adapters/ # Source-specific adapters
│ └── ingest.worker.* # Pub/Sub worker for the ingest topic
├── messaging/ # Pub/Sub abstraction (publisher, worker base, topology, idempotency)
├── scheduler/ # Cloud Scheduler tick handler + local emulation
├── ocr/ # Receipt OCR (OpenAI)
├── file-storage/ # S3 file management
└── report/ # Financial reports
Database Schema
Core Tables
| Table | Purpose |
|---|---|
entry | Financial operations |
entry_item | Line items (products, services, tax) |
entry_transaction | Payment transactions |
entry_document | Supporting documents |
entry_event | Audit trail |
entry_tag | Entry-tag associations |
Organization
| Table | Purpose |
|---|---|
workspace | Top-level container |
project | Workspace subdivision |
party | Counterparties |
party_account | Bank/payment accounts |
Accounting
| Table | Purpose |
|---|---|
accounting_connection | External system connections (sync mode, review config) |
accounting_sync | Per-entry sync tracking (PENDING/SYNCED/FAILED/STALE/CONFLICT) |
accounting_review | Accountant review queue per entry |
accounting_category | Chart of accounts (hierarchical) |
accounting_party_mapping | Moonlight party ↔ external vendor/customer |
accounting_tax_mapping | Tax rate ↔ external tax code |
accounting_connection_project | Connection ↔ project routing |
accounting_period | Accounting period closing |
Integration
| Table | Purpose |
|---|---|
integration | API key registrations |
submission | Ingest submissions |
submission_file | Uploaded files |
Auth
| Table | Purpose |
|---|---|
user_record | User profiles |
user_authorization | Auth providers |
role / permission | RBAC |
Frontend Architecture
moonlight-ui/src/
├── main.tsx # BrowserRouter, AuthProvider, ThemeProvider
├── App.tsx # Route definitions
├── contexts/
│ └── AuthContext.tsx # JWT auth state, auto-refresh, permissions
├── lib/
│ ├── api.ts # HTTP client with auth headers and error handling
│ └── utils.ts # Tailwind class merge helper (cn)
├── pages/
│ └── LoginPage.tsx
├── components/
│ ├── Layout.tsx # Main layout with Sidebar + nested routes
│ ├── Sidebar.tsx # Navigation, workspace selector
│ ├── ProtectedRoute.tsx # Redirect to login if unauthenticated
│ ├── EntryList.tsx # Entry table with project/tag filters
│ ├── EntryDetail.tsx # Full entry view (items, docs, events, actions)
│ ├── PartyList.tsx # Party grid view
│ ├── PartyDetail.tsx # Party details with accounts
│ ├── ProjectList.tsx # Project management
│ ├── WorkspaceManagement.tsx
│ ├── UserManagement.tsx # User CRUD with role assignment
│ ├── accounting/ # Accounting management
│ │ ├── AccountingPage.tsx # Tab container
│ │ ├── AccountingDashboardTab.tsx # Review & sync stats
│ │ ├── AccountingReviewsTab.tsx # Review queue with batch actions
│ │ ├── AccountingCategoriesTab.tsx # Chart of accounts CRUD
│ │ ├── AccountingConnectionsTab.tsx # Connection management
│ │ ├── AccountingMappingsTab.tsx # Party/tax mapping
│ │ └── AccountingPeriodsTab.tsx # Period management
│ ├── reports/ # Analytics dashboard
│ │ ├── ReportsPage.tsx
│ │ ├── tabs/ # Overview, Analytics, Flows, 3D
│ │ ├── charts/ # ECharts + Three.js visualizations
│ │ └── hooks/ # useReportData, useResolvedTheme
│ └── ui/ # shadcn/ui primitives (managed by CLI)
Key Frontend Patterns
- Routing: React Router v7, nested routes defined in
App.tsx, rendered inLayout.tsx - Auth:
AuthContextmanages JWT in localStorage, auto-refreshes 60s before expiry, provideshasPermission() - Styling: Tailwind CSS + shadcn/ui (Radix primitives), dark mode default via
ThemeProvider - API Client:
lib/api.tswrapsfetchwith Bearer token injection, typed error handling (ApiError) - Charts: ECharts for 2D (waterfall, sankey, treemap, sunburst, etc.), react-three-fiber for 3D landscape/globe
- State: Local component state + context (no Redux/Zustand)
Key Design Decisions
Amounts in Cents
All monetary amounts are stored as integers in cents to avoid floating-point issues.
Provider Pattern
Accounting integrations use an adapter/provider pattern (AccountingProvider interface) allowing any external system to be plugged in. The interface supports push, update, void, pull entries, pull chart of accounts, pull parties, pull tax codes, and webhook handling.
Accounting Review Workflow
Entries can optionally go through an accountant review queue before being synced. The review workflow supports assignment, approval with category assignment, rejection, change requests, batch operations, and automatic invalidation when entries are modified during review.
Sync Lifecycle
Sync records track the full lifecycle: PENDING → SYNCED → STALE (on entry edit) → re-sync. Failed syncs are tracked as first-class operational exceptions and surfaced through workspace-scoped sync APIs. Void/refund operations propagate to external systems.
Event Sourcing (Light)
entry_event records every state change, providing a full audit trail without full event sourcing complexity. Entry notes also emit timeline events so the UI can assemble a single activity stream.
Completeness as Derived State
isComplete is a computed flag derived from approved documents and attached transactions. Moonlight now recalculates completeness when entry detail is read and before review approval / sync actions that depend on completeness, reducing the risk of stale control states in operational workflows.
Operational Surfaces
The frontend now exposes three remediation-first operational layers on top of the core accounting model:
- Activity Timeline in entry detail, built from
entry_event, notes, and related entries - Exceptions tab in accounting, backed by workspace-scoped sync and control counters
- Operations inbox in accounting, backed by requested-document and review-rework queues
Reporting Trust Contract
Moonlight reporting now follows a safer intermediate trust contract:
summaryexposescurrencyBreakdownand explicitly flags mixed-currency rangesby-partyuses directionally relevant counterparties instead of counting both sides of a transferby-tagproportionally allocates multi-tagged entries to reduce double countingcalendarreturns signed net daily totals instead of raw gross turnover
This is still not a full finance-grade reporting model, but it is materially safer than raw cross-currency or many-to-many overcounting.
Separation of Auth Methods
JWT for user sessions (15-minute expiry with auto-refresh), API Keys for machine-to-machine communication (Ingest API).
Reconciliation
Entries have an is_reconciled flag that accountants can toggle to mark entries as verified against external records. The field includes reconciled_at and reconciled_by for audit purposes.
Entry Notes
A separate entry_note table allows accountants to attach comments to entries without modifying the entry itself.
Flyway over TypeORM Sync
Database schema is managed by explicit, versioned Flyway migrations — not TypeORM synchronize. This ensures production safety and repeatable deployments.
Migrations
| Version | Description |
|---|---|
| V1 | Initial schema |
| V2 | User permissions and roles |
| V3 | Workspace created_at |
| V4 | Integration and ingest tables |
| V5 | Accounting, documents, events |
| V6 | Accounting review, categories, periods |
| V7 | Accountant features: reconciliation fields, entry notes, new permissions |
| V8 | Grant accountant permissions to owner role |
| V9 | Replace integer MAX(id)+1 allocation with database-backed sequences |
| V10 | processed_message table for Pub/Sub idempotency + accounting_connection.last_scheduled_pull_check_at |
Asynchronous Messaging (Google Cloud Pub/Sub)
Moonlight uses Google Cloud Pub/Sub (with the official Pub/Sub Emulator for local development) as the asynchronous messaging backbone. The MessagingModule is a thin abstraction that hides the @google-cloud/pubsub client behind an IPublisher interface and a PubSubWorker<T> base class.
Topology
| Topic | Subscription (pull) | Producers | Consumer |
|---|---|---|---|
ingest | ingest-worker | IngestService | IngestWorker |
accounting-sync | accounting-sync-worker | EntryService, SchedulerHandler | AccountingSyncWorker |
scheduler-tick | scheduler-tick-worker | Cloud Scheduler (prod) / LocalSchedulerService (dev) | SchedulerHandler |
Every workload subscription is provisioned with:
ackDeadlineSeconds = 600(60 forscheduler-tick)messageRetentionDuration = 7d(1d forscheduler-tick)retryPolicy { minimumBackoff: 5s, maximumBackoff: 20s }(10s/60s forscheduler-tick)deadLetterPolicy { deadLetterTopic: <topic>-dlq, maxDeliveryAttempts: 5 }
DLQ topics (*-dlq) each have an inspect-only subscription (*-dlq-inspect) for manual replay/debugging.
Bootstrap
When PUBSUB_AUTO_CREATE=true (default), MessagingModule.onApplicationBootstrap idempotently creates topics and subscriptions on app start. This is convenient for local/CI and acceptable in production for first-boot bootstrapping; otherwise infra/setup-gcp.sh provisions the same topology with gcloud.
Idempotency
Pub/Sub guarantees at-least-once delivery, so all workers run every payload through IdempotencyService, which uses a Postgres table processed_message with a unique (handler, message_id) index. After a handler completes successfully, the worker writes the row and then ack()s the message; on retry, the same message_id is short-circuited and ack-ed without re-running the handler. A PROCESSED_MESSAGE_GC scheduler tick deletes rows older than 14 days.
Cron / Periodic Work
Three cron-style jobs are owned by Cloud Scheduler and emit messages to scheduler-tick:
*/5 * * * *→{ type: "RETRY_FAILED" }* * * * *→{ type: "SCHEDULED_PULL_TICK" }0 * * * *→{ type: "PROCESSED_MESSAGE_GC" }
SchedulerHandler consumes these ticks, expands them into per-connection work (or republishes coarse jobs to accounting-sync), and persists accounting_connection.last_scheduled_pull_check_at so cron evaluation survives Cloud Run instance restarts. Locally, LocalSchedulerService publishes the same messages on setInterval so the dev loop matches production exactly.