Post

Multicurrency Payments — What I Learned the Hard Way

Lessons from building and maintaining multicurrency payment systems in financial services.

Multicurrency Payments — What I Learned the Hard Way

Working in payments for a while teaches you things that aren’t in any textbook. Mostly because the edge cases are so specific to how your system, your payment rails, and your settlement processes interact that writing them down generically seems almost pointless.

And yet — here we go.

Float is not your friend

This is lesson one and it sounds obvious, but I’ve seen it trip up experienced developers more than once.

1
2
3
// DO NOT do this
double amount = 0.1 + 0.2;
System.out.println(amount); // 0.30000000000000004

In a payment system, that extra 0.00000000000004 eventually shows up somewhere. It compounds across thousands of transactions. It causes settlement reconciliation mismatches. It causes someone to phone you at 6am.

1
2
3
// Use BigDecimal for monetary amounts
BigDecimal amount = new BigDecimal("0.10").add(new BigDecimal("0.20"));
System.out.println(amount); // 0.30

Note the string constructor for BigDecimal. new BigDecimal(0.1) doesn’t help you because 0.1 is already a double with floating-point imprecision baked in by the time it reaches the constructor. Always construct from String.

Store amounts as minor units

Storing amounts as decimals (e.g., 100.50 for R100.50) means you’re either dealing with floating-point in your database, or using DECIMAL columns and hoping everyone is consistent. Neither is fun when you’re reconciling.

The pattern I prefer: store amounts as minor units (integers) alongside the currency code.

amount currency description
10050 ZAR R100.50
10000 USD $100.00
10050 JPY ¥10050

Now your amount column is a BIGINT or NUMBER(19) with no decimal ambiguity. The currency code tells you how many decimal places to apply when displaying it.

The catch: not all currencies have 2 decimal places. Japanese Yen (JPY) has 0. Kuwaiti Dinar (KWD) has 3. This is defined in ISO 4217 — the exponent field tells you the number of minor units. Build this into your currency model from day one, because adding it later is painful.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public enum Currency {
    ZAR(710, 2, "South African Rand"),
    USD(840, 2, "US Dollar"),
    JPY(392, 0, "Japanese Yen"),
    KWD(414, 3, "Kuwaiti Dinar");

    private final int numericCode;
    private final int minorUnits;
    private final String description;

    // getters...

    public BigDecimal toDecimal(long minorAmount) {
        return BigDecimal.valueOf(minorAmount, minorUnits);
    }
}

FX rates have a validity window

When you store a foreign exchange rate, you need to store when it was valid — not just the rate itself. “What was the USD/ZAR rate?” is not a useful question without a timestamp. “What was the USD/ZAR rate at 14:32:07 on the 3rd?” is.

Minimum columns for an FX rate table:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE fx_rate (
    id              NUMBER GENERATED ALWAYS AS IDENTITY,
    from_currency   VARCHAR2(3)    NOT NULL,
    to_currency     VARCHAR2(3)    NOT NULL,
    rate            NUMBER(19, 10) NOT NULL,
    valid_from      TIMESTAMP      NOT NULL,
    valid_to        TIMESTAMP,
    source          VARCHAR2(100),
    CONSTRAINT fx_rate_pk PRIMARY KEY (id)
);

valid_to is nullable — a null means “still current.” Querying the rate at a point in time:

1
2
3
4
5
6
7
8
SELECT rate
FROM fx_rate
WHERE from_currency = 'USD'
  AND to_currency   = 'ZAR'
  AND valid_from   <= :transaction_timestamp
  AND (valid_to IS NULL OR valid_to > :transaction_timestamp)
ORDER BY valid_from DESC
FETCH FIRST 1 ROW ONLY;

Idempotency is not optional

Payment systems fail. Networks drop. The user clicks the button twice. The mobile app retries because it didn’t get a response in time.

Without idempotency, any of these scenarios can result in a duplicate payment. With idempotency, the second (or third, or tenth) submission of the same payment returns the same result as the first, without processing it again.

The common approach: the client generates a unique idempotency key (UUID) and sends it with every payment request. The server stores the result against that key. If the same key comes in again, return the stored result.

1
2
3
4
5
6
7
CREATE TABLE payment_request (
    idempotency_key VARCHAR2(36)  NOT NULL,
    status          VARCHAR2(20)  NOT NULL,
    result_payload  CLOB,
    created_at      TIMESTAMP     NOT NULL,
    CONSTRAINT payment_request_pk PRIMARY KEY (idempotency_key)
);
1
2
3
4
5
6
7
8
9
10
11
12
public PaymentResponse processPayment(String idempotencyKey, PaymentRequest request) {
    // Check if we've seen this before
    Optional<PaymentRequest> existing = paymentRepo.findByIdempotencyKey(idempotencyKey);
    if (existing.isPresent()) {
        return existing.get().getStoredResult();
    }

    // First time — process and store
    PaymentResponse response = paymentGateway.submit(request);
    paymentRepo.saveResult(idempotencyKey, response);
    return response;
}

The tricky case is when the first request fails mid-processing and you’re not sure whether the payment went through or not. This is where your payment gateway’s own idempotency support matters — make sure you’re passing their idempotency mechanisms through, not just your own.

Reconciliation will reveal all sins

Whatever mess exists in your data model, your transaction processing logic, or your rounding approach — reconciliation will find it. When your internal records need to match your bank statement and they don’t, the investigation almost always leads back to one of: inconsistent rounding, missing decimal precision, a race condition that created a duplicate, or a timezone issue.

Timezone is worth calling out separately. Storing transaction timestamps in UTC and only converting to local time for display is non-negotiable. I’ve seen systems that stored timestamps in local time and then couldn’t correctly identify which “day” a transaction belonged to during daylight saving transitions. The bank uses UTC. Your records need to match the bank.

This post is licensed under CC BY 4.0 by the author.