Friends & Family Billing
I built shared billing for nine people so I could stop reopening a spreadsheet every time someone asked what they owed. The math was easy. Explaining it was the work.
The math was never the problem
My husband and I split T-Mobile, Apple One, and 1Password with my parents, my sister’s family, and two friends. Nine people in five households. The name was fairly literal.
Every year, someone would ask how much they owed. I’d open a spreadsheet, work through the math for three bills, and text them a number. Then someone else would ask, and I’d do it again.
The arithmetic was trivial. I had to calculate, explain, ask, remind, verify, and repeat. Asking friends for money feels awkward. Checking whether they’ve looked feels intrusive. Both jobs fall to whoever set the arrangement up, which was me.
I needed accurate numbers and a record I could audit, because I was the one vouching for them. A recipient needed an amount, the arithmetic behind it, a way to pay, and a way to object. I built Friends & Family Billing around those two jobs and kept an account out of the recipient’s way.
One link is the recipient’s whole year
A household member opens a no-login link. It shows their member-scoped view, including equivalent summaries for any linked household members. I wanted the page to explain the number I used to text them.
Every bill shows its derivation inline: $300.00/mo ÷ 8 members = $37.50/mo · ×12 = $450.00/yr. The display reads each figure from the canonical fields the billing math wrote. It doesn’t calculate them again, so it can’t imply a rounding step the calculation never took. The invoice email carries a greeting, a total, and the link. The email asks; the page explains.
A link can let someone question a charge, approve or reject my proposed resolution, confirm or deny receipt of a refund, and request a fresh link when theirs has died. The first three depend on the scopes I grant that link. Without them, it opens to a summary with no controls. Recipients can’t record payments; there’s no payment control or write scope. They see, question, and confirm. I post the money.
“Question This Charge” stays inside that no-login flow. It submits a scoped review request, rate-limits it, writes a server-side audit entry, and emails me the question with a link straight into my review queue. Six of the seven Cloud Functions serve someone who has no account.
I moved three parts of the old conversation into the product: explaining the amount, checking whether the link was opened, and requesting a replacement. A recipient can find the amount and its derivation without asking me. I can see that their link was opened without asking them: each link has a visit count and a last-viewed date. Those count visits through the app, not people. A forwarded link and a second look give me the same signal.
An expired link has a “Request New Link” control. The system emails me the request, but I still have to create and deliver the replacement.
Sending is still my job too. There’s no scheduler in this codebase. Once a year, I open a dialog and press Send for each member. I removed the explaining and the checking, and moved the request for a replacement into the product. I didn’t automate the sending.

