Skip to content

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.

Rebate accrual to general-ledger posting flow A left-to-right pipeline that turns a modeled rebate accrual into a posted journal entry. A write-once accrual enters journal-entry construction, which builds a debit line against contra-revenue or trade-spend expense and a credit line against the accrued-rebate liability, both as Decimal amounts. The entry then passes a balance check that asserts the sum of debits equals the sum of credits; an unbalanced or float amount is rejected to an exception lane. A balanced entry is posted to the general ledger through an idempotent gate keyed on a deterministic posting key, so a replayed batch is a no-op rather than a double-post. A restatement produces a reversal entry that references the original by its posting key and re-posts a corrected true-up. Finally a sub-ledger to GL tie-out compares the accrual sub-ledger balance against the posted GL balance and raises a drift exception on any delta. An append-only, hash-chained audit log spans every stage. 1 Modeled accrual write-once · Decimal accrual_id · period 2 Journal-entry construction DR contra-revenue / trade-spend CR accrued-rebate liability amounts as Decimal, scale 2 3 Balance check Σ debits == Σ credits reject float amounts unbalanced → hold balanced 4 Idempotent GL post upsert on posting_key replay → no-op, never double-posts write-once GL journal Posting exception queue unbalanced · closed period · bad account mismatch code + SLA + accrual_id nothing dropped, nothing auto-posted restatement Reversal + true-up reversal_of → original posting_key re-post corrected entry, never overwrite Sub-ledger → GL tie-out · drift detection on any delta Append-only, hash-chained audit log spans every stage — records each construct · post · reversal · tie-out event

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.

json
{
  "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.

python
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.

python
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.

python
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.

Up one level: Settlement, Deduction & Financial Close