A purchase can capture successfully while the workspace stays “provisioning.” Charging again does not fix that. Find the fulfillment record for the original order, and why it did not finish.

A payment domain model stores sale terms, execution, and money results as separate identities. Quote freezes the payable amount. Order stores what both sides confirmed. CheckoutSession and execution records manage the collection process. Payment is the local payment. Transaction stores the result of a specific money action. Provider objects attach through their own identity map. Refunds and fulfillment then point at confirmed purchase and money records. One paid field cannot replace those links.

These boundaries fit a self-serve SaaS that uses a third-party collector. Goods, currency, and checkout are already chosen. Combined payments, platform splits, or wallet balances add allocation objects; loosening the count between orders and payments will not carry that work.

Payment domain model: how order, execution, and money records connect

A business order says what the customer bought. A payment record says which money the system intends to collect for that purchase. An execution record says who started it, under which frozen conditions, and how far the process went. A transaction record says what authorization, capture, or refund happened on the channel. Names can differ. Responsibilities cannot mix.

“Payment order” and “payment ledger line” collide especially often. One system uses the first for a collection request. Another uses the second for a succeeded capture. Another uses Transaction for the whole commercial sale. When integrating, check creation time, amount meaning, and allowed state changes. Matching names are not a join key.

Local objectWhat it storesHow long the identity lasts
QuoteThis price, quantity, discount, tax, payable currency, and expiryAfter the quote is used or expires, history still explains the original amount
OrderConfirmed goods, entitlements, payer, beneficiary, and commercial termsAfter payment, delivery, and after-sale work
CheckoutSessionThe in-progress purchase, current selection, version, and return contextUntil the session completes, closes, or expires; not the only identity for every payment
CheckoutExecutionOne accepted customer payment or setup action and its frozen conditionsAfter the execution result is confirmed
PaymentAttemptA non-customer-present source, contract, and result, such as a renewalWith the period, order, and payment
PaymentAmount, currency, channel account, and result for that orderAfter refunds or a channel change, the original payment still exists
TransactionChannel identity and current result of one authorization, capture, or refundLater state changes still point at the same action
ProviderResourceExternal id under one channel account, environment, and object typeAfter the old channel stops taking new orders, existing transactions still resolve

This is a local design that splits customer checkout from background collection. It does not require every system to use the same table names. A small product can keep adjacent responsibilities in one service, but it still needs different identities and lifetimes. Splitting services does not create correctness. Inside one service, a new payment still must not overwrite an old failure.

An order links to a customer checkout session and execution, or to a background attempt; the payment then locates money actions through a provider resource
A checkout session can exist before the order. A background debit does not need a browser session. The two branches are not two collections of the same money.

One order can link to several local payments so a failed collection can be retried with history. That does not mean the customer should pay twice, and it does not mean installments or combined payments are supported. Why each payment exists, which funds complete the order, and how other succeeded amounts are handled must be recorded.

A quote freezes the payable amount; an order stores sale terms

The customer picks plan, quantity, currency, and discount on the page. The server builds a quote. The browser submits allowed options. It must not set the amount to capture. Otherwise shrinking amount in the request, or attaching someone else’s discount, can change what is collected.

A quote stores more than a total. It names the price version, quantity, discount, tax calculation, and expiry. While the quote is valid and the business allows the original terms, a later catalog change must not silently change the amount the customer already confirmed. When the quote expires or a key input changes, recompute and ask the customer to confirm again.

Take an annual SaaS list amount of 120.00 USD, a first-term discount of 20.00 USD, and no tax, shipping, balance credit, or FX. Payable is 100.00 USD. Amounts and ids are modeling values, not a live merchant quote.

Quote Q-100 stores list 120.00, discount 20.00, payable 100.00, currency USD, plus the annual service and first-term-only discount. Order O-100 cites that quote and snapshots goods and amounts at sale. A current Price id is not enough: later catalog names, entitlement copy, or price status can change, and the order still has to explain what was bought.

Store amounts as integers in minor units, with currency on the same record. Do not use binary floats. 100 USD is amount=10000, currency=USD. JPY and other currencies have different precision; conversion must read that currency’s rules. Stripe amounts and currencies(opens in a new tab)

An order also distinguishes payer, operator, and service beneficiary. An employee can click buy, a company can hold the bill, and a workspace can receive seats at the same time. One user_id cannot later answer who sees invoices after the employee leaves, who receives a refund, or which workspace should get entitlements.

