2026-08-16 · 3 MIN

Count the notices, don't age one: the penalty model that printed $111,500 against a real $2,500

— WRITING

+

2026-08-16 · 3 MIN

BenchFile's lookup panel rendered $111,500 in accrued penalties against 1043 Faile Street. The building's real exposure is $2,500. Nothing threw, the endpoint was green, and the arithmetic was four readable lines that any reviewer would have waved through.

Here is what it used to be:

function accrued(issue?: string) {
  if (!issue) return null;
  const d = new Date(issue + 'T00:00:00');
  if (isNaN(d.getTime())) return null;
  const now = new Date();
  const months = (now.getFullYear() - d.getFullYear()) * 12 + (now.getMonth() - d.getMonth());
  const q = Math.max(1, Math.floor(months / 3));
  return { quarters: q, dollars: q * PENALTY };
}

PENALTY is 500. That function was called on every violation row the city returned, and the results were summed.

The domain rule the code was modelling wrong

NYC DOB publishes the LL84 benchmarking penalty like this: a $500 penalty, issued on a quarterly basis until the report is submitted, totaling up to $2,000 per year.

Read fast, that sounds like a meter. Quarters elapsed times $500. That is what the function computes.

The city does not let one notice grow. It issues a new notice every quarter, which is exactly why a long-delinquent building carries five separate violation numbers rather than one old one. Each row in the violations table is a flat $500 that has already happened. The rows are the accrual.

So the old code was wrong twice, and the two errors multiply. It aged a charge that never ages, and it did that per row, when the row set was already the complete building total. Five notices from a building that stopped filing in 2020 each got aged back to their own issue date and added together.

BenchFile penalty payment kit interface with multi-step guidance — Professional interface design demonstrates branded structure for compliance guidance.

BenchFile penalty payment kit interface with multi-step guidance — Professional interface design demonstrates branded structure for compliance guidance.

The fix moves the number up a level

function accruedForBuilding(openNotices: number) {
  if (!openNotices) return null;
  return { notices: openNotices, dollars: openNotices * PENALTY };
}

The important part is not the body. It is that accrued_dollars came out of the per-row array and moved to the top of the response payload, next to accrued_notices. A per-row money field invites a .reduce() somewhere downstream. A building-level field has nothing to sum.

Then the fix had its own bug, pointed the other way

The first version passed active.length into it. active is the display page, and stage 2 caps that query at limit: '25' because it is a list a human reads.

495 Classon Avenue (BBL 3019890001) holds 45 active FTF-EN-BENCH notices. The live endpoint answered accrued_notices: 25, accrued_dollars: 12500, against a real $22,500. Every building past 25 notices understated by exactly the amount over the cap, and those are the longest-delinquent buildings, the ones with the most reason to use the tool.

Same class of mistake as the meter: a total derived from a variable that was never the total. The row page is now fetched in parallel with a second Socrata query, select=count(violation_number) filtered to violation_status='Active', and the count comes from the city.

1043 Faile St / 495 Classon Avequarters-since-issue x $500, per rowpage length x $500city's count x $500
1043 Faile Street$111,500not measured$2,500
495 Classon Avenuenot measured$12,500$22,500
notices the city has issuedignoredundercounts past 25matches

What this still costs, after both fixes

Every lookup now makes two round trips to Socrata instead of one, inside a single Promise.all(...).catch(). Either query failing takes down the whole violations stage with one generic message, so a healthy row page plus a failed count reads to the visitor as the city being down.

The panel also lists up to 25 rows underneath a total computed from 45. Anyone who counts the rows gets a different number than the headline. The page stays at 25 deliberately, so that gap is open by choice and is not labelled on screen yet.

And DOB's $2,000-per-year ceiling appears nowhere in the code. openCount * 500 has no cap. It stays honest only because the city cannot issue more than four notices in a year, and nothing in the route verifies that assumption holds.

We built BenchFile: https://benchfile.kynth.studio/?utm_source=kynth-hashnode&utm_medium=social&utm_campaign=kynth


One shipped product, taken apart, once a month. What it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did — read off the repository and the live site, not written from memory. Join the list.

← All writing