API
HTTP endpoints and error contracts for the accounting service.
Ops
| Path | Method | Notes |
|---|---|---|
/healthz | GET | Liveness |
/readyz | GET | Readiness (Postgres ping) |
Versioned API
| Path | Method | Notes |
|---|---|---|
/v1/accounts | POST | Create account; requires Idempotency-Key |
/v1/accounts | GET | List accounts with after/limit pagination |
/v1/accounts/tree | GET | Read the account hierarchy |
/v1/accounts/{id} | GET | Read an account; system services may use this to validate posting account charts |
/v1/journal-entries/{id} | DELETE | Remove 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" }
}| Field | Required | Notes |
|---|---|---|
data.chart_code | Yes | Which chart (e.g. us) |
data.code | Yes | Account code unique within chart |
data.name | Yes | Display name |
data.classification | Yes | asset, liability, equity, revenue, expense |
data.is_postable | Yes | Whether journal lines may reference this account |
data.parent_code | No | Parent account code in same chart |
why | No | Audit 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:manualwhen a person created it,generatedwhen a service such as Billing or Payments did. A service creates its draft and posts it in a later call, so its draft returns409. Rows inserted directly, such as historical imports, aregenerated. - 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.