Implementing Daily-Accruing Fine Schedules in Python

This guide builds on Calculating Permit Fees and Compliance Fines, part of the Violation Tracking & Compliance Enforcement track, and narrows to one hard task: computing a fine that grows every day a violation stays uncured, correctly and reproducibly.

A daily-accruing fine looks simple — rate times days — and is a compliance minefield. The number changes every night, so it must be recomputable from scratch on any date and land on the same value; it stops the day the violation is cured, so the cure date is load-bearing; it usually has a statutory cap; and many ordinances accrue only on business days, which pulls weekends and holidays into the math. Get the day-count off by one and you have overstated a lien on someone’s property. Get idempotency wrong and a nightly job that runs twice doubles the fine. The inputs are a violation record with a start date, an optional cure date, and a fine rule (rate, cap, calendar basis); the output is an exact Decimal amount owed as of a given date, plus a day-by-day breakdown a hearing officer can inspect. Everything uses decimal.Decimal for money and whole-day arithmetic for time.

Daily fine accrual timeline with grace period, business-day skips, cap, and cure stop A horizontal timeline of violation days. A grace period after the notice of violation accrues nothing. Accrual then begins, adding the daily rate on each accruable day while skipping weekend days under a business-day basis. The running total rises step by step until it reaches a statutory cap, after which additional days add nothing and the line is flat. Accrual stops on the cure date, freezing the final amount owed. Each stage is labeled on a caption chip so it stays readable over the timeline axis. calendar days → grace period accrues $0 + daily rate each day weekends skipped statutory cap reached cure date · accrual stops frozen total

Permalink to this section Step 1 — Model the Fine Rule as Immutable, Dated Data

A daily fine rule is set by ordinance, so model it as a frozen record selected by the violation’s effective date — never as loose constants. Carry the rate in Decimal, the cap in Decimal, the calendar basis (calendar days versus business days), and the grace period. Money is Decimal throughout, following the same rules established in the parent permit fee and fine engine.

from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_EVEN
from datetime import date, timedelta
from enum import Enum

CENTS = Decimal("0.01")


class Basis(str, Enum):
    CALENDAR = "calendar"    # accrue every day, including weekends/holidays
    BUSINESS = "business"    # accrue only Mon-Fri excluding holidays


@dataclass(frozen=True)  # frozen: an adopted ordinance rule is never mutated
class FineRule:
    daily_rate: Decimal          # e.g. Decimal("75.00") per accruable day
    cap: Decimal | None          # statutory maximum; None = uncapped
    grace_days: int              # calendar days after notice before accrual
    basis: Basis
    effective_from: date

Constructing daily_rate and cap from strings (never floats) keeps the arithmetic exact. If a council raises the daily rate mid-year, you publish a new FineRule with a later effective_from and select by the violation’s date, exactly as fee schedules are versioned — the historical fine remains reproducible.

Permalink to this section Step 2 — Count Accruable Days Correctly

The core of the calculation is a day count, and it is where off-by-one liens are born. Accrual begins the day after the grace period ends and continues through — but not including — the cure date (the day a violation is cured is not itself a penalty day under most ordinances). Under a business-day basis, weekends and holidays are excluded. Compute the accruable days as an explicit, inspectable list rather than a subtraction, so the breakdown can be audited day by day.

def accruable_days(start: date, through: date, rule: FineRule,
                   holidays: frozenset[date]) -> list[date]:
    """Days that actually accrue a fine, from accrual-start up to `through`
    exclusive. `through` is min(as_of, cure_date)."""
    accrual_start = start + timedelta(days=rule.grace_days)
    days: list[date] = []
    day = accrual_start
    while day < through:  # exclusive: the cure/as-of day is not charged
        is_weekend = day.weekday() >= 5           # 5=Sat, 6=Sun
        if rule.basis is Basis.CALENDAR or (not is_weekend and day not in holidays):
            days.append(day)
        day += timedelta(days=1)
    return days

Making the accruable set a concrete list[date] — not (through - accrual_start).days — is what lets a hearing officer see precisely which days were charged, and it makes the business-day and holiday logic testable in isolation.

