Skip to content

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.

A retroactive window extension produces a delta accrual without mutating the settled original Three horizontal stages. Stage one shows agreement version v1 with an Oct 1 to Oct 15 window and a settled accrual of 800 dollars, marked immutable. Stage two shows an amendment binding version v2 with a widened Sep 20 to Oct 15 window, which re-evaluates the affected transactions and computes a new full accrual of 1200 dollars. Stage three subtracts the settled original from the recomputed amount to emit a write-once delta accrual of 400 dollars that routes into the period true-up, leaving v1 unchanged. 1 · Agreement v1 (settled) window: Oct 1 – Oct 15 accrual = $800.00 immutable · write-once 2 · Amendment binds v2 window: Sep 20 – Oct 15 recompute = $1,200.00 re-open → re-evaluate txns 3 · Emit delta accrual $1,200.00 − $800.00 delta = +$400.00 new record → true-up v1 is never mutated — history is preserved; the delta is additive and independently auditable delta routes into the period close / true-up ledger

Prerequisites

Before processing any retroactive amendment, confirm the following are in place:

  • Versioned agreements. Each promotion is stored under an immutable agreement_version keyed by (promo_id, version_no) with an effective_from timestamp. 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 a status of settled or open. There is no UPDATE path 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_id and their txn_local_day, already normalized by the data ingestion normalization pipelines. Re-evaluation reads them; it does not re-ingest them.
  • Roles. The reconciliation_etl role 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 separate trade_finance_approver role — the engine binds the version, but a human authorizes the amendment upstream.
  • Python packages: pydantic>=2.6 and the standard-library decimal and datetime modules. Every monetary field is decimal.Decimal; a delta computed in float is 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.

python
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.

python
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.

python
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.

python
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.

python
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

  1. 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 new DeltaAccrual row that carries the difference and cites what it adjusts.
  2. Unversioned amendment. Overwriting window_start on the live agreement leaves every prior accrual pointing at boundaries that no longer exist, so nothing can be re-derived. Always bind_amendment a new version_no (Step 1) and keep supersedes populated.
  3. 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.
  4. 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.
  5. Float delta. Computing 1200.0 - 800.0 in float looks harmless until the recomputed and prior figures each carry sub-cent residue and the delta lands a penny off. Keep net_amount, rebate_rate, and every intermediate in Decimal, and quantize only 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.

Up one level: Date-Window Alignment Checks