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 Card
However, 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.
Multiple payment methods appear in many common business situations.
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.
A service costs $700.
The customer has a gift card worth $100 and wants to pay the remaining amount with a credit card.
Gift Card: $100
Credit Card: $600
Total: $700
The 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.
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,000
The 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.
A single payment may include all of the following:
a card presented through a POS device;
a stored credit card;
cash;
an internal wallet;
a 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.
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: $50
The 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 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.
One of the most useful lessons I learned was to group payment methods by operational behavior rather than by name.
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
↓
CONSUMED
If the payment flow fails before completion:
RESERVED
↓
RELEASED
Reserving 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.
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;
or produce a delayed final result.
A timeout therefore does not necessarily mean that the payment failed.
In some cases, the correct local status is:
UNKNOWN
The system should query the third-party provider or wait for reconciliation rather than immediately attempting another charge.
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_REQUIRED
Only after an employee has physically returned the money should the status become:
CASH_RETURNED
Automatically marking cash as refunded when the backend catches an exception would make the database inconsistent with what actually happened in the real world.
There is no universal processing order that works for every system.
The priority should depend on factors such as:
whether the payment method supports reservation;
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 order
This 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 necessary
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 actions
On the client side, the application displayed a loading view or waiting status after important stages.
For example:
Connecting to the payment device
Please tap, insert, or swipe your card
Confirming your payment
The 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;
or a 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 completed;
manual assistance is required.
A generic “Payment failed” message is often not enough.
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
COMMIT
A 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.
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 COMPENSATED
A real payment workflow is therefore closer to:
Local Transactions
+
External Side Effects
+
Compensation
This resembles Saga orchestration more than a traditional database transaction.
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 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 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.
The POS refund succeeds, but the refund for a stored card fails.
A single status such as FAILED is 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_REQUIRED
Each step should have its own:
status;
idempotency key;
retry policy;
audit log;
execution timestamp;
provider reference;
failure reason;
and manual-review process.
A background worker can execute these compensation steps asynchronously and retry them safely when necessary.
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.
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;
or cancel the purchase entirely.
The flow may look like this:
PAYMENT_CREATED
↓
WAITING_FOR_CARD
↓
CANCELLED
The 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 device
This 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.
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.
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.
The system must protect against cases such as:
two webhook handlers processing the same payment;
the client cancelling at the same moment a success webhook arrives;
two workers processing the same payment component;
an order 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.
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.
A production payment system should not depend entirely on webhooks.
A scheduled reconciliation process should compare:
Third-Party Transactions
↔
Local Payment Ledger
When a discrepancy is found, the system can create an alert, repair task, or manual-review case.
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.
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.