Bundul
Internal
Browse docs
Waiting for review

decisions

Utility bills: telling customers what's coming, and keeping their monthly price honest

A frozen record of a decision at the time. Superseded by a new record rather than edited.

Written by the build · 2 September 2026

Frozen. A record of a decision at a point in time, not living documentation. Do not update it — supersede it with a new record instead. For how this works today, see docs/explanation/ and docs/generated/.

Utility bills: telling customers what's coming, and keeping their monthly price honest

Two connected pieces of work:

  • Part A — when a bill arrives, tell the customer where they stand.
  • Part B — when someone's bills settle at a genuinely new level, change their monthly price instead of collecting a true-up every quarter forever.

Revised 2026-08-27. Part B now evaluates on a 2-quarter tenure clock rather than waiting for a year of bill history, and leans on the cash ledger so a paying customer always has enough history to price against. The 12-month averaging window is unchanged — see section 7 for why the two are different things. Sections 7, 9.1, 11, 13 and 15 were rewritten.


1. How the money works today

Follow one customer. Call them Alex. Alex has San Diego Gas & Electric in their One Sub, along with Netflix and Spotify.

What Alex pays us: a fixed amount every month. For the electricity that's $142, set once on the day they signed up from the average of their last 12 electricity payments. It has never changed since.

What Alex's bills actually cost: Netflix and Spotify cost the same every month. The electricity doesn't — July $189, August $214, April $132.

Every three months we add up the difference across everything and settle it — money back to Alex, or money collected from Alex. That's the true-up. It already happens. Alex just doesn't see it coming.

Important detail that shapes the whole design: the true-up is worked out per customer, across their whole One Sub — not per service. So the "you'll owe money" number is never about the electricity alone, even though it's almost always the electricity causing it. More on this in section 4.2.


1a. What the real data says (audit run 2026-08-19)

Run with node scripts/inspect-utility-bill-history.js. Read-only. This changes several assumptions in the rest of this document, so read it first.

There are 4 bundled utilities on the whole book

Customer Utility We charge Bills we've parsed
nkem So Cal Edison $295.40/mo $508.97 (Aug), $549.53 (Jun)
nkem So Cal Gas $69.06/mo $55.62
Olamide T-Mobile $211.00/mo $216.00 (Aug), $285.72 (Jul)
Olamide Aqua Finance $96.53/mo $96.53 — flat, every month

Four. This settles the machine-learning question for good — there is not a book to train anything on, and there will not be one for a long time.

The drift is real, large, and already costing money

Both customers' Q2 true-ups:

Customer Paid in Services cost Difference
nkem $1,093.38 $1,316.07 −$222.69 — owed, already collected
Olamide $991.53 $385.43 +$606.10 — credit, awaiting approval

One under by $223, one over by $606, out of two customers. Part B is not a theoretical improvement; it is the larger half of the value.

Olamide's $385.43 of spend across 7 services over 3 months looks low enough that it may be a gap in the ledger rather than genuine over-charging. Worth confirming before treating $606 as a re-pricing signal — which is exactly why section 11 refuses to re-price on incomplete data.

The seasonality rule just proved itself on real numbers

nkem's Edison bill in August is $508.97 against a $295.40 monthly price — about $210/month short. But their Q2 shortfall was only $222.69 across three months, roughly $74/month.

Same customer, same utility, wildly different gaps — because Q2 is spring and August is peak air-conditioning. Re-price nkem on the August bill and we would overcharge them all winter. This is section 7's trap, sitting in production data. The 12-month rule is the right one.

The bank feed: two bugs found, and what it actually gives us

The bill does NOT print last year's usage — we checked. So the bank feed is the only remaining route to history. Digging into it turned up two bugs, one of them in existing code.

Bug 1 (ours): the utility streams are all marked INACTIVE.

The first pass found no bank history for three of four utilities. The reason is not that the data is missing — it is that we were filtering it out. Once Bundul takes over paying a utility, the customer stops paying it directly, Plaid stops seeing new payments, and the stream is marked inactive. Filtering on is_active excludes exactly the utilities we want history for. All three usable streams were inactive for that reason. Fixed — past payments are still past payments.

Bug 2 (pre-existing, and it affects real money): the Plaid fetch does not paginate.

fetchTransactionsByIds made a single transactionsGet call with no page size or offset, so Plaid returned its default page of 100 out of whatever the window held. Observed live: a stream with 7 known payments returned 3 — the window held 1,228 transactions.

This matters well beyond this feature. getAvergeOflast12TransactionsForRecurrTransac builds each utility's fixed monthly price out of these results, so every utility price set at signup came from an arbitrary partial sample of the customer's payments. Fixed by paging through the window.

What we can actually pull, proven live:

Stream Payments Recovered from Plaid
Aqua Finance (bundled) 14 14/14 — flat $96.53 for 13 months
T-Mobile (bundled) 3 3/3 — flat $211.00
So Cal Edison (not bundled) 7 7/7 — $47.15 to $66.83, clearly seasonal
Golden State Water (not bundled) 4 4/4 — $191.82 to $233.90

So the mechanism works end to end. But be clear about what it does not solve:

  • nkem has no utility stream at all. Their Edison and So Cal Gas payments are simply not in the bank data we can see — they pay from an account that is not linked. No amount of fixing gets history for that customer.
  • Depth is thin where it exists. T-Mobile is 3 payments. Only Aqua Finance clears 12 months, and it is a flat loan payment we would never comment on.

So a seasonal comparison is not available for any bundled utility today, and will not be until roughly a year of bills accrues through the inbox. That is not a reason to delay: it is the reason the quarter position leads (section 3.1) and the fallbacks are honest (section 3.3). Ship those, let history build, and the comparison switches itself on per customer as each one crosses a year.

The "duplicate" rows were something else — cleaned up 2026-08-19

They were not duplicates. Every row had its own utilitySubId, so the upsert was working correctly all along. They were leftovers: each time a utility subscription was created and later deleted (a customer retrying a bundling that failed), its CustomerUtility row stayed behind pointing at a subscription that no longer existed. One customer had accumulated 37 of them for a single service, and because the admin views read this table by customer, those phantom rows were showing up in the dashboard.

