Production Change Request
Investigating the Payment Provider Refund Contract
In the previous lesson, we identified the biggest blocker to implementing refunds:
we do not yet know
how the Payment Provider handles refunds
That matters because refund correctness depends on questions such as:
Can the provider safely deduplicate retries?
What identifies the original successful Payment?
What identifies the refund operation?
What happens after timeout?
Can we query the outcome later?
We should not design:
RefundAttempt states
retry behavior
reconciliation
provider DTOs
until those questions are answered.
So before writing application code, we perform a focused provider investigation.
Investigation Goal
The goal is not:
learn every feature
of the Payment Provider
We need only enough information to implement the production requirement safely.
Our required capability is:
Issue one full refund for an existing successfully paid Order while preventing duplicate financial effects and safely handling uncertain provider outcomes.
That determines the questions we investigate.
What We Need to Learn
The investigation covers:
1. How is a refund initiated?
2. What identifies the original payment?
3. Does the provider support an
application-supplied idempotency reference?
4. What identifies the refund afterwards?
5. What does confirmed success mean?
6. What does confirmed rejection mean?
7. What happens when the HTTP call times out?
8. Can refund state be queried later?
9. How do duplicate requests behave?
10. Can the same payment be successfully
fully refunded more than once?
These answers will shape our application workflow.
Investigation Result
For this course scenario, the provider contract is now confirmed as follows.
1. Refund Requires the Original Provider Payment Reference
A successful Payment already has a stable:
providerPaymentReference
Example:
pay_01JY8V84C3KDX6Y2S1ZH73Q4MA
To refund the Payment, our application must provide that reference.
Conceptually:
refund(
providerPaymentReference,
...
)
The provider does not refund using:
OrderId
because OrderId is our application's resource identity.
The provider knows its own Payment identity.
Why This Is Important
The new refund feature depends on historical Payment state containing:
providerPaymentReference
for successful Payments.
This immediately gives us a production-readiness question:
Do all historical PAID Orders have a successful PaymentAttempt with the provider Payment reference required for refund?
That must be verified before rollout.
OrderId Is Not Enough
Bad provider request concept:
refund order O1
The provider does not understand our:
OrderId
The mapping is:
Order O1
↓
successful PaymentAttempt
↓
providerPaymentReference
↓
Provider Payment
The refund workflow must recover that relationship.
2. Refund Is Full-Amount for Our Product Scope
The provider API is capable of processing a refund against a successful Payment.
For this course's Product requirement, we use:
full amount only
Our application sends:
amountCents =
historical successful Order/payment amount
We do not accept an administrator-supplied refund amount.
Client Must Not Choose Refund Amount
Bad:
{
"amountCents": 500
}
for:
POST refund
That would introduce partial refund semantics we explicitly excluded.
Instead:
Order/payment history
↓
backend derives refund amount
The administrator requests:
refund this eligible Order
not:
refund this arbitrary amount
Historical Amount Is Authoritative
Suppose:
Order total = 12000 cents
That is the amount associated with the successful Payment.
Later Product prices change.
Refund remains:
12000 cents
Current Product price is irrelevant.
3. Provider Accepts a Merchant Refund Reference
The provider allows the caller to supply a stable:
merchantRefundReference
for each logical refund operation.
For our application this will be derived from a durable application-generated UUID.
Conceptually:
RefundAttemptId
=
7b18231f-57d1-465d-8484-2c9d57b1c462
and provider request carries:
merchantRefundReference =
7b18231f-57d1-465d-8484-2c9d57b1c462
This is critical for retry safety.
RefundAttemptId and Provider Refund Reference Are Different
Our system has:
RefundAttemptId
which identifies the durable application operation.
The provider eventually returns:
providerRefundReference
which identifies the refund in the provider system.
For example:
RefundAttemptId
7b18231f-57d1-465d-8484-2c9d57b1c462
providerRefundReference
ref_01JY8WC9W7RX0X4VR0CY5MJ2TA
Do not collapse these identities.
Identity Map
Conceptually:
OrderId
→ our Order resource
PaymentAttemptId
→ our payment operation
providerPaymentReference
→ provider's successful Payment
RefundAttemptId
→ our refund operation
providerRefundReference
→ provider's Refund resource
Each exists for a different reason.
4. Provider Refund Requests Are Idempotent by Merchant Reference
The provider confirms:
Repeating the same refund request with the same
merchantRefundReferencedoes not create another refund.
This is the most important provider capability for our retry strategy.
Suppose we send:
merchantRefundReference = RFA1
providerPaymentReference = P1
amountCents = 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.
Same Reference Must Represent the Same Refund Intent
The provider also requires that a merchant refund reference not be reused with different request content.
If:
RFA1
was originally associated with:
payment P1
amount 12000
then attempting:
RFA1
payment P2
or:
RFA1
amount 5000
is rejected as a reference conflict.
This is exactly the behavior we want from a financial idempotency boundary.
Do Not Generate a New Reference on Retry
Bad:
attempt 1
→ RFA1
timeout
retry
→ RFA2
The provider sees:
two different refund operations
and may process both if otherwise allowed.
Correct:
attempt 1
→ RFA1
timeout
retry/reconciliation
→ RFA1
The logical financial operation identity must remain stable.
5. Confirmed Successful Refund
A successful provider response includes:
providerRefundReference
providerPaymentReference
merchantRefundReference
amountCents
status = SUCCEEDED
Conceptually:
{
"refundReference": "ref_01JY8WC9W7RX0X4VR0CY5MJ2TA",
"paymentReference": "pay_01JY8V84C3KDX6Y2S1ZH73Q4MA",
"merchantReference": "7b18231f-57d1-465d-8484-2c9d57b1c462",
"amountCents": 12000,
"status": "SUCCEEDED"
}
Provider DTO naming remains infrastructure-specific.
Our application should receive a provider-independent result.
What SUCCEEDED Means
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.
Do Not Mark Success Before Confirmation
Bad:
create RefundAttempt
↓
mark Order/refund successful
↓
call provider
The provider could reject the operation.
Correct principle:
refund requested
↓
provider operation
↓
confirmed SUCCEEDED
↓
apply successful refund locally
with durable coordination around the remote call.
6. Confirmed Rejection
The provider may reject a refund and explicitly confirm that:
no refund occurred
Examples could include provider conditions such as:
payment not refundable
payment reference invalid
payment already fully refunded
Our integration layer maps provider-specific reasons into provider-independent application outcomes.
The important semantic is:
CONFIRMED_REJECTED
means:
The provider confirms this refund operation did not succeed.
That differs fundamentally from timeout.
Rejection Is Safe to Distinguish From Unknown
If provider says:
REJECTED
we know:
money was not refunded
If our HTTP call times out:
we do not know
Those must not become the same application result.
7. Timeout Produces Unknown Outcome
Suppose:
POST refund
↓
provider receives request
↓
our HTTP client times out
We cannot infer whether:
provider never processed request
or:
provider processed refund
but response did not reach us
Therefore timeout maps to:
UNKNOWN
not:
FAILED
This mirrors our existing Payment reasoning.
Unknown Is a First-Class Workflow Condition
Bad:
catch TimeoutException
→ mark refund FAILED
Why dangerous?
Because provider might already have:
returned the money
If another administrator starts a new refund operation with a new reference:
double-refund risk
Therefore:
An unknown refund outcome must remain unresolved until reconciled.
Connection Failure Can Also Be Unknown
What about:
connection reset
or:
read failure
after the request may have been transmitted?
Again:
we may not know
whether provider processed it
So the provider client should distinguish:
confirmed rejection
from:
communication failure with uncertain outcome
Do not flatten both into:
false
8. Provider Supports Refund Lookup
The provider exposes a lookup operation using:
merchantRefundReference
or equivalently resolves the stable refund operation reference.
Conceptually:
lookupRefund(
merchantRefundReference
)
This is the foundation of reconciliation.
Lookup Outcomes
For our course scenario, lookup can report:
SUCCEEDED
REJECTED
PROCESSING
NOT_FOUND
These are provider observations.
Our application will later map them to its own durable workflow state.
SUCCEEDED
Means:
refund definitely succeeded
We can safely apply local successful-refund state if not already applied.
REJECTED
Means:
provider definitively rejected
the refund operation
The application can record confirmed failure.
PROCESSING
Means:
provider knows the refund operation
but has not reached a terminal result yet
We must not:
start another refund
while this remains unresolved.
NOT_FOUND
This result needs careful interpretation.
NOT_FOUND means:
provider currently has no refund operation
for this merchantRefundReference
But what we do with that result depends on where we are in the workflow.
We should not automatically create a new RefundAttemptId.
The same logical refund operation should keep the same reference.
Can We Retry After NOT_FOUND?
For our course contract:
Yes, the same refund request may be resubmitted using the same
merchantRefundReference.
Because the provider idempotency reference remains stable:
RFA1
the retry still represents:
the same logical refund
not a new refund.
This is far safer than generating RFA2.
9. Provider Supports At Most One Successful Full Refund Per Payment
The provider confirms that a successful Payment cannot be successfully fully refunded twice.
After a successful full refund:
Payment P1
→ fully refunded
another full refund request against P1 is rejected.
This is useful defense in depth.
But our application must not depend exclusively on it.
Why Application Protection Is Still Needed
Even if the provider rejects a second full refund:
we still need local correctness
Our application should prevent:
two admins
→ two refund workflows
and should preserve:
one durable refund history
Provider protection is not a substitute for application invariants.
Defense in Depth
We will eventually have multiple layers:
Order/payment eligibility
plus:
one unresolved local RefundAttempt
plus:
stable RefundAttemptId
plus:
provider idempotency
plus:
provider full-refund protection
These layers reinforce each other.
10. Provider Refunds Are Not Synchronous Database Transactions
The refund HTTP response may take:
network time
and can fail independently of PostgreSQL.
Nothing in this investigation changes our existing rule:
do not hold DB transaction open
while calling Payment Provider
Refund must be coordinated in phases.
Provider Contract Summary
We can now express the external behavior clearly.
Request
Provider needs:
providerPaymentReference
merchantRefundReference
amountCents
For our Product scope:
amountCents
=
full historical successful payment amount
Immediate Result
Provider operation may yield:
SUCCEEDED
REJECTED
UNKNOWN
where:
UNKNOWN
comes from communication uncertainty rather than a confirmed provider business status.
Reconciliation
Application can later query using:
merchantRefundReference
and receive:
SUCCEEDED
REJECTED
PROCESSING
NOT_FOUND
Retry Rule
When retrying the same logical refund:
reuse the same RefundAttemptId
/
merchantRefundReference
Never generate a new operation identity merely because:
HTTP response was uncertain
Provider-Side Idempotency Rule
same merchantRefundReference
+
same payment
+
same amount
→ same logical provider refund
Conflicting reuse is rejected.
Full-Refund Rule
one successful full refund
per successful provider Payment
A second successful full refund is not permitted.
What This Means for Historical Orders
The provider investigation reveals one hard prerequisite.
To refund an existing paid Order we need:
providerPaymentReference
Therefore historical eligibility is not merely:
Order.status == PAID
It is closer to:
Order is PAID
AND
application has a successful Payment record
with providerPaymentReference
This is an important distinction.
A PAID Order Without Provider Reference Is Operationally Problematic
Suppose historical data contains:
Order O1
status = PAID
but we cannot identify:
providerPaymentReference
We cannot safely call:
refund(P1)
because P1 is unknown.
We must not guess from:
CustomerId
amount
timestamp
Those are correlation clues, not provider payment identity.
Historical Data Audit Becomes Required
Before enabling refunds broadly, we need to verify:
every historical PAID Order
that should be refundable
has exactly one known successful
provider Payment reference
Any exception becomes:
data/operational remediation
not something the refund workflow should guess through.
What This Means for Our Application Model
We now know enough to identify a likely new durable concept:
RefundAttempt
because we need to persist:
application refund identity
OrderId
original successful Payment identity/reference
amountCents
current refund workflow state
providerRefundReference when known
But we still have not finalized its exact fields or enum.
That belongs to the next design lesson.
Why RefundAttempt Is Better Than Reusing PaymentAttempt
This investigation shows the semantic difference clearly.
PaymentAttempt:
charges money
for an UNPAID Order
RefundAttempt:
returns money
for a previously successful Payment
Although their reliability patterns are similar:
operation identity
provider reference
unknown outcome
reconciliation
their business meanings differ.
A separate model is now strongly justified.
Shared Pattern Does Not Require Shared Entity
We may reuse engineering ideas:
stable attempt IDs
short DB transactions
provider-independent result types
unknown state
reconciliation
without creating:
GenericFinancialOperation
or:
AbstractPaymentOperation<T>
Those abstractions are not required.
Duplication of a few explicit concepts can be better than premature generic finance infrastructure.
What This Means for PaymentService
We now know the provider capability includes both:
charge/payment
and:
refund
A reasonable application-facing external boundary may evolve toward:
public interface PaymentService {
PaymentResult pay(
PaymentRequest request
);
RefundResult refund(
RefundRequest request
);
RefundLookupResult getRefund(
RefundLookupRequest request
);
}
This is still conceptual.
Exact names and records come in implementation design.
But extending the existing external payment capability is now justified.
Why Not RefundRepository Calling Provider Directly?
Repository means:
local persistence boundary
Provider communication is:
external integration
So do not build:
RefundRepository.refundMoney()
That mixes responsibilities.
Likely structure remains:
RefundOrderUseCase
├── OrderRepository
├── RefundAttemptRepository
└── PaymentService
Provider DTOs Stay Below the Boundary
Suppose provider calls its fields:
original_txn_id
merchant_ref
refund_txn_id
Those names belong inside:
payment/integration
The UseCase should work with application concepts such as:
ProviderPaymentReference
RefundAttemptId
ProviderRefundReference
RefundResult
Provider vocabulary must not spread through Order Domain.
Provider Error Codes Stay Below the Boundary
If provider returns something like:
R17
or:
PAYMENT_ALREADY_REFUNDED
that exact provider code should be translated by the integration adapter.
The application should receive meaningful provider-independent outcomes.
This preserves the same isolation we already established for payments.
Timeout Configuration Can Be Reused
The existing provider adapter already has network configuration such as:
base URL
credentials
timeout
Refund calls use the same provider infrastructure.
We do not need a separate configuration system.
If refund requires a different timeout later, that should be introduced only with a real operational requirement.
Refund Result Should Not Be a Boolean
Bad:
boolean refund(...)
What does false mean?
provider rejected?
timeout?
connection failed?
still processing?
unknown?
A boolean loses critical financial semantics.
We need explicit outcomes.
Conceptual Immediate Result
A provider-independent result might eventually distinguish:
SUCCEEDED
REJECTED
UNKNOWN
The exact Java type comes later.
The important decision is semantic.
Conceptual Lookup Result
Reconciliation needs at least:
SUCCEEDED
REJECTED
PROCESSING
NOT_FOUND
Again, those are provider-independent application observations.
Why PROCESSING Matters
Suppose initial refund call times out.
Later lookup says:
PROCESSING
We know:
provider has the refund operation
Therefore:
do not submit another new logical refund
Wait/reconcile.
This prevents duplicate financial work.
Why NOT_FOUND Is Different
Lookup:
NOT_FOUND
means provider does not currently know RFA1.
Because our stable reference is reusable safely, we may resubmit:
same payment
same amount
same RFA1
No new RefundAttempt should be created merely for that retry.
Reconciliation Does Not Mean Automatic Infinite Retry
Provider lookup capability allows recovery.
It does not mean:
retry forever
We will need a bounded operational strategy eventually.
For the course workflow, the important correctness rule is:
unknown
→ do not start another logical refund
→ reconcile the same attempt
Exact scheduling policy can remain outside the first implementation if necessary.
We Do Not Need Kafka
The provider supports:
request
+
lookup
Our modular monolith can reconcile using application workflow and durable PostgreSQL state.
No requirement demands:
Kafka
event bus
distributed workflow engine
Do not introduce them.
We Do Not Need 2PC
We cannot atomically commit:
PostgreSQL
and:
Payment Provider
through a normal local transaction.
A distributed two-phase commit protocol is not supported or justified.
We solve the real problem through:
durable attempt state
stable external operation identity
idempotent provider semantics
reconciliation
New Provider Contract — Canonical Course Decision
From this lesson onward, assume the following refund-provider contract.
Original Payment
A successful Payment has:
providerPaymentReference
which is persisted locally.
Refund Request
Requires:
providerPaymentReference
merchantRefundReference
amountCents
Application Reference
merchantRefundReference
=
RefundAttemptId UUID
for our application.
Idempotency
Repeated request with:
same merchantRefundReference
+
same providerPaymentReference
+
same amountCents
resolves to the same logical provider refund.
Conflicting reuse is rejected.
Immediate Outcomes
SUCCEEDED
REJECTED
UNKNOWN
Provider Lookup
Lookup by:
merchantRefundReference
returns:
SUCCEEDED
REJECTED
PROCESSING
NOT_FOUND
Successful Result
Provides:
providerRefundReference
plus the associated payment/reference/amount.
Full Refund Constraint
A successfully fully refunded Payment cannot be fully refunded again.
What We Still Have Not Decided
Even after provider investigation, application design questions remain.
We have not yet finalized:
RefundAttempt states
RefundAttempt schema
whether OrderStatus gets REFUNDED
refund API endpoint
public Problem codes
concurrency claim strategy
how reconciliation is invoked
customer-facing refund representation
Those should now be designed using real provider semantics.
Risk Register After Investigation
Some risks are now better understood.
Double refund
Mitigated by potential combination of:
local operation coordination
stable RefundAttemptId
provider idempotency
provider full-refund protection
Still requires application design.
Timeout
Now understood as:
UNKNOWN
with provider lookup available.
This makes safe reconciliation possible.
Provider success + local failure
Still a real risk.
But because:
RefundAttemptId
remains stable and provider lookup exists, we can recover without issuing a new logical refund.
Historical refundability
Depends on persisted:
providerPaymentReference
for old successful Payments.
Requires data verification.
Concurrent administrators
Still needs local database coordination so two callers do not create two independent RefundAttempts.
Provider protection is defense in depth, not our only guard.
Backlog Impact
The investigation itself now has a concrete output.
We could represent it as completed technical investigation work such as:
Investigate Payment Provider refund capabilities
with findings:
full refund supported
stable merchant reference supported
idempotent replay supported
provider refund reference returned
unknown timeout possible
refund lookup supported
Now the refund implementation can move from:
blocked
to:
ready for workflow design
Engineering Principle
The first principle:
Never design financial retry behavior from HTTP intuition alone. Provider idempotency, stable references, and lookup semantics determine what retries are actually safe.
The second:
A timeout is not a failed refund. When the remote side effect may have happened, preserve an unknown state and reconcile using the same logical operation identity.
The third:
Provider idempotency and application idempotency complement each other. The provider prevents duplicate remote effects for one stable reference; the application prevents multiple independent refund operations from being created for one payment.
And:
Investigating an external contract before implementation is not ceremony. It is how we avoid encoding imaginary guarantees into financial workflows.
Summary
In this lesson, we resolved the Payment Provider questions blocking refund design.
The canonical course provider contract is now:
- A successful Payment persists a
providerPaymentReference. - Refund requires that original provider Payment reference.
- Our Product scope supports only full refunds.
- Refund amount is derived by the backend.
- Administrators do not supply arbitrary refund amounts.
- The provider accepts a stable
merchantRefundReference. - Our
RefundAttemptIdwill serve as that merchant reference. RefundAttemptIdandproviderRefundReferenceremain separate identities.- Repeating the same request with the same merchant reference is provider-idempotent.
- The same merchant reference cannot safely represent different refund content.
- A retry must reuse the same
RefundAttemptId. - Confirmed provider success means the financial refund completed.
- Confirmed rejection means the refund definitely did not occur.
- Timeout/ambiguous communication maps to
UNKNOWN, not failure. - The provider supports lookup using the stable merchant refund reference.
- Lookup may return
SUCCEEDED,REJECTED,PROCESSING, orNOT_FOUND. PROCESSINGremains unresolved and blocks another logical refund.NOT_FOUNDallows resubmission of the same logical operation using the same stable reference.- A successful provider result supplies a
providerRefundReference. - One successful full refund per successful Payment is permitted.
- Provider duplicate protection is defense in depth, not a substitute for application coordination.
- Existing historical PAID Orders need a persisted successful
providerPaymentReferenceto be automatically refundable. - Missing historical provider identity must not be guessed from Customer, amount, or timestamps.
- Refund needs durable workflow state, strongly suggesting a separate
RefundAttempt. PaymentAttemptandRefundAttempthave similar reliability patterns but different business meaning.- We should reuse patterns, not force them into one generic entity.
PaymentServicecan reasonably evolve to expose provider-neutral refund and refund-lookup capabilities.- Provider DTOs and provider-specific codes remain inside the integration adapter.
- Refund result must not be modeled as a boolean.
- PostgreSQL and the Payment Provider still cannot be one atomic transaction.
- We do not need Kafka, 2PC, Redis, or a new service to solve this requirement.
- The provider contract is now sufficiently defined for application workflow design.
Next lesson:
Designing Refund State and the Refund Workflow
We can now decide:
Does OrderStatus gain REFUNDED?
What exactly is RefundAttempt?
Which RefundAttempt states exist?
How do we guarantee one unresolved refund?
What are the local transaction boundaries?
When is the provider called?
How does confirmed success become durable?
What happens after local failure
following provider success?
How does reconciliation work?
How do two concurrent administrators behave?
That is where the product requirement and confirmed provider contract become the actual production design.
