Back to engineering

Engineering

The link in the email was never going to work

The Bookatu engineering team7 min read

A feature request sent us to read our own invoicing code, and the Pay now button in every invoice we had ever emailed pointed at an address that does not exist. Here is the bug, the worse one hiding behind it, and why the fix was a route rather than a string.

The short version

  • A hosted checkout URL is issued by the payment provider. It cannot be rebuilt from the session id, and ours was being assembled by hand.
  • Behind that: a checkout session expires within a day, and an invoice is the one payment designed to be settled whenever the payer gets to it.
  • Both are fixed by the same move. The email now carries a permanent address on our own domain, and the live payment page is resolved when somebody clicks it.
  • Resolving late also means an invoice already paid can say so, instead of taking the money twice.
  • The feature that prompted the reading was a different shape entirely: an invoice for a company that pays by bank transfer, which needs no card at all.

An owner asked for something reasonable. She had done work for a company, the company had not paid, and she needed to send them an invoice so their accounts department could pay it. Her current method was a free invoice builder on the web, filled in by hand from what was already in her calendar.

We had shipped invoicing months earlier, so the first job was to read what we had and find the gap. We found the gap. We also found that the part we had already shipped did not work.

A URL you cannot construct

When you create a hosted checkout session, the payment provider hands back two things: an id, and a URL. It is natural to assume the second is derived from the first, because the id is sitting right there in the middle of it. Our code assumed exactly that. It stored the id, and when the time came to email the customer it built the address by pasting that id onto the end of the provider's checkout host.

That is not the address the provider issues. The real URL carries an opaque fragment after the id, generated when the session is created and available only from the object you get back. There is no way to work it out afterwards. So every Pay now button we had sent pointed somewhere that could not work.

Nothing in our system could have noticed. The code ran without error, the email sent, the template rendered, the button was there and it was the right colour. The failure was entirely on the far side of a link that no test clicks and no log records. It is the shape of bug only a customer finds, and only if they bother to tell you rather than assuming they did something wrong.

A link is not a value you can compute. It is a value you were given, and the only safe thing to do with it is ask again.

The second bug, wearing the first as a disguise

Suppose the string had been right. It still would not have worked for long, because a hosted checkout session expires, typically within a day of being created.

For most payments that is fine. A deposit is paid during booking, a shop order at the till. The gap between the link existing and somebody using it is measured in seconds. An invoice is the exception, and it is the exception by design: the whole point is that the payer settles it when they get to it. On payday. When the month closes. When accounts run their next batch. A link with a one-day life is the wrong instrument for the one payment explicitly meant to be paid later.

So there were two bugs, and the second was the worse of them, because fixing only the first would have produced something that works in testing and fails in the world. You send an invoice, click your own link to check, see the payment page, ship it. The customer opens the email on Saturday and gets an error.

Resolve late, not early

Both bugs come from the same decision: working out the payment address when the invoice was written, and freezing it into an email. Emails cannot be updated after they are sent, so anything you put in one has to stay true forever. A provider's session URL is not that kind of value.

The fix is to put an address we control in the email, and decide what it means at the moment somebody clicks it. Our route looks up the invoice, asks the provider for the current state of its session, and forwards the payer to whatever URL it reports right now. If the session has lapsed, it creates a fresh one first. The address in the email is permanent because it is ours; the thing behind it is current because it was fetched a moment ago.

Asking late buys a third thing we did not set out to get. Because the route checks the session before redirecting, an invoice that has already been paid can say so, rather than sending someone to a live payment page for money they have already handed over. The naive version would have taken it twice.

Anything you put in an email has to be true forever. Put a pointer in, not an answer.

The feature underneath

The request that started this was not about links at all. It was about a second kind of invoice we did not have.

Our invoicing had one shape: raise an amount, mint a payment link, email a Pay now button. That shape assumes the person receiving it will pay by card, immediately, themselves. It also assumes the business has connected a payment account, because there is no link without one, and our code refused to raise any invoice at all without that connection.

An accounts-payable department does none of those things. It does not tap buttons. It pays by bank transfer, on its own schedule, in a batch. What it wants is a document: a number to file it under, the company's own purchase-order reference, a due date, an itemised breakdown, a tax line if you are registered, and the bank details to pay into. No card appears anywhere in that process, so requiring one had blocked the whole feature for exactly the businesses that needed it.

So invoices grew a kind. One is a payment link and keeps every gate it had, because it charges a card and those gates exist to make sure that happens safely. The other is a document, skips all of them, and produces a page built to be printed. The money arrives by a route the software never sees, so somebody ticks it off by hand when the transfer lands.

Freeze the tax, not the rate

One detail worth naming, because it is easy to get wrong and hard to spot. An invoice shows a tax line. The obvious implementation reads the business's tax rate when rendering the page.

That is wrong, and quietly so. An invoice is a document somebody has already filed. If the business changes its rate next year, every historical invoice would silently restate itself, and a page an accountant printed in March would no longer match the same page loaded in November. The tax has to be worked out once, when the invoice is raised, and stored on the row. The rate is a setting. The tax on this invoice is a fact about this invoice.

What we take from it

The Pay now bug survived because everything around it was testable and it was not. We tested that the invoice row was created, that the email rendered, that the webhook flipped the status. All of those passed. The one step nobody could assert on was whether the address in the button was an address.

The lesson is not to write more tests around that seam. It is to notice when your code is constructing a value that somebody else is the only authority on. An id you were given is data. A URL you were given is data. A URL you assembled from an id is a guess, and a guess in an email is a guess you cannot take back.

paymentsinvoicingbugsarchitectureengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs