Core Architecture & Promotion Mapping
In vendor rebate and trade promotion reconciliation, the margin between controlled trade spend and systemic leakage is decided long before any dollar is paid — it is decided by how the architecture maps a negotiated agreement onto a transaction. Retailers, CPG manufacturers, and their financial partners operate across siloed ERP, TPM, and distributor systems where promotional intent almost never maps natively to transactional reality. A promotion is agreed in a planning tool, executed at thousands of stores, invoiced through EDI, and settled in a general ledger that knows nothing about the original deal. When there is no unified blueprint connecting those layers, finance teams fall back to spreadsheet triage, and the predictable failures follow: duplicate accruals on remapped SKUs, payouts against sales dated outside the promotion window, tier breakpoints miscalculated against float arithmetic, and an audit trail that cannot reconstruct why a given amount was paid.
This page is the architectural reference for that mapping problem. It describes the data model, the pipeline guarantees, the rule-evaluation discipline, and the financial and governance controls that turn trade agreements into accurate, auditable accruals without manual triage. It is the parent topic for four implementation areas — schema design, eligibility evaluation, payout modeling, and fallback routing — and it sits directly upstream of the claim validation rule engine that adjudicates vendor claims, and directly downstream of the data ingestion normalization pipelines that deliver it clean, typed records. The audience is concrete: Python ETL developers who own the pipeline, trade finance analysts who defend the accrual, and vendor managers who answer for the dispute.
Canonical Data Model Overview
Reconciliation is only as deterministic as the entities it reasons over. Before any promotion can be mapped to a transaction, four record types have to resolve to stable, hashable identifiers: the agreement (the contracted terms and tiers), the promotion (the executed offer with its window and scope), the transaction fact (POS scans, distributor sell-through, EDI 810/844 movement that the agreement is measured against), and the accrual (the financial outcome the architecture emits). The relationships between them are what the rest of the system traverses, so they must be explicit and version-anchored rather than inferred at query time.
The agreement is the anchor. It carries a stable primary key, a cryptographic version hash, an effective interval, a scope (channel, territory, product hierarchy), and an ordered set of rebate tiers. A promotion references exactly one agreement version — never the agreement in the abstract — so that a contract amended mid-cycle does not silently re-map historical transactions under new terms. Transaction facts are the measurement substrate, normalized to a single unit-of-measure and currency upstream so that no calculation downstream ever performs conversion inside a rule. The accrual is modeled as a first-class, write-once record: it carries the agreement version, the rule trace, and both the modeled and posted figures, so the history of how spend was recognized is reconstructable forever rather than overwritten in place.
The schema fields below are the minimum contract the architecture depends on. Treat them as a versioned interface — adding a field is backward-compatible, renaming or retyping one is a breaking change that bumps the schema version and forces a re-hash. The full field-level specification, amendment lineage, and nullable-extension strategy live in agreement schema design.
| Field | Type | Entity | Constraint |
|---|---|---|---|
agreement_id |
str (ULID) | Agreement | immutable, primary key |
agreement_version |
int | Agreement | monotonic; referenced by promotions |
effective_start / effective_end |
date | Agreement | half-open interval, UTC-anchored |
scope_hash |
str (SHA-256) | Agreement | deterministic over channel+territory+hierarchy |
tiers |
list[Tier] | Agreement | ordered by lower_bound, non-overlapping |
promotion_id |
str (ULID) | Promotion | references one agreement_version |
window |
tuple[date, date] | Promotion | half-open, grace period explicit |
gtin / internal_sku |
str | TransactionFact | resolved via SKU mapping |
txn_date |
date | TransactionFact | UTC day-floored at ingestion |
qty / amount |
Decimal | TransactionFact | >= 0, 4 dp, never float |
accrual_id |
str (ULID) | Accrual | immutable, write-once |
rule_trace |
list[RuleOutcome] | Accrual | ordered, immutable, signed |
Modeling the accrual as a first-class entity — not a column mutated in place — is what makes the architecture auditable. An accrual is written once, carries the full ordered rule_trace that produced it, and is never updated; a re-run produces a new accrual with a new evaluation timestamp, so the lineage of how a promotion was recognized survives indefinitely.
Pipeline Design Principles
The mapping architecture runs as a stateless stage between ingestion and settlement, and four properties make its output trustworthy: idempotency, deterministic hashing, schema versioning, and temporal anchoring.
Idempotency. Re-running the mapping over the same inputs must produce byte-identical results. No stage may read wall-clock time, generate a random tie-breaker, or depend on row ordering. In practice the evaluation function is pure: (transaction_slice, agreement_version, config_version) -> Accrual. Upserts into the accrual store key on the tuple (promotion_id, txn_grain, config_version) so a replayed batch overwrites cleanly instead of duplicating — the single most common cause of phantom liabilities in hand-rolled pipelines.
Deterministic hashing. Every input that can change an outcome is folded into a content hash. The agreement carries a scope_hash; the rule configuration carries a config_version that is itself a SHA-256 over the canonicalized rule manifest. Persisting both alongside the accrual lets an auditor prove that a given posting was produced by a specific contract version and a specific rule set, with no ambiguity. The same hashing discipline gives the canonical staging layer record-level traceability — a composite key over (source, document_id, line_no) deduplicates re-delivered EDI before it ever reaches the registry.
import hashlib
import json
from decimal import Decimal
def config_hash(manifest: dict) -> str:
"""Stable hash over a rule manifest. Sorted keys + Decimal-safe encoding
guarantee the same bytes on every environment."""
canonical = json.dumps(
manifest,
sort_keys=True,
separators=(",", ":"),
default=lambda o: f"D:{o}" if isinstance(o, Decimal) else o,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Schema versioning. Agreement schemas and rule definitions evolve. Both are stored as version-controlled manifests in Git, promoted through environments (dev → staging → prod) by pull request, and validated against a Pydantic v2 model on load so a malformed config fails fast at deploy time rather than mid-batch. Validating the agreement itself is the cheapest gate in the system:
from decimal import Decimal
from pydantic import BaseModel, Field, model_validator
class Tier(BaseModel):
lower_bound: Decimal = Field(ge=0)
upper_bound: Decimal | None = None # None == open-ended top tier
rate: Decimal = Field(ge=0, le=1) # fraction, e.g. 0.0250 == 2.5%
class Agreement(BaseModel):
agreement_id: str
agreement_version: int = Field(ge=1)
tiers: list[Tier]
@model_validator(mode="after")
def tiers_must_be_ordered_and_contiguous(self) -> "Agreement":
bounds = sorted(self.tiers, key=lambda t: t.lower_bound)
for prev, nxt in zip(bounds, bounds[1:]):
if prev.upper_bound is None or prev.upper_bound != nxt.lower_bound:
raise ValueError("tiers must be contiguous and non-overlapping")
return self
Temporal anchoring. Every date the architecture compares is anchored to UTC and floored to the day at ingestion, so the rule layer never reasons about timezones. Overlapping validity windows are resolved with interval-tree lookups rather than ad-hoc overrides, which is what prevents a transaction that falls inside two promotions from being counted twice. Upholding this anchoring contract is the responsibility of the data ingestion normalization pipelines; the mapping layer treats any non-anchored timestamp as a hard reject rather than guessing its intent.
Rule Evaluation Architecture
Promotions are mapped to transactions through a directed acyclic graph of rules, not a linear checklist. Each node is a pure predicate that returns pass, fail, or flag plus structured metadata; edges encode precedence and short-circuit conditions. A DAG — rather than nested if blocks — is what lets analysts reason about evaluation order, prove there are no circular dependencies, and add a commercial term without re-deriving the whole flow. The complete precedence model, predicate encoding, and window logic are owned by the eligibility rule framework; this section frames how that graph sits inside the broader architecture.
The graph runs in four phases, each a gate on the next:
- Structural and identity resolution. Schema conformance, vendor-ID resolution, and agreement-version reference. A failure here is terminal — there is nothing to measure against an unresolved transaction. Identity resolution depends on the SKU normalization performed upstream so that a remapped GTIN or a promotional pack variant lands on the correct internal hierarchy node instead of generating a phantom duplicate.
- Dimensional alignment. Channel, territory, and most critically the promotion window. Temporal misalignment is the single largest driver of overpayment, so window checks run here against the UTC-anchored transaction facts, applying any contracted grace period explicitly rather than implicitly. Configuring those boundaries is detailed in configuring promotion eligibility windows.
- Quantitative verification. Volumes, rates, and tier breakpoints measured against source-of-truth sales, confirming that a claimed tier was actually earned before any liability is recognized.
- Accrual and routing. The terminal phase aggregates every upstream outcome, generates the accrual, and routes any discrepancy. Auto-post, exception queue, or manual review is decided by the combined rule outcome rather than by an analyst’s judgment.
Precedence is explicit and total: a fail in an earlier phase short-circuits to routing and is never silently overridden by a later pass; a flag does not stop evaluation but is carried forward into the routing decision. Encoding this as a DAG makes the order a property of the configuration, not of the code, so changing precedence is a reviewed manifest edit rather than a refactor.
from enum import Enum
class Outcome(str, Enum):
PASS = "pass"
FAIL = "fail"
FLAG = "flag"
def evaluate(txn, agreement, rules) -> list[tuple[str, Outcome]]:
trace: list[tuple[str, Outcome]] = []
for rule in rules: # rules are a topologically sorted DAG
outcome = rule.apply(txn, agreement)
trace.append((rule.id, outcome))
if outcome is Outcome.FAIL and rule.short_circuit:
break # terminal; route straight to exceptions
return trace
Financial Modeling & Accrual Generation
Once a transaction clears verification, the architecture computes the accrual — and this is where correctness is non-negotiable. Trade agreements almost never pay a flat rate; they pay across incremental tiers, where each band of volume earns its own rate, or retroactive tiers, where crossing a breakpoint re-rates the entire volume. Conflating the two is the most common source of payout disputes in rebate reconciliation, and the difference is purely a matter of which summation the engine applies. The full treatment of caps, floors, settlement cadence, and overlapping offers lives in payout structure modeling.
All monetary arithmetic uses decimal.Decimal with an explicit context and rounding mode. Float arithmetic introduces drift that is invisible on a single line and material across a quarter-end accrual run; IFRS 15 and ASC 606 revenue-recognition treatment depends on the number being reproducible to the cent, so float is simply not permitted anywhere in the settlement path.
from decimal import Decimal, ROUND_HALF_UP, getcontext
getcontext().prec = 28
def incremental_rebate(volume: Decimal, tiers: list["Tier"]) -> Decimal:
"""Each band earns its own rate; only the marginal volume in a band is re-rated."""
total = Decimal("0")
for t in tiers:
upper = t.upper_bound if t.upper_bound is not None else volume
band = max(Decimal("0"), min(volume, upper) - t.lower_bound)
total += band * t.rate
return total.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def retroactive_rebate(volume: Decimal, tiers: list["Tier"]) -> Decimal:
"""Crossing a breakpoint re-rates the ENTIRE volume at the highest earned rate."""
earned = next(
t.rate for t in reversed(tiers)
if volume >= t.lower_bound
)
return (volume * earned).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
The accrual the architecture posts is the modeled rebate, not whatever a vendor later claims. When the two disagree, the delta is the dispute, and it is routed — never written off and never auto-paid. The accrual record carries both figures plus the tier trace so a finance analyst can reconstruct the calculation line-by-line during month-end close, where the order of operations (accrual freeze → retroactive re-rate → audit export) is itself a controlled sequence. When promotions overlap on the same volume, the precedence and proration rules in handling overlapping trade promotions determine which offer earns the spend.
Exception Taxonomy & Routing
Production data is never clean, and a mapping architecture that halts on the first imperfect record is unusable. Exceptions are therefore a first-class output, classified into a stable taxonomy so that routing, SLA, and root-cause analysis are driven by code rather than by an analyst’s judgment. Each unmatched or flagged record exits with an explicit mismatch code and a priority, and the routing layer maps that code to a queue and a deadline.
| Mismatch code | Category | Routing priority | SLA target |
|---|---|---|---|
AGREEMENT_UNRESOLVED |
identity | high — blocks accrual | 1 business day |
SKU_UNRESOLVED |
identity | high — blocks accrual | 1 business day |
WINDOW_OUT_OF_RANGE |
temporal | high — overpayment risk | 1 business day |
TIER_MISMATCH |
quantitative | medium — amount dispute | 3 business days |
OVERLAP_CONFLICT |
mapping | medium — double-count risk | 3 business days |
REF_DATA_MISSING |
degraded | low — retry on refresh | 5 business days |
When a primary path fails on missing reference data or an upstream timeout, the architecture degrades rather than aborts: a fallback chain evaluates against cached master data or a simplified rule subset, tags the record for post-processing reconciliation, and keeps the batch moving so operational SLAs hold during partial degradation. The escalation and adjudication mechanics of those chains — including default-rate policy and the manual adjudication queue — are owned by the fallback routing logic cluster, with the specific case of incomplete contracts covered in fallback routing for missing agreement terms. The mapping layer’s job is to emit the correct code and never to silently post a misaligned accrual.
Governance, Audit Trail & Access Control
A reconciliation architecture is a system of financial record, so its governance posture has to satisfy SOX and survive external audit. Three controls are load-bearing.
Immutable, signed audit logs. Every evaluation writes an append-only log entry: input hashes, config_version, agreement_version, the ordered outcome, and the resulting route. Entries are never updated. To make tampering detectable, each accrual is cryptographically signed and the rule_trace is hash-chained, so any retroactive edit breaks the chain and surfaces in reconciliation. Agreement drift detection runs continuously alongside this, comparing active contracts against executed transactions through change-data-capture streams and flagging unauthorized pricing, expired terms, or unapproved promotional extensions.
import hmac, hashlib
def sign_accrual(accrual_bytes: bytes, key: bytes) -> str:
return hmac.new(key, accrual_bytes, hashlib.sha256).hexdigest()
Role-based access control. Configuration and data are segregated by least privilege. Vendor-facing portals expose only contracted terms and settlement statements; ETL developers can deploy rule and schema manifests through the promotion pipeline but cannot mutate posted accruals; trade finance analysts can adjudicate exceptions and export audit trails but cannot edit the rule graph. RBAC tags travel with every entity so authorization is enforced at the field level, not just the endpoint, and sensitive commercial data is encrypted in transit and at rest.
Configuration as code with deterministic testing. Agreement schemas and rule definitions live in Git with pull-request approval, automated linting, and environment promotion. Parameterization beats branching: thresholds and experimental terms toggle via config and feature flags, never via deployed code paths. A synthetic transaction corpus covering the hard cases — leap-year promotions, partial shipments, retroactive amendments, mid-cycle tier crossings, overlapping offers — runs on every change so a manifest edit cannot regress a known boundary. The combination turns promotion mapping from a reactive bottleneck into a proactive control: lower margin leakage, faster vendor settlements, and a reconciliation system that adapts as trade-promotion strategy evolves.
Frequently Asked Questions
Why map a promotion to an agreement version instead of the agreement itself?
Contracts are amended mid-cycle — a rate changes, a window extends, a SKU is added. If a promotion referenced the agreement in the abstract, that amendment would silently re-map historical transactions under terms that did not exist when they occurred. Binding to an immutable agreement_version means each accrual stays attributable to the exact terms in force at evaluation time, which is what makes the posting defensible in an audit.
How does the architecture stop a single transaction from being counted under two promotions?
Overlapping validity windows are resolved with interval-tree lookups and explicit precedence rules in the rule manifest, not ad-hoc overrides. When a transaction falls inside two windows, precedence decides which offer earns the spend and the loser is recorded as an OVERLAP_CONFLICT exception rather than a second accrual, so the same volume can never generate two liabilities.
Why is decimal.Decimal mandatory instead of float for accruals?
Float arithmetic accumulates non-reproducible rounding drift that is immaterial per line but material across a quarter-end run, and it is not consistent across platforms. Because IFRS 15 / ASC 606 treatment requires accruals reproducible to the cent and the accrual hash must be identical on every environment, the settlement path uses Decimal with a fixed context and explicit ROUND_HALF_UP.
How do I change a commercial term without redeploying the pipeline?
Agreements and rules are version-controlled manifests, not code. Edit the manifest, open a pull request, let the Pydantic schema check and synthetic-corpus tests run, and promote dev → staging → prod. The engine loads the new config_version, and because accruals key on that version, replays are unambiguous and old postings remain attributable to the configuration that produced them.
Related
- Agreement Schema Design — model contracts, tiers, and amendment lineage as a versioned, queryable registry.
- Eligibility Rule Framework — encode channel, territory, and window predicates as a deterministic DAG.
- Payout Structure Modeling — translate earned volume into accruals with flat, incremental, and retroactive tiers.
- Fallback Routing Logic — route unmatched and degraded records to default-rate policy and manual adjudication.
- Claim Validation & Rule Engine Configuration — the downstream engine that adjudicates vendor claims against this registry.
- Data Ingestion & Normalization Pipelines — the upstream stage that delivers typed, UTC-anchored records to the mapping layer.
Up one level: Home