Month-End Close Sequencing
Period-end close is where a rebate reconciliation system either holds together or quietly falls apart. Every accrual computed during the month, every deduction routed for recovery, and every vendor payment applied has to be frozen, re-rated against final sales, trued up to the cent, posted to the general ledger, and exported to audit — and the order in which those operations run is not a matter of taste, it is a financial control. Run a retroactive tier recalculation before the accrual base is frozen and the numbers move underneath the calculation; post to the ledger before the true-up completes and the period closes on a stale figure; export the audit package before the payment application reconciles and the close log lies. Within the broader settlement, deduction and financial close discipline, this topic area owns one specific and recurring pain point for finance analysts: the controlled sequence of operations that a month-end close must follow, and the machinery that makes that sequence deterministic, resumable, and defensible.
This is the reference for that sequencing problem. It describes the close-run and close-step entities that model the sequence, the dependency graph that fixes the order, the cut-off and lock semantics that freeze the accrual base, the decimal.Decimal true-up arithmetic that reconciles modeled to posted amounts, the Pydantic-enforced patterns that make a close idempotent and resumable after a mid-run failure, and the drift, dispute, and governance controls that let trade finance sign the close. The audience is concrete: trade finance analysts who own the close calendar, Python ETL developers who build the close orchestrator, and vendor managers who answer for a disputed true-up. The upstream accrual figures this stage freezes are produced by the core architecture and promotion mapping layer; the tier math it re-rates is defined by payout structure modeling; and the journal entries it posts are handed to the accrual posting and GL integration layer.
Positioning Within the Reconciliation Architecture
Close sequencing sits at the tail of the reconciliation pipeline, downstream of every stage that produces a number and upstream of the ledger and the audit file. During the month the mapping and rating layers accrue continuously — each cleared transaction generates or updates an accrual as sales arrive. Close is the moment that continuous stream is halted, snapshotted, reconciled against final figures, and committed. The defining constraint is that close is not one operation but an ordered chain of operations with hard dependencies between them, and the value of modeling it as an explicit sequence is that the order becomes a property of the data rather than of an analyst’s memory or a spreadsheet macro run in the wrong tab.
Two commitments make this safe. First, the accrual base is frozen before anything reads it: cut-off sets a locked_at timestamp and computes a content hash over the accrual set, and every downstream step reads that frozen, hashed snapshot rather than the live table, so a late-arriving transaction cannot mutate the base a recalculation has already consumed. Second, each step is a pure function of the frozen inputs and its declared upstream steps — cut-off and freeze, then retroactive tier recalculation, then true-up, then GL post, then payment application, then audit export — so the orchestrator can prove the order is legal, resume a half-finished run, and replay a step without side effects. The retroactive re-rate this stage performs depends directly on the tier definitions owned by payout structure modeling, and the clean, UTC-anchored sales facts it measures against are delivered by the data ingestion normalization pipelines. The mechanics of the freeze-and-recalculate step in isolation — how the accrual snapshot is taken and how retroactive tier crossings are re-rated against final volume — are worked end to end in sequencing accrual freeze and retroactive recalculation.
Entity Topology and Schema Specification
A close is deterministic only if the sequence it runs is itself a persisted, hashable entity. Each period resolves to one close run and many close steps, and the keys binding them — plus the dependency edges between steps — must be assigned when the run is planned, never inferred at execution time. The close run is the immutable record of what period is closing and under what lock; the close step is a single node in the sequence with an explicit depends_on set, a status, and an idempotency key that makes its execution replay-safe. The table below specifies the minimum schema the orchestrator persists; treat it as a versioned interface, where a new nullable field is backward-compatible while renaming or retyping an existing one is a breaking change that bumps the run schema version.
| Field | Type | Entity | Constraint |
|---|---|---|---|
close_run_id |
str (ULID) |
CloseRun | immutable, primary key |
period |
str (YYYY-MM) |
CloseRun | fiscal period; one open run per period |
status |
enum |
CloseRun | planned, running, locked, posted, failed, closed |
locked_at |
datetime (UTC) |
CloseRun | set at accrual freeze; null before cut-off |
input_hash |
str (SHA-256) |
CloseRun | content hash of the frozen accrual base |
step_id |
str (slug) |
CloseStep | stable, e.g. accrual_freeze, retro_recalc |
run_seq |
int |
CloseStep | monotonic execution order within the run |
depends_on |
list[str] |
CloseStep | upstream step_ids; edges must form a DAG |
status |
enum |
CloseStep | pending, running, done, failed, skipped |
idempotency_key |
str (SHA-256) |
CloseStep | hash of close_run_id + step_id + input_hash |
true_up_delta |
Decimal |
CloseStep | recalculated − posted, scale 2, signed |
started_at / completed_at |
datetime (UTC) |
CloseStep | null until executed |
approver_role |
str |
CloseStep | role required to advance; enforces segregation of duties |
The idempotency_key is what makes a re-run resumable: because it is a deterministic hash over the run id, the step id, and the frozen input_hash, re-invoking a completed step recomputes the same key, finds the existing done record, and returns it as a no-op instead of double-posting a journal entry. The depends_on edges are the sequence made explicit — the orchestrator topologically sorts them into run_seq and refuses to execute a step whose upstream dependencies are not yet done. The true_up_delta is held as Decimal from the moment it is computed, so the reconciling adjustment carries no rounding drift into the ledger.
Conditional Logic and Rule Integration
Before any step runs, the orchestrator evaluates a deterministic gate that decides whether the step is eligible and which branch the run takes. The gate encodes three families of predicate, and getting them right is the difference between a clean close and a restated one.
Lock and cut-off predicates. The accrual freeze is a one-way transition. Cut-off is triggered by an explicit close-calendar event, not by wall-clock time, so the freeze is auditable and repeatable in a replay. At freeze the run computes input_hash over the accrual set, stamps locked_at, and moves to status = locked; from that point any write against a frozen accrual is rejected. A step that attempts to read the live accrual table after locked_at is set is a hard error — steps read the snapshot keyed by input_hash, never the mutable source — because the entire correctness argument for the close rests on the base not moving after it is frozen.
Dependency and precedence predicates. No step executes until every step_id in its depends_on set has status = done. This is what fixes the canonical order: retro_recalc depends on accrual_freeze, true_up depends on retro_recalc, gl_post depends on true_up, payment_apply depends on gl_post, and audit_export depends on payment_apply. Because the edges are data, the orchestrator validates that they form a directed acyclic graph at plan time and rejects a run whose dependencies contain a cycle or whose precedence would let a post precede its true-up.
Idempotency and resumption predicates. On every step invocation the orchestrator checks whether a done record already exists for the computed idempotency_key. If it does, the step short-circuits; if the prior attempt is failed, the step re-runs from a clean slate because it wrote nothing durable. This is what lets a close that died at step four resume at step four rather than restart from freeze.
{
"close_gate": {
"freeze": {"trigger": "close_calendar_event", "sets": ["locked_at", "input_hash"], "one_way": true},
"precedence": {"execute_when": "all(dep.status == 'done' for dep in depends_on)"},
"idempotency": {"skip_when": "exists(step where idempotency_key == key and status == 'done')"}
},
"reject_on": ["read_live_accrual_after_lock", "dependency_cycle", "post_before_true_up"]
}
Financial Settlement Layer
The true-up is where close either reconciles to the cent or introduces error that surfaces as a vendor dispute months later, so the arithmetic rules here are strict. The true-up delta is the difference between the accrual as re-rated against final volume and the accrual already posted during the month, and it is computed entirely in decimal.Decimal. A retroactive tier means crossing a breakpoint re-rates the entire earned volume at the higher rate, so the recalculated figure at close can differ materially from the sum of the monthly incremental accruals — and that difference is the true-up. Routing any part of this through float would inject platform-dependent rounding drift that is invisible on one line but material across a full period’s accrual base, and because IFRS 15 / ASC 606 treatment requires the posted figure to be reproducible to the cent, float is forbidden anywhere in the settlement path.
The delta is signed: a positive delta is an additional accrual to post, a negative delta is a release. Both are journal entries, never silent overwrites of the original accrual, so the ledger records how the period reconciled rather than only its final state. The example below shows the only acceptable idiom — string in, Decimal out, explicit quantize.
from decimal import Decimal, ROUND_HALF_UP, getcontext
getcontext().prec = 28
def retroactive_accrual(volume: Decimal, tiers: list["Tier"]) -> Decimal:
"""Crossing a breakpoint re-rates the ENTIRE final volume at the highest earned rate."""
earned = next(t.rate for t in reversed(tiers) if volume >= t.lower_bound)
return (volume * earned).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def true_up_delta(final_volume: Decimal, tiers: list["Tier"], posted: Decimal) -> Decimal:
"""Signed reconciling adjustment: recalculated accrual minus what was already posted.
Positive -> post additional liability; negative -> release. Never a float, never an overwrite."""
recalculated = retroactive_accrual(final_volume, tiers)
return (recalculated - posted).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Because the true-up reads the frozen accrual base and the final volume from the same snapshot, re-running the step yields a byte-identical delta — a property the close log depends on to prove that a posted adjustment was not manually nudged between attempts.
ETL Implementation Patterns
Enforcement begins at the model boundary. Model the close run and its steps with Pydantic v2 so a cyclic dependency, an out-of-precedence edge, or a float true-up fails fast at plan time instead of corrupting a live close. The validator below rejects the two failures that most often cause a botched close: a dependency graph that is not acyclic, and a canonical precedence violation such as a GL post that does not sit downstream of the true-up.
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator, model_validator
CANONICAL_ORDER = ["accrual_freeze", "retro_recalc", "true_up", "gl_post", "payment_apply", "audit_export"]
class StepStatus(str, Enum):
PENDING = "pending"; RUNNING = "running"; DONE = "done"; FAILED = "failed"; SKIPPED = "skipped"
class CloseStep(BaseModel):
step_id: str
depends_on: list[str] = []
status: StepStatus = StepStatus.PENDING
true_up_delta: Decimal | None = None
@field_validator("true_up_delta", mode="before")
@classmethod
def _no_float(cls, v: object) -> object:
if isinstance(v, float):
raise ValueError("true_up_delta must arrive as str or Decimal, never float")
return v if v is None else Decimal(str(v))
class CloseRun(BaseModel):
close_run_id: str
period: str
steps: list[CloseStep]
@model_validator(mode="after")
def _acyclic_and_ordered(self) -> "CloseRun":
index = {name: i for i, name in enumerate(CANONICAL_ORDER)}
step_ids = {s.step_id for s in self.steps}
for step in self.steps:
for dep in step.depends_on:
if dep not in step_ids:
raise ValueError(f"{step.step_id} depends on unknown step {dep}")
if index.get(dep, -1) >= index.get(step.step_id, 10**6):
raise ValueError(f"precedence violation: {step.step_id} cannot depend on {dep}")
# Kahn's algorithm: a topological sort exists iff the graph is acyclic
incoming = {s.step_id: set(s.depends_on) for s in self.steps}
ready = [n for n, deps in incoming.items() if not deps]
resolved = 0
while ready:
n = ready.pop()
resolved += 1
for m, deps in incoming.items():
if n in deps:
deps.discard(n)
if not deps:
ready.append(m)
if resolved != len(self.steps):
raise ValueError("close-step dependencies contain a cycle")
return self
Persistence uses idempotent upserts keyed on idempotency_key, so re-invoking a completed step is a no-op rather than a duplicate journal entry — the single most common source of a doubled true-up in hand-rolled close scripts. A run that fails mid-sequence is resumable because durable state advances only when a step reaches done; a failed step wrote nothing and re-runs cleanly from its frozen inputs. The same discipline that governs the rating layer in the core architecture and promotion mapping reference — pure functions, deterministic hashing, version-anchored config — is what makes the close orchestrator safe to replay.
Drift Detection and Validation
A close is trustworthy only if the sequence it actually ran matches the sequence it was planned to run. Drift detection compares the executed order and the frozen inputs against the declared DAG and quarantines any anomaly rather than letting the close proceed — the close is held, not silently continued, because a close that ran out of order is a restatement waiting to happen. Every held run keeps its drift reason and raises an exception so nothing is lost and nothing is committed on a corrupt sequence.
| Drift signal | Detection rule | Action |
|---|---|---|
| Out-of-order execution | a step reached running before all depends_on were done |
hold close, raise SEQUENCE_VIOLATION |
| Post-freeze mutation | accrual row changed after locked_at; input_hash no longer matches snapshot |
hold close, raise FROZEN_BASE_MUTATED |
| True-up mismatch | recomputed delta ≠ persisted delta on replay | hold close, raise TRUE_UP_DRIFT |
| Missing dependency | a done step references an upstream step that never ran |
hold close, raise BROKEN_DAG |
| Duplicate post | a gl_post idempotency_key seen more than once |
reject entry, raise DOUBLE_POST |
| Unbalanced period | posted accruals + true-up ≠ control total for the period | hold close, flag for review |
Catching these before the ledger commits is what preserves the integrity of the period: a sequence violation caught at close time is a re-run, while the same corruption discovered after the books are published is a restatement and an audit finding. When drift exceeds tolerance the orchestrator triggers an exception workflow that routes the run to the close owner with the offending step_id and input_hash attached, and the period stays open until the drift is resolved.
Fallback and Dispute Routing
A step failing mid-close is inevitable — an ERP endpoint times out during GL post, a remittance file is late for payment application, a recalculation surfaces a tier that was amended after freeze — so the close degrades deterministically rather than aborting the whole run. Failures are categorized and routed by tier, never dropped. Transient infrastructure failures (a GL API timeout, a locked ledger table) mark the step failed, leave all completed steps intact, and make the run resumable from the failed step once the dependency recovers. Reconciliation failures (a true-up delta outside a materiality threshold, an unbalanced period) route to the trade finance review queue with the modeled and posted figures attached, because the number itself is in question and no analyst should post blind. Contract-context failures (a retroactive amendment that lands after the accrual was frozen) route to the vendor dispute workflow, since the freeze deliberately excluded the change and the adjustment must be adjudicated rather than silently absorbed. Each failure payload carries a deterministic code, the close_run_id and step_id, and a suggested remediation path; the run is held with its partial state preserved so the close can continue exactly where it stopped once the exception clears. The adjudication mechanics of the recovery queues — unauthorized deductions, shortpay chargebacks, and default-rate policy — are owned by the deduction and shortpay routing layer; the close orchestrator’s job is to name the correct lane, hold the period, and record why the sequence could not complete cleanly.
Security and Access Boundaries
A close is a system of financial record, so its sequence is governed as a SOX control, not a convenience script. Segregation of duties is enforced through the approver_role on each step: the analyst who runs the retroactive recalculation cannot approve the GL post, and the developer who deploys the orchestrator cannot advance a live close — the role that executes and the role that approves are distinct, and the orchestrator refuses to advance a step whose approver matches its runner. The close log is immutable and hash-chained: every step transition writes an append-only entry recording the idempotency_key, input_hash, the actor, the timestamp, and the resulting status, and because entries are chained by hash, any retroactive edit to a closed period breaks the chain and surfaces in the next reconciliation rather than passing unnoticed. Role-based access control tags travel with every entity so authorization is enforced per action: trade finance analysts can run and adjudicate close steps and export the audit pack but cannot mutate a posted journal entry; ETL developers can deploy orchestrator versions but cannot advance or approve a live run; vendor managers can view the close status and dispute a true-up but cannot alter a frozen accrual. Ledger credentials rotate on a fixed schedule, and the frozen accrual snapshot is encrypted at rest with its input_hash as the tamper anchor. Treating month-end close as a versioned, access-governed, hash-chained sequence is what turns a fragile spreadsheet ritual into an audit-ready control: faster closes, fewer restatements, and a defensible trail from frozen accrual to posted period.
Frequently Asked Questions
Why does the order of close steps matter so much — can’t they run in parallel?
Because each step consumes the output of the one before it. Retroactive recalculation must read a frozen accrual base, the true-up must read the recalculated figure, the GL post must read the true-up delta, and payment application must read the posted ledger. Running them out of order means a step reads a number that has not been finalized, which produces a stale post and, once the books are published, a restatement. The depends_on edges make the order a validated property of the run, not a convention an analyst has to remember.
How does a close resume after it fails halfway through?
Durable state advances only when a step reaches done, and every step carries an idempotency_key hashed from the run id, the step id, and the frozen input hash. On resume, the orchestrator recomputes each key and skips any step already marked done, so a run that died at the GL post continues from the GL post rather than re-freezing and re-rating. A failed step wrote nothing durable, so it re-runs cleanly from the same frozen inputs.
What stops a late transaction from changing the numbers after cut-off?
The accrual freeze. At cut-off the run stamps locked_at and computes an input_hash over the accrual set, and every downstream step reads that hashed snapshot rather than the live table. A write against a frozen accrual is rejected, and if a row changes anyway the input_hash no longer matches and drift detection raises a frozen-base-mutated exception that holds the close.
Why compute the true-up in decimal.Decimal instead of float? The true-up is the signed difference between the accrual re-rated against final volume and the amount already posted, and it becomes a journal entry the ledger must reproduce to the cent. Float introduces platform-dependent rounding drift that is invisible on one line but material across a full period, and IFRS 15 / ASC 606 treatment requires the posted figure to be reproducible, so the delta is computed from string inputs into Decimal with an explicit quantize and ROUND_HALF_UP.
Related
- Accrual Posting & GL Integration — how the journal entries this sequence produces are posted to and reconciled against the general ledger.
- Payment Settlement Reconciliation — matching vendor payments to the open accruals the close applies during payment application.
- Deduction & Shortpay Routing — the recovery queues that adjudicate the disputes a held close routes out.
- Reconciliation Reporting Automation — generating the period-end reports and evidence pack from the immutable close log.
- Sequencing Accrual Freeze and Retroactive Recalculation — the freeze snapshot and retroactive re-rate steps worked end to end.
Up one level: Settlement, Deduction & Financial Close