woocommerce-paypal-payments/modules/ppcp-agentic-commerce/docs/coupon-validator.md
Daniel Dudzic a94fb3f99b
Some checks failed
CI / PHP 7.4 (push) Has been cancelled
CI / PHP 8.0 (push) Has been cancelled
CI / PHP 8.1 (push) Has been cancelled
CI / PHP 8.2 (push) Has been cancelled
CI / PHP 8.3 (push) Has been cancelled
CI / PHP 8.4 (push) Has been cancelled
PR Playground Demo / prepare_version (push) Has been cancelled
PR Playground Demo / build_plugin (push) Has been cancelled
PR Playground Demo / create_archive (push) Has been cancelled
PR Playground Demo / Comment on PR with Playground details (push) Has been cancelled
📝 Update coupon-validator.md to reflect implementation changes
2026-01-23 19:22:43 +01:00

13 KiB

Coupon Validator

Validates WooCommerce coupons and provides enhanced context to PayPal's Agentic Commerce API.

Quick Reference

Core validation features:

  1. Case-insensitive coupon code matching via wc_sanitize_coupon_code()
  2. Comprehensive WooCommerce coupon validation (expiry, usage limits, restrictions)
  3. Enhanced context for failed validations (eligible items, shortage amounts, discount comparisons)
  4. Uses WC_Discounts for accurate discount calculation
  5. Minimal resolution options by default (suggested alternatives disabled unless explicitly enabled via filter)

How It Works

What it validates:

  • Coupon existence and expiration
  • Usage limits (global and per-user)
  • Minimum/maximum order amounts
  • Product/category restrictions
  • Email restrictions
  • Coupon stacking rules
  • WooCommerce coupon system status

Validation flow:

  1. Filter coupons with APPLY action only (skip REMOVE actions)
  2. Normalize coupon codes using wc_sanitize_coupon_code() for case-insensitive matching
  3. Check if coupons are enabled in WooCommerce via wc_coupons_enabled()
  4. Check for coupon stacking conflicts (individual_use coupons)
  5. Validate each coupon via WooCommerce's WC_Discounts::is_coupon_valid()
  6. Capture numeric error codes via woocommerce_coupon_error filter and map to PayPal issue types
  7. Build enhanced context via declarative context_builders configuration
  8. Apply filters to enrich data

WooCommerce Integration:

  • Uses wc_sanitize_coupon_code() to normalize coupon codes (case-insensitive matching)
  • Uses WC_Discounts for validation and discount calculation
  • Uses WC_Coupon::is_valid_for_product() for product eligibility checking
  • Uses wc_price() for currency formatting, respecting merchant's currency position settings
  • Uses wc_format_decimal() for consistent decimal formatting
  • Automatically supports third-party plugins that extend WooCommerce coupon functionality
  • Stacking conflicts: By default, WooCommerce allows multiple coupons unless "Individual use only" is enabled
  • When individual_use=true, the coupon cannot be combined with ANY other coupons

Issue Types

Issue Type Triggered When
COUPON_EXPIRED Expiration date passed
COUPON_NOT_EXIST Invalid/non-existent code
MINIMUM_ORDER_NOT_MET Cart total below minimum
MAXIMUM_ORDER_EXCEEDED Cart total above maximum
COUPON_NOT_APPLICABLE Product/category restrictions not met
USAGE_LIMIT_EXCEEDED Global or per-user limit hit
COUPON_STACKING_NOT_ALLOWED Can't combine coupons (individual_use)
COUPON_ALREADY_APPLIED Duplicate application
COUPON_EMAIL_RESTRICTED Email restriction failed
COUPON_INVALID Generic invalid coupon
COUPON_NOT_SUPPORTED Coupons disabled in WooCommerce

Error Mapping:

The validator maps WooCommerce errors to PayPal issue types using numeric error constants (100-116) from WC_Coupon. We use the woocommerce_coupon_error filter to capture the numeric error code when WC_Discounts::is_coupon_valid() fails.

Since WooCommerce error messages are localized (esc_html__), we cannot rely on pattern matching - instead we capture the numeric error code via the filter, making the validation work correctly for all languages.

Enhanced Context Fields

Context automatically includes:

// Basic context (always included)
'specific_issue' => 'COUPON_EXPIRED',
'coupon_code'    => 'SUMMER50',

// For minimum order not met
'minimum_required' => '100.00',
'current_subtotal' => '19.99',
'shortage_amount'  => '80.01',

// For product restrictions
'eligible_items' => array( 'PRODUCT-A', 'PRODUCT-B' ),

// For stacking conflicts
'current_discount'   => '32.00',
'attempted_discount' => '9.99',

Note: Suggested alternative coupons are disabled by default. Enable them via filter:

