Friends & Family Billing

Shared billing that shows the math, tracks the money, and takes the awkward checking out of splitting recurring costs.


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, which is the detail the product’s name was carrying all along. Every year, someone would ask how much they owed, I would open a spreadsheet, re-derive the math from three billing cadences, and text them a number. Then someone else would ask, and I would do it again.

The arithmetic was trivial. The product problem was the coordination wrapped around it—calculate, explain, ask, remind, verify, repeat—and its social texture: asking for money feels awkward, checking feels intrusive, and both jobs default to whoever set the thing up.

That loop holds two users with different needs. The coordinator needs accuracy and an auditable record—I am the one vouching for the numbers. A recipient needs an amount, the arithmetic behind it, a way to pay, and a way to object, with no account in the way. Friends & Family Billing holds those two jobs apart.

A household member’s entire relationship with this product is a no-login link. Opening it loads a member-scoped view, including equivalent summaries for any linked household members, and the page is built around one choice: show the arithmetic, not just the answer. Every bill renders its derivation inline—$300.00/mo ÷ 8 members = $37.50/mo · ×12 = $450.00/yr—and nothing in that line is recomputed for display: each figure reads from the canonical fields the billing math wrote, so the page cannot imply a rounding the calculation never took. The invoice email carries a greeting, a total, and the link—the email asks, the page explains.

A link can grant four things, none requiring an account: question a specific charge, approve or reject the resolution I propose, confirm or deny receipt of a refund, and request a fresh link when theirs has died. The first three are per-link scopes rather than universal rights—a link minted without them opens to a summary and no controls. They cannot record a payment—no such control or write scope exists. The recipient sees, questions, and confirms; only the coordinator posts money.

The question path stays inside the no-login product. “Question This Charge” submits a scoped review request, rate-limits it, writes a server-side audit entry, and emails me the question with a deep link into my review queue. Six of the seven Cloud Functions exist to serve someone who has no account.

Three coordination steps are verifiably gone. The recipient learns their amount and its derivation without asking me. I learn that the link was opened, without asking—every link carries a visit count and a last-viewed date I can read back. It counts visits through the app, not people: a forwarded link and a second look are the same signal. And a dead link moves the request into the product: the expired page offers “Request New Link,” and the system emails me, but I still have to mint and deliver the replacement. A fourth step is not gone: nothing sends on its own—there is no scheduler in this codebase. Once a year I still open a dialog and press Send, per member. The product removed the explaining and the checking; it routed the asking without automating the sending.

My own share of the 2026 bills, from the live product. Each line shows its derivation and not just its result—$300.00 / month × 12 ÷ 8 members = $450.00—and every figure is read from the field the billing math wrote, not recomputed for display, so the page cannot imply a rounding the calculation never took. This is the same derivation a recipient's link renders. The capture stops at my own subtotal: the rows below it belong to another member, and my approval to publish covers my data and nobody else's.
Figure 1: My own share of the 2026 bills, from the live product. Each line shows its derivation and not just its result—$300.00 / month × 12 ÷ 8 members = $450.00—and every figure is read from the field the billing math wrote, not recomputed for display, so the page cannot imply a rounding the calculation never took. This is the same derivation a recipient's link renders. The capture stops at my own subtotal: the rows below it belong to another member, and my approval to publish covers my data and nobody else's.
  • 9 peopleone group, five households, no shared budget—the only users there have ever been, on my own account
  • No accountsrecipients hold a link; possession is the credential the design intends
  • No schedulerthe coordinator still presses Send, per member, once a year
  • 365 daysthe default link life, and what the dialog's No-expiry option silently mints

The decisions

