Skip to content

Sequencing Accrual Freeze and Retroactive Recalculation

The single most damaging class of month-end error is not a wrong rate — it is a right rate applied against a moving base. When a retroactive tier recalculation reads live volumes that are still shifting after cut-off, the recalculated accrual reconciles to nothing: not to the frozen ledger, not to the vendor statement, and not to a re-run an hour later. This page documents the correct close-time ordering — freeze an immutable snapshot at cut-off, then run retroactive tier recalculation against that frozen base, then compute the true-up delta, then export the audit pack — and shows exactly why doing any of it out of order corrupts the period. It is the implementation-level companion to the month-end close sequencing topic area inside Settlement, Deduction & Financial Close.

The ordering is not a style preference. Each stage produces the immutable input the next stage depends on, so the dependency graph is strictly linear and the barrier between freeze and recalc is load-bearing.

The mandatory close-time ordering: freeze, recalculate, true-up, export A vertical pipeline of five stages executed strictly in order. Stage one freezes an immutable snapshot of volumes and agreement versions at the period cut-off. A precedence barrier separates it from stage two: recalculation requested before the freeze completes is refused because it re-rates a moving base and corrupts the period. Stage two runs the retroactive re-rate reading only the frozen volumes with decimal math. Stage three computes the true-up delta as recalculated minus frozen. Stage four emits signed true-up entries to the general ledger. Stage five exports an audit pack bundling the snapshot hash, the deltas, and a stable pack hash. A right-hand callout marks the barrier as the corruption boundary. 1 Freeze snapshot at cut-off immutable · volumes + agreement versions + hash precedence barrier 2 Retroactive re-rate reads frozen volumes only · decimal.Decimal 3 Compute true-up delta delta = recalculated − frozen 4 Emit true-up entries signed adjustments → general ledger 5 Export audit pack snapshot hash + deltas + stable pack hash Out-of-order = corruption Recalc before freeze re-rates a base that is still moving. The result ties to nothing and changes on every re-run — the barrier refuses the request. Every stage is a pure function of the frozen snapshot — re-running the close reproduces byte-identical deltas and hashes.

Prerequisites

Before wiring the close sequence, confirm the following are in place:

  • A frozen snapshot store. An append-only, write-once store keyed on fiscal period. Once a period is frozen it is immutable; the store must refuse a second write to the same key rather than overwrite. This is where the volumes, the resolved agreement versions, and the pre-close accrual balances are pinned at cut-off.
  • Pinned agreement versions. Every accrual must reference the agreement_id and monotonic agreement_version in force at sale time, produced by agreement schema design. Recalculation that resolves a current agreement version instead of the frozen one silently re-rates a closed period under terms that did not apply.
  • A close calendar with an explicit cut-off timestamp. The cut-off is the instant that defines “frozen.” Late-arriving sales after the cut-off belong to the next period or to a formal true-up, never to a mutation of the current snapshot.
  • Python 3.11+ with pydantic>=2.6 and the standard-library decimal module. Every monetary and volume field is a decimal.Decimal parsed from a string, never a float.
  • Scoped roles. The reconciliation_close service role has write access to the snapshot store, the true-up ledger, and the audit-pack store, and read access to the agreement registry. It cannot post to the general ledger without a downstream accrual posting to the general ledger approval; releasing or unfreezing a snapshot requires a separate financial-override role.

Step-by-Step Implementation

Step 1 — Model the close sequence and enforce precedence

Encode the four phases as an ordered enum and gate every step behind a validator that refuses to advance until all lower phases are marked complete. The critical rule — recalculation cannot run before the freeze — lives here, in code, not in a runbook a tired analyst can skip at 11pm on close night.

python
from enum import IntEnum
from pydantic import BaseModel, model_validator


class Phase(IntEnum):
    FREEZE = 1
    RECALC = 2
    TRUE_UP = 3
    EXPORT = 4


class CloseStep(BaseModel):
    period: str                     # ISO 'YYYY-MM' fiscal period
    phase: Phase
    completed: set[Phase] = set()   # phases already finished for this period

    @model_validator(mode="after")
    def _enforce_precedence(self) -> "CloseStep":
        required = {Phase(p) for p in range(1, self.phase)}
        missing = required - self.completed
        if missing:
            names = ", ".join(sorted(m.name for m in missing))
            raise ValueError(f"{self.phase.name} blocked: {names} not completed")
        if self.phase >= Phase.RECALC and Phase.FREEZE not in self.completed:
            raise ValueError("recalc requested before freeze — refuse")
        return self

