Payload CMS Promotional Codes: Complete Guide (2026)

Add delivery-date restrictions, multi-type usage limits, server-side validation, and tracking to Payload CMS

·Updated on:·Matija Žiberna·
Payload CMS Promotional Codes: Complete Guide (2026)

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

How to Implement Promotional Codes with Delivery Date Restrictions in Payload CMS

Moving from a basic e-commerce checkout to one with promotional code support isn't straightforward, especially when you need flexible restrictions tied to delivery dates and multiple usage limit types. I recently implemented a complete promotional code system for a farm-to-table e-commerce platform where customers needed codes that could be limited to specific Saturday deliveries or first-time orders.

The challenge wasn't just validating codes—it was building a system that handled delivery date restrictions, prevented usage limit abuse, tracked every redemption, and gracefully handled edge cases like codes expiring mid-checkout. After working through database index conflicts and type comparison bugs, I built a robust solution that works entirely server-side with proper validation at every critical point.

This guide walks you through implementing the complete system: from the Payload CMS collection schema to server-side validation, discount calculations, frontend integration, and automatic usage tracking. By the end, you'll have a production-ready promotional code system that handles complex business rules with confidence.

Understanding the Requirements

Before diving into code, let's clarify what we're building. A promotional code system needs more than basic "enter code, get discount" functionality. In a real-world e-commerce application, you need to handle specific business constraints that prevent abuse while maintaining flexibility.

Our promotional code system supports percentage or fixed-amount discounts that can be restricted to specific delivery dates. This is crucial for farm-to-table businesses or meal delivery services where you want to offer Saturday discounts or holiday promotions tied to specific delivery windows. The system needs four usage limit types: unlimited, once per customer globally, once per customer per delivery date, and a total usage cap across all customers.

The architecture follows a server-side validation approach where codes are validated twice—once when the customer applies them and again when submitting the order. This catches edge cases where a code expires or hits its usage limit between application and checkout. If a code becomes invalid at order submission, the order continues without the discount rather than failing completely. This graceful degradation prevents losing sales due to timing issues.

Usage tracking happens automatically through Payload CMS hooks, storing denormalized data to avoid database index conflicts while maintaining complete history. Every code redemption records the customer, order, delivery date, and discount amount, giving administrators full visibility into code performance.

Setting Up the Promotional Codes Collection

The foundation starts with a Payload CMS collection that captures all the business rules. This collection defines the promotional code schema with validation, restrictions, and automatic tracking fields.

// File: src/collections/PromotionalCodes/index.ts
import { superAdminOrTenantAdminAccess } from "@/access/superAdminOrTenantAdmin";
import type { CollectionConfig } from "payload";
import { beforeChange } from "./hooks/beforeChange";

export const PromotionalCodes: CollectionConfig = {
  slug: "promotional-codes",
  labels: {
    singular: { sl: "Promocijska koda", en: "Promotional code" },
    plural: { sl: "Promocijske kode", en: "Promotional codes" },
  },

  admin: {
    useAsTitle: "code",
    defaultColumns: [
      "code",
      "active",
      "discountType",
      "discountValue",
      "currentUsageCount",
    ],
  },

  access: {
    read: () => true, // Public read for validation
    create: superAdminOrTenantAdminAccess,
    update: superAdminOrTenantAdminAccess,
    delete: superAdminOrTenantAdminAccess,
  },

  hooks: {
    beforeChange: [beforeChange],
  },

  fields: [
    {
      name: "code",
      type: "text",
      required: true,
      unique: true,
      admin: {
        description:
          "Promotional code (e.g., SOBOTA10). Automatically converted to uppercase.",
      },
    },
    {
      name: "active",
      type: "checkbox",
      defaultValue: true,
    },
    {
      name: "discountType",
      type: "select",
      required: true,
      defaultValue: "percentage",
      options: [
        { label: "Percentage", value: "percentage" },
        { label: "Fixed amount", value: "fixed" },
      ],
    },
    {
      name: "discountValue",
      type: "number",
      required: true,
      min: 0,
      admin: {
        description:
          "For percentage: enter 10 for 10%. For fixed: enter 5.00 for 5 EUR.",
      },
    },
    {
      name: "applicableDeliveryDates",
      type: "relationship",
      relationTo: "delivery-dates",
      hasMany: true,
      admin: {
        description:
          "If empty, applies to all dates. Select specific delivery dates.",
      },
    },
    {
      name: "usageLimitType",
      type: "select",
      required: true,
      defaultValue: "perCustomerPerDelivery",
      options: [
        { label: "Unlimited", value: "unlimited" },
        { label: "Once per customer", value: "perCustomer" },
        {
          label: "Once per customer per delivery date",
          value: "perCustomerPerDelivery",
        },
        { label: "Total uses", value: "totalUses" },
      ],
    },
    {
      name: "usageLimitValue",
      type: "number",
      min: 1,
      admin: {
        condition: (data) => data.usageLimitType === "totalUses",
      },
    },
    {
      name: "currentUsageCount",
      type: "number",
      defaultValue: 0,
      admin: { readOnly: true },
    },
    {
      name: "validFrom",
      type: "date",
      admin: { date: { pickerAppearance: "dayAndTime" } },
    },
    {
      name: "validTo",
      type: "date",
      admin: { date: { pickerAppearance: "dayAndTime" } },
    },
    {
      name: "usageHistory",
      type: "array",
      admin: { readOnly: true },
      fields: [
        { name: "customerId", type: "number" },
        { name: "customerEmail", type: "text" },
        { name: "orderId", type: "number" },
        { name: "orderNumber", type: "text" },
        { name: "deliveryDateId", type: "number" },
        { name: "deliveryDateValue", type: "text" },
        { name: "usedAt", type: "date" },
        { name: "discountApplied", type: "number" },
      ],
    },
  ],

  timestamps: true,
};

