Posting Tolerance Adjustments to the General Ledger Permalink to this section
↑ Part of Variance Write-Back and ERP Adjustment Posting.
Once a price or quantity variance clears its approval threshold and a reviewer signs off on the write-off, the reconciliation pipeline still has one dangerous step left: turning that approval into an actual journal entry in the general ledger (GL). This is the one place in the pipeline where a bug does not just mislabel a record — it moves real money in the books. A retried post that double-books a $4.12 rounding variance is immaterial in isolation, but a retried post that double-books a batch of freight-variance write-offs after a network timeout is a reconciliation break that a controller has to explain at month-end close. This page covers the narrow mechanics of posting an already-approved tolerance adjustment: building a balanced debit/credit entry from a variance record, guarding the post with an idempotency key so retries are safe, running in dry-run mode before committing, and emitting a clean reversal when an adjustment is later rescinded.
Operational Trigger Signals Permalink to this section
Posting to the GL is the terminal step for a variance, so it should fire only when all of the following are true — never on a raw match-tolerance breach:
- Adjustment is in
APPROVEDstatus — the variance cleared the review workflow (manual sign-off or an auto-approval rule under the quantity and price tolerance windows) and carries an approver identity and timestamp. - Adjustment amount is within the posting ceiling — most ERPs cap what a reconciliation service account may post without a secondary control; amounts above the ceiling route to a manual GL journal instead of the automated poster.
- No prior successful post exists for the same
adjustment_id— checked against the posting ledger before a single database write is attempted, not inferred from application state. - The accounting period is still open — posting into a closed period fails at the ERP and must be redirected to the next open period with a note, not silently retried.
When these four hold, the adjustment is ready for the posting step described below. If any one fails, the record stays in APPROVED and is surfaced on the next posting run rather than forced through.
Step-by-Step Implementation Permalink to this section
Treat posting as two separable operations — build and commit — so the entry can be constructed, validated, and logged in dry-run mode before anything touches the ERP or the posting ledger:
- Load the approved adjustment — pull the variance amount, adjustment type, PO/invoice references, and approver metadata for one
adjustment_id. - Resolve GL accounts — map the adjustment type to a debit and credit account pair from the configuration table below; never hardcode account numbers in the posting function.
- Build the balanced entry — construct a journal entry object where the sum of debit lines equals the sum of credit lines to the cent, using
Decimalthroughout. - Compute the idempotency key — derive a deterministic key from
adjustment_idand the adjustment’s version so a retried call always produces the identical key. - Dry-run validate — render the entry and check balance, account existence, and open-period status without writing to the posting ledger or calling the ERP.
- Commit and log — on commit, check the idempotency key against the posting ledger inside the same transaction as the insert, post to the ERP, and record the result.
- Handle rescission separately — if an already-posted adjustment is later reversed by a reviewer, emit an equal-and-opposite entry that references the original posting rather than deleting or mutating it.
The block below implements steps 3–7. It keeps all monetary arithmetic in Decimal, derives the idempotency key before any I/O, and treats dry_run as a first-class mode rather than a flag checked after the fact:
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import ROUND_HALF_UP, Decimal
from enum import Enum
logger = logging.getLogger(__name__)
CENT = Decimal("0.01")
class AdjustmentType(str, Enum):
PRICE_FAVORABLE = "PRICE_FAVORABLE"
PRICE_UNFAVORABLE = "PRICE_UNFAVORABLE"
QTY_SHRINKAGE = "QTY_SHRINKAGE"
QTY_OVERAGE = "QTY_OVERAGE"
FREIGHT_VARIANCE = "FREIGHT_VARIANCE"
ROUNDING_FX = "ROUNDING_FX"
# adjustment_type -> (debit_account, credit_account); see Configuration Reference for meaning
GL_ACCOUNT_MAP: dict[AdjustmentType, tuple[str, str]] = {
AdjustmentType.PRICE_FAVORABLE: ("2100-AP-CLEARING", "5010-PURCH-PRICE-VAR"),
AdjustmentType.PRICE_UNFAVORABLE: ("5010-PURCH-PRICE-VAR", "2100-AP-CLEARING"),
AdjustmentType.QTY_SHRINKAGE: ("5040-INV-SHRINK-EXP", "1310-INVENTORY-RM"),
AdjustmentType.QTY_OVERAGE: ("1310-INVENTORY-RM", "5040-INV-SHRINK-EXP"),
AdjustmentType.FREIGHT_VARIANCE: ("5210-FREIGHT-VAR", "2100-AP-CLEARING"),
AdjustmentType.ROUNDING_FX: ("7900-FX-ROUNDING", "2100-AP-CLEARING"),
}
@dataclass(frozen=True)
class JournalLine:
account: str
debit: Decimal
credit: Decimal
memo: str
@dataclass(frozen=True)
class JournalEntry:
idempotency_key: str
adjustment_id: str
entry_date: date
lines: list[JournalLine] = field(default_factory=list)
def is_balanced(self) -> bool:
"""A journal entry may never post unless debits equal credits to the cent."""
total_debit = sum((line.debit for line in self.lines), Decimal("0"))
total_credit = sum((line.credit for line in self.lines), Decimal("0"))
return total_debit.quantize(CENT) == total_credit.quantize(CENT)
def compute_idempotency_key(adjustment_id: str, version: int) -> str:
"""Deterministic key: same adjustment + version always yields the same key,
so a retried post (network timeout, worker crash, at-least-once redelivery)
never depends on wall-clock time or process state to detect a duplicate.
"""
raw = f"gl-post:{adjustment_id}:v{version}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def build_journal_entry(
adjustment_id: str,
adjustment_type: AdjustmentType,
amount: Decimal,
version: int,
entry_date: date,
) -> JournalEntry:
"""Build a balanced two-line journal entry for one approved tolerance adjustment.
amount is always positive; the account pair in GL_ACCOUNT_MAP already encodes
which side (debit vs credit) a favorable or unfavorable variance hits.
"""
debit_account, credit_account = GL_ACCOUNT_MAP[adjustment_type]
posted_amount = amount.quantize(CENT, rounding=ROUND_HALF_UP)
entry = JournalEntry(
idempotency_key=compute_idempotency_key(adjustment_id, version),
adjustment_id=adjustment_id,
entry_date=entry_date,
lines=[
JournalLine(debit_account, posted_amount, Decimal("0"),
f"Tolerance write-off {adjustment_id} ({adjustment_type.value})"),
JournalLine(credit_account, Decimal("0"), posted_amount,
f"Tolerance write-off {adjustment_id} ({adjustment_type.value})"),
],
)
if not entry.is_balanced():
# Should be unreachable given the two-line construction above; guard anyway
# because a future multi-line entry (split freight + price) could break it.
raise ValueError(f"unbalanced entry for adjustment {adjustment_id}")
return entry
def post_journal_entry(conn, entry: JournalEntry, dry_run: bool = True) -> str:
"""Post a validated journal entry, guarded by the idempotency key.
In dry_run mode, validates and logs the entry but performs no writes.
In commit mode, the idempotency check and insert happen inside one
transaction so a concurrent retry cannot slip past the check.
"""
if not entry.is_balanced():
raise ValueError(f"refusing to post unbalanced entry {entry.adjustment_id}")
if dry_run:
logger.info(
"DRY RUN entry=%s adjustment=%s lines=%d — no write performed",
entry.idempotency_key, entry.adjustment_id, len(entry.lines),
)
return entry.idempotency_key
with conn.transaction():
existing = conn.execute(
"SELECT gl_document_id FROM gl_posting_ledger WHERE idempotency_key = %s",
(entry.idempotency_key,),
).fetchone()
if existing:
logger.info(
"skip duplicate post: adjustment=%s already posted as gl_document_id=%s",
entry.adjustment_id, existing[0],
)
return existing[0]
gl_document_id = _post_to_erp(entry) # ERP client call; raises on rejection
conn.execute(
"""
INSERT INTO gl_posting_ledger
(idempotency_key, adjustment_id, gl_document_id, entry_date, posted_at, reversal_of)
VALUES (%s, %s, %s, %s, %s, NULL)
""",
(entry.idempotency_key, entry.adjustment_id, gl_document_id,
entry.entry_date, datetime.utcnow()),
)
logger.info(
"posted adjustment=%s as gl_document_id=%s idempotency_key=%s",
entry.adjustment_id, gl_document_id, entry.idempotency_key,
)
return gl_document_id
def build_reversal_entry(original: JournalEntry, reversal_date: date) -> JournalEntry:
"""Mirror-image entry for a rescinded adjustment: swap debit and credit on
every line, keep the same accounts, and never mutate or delete the original.
"""
reversed_lines = [
JournalLine(line.account, line.credit, line.debit, f"REVERSAL: {line.memo}")
for line in original.lines
]
return JournalEntry(
idempotency_key=compute_idempotency_key(f"{original.adjustment_id}-REV", 1),
adjustment_id=original.adjustment_id,
entry_date=reversal_date,
lines=reversed_lines,
)
def _post_to_erp(entry: JournalEntry) -> str:
"""Placeholder for the ERP-specific client call (SAP BAPI, NetSuite REST, etc.)."""
raise NotImplementedError
The reversal path deliberately reuses build_journal_entry’s line shape but never calls post_journal_entry on the original entry a second time — it builds a brand-new entry with its own idempotency key so the reversal itself is retry-safe, and the posting ledger row for the reversal carries reversal_of pointing back at the original gl_document_id.
Configuration Reference Permalink to this section
The adjustment-type-to-account map is the single source of truth the posting function reads from; changing which accounts absorb a variance type is a configuration change, never a code edit inside the posting function itself:
| Adjustment type | Debit account | Credit account | When it applies |
|---|---|---|---|
PRICE_FAVORABLE |
2100-AP-CLEARING |
5010-PURCH-PRICE-VAR |
Invoiced price below PO price, within tolerance |
PRICE_UNFAVORABLE |
5010-PURCH-PRICE-VAR |
2100-AP-CLEARING |
Invoiced price above PO price, within tolerance |
QTY_SHRINKAGE |
5040-INV-SHRINK-EXP |
1310-INVENTORY-RM |
Received quantity below shipped, within tolerance |
QTY_OVERAGE |
1310-INVENTORY-RM |
5040-INV-SHRINK-EXP |
Received quantity above shipped, within tolerance |
FREIGHT_VARIANCE |
5210-FREIGHT-VAR |
2100-AP-CLEARING |
Landed freight cost variance on the same PO line |
ROUNDING_FX |
7900-FX-ROUNDING |
2100-AP-CLEARING |
Sub-cent or FX conversion rounding artifacts |
The posting ledger row is the durable record of what was actually sent to the ERP, independent of the reconciliation service’s own state. It should be written in the same transaction as the idempotency check, never after an unguarded ERP call:
CREATE TABLE IF NOT EXISTS gl_posting_ledger (
id BIGSERIAL PRIMARY KEY,
idempotency_key CHAR(64) NOT NULL UNIQUE,
adjustment_id TEXT NOT NULL,
gl_document_id TEXT NOT NULL,
entry_date DATE NOT NULL,
posted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reversal_of BIGINT REFERENCES gl_posting_ledger(id)
);
-- The UNIQUE constraint on idempotency_key is the real guard: even if the
-- application-level SELECT-then-INSERT check races under concurrent workers,
-- the database rejects the second insert and the caller catches the
-- unique-violation as a confirmed duplicate rather than a new post.
INSERT INTO gl_posting_ledger
(idempotency_key, adjustment_id, gl_document_id, entry_date, reversal_of)
VALUES
('a3f1c9...64hex', 'ADJ-2026-004821', 'GLDOC-889213', '2026-07-15', NULL)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING gl_document_id;
For the balance identity itself, the posting function’s guard is exactly:
where and are the debit and credit amounts on line of the entry. Every posting function should assert this before the first write, not trust the caller to have built a balanced entry.
Debugging & Recovery Permalink to this section
Because a GL post moves money, recovery here is about proving what happened, not just retrying until it works:
- Duplicate post suspected — query
gl_posting_ledgerbyadjustment_id; if agl_document_idalready exists for the currentversion, the poster is working correctly and any apparent duplicate is two calls converging on the same idempotency key, not two postings. Compare against the ERP’s own document list to rule out a bypass of the posting function (a manual journal entered outside the pipeline). - ERP rejects the post — a rejected post (closed period, invalid account, credit limit) must never be retried blindly; catch the ERP error, log it against the
adjustment_idwithout writing a posting ledger row, and route the adjustment back toAPPROVEDstatus with a rejection reason rather than leaving it silently stuck. - Reversal needed after the fact — always call
build_reversal_entryagainst the original entry’s line shape; never hand-edit the original posting ledger row. Thereversal_offoreign key is what lets an auditor walk from any GL document back to what it corrected and forward to what corrected it. - Balance mismatch caught at dry-run — a failed
is_balanced()check almost always means the account map returned a lopsided pair for a new adjustment type that was added to the enum without a matchingGL_ACCOUNT_MAPentry; fail loud at build time rather than posting an unbalanced entry and hoping the ERP catches it. - Cross-checking against the source variance — the full chain from raw exception to posted GL document should be reconstructable from the audit trail and compliance log; if your posting ledger and audit log disagree on which adjustments posted, treat that divergence itself as an incident, not a formatting difference. Consider modeling
gl_posting_ledgeritself as an append-only ledger design so posted rows are never updated in place, only reversed by a new row.
FAQ Permalink to this section
Why check the idempotency key inside the database transaction instead of just checking application state before calling post? Permalink to this section
Application state (an in-memory flag, a cache entry) does not survive a worker crash or a horizontally scaled retry from a second process, and a message queue with at-least-once delivery guarantees exactly the redelivery scenario that breaks a check performed outside the transaction. Checking — and relying on a UNIQUE constraint as the final backstop — inside the same transaction as the insert means the database itself refuses the second write even if two workers race past the application-level check at the same instant.
Can I post a batch of approved adjustments as one journal entry instead of one entry per adjustment? Permalink to this section
You can, but compute one idempotency key per adjustment and store one posting ledger row per adjustment even when they are grouped into a single ERP document, because a rescission almost always targets one adjustment, not the whole batch. If the batch itself needs to be reversible as a unit, add a batch_id column to gl_posting_ledger in addition to, not instead of, the per-adjustment key.
What happens if the accounting period closes between approval and posting? Permalink to this section
The poster should check the period status before attempting the ERP call and, on a closed period, redirect the entry to the next open period with an explicit late_posting memo rather than silently retrying against a period that will keep rejecting it. This is a routing decision, not a retry-timing decision — retrying against a closed period will never succeed no matter how many attempts remain, which is the same distinction the exception handling section draws between transient and permanent failures elsewhere in the pipeline.
Related Permalink to this section
- Variance Write-Back and ERP Adjustment Posting — the parent workflow for turning approved variances into ERP writes
- Setting Quantity and Price Tolerance Windows — where the approval thresholds behind these adjustments are configured
- Audit Trail and Compliance for Reconciliation — the logging discipline a GL posting ledger must satisfy
- Append-Only Ledger Design in PostgreSQL — a schema pattern for a posting ledger that is never updated in place
- ↑ Parent: Variance Write-Back and ERP Adjustment Posting