Permalink to this section Step 3 — Accrue in Decimal and Clamp at the Cap

Multiply the count by the rate in Decimal, clamp at the statutory cap, and round once to cents. The cap is a hard ceiling: once reached, additional uncured days add nothing, so a fine that has run for a year against a low cap simply sits at the cap.

def accrued_amount(day_count: int, rule: FineRule) -> Decimal:
    """Exact fine for a given number of accruable days, capped."""
    raw = rule.daily_rate * Decimal(day_count)   # Decimal x int stays exact
    if rule.cap is not None:
        raw = min(raw, rule.cap)                  # never exceed the statutory cap
    # Single rounding step, banker's rounding, at the very end.
    return raw.quantize(CENTS, rounding=ROUND_HALF_EVEN)


def amount_owed(violation_start: date, cure_date: date | None, as_of: date,
                rule: FineRule, holidays: frozenset[date]) -> Decimal:
    """Total daily fine owed as of `as_of`, stopping on cure."""
    through = min(as_of, cure_date) if cure_date else as_of
    days = accruable_days(violation_start, through, rule, holidays)
    return accrued_amount(len(days), rule)

Because through is min(as_of, cure_date), the same function answers both “what is owed today?” (violation still open) and “what was the final fine?” (cured) — the cure date simply freezes the count. Rounding happens exactly once, in accrued_amount, so the running arithmetic never loses a fraction of a cent.

Permalink to this section Step 4 — Make Recomputation Idempotent

A nightly job recomputes open fines. It must be safe to run twice, retry after a crash, or replay for a backfill without ever double-charging. The rule: the fine is a pure function of (start, cure_date, as_of, rule) and is always recomputed from those inputs, never incremented from yesterday’s stored value. Persist the computed amount as a dated snapshot keyed on the accrual date, so re-running for the same date overwrites rather than adds.

from dataclasses import dataclass


@dataclass(frozen=True)
class AccrualSnapshot:
    violation_id: str
    as_of: date
    day_count: int
    amount: Decimal
    rule_version: date          # rule.effective_from, for reproducibility


def snapshot(violation_id: str, violation_start: date, cure_date: date | None,
             as_of: date, rule: FineRule,
             holidays: frozenset[date]) -> AccrualSnapshot:
    through = min(as_of, cure_date) if cure_date else as_of
    days = accruable_days(violation_start, through, rule, holidays)
    return AccrualSnapshot(
        violation_id=violation_id, as_of=as_of, day_count=len(days),
        amount=accrued_amount(len(days), rule), rule_version=rule.effective_from,
    )

Upsert the snapshot on the natural key (violation_id, as_of). Because the amount is derived, not accumulated, a job that runs twice for 2026-07-15 writes the identical row twice — an idempotent no-op — instead of doubling the fine. This mirrors the idempotency-key discipline used throughout the platform’s error handling and retry logic.

Permalink to this section Step 5 — Handle Backdated Corrections

Cure dates and violation dates get corrected after the fact: an inspector confirms the violation was actually cured three days earlier, or the original notice date was wrong. Because the amount is recomputed from inputs, a correction is just a re-run with the amended dates — but you must recompute every affected snapshot and record the adjustment for the audit trail, not silently overwrite history.

def recompute_range(violation_id: str, violation_start: date,
                    cure_date: date | None, rule: FineRule,
                    holidays: frozenset[date],
                    first: date, last: date) -> list[AccrualSnapshot]:
    """Rebuild every daily snapshot in [first, last] after a date correction."""
    out: list[AccrualSnapshot] = []
    day = first
    while day <= last:
        out.append(snapshot(violation_id, violation_start, cure_date,
                            day, rule, holidays))
        day += timedelta(days=1)
    return out  # caller upserts each; prior values are superseded, logged

Emit an audit event capturing the old and new dates, who made the change, and the resulting delta in amount owed. A backdated cure that reduces a lien is a material financial correction and must be as traceable as the original charge.

