---
title: "The bug was not PayPal, it was us throwing away the buyer's currency"
canonical: https://dxdev.com/blog/2026-05-14_paypal-buyer-currency/
datePublished: 2026-05-14
---
The staff detail row for a customer's payment showed a bare number with no currency symbol. PayPal's confirmation email to the customer showed a different, larger number in CAD. Those two numbers are reconcilable if you already know the exchange rate. But the staff interface had nothing to reconcile against. No currency code. No conversion note. Just a bare figure.

This looked, at first pass, like a display bug. It was not a display bug.

## what paypal's capture payload actually contains

PayPal's capture response carries two amounts. The `purchase_units[].payments.captures[].amount` field is the merchant-currency settlement, which in our case is USD. The payload also carries `seller_receivable_breakdown`, which includes the original buyer-side amount and currency, plus the exchange rate PayPal applied at capture time. If the buyer's card is CAD, the breakdown tells you exactly what the buyer paid in CAD, converted at whatever the day's rate was, and what that yielded in USD to us.

Our capture handler read the settlement field and ignored the breakdown. It stored the USD amount in `dbo.transactions`, left `currencyCode` as NULL, and moved on.

The money moved correctly. PayPal handled the conversion. The error was that we discarded the buyer's version of the transaction at write time, and there was no way to recover it from the record afterward.

## what staff actually saw

When the support question arrived, the staff detail view showed a bare number: no label, no context. No indication this was a foreign-currency transaction at all. If you didn't already know to check the PayPal confirmation email independently, you were looking at a number with no unit attached.

The support path from there involves pulling the PayPal transaction ID, opening a browser, logging into the PayPal dashboard, and finding the original capture. Three or four minutes per ticket for a routine question. For a billing dispute, it is worse. You are reconstructing the event from a source the customer cannot see and you cannot link them to.

The customer saw a CAD figure on their own confirmation email. Staff saw an unlabeled USD figure. Those are not the same invoice and the record explains nothing about the gap.

## the fix

The change touched two layers.

The write side: the PayPal capture handler now pulls `buyerCurrencyCode`, `buyerAmount`, and `exchangeRate` out of the seller receivable breakdown and writes them alongside the settlement amount. For USD buyers, the new columns are redundant and harmless. For CAD buyers, the record is now complete.

The render side: the staff detail view now checks `buyerCurrencyCode`. If it is non-null and differs from settlement currency, it renders both figures side by side, the buyer-currency amount and the settlement-currency amount, each labeled with its own currency code. If they match, or if the column is null, it renders the old way. No existing rows break.

After the code shipped, I backfilled the customer's row by hand. PayPal's transaction API still had the original capture data, so the source was recoverable. I updated `currencyCode`, `buyerAmount`, and `exchangeRate` directly and reloaded the staff view. That is the step that makes a code fix into a support fix, not just a future-prevention measure. The fix does not fully exist until the affected row renders correctly.

## the bad assumption still sitting in auto-renewal

One thing I did not fix in this ticket: auto-renewal. Some organizations on the platform set a billing currency at the org level. The renewal job reads the invoice amount from `dbo.transactions` in USD, because that is what the original capture stored, and submits it to PayPal without reading the org-level currency flag.

A CAD org renewing today pays in USD, silently. PayPal converts it at current rates. The payment succeeds and the record looks normal. There is no error message and there is no reconciliation flag. The customer may pay slightly more or slightly less than they expect depending on that day's exchange rate.

I flagged it as a follow-up ticket and did not touch it here. The scope was already clear and testable. But it is worth naming: fixing the capture handler does not mean the bad assumption is gone from the codebase. It means the capture handler no longer holds it. The renewal logic has its own copy.

## why this was invisible

CAD payments are a minority of the platform traffic. Most organizations are US-based, paying USD, and for those the buyer currency and settlement currency are identical. The `currencyCode` column being NULL never mattered, because the stored USD amount was the correct number.

The bug only surfaces when a Canadian org runs a payment, notices something wrong, and contacts support. On a 25-year-old ASP-classic application, low-frequency edge cases can stay undetected for a long time if nobody flags them. That customer's ticket was the flag.

I had not thought carefully about what the PayPal capture payload contains beyond the settlement amount. That was wrong. The buyer-side breakdown is documented, it is present in every cross-currency capture, and I was not reading it.

This shipped in a platform release.

If you only store settlement currency, your support team ends up debugging a ghost version of the transaction the customer never actually saw, and the schema fix and the rendered dual-currency row are both required to close that gap, because having the data and being able to act on it are different problems.
