Skip to main content

Overview

Arcus uses a full double-entry general ledger (GL). Every dollar movement in the system — fulfilled orders, received payments, refunds, purchase receipts, vendor bills, inventory adjustments — posts a balanced journal entry. No financial transaction occurs without a corresponding GL record. The GL is the backbone: if the trial balance is in balance (debits = credits), all downstream reports (balance sheet, income statement, AR aging, AP aging) are correct by construction.

Chart of Accounts

Each entity has its own chart of accounts (COA) stored in accounting.gl_accounts. Accounts are organized into a hierarchy:
TypeNormal BalanceExamples
assetdebitAR, Cash, Inventory, Prepaid Expenses
liabilitycreditAP, Sales Tax Payable, Customer Deposits
equitycreditRetained Earnings, Owner’s Equity
revenuecreditSales Revenue, Shipping Income
expense / cogsdebitCOGS, Freight Out, Merchant Fees

Header vs Leaf Accounts

Accounts fall into two categories:
  • Header accounts (is_header=true) — rollup parents (e.g. 1000 Cash and Bank Accounts, 1400 Inventory). They aggregate their children for reporting. You cannot post journal entry lines to a header account. Attempting to do so returns HTTP 422 with code account_is_header.
  • Leaf accounts (is_header=false, is_active=true) — the only valid targets for journal entry lines (e.g. 1300 Accounts Receivable, 4000 Sales Revenue, 5000 Cost of Goods Sold).
When building a journal entry line picker, always filter with ?postable_only=true:
GET /v1/gl-accounts?postable_only=true
Authorization: Bearer ark_live_ent_acme_...

System Account Keys

Arcus’s posting helpers (e.g. postFulfillmentEntries, postPaymentReceived) resolve accounts by a stable string key rather than account number:
ar_account       -> Accounts Receivable (1300)
cogs             -> Cost of Goods Sold (5000)
revenue          -> Sales Revenue (4000)
inventory_fg     -> Finished Goods Inventory (1410)
sales_tax_payable -> Sales Tax Payable (2100)
Use GET /v1/gl-accounts?system_account_key=ar_account to resolve any key to a UUID. System accounts cannot be created or modified via the API.

Journal Entries

Anatomy of a Journal Entry

Every journal entry (accounting.journal_entries) has:
  • entry_date — must fall within an open accounting period
  • description — human-readable label
  • source_type — identifies the business event (MANUAL, FULFILLMENT, PAYMENT, etc.)
  • lines — two or more lines; exactly one of debit or credit is > 0 per line
{
  "entry_date": "2025-06-15",
  "description": "Prepaid insurance June installment",
  "source_type": "MANUAL",
  "lines": [
    { "account_id": "<prepaid_expenses_uuid>", "debit": 500.00 },
    { "account_id": "<cash_operating_bank_uuid>", "credit": 500.00 }
  ]
}

The Two Invariants (P0)

1. Balance (DR = CR): The sum of all debit amounts must equal the sum of all credit amounts. Arcus enforces this in routes/gl.mjs::assertGLBalance() before any INSERT. A mismatch returns HTTP 422:
{
  "error": "gl_imbalance",
  "code": "gl_imbalance",
  "hint": "GL posting imbalance [MANUAL]: debits=500.00 credits=0.00 diff=500.0000"
}
2. Postable accounts only (Rule 21): Every line’s account_id must reference a leaf, active account owned by the entity. Arcus enforces this in assertGLLinesPostable() and also at the database layer via a BEFORE INSERT trigger (migration 165). Error codes:
  • account_is_header — you referenced a rollup account
  • account_inactive — account is deactivated
  • account_not_found — UUID does not exist for this entity
  • account_wrong_entity — account belongs to a different entity; entity isolation enforced
  • line_has_both_debit_and_credit — a single line has both values > 0

Approval Workflow

Your entity may have approval thresholds configured:
AmountBehavior
<= je_approval_threshold_lowAuto-approved, status = posted
> low thresholdRequires manager approval, status = pending_approval
> je_approval_threshold_highRequires senior approval
Owners and admins bypass the threshold and always auto-approve. Use POST /v1/journal-entries/:id/approve (scope accounting:approve) to approve. Separation of duties is enforced: the approver must differ from the creator.

Reversals