Permalink to this section Parameter and Flag Reference

Parameter Type Default Notes
daily_rate Decimal required Per accruable day; construct from a string, never a float.
cap Decimal | None None Statutory maximum; None means uncapped accrual.
grace_days int 0 Calendar days after notice before accrual starts.
basis Basis CALENDAR BUSINESS skips weekends and holidays.
holidays frozenset[date] frozenset() Jurisdiction holiday calendar; only consulted under BUSINESS.
through bound date min(as_of, cure_date) Exclusive upper bound; the cure day is not charged.
rounding str ROUND_HALF_EVEN Applied once in accrued_amount; match the parent fee engine.
snapshot key (violation_id, as_of) Upsert key that makes nightly recomputation idempotent.

Permalink to this section Common Failure Patterns and Fixes

Permalink to this section Off-by-one on the cure day

Charging the cure date itself overstates the fine by one day’s rate. Keep the loop bound exclusive (while day < through) so accrual stops the day the violation is cured, and pin it with a test where cure_date == accrual_start + 1 yields exactly one accruable day.

Permalink to this section Non-idempotent accumulation

Incrementing a stored balance each night (balance += daily_rate) double-charges on any retry or replay. Always recompute the total from (start, cure_date, as_of, rule) and upsert a dated snapshot on (violation_id, as_of), so a repeated run is a no-op rather than an addition.

Permalink to this section Float creeping into the rate or cap

Decimal(75.0) imports the binary error you are trying to avoid. Construct every money value from a string (Decimal("75.00")) and load fine rules from JSON with parse_float=Decimal, so no float ever touches the arithmetic.

Permalink to this section Weekend/holiday drift under a business-day basis

Using (through - start).days silently counts weekends the ordinance excludes. Enumerate accruable days explicitly (Step 2), skip weekday() >= 5 and the holiday set, and unit-test a span containing a holiday to confirm it is excluded.

Permalink to this section Cap applied per day instead of to the total

Clamping each day’s increment rather than the cumulative total lets an uncapped-looking fine blow past the statutory ceiling. Apply min(raw, cap) to the aggregate amount in accrued_amount, after multiplying rate by day count.

Permalink to this section Audit and Logging Guidance

A daily fine is a lien-grade financial figure, so log what makes it reproducible and defensible. For each accrual snapshot, record the violation id, the as_of date, the accruable day_count, the amount in cents, and the rule_version (the rule’s effective_from) that governed it — that version string is what lets you replay the exact calculation during an appeal. For every backdated correction, emit a separate immutable event with the prior and amended dates, the actor, and the resulting change in amount owed; never overwrite a snapshot without leaving that trail. Store these append-only alongside the violation’s escalation timeline so the fine history and the enforcement history reconcile, and surface the aggregates to the compliance reporting dashboards that feed state audits. Retain snapshots for the full statutory period a lien can be contested — recomputing from inputs is only defensible if the inputs and the governing rule version are still on file.

Permalink to this section Frequently Asked Questions

Permalink to this section Should the cure date itself accrue a fine?

Under most ordinances, no — the day a violation is cured is not a penalty day. Keep the accrual bound exclusive so the count runs up to, but not including, min(as_of, cure_date). Confirm the local rule, because a minority of jurisdictions charge through the cure date; whichever applies, encode it once and pin it with a test rather than leaving it to each call site.

Permalink to this section How do I add holidays to a business-day accrual?

Pass a frozenset[date] of jurisdiction holidays and consult it only under the BUSINESS basis, skipping any day that is a weekend or in the set. Keep the holiday calendar as versioned data next to the fine rules so a historical recomputation uses the holidays that were actually observed that year, not the current list.

Permalink to this section Is it safe to run the nightly accrual job twice?

Yes, if the amount is recomputed from inputs and upserted on (violation_id, as_of) rather than incremented. Because the fine is a pure function of the start date, cure date, as-of date, and rule, a second run for the same date writes the identical snapshot — an idempotent no-op. Never accumulate onto a stored balance, which would double-charge on any retry.