Accrual Posting & GL Integration
A modeled rebate accrual is an opinion until it becomes a balanced journal entry sitting in the general ledger. Everything upstream — the agreement, the tier math, the window checks — produces a number that says this much trade spend was earned this period; posting is the stage that converts that number into a debit and a credit the finance organization will actually close its books on. Within the settlement, deduction & financial close discipline, this topic owns one narrow but unforgiving responsibility: taking a validated accrual figure and emitting a double-entry journal that balances to the cent, posts exactly once, reverses cleanly when the underlying model is restated, and ties back to the sub-ledger it came from. Get it wrong and the failures are the kind auditors write findings about — a replayed batch that double-books a liability, an off-by-a-penny rounding drift that leaves debits and credits unequal, a true-up that silently overwrites the original posting so no one can reconstruct what changed.
This is the canonical reference for that posting stage. It specifies the journal-entry schema the poster emits, how rebate economics map onto debit and credit accounts under ASC 606 and IFRS 15 variable-consideration treatment, the balanced-entry invariant enforced with decimal.Decimal, the idempotent posting key that makes a replay a no-op, the reversal and true-up mechanics used on restatement, and the sub-ledger-to-GL tie-out that proves the two agree. It consumes the write-once accruals produced by payout structure modeling in the broader core architecture / promotion mapping design, and it hands a posted, referenced journal to the month-end close. The audience is concrete: Python ETL developers who own the posting job, trade finance analysts who defend the accrued balance, and vendor managers who answer for a contested trade-spend liability.
Positioning Within the Reconciliation Architecture
Posting sits at the boundary between the reconciliation engine and the system of financial record. Upstream, the accrual is a modeled figure carried on a write-once record with its tier trace and agreement version attached; downstream, the general ledger is the authoritative source the controller closes the period against. The posting layer is the translator between those two worlds, and its whole value is that it never invents economics — it only re-expresses a figure that has already been computed and validated as a journal the ledger can accept. The accrual amount, the recognition period, and the responsible cost center all arrive settled; posting decides which accounts move, in which direction, and how the movement is made replay-safe and reversible.
Two design commitments keep this defensible. First, construction is deterministic and side-effect free: given the same accrual, the poster builds the same debit and credit lines with the same posting_key, so the operation can be retried without fear. Second, the poster is the only writer to the GL journal for rebate accruals — no ad-hoc manual journal competes with it — which is what lets the sub-ledger and the ledger be tied out mechanically rather than reconciled by hand. Because the accrual figures themselves originate in payout structure modeling, disputes over how much are adjudicated there and in the claim validation rule engine; by the time a number reaches posting, the argument is settled and the poster’s job is fidelity, not judgment. Everything about period control — when accruals freeze, when a retroactive recalculation is allowed to re-open a posted period — is owned by the month-end close sequencing that surrounds this stage, and the poster simply refuses to write into a period the close calendar has locked.
Entity Topology and Schema Specification
A journal entry is only postable if it is a balanced, self-describing unit. The poster emits one JournalEntry header per accrual event and two or more JournalLine rows beneath it, and the invariant binding them — debits equal credits — has to hold at construction time, never be patched afterward. The header carries the identity and idempotency machinery; the lines carry the account movements. The table below specifies the minimum schema. Treat it as a versioned interface: adding a nullable descriptive field is backward-compatible, while renaming or retyping an account or amount field is a breaking change that bumps the poster version and forces every downstream tie-out query to be revalidated.
| Field | Type | Entity | Constraint |
|---|---|---|---|
entry_id |
str (ULID) |
JournalEntry | immutable, primary key |
accrual_id |
str (ULID) |
JournalEntry | source accrual this entry recognizes |
period |
str (YYYY-MM) |
JournalEntry | recognition period, must be open |
posting_key |
str (SHA-256 hex) |
JournalEntry | deterministic idempotency key, unique |
posting_status |
enum |
JournalEntry | constructed, posted, reversed |
reversal_of |
str | null |
JournalEntry | posting_key of the entry this reverses |
posted_at |
str (ISO-8601 UTC) |
JournalEntry | stamped at GL write, null until posted |
line_no |
int |
JournalLine | monotonic within entry, starts at 1 |
account_debit |
str | null |
JournalLine | GL account for a debit line |
account_credit |
str | null |
JournalLine | GL account for a credit line |
amount |
Decimal |
JournalLine | > 0, scale 2, never float |
cost_center |
str |
JournalLine | dimension for management reporting |
currency |
str (ISO 4217) |
JournalLine | functional currency of the entry |
The posting_key is the linchpin. It is a deterministic SHA-256 over the fields that define the economic identity of the posting — accrual_id, period, the ordered account-and-amount tuples, and the poster version — so re-running the job over the same accrual yields the same key and the GL upsert becomes a no-op. A line never carries both a debit and a credit account; each row moves exactly one account in one direction, which keeps the balance check a simple partition of the lines rather than a per-row sign convention. Amounts are Decimal from the moment the entry is constructed through to the ledger write, so no rounding drift is introduced between the accrual sub-ledger and the posted balance.
Conditional Logic and Rule Integration
Which accounts an entry touches is not a free choice — it is dictated by how the rebate is characterized under revenue-recognition standards, and that characterization is a rule, not a preference. A trade rebate paid to a customer is, under ASC 606 and IFRS 15, almost always variable consideration that reduces the transaction price: it is consideration payable to a customer, so it is recognized as a reduction of revenue rather than as a marketing expense. In double-entry terms, recognizing the accrual debits a contra-revenue account (reducing net revenue) and credits an accrued-rebate liability (the obligation to pay). The narrow exceptions — where the payment is for a distinct good or service received from the customer at fair value — are booked as trade-spend expense instead, and the account-selection rule has to encode that distinction rather than defaulting one way and letting analysts reclassify by hand later.
The poster resolves the account pair through a deterministic mapping keyed on the accrual’s rebate type and the customer’s contra-revenue account structure. Three families of predicate govern construction:
Account-direction predicates. Every rebate accrual produces a debit-credit pair whose direction is fixed by the recognition rule: contra-revenue or trade-spend expense on the debit side, accrued-rebate liability on the credit side. A reversal inverts the pair. The poster never emits a naked single-sided line.
Period-state predicates. The period on the entry must be open on the close calendar. An accrual whose recognition period has already been frozen cannot post into it; it either routes to the next open period as a true-up or is held for the controller’s explicit reopening decision, never silently backdated.
Amount-validity predicates. The entry amount must be a positive Decimal at scale 2, and the sum of debit-line amounts must equal the sum of credit-line amounts exactly. A zero-amount accrual produces no entry at all rather than a balanced pair of zero lines, so the ledger is never cluttered with economically empty postings.
{
"account_router": {
"volume_rebate": {"debit": "4200-contra-revenue", "credit": "2340-accrued-rebate", "basis": "variable_consideration"},
"off_invoice_allowance": {"debit": "4210-contra-revenue", "credit": "2340-accrued-rebate", "basis": "variable_consideration"},
"coop_marketing_service": {"debit": "6150-trade-spend-expense", "credit": "2345-accrued-coop", "basis": "distinct_service"}
},
"reject_post_when": ["period_frozen", "unbalanced_entry", "non_decimal_amount", "unknown_account"]
}
Financial Settlement Layer
The balanced-entry invariant is the heart of the posting layer, and it is expressed entirely in decimal.Decimal. Every line amount is a Decimal at scale 2, and the entry is postable only when the debit total and the credit total are exactly equal — not approximately equal within a tolerance, exactly equal. This is precisely the guarantee float cannot give: summing a handful of float cents can leave debits and credits differing by a machine-epsilon fraction that no ledger will accept and that no analyst can explain. Because IFRS 15 / ASC 606 treatment requires the accrued balance to be reproducible to the cent across every environment and every replay, float is forbidden anywhere in the construction or summation path.
When a modeled accrual carries more scale than the ledger accepts — a tier calculation at four decimal places against a two-decimal GL — the poster quantizes once, at construction, with an explicit ROUND_HALF_UP, and the rounding is applied to the amount before the lines are split so both sides round to the same figure and the entry stays balanced by construction. The idiom below builds the debit-credit pair and proves the invariant before the entry is ever handed to the ledger.
from decimal import Decimal, ROUND_HALF_UP
CENTS = Decimal("0.01")
def build_rebate_entry(accrual_amount: Decimal, debit_acct: str, credit_acct: str) -> list[dict]:
"""Construct a balanced two-line journal for a rebate accrual.
Debits contra-revenue (or trade-spend expense); credits the accrued-rebate
liability. Quantize once so both sides carry the identical Decimal amount.
"""
amount = accrual_amount.quantize(CENTS, rounding=ROUND_HALF_UP)
if amount <= 0:
return [] # no economically empty postings
lines = [
{"line_no": 1, "account_debit": debit_acct, "account_credit": None, "amount": amount},
{"line_no": 2, "account_debit": None, "account_credit": credit_acct, "amount": amount},
]
debits = sum((l["amount"] for l in lines if l["account_debit"]), Decimal("0"))
credits = sum((l["amount"] for l in lines if l["account_credit"]), Decimal("0"))
if debits != credits:
raise ValueError(f"unbalanced entry: debits {debits} != credits {credits}")
return lines
A reversal is the same construction with the accounts swapped and the amount unchanged, so an original posting and its reversal always net to zero across the two accounts — the ledger arithmetic that makes a restatement provably complete.
ETL Implementation Patterns
Enforcement belongs at the model boundary, not in a downstream reconciliation report. Model the journal entry with Pydantic v2 so an unbalanced entry, a float amount, or a line that carries both a debit and a credit account fails fast at construction instead of surfacing as an out-of-balance trial balance weeks later. The model_validator below rejects the three corruptions that actually cause posting incidents: a float amount that smuggled in precision loss, a set of lines whose debits and credits do not tie, and a reversal that forgets to reference its original.
from decimal import Decimal
from pydantic import BaseModel, field_validator, model_validator
class JournalLine(BaseModel):
line_no: int
account_debit: str | None = None
account_credit: str | None = None
amount: Decimal
@field_validator("amount", mode="before")
@classmethod
def _no_float(cls, v: object) -> Decimal:
if isinstance(v, float):
raise ValueError("journal amounts must be str or Decimal, never float")
return Decimal(str(v))
@model_validator(mode="after")
def _exactly_one_side(self) -> "JournalLine":
if bool(self.account_debit) == bool(self.account_credit):
raise ValueError("a line must carry exactly one of debit/credit account")
if self.amount <= 0:
raise ValueError("line amount must be positive")
return self
class JournalEntry(BaseModel):
entry_id: str
accrual_id: str
period: str
posting_key: str
reversal_of: str | None = None
lines: list[JournalLine]
@model_validator(mode="after")
def _must_balance(self) -> "JournalEntry":
debits = sum((l.amount for l in self.lines if l.account_debit), Decimal("0"))
credits = sum((l.amount for l in self.lines if l.account_credit), Decimal("0"))
if debits != credits:
raise ValueError(f"entry {self.entry_id} unbalanced: {debits} != {credits}")
return self
Persistence to the GL is an idempotent upsert keyed on posting_key. The poster computes the key deterministically from the accrual identity and the constructed lines, then writes with an ON CONFLICT (posting_key) DO NOTHING semantic so a re-run of the batch — after a crash, a timeout, or a manual replay — inserts nothing the second time. This is the single most important control in the whole layer: without it, every retry risks a duplicate accrued liability, the classic phantom balance that inflates the reserve and understates margin. The posting key must be computed before any GL write and folded into the audit log alongside the entry, so an operator investigating a suspected double-post can prove from the key alone whether the second attempt wrote anything.
import hashlib, json
from decimal import Decimal
def posting_key(accrual_id: str, period: str, lines: list[dict], poster_version: str) -> str:
"""Deterministic idempotency key over the entry's economic identity."""
canonical = json.dumps(
{"accrual_id": accrual_id, "period": period, "poster_version": poster_version,
"lines": [[l["account_debit"], l["account_credit"], str(l["amount"])] for l in lines]},
sort_keys=True, separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Because the key is a hash over the accounts and amounts as well as the accrual id, a corrected true-up — which has different amounts — produces a different key and therefore posts as a new entry rather than colliding with the original, which is exactly the behavior restatement needs.
Drift Detection and Validation
A posted ledger is only trustworthy if it keeps agreeing with the sub-ledger it was built from. The accrual sub-ledger holds the modeled liability per accrual; the GL holds the posted balance on the accrued-rebate account. Tie-out runs after every posting batch and at period close, summing both sides and asserting they match to the cent — any non-zero delta is a drift signal that is raised as an exception rather than absorbed. Validation also runs within each entry, confirming the balance invariant held at write time and that no line references an account outside the approved chart.
| Drift signal | Detection rule | Action |
|---|---|---|
| Out-of-balance entry | entry debit total ≠ credit total at write | reject entry, raise UNBALANCED_ENTRY |
| Sub-ledger / GL delta | Σ accrual sub-ledger ≠ Σ posted GL for account | hold close, raise TIEOUT_DRIFT |
| Suspected double-post | two GL rows share an accrual_id but differ on posting_key unexpectedly |
quarantine, raise DUPLICATE_POSTING |
| Orphan reversal | reversal_of references a posting_key not present in GL |
reject, raise ORPHAN_REVERSAL |
| Closed-period write | period frozen on close calendar |
route to next open period as true-up |
| Unknown account | account_debit/account_credit not in approved chart |
reject entry, raise UNKNOWN_ACCOUNT |
Catching these before close is what preserves the integrity of the accrued balance: a tie-out drift caught at posting time is a same-day investigation into a mis-mapped account, while the same drift discovered after the controller signs the trial balance is a restatement and an audit finding. When drift exceeds a defined materiality tolerance, the poster triggers an exception workflow that holds the close on that account and routes the delta to trade finance with the offending accrual_id and posting_key attached.
Fallback and Dispute Routing
Posting failures are inevitable — a period locks mid-batch, an account mapping goes stale, a sub-ledger delta appears — and the poster degrades rather than halts. Failures are categorized deterministically and routed by tier, never dropped and never auto-forced through. Balance and structural errors (an unbalanced entry, a non-Decimal amount, a single-sided line) are code defects or upstream corruption; they hold the specific entry, raise a blocking exception, and never post a partial journal. Period and account errors (a frozen period, an unknown or newly-restructured GL account) route to the controller: a frozen-period accrual is re-expressed as a true-up in the next open period, and an unknown account holds until the chart is corrected. Tie-out disputes (a sub-ledger-to-GL delta) go to trade finance with both balances and the contributing entries, because the delta is a reconciliation question, not a posting bug.
Every failure payload carries a deterministic mismatch code, the accrual_id, the computed posting_key, and a suggested remediation path; a dead-letter table retains the rejected entry for auditability while automated alerting notifies the on-call ETL engineer and the responsible analyst. Nothing is silently absorbed into the reserve. The escalation and recovery mechanics that sit downstream of a rejected posting — reopening a period, adjudicating a tie-out delta, chasing an unauthorized deduction back to its source — are owned by the sibling settlement topics, but the poster’s responsibility is to name the correct lane, emit the audit entry explaining why no clean post was possible, and keep the rest of the batch moving so close stays on schedule.
Security and Access Boundaries
The general ledger is the system of financial record, so posting is governed as a hard financial control rather than a background job. Role-based access control (RBAC) tags travel with every entity so authorization is enforced per action: ETL developers can deploy poster versions and account-mapping manifests but cannot mutate a posted journal or reopen a period; trade finance analysts can adjudicate tie-out exceptions and export audit trails but cannot alter a posted entry’s amounts; only the controller role can authorize a period reopening or approve a manual reversal outside the automated path. The poster itself runs under a service identity whose sole GL write permission is the idempotent upsert — it cannot delete or overwrite a posted journal, so the write-once guarantee is enforced by permission, not just convention.
Every construct, post, reversal, and tie-out event writes an append-only, hash-chained audit entry: the posting_key, the accrual_id, the ordered lines, the poster version, and the outcome. Entries are never updated; a correction is a new reversal-and-repost pair that references the original by its posting_key, so the full history of how a liability was recognized, restated, and settled survives indefinitely and any retroactive tampering breaks the hash chain and surfaces in reconciliation. This aligns posting with SOX and internal-control requirements: segregation of duties between the developer who ships the poster and the analyst who adjudicates its exceptions, immutable evidence for every ledger movement, and a defensible trail from a modeled accrual through a balanced journal to a posted, tied-out GL balance — the difference between a reconciliation system an auditor trusts and one they write findings against.
Frequently Asked Questions
Why does a rebate accrual debit contra-revenue instead of a marketing expense? Under ASC 606 and IFRS 15, a rebate paid to a customer is consideration payable to a customer, which is variable consideration that reduces the transaction price. It is therefore recognized as a reduction of revenue — a debit to a contra-revenue account and a credit to the accrued-rebate liability — rather than as an expense. The narrow exception is a payment for a distinct good or service received from the customer at fair value, which is booked to trade-spend expense instead.
How does the posting key stop a replayed batch from double-booking a liability? Every entry carries a deterministic posting key, a SHA-256 over the accrual id, period, poster version, and the ordered account-and-amount lines. Persistence is an idempotent upsert on that key, so re-running the batch after a crash or timeout inserts nothing the second time. A corrected true-up has different amounts and therefore a different key, so it posts as a new entry rather than colliding with the original.
What is the balanced-entry invariant and why must it use Decimal? The invariant is that the sum of debit-line amounts equals the sum of credit-line amounts exactly, enforced at construction by a Pydantic model validator. It must use decimal.Decimal because summing float cents can leave debits and credits differing by a machine-epsilon fraction no ledger will accept, and because ASC 606 treatment requires the accrued balance to be reproducible to the cent on every replay.
How is a restatement posted without overwriting the original entry? The original entry is never mutated. A restatement posts a reversal that swaps the debit and credit accounts and references the original through its reversal_of field set to the original posting key, then posts a fresh corrected true-up. The original and its reversal net to zero across the two accounts, so the history of the change is fully reconstructable and the audit chain stays intact.
Related
- Deduction & Shortpay Routing — route unauthorized deductions and shortpays for recovery before they distort the accrued balance.
- Month-End Close Sequencing — the freeze, recalculation, and export order that decides which periods this poster may write into.
- Payment & Settlement Reconciliation — match vendor payments against the open accruals this stage posts to the ledger.
- Reconciliation Reporting Automation — turn posted journals and tie-out results into period-end reconciliation reports.
- Posting Rebate Accruals to the General Ledger — the step-by-step build, balance, and idempotent-post walkthrough for a single accrual.
Up one level: Settlement, Deduction & Financial Close