Claude Skill

wc-coupon-types-rules

Implement, extend, or audit WooCommerce coupon types, persisted coupon CRUD, eligibility rules, inclusions/exclusions, stacking, usage limits, and order lifecycle behavior. Covers the complete custom discount-type contract (`woocommerce_coupon_discount_types`, product-vs-cart cla

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download Lonsdale201-wp-agent-skills-woocommerce_wc-coupon-types-rules-8820ff3.zip · 12 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/woocommerce/wc-coupon-types-rules
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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

The existence check is not an atomic uniqueness guarantee. Serialize concurrent creation in the integration's own job/idempotency mechanism; do not assume a unique database constraint on coupon codes.

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.

Woo 11.1 adds six woocommerce_coupon_is_valid_for_* restriction filters, each receiving (valid, coupon, discounts). Unlike woocommerce_coupon_validate_* rejection predicates, true means valid. See the reference table before overriding native restrictions: later validators and per-product allocation still apply.

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_by records 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.

Reject zero-allocation monetary applications before mutation

WC_Discounts takes product lines from an order. Fees and shipping are outside that allocation. On the tested pending fee-only order, both unrestricted percent and fixed_cart coupons returned success, left the total unchanged, and consumed usage. A valid coupon is not proof of a realized discount.

For an integration that promises a monetary discount, reject orders without eligible product lines before apply_coupon() or external entitlement reservation. If a preflight uses WC_Discounts, inspect its actual per-code allocation as well as validation; an existing discount/stacking policy can still affect the final amount. After applying, inspect the matching coupon line and product totals. Handle an unexpected zero result through an explicit rollback/error policy using Woo APIs, not direct counter edits. Free-shipping-only and deliberately zero-value coupons need their own policy.

Model a sale of a service as a genuine, non-stock-managed product when that matches the business meaning. Do not suppress stock handling on an entire mixed order to make a synthetic product line work. Do not turn fees into negative rows to imitate native coupon restrictions.

Historical recalculate_coupons() deliberately disables coupon-wide validation. Product allocation/calculation filters still matter; restore immutable policy before the first replay. For virtual coupons, use the scoped filter in wc-coupon-dynamic, not a snapshot repair after apply_coupon() returns.

Compatibility and security checklist

  1. Register the type on every frontend, REST, CLI, cron, and admin request before coupon hydration.
  2. Namespace the slug; preserve other plugins' filter values.
  3. 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.
  4. Test tax-inclusive/exclusive prices, multiple tax classes, currency decimals, rounding, and discounts larger than the item.
  5. Test stacking, individual-use behavior, both sequential settings, and deterministic sort.
  6. Test classic cart/checkout, Cart and Checkout Blocks, Store API, REST v3 coupon CRUD, admin order application/recalculation, refunds, cancellations, and plugin deactivation.
  7. Keep custom rule secrets and customer entitlements server-side; error text must not leak hidden eligibility data.
  8. Define migration/uninstall behavior: persisted custom-type coupons become unloadable or recalculate incorrectly when registration/calculation disappears.

Cross-references

  • Use wc-coupon-dynamic for virtual/non-shop_coupon codes and external usage accounting.
  • Use wc-cart-checkout-classic for classic cart state and order-item transfer.
  • Use wc-order-lifecycle-and-items for order status and refund side effects.

References

  • Disposable CLI smoke example
  • Verified source paths:
    • wp-content/plugins/woocommerce/includes/class-wc-coupon.php
    • wp-content/plugins/woocommerce/includes/class-wc-discounts.php
    • wp-content/plugins/woocommerce/includes/class-wc-cart.php
    • wp-content/plugins/woocommerce/includes/class-wc-cart-totals.php
    • wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
    • wp-content/plugins/woocommerce/includes/wc-coupon-functions.php
    • wp-content/plugins/woocommerce/includes/wc-order-functions.php
    • wp-content/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
    • wp-content/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
Files (wp-agent-skills)
  • agents
    • openai.yaml 251 B
      interface:
        display_name: "WooCommerce Coupon Types and Rules"
        short_description: "Extend coupon types and eligibility rules"
        default_prompt: "Use $wc-coupon-types-rules to implement a WooCommerce coupon type with safe rules and usage handling."
      
  • references
    • coupon-contract.md 17.9 KB
      # WooCommerce coupon extension contract
      
      Version scope: WooCommerce 11.1.1, PHP 7.4+. Use this reference for custom coupon types, rule engines, admin/REST fields, concurrency, or historical order recalculation.
      
      ## Contents
      
      1. [Core object and storage](#core-object-and-storage)
      2. [Custom type pipeline](#custom-type-pipeline)
      3. [Native validation order](#native-validation-order)
      4. [Classification and exclusion semantics](#classification-and-exclusion-semantics)
      5. [Hook contracts](#hook-contracts)
      6. [Usage accounting and concurrency](#usage-accounting-and-concurrency)
      7. [Order snapshots and deterministic recalculation](#order-snapshots-and-deterministic-recalculation)
      8. [Admin, REST, Store API, and Blocks](#admin-rest-store-api-and-blocks)
      9. [Custom rule fields](#custom-rule-fields)
      10. [Regression matrix](#regression-matrix)
      
      ## Core object and storage
      
      `WC_Coupon` is the public data object. Persisted coupons currently use the `shop_coupon` post type and `WC_Coupon_Data_Store_CPT`; HPOS changes orders, not coupon storage.
      
      Important properties include code, status, type, amount, expiry, product/category inclusions and exclusions, sale-item exclusion, spend limits, email restrictions, free shipping, individual use, three usage limits, usage count, and `used_by`.
      
      Use getters/setters and `save()`. The setter order matters:
      
      - register and set `discount_type` before `amount`, because built-in percentage validation caps only the literal `percent` type at 100;
      - set `minimum_amount` before `maximum_amount`, because the maximum setter compares them;
      - set ID lists as arrays and flags as booleans;
      - use `date_expires`, not the legacy `expiry_date` name, in data arrays;
      - use `update_meta_data()` for custom properties, not direct post meta.
      
      Coupon codes are sanitized through the default `woocommerce_coupon_code` filter behind `wc_format_coupon_code()`. Lookup and `wc_is_same_coupon()` are case-insensitive. Check `wc_get_coupon_id_by_code()` before creation; the admin can warn about duplicates, but extension code should prevent them deterministically.
      
      ## Custom type pipeline
      
      ### 1. Registry
      
      `woocommerce_coupon_discount_types` filters `slug => label` from `wc_get_coupon_types()`.
      
      This registry controls:
      
      - the classic coupon editor selector;
      - `WC_Coupon::set_discount_type()` validation since WC 10.3;
      - WC REST v3 coupon schema enum;
      - coupon totals-report type enumeration.
      
      Register globally and early. An admin-only registration lets a coupon save but later frontend/REST/cron hydration can fail after the type disappears.
      
      ### 2. Eligibility family
      
      Add the slug to exactly one:
      
      - `woocommerce_product_coupon_types`: per-product rules determine which lines receive discounts;
      - `woocommerce_cart_coupon_types`: the coupon applies cart-wide and prohibited items invalidate the coupon.
      
      Neither list means both `WC_Coupon::is_valid_for_product()` and `is_valid_for_cart()` default false, so an unrestricted coupon can validate but `WC_Discounts::get_items_to_apply_coupon()` returns no items. Because core treats every non-product type through the cart-style exclusion-validation path, adding native exclusions can make the unclassified coupon fail earlier instead. Classification is therefore required, not a cosmetic label.
      
      ### 3. Calculation
      
      Unknown/custom types reach `WC_Discounts::apply_coupon_custom()`. It:
      
      1. sorts eligible items from highest unit price;
      2. respects `limit_usage_to_x_items` and `woocommerce_coupon_get_apply_quantity`;
      3. calls `$coupon->get_discount_amount( $per_unit_price, $item_object, true )`;
      4. multiplies the returned per-unit discount by quantity;
      5. rounds/clamps to the currently undiscounted line amount;
      6. stores allocation per coupon and item.
      
      `WC_Coupon::get_discount_amount()` delegates custom math through `woocommerce_coupon_get_discount_amount`. The filter also participates in core type calculations, especially cart calculations, so exact slug scoping is mandatory.
      
      `$cart_item` is an array in cart context and a `WC_Order_Item_Product` in order context. Do not type it as only one of those.
      
      `woocommerce_coupon_custom_discounts_array` receives the final allocation array in Woo's internal price precision, keyed by item key. Use it only for a required final balancing algorithm; ordinary decimal return values are wrong at that layer.
      
      ### 4. Sort/stacking
      
      `WC_Cart_Totals` defaults custom types to sort `0`, before:
      
      - fixed product `1`;
      - percent `2`;
      - fixed cart `3`.
      
      Use `woocommerce_coupon_sort` when the custom type should behave like one of those families. The fallback compares usage-item limit, amount, and ID. Virtual coupon ID is zero, so explicit sort is especially important for predictable stacking.
      
      `woocommerce_calc_discounts_sequentially=yes` uses each line's remaining price as the next calculation basis. With `no`, the calculation basis is the original line price, but Woo still clamps total allocation to the remaining value.
      
      ## Native validation order
      
      `WC_Discounts::is_coupon_valid()` runs these checks before the final custom filter:
      
      1. coupon has a persisted ID or is virtual, and is not trashed;
      2. global usage plus tentative holds;
      3. per-user persisted usage;
      4. expiry;
      5. minimum spend;
      6. maximum spend;
      7. included products;
      8. included categories;
      9. exclusion/eligible-item semantics;
      10. allowed current-user/cart/order billing emails;
      11. `woocommerce_coupon_is_valid`.
      
      Native failure codes cover filtered invalid, missing, exhausted, expired, min/max, not applicable, sale-item exclusion, product exclusion, category exclusion, and held/stuck usages. `woocommerce_coupon_error` changes only the message returned after validation catches an exception.
      
      Some `woocommerce_coupon_validate_*` hooks filter a failure predicate, not a validity predicate. For example, returning true from `woocommerce_coupon_validate_minimum_amount` means reject when the surrounding minimum exists. Prefer the clear final/per-product validity hooks unless intentionally replacing a native predicate.
      
      ### WooCommerce 11.1 restriction overrides
      
      All six filters below receive `(bool $valid, WC_Coupon $coupon, WC_Discounts $discounts)`. Return true to permit that check, false to reject, and preserve `$valid` outside the exact owned policy. They run conditionally; they are not universal replacement hooks for the final validity filter.
      
      | Hook | Gate |
      |---|---|
      | `woocommerce_coupon_is_valid_for_product_ids` | Nonempty product inclusion list. |
      | `woocommerce_coupon_is_valid_for_product_categories` | Nonempty category inclusion list. |
      | `woocommerce_coupon_is_valid_for_sale_items` | Sale exclusion on the cart-style validation path. |
      | `woocommerce_coupon_is_valid_for_excluded_items` | Product-family check that at least one line satisfies all restrictions. |
      | `woocommerce_coupon_is_valid_for_excluded_product_ids` | Product exclusions on the cart-style path. |
      | `woocommerce_coupon_is_valid_for_excluded_product_categories` | Category exclusions on the cart-style path. |
      
      An inclusion override alone can still fail the later eligible-item check. Even allowing both checks does not alter per-product allocation: the measured restricted percentage coupon validated but allocated zero. Change allocation only through an explicitly scoped product policy; do not force all these filters true globally. The final `woocommerce_coupon_is_valid` filter cannot rescue a failure thrown before it is reached.
      
      Email restrictions can include wildcards and compare current account email plus cart/order billing email. Persisted per-user usage uses user IDs for logged-in users and billing email for guests, with additional alias checks in checkout/Store API.
      
      ## Classification and exclusion semantics
      
      Product-style coupon:
      
      - allowed product/category lists select lines;
      - excluded product/category/sale lines are skipped;
      - the coupon stays valid when at least one qualifying line remains;
      - `limit_usage_to_x_items` caps qualifying units.
      
      Cart-style coupon:
      
      - included product/category lists require at least one match;
      - an excluded product, excluded category, or sale item anywhere in the cart invalidates the whole coupon;
      - classification changes validity semantics only: built-in `fixed_cart` uses its cart allocator, while a custom cart-classified type still uses `apply_coupon_custom()` and its own per-unit calculation filter.
      
      These semantics come from `validate_coupon_excluded_items()`, `validate_coupon_eligible_items()`, and `WC_Coupon::is_valid_for_product()/is_valid_for_cart()`. Do not decide classification only from the UI label.
      
      Variations compare both variation ID and parent ID for product restrictions. Category resolution includes parent categories where relevant. Test both.
      
      ## Hook contracts
      
      | Hook | Arguments | Use |
      |---|---|---|
      | `woocommerce_coupon_discount_types` | types | Add global slug/label. |
      | `woocommerce_product_coupon_types` | slugs | Choose per-line rule semantics. |
      | `woocommerce_cart_coupon_types` | slugs | Choose cart-wide rule semantics. |
      | `woocommerce_coupon_get_discount_amount` | discount, discounting amount, cart/order item, single, coupon | Calculate per-unit custom discount. |
      | `woocommerce_coupon_sort` | sort, coupon | Define stacking order. |
      | `woocommerce_coupon_is_valid` | valid, coupon, discounts | Add whole-coupon rule. Return boolean; do not throw for ordinary denial unless a deliberate custom message is required. |
      | `woocommerce_coupon_is_valid_for_product` | valid, product, coupon, values | Add per-line rule. Four arguments. |
      | `woocommerce_coupon_is_valid_for_cart` | valid, coupon | Override low-level cart-family applicability. Prefer final validity for ordinary rules; forcing true can bypass per-item selection. |
      | `woocommerce_coupon_get_items_to_validate` | items, discounts | Narrow validation universe only with a documented policy. |
      | `woocommerce_coupon_get_items_to_apply` | eligible items, coupon, discounts | Final allocation set. Prefer removal, not addition. |
      | `woocommerce_coupon_get_apply_quantity` | quantity, normalized item, coupon, discounts | Cap discounted quantity. |
      | `woocommerce_apply_individual_use_coupon` | coupons to keep, new coupon, applied codes | Permit selected existing coupons to remain. |
      | `woocommerce_apply_with_individual_use_coupon` | allow, new coupon, existing individual coupon, applied codes | Permit a new code beside an individual-use code. |
      | `woocommerce_checkout_create_order_coupon_item` | coupon item, code, coupon, order | Store a separate immutable custom snapshot. |
      | `woocommerce_order_recalculate_coupons_coupon_object` | coupon, code, coupon item, order | Restore custom order-only snapshot before recalculation. |
      | `woocommerce_coupon_options_usage_restriction` | coupon ID, coupon | Render custom classic-admin restriction fields. |
      | `woocommerce_coupon_options_save` | coupon ID, coupon | Sanitize and persist custom fields through coupon CRUD. |
      
      Callbacks must preserve other plugins' values and avoid global session assumptions. Validation/calculation can run against either a cart or an order.
      
      ## Usage accounting and concurrency
      
      Persistent coupon global usage is stored as coupon meta `usage_count`; per-user records are repeated `_used_by` meta values.
      
      Checkout calls `WC_Order::hold_applied_coupons()` for limited coupons. The coupon data store creates expiring tentative meta keys:
      
      - `_coupon_held_<expiry>_<random>` for global slots;
      - `_maybe_used_by_<expiry>_<random>` for customer slots.
      
      The hold duration follows the stock-hold setting with at least one minute and is filterable by `woocommerce_coupon_hold_minutes`. SQL conditionally inserts a hold only below the limit and retries expected deadlocks up to three times.
      
      `wc_update_coupon_usage_counts()` is hooked to pending, processing, on-hold, completed, cancelled, failed, and trash transitions. It uses the order's `recorded_coupon_usage_counts` property to make repeated transitions idempotent:
      
      - any status not in the invalid list counts;
      - default invalid statuses are cancelled, failed, and trash;
      - `woocommerce_update_coupon_usage_invalid_statuses` can change the list;
      - counts and one `_used_by` row are decreased when moving into invalid state;
      - holds are released when converted or abandoned.
      
      Refund objects do not automatically imply coupon usage restoration. Decide whether a full/partial refund should release usage and implement it once, idempotently, if merchant policy requires it.
      
      Do not directly edit `usage_count` or `_used_by` under concurrency. Do not add a second counter around core persistent coupons.
      
      ## Order snapshots and deterministic recalculation
      
      Checkout creates a `WC_Order_Item_Coupon` containing code, realized discount, discount tax, and `coupon_info`.
      
      Since WC 8.7, `coupon_info` is a compact JSON array containing only:
      
      1. coupon ID;
      2. code;
      3. type (`null` means fixed cart);
      4. nominal amount;
      5. optional free-shipping flag.
      
      Do not extend or change this format. Add separate namespaced order-item metadata for custom rule inputs/outcomes.
      
      When an order recalculates:
      
      - coupon-wide validation is disabled (`apply_coupon( $coupon, false )`); this is historical replay, not a fresh redemption eligibility check;
      - an existing persisted coupon is reloaded with its current definition;
      - if missing/virtual, Woo reconstructs a temporary coupon from `coupon_info`;
      - `woocommerce_order_recalculate_coupons_coupon_object` can restore a separately stored immutable snapshot;
      - the custom calculation plugin still needs to be active, otherwise the custom type yields no intended calculation.
      
      `apply_coupon()` itself invokes this replay before returning. For virtual coupons, capture the original object and separate restrictions in a scoped recalculation filter before calling it. The compact snapshot contains neither product/category restrictions nor custom entitlement facts. See `wc-coupon-dynamic` and its order-application reference.
      
      This means mutable coupon definitions and external rule state can change historical recalculation. Choose and document one policy:
      
      - live policy: recalculation intentionally uses today's coupon/rules;
      - snapshot policy: capture custom rate/tier/eligibility inputs on the coupon line and restore them for order recalculation.
      
      Avoid using current user/session or an unbounded remote call when recalculating an old order.
      
      ## Admin, REST, Store API, and Blocks
      
      The classic coupon editor gets registered type labels from `wc_get_coupon_types()`. Unknown custom percentage-like types use price-style amount UI and are not automatically capped at 100; validate custom ranges yourself.
      
      Add custom panels/fields with:
      
      - `woocommerce_coupon_data_tabs` and `woocommerce_coupon_data_panels` for a full tab;
      - `woocommerce_coupon_options`, `_usage_restriction`, or `_usage_limit` for smaller fields;
      - `woocommerce_coupon_options_save` for sanitized CRUD persistence.
      
      WC REST v3 `/wc/v3/coupons` uses `wc_get_coupon_types()` for the `discount_type` enum and supports `meta_data`. Registration must run during REST requests. This is the administrative coupon API, not the shopper cart API.
      
      The Store API applies coupons through server-side `WC_Coupon` and `WC_Discounts`, so globally registered custom types and validation/calculation hooks work for Cart/Checkout Blocks. Apply with `POST /wc/store/v1/cart/apply-coupon` or the coupons collection under the correct Nonce/Cart-Token session contract. The legacy by-code route regex is more restrictive than the apply-coupon body, so prefer simple namespaced codes using letters, digits, underscores, and hyphens.
      
      Classic and Store API individual-use paths both invoke the same two stacking filters, but they use different controller code. Test both.
      
      ## Custom rule fields
      
      Keep the admin field, storage, validation, and snapshot layers explicit:
      
      ```php
      add_action( 'woocommerce_coupon_options_usage_restriction', function ( $coupon_id, WC_Coupon $coupon ) {
      	woocommerce_wp_text_input(
      		array(
      			'id'          => '_myplugin_required_tier',
      			'label'       => __( 'Required tier', 'myplugin' ),
      			'value'       => $coupon->get_meta( '_myplugin_required_tier', true ),
      			'description' => __( 'Internal membership tier slug.', 'myplugin' ),
      			'desc_tip'    => true,
      		)
      	);
      }, 10, 2 );
      
      add_action( 'woocommerce_coupon_options_save', function ( $coupon_id, WC_Coupon $coupon ) {
      	// Core has already checked the coupon editor request; still sanitize your field.
      	$value = isset( $_POST['_myplugin_required_tier'] )
      		? sanitize_key( wp_unslash( $_POST['_myplugin_required_tier'] ) )
      		: '';
      
      	$coupon->update_meta_data( '_myplugin_required_tier', $value );
      	$coupon->save();
      }, 10, 2 );
      ```
      
      For a custom public REST field, register a schema/callback or use `meta_data` with an explicit authorization policy. Never accept customer-submitted eligibility facts at checkout.
      
      ## Regression matrix
      
      At minimum test:
      
      - type registry present and absent during admin, REST, frontend, CLI, cron;
      - label-only registration produces no discount, then correct product/cart classification;
      - product-style mixed eligible/excluded cart;
      - cart-style prohibited item invalidates the entire coupon;
      - parent product versus variation IDs and categories;
      - sale-price products and already-zero lines;
      - quantity limits and fractional/custom unit math;
      - prices including/excluding tax, tax classes, zero-decimal and multi-decimal currencies;
      - custom amount `0`, negative attempt, over-item value, and percent-like value over 100;
      - min/max subtotal, allowed email wildcard, guest/user identity, expiry boundary/timezone;
      - individual use and stacking with sequential calculation on/off;
      - classic cart/checkout and Blocks/Store API;
      - REST v3 create/read/update of custom type;
      - existing-order apply, cancellation/failure, pending recovery, partial/full refund policy;
      - fee-only pending order: successful native apply, zero allocation, usage consumed; integration rejects before mutation;
      - Woo 11.1 restriction overrides: true/false polarity, later validators, and allocation separately;
      - order entitlement belongs to the customer, including administrator and CLI actors;
      - concurrent final usage slot with tentative holds;
      - historical recalculation after coupon edit/delete and after plugin deactivation;
      - custom metadata snapshot and redaction in REST/logs.
      
  • SKILL.md 14 KB
    ---
    name: wc-coupon-types-rules
    description: Implement, extend, or audit WooCommerce coupon types, persisted coupon CRUD, eligibility rules, inclusions/exclusions, stacking, usage limits, and order lifecycle behavior. Covers the complete custom discount-type contract (`woocommerce_coupon_discount_types`, product-vs-cart classification, calculation, and sort order), `WC_Coupon` setters, `WC_Discounts`, native product/category/sale/email/spend restrictions, custom validation hooks, admin fields, Store API and REST compatibility, concurrency holds, order snapshots, refunds/cancellations, taxes, and deterministic recalculation. Use when a plugin adds a coupon type or rule engine, changes which products/users qualify, creates coupons programmatically, auto-applies coupons, or produces incorrect/zero/duplicated discounts.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "woocommerce"
      wp-skills-plugin-version-tested: "11.1.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-09-21"
    ---
    
    # 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.
    
    ```php
    $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 `===`.
    
    The existence check is not an atomic uniqueness guarantee. Serialize concurrent creation in the integration's own job/idempotency mechanism; do not assume a unique database constraint on coupon codes.
    
    ## 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()`.
    
    ```php
    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:
    
    ```php
    // 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:
    
    ```php
    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:
    
    ```php
    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.
    
    Woo 11.1 adds six `woocommerce_coupon_is_valid_for_*` restriction filters, each receiving `(valid, coupon, discounts)`. Unlike `woocommerce_coupon_validate_*` rejection predicates, **true means valid**. See the reference table before overriding native restrictions: later validators and per-product allocation still apply.
    
    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](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_by` records 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
    
    ```php
    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.
    
    ### Reject zero-allocation monetary applications before mutation
    
    `WC_Discounts` takes product lines from an order. Fees and shipping are outside that allocation. On the tested pending fee-only order, both unrestricted `percent` and `fixed_cart` coupons returned success, left the total unchanged, and consumed usage. A valid coupon is not proof of a realized discount.
    
    For an integration that promises a monetary discount, reject orders without eligible product lines **before** `apply_coupon()` or external entitlement reservation. If a preflight uses `WC_Discounts`, inspect its actual per-code allocation as well as validation; an existing discount/stacking policy can still affect the final amount. After applying, inspect the matching coupon line and product totals. Handle an unexpected zero result through an explicit rollback/error policy using Woo APIs, not direct counter edits. Free-shipping-only and deliberately zero-value coupons need their own policy.
    
    Model a sale of a service as a genuine, non-stock-managed product when that matches the business meaning. Do not suppress stock handling on an entire mixed order to make a synthetic product line work. Do not turn fees into negative rows to imitate native coupon restrictions.
    
    Historical `recalculate_coupons()` deliberately disables coupon-wide validation. Product allocation/calculation filters still matter; restore immutable policy before the first replay. For virtual coupons, use the scoped filter in `wc-coupon-dynamic`, not a snapshot repair after `apply_coupon()` returns.
    
    ## Compatibility and security checklist
    
    1. Register the type on every frontend, REST, CLI, cron, and admin request before coupon hydration.
    2. Namespace the slug; preserve other plugins' filter values.
    3. 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.
    4. Test tax-inclusive/exclusive prices, multiple tax classes, currency decimals, rounding, and discounts larger than the item.
    5. Test stacking, individual-use behavior, both sequential settings, and deterministic sort.
    6. Test classic cart/checkout, Cart and Checkout Blocks, Store API, REST v3 coupon CRUD, admin order application/recalculation, refunds, cancellations, and plugin deactivation.
    7. Keep custom rule secrets and customer entitlements server-side; error text must not leak hidden eligibility data.
    8. Define migration/uninstall behavior: persisted custom-type coupons become unloadable or recalculate incorrectly when registration/calculation disappears.
    
    ## Cross-references
    
    - Use `wc-coupon-dynamic` for virtual/non-`shop_coupon` codes and external usage accounting.
    - Use `wc-cart-checkout-classic` for classic cart state and order-item transfer.
    - Use `wc-order-lifecycle-and-items` for order status and refund side effects.
    
    ## References
    
    - [Disposable CLI smoke example](../wcs-upgrade-compatibility/examples/woo-skills-smoke/woo-skills-smoke.php)
    - Verified source paths:
      - `wp-content/plugins/woocommerce/includes/class-wc-coupon.php`
      - `wp-content/plugins/woocommerce/includes/class-wc-discounts.php`
      - `wp-content/plugins/woocommerce/includes/class-wc-cart.php`
      - `wp-content/plugins/woocommerce/includes/class-wc-cart-totals.php`
      - `wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-order.php`
      - `wp-content/plugins/woocommerce/includes/wc-coupon-functions.php`
      - `wp-content/plugins/woocommerce/includes/wc-order-functions.php`
      - `wp-content/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php`
      - `wp-content/plugins/woocommerce/src/StoreApi/Utilities/CartController.php`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related