Skip to content

feat(plugin-ecommerce): add support for payment hooks#16313

Open
paulpopus wants to merge 2 commits intomainfrom
feat/plugin-ecommerce-add-support-for-payment-hooks
Open

feat(plugin-ecommerce): add support for payment hooks#16313
paulpopus wants to merge 2 commits intomainfrom
feat/plugin-ecommerce-add-support-for-payment-hooks

Conversation

@paulpopus
Copy link
Copy Markdown
Contributor

@paulpopus paulpopus commented Apr 17, 2026

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

type Summary = {
  total: number // always recomputed by the plugin — never trust what a hook sets
  currency: string // locked — cannot be changed inside a hook
  lines: Line[] // lines[0] is always the cart subtotal, managed by the plugin
}

type Line = {
  type: 'subtotal' | 'tax' | 'shipping' | 'discount' | 'gift_card' | 'custom'
  label: string
  amount: number // signed; negative reduces the total
  metadata?: 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.

Example — taxes and shipping

ecommercePlugin({
  payments: {
    paymentMethods: [stripeAdapter({ ... })],
    hooks: {
      beforeInitiatePayment: [
        async ({ summary, cart, shippingAddress }) => {
          if (!shippingAddress) return summary
          const cost = await calculateShippingCost({ items: cart.items, address: shippingAddress })
          return {
            ...summary,
            lines: [...summary.lines, { type: 'shipping', label: 'Standard', amount: cost }],
          }
        },
        async ({ summary, shippingAddress }) => {
          if (!shippingAddress) return summary
          const shippingTaxable = isShippingTaxableIn(shippingAddress)
          const taxable = summary.lines
            .filter((l) => l.type === 'subtotal' || (l.type === 'shipping' && shippingTaxable))
            .reduce((sum, l) => sum + l.amount, 0)
          const rate = await fetchTaxRate(shippingAddress)
          return {
            ...summary,
            lines: [...summary.lines, { type: 'tax', label: 'Sales Tax', amount: Math.round(taxable * rate) }],
          }
        },
      ],
    },
  },
})

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).
  • 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.

{
  "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.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.

await payload.find({
  collection: 'orders',
  where: { 'summary.lines.type': { equals: 'tax' } },
})

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).

