Flow
ServicesAccounting Service

API

HTTP endpoints and error contracts for the accounting service.

Ops

PathMethodNotes
/healthzGETLiveness
/readyzGETReadiness (Postgres ping)

Versioned API

PathMethodNotes
/v1/accountsPOSTCreate account; requires Idempotency-Key
/v1/accountsGETList accounts with after/limit pagination
/v1/accounts/treeGETRead the account hierarchy
/v1/accounts/{id}GETRead an account; system services may use this to validate posting account charts
/v1/journal-entries/{id}DELETERemove a mistaken entry with an audit snapshot; requires Idempotency-Key and a reason

Enveloped Success responses use the standard {header, payload} envelope, with the account under payload.data.

System principals may read an individual account to validate its chart before creating a posting. This permission does not grant account listing, chart mutations, or account-use changes.

Unguarded Requires Authorization: Bearer <jwt> once JWT middleware ships. Until then, local dev may use X-Dev-Actor as a temporary stand-in (not authentication). See docs/decisions/0013-jwt-validated-in-each-service.md.

Request body

The platform command shape: domain fields under data, optional why alongside. See docs/decisions/0019-the-handler-layer.md.

{
  "data": {
    "chart_code": "us",
    "code": "10000",
    "name": "ASSETS",
    "classification": "asset",
    "is_postable": false
  },
  "why": { "code": "chart.initial_setup" }
}
FieldRequiredNotes
data.chart_codeYesWhich chart (e.g. us)
data.codeYesAccount code unique within chart
data.nameYesDisplay name
data.classificationYesasset, liability, equity, revenue, expense
data.is_postableYesWhether journal lines may reference this account
data.parent_codeNoParent account code in same chart
whyNoAudit context propagated to the event

Errors

Failures use the same enveloped error payload as every other service.

OpenAPI: services/accounting/openapi.yaml.

Delete a journal entry

DELETE /v1/journal-entries/{id} accepts data.reason and optional data.related_entry_ids. All supplied entries and their lines are removed in one transaction after checking their posted periods and existing reversals. Related entries must carry the same non-null payment_id and legal entity as the entry in the URL. Unlinked entries cannot be deleted as a group. A reversal entry cannot be deleted, so deleting it cannot make its original reversible again. A missing related entry or any closed period refuses the whole operation. Deletion creates no offsetting journal; ledger reports exclude the removed lines.

The separate DeleteJournalEntry permission admits controllers, platform admins, and the Payments service principal. Payment-linked journals can only be deleted by Payments; controllers must delete the source payment so the receipt and allocations are removed too. Entries whose lines reference an invoice or charge cannot be deleted directly through this route. Each removed entry retains a complete audit snapshot and emits journal_entry.deleted with its lines, reason, and deletion time. The same idempotency key replays its original result after deletion or a later period close; changing the path ID or body conflicts. The event preserves the command's optional why. Its free-text reason and snapshot are classified as internal PII data and excluded from the non-PII event feed. The outbox publishes them to accounting-accounting-pii-fanout. Migration rollback is refused while deletion audit records exist.

Journal creation accepts an optional data.payment_id, retained in the entry and deletion snapshot. Migration 0021 links existing original payment entries using the Payments service's committed payment-{id} commands. The same exact key links new requests from that service without changing their replay hash. Prepay entries supply the payment ID explicitly. Rollback also refuses to discard payment journal links.

Entry mutation locks serialize posting, reversal, and deletion. Period locks serialize those ledger mutations with closing or reopening the same entity month. The current period model has no soft-close or bank-reconciliation state.

Check journal-entry deletion

GET /v1/journal-entries/{id}/deletion-check checks the same delete permission, open periods, absence of reversals, and related-entry relationships as deletion, without changing the ledger. Optional related_entry_ids is a comma-separated list. Success returns allowed: true. Payments uses it before changing Billing; the deletion endpoint rechecks the conditions at commit time.

Entity calendar setup and current period

POST /v1/legal-entities/{id}/calendar initializes an entity once from verified data.timezone (an IANA timezone) and data.books_start_month (the first day of a month no later than the current month in that timezone). It requires an Idempotency-Key, an active legal entity, and an existing controller or platform-admin person. Configuration and the period.calendar_configured audit event commit together. Replaying the same command returns its original response; a new attempt to overwrite configuration returns 409 calendar_already_configured.

GET /v1/period-calendar/current?legal_entity_id={id} returns legal_entity_id, timezone, books_start_month, local_date, and open_month. Staff and system services can read it. The Open month is calculated from the server clock in the entity timezone; it changes at local midnight on the first day without a rollover job. Missing configuration returns 422 period_calendar_not_configured. Invalid calendar values return 422 invalid_timezone or 422 invalid_books_start_month; an inactive entity returns 422. Malformed or empty fields may fail request-schema validation first. Rejected values are not persisted. The books start month must also form a continuous sequence with existing closed months; configuration rejects a gap before saving it.

