# GL period-close — the machine-readable skill contract.
# The human tutorial is posts/enterprise_software/gl/gl-period-close.md (narrative that references this).
# check:skills binds the two: the post's `skill:` id must resolve here, its example
# param values must equal these defaults, and its tested badge must match tested_branches.
# Lives at skill/enterprise_software/gl/period-close.skill.yaml; served over MCP at
# dodil://skills/gl/period-close; executed by `dodil skill scaffold gl/period-close`.

id: gl/period-close
version: 1.0.0
summary: >-
  Close an accounting period on one DataK3 bucket — snapshot the per-account trial balance (grand total
  must net to 0.00), lock the period with a guarded posting path so no new operational journal routed
  through the guard can post into it (an enforced-by-convention close lock, not a table-level constraint),
  and roll net income (Σrevenue − Σexpense) into the retained-earnings equity account with a
  balanced closing journal that zeroes every P&L account. Pure SQL over gl/core's journals / journal_lines /
  ledger / accounts. Money is DECIMAL(18,2) (never double); every write is idempotent
  (INSERT … ON CONFLICT (<pk>) DO UPDATE, or data_table_upsert), so a re-close never double-rolls net income.
module: gl
category: enterprise-software
workflows: [trial-balance, period-lock, close-to-retained-earnings]
pillars: [sql]                      # trial balance + close are pure SQL aggregates — no graph traversal, no Models
overlays: [finserv, manufacturing]  # cross-industry base; finserv adds a segregation-of-duties close sign-off

# Binding to the narrative (check:skills verifies these resolve + agree)
post: posts/enterprise_software/gl/gl-period-close.md
route: /library/gl-period-close

params:
  bucket:                    { type: string, default: gl,      prompt: "Bucket name?" }
  functional_currency:       { type: string, default: USD,     prompt: "Functional (reporting) currency? (inherited from gl/core)" }
  period:                    { type: string, default: 2026-09, prompt: "Which period to trial-balance and close (YYYY-MM)?" }
  retained_earnings_account: { type: long,   default: 3200,    prompt: "Retained-earnings equity account id net income rolls into?" }

provides:
  tables:  [periods, trial_balance]
  owns_master: []                    # owns the period register + the trial-balance snapshot, not the masters
consumes:
  master:  [{ table: accounts, from: gl/core }, { table: journals, from: gl/core },
            { table: journal_lines, from: gl/core }, { table: ledger, from: gl/core }]

# THE finance-critical correctness rules this skill obeys (stated so builders can't drift):
#  - MONEY = DECIMAL(18,2). NEVER double — float drift breaks the trial-balance-nets-to-0.00 invariant.
#    VERIFIED LIVE 2026-09-03: trial-balance grand total = exact "0.00" string, no float tail.
#  - IDEMPOTENT WRITES = INSERT … ON CONFLICT (<pk>) DO UPDATE SET c=EXCLUDED.c, or data_table_upsert.
#    A bare re-INSERT of a committed PK raises duplicate-key SQLSTATE 23505 — it is NOT an upsert. The
#    trial-balance snapshot (PK period+account_id), the closing journal (PK journal_id,line_no) and the
#    ledger re-materialize (PK account_id) ALL write ON CONFLICT, so a re-close leaves net income rolled ONCE.
#  - PERIOD IMMUTABILITY: a period row (period_id PK, status open|closed) is the lock. An operational posting
#    (source <> 'close') into a closed period is REJECTED by a guarded INSERT … SELECT … WHERE the period is
#    not closed; the close engine's own closing entry (source = 'close') is the ONE permitted write into it.
#  - THE CLOSE: net income = Σrevenue − Σexpense over the period's posted lines; a balanced closing journal
#    debits revenue and credits expense (zeroing every P&L account) and credits retained_earnings_account by
#    net income (credit-normal equity grows). After close: revenue net = expense net = 0.00, equity += net
#    income, trial balance still 0.00. Concurrency: wrap the close in a SerializationFailure retry loop.