This schema defines promotional codes with all necessary validation and tracking. The code field automatically converts to uppercase through a beforeChange hook we'll implement next. The active checkbox allows administrators to disable codes without deleting them, preserving historical data.

The discount type field supports both percentage and fixed amount discounts, with the discount value interpreted based on the type. For percentage discounts, entering 10 means 10 percent. For fixed amounts, entering 5.00 means 5 euros.

The applicable delivery dates relationship allows restricting codes to specific delivery dates. When empty, the code applies to all dates. This enables Saturday-only promotions or holiday-specific discounts by selecting only the relevant delivery dates.

Usage limit types control how many times codes can be used. The per customer per delivery option is the default and most flexible—customers can use the same code multiple times but only once per delivery date. This prevents abuse while allowing repeat customers to benefit from recurring promotions.

The usage history array stores denormalized data rather than relationships. This design choice avoids database index naming conflicts that Payload CMS creates when using relationships in array fields. By storing IDs alongside display values like customer email and order number, we maintain fast queries without joins while preserving complete historical records even if related documents are deleted.

Implementing Validation Logic

The beforeChange hook handles data validation and transformation before saving promotional codes. This ensures data integrity at the database level and provides immediate feedback to administrators.

// File: src/collections/PromotionalCodes/hooks/beforeChange.ts
import type { CollectionBeforeChangeHook } from "payload";
import type { PromotionalCode } from "@payload-types";

export const beforeChange: CollectionBeforeChangeHook<
  PromotionalCode
> = async ({ data, operation }) => {
  // Auto-uppercase the code
  if (data.code) {
    data.code = data.code.toUpperCase().trim();
  }

  // Validate discount value based on type
  if (data.discountType === "percentage") {
    if (data.discountValue < 0 || data.discountValue > 100) {
      throw new Error("Percentage discount must be between 0 and 100");
    }
  } else if (data.discountType === "fixed") {
    if (data.discountValue < 0) {
      throw new Error("Fixed discount must be greater than 0");
    }
  }

  // Validate date ranges
  if (data.validFrom && data.validTo) {
    const validFrom = new Date(data.validFrom);
    const validTo = new Date(data.validTo);
    if (validFrom >= validTo) {
      throw new Error("Valid From date must be before Valid To date");
    }
  }

  // Initialize tracking fields on creation
  if (operation === "create") {
    data.currentUsageCount = 0;
    data.usageHistory = [];
  }

  return data;
};

This hook runs before every save operation, transforming and validating data. Uppercasing codes ensures consistency regardless of how administrators enter them. The validation checks prevent common mistakes like setting percentage discounts above 100 or creating invalid date ranges.

Building Server-Side Validation

Server-side validation is the core of the promotional code system. This validation must be comprehensive, checking not just code existence but also business rules around dates, delivery restrictions, and usage limits.

