You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adds a hook system to the payment flow so applications can compute taxes, shipping, discounts, gift cards, and any other monetary adjustment on top of the cart subtotal — without forking or wrapping payment adapters.
Why
Today the cart subtotal flows straight from items × quantity × price into the payment provider. There's no way to insert the adjustments that real-world commerce requires. Adapters are linear and closed.
What
A three-hook pipeline, an opt-in persistence model, and a typed summary returned from the API.
Hooks
Hook
Purpose
beforeInitiatePayment
Append lines (tax, shipping, discount) to the running Summary. Throwing aborts payment.
beforeConfirmOrder
Pre-confirmation checks (e.g. re-validate a gift card). Throwing aborts.
afterConfirmOrder
Side effects (emails, external systems). Errors logged, do not fail the response.
Hooks are defined at two levels. Plugin-level hooks (payments.hooks) run for all payment methods; adapter-level hooks (paymentMethod.hooks) run only for that adapter. Plugin-level runs first.
The Summary model
typeSummary={total: number// always recomputed by the plugin — never trust what a hook setscurrency: string// locked — cannot be changed inside a hooklines: Line[]// lines[0] is always the cart subtotal, managed by the plugin}typeLine={type: 'subtotal'|'tax'|'shipping'|'discount'|'gift_card'|'custom'label: stringamount: number// signed; negative reduces the totalmetadata?: Record<string,unknown>}
Hooks receive and return the full Summary. The plugin recomputes total = sum(lines.amount) after each hook runs — hook authors just append (or modify) lines.
lines[0] stays the subtotal line with the original cart subtotal (throws if removed or mutated).
currency cannot change.
total is recomputed automatically — you don't set it.
API response
/payments/{provider}/initiate and /payments/{provider}/confirm-order now include summary alongside existing fields. Purely additive — existing fields untouched.
When payments.hooks or any paymentMethod.hooks is configured, the plugin adds a read-only summary group field to the default transactions and orders collections. The breakdown is persisted alongside the amount total, so it's queryable for receipts, refunds, and reporting.
Opt-in: the field is only added when hooks are configured. Existing users not adopting hooks see zero schema change.
Breaking changes
All new config is optional. The InitiatePayment data shape on the PaymentAdapter interface replaced adjustments/total with summary — but the only adapter (Stripe) is updated in this PR, and the prior shape was added in the same development cycle as this change (not on any released version).
I'm running a modified/patched version of the ecommerce plugin in production for a web shop with Stripe, VAT, shipping/pickup delivery, gift cards, and custom fulfillment (Gelato print-on-demand).
Overall this is a great direction, Summary + Line feels like the right model. I'd like to try migrating my app from the patched plugin to the version in this PR, and share feedback from that experience so the plugin works well for use cases like this out of the box.
What's the best place for that feedback, this PR, or Discord?
Hey @paulpopus, I just migrated my ecommerce project from my patched version of the ecommerce plugin to the version in this PR and wanted to share what came up along the way. Looking forward to your feedback.
1. Support inclusive tax in the Summary model
Summary assumes total = sum(lines[].amount) (exclusive tax). That can't represent EU-style inclusive VAT, where tax is baked into displayed prices and must be disclosed on the invoice without being added to the total:
Subtotal (incl. VAT): 119.00
Shipping (incl. VAT): 10.00
Total: 129.00
of which 19% VAT: 20.60 ← breakdown, not an addition
Any workaround today is broken: a normal tax line overcharges by €20.60; a zero-amount line with VAT stashed in metadata keeps the total correct but hides the VAT amount from any summary-driven consumer.
With Line.metadata typed as Record<string, unknown>, consumers (hooks, emails, admin UI) lose the type safety they get elsewhere in Payload and end up writing as SomeShape casts to read domain-specific fields back out.
Proposal. Same pattern as CollectionCustom, export an empty LineMetadata interface, consumers augment via declare module.
// in pluginexportinterfaceLineMetadata{}exporttypeLine={type: LineType;amount: number;label: string;metadata?: LineMetadata;};// in consumerdeclare module "@payloadcms/plugin-ecommerce/types"{interfaceLineMetadata{deliveryMethod?: "shipping"|"pickup";}}
3. Mid-checkout amount changes — what's the recommended pattern?
beforeInitiatePayment runs once on PaymentIntent creation and the result is frozen on the transaction. But some amount changes happen after initiate. In my shop I have a custom attachGiftCardToPayment endpoint that subtracts the gift-card amount from transaction.amount and calls stripe.paymentIntents.update({ amount }) directly. The hook never runs again, so the stored summary.lines and the PaymentIntent drift apart.
Two questions:
Today, is the expectation that consumers own these mutation endpoints end-to-end, or is there a plugin pattern / convention I'm missing?
When a consumer does mutate the PaymentIntent amount, should they also append a corresponding line (e.g. { type: 'gift_card', amount: -X }) to the transaction's stored summary.lines, or is keeping summary in sync a plugin concern you'd rather own?
I am happy to discuss this with you!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a hook system to the payment flow so applications can compute taxes, shipping, discounts, gift cards, and any other monetary adjustment on top of the cart subtotal — without forking or wrapping payment adapters.
Why
Today the cart subtotal flows straight from
items × quantity × priceinto the payment provider. There's no way to insert the adjustments that real-world commerce requires. Adapters are linear and closed.What
A three-hook pipeline, an opt-in persistence model, and a typed summary returned from the API.
Hooks
beforeInitiatePaymentSummary. Throwing aborts payment.beforeConfirmOrderafterConfirmOrderHooks are defined at two levels. Plugin-level hooks (
payments.hooks) run for all payment methods; adapter-level hooks (paymentMethod.hooks) run only for that adapter. Plugin-level runs first.The Summary model
Hooks receive and return the full
Summary. The plugin recomputestotal = sum(lines.amount)after each hook runs — hook authors just append (or modify) lines.Example — taxes and shipping
Invariants enforced between hooks
The plugin validates after every hook:
lines[0]stays the subtotal line with the original cart subtotal (throws if removed or mutated).currencycannot change.totalis recomputed automatically — you don't set it.API response
/payments/{provider}/initiateand/payments/{provider}/confirm-ordernow includesummaryalongside existing fields. Purely additive — existing fields untouched.{ "message": "Payment initiated successfully", "clientSecret": "pi_...", "paymentIntentID": "pi_...", "transactionID": "...", "summary": { "total": 22000, "currency": "USD", "lines": [ { "type": "subtotal", "label": "Subtotal", "amount": 20000 }, { "type": "shipping", "label": "Standard", "amount": 500 }, { "type": "tax", "label": "Sales Tax", "amount": 1500 }, ], }, }Persistence
When
payments.hooksor anypaymentMethod.hooksis configured, the plugin adds a read-onlysummarygroup field to the defaulttransactionsandorderscollections. The breakdown is persisted alongside theamounttotal, so it's queryable for receipts, refunds, and reporting.Opt-in: the field is only added when hooks are configured. Existing users not adopting hooks see zero schema change.
Breaking changes
All new config is optional. The
InitiatePaymentdata shape on thePaymentAdapterinterface replacedadjustments/totalwithsummary— but the only adapter (Stripe) is updated in this PR, and the prior shape was added in the same development cycle as this change (not on any released version).