Validation check: construct CloseStep(period="2026-07", phase=Phase.RECALC, completed=set()) and assert it raises ValidationError; construct it with completed={Phase.FREEZE} and assert it validates. The barrier must be structural, so a mis-sequenced orchestration fails at construction rather than producing a plausible-looking corrupt number.

Step 2 — Take an immutable frozen snapshot at cut-off

At the cut-off timestamp, capture the volumes, the resolved agreement versions, and the pre-close accrual balances into the write-once store, and stamp the payload with a content hash. Everything downstream reads from this snapshot and nothing else.

python
import hashlib
import json
from datetime import datetime, timezone


def freeze_snapshot(period, volumes, agreement_versions, frozen_accruals, store):
    if store.exists(period):
        raise RuntimeError(f"snapshot for {period} already frozen; refuse to overwrite")
    payload = {
        "period": period,
        "cutoff_ts": datetime.now(timezone.utc).isoformat(),
        "volumes": {k: str(v) for k, v in volumes.items()},          # decimal strings
        "agreement_versions": dict(agreement_versions),               # scope_key -> version
        "frozen_accruals": {k: str(v) for k, v in frozen_accruals.items()},
    }
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    payload["snapshot_hash"] = hashlib.sha256(blob.encode()).hexdigest()
    store.put(period, payload)
    return payload["snapshot_hash"]

Validation check: call freeze_snapshot twice for the same period and assert the second call raises RuntimeError. Serialize the payload twice and assert the snapshot_hash is identical — a hash that drifts between serializations means a non-canonical field (an unordered set, a live timestamp inside the hashed blob) leaked in and the snapshot is not reproducible.

Step 3 — Run the retroactive re-rate against frozen volumes

Only now, with Phase.FREEZE complete, re-rate every scope against the frozen volumes. Retroactive tier logic re-rates the whole cumulative volume at the highest earned rate; the exact tier math is owned by payout structure modeling, and here it must consume the frozen agreement version, not a live registry lookup.

python
from decimal import Decimal, ROUND_HALF_UP


def rerate(snapshot: dict, registry) -> dict[str, Decimal]:
    recalculated: dict[str, Decimal] = {}
    for scope_key, vol_str in snapshot["volumes"].items():
        volume = Decimal(vol_str)                                  # frozen input, never live
        version = snapshot["agreement_versions"][scope_key]        # pinned at cut-off
        tiers = registry.tier_table(scope_key, version)            # resolved at frozen version
        amount = registry.rate_volume(volume, tiers)               # retroactive dispatch
        recalculated[scope_key] = amount.quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )
    return recalculated

Validation check: run rerate on the same snapshot twice and assert the two result dicts are equal to the cent. Assert every value is a Decimal, and that the registry was queried with the frozen version (not the current one) — mock the registry and verify the version argument matches snapshot["agreement_versions"].

Step 4 — Compute the true-up delta and emit entries

The true-up is the difference between the freshly recalculated accrual and the frozen pre-close balance, per scope. Compute it in Decimal, emit a signed entry only where the delta is non-zero, and reference the snapshot_hash so each entry is traceable to the exact base it was derived from.

python
def true_up(snapshot: dict, recalculated: dict[str, Decimal]) -> list[dict]:
    entries = []
    for scope_key, new_amt in recalculated.items():
        prior = Decimal(snapshot["frozen_accruals"].get(scope_key, "0"))
        delta = (new_amt - prior).quantize(Decimal("0.01"))
        if delta == 0:
            continue
        entries.append({
            "scope_key": scope_key,
            "period": snapshot["period"],
            "snapshot_hash": snapshot["snapshot_hash"],
            "prior_accrual": str(prior),
            "recalculated": str(new_amt),
            "true_up_delta": str(delta),           # signed: + accrue more, - reverse
        })
    return entries

A representative emitted entry:

json
{
  "scope_key": "VENDOR42:GROCERY_RETAIL:2026-07",
  "period": "2026-07",
  "snapshot_hash": "9f2c…a1",
  "prior_accrual": "12400.00",
  "recalculated": "13925.00",
  "true_up_delta": "1525.00"
}

Validation check: sum every true_up_delta and assert frozen_total + sum(deltas) == recalculated_total to the cent. This tie-out is the invariant that proves the true-up is complete and internally consistent; if it fails, a scope was dropped or a delta was computed against the wrong base.

