Production Change Request

Investigating the Payment Provider Refund Contract

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

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 merchantRefundReference does 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 RefundAttemptId will serve as that merchant reference.
  • RefundAttemptId and providerRefundReference remain 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, or NOT_FOUND.
  • PROCESSING remains unresolved and blocks another logical refund.
  • NOT_FOUND allows 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 providerPaymentReference to 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.
  • PaymentAttempt and RefundAttempt have similar reliability patterns but different business meaning.
  • We should reuse patterns, not force them into one generic entity.
  • PaymentService can 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.