POST /v1/legal-entities/{id}/calendar/timezone corrects a timezone that was entered wrongly for an already configured, active entity. It requires a controller or platform-admin person, an Idempotency-Key, and a nonblank why.detail. A missing or blank reason returns 422 reason_required before taking a command or period lock or writing anything. The period.calendar_timezone_changed audit event records the previous and new timezones and the reason.

The corrected timezone applies to new system-set dates and current-period reads immediately. Dates already set under the earlier timezone, including created dates, online-payment transaction dates, and rent dates on the 1st, are not restated. books_start_month, closed periods, and existing postings are unchanged.

Near a month boundary, a correction can change the current Open month, including moving it backward. A soft-closed month can become Open again, while postings already made to the later month remain there. A correction that would make a hard-closed month current is rejected with 422 current_period_closed; invalid closure history returns 422 invalid_closed_period_prefix.

POST /v1/legal-entities/{id}/calendar/books-start corrects data.books_start_month for a configured, active entity. It uses the same permission and idempotency rules as timezone correction and requires nonblank why.detail. The new start must be the first day of a month no later than the current entity-local month. Moving it later is rejected with 422 books_start_excludes_postings if it would exclude an existing posted journal entry. Validation uses its post month, with entry date as the fallback for legacy entries without a post month. Drafts do not block correction: they have no post month until posted and can keep an earlier transaction date while posting into the current Open month. Choosing a post month before the corrected start returns 422 period_out_of_range; the draft remains unchanged and can still be posted to an allowed month. The resulting closed months must still form a continuous sequence from the new start and end before the current month (422 invalid_closed_period_prefix). Reopen conflicting months through the existing workflow before correcting the start.

The correction leaves postings, timezone, and closure rows unchanged. It takes the exclusive entity lock shared with calendar commands and coordinated with posting. The update and period.calendar_books_start_changed event commit together; the event records the previous and new start, actor, and reason. An unchanged value emits no event. Migration rollback refuses to remove this event type while its audit exists.

Close requires a configured calendar and the earliest unclosed past month. Controllers cannot close the current month. Reopen requires why.detail and also reopens every later closed month. Both commands require a person with Controller or Admin access. Invalid historical closure gaps block close; a controller can reopen recorded months to clear those rows before closing again. If legacy closures include the current month, a controller can reopen it to remove that closure and all later ones, restoring normal calendar reads. This repair path also works before the entity calendar is configured.

Editing a draft journal entry

A draft is a journal entry that hasn't been posted. It isn't in the ledger, balances or reports. POST /v1/journal-entries/{id}/draft replaces a draft's description, transaction date and lines in one step. The legal entity can't change.

  • The lines can be unbalanced. Posting checks that debits equal credits.
  • Each line needs a postable account in the entity's chart and a positive amount.
  • A line can keep its property, unit, lease and billing account. It can't carry an invoice or charge, because those mark entries made by Billing.
  • Only a manual draft can be edited. Each entry has a source: manual when a person created it, generated when a service such as Billing or Payments did. A service creates its draft and posts it in a later call, so its draft returns 409. Rows inserted directly, such as historical imports, are generated.
  • Posted entries, reversals, and entries made by a payment, charge or invoice also return 409.
  • The same people who can create journal entries can edit drafts.

Each edit writes a journal_entry.draft_updated event with the draft's full content. Lines are replaced, so their IDs change with every edit.

Journal posting month

POST /v1/journal-entries/{id}/post assigns post_month from the entity's current Open month. Drafts have no post month. Transaction dates and original postings stay unchanged; retries return the original result. POST /v1/journal-entries/{id}/reverse uses an explicit entry_date as the reversal draft's transaction date. Without one, it needs the entity calendar to choose the entity-local date.

People may supply ?post_month=YYYY-MM-01: Controllers can use Soft-closed months; Controllers and Senior Accountants can use future months except for charges. A verified global Super Admin grant permits closed months and future charges. Services always use Open. Posting requires a configured calendar; Super Admin overrides require a verified global grant. Role labels alone do not grant that override.

Report periods

Balances, trial balances and balance sheets use post_month; legacy posted rows without one use their original entry_date month. Entity trial-balance date bounds select whole months. The account and consolidated balance endpoints accept as_of_month=YYYY-MM and include that entire posting month; they do not offer a day-level balance cutoff. Journal lists expose post_month and occurred_on, accept from_post_month/to_post_month, and keep from_date/to_date as transaction-date filters. Drafts have no posting period and do not appear in month-filtered results.

Period history

GET /v1/periods/history?legal_entity_id=<uuid> returns the entity's close and reopen records, newest first. Each record contains event_type, year, month, occurred_at, actor_fid, and reason. A cascading reopen produces one record per affected month. The reason is empty when none was supplied for a close.

Person principals with controller or platform-admin may read this endpoint. It uses pagination[page] and pagination[pageSize] (defaults 1 and 25, maximum size 100), with payload.data and payload.meta.pagination. An unknown entity returns 404. History reads existing period events; it does not derive calendar state from events or grant access to the general event feed.

On this page