@paulpopus paulpopus changed the title feat(plugin-ecommerce add support for payment hooks feat(plugin-ecommerce): add support for payment hooks Apr 17, 2026
@PatrikKozak PatrikKozak self-assigned this Apr 17, 2026
@github-actions
Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 985.14 KB ✅ No change
packages/payload/meta_index.json esbuild/index.js 1.39 MB ⚠️ +38 B (+0.0%)
packages/payload/meta_shared.json esbuild/exports/shared.js 191.27 KB ✅ No change
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 289.51 KB ✅ No change
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.18 MB ✅ No change
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 16.32 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████▌ }}}$ 82.4%, 807.63 KB
dist/views/Version ${{\color{Goldenrod}{ █▎ }}}$ 5.3%, 51.49 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 21.37 KB
dist/views/Document ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 16.59 KB
dist/views/List ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 11.38 KB
dist/views/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 9.84 KB
dist/views/Versions ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 6.17 KB
dist/views/API ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 6.13 KB
dist/elements/Nav ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 5.96 KB
dist/views/Account ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 5.55 KB
dist/elements/DocumentHeader ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 4.81 KB
dist/views/Login ${{\color{Goldenrod}{ }}}$ 0.4%, 4.40 KB
dist/layouts/Root ${{\color{Goldenrod}{ }}}$ 0.3%, 3.20 KB
dist/views/ForgotPassword ${{\color{Goldenrod}{ }}}$ 0.3%, 3.13 KB
dist/views/CreateFirstUser ${{\color{Goldenrod}{ }}}$ 0.3%, 2.81 KB
dist/templates/Default ${{\color{Goldenrod}{ }}}$ 0.3%, 2.64 KB
dist/views/BrowseByFolder ${{\color{Goldenrod}{ }}}$ 0.3%, 2.61 KB
dist/views/CollectionFolders ${{\color{Goldenrod}{ }}}$ 0.2%, 2.44 KB
dist/views/ResetPassword ${{\color{Goldenrod}{ }}}$ 0.2%, 2.40 KB
dist/views/Logout ${{\color{Goldenrod}{ }}}$ 0.2%, 1.94 KB
(other) ${{\color{Goldenrod}{ ████▍ }}}$ 17.6%, 172.84 KB

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▏ }}}$ 68.7%, 951.98 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 44.07 KB
dist/collections/operations ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 39.96 KB
dist/versions/migrations ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 18.50 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.63 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 14.16 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.32 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 13.13 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.71 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.54 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.08 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.91 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.00 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.80 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.79 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.54 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 6.26 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.23 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.50 KB
dist/queues/config ${{\color{Goldenrod}{ }}}$ 0.4%, 5.31 KB
(other) ${{\color{Goldenrod}{ ███████▊ }}}$ 31.3%, 433.14 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████████████▊ }}}$ 79.4%, 148.89 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▍ }}}$ 5.6%, 10.54 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 2.54 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.42 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.28 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 1.04 KB
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 943 B
dist/folders/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 916 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.4%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/collections/config ${{\color{Goldenrod}{ }}}$ 0.3%, 570 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
dist/auth/sessions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 525 B
dist/fields/getFieldPaths.js ${{\color{Goldenrod}{ }}}$ 0.3%, 485 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.2%, 423 B
dist/utilities/deepMerge.js ${{\color{Goldenrod}{ }}}$ 0.2%, 413 B
(other) ${{\color{Goldenrod}{ █████▏ }}}$ 20.6%, 38.72 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▏ }}}$ 12.7%, 36.34 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▊ }}}$ 11.4%, 32.65 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▏ }}}$ 8.5%, 24.36 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██ }}}$ 8.3%, 23.70 KB
dist/packages/@lexical ${{\color{Goldenrod}{ █▋ }}}$ 6.6%, 18.99 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.5%, 18.53 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.6%, 16.08 KB
dist/features/upload ${{\color{Goldenrod}{ █▏ }}}$ 4.8%, 13.77 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.9%, 11.08 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 9.03 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 8.79 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.36 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 7.40 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 7.15 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.08 KB
dist/features/lists ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.00 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.46 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 2.80 KB
dist/lexical/nodes ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.66 KB
(other) ${{\color{Goldenrod}{ █████████████████████▊ }}}$ 87.3%, 249.86 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████▎ }}}$ 49.3%, 579.12 KB
dist/elements/FolderView ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 29.38 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 28.24 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 17.38 KB
dist/views/Edit ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 17.30 KB
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 15.91 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 15.79 KB
dist/elements/Table ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 15.77 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 14.22 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 13.90 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 10.36 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 9.11 KB
dist/providers/Folders ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.46 KB
dist/elements/HTMLDiff ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.38 KB
dist/elements/ListHeader ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.06 KB
dist/fields/Array ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 7.73 KB
dist/views/CollectionFolder ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.50 KB
dist/views/List ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.36 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.33 KB
dist/elements/LivePreview ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.03 KB
(other) ${{\color{Goldenrod}{ ████████████▋ }}}$ 50.7%, 595.21 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ █████ }}}$ 20.0%, 3.12 KB
../../node_modules ${{\color{Goldenrod}{ ████▎ }}}$ 17.0%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██▍ }}}$ 9.8%, 1.52 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ ██▏ }}}$ 8.5%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▍ }}}$ 5.5%, 862 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █▎ }}}$ 5.2%, 814 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █▏ }}}$ 4.9%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █▏ }}}$ 4.8%, 756 B
dist/elements/Translation ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 399 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 339 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 329 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 301 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 232 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 159 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 152 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 147 B
dist/utilities/abortAndIgnore.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 146 B
(other) ${{\color{Goldenrod}{ ████████████████████ }}}$ 80.0%, 12.51 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Apr 17, 2026

Hi @paulpopus, thanks for pushing this forward!

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?

@paulpopus
Copy link
Copy Markdown
Contributor Author

@jhb-dev This is a good place for feedback, I released this pre canary if you want to install this to try it 3.84.0-internal.d5d6e43

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Apr 19, 2026

Alright, thanks @paulpopus. I'll leave my feedback here through Tuesday.

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Apr 20, 2026

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.

Proposal. Add included?: boolean to Line.

export type Line = { /* … */ included?: boolean };

// runPaymentHooks.ts
total: summary.lines.reduce((sum, l) => (l.included ? sum : sum + l.amount), 0);

2. Type Line.metadata as an augmentable interface

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 plugin
export interface LineMetadata {}
export type Line = {
  type: LineType;
  amount: number;
  label: string;
  metadata?: LineMetadata;
};

// in consumer
declare module "@payloadcms/plugin-ecommerce/types" {
  interface LineMetadata {
    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:

  1. Today, is the expectation that consumers own these mutation endpoints end-to-end, or is there a plugin pattern / convention I'm missing?
  2. 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants