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.
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.
typescript
// File: src/collections/PromotionalCodes/hooks/beforeChange.tsimporttype { CollectionBeforeChangeHook } from"payload";
importtype { PromotionalCode } from"@payload-types";
exportconstbeforeChange: CollectionBeforeChangeHook<
PromotionalCode
> = async ({ data, operation }) => {
// Auto-uppercase the codeif (data.code) {
data.code = data.code.toUpperCase().trim();
}
// Validate discount value based on typeif (data.discountType === "percentage") {
if (data.discountValue < 0 || data.discountValue > 100) {
thrownewError("Percentage discount must be between 0 and 100");
}
} elseif (data.discountType === "fixed") {
if (data.discountValue < 0) {
thrownewError("Fixed discount must be greater than 0");
}
}
// Validate date rangesif (data.validFrom && data.validTo) {
const validFrom = newDate(data.validFrom);
const validTo = newDate(data.validTo);
if (validFrom >= validTo) {
thrownewError("Valid From date must be before Valid To date");
}
}
// Initialize tracking fields on creationif (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.
typescript
// File: src/actions/promoCode.ts"use server";
import { getPayload } from"payload";
import config from"@payload-config";
importtype { PromotionalCode } from"@payload-types";
import { readCartCookie, writeCartCookie } from"@/lib/cart/cookies";
exportinterfacePromoCodeValidationResult {
valid: boolean;
message?: string;
promoCode?: PromotionalCode;
discount?: number;
}
exportasyncfunctionvalidatePromoCode(code: string,
deliveryDateId: string | null,
customerId: string,
): Promise<PromoCodeValidationResult> {
try {
const payload = awaitgetPayload({ config });
// Find the promo codeconst 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 activeif (!promoCode.active) {
return { valid: false, message: "Ta promocijska koda ni več aktivna." };
}
// Check date validityconst now = newDate();
if (promoCode.validFrom) {
const validFrom = newDate(promoCode.validFrom);
if (now < validFrom) {
return { valid: false, message: "Ta promocijska koda še ni veljavna." };
}
}
if (promoCode.validTo) {
const validTo = newDate(promoCode.validTo);
if (now > validTo) {
return { valid: false, message: "Ta promocijska koda je potekla." };
}
}
// Check delivery date restrictionsif (
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 mismatchconst 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 limitsconst 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.",
};
}
} elseif (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.",
};
}
} elseif (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.
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.
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.
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:
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.
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.
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:
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:
typescript
// File: payload.config.tsimport { PromotionalCodes } from"@/collections/PromotionalCodes";
exportdefaultbuildConfig({
collections: [
// ... other collectionsPromotionalCodes,
// ... more collections
],
// ... rest of config
});
After registering the collection, run the Payload migration command to create the database tables:
bash
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.