After O-100 freezes commercial content, switching annual to monthly, or changing currency and quantity, cannot overwrite the order amount. An immutable commercial snapshot creates a new quote and order. The same checkout session can continue the purchase, but the old order still explains payment requests already sent. Whether those old requests can still succeed is a separate check.

Not every field change needs a new order. A billing-contact note that does not change party, tax, price, or delivery can update under its own rules. Whether a new contract is required depends on whether the customer’s purchase promise changed, not on whether any JSON field moved.

A checkout session stores choices; an execution records one payment

While the session is unexpired and identity checks pass, CheckoutSession can bring the customer back after a closed tab, a bank challenge, or a device change. It stores the current version, the order and execution being shown, and the confirmed purchase result. The page reads and displays those states. It does not own the final money result.

One session can try a card, then another allowed method. The customer can also change the plan and produce another order. A session id is good for restoring the purchase. It is a bad money-action id. Otherwise every page adjustment can break payment ownership, or drop a late notification onto a newer order.

CheckoutExecution stores one customer action the server accepted. At creation it freezes order, amount, currency, channel config, and method. Later session choices must not move those commercial conditions. Sequence numbers can show order inside a session. Sequence cannot decide which money actually succeeded.

The same execution also splits “still selected on the page” from “how far the money went.” A new method can deselect the old execution. A channel request may already have been sent, so the old money result still has to be received. Writing “replaced” as “the channel cancelled” drops a capture still in flight.

Save the local execution and its owner before calling the channel. With a local Payment and execution number, a lost create-resource response still tells recovery which operation to find. It does not guess from amount and email. When dispatch must be reliable, save the execution and the outbound command in one local transaction, then call the provider. A database rollback cannot undo a request already sent.

The execution target is not always an immediate collection. Binding a payment method for a free trial can be a setup action. Record it as setup, with the mandate or credential. Do not invent a zero-amount “capture succeeded.” An order that needs no charge is not the same fact as receiving zero from the customer account.

A Stripe Checkout Session has session status and payment_status, and it can attach a PaymentIntent, SetupIntent, or Subscription by mode. Completing the session can still mean no money has been collected. Stripe Checkout Session object(opens in a new tab)

Hosted pages, embedded forms, and payment elements only change how the customer execution is shown. They must not change local order terms or money ownership. Hosted versus embedded checkout decides who maintains the page. The execution record joins the page action back to the same purchase.

How Payment, Attempt, and channel objects correspond

Payment gives the local collection a stable id. It cites order, payable amount, currency, payer account, and channel config for order, refund, and support. The local payment exists before the channel returns a resource id. After a channel switch, the new payment has a new identity; the old payment keeps the old channel and environment.

Execution answers how that payment was started. A customer on the page needs session, version, return action, and current selection. A background renewal needs contract, period, mandate, and a system trigger. Do not force both into one Attempt with a pile of nullable fields.

In a split-execution local model, CheckoutExecution takes customer-present actions and PaymentAttempt takes non-customer-present actions such as renewals. Each cites a Payment. A background renewal must not fake a browser session. A checkout click must not create two records that both claim the same money execution.

Attempt is not a universal layer name. Airwallex PaymentAttempt sits under PaymentIntent and is a channel object. A local PaymentAttempt used for renewals is not the same row, and it does not prove every channel retries the same way. Airwallex Payments data model(opens in a new tab)

Stripe recommends one PaymentIntent per order or customer session, reused after an interrupted purchase, with at most one succeeded Charge. That constrains Stripe intent use. It does not require a new PaymentIntent on every HTTP retry, and it does not require deleting local execution history. Stripe PaymentIntent(opens in a new tab), Payment Intents workflow(opens in a new tab)

Whether a new local payment is created has to match provider reuse. Continuing the same PaymentIntent must still return to the original payment. Once a separate new payment has started, do not silently move the old channel money object onto the new payment. Otherwise old webhooks, refunds, and reconciliation see one external id pointing at different orders over time.

Name in a systemWhat it isNot the same as
Local OrderGoods and commercial terms you promised to deliverAny provider object also named Order
Stripe PaymentIntentStripe-side state of one payment intentEvery HTTP request or the local business order
Airwallex PaymentAttemptOne payment try under a channel intentA local PaymentAttempt used only for renewals
PayPal OrderA channel order that can authorize or captureThe full SaaS contract, entitlements, and fulfillment

PayPal Orders v2(opens in a new tab) treats authorize and capture as different actions. Map from action and result to local responsibility. Do not treat the whole PayPal Order response as your order table.

