Posting Rebate Accruals to the General Ledger
A rebate engine that models a liability but never lands it correctly in the general ledger produces a trial balance that no controller will sign. The failure is rarely the number itself — it is the posting: a journal entry that does not balance, a retry that double-books the same accrual, a line that hits a closed period, or a restatement that reverses with the wrong sign. This page is the implementation-level procedure for turning one modeled rebate accrual into a balanced, idempotently-posted GL journal entry: map the accrual to a debit and credit account pair, build the entry with decimal.Decimal, assert that debits equal credits, key the post deterministically so replays are safe, and reverse-plus-true-up on restatement. It is the hands-on companion to the accrual posting & GL integration topic area inside Settlement, Deduction & Financial Close, which owns the broader posting-and-close architecture; here we focus only on the single-entry mechanics.
Prerequisites
Before posting a single accrual, confirm the following are in place:
- A resolved chart-of-accounts mapping. Each rebate program must resolve to a fixed account pair — a debit account (a contra-revenue or trade-spend expense account) and a credit account (an accrued-rebate liability). Store this mapping as data keyed by
program_typeandchannel, not as branching code, so the same accrual always lands on the same accounts across runs. - A period-open check. The pipeline must be able to ask the ledger whether a fiscal
periodis open before it writes. Posting into a closed period is a hard rejection, not a warning. - The modeled accrual as an input, not a computation. The amount arrives already computed by payout structure modeling and carries an
agreement_id, aperiod, and adecimal.Decimalamount. This page posts that number; it does not re-derive it. - Python 3.11+ with
pydantic>=2.6. All models below use Pydantic v2 validators. Every monetary field is adecimal.Decimal; afloatanywhere in a journal leg is a defect. - Scoped write access. The posting service role holds insert-only access to the journal table and read access to the account map and period calendar. It cannot open or close periods, and it cannot delete a posted entry — corrections are new entries, never in-place edits.
Step-by-Step Implementation
Step 1 — Map the accrual to a debit and credit account pair
A rebate accrual is a two-sided event: it reduces recognized revenue (or books a trade-spend expense) and it raises a liability you owe the vendor or customer. Resolve the modeled accrual to exactly that account pair from the chart-of-accounts map before any journal object exists. Keep the resolution deterministic and data-driven so an audit can reproduce which accounts a program posts to.
from decimal import Decimal
COA_MAP = {
("volume_rebate", "GROCERY_RETAIL"): {
"debit": "5120-CONTRA-REVENUE-REBATE",
"credit": "2140-ACCRUED-REBATE-LIABILITY",
},
("trade_promo", "DTC"): {
"debit": "6310-TRADE-SPEND",
"credit": "2140-ACCRUED-REBATE-LIABILITY",
},
}
def resolve_accounts(program_type: str, channel: str) -> dict:
key = (program_type, channel)
if key not in COA_MAP:
raise KeyError(f"no account mapping for {key}")
return COA_MAP[key]
Validation check: assert that both debit and credit resolve to non-empty account codes and that they differ. An accrual whose two legs point at the same account is a mapping error and must be rejected before it becomes a journal entry.
Step 2 — Build the JournalEntry and enforce balance with Pydantic
Construct the journal as an entry with two legs, both decimal.Decimal. A model_validator is the enforcement point: it rejects any leg typed as float and asserts that the sum of debits equals the sum of credits. Balance is not a downstream reconciliation — it is a precondition for the object existing at all.
from decimal import Decimal
from pydantic import BaseModel, field_validator, model_validator
class Leg(BaseModel):
account: str
debit: Decimal = Decimal("0")
credit: Decimal = Decimal("0")
@field_validator("debit", "credit", mode="before")
@classmethod
def _reject_float(cls, v):
if isinstance(v, float):
raise ValueError("journal amounts must be Decimal/str, never float")
return v
class JournalEntry(BaseModel):
agreement_id: str
period: str # ISO 'YYYY-MM' fiscal period
legs: list[Leg]
@model_validator(mode="after")
def _must_balance(self) -> "JournalEntry":
debits = sum((l.debit for l in self.legs), Decimal("0"))
credits = sum((l.credit for l in self.legs), Decimal("0"))
if debits != credits:
raise ValueError(f"unbalanced: debits {debits} != credits {credits}")
if debits == Decimal("0"):
raise ValueError("refusing to post a zero-value journal entry")
return self
def build_entry(accrual, accounts) -> JournalEntry:
amount = accrual.amount.quantize(Decimal("0.01"))
return JournalEntry(
agreement_id=accrual.agreement_id,
period=accrual.period,
legs=[
Leg(account=accounts["debit"], debit=amount),
Leg(account=accounts["credit"], credit=amount),
],
)
Validation check: build an entry whose two legs differ by one cent and assert it raises ValidationError; build one with a float amount and assert _reject_float fires. A journal object that exists is, by construction, balanced and float-free.
Step 3 — Compute a deterministic idempotency posting key
The posting key is what makes the operation replay-safe. Derive it as a stable hash over the fields that define the entry’s identity — period, both account codes, the agreement_id, and the quantized amount as a string — so the same modeled accrual always yields the same key, and any change to the amount yields a different one. The key, not a database auto-increment, is the primary anchor for “have I posted this already?”.
import hashlib
def posting_key(entry: JournalEntry) -> str:
debit = next(l.account for l in entry.legs if l.debit > 0)
credit = next(l.account for l in entry.legs if l.credit > 0)
amount = next(l.debit for l in entry.legs if l.debit > 0)
parts = [
entry.period,
entry.agreement_id,
debit,
credit,
str(amount), # quantized Decimal as string, not float repr
]
return hashlib.sha256("|".join(parts).encode()).hexdigest()
Validation check: compute the key twice for the same entry and assert equality; change the amount by one cent and assert the key changes. A key that varies across identical inputs means a non-deterministic field (a timestamp, a UUID) leaked into the hash and idempotency is broken.
Step 4 — Post, or no-op on replay, under a period-open check
Posting is a guarded insert. First confirm the target period is open; then attempt to insert the journal with posting_key under a unique constraint. If the key already exists, return the existing entry id and do nothing — the retry is a no-op, not a second liability. Let the database uniqueness enforce the invariant so two concurrent workers cannot both win.
from datetime import datetime, timezone
class ClosedPeriodError(Exception): ...
def post(entry: JournalEntry, key: str, ledger) -> dict:
if not ledger.is_period_open(entry.period):
raise ClosedPeriodError(f"period {entry.period} is closed")
existing = ledger.find_by_posting_key(key)
if existing is not None:
return {"entry_id": existing["entry_id"], "status": "noop_replay"}
record = {
"posting_key": key, # UNIQUE constraint in the journal table
"agreement_id": entry.agreement_id,
"period": entry.period,
"legs": [l.model_dump(mode="json") for l in entry.legs],
"status": "posted",
"posted_at": datetime.now(timezone.utc).isoformat(),
}
entry_id = ledger.insert_journal(record) # raises on UNIQUE violation
return {"entry_id": entry_id, "status": "posted"}
Validation check: post the same entry twice against a stubbed ledger and assert the second call returns status == "noop_replay" with the first call’s entry_id. Then attempt a post into a closed period and assert ClosedPeriodError. The insert must be the only path that creates liability.
Step 5 — Reverse and true-up on restatement
When a period is restated — a corrected sales feed, a renegotiated rate, a late credit — never edit the posted entry. Post a reversal that flips the original entry’s debits and credits, then post the corrected entry to the same original period. The reversal carries the original entry_id as reverses, so the audit trail shows the full lifecycle rather than a mutated number.
def reverse(original: dict, ledger) -> dict:
flipped = [
{"account": l["account"], "debit": l["credit"], "credit": l["debit"]}
for l in original["legs"]
]
record = {
"reverses": original["entry_id"],
"agreement_id": original["agreement_id"],
"period": original["period"], # original period, not today's
"legs": flipped,
"status": "reversal",
"posted_at": datetime.now(timezone.utc).isoformat(),
}
return {"entry_id": ledger.insert_journal(record), "status": "reversal"}
def true_up(original: dict, corrected: JournalEntry, ledger) -> list:
rev = reverse(original, ledger)
key = posting_key(corrected)
fresh = post(corrected, key, ledger)
return [rev, fresh]
Validation check: reverse a posted entry and assert the reversal’s per-leg debit equals the original’s credit and vice versa, so the reversal plus the original net to zero on every account. Then confirm the corrected entry posts under its own new posting_key — a true-up that reuses the original key would no-op and silently drop the correction.
Common Failure Modes and Fixes
1. Unbalanced entry reaches the ledger. A rounding step applied to one leg but not the other, or a tax/fee line added on only one side, leaves debits and credits unequal and corrupts the trial balance. Fix: make balance a model_validator precondition (Step 2) so an unbalanced entry cannot be constructed, and quantize the shared amount once before splitting it into legs.
2. Float rounding drift in the amount. Computing or storing the accrual as a float reintroduces binary rounding that diverges across platforms and pushes the two legs a fraction of a cent apart. Fix: keep every amount in decimal.Decimal, quantize to Decimal("0.01") explicitly, and let the _reject_float validator block any float leg at construction.
3. Double-post on retry. A worker times out after the insert commits but before it acknowledges, the job retries, and the accrual books twice — doubling the liability. Fix: derive a deterministic posting_key (Step 3), enforce it with a UNIQUE constraint, and treat a duplicate key as a no-op that returns the existing entry id (Step 4).
4. Posting into a closed period. A late-arriving accrual targets a fiscal period that month-end has already frozen, silently reopening a signed-off balance. Fix: call is_period_open before every insert and raise ClosedPeriodError; route the rejected accrual to the next open period as an explicit adjustment. Period gating is coordinated by month-end close sequencing.
5. Wrong sign on reversal. Copying the original legs into the reversal without swapping debit and credit posts the liability twice instead of unwinding it. Fix: build the reversal by flipping each leg’s debit and credit (Step 5), and assert the reversal-plus-original nets to zero per account before the true-up posts.
Operational Checklist
Frequently Asked Questions
Which side of the entry is the debit and which is the credit? A rebate accrual debits a contra-revenue or trade-spend account — reducing net revenue or booking the promotional expense — and credits an accrued-rebate liability, recording what you owe the trading partner. The liability sits on the balance sheet until the deduction or payment settles it. Store the pair as data keyed by program type and channel so the direction is fixed and auditable, never inferred at runtime.
Why hash a posting key instead of using the database’s auto-increment id? An auto-increment id is assigned by the insert, so it cannot answer “have I already posted this?” before the insert happens — which is exactly when a retry needs the answer. A deterministic hash over the entry’s identifying fields is stable across runs and machines, so a replayed job computes the same key, finds the prior row, and no-ops. The key is the idempotency anchor; the auto-increment id is just a local surrogate.
What happens when an accrual targets a period that is already closed? The period-open check rejects the insert with a ClosedPeriodError rather than silently reopening a signed-off balance. The correct handling is to post the accrual as an explicit adjustment in the next open period, so the closed period’s trial balance stays intact and the late liability is still recognized. Coordinating which periods are open belongs to month-end close sequencing.
How do I correct an accrual that was already posted? Never edit the posted row. Post a reversal that flips the original entry’s debits and credits to unwind it, then post the corrected entry to the same original fiscal period under its own new posting key. The reversal references the original entry id, so the ledger shows the original booking, its reversal, and the true-up as a complete, immutable lifecycle that reconciles to zero on the reversed accounts.
Related
- Parent topic: Accrual Posting & GL Integration — the broader posting-and-integration architecture this procedure sits inside.
- Sequencing Accrual Freeze and Retroactive Recalculation — how period gating and freeze ordering decide when a journal may post.
- Payout Structure Modeling — where the accrual amount is computed before it reaches this posting step.
Up one level: Accrual Posting & GL Integration