Invoicing: Parsedown, SITE_URL, and the boring 2%
Money software loses trust on the parts most teams never test — markdown rendering and link resolution. We fixed both: Parsedown renders the breakdown, SITE_URL points share links at the real host. Two boring fixes that earn more trust than the next three feature launches combined.
The most important parts of money software are the parts nobody screenshots. Nobody tweets about a markdown render that didn't break. Nobody books a podcast about an absolute URL that resolved correctly the first time it's clicked. Those two boring fixes — Parsedown and SITE_URL — are the parts that tell a client whether they can trust the rest of the invoice they just opened, and they're the parts we shipped this quarter on Invoicing.
The boring 2% is the part most operators inside the company underestimate. It is also the part their clients feel first.
Why money software loses trust on the parts nobody tests
A money document — invoice, retainer receipt, monthly accounting breakdown — needs to do two things most software worlds can ignore. It needs to render the way the writer wrote it, and it needs the links in it to resolve to actual, clickable addresses. If either of those breaks, the document no longer looks like the document the operator wrote, and the operator no longer trusts the product it shipped through. That's not a feature gap. That's a trust hole.
We are not making this up. We've watched operators write long accounting breakdowns for handoff — the kind of note that takes an experienced Otto forty minutes to write — and then opened those notes in the client-facing invoice view to find naked markdown sitting where rendered text was expected. Asterisks where bullets should have been. https:// literal where a clickable link was expected. The operator's note looked unfinished even though the operator finished it. The client opened the invoice, saw asterisks, and decided some part of the company was rushing them.
That's the trust failure. That is also the boring fix.
What we fixed: Parsedown, in safe mode
The fix on the render side was small enough to ship today. We added invoicing/public/includes/markdown.php on the same Parsedown 1.7.4 stack Tasks already uses — and we ran it in safe mode so the same content an operator wrote in Tasks can be embedded into an outbound invoice breakdown without losing the rendering or gaining an XSS vector.
The pattern across the company now is: an operative writes a long accounting note in Tasks, snapshots it onto the matching outbound invoice row, and the client-facing invoice.php page renders it through Parsedown. An Otto whose job is keeping the long accounting breakdowns in shape keeps their notes in Tasks and snapshots them down to Invoicing. The render now matches the source. The asterisks stop being asterisks and become bullet points. The backticks become preformatted lines. The links become clickable.
If you run money software, do not roll your own markdown parser for invoice pages. Pull the same parser your docs use, run it in safe mode, snapshot the source faithfully. The boring fix is the boring fix because it's obvious in retrospect and expensive to skip.
What we fixed: SITE_URL resolution
The link-resolution fix is even shorter. Invoicing generates absolute URLs — for retainer-share, overage-share, monthly statement download — and those need to point at the real host, not at whatever localhost the request happened to be on during development. We shipped a four-tier resolution chain on invoicing.decisionsciencecorp.com:
- The
SITE_URLenv var, when set, wins. - If not set, the SQLite
config.site_urlrow, when populated, wins. - If neither is set, we fall back to the request's
Hostheader. - If that fails, we fall back to a configured localhost default — but the link gets a
localhostwarning so an operator notices before it ships outbound.
This is the boring fix because every share-link failure on money software looks like a localhost link showing up in a client's email. That kind of failure is unrecoverable in the moment: the client clicks, hits a private dev port, the thing they see at the other end is not a confidence-building moment. The resolution chain makes the link correct without anyone having to think about it.
The chain also means an Otto installing the system does not need to remember to set the env var before the first run. If it isn't set, the system still produces a correct link via the configured row or the request host. We're not going to ship money software that hard-fails on environment configuration. That is a category of failure we are building out of the product.
How Tasks and Invoicing talk
The through-line across both fixes is that Tasks and Invoicing now share one rendering pipeline — Parsedown, same safe-mode config — and one truth chain — the resolver, same priority order. The two products are not entirely the same — Tasks is the operator's day, Invoicing is the client's month — but the structural decisions underneath them are.
There is a quiet payoff here that matters more than either fix alone. The snapshot the operator wrote in Tasks is the same document the client opens when the invoice lands. If the operator changes the breakdown in Tasks, they can resnapshot it onto the outbound invoice row without rewriting it. We built a small tool — tools/resnapshot-accounting-markdown.php — to pull a fresh Tasks-side markdown onto the invoice row when a prior snapshot got poisoned. Operators do not need to remember this exists; the invoice breakdown view prompts a resnapshot if the stored snapshot is missing or stale.
Doing this once, well, means we never redo the same conversation with the Otto writing it. A resnapshot is a one-line PHP call, not a forty-minute re-write. The work compounds. The boring 2% is the part most easily skipped and most easily charged for later.
What's still honest
There are limits I won't pretend around. Parsedown safe mode is good, not infallible — a creative writer writing a fragment of HTML in their Tasks note will have that fragment treated as text. We document the safe-mode boundaries in docs/invoice-breakdown.md, and we surface a warning when the snapshot detects an unsafe fragment. The resolver still requires an operator to set SITE_URL or the SQLite row for inbound integrations that don't trust the request Host; that configuration is one-time but it is real. The resnapshot tool is currently one-shot; we're not yet running a heartbeat that re-fetches Tasks markdown on a schedule. That's the next sprint.
We're not claiming the boring 2% is finished. We're claiming the boring 2% ships today. The render matches the source. The link resolves to the real host. The snapshot is recoverable from a poisoned state in one PHP call. Those are the fixes that earn more trust than the next three feature launches combined, and they're the fixes that compound.
The next sprint is the one that turns the resnapshot from a one-shot into a heartbeat. We're aiming for that sprint.