// File: src/actions/promoCode.ts
"use server";

import { getPayload } from "payload";
import config from "@payload-config";
import type { PromotionalCode } from "@payload-types";
import { readCartCookie, writeCartCookie } from "@/lib/cart/cookies";

export interface PromoCodeValidationResult {
  valid: boolean;
  message?: string;
  promoCode?: PromotionalCode;
  discount?: number;
}

export async function validatePromoCode(
  code: string,
  deliveryDateId: string | null,
  customerId: string,
): Promise<PromoCodeValidationResult> {
  try {
    const payload = await getPayload({ config });

    // Find the promo code
    const promoCodes = await payload.find({
      collection: "promotional-codes",
      where: { code: { equals: code.toUpperCase().trim() } },
      limit: 1,
    });

    if (promoCodes.docs.length === 0) {
      return { valid: false, message: "Promocijska koda ne obstaja." };
    }

    const promoCode = promoCodes.docs[0];

    // Check if active
    if (!promoCode.active) {
      return { valid: false, message: "Ta promocijska koda ni več aktivna." };
    }

    // Check date validity
    const now = new Date();
    if (promoCode.validFrom) {
      const validFrom = new Date(promoCode.validFrom);
      if (now < validFrom) {
        return { valid: false, message: "Ta promocijska koda še ni veljavna." };
      }
    }

    if (promoCode.validTo) {
      const validTo = new Date(promoCode.validTo);
      if (now > validTo) {
        return { valid: false, message: "Ta promocijska koda je potekla." };
      }
    }

    // Check delivery date restrictions
    if (
      promoCode.applicableDeliveryDates &&
      Array.isArray(promoCode.applicableDeliveryDates) &&
      promoCode.applicableDeliveryDates.length > 0
    ) {
      if (!deliveryDateId) {
        return {
          valid: false,
          message: "Prosimo, najprej izberite datum dostave.",
        };
      }

      // Convert all IDs to strings for comparison to avoid type mismatch
      const applicableDateIds = promoCode.applicableDeliveryDates.map((d) =>
        (typeof d === "object" ? d.id : d).toString(),
      );

      if (!applicableDateIds.includes(deliveryDateId)) {
        return {
          valid: false,
          message: "Ta promocijska koda ne velja za izbrani datum dostave.",
        };
      }
    }

    // Check usage limits
    const usageLimitType = promoCode.usageLimitType || "perCustomerPerDelivery";

    if (usageLimitType === "totalUses") {
      const currentUsage = promoCode.currentUsageCount || 0;
      const maxUsage = promoCode.usageLimitValue || 0;

      if (currentUsage >= maxUsage) {
        return {
          valid: false,
          message:
            "Ta promocijska koda je bila že uporabljena maksimalno število krat.",
        };
      }
    } else if (usageLimitType === "perCustomer") {
      const usageHistory = promoCode.usageHistory || [];
      const customerUsed = usageHistory.some((usage: any) => {
        return usage.customerId?.toString() === customerId.toString();
      });

      if (customerUsed) {
        return {
          valid: false,
          message: "To promocijsko kodo ste že uporabili.",
        };
      }
    } else if (usageLimitType === "perCustomerPerDelivery") {
      if (!deliveryDateId) {
        return {
          valid: false,
          message: "Prosimo, najprej izberite datum dostave.",
        };
      }

      const usageHistory = promoCode.usageHistory || [];
      const customerUsedForDate = usageHistory.some((usage: any) => {
        return (
          usage.customerId?.toString() === customerId.toString() &&
          usage.deliveryDateId?.toString() === deliveryDateId.toString()
        );
      });

      if (customerUsedForDate) {
        return {
          valid: false,
          message: "To promocijsko kodo ste že uporabili za ta datum dostave.",
        };
      }
    }

    return { valid: true, promoCode };
  } catch (error) {
    console.error("Error validating promo code:", error);
    return {
      valid: false,
      message: "Prišlo je do napake pri preverjanju promocijske kode.",
    };
  }
}

This validation function performs six critical checks in order of importance. First, it verifies the code exists in the database using a case-insensitive search. Second, it checks the active flag to ensure administrators can disable codes without deleting them. Third, it validates date ranges using the current timestamp.

