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). Accounts are organized into a hierarchy:

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:

System Account Keys

Arcus’s automatic postings (fulfillment, payments received, and so on) resolve accounts by a stable string key rather than account number:
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 has:
  • entry_date — must fall within an open accounting period and inside the entity’s posting window (see below)
  • 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

The Two Invariants (P0)

1. Balance (DR = CR): The sum of all debit amounts must equal the sum of all credit amounts. Arcus checks this before it writes the entry. A mismatch returns HTTP 422:
2. Postable accounts only: Every line’s account_id must reference a leaf, active account owned by the entity. Arcus checks this on every posting path, and checks it again when the line is stored, so no path can write a line to a header or inactive account. 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

The Posting Window

Besides an open period, POST /v1/journal-entries checks the entry date against the entity’s posting window, which keeps a mistyped year from landing a manual entry months away from where it belongs:
  • Backdating: an entry dated further back than the entity’s backdate window (180 days by default) is refused with 422 backdate_beyond_allowed_range.
  • Future dating: an entry dated further ahead of today (UTC) than the entity’s forward window (0 days by default, so tomorrow is already outside it) is refused with 422 future_beyond_allowed_range. The body names the window (window_days) and says whether an override is available (override_available) and which permission it takes (required_permission).
To post outside the window on purpose, send a reason: backdate_reason for an older date or future_reason for a later one (4 to 500 characters). The override is granted only when the identity behind the call holds the accounting.close_period permission and an accounting period covers the date (a monthly period for a future date); the usual open-period check still applies. The grant and the reason are recorded on the entry’s audit row. A reason sent by an identity without that permission is refused 403, and the other refusals are 422 backdate_override_reason_invalid, future_override_reason_invalid, backdate_override_requires_accounting_period and future_override_requires_accounting_period. bypass_period_check is for migration only: it needs the migration:write scope, is silently ignored for every other key, and every use is audit-logged.

Approval Workflow

Your entity may have approval thresholds configured: Owners and admins bypass the threshold and always auto-approve. Use POST /v1/journal-entries/:id/approve (scope accounting:approve) to approve; it posts the entry on its own entry_date. Separation of duties is enforced: the approver must differ from the creator. The forward window is read again at approval, so an entry that was dated within it when created but now sits further ahead of today than the window is refused 422 future_beyond_allowed_range unless the approver sends future_reason on the same terms as above (the approver holds accounting.close_period, the reason is 4 to 500 characters, and an open monthly accounting period covers the date). A granted override is recorded on the approval’s own audit row. The approval can also be refused 422 future_override_reason_invalid, future_override_requires_accounting_period or future_override_period_check_failed.

Reversals

To undo a posted journal entry, use POST /v1/journal-entries/:id/reverse (scope accounting:write). 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 body is optional and takes one field, reason. It is written into the reversing entry’s description (Reversal of JE-00128: <reason>); without it the description reads Reversal of JE-00128: Reversed via API.
  • The reversing entry is dated the day of the call (UTC), not the original’s date.
  • The response is the new offsetting entry as it was posted: source_type is MANUAL_REVERSAL and source_id names the original. Arcus then links the two, storing is_reversing=true and reversal_of_id on the new entry and reversed_by_id on the original; read those links back with GET /v1/journal-entries/{id}.
  • Only a posted entry can be reversed (409 je_not_posted), an entry can be reversed once (409 je_already_reversed), and a reversing entry cannot itself be reversed (409 je_target_is_reversing; its original is already reversed, so post a correcting entry instead).
Bank lines the reversal frees. If a bank statement line was matched to the original entry, the reversal returns that line to the review queue: the match is cleared, the line goes back to unreconciled, and the entry’s leg on that bank account is no longer marked as cleared. The response lists every freed line in released_bank_transactions (an empty array when there were none), each with bank_transaction_id, bank_account_id, link_kind, and the line’s own transaction_date and amount. A payout line whose match had posted a settlement correction also carries settlement_corrections_still_posted: that correction stays posted, and only the bank line’s own undo reverses it. A line that sits in a completed reconciliation (status reconciled) is not released; re-open or cancel that reconciliation first.

Idempotency

Pass an Idempotency-Key header on POST /v1/journal-entries to make creation safe against retries:

Accounting Periods

