Versioned checkout quotes help when a customer changes their cart after you have already sent a payment link. They add another item, the delivery fee changes, and the link still asks for the old amount. If your order reads the latest cart after payment, it can fulfil more than the customer paid for.
Let’s separate the editable cart from the amount offered for payment. Once a quote has an identity and version, the payment can refer to something that doesn’t change underneath it.
Freeze the payable proposal
A quote records the selected variants, quantities, unit prices, discounts, delivery, taxes where applicable, currency and total. Keep those amounts in minor units with a consistent rounding policy.
The quote has an ID, cart version, creation time and expiry. Treat its commercial fields as immutable. If the cart changes, produce another quote rather than editing the old one in place.
The customer can now be shown exactly what they are paying for. Store that displayed version alongside any confirmation so a later model interpretation can’t silently change the proposal.
Tie the payment to that quote
Create the payment attempt against the quote ID and expected total. Verify the provider’s current supported way to associate your reference with its payment object, and keep the mapping in your own database too.
Don’t reconstruct the payable amount from the current cart after a success event. That cart may represent a different request. Load the frozen quote associated with the payment attempt.
Payment services have their own object lifecycle. Use the relevant integration’s documentation to distinguish creation, processing and successful payment. Payment lifecycle reference.
Build a quote check
This local example creates a quote, changes the cart and verifies a payment against the original quote. It also rejects an expired quote and a mismatched amount.
import assert from 'node:assert/strict';
const cart={version:1,quantity:1};
const quote=structuredClone({id:'q1',cartVersion:cart.version,quantity:cart.quantity,total:1000,currency:'NGN',expiresAt:100});
cart.version++;cart.quantity=2;
function validate(payment, q) {
if(payment.quoteId!==q.id||payment.amount!==q.total||payment.currency!==q.currency)throw new Error('Quote mismatch');
if(payment.completedAt>q.expiresAt)throw new Error('Quote expired under this policy');
return {quantity:q.quantity,quoteId:q.id};
}
const payment={quoteId:'q1',amount:1000,currency:'NGN',completedAt:90};
assert.equal(validate(payment,quote).quantity,1);
assert.equal(cart.quantity,2);
assert.throws(()=>validate({...payment,amount:2000},quote));
assert.throws(()=>validate({...payment,completedAt:101},quote));
assert.equal(quote.cartVersion,1);
console.log('PASS: payment binds to immutable quote, amount, currency and expiry policy');
The old quote still describes one item. Editing the cart doesn’t turn an old payment into permission to fulfil the newer cart. The assertions test the local association, not a provider’s payment-link behaviour.
Decide what superseding a quote means
Creating a new quote doesn’t automatically make an old link impossible to pay. You may need to expire or disable the provider object through its supported mechanism, and that operation can race with payment.
Define a policy for late success on a superseded quote. You might honour that exact quote when stock remains, ask the customer to resolve the difference or refund under your business rules. Don’t silently reinterpret it as partial payment for the new cart.
Keep a payment-attempt table that records every quote/payment association and outcome. A unique current-quote pointer on the cart isn’t enough to investigate an older link that was paid later.
Quote expiry and payment timing need a policy
Some payment methods finish after the customer initiated them. Decide whether quote validity is assessed at creation, customer initiation or confirmed success, and whether the provider gives evidence for that event.
The example checks a supplied verified completion time against expiry. That is an illustrative strict policy, not a universal rule. Your deployed integration must use trustworthy provider timing and the policy you communicated to the customer.
Stock reservations can have a different expiry from price quotes. Record both and resolve the case where one remains valid after the other expires.
Keep discounts reproducible
Store the applied discount and rule version in the quote. Re-running today’s promotion rules against yesterday’s paid order can produce a different total.
If a promotion depends on eligibility, verify it when the quote is created and decide whether it must be rechecked at payment. Keep the outcome and evidence appropriate to your product rather than copying personal data into every quote.
For financial calculations, define rounding at line and total levels. Binary floating point is a poor default for currency arithmetic. The fixture uses integer amounts so its equality checks are meaningful.
Test the old link deliberately
Create a quote, edit the cart, then pay the old attempt. Confirm that the application loads the old quote and applies the explicit late-payment policy. Repeat the success event and verify that fulfilment isn’t duplicated.
Also test a changed currency, discount removal, changed delivery fee and payment after a reservation expired. Check the resulting customer message as well as the database state.
When support opens the order, show the paid quote and the newer cart separately. That makes the discrepancy understandable instead of presenting a total that appears to have changed without explanation.
Start by making quote contents immutable. Once a payment refers to a stable proposal, cart edits become a normal versioning problem rather than a source of unexplained charges and fulfilment errors.
Run the example and adapt it
Save the code as 17-immutable-checkout-quotes.mjs and run it with Node.js 24 or later:
node 17-immutable-checkout-quotes.mjs
The code above includes assertions you can run locally. It tests the local decision logic with controlled inputs; it doesn’t connect to a live provider or establish production performance. Keep that boundary when adapting it to your application.