add_filter(
    'woocommerce_paypal_payments_agentic_commerce_suggested_alternative_coupons',
    function ( $alternatives, $failed_code, $specific_issue, $cart ) {
        // Return array of alternative coupon codes
        return array( 'SUMMER20', 'WELCOME15', 'FALL10' );
    },
    10,
    4
);

API Response Examples

Successful Coupon Application

{
  "id": "CART-123",
  "status": "READY",
  "validation_status": "VALID",
  "validation_issues": [],
  "applied_coupons": [
    {
      "code": "GAMING20",
      "description": "20% off gaming accessories",
      "discount_amount": {"currency_code": "USD", "value": "32.00"}
    }
  ],
  "totals": {
    "item_total": {"currency_code": "USD", "value": "159.98"},
    "discount": {"currency_code": "USD", "value": "32.00"},
    "shipping": {"currency_code": "USD", "value": "9.99"},
    "tax_total": {"currency_code": "USD", "value": "11.52"},
    "amount": {"currency_code": "USD", "value": "127.98"}
  }
}

Failed Coupon Validation (Default Response)

{
  "id": "CART-123",
  "status": "INCOMPLETE",
  "validation_status": "INVALID",
  "validation_issues": [
    {
      "code": "PRICING_ERROR",
      "type": "BUSINESS_RULE",
      "message": "Coupon has expired",
      "user_message": "The coupon code 'SUMMER50' has expired.",
      "field": "coupons[0]",
      "context": {
        "specific_issue": "COUPON_EXPIRED",
        "coupon_code": "SUMMER50",
        "expiration_date": "2024-05-31T23:59:59Z"
      },
      "resolution_options": [
        {
          "action": "REMOVE_COUPON",
          "label": "Remove coupon and continue",
          "metadata": {"priority": "low"}
        }
      ]
    }
  ]
}

Note: By default, resolution options are minimal (usually just REMOVE_COUPON). Actions like SUGGEST_ALTERNATIVE_COUPON, VIEW_AVAILABLE_COUPONS, and TRY_DIFFERENT_COUPON are not included by default because:

  • Most WooCommerce stores don't expose available coupons publicly
  • Suggesting alternatives requires custom logic to determine which coupons are actually available
  • These options should be explicitly enabled via the woocommerce_paypal_payments_agentic_commerce_coupon_validation_resolutions filter

Filter Integration

Three filters allow third-party plugins (Advanced Coupons, Smart Coupons, etc.) to extend validation.

These filters complement WooCommerce's native filters:

  • WooCommerce filters (woocommerce_coupon_is_valid, woocommerce_coupon_error, etc.) control core validation logic
  • Our filters enrich the API response structure and provide AI agent guidance
  • Third-party plugins can use both: WC filters for validation, our filters for Agentic Commerce features

1. Modify Resolution Options

Add or change resolution actions:

add_filter(
    'woocommerce_paypal_payments_agentic_commerce_coupon_validation_resolutions',
    function ( $resolutions, $issue_type, $code, $wc_coupon, $cart, $context ) {
        if ( $issue_type === 'COUPON_NOT_APPLICABLE' ) {
            $resolutions[] = array(
                'action'   => 'EARN_POINTS',
                'label'    => 'Complete a purchase to earn points',
                'metadata' => array( 'priority' => 'high' ),
            );
        }
        return $resolutions;
    },
    10,
    6
);

2. Customize User Message

Personalize error messages (richer context than WooCommerce's woocommerce_coupon_error):

add_filter(
    'woocommerce_paypal_payments_agentic_commerce_coupon_validation_user_message',
    function ( $user_message, $issue_type, $code, $wc_coupon, $cart, $context ) {
        $name = $cart->customer()['name'] ?? 'there';
        return "Hi {$name}! " . $user_message;
    },
    10,
    6
);

3. Control Alternative Coupons

Filter suggested alternatives:

add_filter(
    'woocommerce_paypal_payments_agentic_commerce_suggested_alternative_coupons',
    function ( $alternatives, $failed_code, $specific_issue, $cart ) {
        if ( is_vip_customer( $cart ) ) {
            array_unshift( $alternatives, 'VIP25' );
        }
        return array_slice( $alternatives, 0, 5 );
    },
    10,
    4
);

Complete Plugin Example

<?php
/**
 * Plugin Name: Advanced Coupons - PayPal Agentic Integration
 */

class ACFW_PayPal_Agentic_Integration {

    public function __construct() {
        add_filter(
            'woocommerce_coupon_is_valid',
            array( $this, 'validate_bogo' ),
            10,
            3
        );

        add_filter(
            'woocommerce_paypal_payments_agentic_commerce_coupon_validation_resolutions',
            array( $this, 'add_bogo_resolutions' ),
            10,
            6
        );
    }

    public function validate_bogo( $valid, $wc_coupon, $discounts ) {
        $bogo = get_post_meta( $wc_coupon->get_id(), '_acfw_bogo_deals', true );

        if ( empty( $bogo ) ) {
            return $valid;
        }

        $cart_items = WC()->cart->get_cart();
        $has_trigger = false;

        foreach ( $cart_items as $item ) {
            if ( $item['product_id'] === $bogo['trigger_product'] ) {
                $has_trigger = true;
                break;
            }
        }

        if ( ! $has_trigger ) {
            throw new Exception(
                sprintf( 'Add %s to unlock this BOGO deal.', get_the_title( $bogo['trigger_product'] ) ),
                WC_Coupon::E_WC_COUPON_NOT_APPLICABLE
            );
        }

        return $valid;
    }

    public function add_bogo_resolutions( $resolutions, $issue_type, $code, $wc_coupon, $cart, $context ) {
        if ( $issue_type !== 'COUPON_NOT_APPLICABLE' || ! $wc_coupon ) {
            return $resolutions;
        }

        $bogo = get_post_meta( $wc_coupon->get_id(), '_acfw_bogo_deals', true );

        if ( $bogo ) {
            array_unshift( $resolutions, array(
                'action'   => 'ADD_PRODUCT',
                'label'    => sprintf( 'Add %s to unlock BOGO deal', get_the_title( $bogo['trigger_product'] ) ),
                'metadata' => array(
                    'priority'   => 'high',
                    'product_id' => $bogo['trigger_product'],
                ),
            ) );
        }

        return $resolutions;
    }
}

new ACFW_PayPal_Agentic_Integration();

Best Practices

Use Both WooCommerce and Agentic Filters

Our filters complement WooCommerce's native filters:

add_filter( 'woocommerce_coupon_is_valid', function( $valid, $coupon, $discounts ) {
    if ( ! custom_check( $coupon ) ) {
        return false;
    }
    return $valid;
}, 10, 3 );

add_filter( 'woocommerce_paypal_payments_agentic_commerce_coupon_validation_resolutions',
    function( $resolutions, $issue_type, $code, $wc_coupon, $cart, $context ) {
        $resolutions[] = array(
            'action' => 'CUSTOM_ACTION',
            'label'  => 'Custom resolution for this issue',
        );
        return $resolutions;
    }, 10, 6
);

Preserve Existing Data

When filtering, always preserve existing array keys:

$context['custom_field'] = 'value';
return $context;

Check Null Values

WooCommerce coupon object can be null:

if ( ! $wc_coupon ) {
    return $result;
}

Use Specific Issue Types

Filter only relevant validation scenarios:

if ( $specific_issue !== 'COUPON_EXPIRED' ) {
    return $context;
}

Clear Resolution Labels

Provide actionable guidance for AI agents:

'label' => 'Add $50.00 more to qualify'

Implementation Details

Architecture:

Split into focused, single-responsibility classes:

Class Responsibility
CouponValidator Orchestrates validation flow, checks stacking conflicts, delegates to specialists
CouponContextBuilder Builds enriched context data using declarative pattern, uses WC native methods
DiscountCalculator Handles discount calculation via WC_Discounts, uses wc_format_decimal()
CouponResolutionBuilder Builds resolution options from templates, handles stacking-specific resolutions
AppliedCouponsBuilder Builds applied_coupons response data for successfully validated coupons

Declarative Configuration:

Each issue type declares what it needs via ISSUE_CONFIG:

'MINIMUM_ORDER_NOT_MET' => array(
    'message'          => 'Minimum order amount not met',
    'user_message'     => "The coupon '%s' requires a minimum order of %s. Your current order is %s.",
    'resolutions'      => array( 'add_items_to_minimum', 'continue_without' ),
    'context_builders' => array( 'minimum_spend', 'eligible_items' ),
),

Context builders are small focused methods in CouponContextBuilder:

  • build_expiration() - Adds expiration date
  • build_usage_limits() - Adds usage limit/count
  • build_minimum_spend() - Adds minimum required, current subtotal, shortage amount
  • build_maximum_spend() - Adds maximum allowed
  • build_eligible_items() - Adds list of eligible product variant IDs
  • build_stacking() - Adds stacking conflict details with discount comparison
  • build_email_restriction() - Adds email restriction flag

Note: Alternative coupon suggestions are handled separately via the woocommerce_paypal_payments_agentic_commerce_suggested_alternative_coupons filter, not as a declarative context builder.

Response Integration:

  • ResponseFactory uses AppliedCouponsBuilder to build coupon data before creating response objects
  • Response DTOs (CartResponse, NewCartResponse, PaidCartResponse) receive applied_coupons as plain array data
  • This keeps response classes as pure DTOs without business logic
  • AppliedCouponsBuilder::calculate_total_discount() provides discount totals for PayPal order updates