- 9 peopleone group across five households, no shared budget; the only users there have ever been, on my account
- No accountsrecipients hold a link; I designed possession of it to be their credential
- No schedulerI still press Send for each member once a year
- 365 daysthe default link life, including what the dialog silently creates when I choose No expiry
The decisions
All three decisions are mixed. Each has real support and a real limitation. Two concern the money: who can see a household’s numbers without an account, and what I owe someone who overpaid. The third is the rendering failure I wrote about in Six PRs, One Bug.
- AccessMixed
Make the link the credential
- What I encountered
- A household member checks their bill a handful of times a year. I didn't want to put a password, recovery flow, and sign-in in front of that, especially for the person I was asking to pay. The token link was already there before the React app. No ADR, spec, or pull request records a comparison with recipient accounts.
- What I decided
- I gave recipients a URL. It opens their member view, including equivalent summaries for any linked household members. I added expiry, revocation, and pruning, plus a visit count and last-viewed date for each link.
- Why
- A recipient needs four things: an amount, its derivation, a way to pay, and a way to object. Those are read-and-request acts. I didn't need to establish an identity for them, and I expected an account requirement to stop someone who only checks once a year from looking at all.
- Cost
- The application treats whoever holds the link as the recipient. The deployed data-layer rules let anyone who knows the token hash read an existing cached view of that member and any linked household members, without checking expiry or revocation. Revocation marks the token revoked and tries to delete the cache, but ignores a failed delete; pruning batch-deletes caches. A successfully deleted cache no longer exposes its payload.
- What it changed
- Managing links became part of my workflow. I can read each link's lifecycle, visit count, and last-viewed date to see that a bill was opened without asking.
- SettlementMixed
The coordinator doesn't keep the change
- What I encountered
- The vanilla-JS app had three balance states: outstanding, partial, settled. Then consider a service credit applied after someone has paid. I'm holding money that belongs to them, and none of those states says so.
- Over
- Writing it off. A coordinator can quietly keep a small overpayment in an informal arrangement. I deliberately removed that option; ADR-0004 records the absence of a forgive-credit action as intentional.
- What I decided
- I added overpaid as a fourth state in the React port on 2026-03-21, in a Phase 2a commit rather than an ADR. ADR-0004 later made the rule explicit: I have to return an overpayment as a refund or carry it forward. I can't absorb it.
- Why
- If I can call a balance settled but can't say that I owe someone money, I can turn the household's error into my gain and call it even. Between people sharing a phone plan, returning the money matters to whether the arrangement feels fair.
- Cost
- The fourth state doesn't clear itself. I have to close it by deciding where the money goes. Behind the badge came credit adjustments, refund events, a refund-notice service, a refunds scope on share links, a Cloud Function for the recipient to confirm receipt, and five ADRs about how credit moves.
- What it changed
- I got the state into the model before I made it easy to find. The board has an Owed to members tile, so I can see that I owe money. Its filters are All, Outstanding, Partial, Settled. There's no Overpaid chip; I have to scroll All to find the household. The share page doesn't import the status badge. The person whose money I'm holding sees green settled or a red amount due. I made the debt visible in the headline and left both people who need to act without the view they need.
- ParityMixed
Fix the rendering model, not the preview
- What I encountered
- The invoice template had three render paths. The editor drew its own DOM with unresolved token pills, the preview used one markdown pipeline, and the sent email used another. Six pull requests shipped on this surface in one session across roughly twenty hours without closing the gap. The three attempts at the bug itself all tried to make the surfaces look closer.
- What I decided
- I changed the brief, and Codex delivered PR #161. It introduced a canonical renderer whose docstring calls it the single source of truth for the Invoicing preview and template email HTML. That path bypassed the plain-text bridge. A regression test in a required check held the preview's DOM and the test email's payload to the same HTML until 2026-09-30.
- Why
- The sent email is the source of truth. I needed one renderer that the other surfaces used. Making Preview look closer kept the three competing versions in place, which was the approach that had already failed.
- Cost
- The fix reached the surfaces I was watching. Before it, the test email and the invoice rendered identically. After it, they diverged: the preview and test send moved together, and the invoice stayed where it was. The pull request never touched the file that sends the recipient's invoice.
- What it changed
- After the third failed attempt, I rewrote the brief as an invariant, explicitly banned the approach that had failed three times, and moved the task to a different agent. That produced the canonical renderer and required tests comparing the preview with the test send: two at the time, though the set has changed since. None checks the invoice people actually receive. The tests still cover the same incomplete set of surfaces the fix did. [Six PRs, One Bug](/blog/six-prs-one-bug-agent-failure-modes/) has the chronology.
What broke
I kept getting the model right and stopping before the screen someone needed.
- Rendering. From April to September 2026, the preview and test send shared a canonical renderer. The recipient’s invoice didn’t; its dialog sent plain text with no HTML field. A security change on 2026-09-30 (friends-and-family-billing#459) closed that particular gap by taking every email off the client’s HTML. Both sent messages now render server-side from plain-text bodies, which still differ in serialization. Only the preview uses the canonical renderer.
- Settlement. I modeled overpayment and built the subsystem behind it. I can see an
Owed to memberstile, but there’s noOverpaidfilter to take me to the household. The recipient’s share page has no badge for the money I owe them. - Access. I built expiry, revocation, and pruning into the application. The deployed rules leave an existing cached member view readable to anyone who knows its token hash, without checking expiry or revocation. The missing backstop is tracked in issue #492.
The gaps sit at different layers, but I made the same mistake each time: I stopped checking just before the boundary that mattered.
Three paths out of one template. The two the parity fix unified were the two the tests covered, and the third was the one that reaches a person; since 2026-09-30 the test send has rejoined the invoice on the bridge, leaving only the preview on the canonical renderer.
What I learned
Splitting a phone plan wasn’t mathematically hard. It was always a division. Showing the derivation answers the question someone would otherwise ask me, and the visit count lets me check without another message.
I routed the asking without automating the sending. That feels like the right place to stop. Nothing here should decide on its own that a friend owes money.
The harder lesson was mine. I modeled overpayment correctly and refused to let the coordinator absorb it, then left myself scrolling to find it and the recipient with no sign of it. I unified the renderer and missed the path into a person’s inbox. In both cases, I stopped at correctness before reaching the surface someone actually looks at.
The household doesn’t experience a balance state or a canonical renderer. It experiences an amount, an explanation, and whether the arrangement still feels fair a year later. In a product moving money between people who know each other, that is the whole job.
