Eight tagged production releases in one thirteen-hour day. That’s what it took to chase down a thank-you page that couldn’t find the registrant. Payments went through and the money moved, but every registration receipt on the site failed to say whose payment it was. The receipt looked the registration up by the wrong field, and a fallback hid which field it should have used. The fix that mattered was deleting half of one line.

How Direct Post works, and where the trap is

Authorize.Net’s Direct Post Method does not work like Stripe. There is no server-to-server capture call. The gateway processes the charge, then POSTs the result straight back to a URL on your own server as application/x-www-form-urlencoded, and you render the receipt from those form fields. The fields all come prefixed with x_.

Two of those fields look almost interchangeable and are not:

  • x_trans_id is Authorize.Net’s transaction ID. It belongs to the gateway. You store it so you have a reference back to their system. In our schema that goes in the gateway transaction column.
  • x_invoice_num is the value you sent the gateway when you kicked off the payment. It gets echoed back untouched. We had put the registrationID there.

So the identifier that finds the customer’s row is in x_invoice_num. The gateway’s own ID is in x_trans_id. They are different numbers from different places, and only one of them maps to a row in our database.

The original receipt code looked it up like this:

var registrationID = String(
this.authNetPostData["x_trans_id"] || this.authNetPostData["x_invoice_num"] || ""
);

Read that || and you can see the whole bug. The code says “use the transaction ID, and if that’s empty, fall back to the invoice number.” It treats two unrelated identifiers as names for the same thing and treats the fallback as a safety net. The fallback was no safety net at all.

Why it passed in test and broke in prod

In the test environment, x_trans_id came back empty or in a shape that didn’t resolve, so the expression fell through to x_invoice_num and found the registration. The receipt rendered. Everything looked correct.

In production, x_trans_id came back populated with a real Authorize.Net transaction ID. The OR short-circuits on the first truthy value, so the lookup grabbed the gateway’s transaction ID and went looking for a registration row with that number as its primary key. There was no such row. The page failed to find the registrant for every live payment.

The fallback made the code’s behavior depend on which of two fields happened to be populated in a given environment, and it hid that dependency behind an expression that reads like a sensible default. Test exercised one branch. Prod exercised the other. Nothing in between showed that the two branches resolved to different identifiers.

The fix

The fix shipped as a hotfix tag late that evening. It stopped pretending the fields are interchangeable:

// x_invoice_num contains our registrationID;
// x_trans_id contains Authorize.net's transaction ID
// and should be stored separately in the gateway transaction column
var registrationID = String(this.authNetPostData["x_invoice_num"] || "");

The registration ID now comes from x_invoice_num and nowhere else. x_trans_id is stored separately in the gateway transaction column where it belongs. The whole post-handler is gated on x_invoice_num being present, because that is the field that means “this is one of our payments and here is which registration it’s for.”

That comment stayed in the code on purpose. If it says which echoed field carries your identifier and which carries the gateway’s, the next person debugging this at 8pm doesn’t have to rediscover it from a failing receipt.

The same change carried a related correctness fix. The receipt’s printed “Invoice Number” line had been preferring the database registrationID over the gateway’s echoed x_invoice_num. Those should agree, but the source of truth for what the customer was charged against is the value the gateway echoed back. We flipped it to prefer the echoed value.

The card-number formatting trap, while we were in there

Authorize.Net returns the card number already masked, as XXXX1111. The old formatting helper took the last four characters of whatever string it was given and prefixed ****. A value that was already XXXX1111 came out as ****1111, which happens to look fine, but a value in any other shape would double-format or mangle. The guard was simple once we knew the input was already masked:

if (cardNum.indexOf("X") == -1 && cardNum.length > 4) {
// only reformat if it isn't already masked
}

If the string already contains an X, the gateway already masked it, so the helper leaves it alone. The cause is the same as in the x_trans_id bug. A field arrived from a third party in a known shape, and the code guessed at that shape. When the gateway tells you the format, encode it.

What to check on your own integration

When a payment gateway echoes back an identifier, confirm which field carries your own ID and which carries the gateway’s, and write it down in the code. Be wary of any fallback that lets two different identifiers stand in for each other, because it can pass in test and fail on every live payment.