An external id’s identity scope includes merchant account, test or live, object type, and resource number. The same resource_id in different environments must not merge. Invoice, PaymentIntent, and Charge on one account can help locate a payment; they are not the same money action.

After a channel account is retired, the resource map still keeps the old identity. Bulk-rewriting old records to a new account sends lookups and refunds to the wrong account. New purchases use the new config. Original transactions stay on the original account. Whether lookup or refund still works after deactivation follows that channel’s in-flight rules.

The resource map joins those identities back to local owners. Merchant order number, idempotency key, channel object id, and event id stay separate: order number finds the purchase, idempotency reuses one request result, channel object id finds the remote resource, event id identifies one notification. One field doing all of that usually fails at retry or at the second webhook.

Transaction records a money action; it is not the ledger

Payment can wait, authenticate, fail, or succeed. Transaction corresponds to one money action. Succeeded authorization is permission to capture within a range. Capture moves authorized funds to an actual debit. Cancelling an uncaptured authorization is not refunding a captured amount. Even if the product only takes immediate capture, keep identities for the original succeeded amount and later refunds.

A transaction row is not write-once. An action can start as processing and later receive a reliable result, updating status, error, or channel resource on the same Transaction. Once an action is in a terminal state such as succeeded, refunded, or disputed, a late old status cannot reverse it. Later refunds or disputes are related new facts. Calling it a “ledger line” does not make that updatable row an immutable journal.

Commercial snapshots on order and execution explain the terms then agreed. Received events and sent commands explain evidence and actions. Transaction’s current status is for business lookup. Accounting entries explain asset, liability, revenue, or expense changes. They can cite each other. They cannot replace each other.

When a webhook arrives, verify the signature with that channel config’s secret and the raw body. A failed verification must not change any payment state. After verification, locate the remote resource by merchant account, environment, object type, and id, then return to the payment and money action. Event handling needs a stable event identity for idempotency. The same succeeded Charge mentioned by a payment notification, a lookup, and an invoice notice is still one capture. Different event ids are not several succeeded transactions. Looking up the same resource must not add captured amount again.

Authorization, capture, and refund on one payment cannot collapse into one row that keeps changing type. A refund has to point at the original succeeded action. The original capture amount and identity stay queryable. A Stripe Charge records capture and balance-transaction links separately; request success alone does not confirm those money states. Stripe Charge object(opens in a new tab)

If a status notice is missing or incomplete, keep “ownership not yet confirmed.” Do not claim a transaction because amounts match. Two customers paying 100 USD at the same time is normal. Close amount, time, and email can help an investigation. They cannot be an automatic money-assignment key.

How a 100 USD payment becomes usable service

Quote Q-100 lists annual service at 120 USD with a 20 USD first-term discount. After confirmation, order O-100 freezes 100 USD payable, a team workspace as beneficiary, and a company as payer. No money has been collected. Fulfillment has not run.

Session S-100 points at that order. The customer submits a card. The server accepts execution E-1, freezes the order, 100 USD, and the channel account, and creates local payment P-1. After saving the outbound command, it calls the channel and attaches the returned resource to P-1. If the execution needs extra authentication, the page only shows that action. It does not mark the order paid early.

When the channel confirms capture, the system finds money action T-1 from a verified event or a server lookup. Confirmation includes account, environment, amount, currency, and local owner, not only a status. T-1 records succeeded capture 100 USD. P-1 records payment succeeded. The order binds that succeeded payment. Money state becomes paid.

Only after the order has a money result does fulfillment start annual entitlements for the target workspace. Delivery F-1 stores order, workspace, entitlement version, and current result. Money confirmation and fulfillment need a durable handoff. If the process dies between them, work continues from the saved fulfillment request.

How far the purchase has goneCommercial contractMoney stateFulfillment
Customer confirmed, not yet paidPending sale, terms frozenUnpaidNot delivered
Card payment waiting for authenticationPending sale, original termsUnpaid; execution waiting to authenticateNot delivered
Verified succeeded capture 100 USDSoldPaid, bound to P-1In progress
F-1 provisionedSoldPaidDelivered
Later succeeded refund 30 USDOriginal sale terms keptPartially refundedAfter-sale rules apply separately

If F-1 fails because the product service is down, P-1 and T-1 still succeeded. Recovery retries fulfillment for an already-paid order. The customer does not pay again. The system does not create another collection to push delivery.

