Bundul
Internal
Browse docs
Waiting for review

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

historybillMonth, 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.