To undo a posted journal entry, use POST /v1/journal-entries/:id/reverse. This creates an inverted copy (all debits become credits and vice versa). The original entry is immutable — GAAP requires an audit trail, so Arcus never modifies or deletes posted entries. The reversal response is the new offsetting entry with is_reversing=true and reversal_of_id pointing to the original.

Idempotency

Pass an Idempotency-Key header on POST /v1/journal-entries to make creation safe against retries:
POST /v1/journal-entries
Idempotency-Key: je-monthly-rent-2025-06

Accounting Periods

The GL is segmented into accounting periods (accounting.accounting_periods). Each period has a start_date, end_date, and status of open, close_requested, or closed. Closed periods reject new postings. Attempting to create a JE with an entry_date inside a closed period returns HTTP 422 with code period_closed.

Close Workflow

Before closing, check the pre-close checklist:
GET /v1/periods/:id/close-checklist
The checklist reports blocking issues:
  • Unposted or draft journal entries
  • Pending-approval journal entries
  • Unreconciled bank accounts
  • Open vendor bills
Two-person separation of duties (recommended):
  1. POST /v1/periods/:id/request-close — initiates the request (scope: accounting:close_period)
  2. POST /v1/periods/:id/approve-close — a different user approves (scopes: accounting:close_period + accounting:approve)
Force-close (owner authority):
POST /v1/periods/:id/close

Financial Reports

All reports are synchronous (no job queue). Pass a period_id or date range:
EndpointWhat it returns
GET /v1/reports/trial-balanceDR and CR balance per GL account (leaf accounts only)
GET /v1/reports/profit-lossRevenue, COGS, gross profit, operating expenses, net income
GET /v1/reports/balance-sheetAssets, liabilities, equity as of a date
GET /v1/reports/cash-flowOperating, investing, financing cash flows
GET /v1/reports/account-ledgerRunning transaction history for one GL account
GET /v1/reports/ar-agingReceivables aging (current, 30, 60, 90, 90+ days)
GET /v1/reports/ap-agingPayables aging
GET /v1/reports/subledger-reconciliationGL balance vs AR/AP/inventory subledger

Trial Balance and the Balance Guarantee

The trial balance endpoint sums leaf accounts only (is_header=false). When the GL is in balance, is_balanced=true and total_debits == total_credits. Any imbalance indicates a data integrity issue (likely a header-account posting that bypassed the assertGLLinesPostable guard).

Bank Accounts

Bank accounts (accounting.bank_accounts) link a real bank account to a GL account. When Plaid sync is active, transactions are automatically pulled and can be matched during bank reconciliation.
POST /v1/bank-accounts
Content-Type: application/json

{
  "name": "First National Checking",
  "gl_account_id": "<cash_operating_bank_uuid>",
  "account_type": "checking"
}
Plaid fields (plaid_item_id, plaid_access_token, etc.) are read-only and managed by the Plaid connector in Settings > Integrations.

Recurring Journal Entries

Use recurring templates for entries that post on a fixed schedule (monthly depreciation, insurance amortization, rent, etc.):
POST /v1/recurring-journal-entries
Content-Type: application/json

{
  "name": "Monthly rent accrual",
  "frequency": "monthly",
  "next_run_date": "2025-07-01",
  "template_lines": [
    { "account_id": "<rent_expense_uuid>", "debit": 3000.00 },
    { "account_id": "<prepaid_expenses_uuid>", "credit": 3000.00 }
  ]
}
The scheduler posts the entry on next_run_date and advances by the frequency. Idempotency key per run: recurring:<template_id>:<next_run_date>. To post immediately, use POST /v1/recurring-journal-entries/:id/run.

Required API Key Scopes

ActionRequired Scope
Read GL accounts, JEs, reports, periods, bank accountsaccounting:read
Create / update GL accounts, JEs, bank accounts, recurring JEsaccounting:write
Approve / reject journal entriesaccounting:approve
Close, reopen, or request-close accounting periodsaccounting:close_period
Backfill JEs to a closed period (migration only)migration:write

Common Error Codes

CodeHTTPMeaning
gl_imbalance422DR sum does not equal CR sum
account_is_header422Line targets a rollup account (not postable)
account_inactive422Line targets a deactivated account
account_not_found422account_id does not exist for this entity
period_closed422entry_date falls in a closed accounting period
self_approval_forbidden422Approver is the same as the creator (SoD violation)
system_account_creation_forbidden403Cannot create system accounts via the API

See Also