Fulfillment must be idempotent by business effect. One purchase grants one annual entitlement set, constrained by order, entitlement object, or a stable delivery identity. Repeated events, a refreshed browser, or a notice arriving with a lookup must not add another year. Refunds, redelivery, and human support cite the same confirmed purchase result.

Stripe’s automatic fulfillment docs require handling the same Checkout Session called more than once, including concurrently, and storing fulfillment state. A success page is not enough: the customer can pay and lose the network before the return page. Delayed methods can still wait for a final payment result after the session completes. Stripe fulfillment(opens in a new tab)

Success-page copy therefore has an explicit source. “Confirming payment” comes from the server still checking money. “Paid, provisioning” comes from a succeeded money record and in-progress fulfillment. “Service available” comes from the actual provision result. Three prompts on the page map to different objects, so support can find the owner along the same order.

Replay, a new payment, and a price change leave different records

If the customer clicks pay and gets no response, the server looks up the original request identity. If that idempotency key already accepted E-1, it returns the original execution and any continue action. It does not create E-2. The original request may have reached the channel with only the HTTP response lost. Sending the request again is not the same as charging the customer again.

An idempotency key also binds request meaning. If order, amount, currency, or payment choice changed under the same key, the server rejects it as a replay of the original request. Otherwise a customer who switched to annual service can receive a monthly payment. The scope also includes the needed user or authorization context. Another customer must not recover someone else’s execution with the same string.

When the channel clearly declined and a new payment is allowed, the new action can have a new execution identity. If commercial terms are unchanged, the order is still O-100. In a local design that creates a Payment per independent collection, new execution E-2 cites P-2. The failure reason on E-1 stays in history. Clearing the error field must not pretend it never failed.

If the server rejected because the quote expired before the request was sent, there is no new channel capture to look up. If the request was sent and the connection timed out, the money result is still unknown. Only a clear channel decline of this capture supports a new try under that method’s rules. Storing one failed without the stage makes all of those cases share one dangerous “pay again.”

A request replay keeps the original order and execution; an independent new payment keeps the order and creates an execution; a commercial change creates a new order and execution
A “new payment” requires the original money state and channel contract to allow another capture. A timeout by itself does not.
What changedWhat to reuseWhat needs a new identity or reconfirmation
Same request sent again after a lost responseOriginal order, execution, local payment, and request resultDo not create a second money obligation from a network retry
Same execution continues a bank challengeOriginal execution and paymentUpdate follow-on actions and verified results; do not move ownership
Independent new payment allowed, commercial terms unchangedFrozen business orderNew execution and payment identity; create or attach resources by channel rules
Plan, quantity, currency, discount, or tax-affecting terms changedRecoverable purchase sessionNew quote and commercial order; close or reconcile the old payment separately
Another provider or merchant accountUnchanged commercial contractNew channel execution and resource scope; keep old-channel history

“The customer changed method” does not by itself allow a new capture. If the old execution is still waiting on a bank result, look up, recover, or close the old action the way the channel supports. A close-request response can also be uncertain. Sending cancel is not cancel succeeded. Open a new money execution only after the old action cannot collect, or after the business has an explicit way to handle two successes at once.

Workspace, commercial party, currency, amount, entitlement, or discount can change the contract. Switching light to dark theme, or refreshing the return page, must not create a new order. A commercial fingerprint covers only terms that affect the sale. Display settings stay on the session or page.

A late success on an old payment cannot keep only the new result

E-1 timed out. The page moved to E-2. The old channel then reports P-1 succeeded. Even if the page no longer shows E-1, that money still belongs to the original payment. Do not drop the event. Do not hang P-1 under E-2.

Locate P-1 by original channel account, environment, resource type, and id. Verify the 100 USD succeeded money action and store the event source. Deselection is session state. If money succeeded, the money record must say so. Old timeout or local-failure notes can stay as history. They cannot override a later confirmed capture.

Event time and local receive time are stored separately. A late failure notice arriving after success cannot overwrite success because it was “received last.” A refund or dispute after success registers a new money or after-sale fact. “Success is immutable” cannot ignore those. State moves by object type and fact content, not by message arrival order.

If P-2 has not succeeded, current commercial terms decide whether P-1 completes the original order, and whether another still-runnable collection is blocked from a duplicate result. Whether stopping P-2 succeeded still has to be verified.

If both P-1 and P-2 succeeded, the system received two amounts. A contract that should be charged once gets one valid fulfillment. The second amount enters an explicit extra-collection path: refund or human review after verification. Do not change the order amount from 100 to 200 to hide the gap. Do not delete a transaction to make the report look clean.

If the old payment belongs to a commercial order that was already replaced, it cannot directly provision the new plan. Confirm original goods, amount, customer consent, and current intent, then settle the old sale or return the funds. A record that proves where money came from does not authorize using it for another purchase.

When automatic repair cannot confirm resource ownership, amounts disagree, or several successes conflict, stop new money actions and keep a locatable human-review record. Recovery is done when every succeeded amount has a clear owner, the order has only the fulfillment it should, and extra funds have a traceable handling result. The page no longer showing an error is not enough.

Where refunds, renewals, and bank credit attach

If the customer is later allowed a 30 USD refund, local refund R-1 cites P-1 and succeeded capture T-1. After the refund request is sent, R-1 can still be processing. When the channel confirms return, refund money action T-R1 records succeeded 30 USD. Original T-1 is still succeeded capture 100 USD. It is not rewritten as a 70 USD payment.

After succeeded capture 100 and succeeded refund 30, receipt after refunds is 70 USD. A processing refund cannot be counted as already returned, and it cannot free its hold so another concurrent refund reuses the same refundable amount.

While R-1 asks for 30 USD with an unknown result, succeeded refunds are still zero, but the amount that can still be requested is at most 70. Another request for 80 USD is refused or reduced. It cannot pass because “succeeded refunds are zero.” Only after lookup confirms the original action failed and will not refund is the 30 USD hold released. A lookup timeout keeps the original request. Otherwise two successes can refund more than the original capture.

The original capture keeps a 100 USD succeeded record; a refund cites that action and returns 30 USD; fulfillment uses a separate state
70 USD is capture minus succeeded refunds. It excludes channel fees and is not a bank credit.

Stripe allows several partial refunds on one Charge, totaling no more than the original capture. A Refund cites the original Charge or PaymentIntent. Uncaptured authorization and succeeded capture use different cancel or refund paths. Stripe refunds and cancellations(opens in a new tab)

How refund amount changes entitlements is a separate business rule. Refunding an add-on can reverse only that entitlement. A goodwill refund can keep service. A full-order refund can end the subscription. The refund service must not wipe workspace permissions from “refund succeeded” alone. After-sale decisions and target entitlements have to return to order lines and actual fulfillment.

The next year’s renewal also cannot rewrite O-100. The first-term discount applies once. If the next term bills 120 USD, that period gets a new receivable and order, and a background PaymentAttempt plus a new Payment fire at the agreed time. The old order still explains the first-year 100 USD and that 30 USD refund. A failed renewal does not mark the first-year payment failed. Whether one customer may hold several active subscriptions is a product-contract rule. A channel adapter converts provider capability and results. It does not decide subscription count.

A background debit also needs an applicable mandate. Saving a usable card credential is not consent to charge any amount at any time. The execution cites applicable contract, period, and mandate range. When the provider hosts billing, map its invoice and payment facts back to the local period. Do not also let a local cron charge the same fee.

Money reaching the merchant bank still passes provider balance, fees, and settlement. Pricing currency and settlement currency can differ. FX, refunds, fees, and account adjustments can change the net. Finance cannot match the order’s 100 USD to a bank credit of the same number.

Stripe Balance Transaction records amount, fee, net, and when the balance becomes available. available_on is availability in the Stripe balance, not bank credit. Stripe Balance Transaction(opens in a new tab) Connecting the original succeeded action to fees, balance changes, and later payouts explains the gap between customer payment and bank credit. One local Transaction status cannot replace that money check.

Follow an order number to money and service results

If order O-100 is paid and the workspace still shows unprovisioned, follow the bound payment to P-1 and succeeded money action T-1, then fulfillment F-1. If 100 USD is confirmed and order and beneficiary match, while F-1 stopped on a product-service call, recover that fulfillment. Do not call collection again.

After F-1 recovers, the original succeeded payment does not increase, the original order does not change identity, and the workspace gets one agreed entitlement. The customer refresh moves from “paid, provisioning” to “service available,” not to a form that asks to pay again.

When this payment domain model is in the database, one business order can reach the frozen quote, each execution, provider resources, succeeded or failed money actions, refunds, and fulfillment. Any place that still depends on the current page, a vague status, or a guessed join is missing an identity that should be stored. Add it, then verify with lost responses, duplicate notices, and failed fulfillment: records can continue, and captures and deliveries already made cannot be manufactured again.

How Payment, Attempt, and Transaction amounts stay separate is the next job.