Payment, Attempt, and Transaction amounts cannot be added together. Payment stores owner and target amount for one local collection. Attempt stores the conditions and progress of one accepted operation. Transaction stores the result of one channel money action. A failed try followed by a succeeded retry can leave two execution records and only one amount received.
Keeping only the latest state on one row lets a retry overwrite the last failure, lets a refunded net hide the original capture, and leaves a back-office “success” that cannot say whether money was captured. Separate records answer how many times the customer tried, how much the channel actually captured, and how much was later returned.
Payment ownership starts from the business order. Quote, order, and fulfillment live in the payment domain model. Amount examples are synthetic, for a self-serve SaaS using a third-party collector. They exclude tax, fees, FX, dispute debits, and splits.
What Payment, Attempt, and Transaction each store
Payment cites order, payer, amount, currency, and channel account. It is the stable identity other local services use. Attempt records who started the operation, which frozen conditions were used, and whether it needs authentication, is still processing, or already has a result. Transaction cites a specific capture, authorization, or refund. It is not an alias for one HTTP request.
| Record | What 80 USD means | What stays when the result changes |
|---|---|---|
| Payment | Amount this collection is meant to take | Original order, currency, and channel owner; a refund net does not overwrite the target |
| Attempt or other execution | Amount this accepted operation used | Execution identity, method, failure reason, and later confirmed result |
| Transaction | Amount of one money action | Action type, channel resource identity, source action, and current status |
Separate responsibilities do not require a fixed “many Attempts under one Payment” tree. A channel PaymentIntent can hold several tries. Locally, Payment can mean one collection already bound to a channel, with one business order citing several Payments. The parent objects are not the same thing. Field names do not decide the foreign key.
A customer on checkout also needs session version, bank authentication, and return state. A background renewal needs period, contract, and mandate. CheckoutExecution can take the first. PaymentAttempt can take the second. Each cites a local payment. Do not create an Attempt with no job just to fill three tables.
Airwallex’s object model(opens in a new tab) splits PaymentIntent from PaymentAttempt. Stripe PaymentIntent(opens in a new tab) produces at most one succeeded Charge. Those rules describe those channels. They do not name local tables.
A network replay is not a new payment attempt
Order O-80 is payable 80 USD. The customer submits a card. The system accepts execution E-1 and, in a model with independent payment identity, cites P-1. After E-1 is sent, the HTTP connection drops. Local state does not know the result. The same request arriving again should recover E-1 and the original payment. An extra network request is not consent to pay another 80 USD.
Idempotency binds the original amount, currency, order, and payment choice. The same key with a different amount is a conflict. It must not return the old payment as a new operation. Completing 3-D Secure is also a continuation of the original execution, not a new collection.
If the channel clearly declined the first card payment and another method is allowed, a later confirmation can start E-2. Commercial terms are unchanged, so the order is still O-80. If that really becomes a separate channel payment, create P-2. E-1’s decline reason stays. P-1 does not become succeeded because P-2 succeeded.
After E-2 captures 80 USD, O-80 can complete payment with P-2. There are two executions, one failed payment, one succeeded payment, and succeeded captures still total 80. A failed Transaction that stored amount 80 must not enter receipts.
Changing method does not always change the channel intent. Stripe can continue payment on the original PaymentIntent in the right state. Allowed actions differ when it needs authentication, is processing, or needs a new payment method. Stripe PaymentIntent lifecycle(opens in a new tab) decides whether the original resource can continue. The local map must keep pointing at the original payment. Do not move the same resource onto another payment to look like “the second try.”
While the old execution result is unknown, a new card or channel does not cancel the original capture risk. Recover or check the original result first. Opening an independent new payment also needs a way to handle two successes at once. A new merchant account cannot look up or take over the old account’s payment. A change in amount, currency, or purchase content cannot keep using the old execution conditions.
Why authorizing 80 USD still leaves captured amount at zero
A pre-authorization usually holds customer available funds so a later capture can complete. It is not refundable money the merchant already received. Under a manual-capture contract, succeeded authorization of 80 USD with no capture yet can leave capturable amount at 80 and captured amount at zero.
A Stripe PaymentIntent is then requires_capture. Airwallex in manual-capture mode uses REQUIRES_CAPTURE for authorized and not captured. That is not a succeeded capture. Stripe place a hold(opens in a new tab), Airwallex payment statuses(opens in a new tab)
After a later capture of 80 USD succeeds, a money action that can enter receipts appears. Authorization 80 and capture 80 do not mean 160 received. They describe different stages: authorization amount says whether capture is still possible; succeeded capture says what was taken.
If only 60 USD should be taken, and Stripe’s ordinary single-capture mode applies, capture 60 and release the uncaptured 20. That 20 was not captured then refunded. Most payments cannot capture the remainder after a partial capture. Multiple captures need a method and account that support them. Authorization also expires; an expiry release is not a refund. Stripe partial capture(opens in a new tab)
Payment succeeded also cannot fill the order amount by itself. If the order is still payable 80 and only 60 was captured, 20 remains unless the customer and merchant legally changed the commercial terms. A succeeded channel payment does not by itself settle the whole order.
If the channel does not support manual capture, the API must reject that operation or keep it off the product. Defining a capture enum and stuffing “awaiting capture” into a generic status does not give the channel capture ability.
Refunding 20 USD leaves the original capture at 80 USD
After a succeeded capture of 80 USD, a 20 USD refund R-20 points at the original payment and source capture T-80. Refund money action T-R20 stores the channel refund identity and result. Original T-80 still holds succeeded capture 80. It is not rewritten as 60. Otherwise customer reconciliation and the next refund lose the original amount.
| Money result | Succeeded capture | Succeeded refund | Receipt after refunds |
|---|---|---|---|
| Authorize 80, not captured | 0 | 0 | 0 |
| Capture 80, no refund | 80 | 0 | 80 |
| Capture 80, succeeded refund 20 | 80 | 20 | 60 |
| Capture 60, release remaining authorization 20 | 60 | 0 | 60 |
Two paths to a 60 receipt have different after-sale bases. The first already refunded. The second did not. Storing only net_amount=60 and deleting history loses that. All amounts also stay in one currency and one source-capture scope. Limits from different payments must not mix.
Stripe refunds(opens in a new tab) allow several partial refunds totaling no more than the original capture. Concurrent local refunds also subtract requests already accepted whose result is unknown. After capturing 80, while a first 20 is processing, at most 60 more can be accepted. Succeeded refunds still being zero cannot allow another 80.
A refund lookup timeout only means this lookup got no result. It does not mean the original refund failed. Keep the 20 hold. Release it only after the original action failed and will not refund. Succeeded refunds and in-process holds stay separate so both “how much the channel has refunded” and “how much can still be requested” have answers. Actual card credit still follows the bank.
A late result can only update the record it belongs to
E-1 looks failed locally because of a timeout. Later the channel confirms P-1 already captured. That is still the original payment’s money fact. Find P-1 by channel account, environment, resource type, and id, then verify amount, currency, and order. Do not write the result onto P-2, which the page happens to show now.
Execution error text and money result are not mutually exclusive: the request did time out, and the capture did succeed. Keep where the timeout happened, the original result, and receive time, then update the current payment projection by state rules. Do not clear history so the operation looks clean.
A failure notice arriving after success must not overwrite a confirmed capture. Received later is not happened later, and not more authoritative. A refund after success updates the refund and totals. It does not send the original capture back to “payment failed.”
If both P-1 and P-2 succeeded, receipts are 160 USD. An order that should be charged 80 treats the extra success as extra collection and blocks duplicate fulfillment. Deleting a Transaction or keeping only the latest succeeded payment under-counts 80 in the report and does not return money to the customer.
The same capture can appear in a webhook, a lookup, and a billing notice. Verify the webhook on the raw body first, then process with a stable event identity. Those are several pieces of evidence, not several amounts of money. The transaction projection updates by determined channel-action identity. Event id identifies the notice. Each new event id must not mint a new succeeded capture.
Payment lookup and money totals need different reads
Support asking “why did the second try succeed” reads execution order, methods, and failure evidence. Asking “how much was received” reads succeeded money actions, not the sum of every Attempt amount. Both queries can start from the same order. They aggregate different objects.
Receipt after refunds sums confirmed, deduplicated captures or equivalent succeeded debits, then subtracts succeeded refunds. Authorized-not-captured, failed, cancelled, and in-process actions stay out of received amount. In-process refunds only affect how much can still be requested. Some channels express capture on a captured Charge; do not count both that Charge and a Capture mapped to it. Stripe’s Charge capture fields(opens in a new tab) confirm the money state.
A straight join-and-sum also duplicates amounts. If O-80 had two tries, then succeeded refunds of 20 and 10, joining both tries, both refunds, and the succeeded capture by order number yields four rows. The original 80 appears on every row and sums to 320. The two refunds each appear twice and sum to 60. The real result is capture 80, refunds 30, remaining 50.
Aggregate capture and refund by payment owner, currency, and unique money action first, then attach that aggregate to the order. Attempt history is queried separately, or aggregated as a set that does not enter the amount sum. SUM(DISTINCT amount) is not a fix: two legitimate 20 USD refunds have the same amount and are still two refunds. Dedup is by action identity, not by number.
Totals also need a time basis. After-sale amounts rolled up by the payment’s order are not the same report as money actions that happened today. Yesterday’s capture of 80 and today’s refund of 20 make today’s refund outflow 20. Do not drop that refund because the original capture is not today. Each query keeps the source-capture link and states whether it is by order scope or by money-action time.
A Transaction’s current result can be updated by later reliable facts. That is not an immutable accounting entry. Capture 80 with one succeeded refund of 20 leaves net receipt 60 before fees. It is not confirmed 60 revenue or a bank credit. Finance still checks fees, balance changes, settlement, and revenue rules. Stripe Balance Transaction(opens in a new tab) fees, net, and available time belong to another layer of balance checking.
Reading O-80 by Payment, Attempt, and Transaction should find the first failure, the second success, and the original capture’s money identity. With only one 20 USD refund, the detail page shows two tries, succeeded capture 80, succeeded refund 20, net receipt 60. Adding a later failed execution must not change those three amounts. If changing the try count changes receipt totals, check whether the join duplicated rows, then whether succeeded money actions were counted twice. Do not overwrite the original payment amount to balance the report.