A payment callback arrives with these fields:
{
"status": "COMPLETED",
"amount": 7.79,
"currency": "EUR",
"reference": "session-demo-8",
"transaction": "remote-demo-31",
"message_time": "2026-04-01T09:15:00Z"
}The application does not need a COMPLETED string or a floating-point amount.
It needs a trusted result: which local operation completed, for how much, in
which currency, under which remote identity, and at what instant.
Passing the decoded array into business code postpones that translation. It does not avoid it.
Authenticity comes before interpretation
The retained callback boundary receives a JSON string and a message authentication code. It rejects a missing or invalid code before the payment driver finalizes anything.
That order is non-negotiable:
receive bytes
-> verify authenticity
-> decode representation
-> validate required shape
-> map external meaning
-> apply business transitionMapping an unverified payload into a well-typed object only gives untrusted input a nicer outfit.
The exact signature algorithm and canonical representation belong to the remote protocol. Verification must use the representation the protocol defines—often the raw body—before parsing or re-encoding can change it. The public fixture signs its synthetic raw JSON and proves that changing the amount after signing is rejected before mapping.
Decoding is not mapping
JSON decoding changes bytes into language values:
"amount": 7.79
-> floating-point numberMapping decides what those values mean locally:
7.79 major currency units
-> 779 integer minor unitsThe same distinction applies to every field:
| External field | External meaning | Internal result |
|---|---|---|
status: COMPLETED | provider lifecycle state | accepted |
amount: 7.79 | major units | amount_minor: 779 |
reference | caller-provided correlation | local operation identity |
transaction | provider record | external transaction identity |
message_time | provider timestamp | parsed instant |
This table is the anti-corruption layer in miniature. It prevents provider vocabulary and representation choices from becoming the application’s default language.
The mapper should reject unknown required values. Treating every status other
than COMPLETED as “cancelled” collapses pending, expired, failed, reversed,
and genuinely cancelled states into one answer. That may match a narrow remote
contract, but it must be a deliberate exhaustive decision.
One internal result gives downstream code a smaller world
Instead of passing the remote array, return a local result:
type AcceptedPayment = {
kind: 'accepted'
operationId: string
amountMinor: number
currency: string
externalTransactionId: string
occurredAt: string
}Downstream code no longer knows:
- whether the provider called success
COMPLETED,SETTLED, orpaid; - whether money arrived in major units, minor units, or a decimal string;
- whether correlation was named
reference,order, ormerchant_id; or - which timestamp syntax the callback used.
Those differences remain real. They are contained in one translator with contract tests.
This does not require one universal payment model for every capability. Disputes, partial captures, asynchronous refunds, and authorization expiry may need distinct internal results. The goal is not to flatten provider behavior. It is to translate each behavior into terms the application deliberately owns.
Replacing an SDK does not complete the boundary
A retained migration removed several vendor SDKs while preserving existing client and factory seams. For one payment integration, callback extraction and MAC verification moved into owned code. Existing driver tests continued to pin charge and refund behavior.
That is useful progress. The application controls transport code and no longer depends on the SDK for callback verification.
The driver still reads provider-shaped arrays and objects. It compares provider status strings, multiplies a decimal amount by one hundred, reads the remote reference and transaction fields, and parses the remote timestamp.
So the retained change proves a partial boundary:
owned:
transport
request construction
callback extraction
signature verification
still leaking into payment driver:
provider status vocabulary
provider response shape
provider monetary units
provider timestamp fieldsCalling this a completed anti-corruption layer would hide the most important remaining work.
Put unit conversion beside the source representation
Money translation is easy to scatter:
'amount' => $remote['amount'] * 100Now every caller must remember that this provider uses major units. A refund path may round differently. A later provider may already use minor units.
The mapper should declare:
source unit:
decimal major currency units
target unit:
integer minor currency units
rounding:
explicit for the supported currency scaleFor the synthetic EUR fixture, the string "7.79" maps exactly to 779.
That fixture does not claim every currency has two decimal places. Currency
scale belongs to the money policy, not a hard-coded multiplication copied
through adapters.
DATA-05 owns monetary arithmetic in depth. Here the point is ownership: external units must stop at the mapper.
Preserve both local and remote identity
The callback contains two useful identities.
The reference associates the callback with the local operation which initiated payment. The remote transaction identifier supports reconciliation, support, refunds, and provider queries.
Neither should replace the other:
operation_id:
which local intent is being completed?
external_transaction_id:
which remote record produced this result?Mapping them into named fields prevents later code from treating a provider transaction as the application’s durable identity or trusting a caller-controlled reference as proof of remote uniqueness.
Identity also participates in verification. The authenticated payload proves what the provider signed; the application must still confirm that the local operation exists, expects this currency and amount, and has not already reached an incompatible terminal state.
Contract tests should straddle the boundary
Transport tests alone prove the request URL, authentication, and wire body. Driver tests alone can hide a mapper behind mocks. The translation needs its own table:
valid signed completed callback
-> accepted result with integer minor units
tampered signed body
-> authentication rejection, no mapping
unknown status
-> explicit unsupported-state result
missing identity or time
-> shape rejection
valid remote result for mismatched local amount
-> business reconciliation failureThe retained tests establish signature extraction and selected payment behavior. They do not prove that the current driver has eliminated provider terminology, because it has not.
An adapter lets the application talk to a remote system. A mapper decides what that conversation means. The boundary is complete only when business code receives a trusted result in units, identities, states, and time semantics the application owns.