The GL is segmented into accounting periods. Each period has a start_date, end_date, and a status: 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:
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). The period moves from open to pending_approval.
  2. POST /v1/periods/:id/approve-close is sent by a different user (scopes: accounting:close_period + accounting:approve). The body takes optional notes and acknowledge_old_period. Before it closes the period, the approval:
    • refuses when the approver is the user who requested the close (403 sod_self_approval_forbidden)
    • refuses when the period is not pending_approval (409 period_not_in_pending_approval)
    • re-runs the checklist and refuses while it reports blocking issues (409 close_blocked_by_checklist)
    • checks that the period’s posted debits equal its credits (409 period_checksum_failed when they do not)
    On success the period moves to closed.
Direct close (one call, no second approver):
A key with accounting:close_period closes the period in one call. The checklist still applies: while it reports blocking issues the call returns 409 close_blocked_by_checklist, with the blocking items in warnings. Old periods need an acknowledgement. Closing or approving the close of a period that ended more than 180 days ago requires "acknowledge_old_period": true in the body. Without it, both calls return 422 period_too_old_for_simple_close, carrying period_age_days and max_age_days_without_ack (180) so your client can show the period’s age. Closing an old period is routine on books that are behind; the flag only confirms you meant it. On a period within 180 days, omit it and nothing changes. The key acts as its creator. For the period calls above and for reversals, Arcus checks the key’s scope and also checks that the user who created the API key holds the matching permission in their role; if that user does not, the call returns 403 permission_denied.

Financial Reports

All reports are synchronous (no job queue). Most take a period_id or date range; AR aging is the exception (see below): AR aging, AP aging and the subledger reconciliation all age against the entity’s current business day and reject the deprecated as_of_date parameter with 400 unsupported_param, so omit it on those three reports.

AR Aging

GET /v1/reports/ar-aging (scope accounting:read) ages open receivables against the entity’s current business day. It returns every customer with an open balance in the standard buckets; the account_id and buckets parameters listed in the API reference are not applied by this release, so filter the rows on your side. It has no as-of rewind: as_of_date is rejected with 400 unsupported_param, so omit it. Each row and the totals block carry current_amount, days_1_30, days_31_60, days_61_90, days_90_plus, total and gross_total. For exposure net of open customer credit, each row adds credit_balance and net_total, and the totals block sums them as total_credit_balance and net_total. Billed not shipped. When the entity’s invoice timing (Settings > Payments & Checks > AR & Invoicing, Invoice Timing) is Per shipment or Invoice what has shipped (consolidated), every row and the totals block also carry billed_not_shipped: value invoiced for goods that have not left the warehouse yet. It sits outside current_amount, outside every day bucket and outside the overdue total, because nothing is owed until delivery, but it is inside total, so the six buckets still sum to total exactly and an existing tie to your outstanding AR figure is unaffected. With the other invoice timings the field is absent and the payload is unchanged.

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 posting to a header account that bypassed the postable-account check).

Bank Accounts

Bank accounts link a real bank account to a GL account. When a bank feed is connected, transactions arrive automatically and can be matched during bank reconciliation.
name and gl_account_id are required; gl_account_id must be a GL account owned by your entity. Optional fields are institution, is_ap_account (true when the account pays vendor bills) and account_type, which is checking, savings, money_market, cd, clearing or other on a detail GL account marked as a bank account, or credit_card, line_of_credit or loan on a credit-normal liability GL account (a card is credit_card, not credit, and a mismatch returns 422 gl_normal_balance_mismatch); a deposit_layout sent on create is ignored and the default slip layout is stored. The call needs scope accounting:write. Bank feed fields (plaid_item_id, plaid_access_token, and so on) are stripped from the request body. The feed itself is connected in the app, on Finance > Close & Reconcile > Bank Reconciliation, Bank Feed tab (Connect a bank). The create call returns 201 with the stored bank account; read the resolved sync_status and has_feed from GET /v1/bank-accounts/{id}. has_feed is true only when the account is mapped to a connected bank feed AND that bank connection still works: a mapped account whose connection was removed stops producing lines, so it reads false.

Recurring Journal Entries

Use recurring templates for entries that post on a fixed schedule (monthly depreciation, insurance amortization, rent, etc.):
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


Common Error Codes


See Also