Cleaned with scripts/cleanup-orphan-customer-utilities.js — dry-run by default, refuses to delete any row carrying a real payment, backs up what it removes. 39 rows removed, 21 live rows remain, none of them carrying a payment trace. Airtable untouched.

Note paymentSettledAt was set on 32 of the deleted rows but is not a payment: it is written by the "looks already paid" check against Plaid at import time. paymentInitiated was false and passportTransactionId was null on every one.

Still open: nothing deletes the CustomerUtility row when a UserSubscription is deleted, so these will accumulate again. Worth fixing at the source.


PART A — Telling the customer where they stand

2. The honest problem with "compare to last August"

I previously said we'd compare a bill to the same month a year ago. You pushed back, correctly. Two corrections:

A clarification first. The 12 bank payments we pull aren't only an average — they're 12 individual payments, each with a date and an amount. So for some customers we genuinely can see last August. But that doesn't rescue the idea, because:

The real problem stands. Very often we won't have last August at all:

  • Alex joined in October. It's now August. We have 10 months, not 12.
  • Alex moved house in March. Last August was a different home — useless as a comparison.
  • Alex used to pay the utility from a bank account we can't see.
  • Alex switched utility providers.

A plan leaning on "same month last year" works for a minority of customers and quietly does nothing for everyone else. Not good enough.

3. What we do instead

3.1 The valuable half needs no history at all

What Alex actually wants to know is: am I going to owe money at the end of the quarter?

For August, that's mostly already-settled facts:

Month Alex paid us Everything cost Difference
July $175 $222 −$47
August $175 $247 −$72
September $175 ? ?

Two of the three months are settled history. Only September is unknown — and even September is mostly known, because Netflix and Spotify cost the same every month. The only genuinely uncertain part of the whole calculation is next month's electricity bill.

So we can tell Alex "you're $119 behind with one month to go" on day one, for every customer, with zero history. That's the number that affects their life.

So we lead with the quarter position. The comparison is the nice-to-have that improves as we learn them.

3.2 The best fix for the comparison: the bill already contains last year

Utility bills usually print last year on them. Most electric and gas bills carry a 13-month usage chart, or a line like "Same period last year: 782 kWh." It's on the paper because regulators and utilities want customers to see it.

We already use AI to read these bills — it pulls out the amount and the due date. If we also teach it to read the usage history the bill already prints, then Alex who joined in October still gets a valid last-August comparison, from their own August bill.

Check this first, on real customer bills, before building anything. If it's there for the utilities our customers actually use, the history problem largely disappears. If it isn't, we fall back to 3.3.

3.3 When we genuinely don't know their normal

Then we don't pretend to. In order of preference:

What we know What we say
A full year — theirs, or read off their bill "About $30 more than a normal August for you"
Other customers on the same utility have a full year Use their summer/winter pattern, scaled to Alex's level
Under a year, nobody to borrow from Only what's plainly true — "your highest bill since you joined", "$25 more than last month" — plus the quarter number, which we know regardless
Nothing notable to say Say nothing. No push.

The rule: never claim to know what's normal for someone when we don't.

3.4 Why "compare to the last few months" is not an option

Alex's bills:

Aug '25 $196    Sep $161    Oct $138    Nov $129
Dec $141        Jan '26 $166   Feb $181    Mar $150
Apr $132        May $128    Jun $147    Jul $189

August 2026 arrives at $214.

Compare to the last three months (middle value of $128, $147, $189 = $147) and we'd announce:

"Your bill is 46% higher than usual!"

Wrong. Alex runs the air conditioning every summer; their bills climb May through August every year. We'd be shouting about the most predictable event in their calendar, and Alex would learn to ignore us within two messages.