Step 5 — Export the audit pack

Bundle the snapshot hash, the true-up entries, and the period totals into a single immutable audit pack and hash it. The pack is the artifact a controller or external auditor replays to reconstruct the close, and it is the input the downstream general-ledger posting step consumes.

python
def export_audit_pack(snapshot: dict, entries: list[dict], store) -> dict:
    total = sum((Decimal(e["true_up_delta"]) for e in entries), Decimal("0"))
    pack = {
        "period": snapshot["period"],
        "snapshot_hash": snapshot["snapshot_hash"],
        "true_up_entries": entries,
        "total_true_up": str(total.quantize(Decimal("0.01"))),
        "exported_ts": datetime.now(timezone.utc).isoformat(),
    }
    core = {k: pack[k] for k in ("period", "snapshot_hash", "true_up_entries", "total_true_up")}
    blob = json.dumps(core, sort_keys=True, separators=(",", ":"))
    pack["pack_hash"] = hashlib.sha256(blob.encode()).hexdigest()
    store.put_pack(snapshot["period"], pack)
    return pack

Validation check: export the pack twice from the same snapshot and entries and assert the pack_hash is identical (the volatile exported_ts is deliberately excluded from the hashed core). Assert total_true_up equals the independent sum of the entry deltas.

Common Failure Modes and Fixes

1. Recalculation runs before the freeze. The re-rate reads volumes that are still being ingested, so the accrual ties to no fixed base and changes on every run. Fix: gate recalc behind the CloseStep precedence validator from Step 1 — Phase.RECALC is unreachable until Phase.FREEZE is in completed. Orchestration that skips the freeze fails at construction.

2. Mutating a frozen snapshot. Patching a late sale or a corrected volume directly into the snapshot invalidates its hash and every downstream artifact derived from it. Fix: make the store write-once (the store.exists guard in Step 2), and route late corrections through a new true-up in a later period rather than an in-place edit. The snapshot is a fact about the cut-off, not a mutable working copy.

3. Non-idempotent re-run. Re-running the close appends duplicate true-up entries or double-posts deltas. Fix: key every stage on (period, snapshot_hash) and make emission upsert-by-key. Because each stage is a pure function of the frozen snapshot, a correct re-run reproduces byte-identical entries and the same pack_hash — a changed hash on re-run is the signal that a non-deterministic input leaked in.

4. Float drift in the delta. Computing recalculated - prior with float reintroduces double-precision rounding that pushes the true-up off by fractions of a cent, which aggregate into a material variance across thousands of scopes. Fix: keep every amount in decimal.Decimal, parse from strings, and quantize the delta explicitly. The tie-out in Step 4 catches any residual drift.

5. Missing or stale agreement version. Recalculating against the current agreement instead of the version frozen at cut-off re-rates a closed period under terms that were not in force. Fix: pin agreement_versions inside the snapshot (Step 2) and resolve tier tables by that frozen version during the re-rate (Step 3), never by a live registry default.

Operational Checklist

Frequently Asked Questions

Why must the freeze precede recalculation instead of running them together? Retroactive recalculation re-rates the full cumulative volume, so its output is only meaningful relative to a fixed base. If volumes are still moving when the re-rate runs, two invocations minutes apart produce different accruals and neither reconciles to the ledger. Freezing first pins the base so the recalculation is a pure, reproducible function of the snapshot.

What happens to sales that arrive after the cut-off? They never mutate the frozen snapshot. Depending on the close calendar they either belong to the next period or, if they correct an in-period claim, are handled as a formal true-up in a later period that references the prior snapshot_hash. The current period’s snapshot remains a permanent, immutable record of what was known at cut-off.

How do I guarantee two close runs produce the same numbers? Every stage is a pure function of the frozen snapshot: the re-rate reads frozen volumes at pinned versions, the delta is decimal-exact, and the audit pack excludes volatile timestamps from its hashed core. Identical snapshots therefore yield identical entries and an identical pack_hash; any divergence points to a live read that was not frozen.

Can I fix a wrong volume by editing the snapshot before export? No. Editing the snapshot breaks its hash and orphans every downstream artifact. Post the correction as a new true-up entry in a subsequent period, referencing the original snapshot hash it adjusts. The audit trail then shows both the original frozen state and the correction, which is exactly what an auditor needs to reconstruct the close.

Up one level: Month-End Close Sequencing