Flat vs Incremental vs Retroactive Tier Math
The single most common calculation dispute in rebate reconciliation is not a missing claim or a mismatched SKU — it is two parties reading the same tier table three different ways and each insisting their number is correct. Flat, incremental, and retroactive tier math applied to one identical volume produce materially different accruals, and unless the settlement engine declares which model governs each agreement, that gap surfaces at month-end as an unexplained variance nobody can defend in an audit. This guide works all three models on the same $200,000 net-purchase volume so the payout difference is explicit, then walks the mid-cycle breakpoint crossing where the retroactive model diverges hardest. It is a hands-on companion to payout structure modeling, which owns the broader settlement math; here we isolate the one decision — which summation the engine applies — that decides the number.
The three models share the same tier ladder — a base rate to $50,000, a higher rate to $150,000, and the top rate above — and differ only in how they rate a volume that reaches the top band:
| Model | How it rates the volume | Same-volume payout ($200,000) | Typical use |
|---|---|---|---|
| Flat | One negotiated rate applies to the entire volume; no tier escalation | $3,000.00 | Simple all-purchase rebates, base-tier loyalty |
| Incremental (marginal) | Each band earns its own rate on only the volume that falls inside it | $6,000.00 | Growth incentives that reward marginal spend |
| Retroactive (whole-volume) | The highest earned rate re-rates the entire volume once its breakpoint is crossed | $9,000.00 | Aggressive annual volume targets, cliff deals |
Prerequisites
Before reconciling a tiered payout, confirm the following are in place:
- A validated agreement with an explicit model flag. The tier ladder originates in agreement schema design and must declare which model — flat, incremental, or retroactive — governs the payout. Inferring the model from the tier shape is the root cause of the dispute this page exists to prevent.
- Contiguous, non-overlapping tiers. Each band’s
upper_boundmust equal the next band’slower_bound, withlower_boundinclusive andupper_boundexclusive, so no volume is double-rated or skipped at a breakpoint. - Python 3.11+ with
pydantic>=2.5. Validation uses Pydantic v2 model validators; install withpip install "pydantic>=2.5". decimal.Decimaldiscipline. Every rate, threshold, and payout is parsed and computed withdecimal.Decimalunder a fixed context, never a JSON float. This is non-negotiable for accruals that must tie out to the cent.- A cumulative-volume accumulator. Retroactive math is stateful across a cycle; the running total must be keyed on
(agreement_id, scope, period)so a mid-cycle crossing recomputes against a replay-safe aggregate.
Step-by-Step Implementation
Step 1 — Define the shared tier ladder
Encode one ladder that all three models read, storing every rate and threshold as a decimal string so nothing enters the engine as a double-precision float. The flat_rate sits alongside the tiers because a flat agreement pays a single negotiated rate independent of the escalators.
{
"agreement_id": "AGR-2026Q3-VENDOR88",
"basis": "net_purchase_amount",
"flat_rate": "0.015",
"tiers": [
{"lower_bound": "0", "upper_bound": "50000", "rate": "0.015"},
{"lower_bound": "50000", "upper_bound": "150000", "rate": "0.03"},
{"lower_bound": "150000", "upper_bound": null, "rate": "0.045"}
]
}
Validate the ladder at ingestion with the same Tier model the settlement path uses, rejecting any gap or overlap between bands:
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.0450 == 4.5%
class Agreement(BaseModel):
agreement_id: str
flat_rate: Decimal = Field(ge=0, le=1)
tiers: list[Tier]
@model_validator(mode="after")
def _contiguous(self) -> "Agreement":
bands = sorted(self.tiers, key=lambda t: t.lower_bound)
for prev, nxt in zip(bands, bands[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
Validation check: assert Agreement.model_validate_json(payload).tiers has length 3, and that mutating the middle lower_bound to 60000 raises ValidationError. A ladder with a gap must fail at the boundary, not at month-end.
Step 2 — Compute the flat payout
A flat agreement applies one rate — here the base 1.5% — to the entire volume, with no escalation. This is the simplest model and the floor of the comparison.
from decimal import Decimal, ROUND_HALF_UP, getcontext
getcontext().prec = 28
def flat_rebate(volume: Decimal, flat_rate: Decimal) -> Decimal:
"""A single rate applies to the whole volume; no tier escalation."""
return (volume * flat_rate).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
volume = Decimal("200000")
assert flat_rebate(volume, Decimal("0.015")) == Decimal("3000.0000")
Validation check: the assertion pins the flat payout to $3,000.0000. Because flat ignores the escalators entirely, its result is invariant to how much of the volume lands in the upper bands — a property the other two models do not share.
Step 3 — Compute the incremental payout
Incremental (marginal) math re-rates only the slice of volume that falls inside each band. It is the same incremental_rebate function the architecture uses in its core architecture and promotion mapping settlement path — each band contributes its own volume times its own rate.
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)
# 50,000 @ 1.5% + 100,000 @ 3% + 50,000 @ 4.5% = 750 + 3,000 + 2,250
assert incremental_rebate(volume, tiers) == Decimal("6000.0000")
Validation check: the assertion pins the incremental payout to $6,000.0000 — double the flat result on the same volume. The three band contributions (750, 3000, 2250) sum to the total; instrument the loop to emit that trace so an analyst can reconstruct the figure line-by-line.
Step 4 — Compute the retroactive payout
Retroactive (whole-volume) math finds the highest rate the volume has earned and applies it to all of the volume. It is the retroactive_rebate function from the same settlement path, and it produces the largest number of the three.
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)
# 200,000 has crossed the $150k breakpoint, so ALL of it rates at 4.5%
assert retroactive_rebate(volume, tiers) == Decimal("9000.0000")
Validation check: the assertion pins the retroactive payout to $9,000.0000 — triple the flat result. The reversed(tiers) scan selects the top band whose lower_bound the volume meets or exceeds; the >= comparison is deliberate and load-bearing, as Step 5 and the boundary failure mode below make clear.
Step 5 — Model the mid-cycle breakpoint crossing
Retroactive math is stateful. When cumulative volume crosses a breakpoint partway through a cycle, the entire aggregate re-rates at the new higher rate — so a small incremental sale can trigger a large accrual jump. Suppose $140,000 was accrued mid-quarter and a single $20,000 order lifts the cumulative total to $160,000, crossing the $150,000 breakpoint. The correct posting is the delta between the two whole-volume figures, not the new units alone.
def retroactive_delta(prior: Decimal, current: Decimal, tiers: list[Tier]) -> Decimal:
"""Post the difference between the re-rated cumulative totals, not the new units."""
return retroactive_rebate(current, tiers) - retroactive_rebate(prior, tiers)
prior, current = Decimal("140000"), Decimal("160000")
# retroactive: 140,000 @ 3% = 4,200 -> 160,000 @ 4.5% = 7,200
assert retroactive_delta(prior, current, tiers) == Decimal("3000.0000")
# incremental on the SAME $20,000 sale re-rates only the new units
inc_delta = incremental_rebate(current, tiers) - incremental_rebate(prior, tiers)
assert inc_delta == Decimal("750.0000") # 10k @ 3% + 10k @ 4.5%
Validation check: the same $20,000 order accrues $750 under incremental math but $3,000 under retroactive — the $2,250 gap is the re-rate cliff. The engine must post this delta against the prior accrual and flag the crossing for trade finance review, keyed on (agreement_id, scope, period). Rating only the new units at 4.5% ($900) underpays the vendor and guarantees a dispute; the ordering of accrual freeze and retroactive re-rate is itself a controlled step in month-end close sequencing.
Common Failure Modes and Fixes
1. Conflating incremental with retroactive. The same ladder pays $6,000 incrementally and $9,000 retroactively on identical volume — a 50% difference driven purely by which summation runs. Fix: store the model as an explicit enum on the agreement and dispatch on it; never infer the model from the tier shape, and never let a growth-incentive deal silently rate as a whole-volume cliff deal.
2. Floating-point drift. Computing volume * rate in float reintroduces non-reproducible rounding that is immaterial per line but material across a quarter-end run and inconsistent across platforms. Fix: parse rates and thresholds as decimal strings, compute in decimal.Decimal under a fixed getcontext().prec, and quantize once at the end.
3. Off-by-one band boundary. A volume of exactly $150,000 belongs in the top band under an inclusive-lower convention, but a > comparison leaves it in the middle band and underpays. Fix: fix lower_bound as inclusive and upper_bound as exclusive everywhere, and assert the boundary case directly:
assert retroactive_rebate(Decimal("150000"), tiers) == Decimal("6750.0000") # 150k @ 4.5%
# a '>' comparison would wrongly return 150000 * 0.03 == 4500.0000
4. Retroactive cliff not accrued. On a mid-cycle crossing, posting only the new units at the new rate ($900 in Step 5) instead of the re-rated cumulative delta ($3,000) understates liability by $2,100 and surfaces as a vendor short-pay claim. Fix: recompute the full cumulative volume at the new rate and post the delta versus the prior accrual, preserving the original record for audit.
5. Wrong or inconsistent rounding. Quantizing each band product before summing, or using ROUND_HALF_EVEN where the contract specifies ROUND_HALF_UP, drifts the total by cents that aggregate into a material tie-out break. Fix: accumulate at full precision and apply one quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) at the end, matching the rounding mode written into the agreement.
Operational Checklist
Frequently Asked Questions
What happens when a retroactive tier is crossed mid-cycle? The entire cumulative volume re-rates at the newly earned rate, so the engine posts the difference between the two whole-volume figures. In the worked example a $20,000 order that lifts cumulative volume from $140,000 to $160,000 accrues a $3,000 delta — not the $900 the new units alone would earn at 4.5% — because all $160,000 now rates at the top band. The pipeline recomputes against the running total keyed on agreement, scope, and period, posts the delta against the prior accrual, and flags the crossing for review while preserving the original record.
Why do flat and incremental produce different numbers on the same volume? Flat applies one negotiated rate to the whole volume with no escalation, so $200,000 at 1.5% is $3,000. Incremental re-rates each band separately — $50,000 at 1.5%, $100,000 at 3%, and $50,000 at 4.5% — summing to $6,000. The difference is entirely structural: flat never sees the upper bands, while incremental credits the marginal volume in each. Storing the model explicitly is what keeps the two from being confused during settlement.
Which rounding mode should tier math use?
Use the mode written into the agreement, most commonly ROUND_HALF_UP, and apply it exactly once by accumulating band products at full Decimal precision and quantizing the final total. Rounding each intermediate product before summing, or mixing ROUND_HALF_EVEN with a vendor computing ROUND_HALF_UP, drifts the payout by cents that compound into a material tie-out break at quarter-end.
Is whole-turnover at the achieved tier the same as flat or retroactive? It is retroactive, not flat. “Whole-turnover at the achieved tier” means the rate of the highest tier reached applies to all volume — precisely the retroactive model that pays $9,000 here. Flat means a single fixed rate independent of tiers, paying $3,000. The phrase is a frequent source of confusion in contract language, so pin the intended model to an explicit enum rather than to prose.
Related
- Parent topic: Payout Structure Modeling — the settlement math that owns caps, floors, and cadence around these three models.
- Handling Overlapping Trade Promotions — precedence and proration when more than one tiered offer claims the same volume.
- Sequencing Accrual Freeze and Retroactive Recalculation — the controlled order in which mid-cycle retroactive re-rates post during close.
- How to Map Vendor Rebate Tiers in JSON — encoding the tier ladder these models read as validated, audit-ready JSON.
Up one level: Payout Structure Modeling