Compare to last August ($196, nudged up ~6% because Alex's bills run higher this year) and a normal August is around $208. Actual $214 — a $6 gap. We say nothing. Correct.

Had the bill been $284: normal $208, so $76 more. Worth telling them, and we'd mention the 34-day billing period as a likely reason.


4. What we actually say

4.1 Written in advance, not generated on the spot

Every sentence is written by us ahead of time. The system picks which one fits and fills in the numbers. Nothing is written fresh by AI at send time.

Why, plainly:

  • These are statements about someone's money. Every sentence a customer can receive should be read and approved by a person before anyone receives it. You can't approve a sentence that doesn't exist yet.
  • The same situation must always produce the same message. If two customers in identical positions get differently-worded messages, support can't reason about it and neither can we.
  • We can't test it otherwise. Step 6 of the build order replays every past bill and prints the alerts we would have sent, so we can read them before launch. That's meaningless if the wording is invented fresh each time.
  • Speed and cost. An AI call per bill per customer, to write one line of a phone notification, is waste.
  • It's already how push copy works here. The promotional push system uses fixed templates with protected {service} tokens, editable by the team in admin without a deploy. Same pattern, and we should reuse it rather than invent a second one.

The intelligence isn't in the wording — it's in choosing which true thing to say. That decision is code with clear rules. The wording is then filling in blanks.

4.2 The full list of sentences

This is every sentence a customer can receive. There are no others, and nothing is written at send time.

Where they live in the code: src/utility-insights/constants/utility-insight-messages.ts. That file is the source of truth; this table mirrors it. Anything in braces is a number filled in per customer, and a sentence cannot be edited in a way that drops one — the send is refused if a required number goes missing.

The title, shown whenever we send anything at all:

{service} bill: {amount} — e.g. "SDG&E bill: $214.30"

Line 1 — about this bill (one utility):

Id When it is used The sentence
bill_higher_than_normal We have a full year for them — their own history, or read off the bill — and this bill is over both cut-offs above it "That's about {amount} more than a normal {month} for you."
bill_lower_than_normal Same, but below their normal "That's about {amount} less than a normal {month} for you."
bill_highest_since_joining We do NOT know their normal, but it is the largest bill we hold for them, and we have seen at least 3 "That's your highest bill since you joined."
bill_more_than_last_month We do NOT know their normal, it is not a record, but it is a clear jump on the month before "That's {amount} more than last month."
bill_none Unremarkable, or we know too little to say anything true (nothing)

Line 2 — about the quarter (their WHOLE One Sub, not just this utility):

Id When it is used The sentence
quarter_owe_projected Behind, and every remaining month can be estimated "Looks like you'll owe about {amount} at the end of the quarter."
quarter_behind_so_far Behind, but a remaining month cannot be estimated — states only what already happened "You're about {amount} behind this quarter, with {months} month(s) to go."
quarter_building_credit Ahead by more than the cut-off "You're building up a credit of around {amount}."
quarter_on_track Close enough to even that neither direction deserves a number "You're on track for this quarter."
quarter_none Too early in the quarter, bills missing, or the ledger is incomplete (nothing)

The two silence rules:

  1. Both lines empty → send nothing. The most common outcome, and the point.
  2. Nothing about the bill, and the quarter is only "on track" → send nothing. Not worth a buzz on its own.

Worked examples — the same three that appear in section 4.4, assembled from the table:

Situation Lines used What lands on their phone
Full year of history, bill well above their normal August, behind for the quarter bill_higher_than_normal + quarter_owe_projected "SDG&E bill: $284.00 — That's about $75 more than a normal August for you. Looks like you'll owe about $190 at the end of the quarter."
Joined recently, no idea what's normal, but we know the quarter bill_none + quarter_behind_so_far "SDG&E bill: $214.30 — You're about $119 behind this quarter, with 1 month(s) to go."
Cheap month, ahead for the quarter bill_lower_than_normal + quarter_building_credit "SDG&E bill: $118.00 — That's about $25 less than a normal April for you. You're building up a credit of around $60."

Every one of these is covered by a test that asserts the exact string, so the wording cannot drift without someone noticing.

4.2b Which channel, and where a tap goes

Push only. No email. That is the current build, and it is a deliberate choice rather than an oversight — but it is worth stating plainly because the earlier drafts said "optional email" and nothing was built.

Why push alone, for now:

  • The message is short and time-sensitive: a bill landed, here is where you stand. That is what a push is for.
  • The full breakdown — the comparison, the reasons, the quarter table — belongs on a screen, not in an inbox. The push exists to get them to that screen.
  • Every extra channel is another thing to get wrong while the switch is still off. One channel, reviewed properly, beats two half-reviewed.

When email would earn its place: the quarter-end position, once it firms up. "You'll owe about $190 when the quarter closes" is something people want a record of, want to forward, and want to read on a laptop. That is an email. A per-bill nudge is not.

The message catalog is channel-agnostic — the same lines render into an email body without change — so adding it later is small. It is simply not built.

Where a tap goes — and the gap.

The push carries bundulio://sub/{userSubscriptionId}/bill, intended to open the bill detail screen in section 4.4.

That screen does not exist, and the app does not know that route. The app currently routes seven deeplinks — sub OTP, sub reconnect, Plaid relink, email relink, One Sub, connect account, home, retry subscriptions — and /bill is not among them. Tapping the notification today would do nothing useful.

This is not currently harmful, because customer messaging is switched off by default and the switch is the thing that turns any of this on. But it is a hard blocker on turning it on:

  • The mobile app needs the bill detail screen and a route for sub/{id}/bill, OR
  • We point the notification at bundulio://one-sub, which the app already handles, and accept that the customer lands on a general screen rather than the bill in question.

The second is a one-line change and a reasonable interim. It should be a decision, not something that happens by default.

4.3 A correction to my earlier draft

My last version showed the quarter box as if it were the electricity alone. That's wrong. The true-up is calculated per customer across the whole One Sub — every service, plus fees, plus any one-off charges. If we tell Alex "you'll owe $125" based only on their electricity while Netflix is also drifting, the number won't match the true-up they later receive. That's exactly the failure we most need to avoid.

So: the bill comparison is about that one utility. The quarter number is always about everything.

In practice the electricity is almost all of the drift, because the fixed-price services cost the same every month — but the number we show must be the real whole-account figure, not a stand-in.

Fixed services do drift too, just differently. Netflix puts its price up from $15.49 to $17.99 and that's a permanent step, not a seasonal wobble — and if we don't move with it, that's a small shortfall every single month forever. It's actually easier to spot than utility drift: one clean jump, no seasons to see through, no 12-month average needed. Same saved-bill machinery catches both, so we capture every gap regardless of which service caused it.

4.4 What Alex sees

On their phone, only when there's something worth saying:

SDG&E bill: $214 You're about $119 behind this quarter, with one month to go.

Or, when we have a comparison we trust:

SDG&E bill: $284 That's about $75 more than a normal August for you. Looks like you'll owe around $190 at the end of the quarter.

Or the happy one:

SDG&E bill: $118 That's about $25 less than a normal April for you. You're building up a credit of around $60.

When they tap it:

  SDG&E — August bill                              $214.30
  ────────────────────────────────────────────────────────
  A normal August for you                          ~$208
  This bill                                     ▲ $6 more

  Your quarter so far (July–September) — all services
  You pay us          $175 a month  →  $525
  Your services cost  $222 + $247   →  $469 so far
  September estimate                   ~$183
  ────────────────────────────────────────────────────────
  Looks like you'll owe about $125 when the quarter ends
  (an estimate — we confirm the real number after September)

The "a normal August for you" line disappears entirely when we don't have the history. The quarter box always shows.

If two bills land at once (a customer with electricity and gas), send one message, not two.

4.4b What the app calls to fill that screen

The mobile team builds the screen and registers the sub/{id}/bill route. Everything behind it is built and tested:

query BillDetail($utilitySubId: String!, $billId: String) {
  utilityBillDetail(utilitySubId: $utilitySubId, billId: $billId) {
    billId  provider  amount  billMonth  dueDate
    periodDays  currentCharges  priorBalance

    comparison {                # null = hide this block
      basis                     # seasonal | recent | last_bill | none
      confidence                # high | medium | low
      expected  bandLow  bandHigh
      deltaAmount  deltaPercent
      direction                 # higher | lower | normal
      explanations              # ["This bill covers 34 days. Yours usually cover 30."]
    }

    quarter {                   # null = hide this block. WHOLE One Sub, not this utility
      quarter  paidToDate  spentToDate  deltaToDate
      monthsRemaining
      projectedDelta            # null = we cannot estimate the rest
      canProject
    }

    history { billMonth dueDate amount source }

    headline  body  wasNotified
  }
}

There is also utilitiesWithBills — the ids of the customer's utilities that have at least one bill, so the app knows which services can show this screen at all.

Three rules for whoever builds the screen:

  1. A null block means hide it, never render a zero. comparison is null when we had no trustworthy basis; quarter is null when the quarter cannot be stated honestly. Both are deliberate silences (sections 3.3 and 6), and showing "$0" would turn a silence into a false statement.
  2. canProject: false means the projection is unavailable, not zero. Show deltaToDate with monthsRemaining — what has already happened is a fact — and no quarter-end figure.
  3. headline and body are the exact sentences the push used. They are returned so the screen and the notification cannot disagree about what we told the customer. Render them as-is rather than re-deriving copy from the numbers.

Everything is scoped to the caller's own JWT — passing another customer's utilitySubId returns null, not their bill.

4.5 The switch, and who holds it

Customer messaging is OFF unless someone deliberately turns it on. Two controls:

  • The master switch. Off means nobody is messaged at all.
  • An allowlist of emails. When non-empty, only those accounts can be messaged, even with the master switch on. Start with our own accounts.

Both are settable by an admin in the dashboard — GET and POST /admin/utility-insights/settings, behind the notifications-send permission — so the switch can be flipped without a deploy. That matters most in the OFF direction: if something looks wrong, waiting on a release is not an acceptable answer.

UTILITY_INSIGHTS_SEND_ENABLED and UTILITY_INSIGHTS_ALLOWLIST are the defaults, used until an admin has ever touched the switch. After that the admin setting always wins, so a deploy can never quietly re-enable messaging that somebody turned off. The setting is cached for a minute — switching off takes effect within a minute, not at the next restart — and if the setting cannot be read it fails closed.

Insights are computed and stored either way. That is the point of having the switch rather than simply not deploying: with sending off we accumulate a complete record of what we WOULD have said to real customers, and can read it before anybody receives anything.

4.6 Where a customer's history comes from

Three sources, and they cost wildly different amounts. That difference decides the order.

Source Cost Covers Detail
Bills we paid (our ledger) free months since they bundled exact amount, exact month
Their bank (Plaid) one API call as far back as the bank reports amount + a payment date
Their inbox one AI call per bill up to 24 months the full invoice — period, usage, carried-over balance

They are not alternatives, they are different periods. Our ledger only starts when a customer bundled with us; the bank covers the years before that. Stopping at the first source that returns anything would throw away the older half — which is exactly the half the level-drift adjustment needs.

So all three run, cheapest first, and email pays only where it adds something. The rule:

  • Always read the newest few bills. They drive the notification and the payment path, and only the invoice carries the fields a fair comparison needs.
  • For older months, read an email only if nothing cheaper covered that month. A historical bill just has to be an amount to work as a comparison point.
  • Never re-read an email already turned into a stored bill.

The effect: a customer's first pull is expensive once, and every refresh afterwards reads only genuinely new bills — cheaper than before this rule existed, not dearer.

Two years, not one. A one-year window gives exactly one shot at "the same month last year" and no margin. It also made the level-drift adjustment — telling "your bills went up" apart from "it's summer" — permanently unreachable, because that compares the last twelve months against the twelve before them and the older half was always empty.

No single run can overrun. A first pull might find thirty bills, and the refresh gives each utility five minutes. So a run reads at most a dozen and leaves the rest for the next one: those months become covered, the next run continues, and two runs usually finish two years. Newest-first ordering means the bills that matter are always read first.

What this does not fix. A customer whose bank shows us nothing has no history before they joined us — the ledger starts at bundling. In production that is one of our two utility customers. The wider window pays off for new signups and for anyone who crosses two years with us.

5. When does it fire?

Only when we see a bill we've never seen before.

Every 5 days a background job reads the customer's inbox. It reads the same emails every time — the August bill is still there in September. Alert on "the job ran" and Alex gets buzzed about one bill six times.

So: the job pulls out each bill and tries to save it. Already have it → saving does nothing → no alert. Genuinely new → saving works → that's when we tell Alex.

The "have we seen this?" check is the trigger. It can't double-fire.

6. The one thing blocking all of this

The job finds up to 10 past bills in the inbox, keeps two, throws eight away, and overwrites those two next run. So we can only ever answer "what was last month?" — never "what's normal?"

We throw away history in a second place too: we already fetch Alex's 12 past electricity payments from their bank, average them into the $142, and discard the individual payments.

So: start saving every bill. One entry per bill, written once, never overwritten, from both sources. This is the foundation for Part A and Part B, and every day it isn't live is another day of history read and binned in two places at once.

From each bill we also want: the period it covers (a 34-day bill is bigger than a 30-day one for no real reason), this month's charges separate from any unpaid balance carried over, the usage, whether they're on a flat plan with the utility, and — the big one — whatever usage history the bill prints.


PART B — Updating the monthly price when bills genuinely change

7. Two clocks, not one (revised 2026-08-27)

Chasing the same shortfall with a true-up every quarter forever is the wrong tool. The price should move. But there are two separate questions here, and the first draft of this section ran them together:

Question Answer
How often do we look at a customer? Every 2 quarters, counted from when they started paying us.
What do we average when we look? Everything we hold, up to 12 months. Never a deliberately shortened window.

The second answer is the one that must not move, and here is why.

Two quarters is half a year. Re-price Alex on April–September and you lock in their summer price and charge it all winter. They overpay every month to March and get a large credit — the same problem in reverse.

Seasonal swing and level shift look identical over six months and need opposite responses:

What it looks like Right response
Seasonal swing Up every summer, down every winter, year after year Leave the price alone. The fixed price exists to smooth exactly this.
Level shift Bills moved and stayed moved — rate rise, moved house, bought an EV, installed solar, housemate left Change the price. This is genuinely their new normal.

Worked on real numbers. nkem's So Cal Edison across a year — June and August are bills we actually parsed, the rest is the shape of an air-conditioned Southern California year:

Sep 480   Oct 330   Nov 240   Dec 250   Jan 265   Feb 250
Mar 230   Apr 245   May 300   Jun 549   Jul 520   Aug 509
  • Twelve months ÷ 12 → $347.38. Against the $295.40 we charge, a real 17.6% gap. Worth acting on.
  • Last six months ÷ 6 → $392.25. That is $44.87/month too high — roughly $539 a year overcharged and handed straight back as a credit.

And the +25% cap makes a short window worse, not better: it trims $392.25 to $369.25, which still overcharges by $22/month while looking like the system protected them.

So: look every 2 quarters, average everything we have. The question is still "has their whole year moved?" — we just ask it twice a year instead of waiting a year before asking at all.

8. What the price should be — at any length of history

The newest 12 months of bills, divided by 12. Their true yearly average.

Not invented — it's exactly what utilities do themselves. "Budget billing" or "levelized billing" is this: take the last year, divide by twelve, charge it flat, recalculate periodically. We're doing the same job, so we use the same method.

Alex's last 12 months ($196, 161, 138, 129, 141, 166, 181, 150, 132, 128, 147, 189) total $1,858. Divided by 12 = $155 a month.

We charge $142. Alex is under-priced by about $13 a month — roughly $39 a quarter, four times a year, forever, until the price changes. That's the whole problem in one number.

But almost nobody has exactly twelve. So, the full rule:

What we hold What we do Confidence
More than 12 months Average the newest 12. Discard the older ones from the calculation. Highest — and at 24 months we can do better still, see below
Exactly 12 Average all 12. The clean case above. High
6 to 11 Average what we have, then lean 5% high. Treat as provisional and check the spread. Medium — it is a sample, not a year
Under 6 Don't price. The true-up carries them.
Gappy at any length Count real bills, not span. See the warning below

More than 12 months: newest twelve, not a lifetime average

A customer who has been with us three years has 36 bills. Averaging all 36 would be wrong — it would lag every real change. Say their utility raised rates 18 months ago:

  • Newest 12 ÷ 12 → $180. Their electricity at today's rates. Correct.
  • All 36 ÷ 36 → $158. Two-thirds of that average is priced at the old rates. We would under-charge by $22/month and collect it as a true-up shortfall forever — the exact thing Part B exists to stop.

The point of the window is that it is a moving year. Old bills fall out of the back as new ones arrive. In the code this is bills.slice(0, 12) over a newest-first, one-row-per-month series (resolveHistory dedupes each month and sorts by due date descending, so the newest twelve really are the newest twelve).

The bonus at 24 months. Once we hold two years, we can compare the last 12 against the 12 before them — and that answers section 7's question directly rather than by inference. A yearly average that has moved from $155 to $180 is a level shift, full stop; no reasoning about seasons required, because both halves contain the same seasons. This is precisely why the inbox lookback is 24 months and not 12 (lookback.constants.ts). Part A already uses it for the level-drift adjustment. Part B does not yet, and should — it is the strongest signal available to us, and it arrives free with tenure.

Fewer than 12 months: a sample, not a year

With 8 months we divide by 8, not by 12 — dividing by 12 would invent four months of zero bills and under-price badly. Then we lean 5% high (PARTIAL_HISTORY_MARGIN), because if we are going to be wrong, a small credit is a pleasant surprise and an unexpected debit is why people leave.

But be honest about what that 5% is: a cushion, not a correction. A partial year is a sample of whichever seasons it happens to cover, and how wrong that sample is has nothing to do with how many months are in it:

  • Alex's newest six months average $154.50 against a true yearly $154.83 — a difference of 33 cents. Their bills peak in both summer (Aug $196) and winter (Feb $181), so half a year already contains the whole shape. Here the 5% nudge is the only error in the room: it would price them at $162.23, about $7 above a number that was already right.
  • nkem's newest six months average $392.25 against a true yearly $347.38 — a 13% overshoot. Their bills peak only in summer, so half a year is all peak. Here the nudge is nowhere near enough, and pushes in the wrong direction on top.

The difference is not the number of months. It is how much that customer's bills swing. So the partial-history rule is not "wait longer", it is look at the spread:

Their bills At 6–11 months
Flat — Aqua Finance at $96.53 every month, T-Mobile at $211 Price them. Six months tells us everything twelve would. Waiting a year is pointless.
Swinging — So Cal Edison $230 to $549 Hold, or demand a much bigger gap than the usual 10%/$10, and mark the row provisional.

That is the same judgement section 11 applies to the tenure clock, stated here in pricing terms.

Gaps beat length

Twelve bills spanning twelve months and eight bills spanning eighteen are not the same thing, and today the code cannot tell them apart: monthsCovered measures newest-due-date minus oldest, so a customer with eight scattered bills across eighteen months reports as "18 months of history", clears every gate, skips the partial-history nudge entirely, and gets priced off a gappy average as though it were a clean year.

Count real, consecutive bills. This is Left #7, and it matters more the shorter the window gets.

9. When to actually change it

  1. Work out the target: last 12 months ÷ 12 — or everything we hold, when it is less.
  2. Only act on a real gap: more than both ~10% and ~$10 off the current price. Alex at $142 vs $155 is 9% — borderline. A $180 target would be clear-cut.
  3. Check every 2 quarters. Change rarely. The evaluation runs on the customer's tenure clock, not on ours — see section 9.1.
  4. Cap how far it moves in one step. Nobody should open the app to find their payment jumped 40%. Cap it and move again next quarter if it's still short.
  5. Don't flip-flop. Just changed it? Require a clearly bigger gap before changing again.
  6. Tell the customer before it takes effect, not after. We're changing a recurring payment they authorized. Silently increasing it is wrong and generates exactly the kind of complaint we don't want.

Increases and decreases aren't the same. Alex installs solar and their bills halve — drop the price quickly and gladly, nobody complains about paying less. Increases need the cap, the notice and the human check.

9.1 The 2-quarter clock, and why looking often is safe

The clock is the customer's tenure with us, not the calendar and not how deep their bill history happens to be. Two quarters after they start paying, we evaluate. Two quarters after that, we evaluate again. It rides along with the true-up, which already runs at quarter close.

Looking every 2 quarters cannot make a price wobble, because the thing we compare against is a 12-month average. Six months of new bills move a yearly average slowly, by construction.

Alex, evaluated on that clock:

Evaluation Their last 12 months ÷ 12 We charge Gap What happens
After 2 quarters $151 $142 6% Nothing. Under the cut-offs.
After 4 quarters $155 $142 9% Borderline — a person looks and probably still says no.
After 6 quarters $163 $142 15% Change it. $142 → $163, Alex is told first.
After 8 quarters $165 $163 1% Nothing. It's right now.

For a customer whose life doesn't change, the price gets set once and then sits there for years. The gap only opens when something real happens — the utility raises its rates, they move, they buy an electric car, they put solar on the roof. That is maybe once every year or two, and often never.

A useful side effect: evaluating only every ~180 days makes the anti-wobble rule (MIN_DAYS_BETWEEN_CHANGES = 80) redundant by construction. It stays in as a backstop for a manual run, not as the thing doing the work.

10. The trap that would cost us real money

Re-pricing fixes the future. The true-up settles the past. Never mix them.

Alex is $127 behind for July–September. At quarter close, two separate things:

  • True-up: collect the $127 Alex is behind. Past. Settled.
  • Re-price: raise Alex from $142 to $155 going forward, because their yearly average says that's what their electricity costs.

Set the new price to "$142 plus a bit to catch up on the $127" and run the true-up, and we collect that $127 twice. Given how the settlement already works — admin approval, ACH pull, two-hop — a double collection would be painful to find and worse to refund.

The new price comes only from what their bills are expected to cost. Never from what they owe.

11. When we don't have 12 months — and why that is now rare

The original worry here was that most customers would never accumulate enough history to price against. That turned out to be solvable, and the solution was already half-built.

We pay these bills ourselves. Every month a customer is bundled with us, we push money to their utility, and the cash ledger records the amount, the month it was for, and the subscription it belongs to. LedgerHistoryBackfillService turns those payments into bill history. Proven in production on a customer with no Plaid transactions at all, where we still held six months of electricity and four of gas purely because we had paid every one of them.

To be precise about what this does and does not replace. A connected inbox is a prerequisite for bundling a utility at all — utilities give us no API for what a customer owes, so we read the bill out of their mailbox, and that number is what drives the payment (email-invoice-parsing.md). So there is no such thing as a bundled utility with no inbox. What the ledger replaces is the reliability of that route as a source of HISTORY:

  • Email access lapses. Tokens expire and customers do not always relink. Payments already made stay in the ledger regardless.
  • Parsing fails. An unreadable bill, or a utility with no detection fingerprint configured, never becomes a bill row — but we still paid it, and the ledger knows the exact amount and month.
  • The pull is expensive and capped. L3 is one Claude call per email and a run reads at most a dozen. The ledger is free and complete.
  • Month attribution is exact. billingPeriod says which month the money was for; email parsing has to infer it.

That is what makes the tenure clock honest: a paying customer generates one authoritative bill per utility per month just by being bundled with us. Two quarters of tenure is six bills, guaranteed.

It is also a legitimate basis for a price. The ledger records what the utility cost. It does not record what the customer owes. Section 10's rule is about never pricing off a shortfall, and this does not.

Two fixes are needed before that holds:

  1. The backfill runs once per utility, ever. hasHistoryFromSource(utilitySubId, LEDGER) returns early if a single ledger row exists, so it fires at bundling time on one month of data and never runs again. It must become a top-up from the last recorded month forward. Safe to re-run — recordBill is deduped by a unique index and backfilled rows never notify.
  2. It only runs inside the email invoice pull, after that pull's early returns. So a utility whose email access has broken, or which has no detection fingerprint configured, never gets a ledger backfill either — even though the payments are sitting in the ledger untouched. And a utility with zero bills is invisible to the review entirely, because the scan is a distinct over the bill collection. It must be callable from the quarterly evaluation directly.

What is genuinely left after that:

Who What we do Why
Bundled less than 2 quarters ago Nothing. Not due. There is no question to answer yet.
We pay them, but months are missing — failed pulls, gaps Don't re-price. Show as "4 of 6 months — not enough to price". A price built on a gappy window is worse than no change.
The utility isn't paid through us at all Don't re-price. Show the reason. We have no cost data and no route to any.
Six months, but all of them one season Hold, or demand a much bigger gap. Mark the row provisional. Tenure gives us data, not representativeness. Six ledger months from March to August is still a summer-only window.

The rule across all four: don't price it, but never let them silently vanish. Today "correctly priced" and "we have no idea" render identically, because the second one isn't rendered at all. Those customers are being carried by the true-up in arrears, quarter after quarter — which is exactly what Part B exists to end, so they should be visible and uncomfortable rather than absent.

Unchanged from the original: bills we couldn't read are never a basis for a price change. A price built on a mis-parsed bill is far worse than doing nothing.

12. How it plugs into what already exists

The machinery is all there — changing the One Sub amount is a well-worn path, it's what happens when a customer adds a service or reconnects. It reuses:

  • The price on the customer's service record, which feeds the One Sub total
  • The existing function that recomputes the One Sub amount from its services plus fees
  • The existing call that updates the recurring charge amount on Passport
  • The existing history trail on the One Sub, which already records what an amount was, what it became and why. We add a new reason — a price update — so every change is auditable alongside additions, corrections and split conversions.

Two things to watch.

Split payments — corrected 2026-08-27. An earlier draft of this section said the change "must flow into both halves". That is wrong, and the opposite mistake is the dangerous one. A split One Sub is two documents, each with its own Passport recurring charge, and a given service sits in exactly one of them. So a price change resizes exactly one charge — but it has to be the right one. The trap is the findOne(...).sort({ createdAt: -1 }) pattern most callers grew up with: on a split that resolves to part 2, so a service living in part 1 would silently resize the wrong charge and leave the real one stale. The apply step locates the doc that actually carries the line item (one-sub-selection.util.ts documents the same hazard).

Timing. Changes happen between cycles, never mid-cycle, so nobody gets a part-charged month.

13. Who decides

The system recommends. A person approves. Charging here is deliberately admin-triggered, and an automatic price change would go against that on purpose.

Where it lives

The "Monthly price check" card, at the top of the True-up page in bundul-admin (src/components/RepricingPanel.tsx, mounted at src/pages/TrueUpPage.tsx). It sits above the Console / Scheduled / Activity tabs, so it is visible on all three views — quarter-close decisions get made on that page, and this is one of them.

Per customer, on one screen:

  Alex — SDG&E
  Currently charging          $142 / month
  Their last 12 months        $1,858  →  $155 / month
  Gap                         $13 / month under
  Last 4 quarters of true-ups −$39, −$41, −$36, −$44   ← the same shortfall, four times running

That "same shortfall four times running" line is the tell. When it appears, the answer is a price change, not another collection.

How an admin knows to look

They get an email. Not on a fixed drumbeat — on the tenure clock, and only when there is something to act on:

  1. Each quarter the job finds paying customers whose evaluation is due — bundled at least 2 quarters ago, and at least 2 quarters since their last evaluation.
  2. It tops up their bill history from the ledger first.
  3. It evaluates only those.
  4. It emails only if something actually came out of it.

Nobody due, or everybody due correctly priced → no email. Silence has to mean something, or the email becomes noise and stops being read. That is not hypothetical: the per-record true-up admin email was removed for exactly that reason, because the console already listed everything. This one earns its place because a re-pricing fires perhaps once or twice a year per customer and there is no other prompt to go and look.

One digest per run, not one message per customer. To the existing trueUpAdminEmails roster, built with the house helpers in src/notifications/email-style.ts, deep-linking to the True-up page. No approve/decline links in the email — same rule as the true-up, money is authorized in the console.

3 customers need a price review — Q4

  Customer   Utility          Now       Suggested   Gap      Based on
  nkem       So Cal Edison    $295.40   $347.38     +17.6%   12 months
  Olamide    T-Mobile         $211.00   $248.00     +17.5%   7 months — provisional

  Nothing changes until you approve it.
  [ Open the True-up console ]

A second, separate alert fires when a scheduled change actually lands, or fails to. A failed price change means a customer has already been told their payment is changing and it didn't — that needs somebody the same day, not at next quarter-close.


14. Do we need models?

New database tables? Yes — two.

  1. Somewhere to keep every bill. The foundation for both parts. Nothing works without it.
  2. Somewhere to keep what we worked out — the comparison shown to each customer, which message we sent them, and every price change with its reasoning. So we can look back and see exactly what we told someone and why we charged what we charged.

Nothing existing changes shape. The record that handles paying the bill stays exactly as it is.

Actual AI / machine learning? No.

What we'd be feeding it: one number per month, maybe 12 of them, higher in summer. Not enough to train anything on. A model built on that is a complicated way of doing the arithmetic above, except nobody could explain its answers or check them.

Everything here is: add up 12 bills, divide by 12, subtract, see if the gap is big enough to act on. Every number checkable with a calculator. It does everything the customer was promised.

If we ever want more, in order: borrow the summer/winter pattern from other customers on the same utility (needs a handful per utility), and much later factor in how hot or cold the month actually was — what utilities themselves do — but only if it beats the simple version when tested against past bills.

Where AI genuinely earns its place is reading the bills, which we already do. Teaching it to pull out the billing period, the carried-over balance and the printed usage history will do more for accuracy than any prediction model. Note this is AI reading a document, not AI deciding what to tell a customer — that distinction is the whole of section 4.1.


15. Where we are, and what is left

Built and verified

Part A — telling the customer

What Verified by
Data audit against production scripts/inspect-utility-bill-history.js — findings in section 1a
Bill history: every bill saved once, never overwritten Booted against a real database; three identical saves → one row, one event
The new-bill trigger Same test — a re-read of the inbox cannot notify twice
Bank backfill (via Plaid, inactive streams included) Proven live: 14/14, 7/7, 4/4, 3/3 payments recovered
The comparison The seasonality trap asserted both ways
The quarter position Reads the same ledger the real true-up reads
The message catalog — every sentence, written in advance Exact strings asserted; an edit that drops a number is refused
Richer bill extraction (period, current charges, usage, flat-plan flag, last year) 19 tests on the field parsers
The send switch — off by default, admin-controlled 6 tests; off with nothing configured
⚠️ Admin UI for the switch Screenshotted in all four states, including the loud "ON for EVERY customer" warning — but uncommitted, see Left #1
Customer bill detail query (utilityBillDetail) GraphQL schema built for real; live server introspected; ownership enforced
⚠️ Admin bill-insights screen Screenshotted: overview, expanded bill series, replay results — but uncommitted, see Left #1
Backtest harness + scripts/backtest-utility-insights.ts 7 tests; each bill judged only on what preceded it

Part B — keeping the price honest

What Verified by
Re-pricing rules: 12 months ÷ 12, cut-offs, caps, no flip-flopping 18 tests, including that a summer-only window would overshoot by ~$28/month
Price-change record — every proposal and decision, including declines One open proposal per utility, enforced by index
Admin endpoints: review, approve, decline, history Live on the running server
⚠️ Re-pricing panel in the True-up console Screenshotted: a capped increase, a decrease, and the correctly-priced list — but uncommitted, see Left #1
Advance notice to the customer 5 tests; sent on approval, before the change lands

Fixes found along the way

What
Plaid pagination — a single call returned 100 rows of a 1,228-row window, so every utility price was set from a partial sample
Plaid inactive-stream filter — bundling makes a stream inactive, so we were excluding exactly the utilities we wanted
Mongo index creation — prod runs autoIndex off, so the dedupe index (and therefore the whole no-duplicate-notification guarantee) was never being built
Enum @Props made explicit — ambiguous to the decorator metadata under a transpile-only toolchain
39 orphan CustomerUtility rows removed, and the source fixed so they stop accruing
emailButton was emitting NaN instead of an anchor — a stray + + coerced the whole <a href=...> to the string "NaN", so every call-to-action button in every Bundul email was an unclickable blob with no URL, including the true-up console link. The existing font-style tests passed vacuously, because the <a> tag they looked for no longer existed. Fixed, with a regression test that asserts a real href.
Split One Sub "which half?" audit — 4 money/display bugs, including a Bundul fee that could be charged twice. See split-conversion.md.

Left

# What Where Note
1 Commit the admin UI — done, push pending bundul-admin Committed 3d631c1 on main (9 files: the four new components/pages, api/utilityInsights.ts, and the route/nav/mount edits). tsc --noEmit + vite build clean. Not pushed — and worth a live pass on the deployed dashboard afterwards, since these screens were only ever verified locally.
2 Apply a scheduled price change — done this repo changeServicePriceForUser (user-payment.service.ts) moves the service amount, resizes the Passport charge (Passport before Mongo), writes a price_update history entry and updates the catalog price — split-safe, idempotent. PriceChangeApplyService picks up scheduled changes whose date has arrived and marks them applied/failed; daily cron at 05:00 UTC (repricing-apply.job.ts). A failure alerts the same day. 20 tests.
3 Ledger backfill: top-up, not once-ever — done this repo Section 11. The once-ever guard is gone (recordBill's unique index makes re-reading free), and backfillAllFromLedger() enumerates utilities from the cash ledger rather than from bills we already hold — so a customer with zero bills on file is no longer invisible, and none of it depends on them connecting an inbox. 9 tests.
4 Quarterly evaluation job on the tenure clock — done this repo Section 9.1. RepricingScheduleService tops up history, then evaluates whoever is due; quarterly cron (repricing-review.job.ts) at 07:00 UTC on 1 Jan/Apr/Jul/Oct, an hour after the true-up. Correction to the earlier plan: the clock could not live on the price-change record — that only exists when there IS a proposal, and most looks produce none, so most utilities would never start a clock. It lives in its own one-row-per-utility record (repricing-evaluation.schema.ts). 11 tests.
5 Admin digest email + applied/failed alert — done this repo Section 13. repricing-digest.email.ts — one digest per run, only when there is something to act on, no approve/decline links, flags capped and provisional rows. Plus an "it landed" summary and the same-day failure alert on the apply job. Routed through sendAdminAlert (admin roster and the admin dashboard) rather than the trueUpAdminEmails list the plan first named — one mechanism, and it leaves a record in the console. 13 tests.
6 Show the un-priceable customers, flag provisional rows — done both repos Section 11. review() now unions "we hold bills for it" with "we pay for it" (from the ledger), so a customer with no bills can no longer vanish from the screen. The panel renders a third group — "N we can't price yet" with the reason — plus a provisional badge and a year-on-year badge. The UI reads a missing priceable as true, so shipping it ahead of the backend cannot dump every customer into the blocked list.
7 Count real consecutive bills, not span — done this repo monthsCovered now counts the unbroken run back from the newest bill, and the averaging window is that run rather than bills.slice(0, 12). Four bills for Jan/Feb/Mar/Jul report as 1 month, not 7. 4 tests.
8 Use the year-over-year comparison once we hold 24 months — done this repo Section 8. At 24 unbroken months the recommendation carries yearOverYear and the reason says why the gap exists — "their bills moved up 16%, a real change and not a seasonal swing" vs "flat year on year, so the gap is a stale price". Evidence only: the thresholds are unchanged, so it cannot move money on its own. 4 tests.
9 The bill detail screen mobile app Their team. The query and the contract are in section 4.4b.
10 Register the sub/{id}/bill deeplink mobile app Their team. Until it exists, a tap goes nowhere — harmless while messaging is off, but it blocks switching on.
11 Second pair of eyes on the Plaid pagination fix this repo Small change, shared path, prices customers.
12 Deploy Nothing customer-facing can fire; the switch is off and admin-controlled.

Order

Items 1–8 are built. What remains is verification and rollout, in this order:

  1. Push bundul-admin (3d631c1 + the #6 changes) and deploy the backend.
  2. Read the admin screen on real customers — including the new "can't price yet" list, which is the first honest view of how much of the book we cannot price.
  3. Sandbox-verify a real price change end to end. Everything is unit-tested against a mocked Passport; nothing has moved a live recurring charge yet. This is the gate before any customer is affected.
  4. Run the replay and read the sentences.
  5. Switch customer messaging on for internal accounts only, then widen.

16. What could go wrong

Risk What we do about it
We claim to know someone's "normal" when we don't Only say it when we have the history; otherwise say something plainly true, and always show the quarter number
Normal summer increases get flagged as unusual Compare to the same month a year ago, never the last few months (3.4)
A longer billing period looks like a spike Compare cost per day, not the total
The bill amount is really the whole account balance including old unpaid money Step 1 checks this on real bills; step 3 separates the two
The quarter number we show doesn't match the true-up they get The quarter number is always whole-account, from the same source the real settlement uses (4.3)
The job re-reads the same emails and alerts twice Alerts fire only when a bill is genuinely new to us
Two bills in one week, two pushes One message per run, not one per bill
A message goes out that nobody reviewed Every sentence written and approved in advance; nothing generated at send time (4.1)
Re-pricing on summer bills, then overcharging all winter Price off the full 12 months, never recent months (section 8)
Collecting the same shortfall twice — once as true-up, once baked into the new price New price comes only from expected cost, never from what they owe (section 10)
A customer's payment jumps sharply with no warning Cap the move, evaluate once every 2 quarters at most, tell them first, human approves
Re-pricing on bad data Never when bills are missing or failed to read, or under 6 months of history
A customer is invisible because we hold no bills for them The ledger gives every paying customer a bill a month whether or not they connect an inbox (11); customers we still can't price are shown with the reason, never omitted
We tell a customer their payment is changing and it never does Nothing is scheduled until the apply step exists (Left #2); a failed apply raises a same-day alert (13)
Six months of data that are all one season Judge the spread, not the calendar — hold or demand a bigger gap on a swinging bill, and mark the row provisional (11)
We alert so often people ignore us Two cut-offs before we speak, one message per run, tested against past bills first

This is a record of a decision at the time. It is not edited — write a new record that supersedes it.