How to Map Vendor Rebate Tiers in JSON
Reconciliation breaks most often when a negotiated tier table — the volume breakpoints, rate escalators, and rate_type semantics in the signed agreement — is translated into machine logic that disagrees with the contract by a fraction of a cent or a single boundary unit. This page solves one concrete task: encoding a vendor’s tiered rebate structure as deterministic, validated JSON that a settlement engine can evaluate the same way on every run. It is a hands-on companion to agreement schema design, which owns the broader contract-to-record model; here we focus only on the tier array — its layout, its ordering guarantees, and the validation that keeps it from silently mis-rating a payout.
Prerequisites
Before mapping a tier table, confirm the following are in place:
- An active agreement version to attach to. Tiers do not exist standalone — they belong to a specific
agreement_idandversion_sequenceproduced by the agreement schema design stage. Mapping tiers without a governing version hash means claims cannot be pinned to the terms in force when the sale occurred. - Python 3.11+ with
pydantic>=2.5. All validation snippets below use Pydantic v2 model validators. Install withpip install "pydantic>=2.5". decimal.Decimaldiscipline. Every rate and threshold is parsed viadecimal.Decimal, never a JSON float. This is non-negotiable for accruals that must reconcile to the cent.- A product taxonomy reference. Scope filters should resolve against an authoritative identifier system such as GS1 product identification standards so SKU and channel scopes mean the same thing in the ERP, the WMS, and the vendor portal.
- A published JSON Schema target. Validate the serialized contract against the JSON Schema specification so vendor portals, BI tools, and the reconciliation engine consume one definition without per-consumer transformation.
- Write access scoped to draft agreements only. Tier edits land on draft versions; posted accruals are immutable.
Step-by-Step Implementation
Step 1 — Anchor the tiers to an immutable agreement container
At the root, declare the identifiers that bind the tier table to the master contract registry and fix its temporal boundaries: agreement_id, version, effective_date_range, currency, and calculation_frequency. The tiers array is nested inside this container so every tier inherits the version hash and effective window.
{
"agreement_id": "AGR-20260101-VENDOR42",
"version": "2.1.0",
"effective_date_range": {
"start": "2026-01-01T00:00:00Z",
"end": "2026-12-31T23:59:59Z"
},
"currency": "USD",
"calculation_frequency": "MONTHLY",
"tiers": []
}
Validation check: assert that effective_date_range.end is strictly after start and that currency is a valid ISO 4217 code before accepting any tiers. A container with an inverted interval must be rejected at the boundary, not at month-end.
Step 2 — Encode each tier band with explicit boundaries and rate semantics
Each tier object carries an ascending, non-overlapping band plus the semantics the modeling layer needs. Store rate as a decimal string, never a JSON number — JSON numbers are double-precision floats, and string-plus-Decimal parsing eliminates the rounding drift that compounds across millions of line items.
{
"tiers": [
{
"min_threshold": "0",
"max_threshold": "9999",
"rate": "0.02",
"rate_type": "flat",
"scope": {"channel": ["GROCERY_RETAIL"]}
},
{
"min_threshold": "10000",
"max_threshold": "24999",
"rate": "0.035",
"rate_type": "incremental",
"scope": {"channel": ["GROCERY_RETAIL"]}
},
{
"min_threshold": "25000",
"max_threshold": null,
"rate": "0.05",
"rate_type": "retroactive",
"scope": {"channel": ["GROCERY_RETAIL"]}
}
]
}
The fields per tier:
| Field | Type | Meaning |
|---|---|---|
min_threshold |
str → Decimal | Minimum volume, net sales, or unit count to qualify |
max_threshold |
str → Decimal | null | Optional ceiling that caps tier applicability; null = open top |
rate |
str → Decimal | Rebate percentage or fixed amount as a decimal string |
rate_type |
enum | flat, incremental, or retroactive |
scope |
object | Product, channel, or geography filters |
Validation check: confirm the min_threshold values are strictly ascending and that no band overlaps the next (tiers[i].max_threshold < tiers[i+1].min_threshold). Unordered or overlapping bands make rate application ambiguous.
Step 3 — Attach the tier scope to a parsable predicate, not free text
Tier qualification rarely depends on volume alone. Channel, SKU family, and the promotional window are encoded as machine-parsable objects so the eligibility rule framework can validate types and reject malformed payloads before they reach settlement.
{
"eligibility": {
"product_scope": {
"type": "taxonomy_path",
"values": ["CPG/Beverage/Carbonated/Soda"]
},
"channel_scope": ["DTC", "GROCERY_RETAIL"],
"temporal_window": {
"start": "2026-01-01T00:00:00Z",
"end": "2026-03-31T23:59:59Z",
"timezone": "UTC"
}
}
}
Deterministic precedence resolves overlapping scopes: explicit UPC/EAN beats brand family, which beats category path, which beats the global default. When multiple tiers intersect, the engine selects the highest qualified rate unless the contract caps retroactive stacking.
Validation check: every product_scope.values entry must resolve to a known taxonomy node, and temporal_window must fall inside the agreement’s effective_date_range. A scope that extends past the contract window is drift, not a tier.
Step 4 — Validate the structure with a Pydantic v2 model
Enforce the schema at ingestion so a bad tier table fails fast at the boundary instead of surfacing as a bad accrual weeks later. The validator rejects the two most common defects — float-typed rates and unordered bands.
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator, model_validator
class RateType(str, Enum):
flat = "flat"
incremental = "incremental"
retroactive = "retroactive"
class Tier(BaseModel):
min_threshold: Decimal
max_threshold: Decimal | None = None
rate: Decimal
rate_type: RateType
@field_validator("rate", "min_threshold", "max_threshold", mode="before")
@classmethod
def _reject_float(cls, v):
if isinstance(v, float):
raise ValueError("rates and thresholds must be strings, not floats")
return v
class TierTable(BaseModel):
agreement_id: str
version: str
tiers: list[Tier]
@model_validator(mode="after")
def _check_ordering(self) -> "TierTable":
lowers = [t.min_threshold for t in self.tiers]
if lowers != sorted(lowers):
raise ValueError("tiers must be ordered by ascending min_threshold")
for a, b in zip(self.tiers, self.tiers[1:]):
if a.max_threshold is None or a.max_threshold >= b.min_threshold:
raise ValueError(f"overlapping band at {a.min_threshold}")
return self
Validation check: load every incoming tier table through TierTable.model_validate_json(payload). A raised ValidationError routes the payload to quarantine; a clean parse lets it proceed to rating.
Step 5 — Apply the correct payout math per rate_type
The mathematical behavior of a tier depends entirely on its rate_type, and getting it wrong is the most common source of payout disputes. The settlement math itself is owned by payout structure modeling; the tier table’s job is to declare which behavior applies.
- Flat — the tier rate applies only to units inside the current band; units below
min_thresholdearn the prior band’s rate. - Incremental (marginal) — the total rebate is the sum of each band’s volume times that band’s rate.
- Retroactive — once the cumulative threshold is crossed, the highest qualified rate applies to all eligible units in the window, which forces a recalculation of prior claims when the breakpoint is crossed mid-cycle.
from decimal import Decimal, ROUND_HALF_UP
def rate_tier_table(volume: Decimal, tiers: list[Tier]) -> Decimal:
"""Dispatch on the top qualifying tier's rate_type."""
qualifying = [t for t in tiers if volume >= t.min_threshold]
if not qualifying:
return Decimal("0")
top = qualifying[-1]
if top.rate_type == RateType.retroactive:
total = volume * top.rate
else: # flat / incremental sum each marginal band
total = Decimal("0")
for t in qualifying:
upper = min(volume, t.max_threshold or volume)
total += (upper - t.min_threshold) * t.rate
return total.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
Validation check: for a retroactive crossing, assert the engine emits an adjustment entry against the prior accrual rather than a fresh full accrual, and flags the event for trade finance review. Track cumulative volume keyed on (agreement_id, scope_hash, period) so the running aggregate is replay-safe.
Common Failure Modes and Fixes
1. Floating-point drift from numeric rates. Storing "rate": 0.035 as a JSON number reintroduces double-precision rounding that diverges across platforms and accumulates across a quarter-end run. Fix: keep rates as strings at rest and parse via Decimal; the _reject_float validator in Step 4 blocks the regression at ingestion.
2. Overlapping or unordered bands. A tier whose max_threshold meets or exceeds the next tier’s min_threshold makes rate selection non-deterministic. Fix: enforce the _check_ordering model validator; reject the payload rather than guess which band wins.
3. Missing or stale version hash. Editing a tier in place without bumping version silently re-rates closed periods under new terms. Fix: treat tier edits as a new agreement version — every change produces a fresh version_sequence and hash, and claims pin to the version in force at sale time.
4. Retroactive crossing not back-applied. When a retroactive breakpoint is crossed mid-cycle, rating only the new units underpays the vendor and triggers a dispute. Fix: on every crossing, recompute the full cumulative volume at the new rate and post the delta versus the prior accrual; preserve the original record for audit.
5. Scope creep past the contracted window. A temporal_window that extends beyond effective_date_range, or a product_scope broader than negotiated, accrues claims the contract never authorized. Fix: validate scope containment at ingestion and route violations to the quarantine queue with an exception ticket rather than auto-settling. Unmatched claims fall through to fallback routing logic with an explicit fallback_rate (typically "0.0" or a contractual floor) while the original payload is preserved for manual adjudication.
Operational Checklist
Frequently Asked Questions
Why store rebate rates as strings instead of JSON numbers?
JSON numbers are double-precision floats. Float arithmetic accumulates non-reproducible rounding drift that is immaterial per line but material across a quarter-end run, and it differs across platforms. Holding rates as decimal strings at rest and parsing them with decimal.Decimal and an explicit ROUND_HALF_UP context keeps accruals reproducible to the cent and keeps the version hash identical across environments.
What happens when a retroactive tier is crossed mid-cycle?
The highest qualified rate re-rates the entire cumulative volume for the window, not just the units above the breakpoint. The pipeline recomputes prior claims keyed on (agreement_id, scope_hash, period), posts the delta versus the existing accrual, and flags the crossing for trade finance review. The original accrual record is preserved so the audit trail shows both pre- and post-crossing state.
How do flat, incremental, and retroactive tiers differ in the math?
Flat rates only the units inside the current band at that band’s rate. Incremental sums each band’s volume times its own rate. Retroactive applies one rate — the highest earned — to all qualifying volume once the breakpoint is crossed. Because the dispute risk is highest here, rate_type must be stored explicitly per tier rather than inferred.
How do overlapping tier scopes resolve to a single rate? The engine applies a specificity hierarchy — explicit UPC/EAN beats brand family, which beats category path, which beats the global default — and then selects the highest qualified rate unless the contract caps retroactive stacking. Encoding scope as parsable predicate data rather than free text is what makes this resolution deterministic and reproducible during an audit.
Related
- Agreement Schema Design — the parent model this tier table attaches to, including version hashing and entity topology.
- Handling Overlapping Trade Promotions — precedence and stacking rules when multiple tiered promotions match the same transaction.
- Setting Dynamic Volume Thresholds for Rebates — validating the threshold values that define each tier band at claim time.
Up one level: Agreement Schema Design