# The ordered scaffold plan. Each step's `tools` must resolve against dodil://commands (check:skills).
steps:
  - id: stub_masters
    title: Stub the masters you consume (standalone — skip if gl/core is present)
    when: standalone
    tools: [data_bucket_create, data_table_create, data_table_upsert]
    detail: >-
      Create bucket {{bucket}} + minimal stubs of the gl/core masters this skill reads: accounts(key
      account_id: name, type asset|liability|equity|revenue|expense, normal_balance debit|credit,
      parent_account_id long, currency, active boolean), journals(key journal_id: journal_date timestamp,
      period, source, status draft|posted|reversed, memo, reverses_journal_id long), journal_lines(composite
      key journal_id,line_no: account_id long, debit DECIMAL(18,2), credit DECIMAL(18,2), line_memo),
      ledger(key account_id: balance DECIMAL(18,2), currency, as_of timestamp). EVERY non-key column
      nullable:true (data table create defaults to NOT NULL, else a partial seed 500s). Seed a minimal
      balanced COA + a balanced opening journal (assets vs liabilities+equity) + two posted P&L journals in
      {{period}}: a sales journal (debit Cash, credit revenue) and an expense journal (debit expense, credit
      Cash) so net income (Σrevenue − Σexpense) is non-zero, then materialize the ledger ON CONFLICT. When
      gl/core is present it OWNS these masters — skip this step. Money is DECIMAL(18,2), never double.
  - id: tables
    title: Stand up the period register + the trial-balance snapshot
    when: always
    tools: [data_table_create, data_table_upsert]
    detail: >-
      Create periods(key period_id: status open|closed, opened_at timestamp, closed_at timestamp, locked_by)
      and trial_balance(composite key period,account_id: debit DECIMAL(18,2), credit DECIMAL(18,2), balance
      DECIMAL(18,2), as_of timestamp). Seed the period {{period}} as status=open. --merge-key required so a
      re-run upserts idempotently. Every non-key column nullable:true. trial_balance.balance is the signed net
      (Σdebit − Σcredit) per account for the period; SUM(balance) over the period IS the trial balance.
  - id: trial_balance
    title: Snapshot the trial balance (grand total nets to 0.00)
    when: always
    tools: [data_pg, data_sql]
    detail: >-
      For {{period}}, aggregate the posted journal_lines per account — SUM(debit), SUM(credit),
      SUM(debit) − SUM(credit) as balance — and snapshot into trial_balance with INSERT … SELECT …
      ON CONFLICT (period, account_id) DO UPDATE (idempotent; re-running the snapshot never duplicates a row).
      THE assertion: SUM(balance) over the period = exactly 0.00 (VERIFIED LIVE: total_debit = total_credit =
      1130000.00, grand total 0.00, 12 accounts). A non-zero total means the books don't tie — stop the close.
  - id: period_lock
    title: Lock the period (a closed period rejects new operational journals)
    when: always
    tools: [data_table_upsert, data_pg]
    detail: >-
      Mark {{period}} closed — upsert periods {status:closed, closed_at:<now>, locked_by:<controller>}. The
      lock is enforced by a GUARDED posting path: INSERT INTO journals … SELECT … WHERE :source = 'close'
      OR NOT EXISTS (SELECT 1 FROM periods p WHERE p.period_id = :period AND p.status = 'closed'). An
      operational journal (source <> 'close') dated in a closed period inserts 0 rows — REJECTED (VERIFIED
      LIVE: journal 100 into closed 2026-09 was absent afterwards; journal 101 into open 2026-10 was present).
      The close engine's own closing entry (source='close') is the ONE write the guard permits into a closed
      period. CAVEAT (do NOT overclaim as absolute immutability): periods.status='closed' is a guard
      CONVENTION, not a table constraint — a plain INSERT INTO journals … or a data_table_upsert that skips
      the WHERE guard is NOT blocked at the table level and silently reopens the books. Immutability holds ONLY
      if EVERY posting path routes through the guard; make gl/journal-entry's posting engine the sole writer
      (it always applies the guard) or require every writer to use it — an enforced-by-convention close lock.
      Concurrency: wrap the guarded post in a SerializationFailure retry loop (shown-as-code).
  - id: close_retained_earnings
    title: Close to retained earnings (roll net income, zero the P&L)
    when: always
    tools: [data_pg, data_sql]
    detail: >-
      Compute net income = Σrevenue − Σexpense over {{period}}'s posted non-close lines (VERIFIED LIVE:
      revenue 80000.00 − expense 50000.00 = 30000.00). Post a balanced closing journal (source='close', so
      the period lock permits it): DEBIT every revenue account by its credit balance and CREDIT every expense
      account by its debit balance (zeroing all P&L accounts), then CREDIT {{retained_earnings_account}} by
      net income (credit-normal equity grows). Write the lines with INSERT … ON CONFLICT (journal_id, line_no)
      DO UPDATE, then re-materialize the ledger ON CONFLICT (account_id). PROVE the closing journal balances
      (debit=credit=80000.00, balance 0.00), and after close revenue net = expense net = 0.00, retained
      earnings = opening −300000.00 + net income → −330000.00, trial balance still 0.00.
  - id: re_close
    title: Prove the re-close is idempotent (no double-roll)
    when: always
    tools: [data_pg, data_sql]
    detail: >-
      Re-run the close. A bare re-INSERT of a committed closing line (journal 900, line 6) raises SQLSTATE
      23505 — a re-INSERT is NOT an upsert. The correct re-close re-posts the 6 closing lines with
      INSERT … ON CONFLICT (journal_id, line_no) DO UPDATE and re-materializes the ledger ON CONFLICT
      (account_id). VERIFIED LIVE: close_lines stays 6, close journals stays 1, retained earnings stays
      −330000.00, revenue net = expense net = 0.00, trial balance 0.00 — net income rolled exactly ONCE.