Three decisions carry the argument, all three mixed—real support beside a real limitation. Two are about the money: who may see a household’s numbers without an account, and what the model owes the person who overpaid. The third is the day the rendering model broke, told at length in Six PRs, One Bug.

  1. AccessMixed

    Make the link the credential

    What I encountered
    A household member checks their bill a handful of times a year. An account—password, recovery, sign-in—prices that rare act absurdly, and the person paying is the one I least wanted to burden. The token link predates the React app entirely, and no ADR, spec, or pull request ever records an account model being weighed against it.
    What I decided
    A recipient holds a URL and nothing else. Opening it presents that member's view, including equivalent summaries for any linked household members, and the application gives each link a lifecycle: expiry, revocation, and pruning, with a visit count and last-viewed date against every link.
    Why
    The recipient's four needs—an amount, its derivation, a way to pay, a way to object—are all read-and-request acts. None of them requires an identity, and putting an identity in front of them is what would have killed them: a person who checks a bill once a year does not create an account, they just never look.
    Cost
    Whoever holds the link is treated as the recipient. The data-layer rules do not fully enforce the application's intended expiry and revocation model, so a leaked link can expose the intended member's view, including linked household members.
    What it changed
    Link management became part of the coordinator's workflow: every link carries a lifecycle, a visit count and a last-viewed date, so I can see that someone opened their bill without asking them whether they did. The weakness is enforcement—expiry and revocation live in the application, and the deployed data-layer rule does not fully back them.
  2. SettlementMixed

    The coordinator doesn't keep the change

    What I encountered
    The vanilla-JS app had exactly three balance states—outstanding, partial, settled—and no overpaid branch. But a service credit applied after someone has already paid creates a real position: the household is holding money that belongs to a member, and a three-state model has nowhere to put it.
    Over
    The write-off. Letting the coordinator quietly keep small overpayments is what most informal arrangements do, and ADR-0004 removes the option deliberately—it records the absence of any forgive-credit action as intentional.
    What I decided
    A fourth state, overpaid, added in the React port on 2026-03-21—in a Phase 2a commit, not an ADR. What forces it to exist is ADR-0004, which bans the write-off: an overpayment must be discharged, by refund or carry-forward, never absorbed.
    Why
    Between people who share a phone plan, the money that flows back is what decides whether the system is trusted. A model that can say settled but cannot say overpaid converts the household's error into the coordinator's gain and calls it even.
    Cost
    The fourth state is not self-clearing. It opens a disposition the coordinator must close, and behind that one badge came a subsystem—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 settling how credit moves.
    What it changed
    Modelling the state was not the same as surfacing it. The board does carry a headline Owed to members tile, so the coordinator can see that a debt exists at all; what it has no way to do is find it. The filter row runs All, Outstanding, Partial, Settled—no Overpaid chip—so an overpaid household is reachable only by scrolling All. And the share page never imports the status badge, so the person whose money it is sees two states: a green settled, or a red amount due. The money is modelled correctly and shown to nobody who would act on it.
  3. ParityMixed

    Fix the rendering model, not the preview

    What I encountered
    The invoice template rendered three ways: the editor drew its own DOM with unresolved token pills, the preview ran one markdown pipeline, and the sent email ran another. Six pull requests shipped in one session on this surface across roughly twenty hours without closing it, and the three attempts at the bug itself had all converged on the same move—make the surfaces look closer.
    What I decided
    PR #161 introduced a canonical renderer—its docstring names it the single source of truth for the Invoicing preview and template email HTML—bypassed the plain-text bridge on that path, and left behind regression tests in a required check: the preview's DOM and the test email's payload must be the same HTML.
    Why
    The sent email is the source of truth, so the renderer had to become one thing rather than three things reconciled. Making Preview merely look closer was the failure mode, not the fix.
    Cost
    The fix landed on the surface being watched. Before it, the test email and the invoice rendered identically; after it, the proven surface and the real one diverged—and the pull request that unified the first two never touched the file that sends the third.
    What it changed
    After the third failed attempt I rewrote the brief as an invariant, banned in writing the approach that had failed three times, and moved the task to a different agent. That produced the canonical renderer and two required tests—and both tests guard the preview against the test send. Neither checks the invoice people actually receive, so the tests encode the same incomplete definition of parity the fix did. [Six PRs, One Bug](/blog/six-prs-one-bug-agent-failure-modes/) has the chronology.

What broke

All three decisions left the same gap between the model and the surface that mattered.

  • Rendering. The preview and the test send share a canonical renderer. The invoice a household member actually receives does not: its dialog sends plain text with no HTML field, and the pull request that unified the other two never touched the file that sends it. The divergence has stood since April 2026.
  • Settlement. Overpayment exists in the model and in a subsystem behind it. The coordinator gets an Owed to members tile but no Overpaid filter to reach the household, and the share page carries no badge at all for the person whose money it is.
  • Access. Expiry, revocation and pruning exist in the application. The deployed data-layer rule does not fully enforce them, so a leaked link can expose the intended member’s view and the design’s safety rests on the application layer alone.

Different layers, one failure: the invariant stops just before the boundary where it would have mattered.

Three paths out of one template. The two the parity fix unified are the two anyone tested; the third is the one that reaches a person.

What I learned

The product did not make splitting a phone plan mathematically easier. The arithmetic was always a division. What it removed was the explaining and the checking—and it removed those by showing the derivation instead of the answer, so the question a recipient would have asked is answered before they ask it. It routed the asking without automating the sending, which is the right place to stop: nothing here should decide on its own that a friend owes money.

The harder lesson is that getting the model right is not the same as getting it seen. I modelled overpayment correctly, refused on principle to let the coordinator absorb it, and then left the coordinator scrolling for it and the recipient with no sign of it at all. I unified the renderer and missed the one path that reaches a person’s inbox. Both are the same failure at different layers: correctness that stops before the surface someone actually looks at.

In a product moving money between people who know each other, that is the whole job. The household does not experience the balance state or the canonical renderer. It experiences an amount, an explanation, and whether the arrangement still feels fair a year later.