Investigating the Payment Provider Refund Contract
The provider's refund and lookup calls, and what each answer means.
You are viewing a free preview lesson.
In the previous lesson, we identified the biggest blocker to implementing refunds: we do not yet know how the payment provider handles them. Until those questions are answered, we should not design RefundAttempt states, retry behaviour, reconciliation or provider DTOs. Module 9 was in the same position for payments: it built the boundary, the client and the failure types around a placeholder protocol (POST /payments, a merchantReference and an amount in, a status and a reference out) and deliberately left idempotency and status lookup open. So before writing application code for refunds, we run a focused provider investigation.
Investigation Goal
The goal is not to learn every feature of the payment provider. We need only enough to implement the production requirement safely:
Issue one full refund for an existing successfully paid Order while preventing duplicate financial effects and safely handling uncertain provider outcomes.
That requirement determines the questions we take to the provider's documentation and support team, the ten listed in 14.01. For this course scenario, the provider has now confirmed its refund contract, and the following sections go through it answer by answer.
1. A Refund Needs the Original Provider Payment Reference
A refund is requested with POST /refunds on the same provider, under the same payment.provider.base-url our payment client already uses. Its request names the payment to refund by the provider's own payment reference, the value our payment client received when the payment was approved:
POST /refunds HTTP/1.1
Host: localhost:9001
Content-Type: application/json
{
"paymentReference": "pay_01JY8V84C3KDX6Y2S1ZH73Q4MA",
"merchantReference": "7b18231f-57d1-465d-8484-2c9d57b1c462",
"amount": 12000
}
The provider does not refund by OrderId, because OrderId is our application's resource identity; the provider knows only its own payment identity. We already store that identity: PaymentResult.succeeded(providerPaymentId) carries it out of the adapter, and the payment workflow of 9.08 and 9.09 persists it in the provider_reference column of the Order's SUCCEEDED row in payment_attempts. The refund workflow must recover that relationship:
Drawing diagram…
This immediately raises a production-readiness question for the rollout:
Do all historical
PAIDOrders have a successfulPaymentAttemptwith the provider payment reference a refund requires?
We come back to it once the whole contract is known.
2. Full Refunds Only, With the Amount From History
The provider supports partial refunds: any amount up to what remains refundable on the payment. Our product requirement asks only for full refunds, so our application always sends the full amount of the successful payment, in cents like every amount the provider receives. The backend derives that amount; an administrator never supplies it. A request body such as {"amountCents": 500} on the refund endpoint would introduce exactly the partial-refund semantics we excluded. The administrator asks "refund this eligible Order", not "refund this arbitrary amount".
The historical amount is authoritative: the successful PaymentAttempt recorded amountCents when the payment started (9.08), and the refund sends exactly that value.
3. The Merchant Reference Is the Idempotency Key
The provider lets the caller supply a stable merchantReference for each logical refund operation, the same field name its payment endpoint uses. There is no separate Idempotency-Key header: the provider deduplicates by this reference and keeps it for as long as the payment exists. For our application, the reference is a durable, application-generated UUID, the identity of the refund attempt we will persist before calling the provider. RefundAttemptId 7b18231f-57d1-465d-8484-2c9d57b1c462 is sent as "merchantReference": "7b18231f-57d1-465d-8484-2c9d57b1c462". This is critical for retry safety.
Our RefundAttemptId and the reference the provider returns are different identities. The first identifies our durable operation; the second, such as ref_01JY8WC9W7RX0X4VR0CY5MJ2TA, identifies the refund inside the provider's system and exists only once the provider has processed it. Do not collapse them. Each identity in the refund story exists for a different reason:
| Identity | Owned by | Identifies |
|---|---|---|
OrderId | us | our Order resource |
PaymentAttemptId | us | our payment operation |
provider payment reference (pay_…) | provider | the provider's successful payment |
RefundAttemptId (the merchant reference) | us | our refund operation |
provider refund reference (ref_…) | provider | the provider's refund |
4. Repeated Requests Return the Same Refund
The provider confirms:
Repeating the same refund request with the same
merchantReferencedoes not create another refund.
This is the most important provider capability for our retry strategy. Suppose we send merchant reference RFA1 for payment P1 with amount 12000, and the response is lost. We may safely send the same logical request again with RFA1: the provider resolves it to the same refund operation and answers with that refund's current status and reference. If the first request is still being processed, the repeat answers "status": "processing".
The provider also requires that a merchant reference never be reused with different content. If RFA1 was first sent for payment P1 and amount 12000, a request with RFA1 for payment P2, or with RFA1 and amount 5000, is refused with 409 Conflict and {"code": "merchant_reference_conflict"}, and nothing is created. This is exactly the behaviour we want from a financial idempotency boundary: one reference, one refund intent.
It also tells us what a retry must not do. If attempt 1 sends RFA1, times out, and the retry sends a freshly generated RFA2, the provider sees two different refund operations and may process both. The correct retry, whether an immediate one or a later reconciliation, sends RFA1 again. The identity of the logical financial operation must remain stable, just as 9.04 asked for payments.
5. Confirmed Success
A successful response carries the provider's status and its refund reference:
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "succeeded",
"reference": "ref_01JY8WC9W7RX0X4VR0CY5MJ2TA"
}
For this contract, succeeded means:
The provider confirms that the refund operation completed successfully and the amount has been refunded.
Only this confirmed result allows our application to mark the refund as successful. The field names and the lowercase status belong to the provider; like approved and declined for payments, they stay in the integration adapter, which hands the application a provider-independent result.
6. Confirmed Rejection
The provider may reject a refund and explicitly confirm that no refund occurred. Such an answer is 200 OK with "status": "rejected" and a reason such as already_refunded, payment_not_refundable or unknown_payment. Our integration layer maps these provider-specific reasons into provider-independent application outcomes. The important semantic is:
A confirmed rejection means the provider confirms this refund operation did not succeed.
That differs fundamentally from a timeout. After rejected, we know the money was not refunded; after a timeout, we do not know. Those two must never become the same application result.
7. Timeouts and Broken Exchanges Mean Unknown
A timeout, a connection reset or a read failure after the request may have been transmitted is an unknown outcome, not a failure, exactly as for payments (9.03). The provider's refund documentation adds one more: a 5xx answer can follow a refund that was completed, so it proves nothing either. That is the refinement 9.02 expected the real contract to make to its starting assumption about 5xx.
The cautious rules of 9.02 and 9.03 therefore carry over to the refund call unchanged in spirit:
| What happened | Result for the application |
|---|---|
succeeded | confirmed success with the provider's refund reference |
rejected | confirmed rejection with a reason |
processing, an unrecognized status, an unreadable body | PaymentOutcomeUnknown |
Read timeout, I/O error, 5xx | PaymentOutcomeUnknown |
4xx (including the 409 reference conflict) | PaymentProviderUnavailable: the provider documents that nothing was created |
| Refused connection, unknown host | PaymentProviderUnavailable: nothing was sent |
The two exceptions are the ones pay() already throws, so the refund result is never a boolean: false could not tell a confirmed rejection from a timeout. Marking a timed-out refund as failed would let another administrator start a new refund with a new reference while the provider may already have returned the money. Therefore:
An unknown refund outcome must remain unresolved until reconciled.
8. The Provider Supports Refund Lookup
The provider exposes a lookup by our merchant reference, the foundation of reconciliation:
GET /refunds?merchantReference=7b18231f-57d1-465d-8484-2c9d57b1c462 HTTP/1.1
Host: localhost:9001
It answers with the same body as the refund call, or with 404 Not Found when it has no refund for that reference. These are provider observations, which our application will later map to its own durable workflow state:
| Lookup answer | Meaning | What we may do |
|---|---|---|
succeeded | The refund definitely succeeded | Apply the successful refund locally if not already applied |
rejected | The provider definitively rejected the refund operation | Record the confirmed rejection |
processing | The provider knows the refund but has no terminal result yet | Wait and reconcile; never start another refund |
404 (not found) | The provider currently has no refund for this merchant reference | Resubmit the same refund with the same reference |
processing matters after a timeout. If the first call timed out and a later lookup says processing, we know the provider has the refund operation, so submitting another logical refund would only create duplicate financial work. We wait and reconcile.
Not found needs careful interpretation, because what we do with it depends on where we are in the workflow. It does not justify a new RefundAttemptId. Since the provider's idempotency reference is stable, the same request may be resubmitted with the same reference: same payment, same amount, same RFA1. The retry still represents the same logical refund, which is far safer than generating RFA2, and no new RefundAttempt is created for it. A lookup that gets no answer at all (a timeout, a refused connection, a 5xx) is not "not found": it tells us nothing, and the attempt stays as it was.
9. At Most One Successful Full Refund per Payment
The provider confirms that a payment cannot be successfully fully refunded twice. Once P1 is fully refunded, another refund request against P1 is rejected with already_refunded. This is useful defence in depth, but our application must not depend on it alone.
Even with the provider's protection, we need local correctness. Our application should prevent two administrators from starting two refund workflows and should keep one durable refund history. Provider protection is not a substitute for application invariants. We will eventually have several layers that reinforce each other:
- Order and payment eligibility checks
- at most one unresolved local
RefundAttemptper Order - a stable
RefundAttemptId - provider idempotency by merchant reference
- the provider's full-refund protection
Nothing in the contract changes the rule from 9.05 either: the refund call happens outside any database transaction, coordinated in phases like payment.
The Contract We Build On
From this lesson onward, the course assumes the following refund contract. It is short enough to record as the investigation's output in the team's integration notes:
# Payment provider: refund contract
- Refund: `POST /refunds` with `paymentReference` (the provider's payment
reference), `merchantReference` (our `RefundAttemptId`) and `amount` (cents).
Same base URL, client and timeouts as payments.
- Scope: the provider allows partial refunds; we always send the full,
historical payment amount.
- Idempotency: the same `merchantReference` with the same payment and amount
returns the same refund; reuse with different content is `409 Conflict`.
- Result: `succeeded` (with the provider's refund `reference`) or `rejected`
(with a `reason`); a repeat of a refund still in progress returns `processing`.
- Uncertainty: timeouts, broken connections and `5xx` leave the outcome unknown.
- Lookup: `GET /refunds?merchantReference=…` returns `succeeded`, `rejected`
or `processing`, or `404` when the provider has no such refund.
- One successful full refund per payment; a second is `rejected`
(`already_refunded`).
When retrying the same logical refund, we reuse the same RefundAttemptId as merchant reference and never generate a new operation identity merely because an HTTP response was uncertain.
What This Means for Historical Orders
The investigation reveals one hard prerequisite. To refund an existing paid Order, we need its provider payment reference, so historical eligibility is not merely status = 'PAID'. It is closer to "the Order is PAID and the application has a successful payment record with a provider reference".
Our schema already guarantees half of that. The payment_attempts_success_has_reference constraint (9.08) means every SUCCEEDED attempt has a provider_reference, and the partial unique index allows at most one success per Order. What we must check is whether any PAID Order has no successful attempt at all, for example one marked paid by a manual data fix. We ran the audit on PostgreSQL 18 against the course schema with three test Orders, one of them paid without a successful attempt:
SELECT o.id, o.status
FROM orders o
WHERE o.status = 'PAID'
AND NOT EXISTS (
SELECT 1 FROM payment_attempts a
WHERE a.order_id = o.id AND a.status = 'SUCCEEDED');
id | status
--------------------------------------+--------
9d2e4f6a-1b3c-4d5e-8f7a-6b5c4d3e2f1a | PAID
(1 row)
Before enabling refunds broadly, this query must return no rows in production. Any row it returns becomes data or operational remediation, not something the refund workflow should guess through. We cannot safely call the refund endpoint for such an Order, and we must not guess its payment from the Customer, the amount or a timestamp: those are correlation clues, not provider payment identity.
A Separate RefundAttempt
We now know enough to identify a new durable concept, RefundAttempt, because we need to persist the application's refund identity, the OrderId, the original successful payment, the amountCents, the current workflow state and the provider's refund reference once known. Its exact fields and states belong to the next design lesson.
The investigation also shows why it should not be a reused PaymentAttempt. A PaymentAttempt charges money for an UNPAID Order; a RefundAttempt returns money for a previously successful payment. Their reliability patterns are similar (operation identity, provider reference, unknown outcome, reconciliation), but their business meanings differ, so a separate model is justified. We reuse engineering ideas, such as stable attempt IDs, short database transactions, provider-independent result types, an unknown state and reconciliation, without creating a GenericFinancialOperation or an AbstractPaymentOperation<T>. Duplicating a few explicit concepts is better than premature generic finance infrastructure.
Extending PaymentService
The provider capability we depend on now includes both payment and refund, so extending the existing outbound port is justified. A reasonable application-facing boundary evolves toward this:
<!-- fragment -->package com.tarkev.ordermanagement.payment.application;
public interface PaymentService {
PaymentResult pay(PaymentRequest request);
RefundResult refund(RefundRequest request);
RefundLookupResult lookupRefund(RefundAttemptId refundAttemptId);
}
This is still a sketch; the exact records come with the implementation design. The refund call does not belong in a repository either. A repository is the local persistence boundary, while the provider call is an external integration, so a RefundRepository.refundMoney() would mix responsibilities. The likely structure is a RefundOrderUseCase that depends on OrderRepository, a RefundAttemptRepository and PaymentService.
Below the boundary, everything stays as module 9 set it up. ProviderPaymentService and ProviderPaymentClient gain the refund call and its DTOs in payment/integration/provider/. Field names like paymentReference and merchantReference, statuses like processing and reason codes like already_refunded are translated there, and provider vocabulary does not spread into the Order domain or the use case, which works with our own concepts: the provider payment reference, RefundAttemptId, the provider's refund reference and the refund result. The refund call also reuses the existing RestClient, base URL and payment.provider.connect-timeout and read-timeout: we need no separate configuration system, and a refund-specific timeout arrives only with a real operational requirement. The local stub provider from 9.02 gains a /refunds context that answers with the documented JSON.
Reconciliation From the Lookup
The refund result distinguishes a confirmed success from a confirmed rejection, and the lookup result at least succeeded, rejected, processing and not found; the exact Java types come later. Because the provider supports a request plus a lookup, durable PostgreSQL state and an application workflow are enough to reconcile. The correctness rule for the first implementation is that an unknown outcome never starts another logical refund but reconciles the same attempt; the scheduling policy can come later.
Risk Register After Investigation
Some risks are now better understood:
| Risk | Status after the investigation |
|---|---|
| Double refund | Mitigated by local operation coordination, a stable RefundAttemptId, provider idempotency and the provider's full-refund protection; still requires application design |
| Timeout | Understood as an unknown outcome, and the lookup makes safe reconciliation possible |
| Provider success, local failure | Still a real risk, but with a stable RefundAttemptId and the lookup we can recover without issuing a new logical refund |
| Historical refundability | Depends on a successful payment attempt with a provider reference for every old paid Order; requires the data audit |
| Concurrent administrators | Still needs local database coordination so two callers do not create two independent refund attempts; provider protection is defence in depth, not our only guard |
Backlog Impact
The investigation has a concrete output, and the backlog records it as completed technical work:
# Investigate payment provider refund capabilities — Done
## Findings
- Full refund supported (partial refunds exist; out of our scope).
- Stable merchant reference supported; idempotent replay supported.
- Provider refund reference returned on success.
- Unknown outcomes possible after timeouts and 5xx answers.
- Refund lookup by merchant reference supported.
- Prerequisite: audit PAID Orders without a successful payment attempt.
The refund implementation moves from blocked to ready for workflow design.
Practice Exercise 1: Map the Answers
Solutions: lesson 14.14.
For each provider answer, give the result our integration hands the application (confirmed success, confirmed rejection, PaymentOutcomeUnknown or PaymentProviderUnavailable) and whether the workflow may later start a refund with a new RefundAttemptId:
200with{"status": "succeeded", "reference": "ref_01JYB4C2D6E8F0G1H3J5K7M9N2"}200with{"status": "rejected", "reason": "payment_not_refundable"}200with{"status": "refunded"}200with{"status": "processing"}502 Bad Gateway- No answer before the read timeout
409 Conflictwith{"code": "merchant_reference_conflict"}- Connection refused
Then two lookups for RFA1: what does 404 allow, and what does a lookup that times out allow?
Practice Exercise 2: The Refund Request for Every Paid Order
On the local database, inside BEGIN … ROLLBACK, insert four Orders: a PAID one with a SUCCEEDED attempt; a PAID one with only a DECLINED attempt; a PAID one with a DECLINED and then a SUCCEEDED attempt; and an UNPAID one with a DECLINED attempt. Write one query that lists every PAID Order with the paymentReference and amount a refund would send, empty where there is none. Predict the rows first. Which Order fails the audit, and why is the Order with two attempts fine?
Practice Exercise 3: A Retry That Looks Safe
A colleague's draft for the adapter retries once:
<!-- fragment -->RefundResult refundWithRetry(String paymentReference, long amountCents) {
try {
return client.refund(paymentReference, UUID.randomUUID(), amountCents);
} catch (PaymentOutcomeUnknown timeout) {
return client.refund(paymentReference, UUID.randomUUID(), amountCents);
}
}
Name three problems, using the contract above.
Knowledge Check
Question 1
Why can the refund request not identify the payment by OrderId?
Question 2
The provider supports partial refunds. Why does our refund endpoint still never take an amount?
Question 3
Why is a 5xx answer to a refund an unknown outcome and not a failure?
Question 4
A lookup for RFA1 answers 404. Why do we resubmit RFA1 instead of creating RFA2?
Question 5
The provider rejects a second full refund of a payment anyway. Why do we still need local coordination?
Knowledge Check Answers
Answer 1
OrderId is our identity; the provider knows only its own payment reference. The refund must name provider_reference from the Order's successful PaymentAttempt.
Answer 2
Product asked for full refunds only, and an amount in the request would introduce partial refunds through the back door. The backend sends the historical amount of the successful payment.
Answer 3
The provider documents that a 5xx can follow a refund that completed. Treating it as a failure could lead to a second refund while the money is already on its way back.
Answer 4
The provider deduplicates by merchant reference, so resubmitting RFA1 is safe even if an earlier request is still in flight. RFA2 would be a different refund operation that the provider could process as well.
Answer 5
The provider's rule rejects a second refund only after the first succeeded, and it gives two administrators race-dependent errors and two attempts. Our application must keep one refund operation per Order itself; the provider's rule is defence in depth.
Engineering Principle
Never design financial retry behaviour from HTTP intuition alone. Provider idempotency, stable references and lookup semantics determine which retries are actually safe.
The provider prevents duplicate remote effects for one stable reference; the application prevents several independent refund operations for one payment.
Summary
- A refund is
POST /refundswith the provider's payment reference, our merchant reference and the amount in cents; the payment reference is theprovider_referenceof the Order's successfulPaymentAttempt, andOrderIdalone is not enough. - The provider allows partial refunds, but we always send the full historical payment amount, derived by the backend; administrators never supply an amount.
- Our
RefundAttemptIdis the merchant reference and the idempotency key: the same reference and content return the same refund, conflicting reuse is refused, and every retry reuses the sameRefundAttemptId. - A confirmed
succeededis the only path to local success; a confirmedrejectedmeans no refund occurred; timeouts, broken exchanges and5xxanswers are an unknown outcome (PaymentOutcomeUnknown), never a failure or a boolean. - The lookup by merchant reference returns succeeded, rejected, processing or not found; processing blocks another logical refund, and not found allows resubmitting the same attempt, never a new one.
- One successful full refund per payment is permitted; the provider's protection is defence in depth, not a substitute for local coordination.
- Every
PAIDOrder needs a successful payment attempt before rollout; missing provider identity is remediated, never guessed from the Customer, the amount or timestamps. - Refunds get their own
RefundAttempt,PaymentServicegrows provider-neutral refund and lookup operations, and provider DTOs and codes stay in the adapter.
In the next lesson, we design refund state and the refund workflow: whether OrderStatus gains REFUNDED, what a RefundAttempt is and which states it has, and how the workflow behaves under failures and concurrent administrators.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.