obsidian/wiki/payloadcms/ecommerce.md
2026-05-15 15:22:27 +01:00

7.7 KiB

tags topic sources created updated
payloadcms
tech-patterns
payloadcms
ecommerce__overview.md
ecommerce__plugin.md
ecommerce__payments.md
ecommerce__frontend.md
ecommerce__advanced.md
2026-05-15 2026-05-15

PayloadCMS — Ecommerce

Overview

@payloadcms/plugin-ecommerce adds full-featured store functionality on top of Payload. Currently Beta — expect breaking changes.

Collections created automatically:

  • products + variants, variantTypes, variantOptions
  • carts (guest and authenticated)
  • orders, transactions
  • addresses

Not covered natively: shipping, taxes, subscriptions — implement yourself via hooks.

Setup

pnpm add @payloadcms/plugin-ecommerce stripe

Minimal config — you must provide all access-control functions yourself:

import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'

export default buildConfig({
  plugins: [
    ecommercePlugin({
      access: {
        adminOnlyFieldAccess: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
        adminOrPublishedStatus: ({ req: { user } }) =>
          user?.roles?.includes('admin') ? true : { _status: { equals: 'published' } },
        isAdmin: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
        isAuthenticated: ({ req: { user } }) => Boolean(user),
        isCustomer: ({ req: { user } }) => Boolean(user && !user?.roles?.includes('admin')),
        isDocumentOwner: ({ req: { user } }) =>
          user?.roles?.includes('admin') ? true : user?.id ? { customer: { equals: user.id } } : false,
      },
      customers: { slug: 'users' },
      payments: {
        paymentMethods: [
          stripeAdapter({
            secretKey: process.env.STRIPE_SECRET_KEY!,
            publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
            webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!,
          }),
        ],
      },
    }),
  ],
})

Key Patterns

Stripe Payment Flow

  1. Frontend calls initiatePayment → server creates a Stripe PaymentIntent + Transaction (status: "Processing"), returns client_secret
  2. Frontend confirms payment via Stripe.js using client_secret
  3. Frontend calls confirmOrder → server verifies intent, creates Order, marks Transaction as "succeeded", sets cart.purchasedAt

REST endpoints added automatically:

  • POST /api/payments/stripe/initiate
  • POST /api/payments/stripe/confirm-order
  • POST /api/payments/stripe/webhooks

Stripe Webhooks (local dev)

stripe login
stripe listen --forward-to localhost:3000/api/payments/stripe/webhooks
# Copy the printed whsec_... secret → STRIPE_WEBHOOKS_SIGNING_SECRET in .env
stripe trigger payment_intent.succeeded  # test

The CLI webhook secret is ephemeral — changes on every stripe listen restart. Update .env and restart dev server.

Cart API Endpoints

Endpoint Description
POST /api/carts/:id/add-item Add product/variant, auto-increments if match
POST /api/carts/:id/update-item Set qty or { $inc: n } operator
POST /api/carts/:id/remove-item Remove by itemID
POST /api/carts/:id/clear Empty cart

Server-side equivalents: addItem, updateItem, removeItem, clearCart from @payloadcms/plugin-ecommerce.

Multi-currency

Prices stored as integers (avoids float issues). USD 1000 = $10.00.

currencies: {
  supportedCurrencies: [USD, { code: 'JPY', decimals: 0, label: 'Japanese Yen', symbol: '¥' }],
  defaultCurrency: 'USD',
}

Adding a currency triggers a schema migration (new price fields on products/variants).

Config / Code Examples

Frontend Provider

import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { USD, EUR } from '@payloadcms/plugin-ecommerce'

<EcommerceProvider
  currenciesConfig={{ supportedCurrencies: [USD, EUR], defaultCurrency: 'USD' }}
  paymentMethods={[stripeAdapterClient({ publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! })]}
>
  {children}
</EcommerceProvider>

Available React hooks:

  • useCart — add/remove/update items, isLoading state
  • usePaymentsinitiatePayment, confirmOrder
  • useAddresses — create/update addresses
  • useCurrencyformatPrice(amount), setCurrency
  • useEcommerce — all of the above unified + onLogin, onLogout, clearSession, mergeCart, refreshCart

Session management

// After login — merges guest cart into user cart automatically
await onLogin()

// After logout — clears cart, addresses, localStorage
onLogout() // or clearSession()

Custom CartItemMatcher (e.g. fulfillment options)

const fulfillmentMatcher: CartItemMatcher = ({ existingItem, newItem }) => {
  const productMatches = existingItem.product === newItem.product
  const variantMatches = newItem.variant ? existingItem.variant === newItem.variant : !existingItem.variant
  return productMatches && variantMatches && existingItem.fulfillment === newItem.fulfillment
}

Piecemeal usage (collections only)

import { createProductsCollection, createCartsCollection } from 'payload-plugin-ecommerce'

// Add individual collections without the full plugin

Available factory functions: createAddressesCollection, createCartsCollection, createOrdersCollection, createTransactionsCollection, createProductsCollection, createVariantsCollection, createVariantTypesCollection, createVariantOptionsCollection.

Custom ProductsValidation

Override validation called before transaction creation and payment confirmation:

products: {
  validation: ({ product, variant, quantity, currency }) => {
    if (!currency) throw new Error('Currency required')
    const priceField = `priceIn${currency.toUpperCase()}`
    if (!(variant ?? product)?.[priceField]) throw new Error('No price in currency')
    // add custom checks: max qty, region lock, etc.
  }
}

Translations

The plugin ships admin UI translations under the plugin-ecommerce namespace. Import and register them explicitly:

import { en } from '@payloadcms/translations/languages/en'
import { enTranslations as ecommerceEn } from '@payloadcms/plugin-ecommerce/translations/languages/en'

buildConfig({
  i18n: {
    supportedLanguages: { en },
    translations: { en: ecommerceEn },
  },
})

Override specific strings by spreading and patching the namespace:

translations: {
  en: {
    ...ecommerceEn,
    'plugin-ecommerce': {
      ...ecommerceEn['plugin-ecommerce'],
      cart: 'Shopping Cart',
      orders: 'My Orders',
    },
  },
}

Gotchas

  • publicAccess is the only default — all other access functions must be implemented by you
  • Adding a currency = schema migration — new price columns/fields on products and variants
  • Guest cart secret is ephemeral — stored in localStorage; lost on browser clear
  • stripe SDK not auto-installed — run pnpm add stripe separately (min 18.5.0)
  • Never trust client-side prices — always compute total server-side from DB records
  • Webhook secret from stripe listen changes on restart — update .env each time
  • customerOnlyFieldAccess is deprecated — use isCustomer instead (removed in v4)
  • confirmOrder creates the Order — not initiatePayment; abandoned checkouts leave Transaction in "Processing"