The delivery date restriction check handles a subtle type comparison bug. Delivery date IDs from the database are numbers, but the deliveryDateId parameter is a string. The includes method uses strict equality, so we must convert both sides to strings. This conversion prevents false negatives where valid codes are rejected due to type mismatches.

Usage limit validation varies by type. For total uses, we check the current usage count against the maximum. For per customer limits, we search the usage history for any entry matching the customer ID. For per customer per delivery limits, we check both customer ID and delivery date ID. All ID comparisons convert to strings to avoid type issues.

Calculating Discounts

The discount calculation function handles both percentage and fixed-amount discounts with proper boundary checks to prevent discounts from exceeding the order total.

// File: src/actions/promoCode.ts (continued)
export async function calculatePromoDiscount(
  code: string,
  grandTotal: number,
): Promise<number> {
  try {
    const payload = await getPayload({ config });

    const promoCodes = await payload.find({
      collection: "promotional-codes",
      where: { code: { equals: code.toUpperCase().trim() } },
      limit: 1,
    });

    if (promoCodes.docs.length === 0) {
      return 0;
    }

    const promoCode = promoCodes.docs[0];
    let discount = 0;

    if (promoCode.discountType === "percentage") {
      discount = grandTotal * (promoCode.discountValue / 100);
    } else if (promoCode.discountType === "fixed") {
      discount = promoCode.discountValue;
    }

    // Ensure discount doesn't exceed grand total
    if (discount > grandTotal) {
      discount = grandTotal;
    }

    return Math.round(discount * 100) / 100;
  } catch (error) {
    console.error("Error calculating promo discount:", error);
    return 0;
  }
}

Discount calculation applies to the grand total after tax but before the final total. For percentage discounts, we multiply the grand total by the percentage value divided by 100. For fixed discounts, we use the discount value directly but cap it at the grand total to prevent negative order totals.

Rounding to two decimal places prevents floating-point precision errors that can occur with currency calculations. This ensures displayed amounts match calculated amounts exactly.

Applying and Storing Codes

The apply function combines validation and calculation, then stores the result in the cart cookie for persistence across page loads.

// File: src/actions/promoCode.ts (continued)
export async function applyPromoCode(
  code: string,
  deliveryDateId: string | null,
  customerId: string,
  grandTotal: number,
): Promise<{ ok: boolean; message?: string; discount?: number }> {
  try {
    const validation = await validatePromoCode(
      code,
      deliveryDateId,
      customerId,
    );

    if (!validation.valid) {
      return { ok: false, message: validation.message };
    }

    const discount = await calculatePromoDiscount(code, grandTotal);

    const cart = await readCartCookie();
    cart.promoCode = code.toUpperCase().trim();
    cart.promoCodeDiscount = discount;
    await writeCartCookie(cart);

    return { ok: true, discount };
  } catch (error) {
    console.error("Error applying promo code:", error);
    return {
      ok: false,
      message: "Prišlo je do napake pri uporabi promocijske kode.",
    };
  }
}

export async function removePromoCode(): Promise<{ ok: boolean }> {
  try {
    const cart = await readCartCookie();
    cart.promoCode = null;
    cart.promoCodeDiscount = null;
    await writeCartCookie(cart);
    return { ok: true };
  } catch (error) {
    console.error("Error removing promo code:", error);
    return { ok: false };
  }
}

The cart state needs to include promotional code fields. Update your cart types to include these properties.

// File: src/lib/cart/types.ts
export type CartState = {
  deliveryDateId: string | null;
  items: CartItem[];
  pickupLocationId: string | null;
  pickupTimeStart: string | null;
  pickupTimeEnd: string | null;
  promoCode: string | null;
  promoCodeDiscount: number | null;
};

This separation between validation, calculation, and storage keeps concerns separated and makes testing easier. The apply function validates first to provide immediate feedback, calculates the discount amount, then persists both the code and discount to the cart cookie. The remove function clears both fields, allowing customers to try different codes.

Integrating with Checkout UI

The frontend integration requires state management for the promo code input, handling application and removal, and updating the order summary to show the discount.

// File: src/app/(frontend)/narocilo/AuthenticatedCheckout.tsx
import { applyPromoCode, removePromoCode } from "@/actions/promoCode";

// Add state for promo code management
const [promoCode, setPromoCode] = useState("");
const [promoDiscount, setPromoDiscount] = useState<number | null>(null);
const [promoError, setPromoError] = useState<string | null>(null);
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
const [promoLoading, setPromoLoading] = useState(false);

const handleApplyPromoCode = async () => {
  setPromoError("");
  setPromoLoading(true);

  try {
    const result = await applyPromoCode(
      promoCode,
      deliveryDate?.id?.toString() || null,
      customer.id.toString(),
      grandTotal,
    );

    if (result.ok) {
      setAppliedPromoCode(promoCode);
      setPromoDiscount(result.discount || 0);
      setPromoCode("");
    } else {
      setPromoError(result.message || "Invalid promo code");
    }
  } catch (error) {
    setPromoError("Error applying promo code");
  } finally {
    setPromoLoading(false);
  }
};

const handleRemovePromoCode = async () => {
  await removePromoCode();
  setAppliedPromoCode(null);
  setPromoDiscount(null);
  setPromoError(null);
};

// Calculate totals with promo discount
const grandTotal = summary.subtotal + summary.tax;
const finalTotal = promoDiscount
  ? Math.max(0, grandTotal - promoDiscount)
  : grandTotal;

The UI component manages local state for the promo code input and application status. When applying a code, it passes the current delivery date and customer IDs along with the code to the server action. Success updates the applied code state and clears the input. Failure displays the error message returned from validation.

Add the promo code input to your order summary section:

{
  /* Promo Code Section */
}
<div className="space-y-2">
  {!appliedPromoCode ? (
    <div className="flex gap-2">
      <input
        type="text"
        value={promoCode}
        onChange={(e) => setPromoCode(e.target.value.toUpperCase())}
        placeholder="Enter promo code"
        className="flex-1 px-3 py-2 border rounded"
      />
      <button
        onClick={handleApplyPromoCode}
        disabled={!promoCode || promoLoading}
        className="px-4 py-2 bg-primary text-white rounded"
      >
        {promoLoading ? "Applying..." : "Apply"}
      </button>
    </div>
  ) : (
    <div className="flex items-center justify-between bg-green-50 p-2 rounded">
      <span className="text-green-800">
        Code: <strong>{appliedPromoCode}</strong>
      </span>
      <button onClick={handleRemovePromoCode} className="text-red-600">
        Remove
      </button>
    </div>
  )}
  {promoError && <p className="text-red-600 text-sm">{promoError}</p>}
</div>;

{
  /* Order Summary with Discount */
}
<div className="space-y-2 border-t pt-2">
  <div className="flex justify-between">
    <span>Subtotal:</span>
    <span>{formatCurrency(summary.subtotal)}</span>
  </div>
  <div className="flex justify-between">
    <span>Tax:</span>
    <span>{formatCurrency(summary.tax)}</span>
  </div>
  <div className="flex justify-between font-medium">
    <span>Grand Total:</span>
    <span>{formatCurrency(grandTotal)}</span>
  </div>
  {promoDiscount && (
    <div className="flex justify-between text-green-600">
      <span>Promo Discount:</span>
      <span>-{formatCurrency(promoDiscount)}</span>
    </div>
  )}
  <div className="flex justify-between text-lg font-bold border-t pt-2">
    <span>Final Total:</span>
    <span>{formatCurrency(finalTotal)}</span>
  </div>
</div>;

The UI conditionally shows either the input form or an applied code badge. When a code is applied, the order summary displays the promo discount as a negative green line item before the final total. This visual hierarchy makes it clear how the discount affects the final price.

Handling Order Submission with Re-validation

Order submission must re-validate the promotional code to handle edge cases where codes become invalid between application and checkout. This prevents customers from submitting orders with expired or fully-used codes.

// File: src/app/(frontend)/narocilo/actions.ts
import { validatePromoCode, getPromoCodeFromCart } from "@/actions/promoCode";

export async function submitOrder(/* ... */): Promise<SubmitOrderResult> {
  // ... existing validation ...

  // Get and re-validate promo code
  const promoCodeData = await getPromoCodeFromCart();
  let appliedPromoCodeData = undefined;

  if (promoCodeData.code) {
    const validation = await validatePromoCode(
      promoCodeData.code,
      cart.deliveryDateId,
      customer.id.toString(),
    );

    if (validation.valid && validation.promoCode) {
      appliedPromoCodeData = {
        code: validation.promoCode.code,
        promoCodeRecord: Number(validation.promoCode.id),
        discountType: validation.promoCode.discountType,
        discountValue: validation.promoCode.discountValue,
        discountApplied: promoCodeData.discount || 0,
      };
    } else {
      console.warn(
        `Promo code ${promoCodeData.code} became invalid during checkout`,
      );
    }
  }

  const finalTotal = appliedPromoCodeData
    ? Math.max(0, grandTotal - appliedPromoCodeData.discountApplied)
    : grandTotal;

  const order = await payload.create({
    collection: "orders",
    data: {
      // ... other fields ...
      promoCodeDiscount: appliedPromoCodeData?.discountApplied || 0,
      finalTotal,
      appliedPromoCode: appliedPromoCodeData,
    },
  });

  // ... rest of order processing ...
}

The re-validation step fetches the promo code from the cart cookie and validates it again using the same comprehensive checks. If validation fails at this point, the code has become invalid since application. Rather than failing the entire order, we simply proceed without the discount and log a warning. This graceful degradation prevents losing sales due to timing issues while maintaining security.

The order document stores a complete snapshot of the promotional code data including the code string, reference to the promo code record, discount type, discount value, and actual discount applied. This snapshot preserves historical accuracy even if administrators later modify or delete the promotional code.

Implementing Automatic Usage Tracking

Usage tracking happens automatically through an afterChange hook on the Orders collection. This hook fires after order creation and records the redemption in the promotional code's usage history.

// File: src/collections/Orders/hooks/afterChange.ts
import type { CollectionAfterChangeHook } from "payload";
import type { Order } from "@payload-types";

