Summary
Add a ClosePeriod bookkeeping use case that produces period-end closing entries and links them to every revenue / expense entry of the closing period through JournalRelation rows of type closes. This is the first M:N consumer of the relation table introduced in #15.
Depends on #15 (structural reversal tracking) being merged first.
Why
Period-end closing is the canonical M:N case the closes relation kind was reserved for: one closing entry references many original revenue/expense entries (or accounts) in the period. Without it, the relation between a period's nominal accounts and the closing entry that zeroed them lives only in tribal knowledge.
Domain mechanics (what closing does)
At period end, temporary accounts (revenue, expense) must be zeroed and their balance transferred to Retained Earnings (or an Income Summary account first). Real accounts (assets, liabilities, equity) carry forward untouched.
Typical three-step closing:
- Close all revenue accounts → Income Summary
- Close all expense accounts → Income Summary
- Close Income Summary → Retained Earnings
Each step produces one JournalEntry plus N JournalRelation rows of type closes pointing at the originals it summarized.
Design sketch
New domain types
bookkeeping.ClosePeriodIntent { PeriodID string }
bookkeeping.ClosePeriod use case (Validate / Execute / Handle)
- (No new LLM intent — this isn't a model-callable operation; see Trigger below.)
Use case flow
- Load
Period by ID; reject if not open
- Enumerate all posted entries in the period that touch revenue/expense accounts (via
LedgerRepository.Entries + a chart-of-accounts type lookup)
- Aggregate per-account net balances
- Build the closing
JournalIntent(s); validate
- Build the
JournalRelation bundle: one closes relation per source entry, all pointing from the closing entry to each contributing entry
- Publish a single
JournalPosted carrying entry + relations → Apply writes atomically (same path reversal uses today)
- Update
Period.Status = closed
Idempotency
Repeat invocations are a feature: schedulers retry. If Period.Status == closed already, the use case is a no-op. Validator already enforces no posting to closed periods so subsequent calls are naturally inert.
Trigger: scheduler, not LLM
ClosePeriod is rule-driven, not judgment-driven. Trigger options, in order of preference:
- External scheduler (cron / k8s
CronJob) invoking ledger close --period <id> — matches the existing seed / book-run CLI surface, keeps ledger as a leaf service
- Not an internal Go scheduler (leader-election noise, ops cost)
- Not a
bookkeeping.IntentKind — agent prompts shouldn't decide when books close
Period.End + Company.TimeZone (already IANA) compute the real cutoff; the cron expression itself runs in UTC. The CLI binary should refuse to close before Period.End has actually passed in the company's timezone.
New CLI
ledger close --period <id> — runs the use case against the configured repo, prints a JSON report (entries + relations created), non-zero exit on validation failure
Docs to update in the same PR
docs/architecture.md: add ClosePeriod to the Use Cases table; note the M:N pattern as the first non-reversal consumer of JournalRelation
AGENTS.md: extend the Structural reversal/correction tracking design decision to mention closes is now exercised
README.md: document the new ledger close command and recommended scheduler patterns
Out of scope (deliberately deferred)
- Soft close → review → hard close workflow (draft closing entries pending accountant approval). MVP does hard close.
- Reopening a closed period (write
unlinks-style reversal of the closing entry). Add when needed.
- Multi-currency translation adjustments (FX revaluation at period end). Tracked separately.
- Partial-period closes (e.g. quarterly across monthly periods). Out of scope.
- LLM-driven closing intent. Closing policy is too company-specific to delegate to a model.
Acceptance criteria
Summary
Add a
ClosePeriodbookkeeping use case that produces period-end closing entries and links them to every revenue / expense entry of the closing period throughJournalRelationrows of typecloses. This is the first M:N consumer of the relation table introduced in #15.Depends on #15 (structural reversal tracking) being merged first.
Why
Period-end closing is the canonical M:N case the
closesrelation kind was reserved for: one closing entry references many original revenue/expense entries (or accounts) in the period. Without it, the relation between a period's nominal accounts and the closing entry that zeroed them lives only in tribal knowledge.Domain mechanics (what closing does)
At period end, temporary accounts (revenue, expense) must be zeroed and their balance transferred to Retained Earnings (or an Income Summary account first). Real accounts (assets, liabilities, equity) carry forward untouched.
Typical three-step closing:
Each step produces one
JournalEntryplus NJournalRelationrows of typeclosespointing at the originals it summarized.Design sketch
New domain types
bookkeeping.ClosePeriodIntent { PeriodID string }bookkeeping.ClosePerioduse case (Validate/Execute/Handle)Use case flow
Periodby ID; reject if notopenLedgerRepository.Entries+ a chart-of-accounts type lookup)JournalIntent(s); validateJournalRelationbundle: oneclosesrelation per source entry, all pointing from the closing entry to each contributing entryJournalPostedcarrying entry + relations →Applywrites atomically (same path reversal uses today)Period.Status = closedIdempotency
Repeat invocations are a feature: schedulers retry. If
Period.Status == closedalready, the use case is a no-op. Validator already enforces no posting to closed periods so subsequent calls are naturally inert.Trigger: scheduler, not LLM
ClosePeriod is rule-driven, not judgment-driven. Trigger options, in order of preference:
CronJob) invokingledger close --period <id>— matches the existingseed/book-runCLI surface, keeps ledger as a leaf servicebookkeeping.IntentKind— agent prompts shouldn't decide when books closePeriod.End+Company.TimeZone(already IANA) compute the real cutoff; the cron expression itself runs in UTC. The CLI binary should refuse to close beforePeriod.Endhas actually passed in the company's timezone.New CLI
ledger close --period <id>— runs the use case against the configured repo, prints a JSON report (entries + relations created), non-zero exit on validation failureDocs to update in the same PR
docs/architecture.md: addClosePeriodto the Use Cases table; note the M:N pattern as the first non-reversal consumer ofJournalRelationAGENTS.md: extend the Structural reversal/correction tracking design decision to mentionclosesis now exercisedREADME.md: document the newledger closecommand and recommended scheduler patternsOut of scope (deliberately deferred)
unlinks-style reversal of the closing entry). Add when needed.Acceptance criteria
bookkeeping.ClosePerioduse case withValidate/Execute/Handleledger close --period <id>CLI commandclosesrelations applied atomically via existingJournalPosted→Applypathdocs/architecture.md,AGENTS.md,README.mdupdated in the same PRCronJobmanifest (or systemd timer) documented inREADME.mdunder a "Scheduling closings" section