WooCommerce coupon types and rules
Keep coupon definition, eligibility, calculation, and usage accounting separate. Let WC_Discounts allocate discounts and let Woo totals calculate tax; do not rewrite cart item prices or totals to imitate a coupon.
Choose the coupon model
| Need | Model |
|---|---|
| Merchant-managed reusable code, native limits/reporting | Persisted WC_Coupon (shop_coupon) |
| Generated code resolved from your own entitlement/table/service | Use wc-coupon-dynamic for a virtual coupon |
| A new mathematical discount behavior visible in the type selector | Custom coupon type from this skill |
| Surcharge or positive adjustment | Use the Woo fee API, not a negative/creative coupon |
| Silent customer-specific product price | Use a pricing rule only when it should not be represented as a coupon/order coupon line |
Create persisted coupons with CRUD
Register custom types before loading or setting them. HPOS does not move coupons into order tables; they remain shop_coupon objects, but integrations should still use WC_Coupon CRUD.
$code = wc_format_coupon_code( 'PARTNER-2026' );
if ( wc_get_coupon_id_by_code( $code ) ) {
throw new RuntimeException( 'Coupon code already exists.' );
}
$coupon = new WC_Coupon();
$coupon->set_code( $code );
$coupon->set_status( 'publish' );
$coupon->set_discount_type( 'percent' ); // Set before amount validation.
$coupon->set_amount( '15' );
$coupon->set_product_ids( array( 101, 102 ) );
$coupon->set_excluded_product_ids( array( 103 ) );
$coupon->set_exclude_sale_items( true );
$coupon->set_minimum_amount( '50' ); // Set before maximum.
$coupon->set_maximum_amount( '500' );
$coupon->set_usage_limit( 100 );
$coupon->set_usage_limit_per_user( 1 );
$coupon->set_date_expires( '2026-12-31 23:59:59' );
$coupon->save();
Use arrays of IDs and real booleans. Coupon-code comparison is case-insensitive; use wc_is_same_coupon() where available instead of raw ===.
Register a complete custom type
A functional type needs at least three layers. A label alone only makes the slug visible and acceptable to set_discount_type().
const MYPLUGIN_COUPON_TYPE = 'myplugin_member_percent';
// 1. Register globally: admin selector, WC_Coupon validation, and REST enum.
add_filter( 'woocommerce_coupon_discount_types', function ( array $types ): array {
$types[ MYPLUGIN_COUPON_TYPE ] = __( 'Member percentage', 'myplugin' );
return $types;
} );
// 2. Choose exactly one eligibility family. This is product-style.
add_filter( 'woocommerce_product_coupon_types', function ( array $types ): array {
$types[] = MYPLUGIN_COUPON_TYPE;
return array_values( array_unique( $types ) );
} );
// 3. Return a per-unit discount amount for the custom branch.
add_filter(
'woocommerce_coupon_get_discount_amount',
function ( $discount, $discounting_amount, $cart_item, $single, $coupon ) {
if ( ! $coupon instanceof WC_Coupon || ! $coupon->is_type( MYPLUGIN_COUPON_TYPE ) ) {
return $discount;
}
$price = max( 0.0, (float) $discounting_amount );
$rate = min( 100.0, max( 0.0, (float) $coupon->get_amount() ) );
return min( $price, $price * $rate / 100 );
},
10,
5
);
For the custom branch, Woo calls get_discount_amount() per unit with $single = true, then multiplies by the applicable quantity. Return the discount, not the final price and not the entire line discount. The same filter also fires for core types, so always return the original value unless the exact custom slug matches.
Product-style versus cart-style
Choose exactly one:
// Product-style: inclusions/exclusions decide which items receive a discount.
add_filter( 'woocommerce_product_coupon_types', $register_type );
// Cart-style eligibility: a prohibited item invalidates the whole coupon.
add_filter( 'woocommerce_cart_coupon_types', $register_type );
Classification controls eligibility, not the custom type's math branch. A custom cart-style type still reaches apply_coupon_custom() rather than inheriting fixed_cart calculation. If the slug is in neither list, an unrestricted coupon normally validates but finds zero applicable items; native exclusions can instead reject it under the non-product path. Putting it in both makes rule semantics ambiguous and can cause cart validity to bypass per-item selection.
Custom types sort before built-in types by default. Define stacking order deliberately when it affects the result:
add_filter( 'woocommerce_coupon_sort', function ( $sort, WC_Coupon $coupon ) {
return $coupon->is_type( MYPLUGIN_COUPON_TYPE ) ? 2 : $sort; // Percent-like position.
}, 10, 2 );
Test both values of woocommerce_calc_discounts_sequentially.
Use native rules before custom rules
| Rule | WC_Coupon API |
|---|---|
| Include products/variations or parents | set_product_ids() |
| Exclude products/variations or parents | set_excluded_product_ids() |
| Include/exclude product categories | set_product_categories(), set_excluded_product_categories() |
| Exclude sale items | set_exclude_sale_items() |
| Cart subtotal window | set_minimum_amount(), set_maximum_amount() |
| Allowed billing emails, including wildcard matching at validation | set_email_restrictions() |
| Cannot normally stack | set_individual_use() |
| Maximum qualifying quantity | set_limit_usage_to_x_items() |
| Global/per-customer usage | set_usage_limit(), set_usage_limit_per_user() |
| Expiry/free-shipping flag | set_date_expires(), set_free_shipping() |
free_shipping does not manufacture a rate; configure a compatible free-shipping method. Product restrictions behave differently according to product/cart classification, so regression-test mixed eligible and excluded carts.
Add deterministic custom rules
Use the narrowest hook:
add_filter(
'woocommerce_coupon_is_valid',
function ( bool $valid, WC_Coupon $coupon, WC_Discounts $discounts ): bool {
if ( ! $coupon->is_type( MYPLUGIN_COUPON_TYPE ) ) {
return $valid;
}
return $valid && myplugin_customer_is_member( $discounts->get_object() );
},
10,
3
);
woocommerce_coupon_is_valid: coupon/order-wide eligibility after native validation.woocommerce_coupon_is_valid_for_product: per-item eligibility; signature is valid, product, coupon, values.woocommerce_coupon_is_valid_for_cart: low-level cart-family applicability; prefer the final validity filter for ordinary whole-coupon rules, because forcing this true can bypass per-item selection.woocommerce_coupon_get_items_to_apply: final item list; prefer removing items, because adding previously rejected items can bypass restrictions.woocommerce_coupon_get_apply_quantity: cap qualifying quantity without changing cart quantity.woocommerce_coupon_error: presentation only, not authorization.
Validation runs many times in classic checkout, Blocks/Store API, order creation, and recalculation. Make it side-effect free, bounded, and usable with either WC_Cart or WC_Order. Avoid network requests in calculation/validation; prefetch/cache authoritative state or fail closed with a short timeout outside the hot calculation loop.
Do not base historical order recalculation on mutable membership tiers, time, external prices, or the current session. Store custom checkout facts as separate coupon-line metadata through woocommerce_checkout_create_order_coupon_item; never extend Woo's coupon_info JSON format. Load references/coupon-contract.md for hook signatures, order snapshots, admin fields, usage holds, and the complete validation matrix.
Respect core usage accounting
Persisted coupon usage is not just usage_count:
- checkout tentatively holds global and per-user slots to reduce concurrent over-redemption;
- pending, processing, on-hold, and completed orders count as usage;
- cancelled, failed, and trashed orders release/reduce usage;
_used_byrecords customer ID or guest billing email;- the order's recorded-usage flag makes status retries idempotent.
Do not increment counts from validation, woocommerce_applied_coupon, or every payment webhook. A refund alone does not necessarily change the order into an invalid usage status, so define the merchant's refund/restoration policy explicitly.
Apply and auto-apply through Woo
if ( WC()->cart && ! WC()->cart->has_discount( $code ) ) {
WC()->cart->apply_coupon( $code );
}
Guard repeated hooks because carts recalculate frequently. Use remove_coupon() when eligibility disappears. Do not mutate WC()->cart->applied_coupons directly, and do not assume classic form handlers cover Store API requests.
For an existing order, use $order->apply_coupon( $coupon_or_code ) and inspect WP_Error; it recalculates coupon/item/tax totals and usage state. Never add only a WC_Order_Item_Coupon row and assume the product totals were discounted.
Compatibility and security checklist
- Register the type on every frontend, REST, CLI, cron, and admin request before coupon hydration.
- Namespace the slug; preserve other plugins' filter values.
- Test product and variation inclusion/exclusion, categories, sale items, empty/free items, quantities, min/max, email, guest/user, expiry, and timezone. Include an unclassified-type regression: no restrictions yields zero allocation, while exclusions can reject it earlier.
- Test tax-inclusive/exclusive prices, multiple tax classes, currency decimals, rounding, and discounts larger than the item.
- Test stacking, individual-use behavior, both sequential settings, and deterministic sort.
- Test classic cart/checkout, Cart and Checkout Blocks, Store API, REST v3 coupon CRUD, admin order application/recalculation, refunds, cancellations, and plugin deactivation.
- Keep custom rule secrets and customer entitlements server-side; error text must not leak hidden eligibility data.
- Define migration/uninstall behavior: persisted custom-type coupons become unloadable or recalculate incorrectly when registration/calculation disappears.
Cross-references
- Use
wc-coupon-dynamicfor virtual/non-shop_couponcodes and external usage accounting. - Use
wc-cart-checkout-classicfor classic cart state and order-item transfer. - Use
wc-store-apifor Blocks/headless cart mutation and Cart-Token/Nonce behavior. - Use
wc-order-lifecycle-and-itemsfor order status and refund side effects.
References
- Verified source paths:
wp-content/plugins/woocommerce/includes/class-wc-coupon.phpwp-content/plugins/woocommerce/includes/class-wc-discounts.phpwp-content/plugins/woocommerce/includes/class-wc-cart.phpwp-content/plugins/woocommerce/includes/class-wc-cart-totals.phpwp-content/plugins/woocommerce/includes/abstracts/abstract-wc-order.phpwp-content/plugins/woocommerce/includes/wc-coupon-functions.phpwp-content/plugins/woocommerce/includes/wc-order-functions.phpwp-content/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.phpwp-content/plugins/woocommerce/src/StoreApi/Utilities/CartController.php