# The Q&A an agent runs with the user (was `## Customize`). Each answer's effect = the best-practice encoding.
customize:
  - param: period
    ask: "Which accounting period do you want to trial-balance and close (YYYY-MM)?"
    effects:
      default: >-
        the period the close operates on (default 2026-09). The trial-balance snapshot aggregates this
        period's posted lines, the lock closes this period_id, and the closing journal rolls this period's
        net income. Every downstream number (trial balance, net income, retained earnings) re-derives from it.
  - param: retained_earnings_account
    ask: "Which equity account does net income roll into at close?"
    effects:
      default: >-
        the credit-normal equity account (default 3200 Retained Earnings) the closing journal credits by net
        income. Point it at a different equity account and the roll target moves; the P&L accounts still zero
        the same way. Net income = Σrevenue − Σexpense; a credit to a credit-normal account grows equity.
  - param: functional_currency
    ask: "What functional (reporting) currency is the ledger kept in? (inherited from gl/core)"
    effects:
      USD: >-
        the trial-balance snapshot and every closing-journal amount are in this currency; inherited from
        gl/core so the close never re-declares it. gl/multi-currency revalues foreign balances INTO it first.

# What proves it (was `## Test`) — assertions the CI replay runs on the default branch.
# Live-validated 2026-09-03, org IHDIASH, bucket glb-close-9r3t (torn down). Real returned values inline.
tests:
  - "trial_balance snapshot for 2026-09 → total_debit = 1130000.00, total_credit = 1130000.00, grand total SUM(balance) nets to 0 across 12 accounts (exact, no float tail)"
  - "period lock: journal 100 (source=manual, dated 2026-09-20) into CLOSED 2026-09 → guarded INSERT rejects it (absent afterwards); journal 101 (source=manual) into OPEN 2026-10 → accepted (present) — the guarded-write close lock (enforced by convention, not a table constraint)"
  - "net income for 2026-09 = Σrevenue − Σexpense = 80000.00 − 50000.00 = 30000.00"
  - "closing journal 900 balances → SUM(debit) = SUM(credit) = 80000.00, balance 0.00; source='close' so the period lock permits it"
  - "after close → revenue net = 0.00, expense net = 0.00 (P&L zeroed); retained earnings (3200) = opening −300000.00 + 30000.00 net income = −330000.00; trial balance SUM(ledger.balance) = 0.00"
  - "idempotent re-close: a bare re-INSERT of closing line (900,6) raises SQLSTATE 23505; the ON CONFLICT re-post + ledger re-materialize leaves close_lines = 6, close journals = 1, retained earnings −330000.00, trial balance 0.00 (no double-roll)"
  - "data connect {{bucket}} → prints pg.uk-lon-1.dodil.io:5432/{{bucket}} + bolt + grpc"

tested_branches:
  - { period: 2026-09, retained_earnings_account: 3200, functional_currency: USD, tested_at: 2026-09-03 }   # anchor — full replay: trial balance 0.00, closed-period-rejects-post, net income 30000.00 rolled to RE, idempotent re-close
  - { standalone: true, tested_at: 2026-09-03 }                                                             # stub_masters path — masters + balanced COA + 3 posted journals self-stubbed on the throwaway bucket, then the full close replayed

# Ship-it contract.
# NOTE (2026-09-03): gl/period-close is a pure-SQL workflow over gl/core's masters. The trial balance, the
# period lock guard, and the close-to-retained-earnings roll are all deterministic SQL aggregates over the
# pg wire (data_pg / data_sql) — NO Models, NO embeddings, NO graph traversal. The stateful piece, if
# deployed, is the period-close engine — an IMAGE-mode Ignite app (Lane B: a Dockerfile + --dockerfile-path,
# Kaniko build-on-deploy, HTTP server on $PORT with GET /healthz + a POST /close route) mirroring
# itsm-sla-monitor. A pure-SQL GL engine calls NO Models, so its service account needs only k3.editor +
# ignite.app-developer (NO ignite.model-user). DODIL_SERVICE_ACCOUNT_ID = the cli-… serviceAccountId from
# `auth service-account create`, NOT the internal uuid. This phase shows the close as data ops (shown-as-code
# for the handler); a dedicated live-deploy stress test runs later.
deploy:
  app: gl-period-close-engine
  mode: image            # Dockerfile + --dockerfile-path (NOT --runtime python / compile mode); shown-as-code this phase
  entrypoint: "http server (0.0.0.0:$PORT, /healthz + POST /close)"
  service_account:
    roles: [k3.editor, ignite.app-developer]   # pure SQL — NO ignite.model-user
    note: >-
      DODIL_SERVICE_ACCOUNT_ID = the cli-… serviceAccountId printed by `auth service-account create`,
      NOT the internal uuid (the uuid fails client_credentials with invalid_client).
  data_plane: "pg-wire pg.uk-lon-1.dodil.io:5432 (db = bucket, user = token, password = login/SA token) — idempotent write = INSERT … ON CONFLICT (<pk>) DO UPDATE (a bare re-INSERT of a committed PK raises duplicate-key 23505); no k3.dodil.io HTTP API"
  scheduling: "no server-side scheduler — run the close on your own cron hitting POST /close at period end, or pin an always-on operator loop warm (--reserved 1 --max-replicas 1)"
  models: []             # GL period-close is pure SQL — no Models
