Skip to content

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.

Idempotent posting flow from a modeled accrual to a balanced GL journal entry A modeled rebate accrual carrying an amount, period, and agreement id flows downward through five stages. First it maps to a chart-of-accounts pair: a debit to contra-revenue or trade-spend and a credit to the accrued-rebate liability. Next it is built as a JournalEntry with decimal legs, whose model validator enforces that the sum of debits equals the sum of credits and rejects any float value. A deterministic posting key is then computed as a sha256 over the period, account pair, and quantized amount. That key hits an idempotency gate: if the key was already posted, the pipeline no-ops and returns the prior entry id; if not, it inserts the journal with status posted. On restatement, the original entry is reversed with its signs flipped and a corrected true-up delta is posted to the same original period, with the reversal linking the original entry id for audit. 1 Modeled rebate accrual amount · period · agreement_id 2 Map to chart of accounts DR contra-revenue / trade-spend CR accrued-rebate liability 3 Build balanced JournalEntry Decimal legs · reject float assert Σdebits = Σcredits 4 Compute posting_key sha256( period + accounts + amount ) 5 posting_key already in ledger? unique-constrained idempotency gate yes No-op · return prior entry_id replay-safe · zero double-post no Insert journal · status = posted period-open check enforced at write On restatement reverse original (signs flipped) + post true-up delta to original period Reversal links original entry_id immutable audit trail preserved

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_type and channel, 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 period is 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, a period, and a decimal.Decimal amount. 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 a decimal.Decimal; a float anywhere 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.

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

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

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

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

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

Up one level: Accrual Posting & GL Integration