Skip to content

ClosePeriod use case: structural period-end closing via JournalRelation #16

@flarexium

Description

@flarexium

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:

  1. Close all revenue accounts → Income Summary
  2. Close all expense accounts → Income Summary
  3. 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

  1. Load Period by ID; reject if not open
  2. Enumerate all posted entries in the period that touch revenue/expense accounts (via LedgerRepository.Entries + a chart-of-accounts type lookup)
  3. Aggregate per-account net balances
  4. Build the closing JournalIntent(s); validate
  5. Build the JournalRelation bundle: one closes relation per source entry, all pointing from the closing entry to each contributing entry
  6. Publish a single JournalPosted carrying entry + relations → Apply writes atomically (same path reversal uses today)
  7. 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

  • bookkeeping.ClosePeriod use case with Validate / Execute / Handle
  • ledger close --period <id> CLI command
  • Closing entries + closes relations applied atomically via existing JournalPostedApply path
  • Idempotent: a second invocation against an already-closed period is a no-op
  • Tests cover: happy path, idempotency, refusal to close a future / still-open-in-its-timezone period, refusal when no revenue/expense activity exists
  • docs/architecture.md, AGENTS.md, README.md updated in the same PR
  • Example k8s CronJob manifest (or systemd timer) documented in README.md under a "Scheduling closings" section

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions