When I first approached the problem of supporting multiple payment methods, I assumed it was mainly about splitting an amount across several sources.
For example, suppose a service costs $700. The customer has a $100 gift card and wants to pay the remaining $600 with a credit card.
The arithmetic is straightforward:
$700 = $100 Gift Card + $600 Credit CardHowever, once I implemented this flow in a real production system—one involving a POS device, stored credit cards, cash, an internal wallet, and gift cards—I realized that splitting the amount was only a small part of the problem.
The more difficult questions were:
Which payment method should be processed first?
What happens when one method succeeds but the next one fails?
Can a database rollback reverse a transaction that has already been created by a third-party payment provider?
If a request to the third party times out, how do we know whether the customer was charged?
What happens if the customer changes the product, price, or payment allocation while the POS device is waiting for a card?
What happens if a refund succeeds at the third party, but the backend never receives the response?
At that point, I realized that supporting multiple payment methods is not primarily an arithmetic problem.
It is an orchestration problem: coordinating payment methods with different behaviors while keeping the system consistent and ensuring that the customer never loses money.
Real-World Multiple-Payment Scenarios
Multiple payment methods are commonly used in various business situations.
Scenario 1: Splitting a Bill Across Different Methods
Two people share the same bill but want to pay separately.
Person A pays with a credit card.
Person C pays with cash.
The system must associate both payments with the same order or invoice while ensuring that their combined amount exactly matches the total amount due.
Scenario 2: Gift Card and Credit Card
A service costs $700.
The customer has a $100 gift card and wants to pay the remaining amount with a credit card.
Gift Card: $100
Credit Card: $600
Total: $700The happy path is easy. The difficult case occurs when the gift card has already been deducted but the credit card is declined.
The system must restore the gift card balance without creating duplicate credits or losing the audit history.
Scenario 3: Cash and Credit Card
A product costs $2,000.
The customer has $1,500 in cash and a credit card with a $3,000 limit. However, they want to preserve part of the card limit for another purchase.
They decide to pay:
Cash: $1,000
Credit Card: $1,000
Total: $2,000The system must record not only the amounts, but also who received the cash, when it was received, and what must happen if the credit-card portion fails.
Scenario 4: Several Payment Methods in One Flow
A single payment may include all of the following:
POS device;
Credit card;
Cash;
Internal wallet;
Gift card.
This is where the problem becomes significantly more complex.
One payment method may be fully controlled by the application. Another may require communication with a third-party provider. Cash may require manual confirmation from a staff member.
These methods cannot be treated as identical transaction steps.
The Real Problem Is Payment Orchestration
From the perspective of total amounts, a multiple-payment flow may look like a simple calculation.
In reality, the system needs to manage a payment plan containing several payment components.
For example:
Payment Plan
├── POS Card: $500
├── Stored Card: $300
├── Gift Card: $100
├── Internal Wallet: $50
└── Cash: $50The sum of all components must equal the order total.
However, validating the total is only the beginning. The system also needs to know:
Which component is currently pending?
Which component has succeeded?
Which component has failed?
Which component needs to be refunded?
Which component has an unknown status?
Has the entire payment plan been completed successfully?
For this reason, a payment plan should be modeled as a workflow with an explicit state machine, not merely as a list of transaction records created one after another.
Grouping Payment Methods by Behavior
One of the most useful lessons I learned was to group payment methods by operational behavior rather than by name.
Group 1: Internal Payment Methods
This group may include:
Internal wallets
Gift cards
Store credit
Loyalty balances
These methods are usually controlled entirely by the application.
They can often be designed around a lifecycle such as:
AVAILABLE => RESERVED => CONSUMEDIf the payment flow fails before completion:
RESERVED => RELEASEDReserving a balance is generally safer than deducting it immediately and crediting it back later.
Reservations reduce the number of compensating financial entries and make the transaction history easier to understand.
Group 2: External Electronic Payments
This group may include:
stored credit cards;
cards presented through a POS device;
other electronic payment methods processed by a third party.
These methods have an important characteristic: the application does not fully control their lifecycle.
A request sent to a third-party provider may:
Succeed
Fail
Time out
Succeed remotely while the response never reaches the backend
Generate the same webhook more than once
Produce a delayed final result
A timeout, therefore, does not necessarily mean that the payment failed.
In some cases, the correct local status is:
UNKNOWNThe system should query the third-party provider or wait for reconciliation rather than immediately attempting another charge.
Group 3: Physical Payments
Cash is the clearest example.
Cash cannot be refunded through an API.
If an employee has already received the money but another payment component fails, the system should create an operational requirement such as:
CASH_RETURN_REQUIREDOnly after an employee has physically returned the money should the status become:
CASH_RETURNEDAutomatically marking cash as refunded when the backend catches an exception would make the database inconsistent with what actually happened in the real world.
Determining Payment Processing Priority
There is no universal processing order that works for every system.
The priority should depend on factors such as:
Whether the payment method supports reservations
Whether it supports separate authorization and capture
The cost and complexity of issuing a refund
How easily the operation can be reversed
The probability of failure
The user experience
Operational procedures at the point of sale
And business rules
A useful general principle is:
Reserve early, commit late.
A possible flow could be:
1. Validate the entire payment plan
2. Reserve the internal wallet balance
3. Reserve the gift card balance
4. Authorize the stored credit card
5. Process or authorize the POS card
6. Confirm the cash payment
7. Capture external payments
8. Commit internal balances
9. Complete the orderThis approach reduces the need for refunds.
If a credit card cannot be authorized, the system can release the wallet and gift-card reservations without creating reverse financial transactions.
In practice, not every payment method supports separate authorization and capture.
A POS flow may also need to happen earlier because it requires direct interaction with the customer.
The goal is therefore not to establish one rigid order for every payment method. Instead, each payment method should define its own lifecycle:
Validate => Reserve or Authorize => Commit or Capture => Compensate if necessaryA Payment Flow I Implemented
In one real system, the payment flow looked roughly like this:
1. Create a POS payment and send it to the POS device
2. The customer taps, inserts, or swipes their card
3. The backend receives a successful webhook from the third party
4. Create and save the POS transaction
5. Process the customer's stored credit card
6. Process cash, wallet, gift card, and other payment components
7. Execute post-payment actionsOn the client side, the application displayed a loading view or waiting status after important stages.
For example:
Connecting to the payment devicePlease tap, insert, or swipe your cardConfirming your paymentThe client should not conclude that the overall payment has succeeded merely because the POS device displays a successful result.
The final status should come from the backend after it has received and processed the result from the third-party provider.
The client can receive updates through:
Polling
WebSockets
Server-Sent Events
Push notifications
Dedicated payment status page
An important detail is that webhook processing is asynchronous.
The original client request may have ended long before the webhook arrives. This means the system cannot rely entirely on throwing an exception back to the client.
Instead, exceptions should be converted into persisted domain states that the client can query or subscribe to.
For example:
{
"paymentId": "payment_123",
"status": "COMPENSATING",
"failedStep": "STORED_CARD",
"errorCode": "CARD_DECLINED",
"message": "One payment component failed. A previously collected payment is being refunded."
}
This also enables the user interface to distinguish between several very different situations:
The payment failed before any money was collected;
a payment failed, and compensation is in progress;
The refund has been completed;
Manual assistance is required.
A generic “Payment failed” message is often not enough.
Avoid Keeping a Database Transaction Open for Too Long
In an early version of the design, I considered wrapping all steps after the successful webhook in one large database transaction.
The idea appeared reasonable: if any step failed, all local data changes would be rolled back.
The problem begins when that database transaction also includes calls to a third-party system.
For example:
BEGIN TRANSACTION
Save POS transaction
Call third party to charge stored card
Update wallet
Update gift card
Update order
COMMITA third-party API call may take several seconds or eventually time out.
During that time, the database transaction remains open and may continue holding connections and row locks.
This can cause:
Long-running transactions
Deadlocks
Reduced throughput
Blocked requests
Difficult retries
And uncertainty about the actual external payment status
A safer design uses multiple short local transactions.
For example:
Transaction 1
- Record the successful POS result
- Update the payment attempt
- Create the work required for the next step
- Commit
External processing
- Call the third party
- Receive or resolve the result
Transaction 2
- Record the external result
- Update the payment state
- Commit
Database transactions remain important, but they should protect local data only.
They cannot turn an external provider into part of the same ACID transaction.
A Database Rollback Is Not a Payment Rollback
This was the most important lesson from the implementation.
Suppose the POS payment succeeds at the third party, but the stored credit card is later declined.
The database can roll back local changes.
However, rolling back the database cannot make the successful POS charge disappear from the third-party system.
The application now needs a compensation workflow:
Payment component failed
↓
Mark payment plan as COMPENSATION_REQUIRED
↓
Refund the POS payment
↓
Release the wallet reservation
↓
Release the gift-card reservation
↓
Handle the cash return if required
↓
Mark payment plan as COMPENSATEDA real payment workflow is therefore closer to:
Local Transactions + External Side Effects + CompensationThis resembles Saga orchestration more than a traditional database transaction.
Why Refunding Directly Inside a Catch Block Is Not Enough
A common implementation might look like this:
try {
beginTransaction()
savePosTransaction()
chargeStoredCard()
updateWallet()
updateGiftCard()
updateOrder()
commit()
} catch (error) {
rollback()
refundPos()
refundStoredCard()
throw error
}
This may work in simple cases, but it leaves several dangerous failure windows.
The Process Stops After Rollback but Before Refund
The database has already rolled back, but the refund has not been requested.
The customer remains charged, while the system may no longer have a complete transaction record describing what needs to be refunded.
The Refund Succeeds but the Response Is Lost
The third party completes the refund, but the backend times out before receiving the response.
If the system retries without using idempotency or checking the provider's current state, the compensation workflow becomes difficult to control.
Only Part of the Compensation Succeeds
The POS refund succeeds, but the refund for a stored card fails.
A single status such as
FAILEDis not enough to represent this outcome.
Refunds and reversals should therefore be stored as independent compensation steps:
POS_REFUND_REQUIRED
STORED_CARD_REFUND_REQUIRED
WALLET_RELEASE_REQUIRED
GIFT_CARD_RELEASE_REQUIRED
CASH_RETURN_REQUIREDEach step should have its own:
Status
Idempotency key
Retry policy
Audit log
Execution timestamp
Provider reference
Failure reason
Manual review process
A background worker can execute these compensation steps asynchronously and retry them safely when necessary.
Do Not Remove Evidence of a Payment That Actually Happened
Another dangerous design is rolling back the local transaction record for an external payment that has already succeeded.
If a POS charge truly occurred at the third party, the local database should preserve that fact.
Instead of deleting the record, its lifecycle should be updated:
POS CHARGE: SUCCEEDED
↓
REFUND REQUIRED
↓
REFUND PROCESSING
↓
REFUNDED
In the payment or accounting ledger, the charge and refund should be stored as separate movements:
CHARGE: +$500
REFUND: -$500
NET: $0
The original charge should not be rewritten as though it never existed.
This design is significantly better for:
Auditing
Reconciliation
Reporting
Dispute investigation
Partial refunds
Payment-fee tracking
And incident analysis
Allowing Cancellation Before the Customer Is Charged
Before the customer presents a card to the POS device, the payment flow can usually be cancelled safely.
The customer may want to:
Change a product or service;
Change the quantity;
Update the price;
Use a different payment method;
Change the allocation between payment methods;
Cancel the purchase entirely.
The flow may look like this:
PAYMENT_CREATED
↓
WAITING_FOR_CARD
↓
CANCELLEDThe application can then create a new payment attempt.
It is safer not to modify the existing payment plan while the POS device may still be displaying the previous amount.
A better approach is:
Cancel the old attempt
↓
Create a new attempt
↓
Send the updated amount to the POS deviceThis prevents a situation in which the POS device charges the old amount while the order has already been updated to a new amount.
Once the customer has presented the card, cancellation becomes more complicated.
The payment may already be processing.
A cancellation request does not guarantee that the payment has stopped.
The system needs to wait for a final state:
CARD_PRESENTED
↓
PROCESSING
↓
SUCCEEDED or FAILED
If the payment has been authorized but not captured, it may be possible to void it.
If it has already been captured, a refund may be required.
If the status remains uncertain, the system should reconcile the transaction before taking further action.
Additional Considerations
Idempotency
Idempotency is required at several layers:
client to backend;
backend to third party;
webhook processing;
refund and compensation workers.
Retrying the same logical operation must not create an additional charge.
Duplicate and Out-of-Order Webhooks
A webhook may be delivered more than once or arrive out of order.
The webhook handler should:
verify the signature;
store or recognize the provider event ID;
process events idempotently;
validate state transitions;
and never assume that an event will be delivered exactly once.
Concurrency Control
The system must protect against cases such as:
Two webhook handlers are processing the same payment;
The client is cancelling at the same moment a success webhook arrives;
Two workers are processing the same payment component;
An order is being modified while payment is in progress.
Possible techniques include:
Optimistic locking
Version numbers
Short-lived row locks
Unique constraints
And compare-and-set state transitions
The Outbox Pattern
Suppose the database commits successfully, but the process stops before publishing the next background task.
The payment flow may become stuck.
The outbox pattern helps by storing the state update and the next work item in the same local transaction.
Reconciliation
A production payment system should not depend entirely on webhooks.
A scheduled reconciliation process should compare:
Third-Party Transactions <=> Local Payment LedgerWhen a discrepancy is found, the system can create an alert, repair task, or manual-review case.
Critical and Non-Critical Post-Payment Actions
Not every action performed after payment should be allowed to fail the payment.
Critical actions may include:
Marking the order as paid
Committing an inventory reservation
Granting access to the purchased service
Non-critical actions may include:
Sending an email;
Publishing analytics events;
Sending notifications;
Updating a CRM system.
A failed receipt email should not cause the customer’s payment to be refunded.
Conclusion
Supporting multiple payment methods is not merely about splitting an amount.
The real complexity comes from orchestrating payment methods with fundamentally different behaviors.
An internal wallet may support reservation and release.
A gift card may allow the system to hold a balance temporarily.
Credit cards require communication with a third-party provider.
A POS device requires direct customer interaction and asynchronous processing.
Cash depends on manual confirmation by an employee.
A production-grade design should combine:
A payment plan
An explicit state machine
Short local database transactions
Reservation and authorization
Idempotency
Durable compensation
An append-only or immutable ledger
Retry mechanisms
Reconciliation
Audit logs
And client-side status tracking
The most important lesson I learned was this:
A database rollback is not a payment rollback.
Once money has moved through a third-party provider—or has been physically received by an employee—reversing the operation is no longer a simple rollback command.
It becomes a separate business process with its own state, failure modes, retries, and operational responsibilities.
If we treat multiple payment methods as nothing more than splitting an amount, we may build a flow that works on the happy path.
If we treat it as a distributed payment workflow, we can build a system that is resilient enough for real-world production use.