archive
Utility bill insights — what the mobile app needs to build
A frozen record of a decision at the time. Superseded by a new record rather than edited.
Written by the build · 2 September 2026
Utility bill insights — what the mobile app needs to build
Everything on the backend is built and live-verified. This is the whole contract.
Background, if you want it: docs/utility-bill-insights-plan.md. You don't need it to build this.
What this is
When a customer's utility bill arrives, we work out how it compares to their normal and where they stand for the quarter. If it's worth saying, we send a push. Tapping it should open a detail screen.
Two things to build: the deeplink route, and the screen behind it.
1. The deeplink route
Register one new route alongside the seven the app already handles:
Custom scheme (used in push data.url) |
bundulio://sub/{userSubscriptionId}/bill |
| Universal link (used in email, same destination) | https://cdn.bundul.io/sub/{userSubscriptionId}/bill |
{userSubscriptionId} is the customer's utility subscription id. It also arrives in the push
payload as additionalInfo.userSubscriptionId, so you don't have to parse it out of the URL.
Same pattern as the existing sub/{id}/otp and sub/{id}/reconnect routes.
2. The push payloads
Two notification types. Both follow the standard shape — { type, url, additionalInfo }.
A bill arrived and is worth mentioning
{
"type": "UTILITY_BILL_INSIGHT",
"url": "bundulio://sub/697bbbbdeaec33ff162e07d9/bill",
"additionalInfo": {
"userSubscriptionId": "697bbbbdeaec33ff162e07d9",
"serviceName": "SDG&E",
"billLineId": "bill_higher_than_normal",
"quarterLineId": "quarter_owe_projected"
}
}
Title and body are already written — render them as-is:
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.
The two *LineId fields say which of our pre-written sentences were used. You don't need them
to render anything; they're there for debugging and analytics.
Their monthly amount is changing
{
"type": "UTILITY_PRICE_CHANGE",
"url": "bundulio://one-sub",
"additionalInfo": {
"userSubscriptionId": "…",
"serviceName": "SDG&E",
"oldPrice": 295.40,
"newPrice": 369.25,
"effectiveFrom": "2026-09-01T00:00:00.000Z"
}
}
This one opens the existing One Sub screen — no new route needed.
3. The queries
Two. Both on the normal customer JWT — nothing extra to set up.
utilityBillDetail — everything the screen needs, in one call
query BillDetail($utilitySubId: String!, $billId: String) {
utilityBillDetail(utilitySubId: $utilitySubId, billId: $billId) {
billId
utilitySubId
provider
amount
billMonth
dueDate
periodDays
currentCharges
priorBalance
comparison {
basis
confidence
expected
bandLow
bandHigh
deltaAmount
deltaPercent
direction
explanations
}
quarter {
quarter
paidToDate
spentToDate
deltaToDate
monthsRemaining
projectedDelta
canProject
}
history {
billMonth
dueDate
amount
source
}
headline
body
wasNotified
}
}
billId is optional — leave it out for the latest bill, pass it to open an older one from the
history list.
The whole query returns null when we hold no bills for that utility, or when the
utilitySubId isn't the caller's. Handle that as an empty state, not an error.
utilitySubId — where to get it
From the push (additionalInfo.userSubscriptionId) or from the URL. It's the customer's
utility subscription id, the same id used by the existing sub/{id}/otp route.
utilitiesWithBills — which services can show this screen
query { utilitiesWithBills } # -> ["697bbb…", "697cfa…"]
No arguments. Returns the ids of the caller's utilities that have at least one bill on record. Use it to decide whether to show an entry point at all.
Field reference
| Field | Type | Notes |
|---|---|---|
billId |
ID! |
Pass back as billId to reopen this bill |
utilitySubId |
String! |
|
provider |
String |
Display name, e.g. "SDG&E" |
amount |
Float! |
What the bill says in total |
billMonth |
String! |
"2026-08" |
dueDate |
DateTime! |
ISO |
periodDays |
Int |
Days the bill covers. Null = the bill didn't say — don't infer it |
currentCharges |
Float |
This period only, excluding carry-over |
priorBalance |
Float |
Unpaid money carried over from an earlier bill |
comparison |
UtilityBillComparison |
Nullable — null means hide the block |
quarter |
UtilityQuarterPosition |
Nullable — null means hide the block |
history |
[UtilityBillHistoryPoint!]! |
Always present, may be empty. Newest first |
headline |
String |
Exactly the push title |
body |
String |
Exactly the push body |
wasNotified |
Boolean! |
Whether a push actually went out for this bill |
comparison
| Field | Type | Values / notes |
|---|---|---|
basis |
String! |
seasonal | recent | last_bill | none |
confidence |
String! |
high | medium | low |
expected |
Float |
What we expected this bill to be |
bandLow / bandHigh |
Float |
How far from expected still counts as normal |
deltaAmount |
Float |
Actual minus expected. Positive = higher |
deltaPercent |
Float |
0.365 = 36.5% |
direction |
String! |
higher | lower | normal |
explanations |
[String!]! |
Always present, often empty |
quarter — all figures cover the customer's whole One Sub, not this utility alone.
| Field | Type | Notes |
|---|---|---|
quarter |
String! |
"2026-Q3" |
paidToDate |
Float! |
What they've paid us this quarter |
spentToDate |
Float! |
What their services have cost |
deltaToDate |
Float! |
Positive = ahead (credit), negative = behind |
monthsRemaining |
Int! |
Whole months still to come |
projectedDelta |
Float |
Null when we can't estimate the rest |
canProject |
Boolean! |
False = treat projectedDelta as unavailable, not zero |
history — billMonth, dueDate, amount, and source (email | bank). Bank points
are a payment date and amount only; the date can sit a few weeks after the bill it paid. Fine
for a trend line, not for labelling a specific bill.
Two sample responses
The rich case — a customer we've known for over a year:
{
"utilityBillDetail": {
"billId": "68a3f1e2c9a04b7d3e5f1a20",
"utilitySubId": "697bbbbdeaec33ff162e07d9",
"provider": "SDG&E",
"amount": 284.00,
"billMonth": "2026-08",
"dueDate": "2026-08-26T00:00:00.000Z",
"periodDays": 34,
"currentCharges": 271.15,
"priorBalance": 12.85,
"comparison": {
"basis": "seasonal",
"confidence": "high",
"expected": 208.00,
"bandLow": 185.00,
"bandHigh": 231.00,
"deltaAmount": 76.00,
"deltaPercent": 0.3654,
"direction": "higher",
"explanations": ["This bill covers 34 days. Yours usually cover 30."]
},
"quarter": {
"quarter": "2026-Q3",
"paidToDate": 525.00,
"spentToDate": 469.00,
"deltaToDate": 56.00,
"monthsRemaining": 1,
"projectedDelta": -125.00,
"canProject": true
},
"history": [
{ "billMonth": "2026-08", "dueDate": "2026-08-26T00:00:00.000Z", "amount": 284.00, "source": "email" },
{ "billMonth": "2026-07", "dueDate": "2026-07-26T00:00:00.000Z", "amount": 189.00, "source": "email" }
],
"headline": "SDG&E bill: $284.00",
"body": "That's about $75 more than a normal August for you. Looks like you'll owe about $190 at the end of the quarter.",
"wasNotified": true
}
}
The sparse case — build for this one. A customer who joined a few months ago. This will be the common shape for a while, because we need about a year of bills before a comparison is possible:
{
"utilityBillDetail": {
"billId": "68a3f1e2c9a04b7d3e5f1a21",
"utilitySubId": "697cfaea9f27a297cadb5008",
"provider": "T-Mobile",
"amount": 216.00,
"billMonth": "2026-08",
"dueDate": "2026-08-15T00:00:00.000Z",
"periodDays": null,
"currentCharges": null,
"priorBalance": null,
"comparison": null,
"quarter": {
"quarter": "2026-Q3",
"paidToDate": 350.00,
"spentToDate": 469.00,
"deltaToDate": -119.00,
"monthsRemaining": 1,
"projectedDelta": null,
"canProject": false
},
"history": [
{ "billMonth": "2026-08", "dueDate": "2026-08-15T00:00:00.000Z", "amount": 216.00, "source": "email" }
],
"headline": "T-Mobile bill: $216.00",
"body": "You're about $119 behind this quarter, with 1 month(s) to go.",
"wasNotified": false
}
}
Here the screen shows the bill, no comparison block at all, and a quarter block with "you're $119 behind, 1 month to go" and no quarter-end figure.
4. Three rules that matter
These aren't style preferences. Each one exists because breaking it turns a deliberate silence into a false statement about someone's money.
1. A null block means hide it. Never render a zero.
comparison is null when we had no trustworthy basis to compare against — a customer who
joined four months ago has no "normal August". quarter is null when the quarter can't be
stated honestly. Rendering "$0" or "—" in those slots claims we know something we don't.
2. canProject: false means unavailable, not zero.
When it's false, show deltaToDate alongside monthsRemaining — "You're $119 behind with 1
month to go" — and no quarter-end figure. deltaToDate is a settled fact from the ledger;
the projection is a guess we've declined to make.
3. headline and body are the exact sentences the push used.
They're returned specifically so the screen and the notification can't contradict each other.
Render them as given — don't re-derive copy from the numbers, and don't reformat the amounts.
One more, minor: the copy can be reworded by our team in the admin dashboard without a release. So don't hard-code any of these sentences in the app, and don't pattern-match on them.
5. The screen
Rough shape — treat the layout as yours, the content as the spec:
SDG&E — August bill $284.00
────────────────────────────────────────────────────────
A normal August for you ~$208
This bill ▲ $76 more ← comparison block
(hide entirely if null)
Why it might be higher
• This bill covers 34 days. Yours usually cover 30. ← explanations[]
(omit if empty)
Your quarter so far (July–September) — all services ← quarter block
You pay us $175 a month → $525 (hide entirely if null)
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 quarter box covers their whole One Sub, not just this utility. The quarter-end settlement is calculated per customer across every service, so labelling it as the utility's quarter would be wrong. Worth wording carefully.
Always show the caveat line on any projected figure. The final number is confirmed by our team after the quarter closes and can move.
6. Testing it
Customer messaging is off by default and controlled from the admin dashboard, so no push will fire until someone turns it on. To build against real data, ask us to add your test account to the allowlist and switch it on — that scopes sending to named accounts only.
The query works regardless of the switch: insights are computed and stored whether or not anyone is being messaged.
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.