export const afterChange: CollectionAfterChangeHook<Order> = async ({
  doc,
  previousDoc,
  req,
  operation,
}) => {
  // Only track on order creation
  if (operation !== "create") {
    return doc;
  }

  // Track promotional code usage
  if (doc.appliedPromoCode?.promoCodeRecord && !context?.skipPromoUsage) {
    try {
      const promoCodeId =
        typeof doc.appliedPromoCode.promoCodeRecord === "object"
          ? doc.appliedPromoCode.promoCodeRecord.id
          : doc.appliedPromoCode.promoCodeRecord;

      const customerId =
        typeof doc.customer === "object" ? doc.customer.id : doc.customer;
      const deliveryDateId =
        typeof doc.deliveryDate === "object"
          ? doc.deliveryDate.id
          : doc.deliveryDate;

      const [promoCode, customer, deliveryDate] = await Promise.all([
        req.payload.findByID({
          collection: "promotional-codes",
          id: promoCodeId,
          req,
        }),
        req.payload.findByID({
          collection: "customers",
          id: customerId,
          req,
        }),
        req.payload.findByID({
          collection: "delivery-dates",
          id: deliveryDateId,
          req,
        }),
      ]);

      const usageHistory = Array.isArray(promoCode.usageHistory)
        ? [...promoCode.usageHistory]
        : [];

      const orderIdNumber = Number(doc.id);
      const alreadyTracked = usageHistory.some(
        (entry: any) => entry?.orderId === orderIdNumber,
      );

      if (!alreadyTracked) {
        usageHistory.push({
          customerId: Number(customerId),
          customerEmail: customer?.email || "",
          orderId: orderIdNumber,
          orderNumber: doc.orderNumber,
          deliveryDateId: Number(deliveryDateId),
          deliveryDateValue: (deliveryDate as any)?.date || null,
          usedAt: new Date().toISOString(),
          discountApplied: doc.appliedPromoCode.discountApplied || 0,
        });
      }

      await req.payload.update({
        collection: "promotional-codes",
        id: promoCodeId,
        req,
        context: {
          ...context,
          skipPromoUsage: true,
        },
        data: {
          usageHistory,
          currentUsageCount:
            (promoCode.currentUsageCount || 0) + (alreadyTracked ? 0 : 1),
        },
      });

      console.log(
        `[Order ${doc.id}] Tracked promo code usage: ${doc.appliedPromoCode.code}`,
      );
    } catch (error) {
      req.payload.logger.error({
        msg: "Failed to track promo code usage",
        orderId: doc.id,
        promoCode: doc.appliedPromoCode.code,
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }

  return doc;
};

This hook only runs on order creation, not updates. It fetches the promotional code record, customer record, and delivery date record to build a complete usage history entry with denormalized data. By storing customer email, order number, and delivery date value alongside the IDs, administrators can view usage history without performing joins. This denormalization also preserves historical data even if related records are deleted.

Because the code executes inside an afterChange hook, every nested Payload call must stay within the original transaction. Passing the incoming req to each findByID/update ensures they share the open transaction, while the skipPromoUsage flag mirrors the skipInventoryHooks pattern to prevent recursion. The update only increments currentUsageCount when a new entry is added, which protects against duplicated history during retries. Error handling logs failures but doesn't throw exceptions—usage tracking failures should never prevent order creation. Administrators can manually verify and fix usage counts if tracking fails.

Adding Promo Fields to Orders Collection

The Orders collection needs fields to store promotional code data. Add these fields to your Orders collection schema:

// File: src/collections/Orders/index.ts
{
  name: 'promoCodeDiscount',
  type: 'number',
  label: 'Promo code discount',
  defaultValue: 0,
},
{
  name: 'finalTotal',
  type: 'number',
  label: 'Final total (after discount)',
  required: true,
},
{
  name: 'appliedPromoCode',
  type: 'group',
  label: 'Applied promotional code',
  fields: [
    {
      name: 'code',
      type: 'text',
      label: 'Code',
    },
    {
      name: 'promoCodeRecord',
      type: 'relationship',
      relationTo: 'promotional-codes',
      label: 'Promo code record',
    },
    {
      name: 'discountType',
      type: 'text',
      label: 'Discount type',
    },
    {
      name: 'discountValue',
      type: 'number',
      label: 'Discount value',
    },
    {
      name: 'discountApplied',
      type: 'number',
      label: 'Discount applied (EUR)',
    },
  ],
},

These fields store the discount amount, final total, and complete promotional code snapshot. The group field keeps all promo code data organized together in the order document.

Registering the Collection

Don't forget to register your new PromotionalCodes collection in the Payload config:

// File: payload.config.ts
import { PromotionalCodes } from "@/collections/PromotionalCodes";

export default buildConfig({
  collections: [
    // ... other collections
    PromotionalCodes,
    // ... more collections
  ],
  // ... rest of config
});

After registering the collection, run the Payload migration command to create the database tables:

pnpm payload migrate:create
pnpm payload migrate

Testing the Implementation

Create a test promotional code in the admin panel to verify the system works correctly. Navigate to Sales > Promotional codes and create a code with these settings:

  • Code: TEST10
  • Active: checked
  • Discount Type: Percentage
  • Discount Value: 10
  • Usage Limit Type: Once per customer per delivery date

Test the following scenarios to ensure proper functionality:

First, apply the code at checkout with a valid delivery date selected. The discount should apply immediately and appear in the order summary. Remove the code and reapply it to verify the remove function works. Change the delivery date after applying the code—if the code is restricted to specific dates, it should validate against the new date.

Second, submit an order with an applied code. Check the order document in the admin panel to verify the promotional code fields are populated correctly. Open the promotional code record and verify the usage history contains an entry for your test order. The current usage count should increment by one.

Third, test the usage limits by attempting to apply the same code again for the same delivery date. The validation should reject it with the appropriate message. Try applying the code for a different delivery date—it should work if the code applies to that date.

Fourth, test expiration by creating a code with a valid to date in the past. Attempting to apply it should fail with the expiration message. Test the active flag by unchecking it on an existing code—applying it should fail with the inactive message.

Conclusion

We've built a complete promotional code system that handles complex business rules through server-side validation, graceful degradation, and automatic usage tracking. The implementation validates codes at both application and order submission to catch edge cases where codes become invalid mid-checkout. By storing denormalized data in usage history, we avoid database index conflicts while maintaining complete historical records.

The system supports percentage and fixed discounts, delivery date restrictions, and four usage limit types. Administrators can monitor code performance through the automatically-populated usage history showing every redemption with customer, order, and discount details. The architecture keeps validation logic centralized in server actions, making it easy to add new validation rules or discount types as requirements evolve.

You now have a production-ready promotional code system that prevents abuse, handles edge cases gracefully, and provides complete visibility into code usage. The implementation patterns here—server-side validation, denormalized history, graceful degradation—apply to many e-commerce features beyond promotional codes.

Let me know in the comments if you have questions about implementing promotional codes in your Payload CMS application, and subscribe for more practical development guides.

Thanks, Matija

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.