Handling Retroactive Promotion Window Changes
A vendor’s trade team extends a promotion that ran October 1–15 back to September 20, and by the time the amendment lands the September transactions have already posted, settled, and accrued against the old window. You cannot simply re-run the job: the prior accruals are financial records that a closed or closing period depends on. The correct response is to bind a new agreement version, re-evaluate the affected transactions against the revised boundaries, and emit a delta — a second, write-once accrual that carries only the difference — while the original record stays untouched. This page is the implementation-level companion to the date window alignment checks topic area, which governs how temporal boundaries are enforced inside the broader claim validation rule engine; where the sibling guide on aligning promotion start/end dates with sales data binds a transaction to a window the first time, this guide handles what happens when that window later moves under records that already settled.
Prerequisites
Before processing any retroactive amendment, confirm the following are in place:
- Versioned agreements. Each promotion is stored under an immutable
agreement_versionkeyed by(promo_id, version_no)with aneffective_fromtimestamp. An amendment never edits the current version in place — it appends a new one, following the discipline in versioning rebate agreement schemas. - Write-once accruals. Prior accruals are append-only ledger rows carrying
accrual_id,agreement_version_no,amount, and astatusofsettledoropen. There is noUPDATEpath against a settled accrual; corrections are expressed only as new rows. - A stable transaction store. The sales transactions that were attributed to the original window must still be retrievable by
promo_idand theirtxn_local_day, already normalized by the data ingestion normalization pipelines. Re-evaluation reads them; it does not re-ingest them. - Roles. The
reconciliation_etlrole holds read access to the agreement registry and transaction store and insert-only access to the accrual ledger. The authority to approve a retroactive amendment belongs to a separatetrade_finance_approverrole — the engine binds the version, but a human authorizes the amendment upstream. - Python packages:
pydantic>=2.6and the standard-librarydecimalanddatetimemodules. Every monetary field isdecimal.Decimal; a delta computed infloatis a defect, not a rounding nuisance.
Step-by-step implementation
Step 1 — Detect the amendment and bind a new agreement version
An incoming window change is a signal to branch, not to overwrite. Load the currently effective version, apply the revised boundaries onto a copy, and persist it as the next version_no. The old version remains addressable so any accrual that cites it can still be explained.
from datetime import date, datetime, timezone
from decimal import Decimal
from pydantic import BaseModel, field_validator
class AgreementVersion(BaseModel):
promo_id: str
version_no: int
window_start: date
window_end: date
rebate_rate: Decimal # money math stays in Decimal
effective_from: datetime
supersedes: int | None # prior version_no, None for the first
@field_validator("window_end")
@classmethod
def ordered(cls, v: date, info) -> date:
start = info.data.get("window_start")
if start and v < start:
raise ValueError("window_end precedes window_start")
return v
def bind_amendment(current: AgreementVersion, new_start: date,
new_end: date) -> AgreementVersion:
return AgreementVersion(
promo_id=current.promo_id,
version_no=current.version_no + 1,
window_start=new_start,
window_end=new_end,
rebate_rate=current.rebate_rate,
effective_from=datetime.now(timezone.utc),
supersedes=current.version_no,
)
Validation check: assert new.version_no == current.version_no + 1 and new.supersedes == current.version_no. If the store already holds a row at the target (promo_id, version_no), abort — an amendment must never collide with an existing version.
Step 2 — Select the transactions now in or out of the revised window
Re-open the promotion by computing the symmetric difference between the old window’s membership and the new window’s membership. Transactions that entered the window (the September rows in the opening example) drive a positive delta; transactions that left it drive a negative one.
def reclassify(txns: list[dict], old: AgreementVersion,
new: AgreementVersion) -> dict[str, list[dict]]:
def in_window(day: date, v: AgreementVersion) -> bool:
return v.window_start <= day <= v.window_end # inclusive both ends
entered, left, unchanged = [], [], []
for t in txns:
d = t["txn_local_day"]
was, now = in_window(d, old), in_window(d, new)
if now and not was:
entered.append(t)
elif was and not now:
left.append(t)
elif was and now:
unchanged.append(t)
return {"entered": entered, "left": left, "unchanged": unchanged}
Validation check: len(entered) + len(left) + len(unchanged) must equal the count of transactions that were in either window. Rows in neither window are correctly ignored — assert none of them appear in any bucket.
Step 3 — Recompute eligibility and accrual with decimal.Decimal
Recompute the full accrual the promotion would carry under the new version — not the delta yet. Run the same eligibility rule framework and payout structure modeling you would on a first pass, so the recomputed figure is defensible on its own terms.
from decimal import ROUND_HALF_UP
CENTS = Decimal("0.01")
def full_accrual(txns: list[dict], version: AgreementVersion) -> Decimal:
eligible = [t for t in txns
if version.window_start <= t["txn_local_day"] <= version.window_end]
gross = sum((t["net_amount"] for t in eligible), Decimal("0"))
return (gross * version.rebate_rate).quantize(CENTS, rounding=ROUND_HALF_UP)
Validation check: assert the recomputed amount is Decimal and quantized to two places (amount.as_tuple().exponent == -2). Recompute twice and assert equality — a non-deterministic total means an unsorted aggregation or a stray float crept into net_amount.
Step 4 — Emit a delta accrual as a new write-once record
The delta is the recomputed full accrual minus the sum of all prior settled accruals for this promotion. Persist it as a brand-new ledger row that cites the new version_no and the accrual_ids it adjusts. Never mutate the settled original.
class DeltaAccrual(BaseModel):
accrual_id: str
promo_id: str
agreement_version_no: int
adjusts: list[str] # prior accrual_ids this delta corrects
amount: Decimal # signed: positive top-up, negative claw-back
status: str = "open"
@field_validator("amount")
@classmethod
def two_places(cls, v: Decimal) -> Decimal:
if v.as_tuple().exponent != -2:
raise ValueError("amount must be quantized to cents")
return v
def emit_delta(new_id: str, version: AgreementVersion,
recomputed: Decimal, prior_settled: list[dict]) -> DeltaAccrual:
already = sum((p["amount"] for p in prior_settled), Decimal("0"))
return DeltaAccrual(
accrual_id=new_id,
promo_id=version.promo_id,
agreement_version_no=version.version_no,
adjusts=[p["accrual_id"] for p in prior_settled],
amount=(recomputed - already).quantize(CENTS, rounding=ROUND_HALF_UP),
)
Validation check: assert recomputed == already + delta.amount exactly — the delta plus what was already booked must reconcile to the recomputed full accrual to the cent. If delta.amount == 0, skip the write: an amendment that changes no eligible membership should leave the ledger alone.
Step 5 — Route the delta into the close / true-up
A delta raised against an open period posts directly; a delta raised against a closed period cannot back-date and must flow to the next period’s true-up. Tag it accordingly so the settlement and financial close process picks the right lane, in step with month-end close sequencing.
def route_delta(delta: DeltaAccrual, period_status: str,
current_period: str, next_period: str) -> dict:
if period_status == "open":
target, disposition = current_period, "post_in_period"
else:
target, disposition = next_period, "true_up_next_period"
return {
"accrual_id": delta.accrual_id,
"amount": str(delta.amount), # Decimal serialized as string, never float
"post_to_period": target,
"disposition": disposition,
}
Validation check: assert a delta with disposition == "true_up_next_period" never carries a post_to_period equal to a closed period, and that the routed amount round-trips (Decimal(routed["amount"]) == delta.amount). Serializing money through str(Decimal) — not float() — is what keeps the true-up penny-exact.
Common failure modes and fixes
- Mutating a settled accrual. The instinct to
UPDATE accruals SET amount = 1200 WHERE accrual_id = ...erases the audited history the closed period rests on. Never mutate; the only legal correction is a newDeltaAccrualrow that carries the difference and cites what it adjusts. - Unversioned amendment. Overwriting
window_starton the live agreement leaves every prior accrual pointing at boundaries that no longer exist, so nothing can be re-derived. Alwaysbind_amendmenta newversion_no(Step 1) and keepsupersedespopulated. - Double-counting on re-open. Recomputing the full accrual and posting that — rather than the delta — books the eligible amount twice. Subtract the sum of prior settled accruals before writing (Step 4), and assert
recomputed == already + delta.amount. - Timezone or boundary error on the widened edge. A retroactive extension adds a new boundary day; if the added September rows are compared as raw timestamps rather than
txn_local_day, off-by-one drift silently drops the very transactions the amendment meant to include. Reuse the floor-to-local-day discipline from the sibling alignment guide and keep the interval inclusive on both ends. - Float delta. Computing
1200.0 - 800.0infloatlooks harmless until the recomputed and prior figures each carry sub-cent residue and the delta lands a penny off. Keepnet_amount,rebate_rate, and every intermediate inDecimal, andquantizeonly at the final step.
Operational checklist
Frequently asked questions
Why emit a delta instead of reversing and re-booking the original? A full reverse-and-rebook writes three rows (original, reversal, new) where one signed delta suffices, and it briefly leaves the ledger showing zero for a promotion that is genuinely accrued. The delta keeps the original standing as-settled and adds only the incremental correction, which is both smaller to audit and safe to apply against a period that is already closing.
What if the amendment shrinks the window and some transactions leave it? The mechanism is identical, only the sign flips. Transactions in the left bucket reduce the recomputed full accrual, so recomputed - already is negative and the DeltaAccrual.amount is a claw-back. Route it into the true-up exactly as you would a positive top-up.
Can two retroactive amendments land on the same promotion? Yes. Each binds its own version_no and each delta is computed against the sum of all prior settled accruals — the original plus any earlier delta. Because deltas are additive and write-once, a chain of amendments always reconciles to the latest recomputed full accrual without any single row being touched twice.
Does the delta wait for the period to close before posting? No. A delta raised while the period is still open posts in-period immediately (Step 5); only a delta whose target period has already closed defers to the next period’s true-up. The disposition tag, not a timer, decides the lane.
Related
- Parent topic: Date Window Alignment Checks — how temporal boundaries are enforced across the rule engine
- Aligning Promotion Start/End Dates with Sales Data — the first-pass attribution this guide re-runs after a window moves
- Month-End Close Sequencing — where delta accruals land in the close and true-up order
- Versioning Rebate Agreement Schemas — the immutable versioning an amendment depends on
Up one level: Date-Window Alignment Checks