Home / Disable / WPForms — Quantity-Aware Form Locker Inventory
Duplicate Snippet

Embed Snippet on Your Site

WPForms — Quantity-Aware Form Locker Inventory

Use the Form Locker's Total Entry Limit as an inventory for product items, ensuring that you keep track of each item as it is entered.

Ralden Souza PRO
<10
Code Preview
php
<?php
/**
 * WPForms — Quantity-Aware Form Locker Inventory
 *
 * Makes WPForms Form Locker's "Total Entry Limit" behave as a unit/inventory
 * counter instead of a submission counter. When a customer submits a form,
 * the quantity they selected is deducted from the available stock rather than
 * a flat "1 per submission."
 *
 * Works with payment-single and payment-select fields that have the
 * "Enable Quantity" option turned on.
 *
 * Compatible with all WPForms payment addons (PayPal Commerce, Stripe,
 * Square, Authorize.Net). The inventory check runs at form submission time
 * regardless of payment gateway.
 *
 * SETUP
 * -----
 * 1. In WPForms Form Locker settings, enable "Total Entry Limit" and set the
 *    number to your TOTAL stock ever available (e.g. 71 bandanas = 71).
 * 2. Set the "Sold Out" message in Form Locker — it will display when stock
 *    reaches zero.
 * 3. Fill in the configuration section below with your Form ID, the Field IDs
 *    of every quantity-enabled field you want to track, and (optionally) the
 *    ID of an HTML field to display the live availability counter.
 *
 * HOW THE LIMIT WORKS — IMPORTANT
 * --------------------------------
 * The "Total Entry Limit" in Form Locker represents your TOTAL stock from the
 * very beginning — it is NOT reset by changing the number.
 *
 * The available stock is always calculated as:
 *   Available = Limit − (sum of all quantities in saved entries)
 *
 * This means:
 *
 *   - INCREASING the limit works as expected.
 *     Example: you started with 71, sold 20, and received 10 more units.
 *     Set the limit to 81. Available becomes 81 − 20 = 61. Correct.
 *
 *   - DECREASING the limit does NOT undo past sales.
 *     Example: you sold 20 units and then set the limit to 15.
 *     Available becomes max(0, 15 − 20) = 0. The form will appear sold out
 *     even though no real oversell happened.
 *
 *   - To set a specific number of units available going forward, use:
 *     New limit = units already sold + units you want to make available
 *     Example: sold 20, want 15 more available → set limit to 35.
 *
 *   - To find how many units have been sold, look at the Available counter
 *     in the HTML field (if configured) or calculate:
 *     Units sold = current limit − displayed available number.
 *
 * PERFORMANCE NOTE
 * ----------------
 * The snippet reads stored entry quantities by loading all non-abandoned,
 * non-partial entries for the form and summing the quantities. For forms with
 * a large number of entries this is acceptable; for very high-volume forms
 * consider adding a transient cache on top.
 */
// =============================================================================
// CONFIGURATION — edit these values to match your form
// =============================================================================
/**
 * Return the map of Form ID => quantity Field IDs to track for inventory.
 *
 * You can list multiple forms, each with one or more quantity fields.
 * If a form has more than one quantity field, ALL of them are summed together
 * into a single inventory pool tracked against the Form Locker limit.
 *
 * Example with a single form and one field:
 *   [ 123 => [ 6 ] ]
 *
 * @return array<int, int[]>
 */
function wpforms_inventory_get_config() {
    return [
        123 => [ 6 ], // Replace 123 with your Form ID, 6 with your quantity Field ID
    ];
}
/**
 * Return the map of Form ID => HTML Field ID for the availability counter.
 *
 * Optional. If set, the snippet will inject a live availability message into
 * the HTML field you specify. The field must already exist in your form — add
 * an HTML field in the form builder and note its Field ID (shown in the field
 * options panel as "Field ID #XX").
 *
 * Return an empty array ( [] ) to disable the counter entirely.
 *
 * The counter displays as:
 *   "Available: 51 of 71"   — when units remain
 *   "Sold out"              — when 0 units remain (uses your Form Locker message)
 *
 * Example:
 *   [ 123 => 8 ]   →  inject counter into HTML field #8 on form #123
 *
 * @return array<int, int>
 */
function wpforms_inventory_get_counter_config() {
    return [
        123 => 8, // Replace 8 with the Field ID of your HTML field, or remove this line
    ];
}
// =============================================================================
// END CONFIGURATION — do not edit below this line
// =============================================================================
/**
 * Sum all units already sold for a given form.
 *
 * Loads all completed entries for the form, reads the quantity stored in each
 * tracked field, and returns the total units consumed so far. Abandoned and
 * partial entries are excluded to mirror Form Locker's own counting logic.
 *
 * @param int   $form_id   WPForms form ID.
 * @param int[] $field_ids Field IDs to sum quantities for.
 *
 * @return int Total units sold across all saved entries.
 */
function wpforms_inventory_get_units_sold( $form_id, array $field_ids ) {
    $units_sold = 0;
    $entries = wpforms()->obj( 'entry' )->get_entries(
        [
            'form_id' => $form_id,
            'status'  => [ 'not_in' => [ 'abandoned', 'partial' ] ],
        ]
    );
    if ( empty( $entries ) || ! is_array( $entries ) ) {
        return 0;
    }
    foreach ( $entries as $entry ) {
        $fields = ! empty( $entry->fields ) ? json_decode( $entry->fields, true ) : [];
        if ( empty( $fields ) || ! is_array( $fields ) ) {
            continue;
        }
        foreach ( $field_ids as $field_id ) {
            if ( ! isset( $fields[ $field_id ] ) ) {
                continue;
            }
            // If quantity is not stored, the field was submitted without the
            // quantity feature enabled, so count it as 1.
            $quantity = isset( $fields[ $field_id ]['quantity'] )
                ? absint( $fields[ $field_id ]['quantity'] )
                : 1;
            $units_sold += $quantity;
        }
    }
    return $units_sold;
}
/**
 * Get the total inventory limit configured in Form Locker for a given form.
 *
 * Returns 0 if the Form Locker entry limit is not enabled for the form.
 *
 * @param int $form_id WPForms form ID.
 *
 * @return int Configured stock limit, or 0 if not set.
 */
function wpforms_inventory_get_stock_limit( $form_id ) {
    $form = wpforms()->obj( 'form' )->get( $form_id );
    if ( empty( $form ) ) {
        return 0;
    }
    $settings = wpforms_decode( $form->post_content );
    if (
        empty( $settings['settings']['form_locker_entry_limit_enable'] ) ||
        empty( $settings['settings']['form_locker_entry_limit'] )
    ) {
        return 0;
    }
    return absint( $settings['settings']['form_locker_entry_limit'] );
}
/**
 * Get the units available (remaining stock) for a form.
 *
 * @param int   $form_id   WPForms form ID.
 * @param int[] $field_ids Quantity field IDs to track.
 *
 * @return int Units remaining. Can be 0 (sold out) but never negative.
 */
function wpforms_inventory_get_units_available( $form_id, array $field_ids ) {
    $stock_limit = wpforms_inventory_get_stock_limit( $form_id );
    if ( $stock_limit <= 0 ) {
        return 0;
    }
    $units_sold = wpforms_inventory_get_units_sold( $form_id, $field_ids );
    return max( 0, $stock_limit - $units_sold );
}
/**
 * Get the sold-out message configured in Form Locker for a given form.
 *
 * Falls back to a generic message if the admin left the field blank.
 *
 * @param int $form_id WPForms form ID.
 *
 * @return string Sold-out message, may contain HTML.
 */
function wpforms_inventory_get_sold_out_message( $form_id ) {
    $form = wpforms()->obj( 'form' )->get( $form_id );
    if ( empty( $form ) ) {
        return '';
    }
    $settings = wpforms_decode( $form->post_content );
    $message  = $settings['settings']['form_locker_entry_limit_message'] ?? '';
    if ( empty( $message ) ) {
        $message = __( 'Sorry, this item is sold out.', 'wpforms-lite' );
    }
    return $message;
}
/**
 * Block over-purchase at submission time.
 *
 * Runs before the entry is saved. Checks whether the quantity requested in
 * the current submission would exceed the remaining stock. If so, attaches a
 * field-level error so the customer sees which field is the problem.
 *
 * Also handles the sold-out case (0 units remaining) as a server-side backstop
 * in case the customer bypasses the frontend lock or submits with JavaScript
 * disabled.
 *
 * @link https://wpforms.com/developers/wpforms_process_initial_errors/
 *
 * @param array $errors    Existing process errors.
 * @param array $form_data Form data and settings.
 *
 * @return array
 */
function wpforms_inventory_block_overpurchase( $errors, $form_data ) {
    $config  = wpforms_inventory_get_config();
    $form_id = absint( $form_data['id'] );
    if ( ! isset( $config[ $form_id ] ) ) {
        return $errors;
    }
    $field_ids = $config[ $form_id ];
    $available = wpforms_inventory_get_units_available( $form_id, $field_ids );
    $requested = 0;
    foreach ( $field_ids as $field_id ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $qty = isset( $_POST['wpforms']['quantities'][ $field_id ] )
            ? absint( $_POST['wpforms']['quantities'][ $field_id ] )
            : 0;
        $requested += $qty;
    }
    if ( $requested <= 0 ) {
        return $errors;
    }
    if ( $available <= 0 ) {
        $errors[ $form_id ]['form_locker'] = 'entry_limit';
        $errors[ $form_id ]['header']      = wpforms_inventory_get_sold_out_message( $form_id );
        return $errors;
    }
    if ( $requested > $available ) {
        foreach ( $field_ids as $field_id ) {
            $errors[ $form_id ][ $field_id ] = sprintf(
                /* translators: %d = number of units still available. */
                _n(
                    'Only %d unit is available. Please reduce your quantity.',
                    'Only %d units are available. Please reduce your quantity.',
                    $available,
                    'wpforms-lite'
                ),
                $available
            );
        }
    }
    return $errors;
}
add_filter( 'wpforms_process_initial_errors', 'wpforms_inventory_block_overpurchase', 20, 2 );
/**
 * Lock the form on the frontend when stock is exhausted.
 *
 * Prevents the form from rendering when units_available reaches 0 and
 * displays the sold-out message configured in Form Locker. Mirrors the
 * behaviour of Form Locker's own EntryLimit::display_form() but uses unit
 * count rather than submission count as the trigger.
 *
 * @link https://wpforms.com/developers/wpforms_frontend_load/
 *
 * @param bool  $load_form Whether the form should be loaded.
 * @param array $form_data Form data and settings.
 *
 * @return bool
 */
function wpforms_inventory_lock_frontend( $load_form, $form_data ) {
    $config  = wpforms_inventory_get_config();
    $form_id = absint( $form_data['id'] );
    if ( ! isset( $config[ $form_id ] ) ) {
        return $load_form;
    }
    $field_ids = $config[ $form_id ];
    $available = wpforms_inventory_get_units_available( $form_id, $field_ids );
    if ( $available > 0 ) {
        return $load_form;
    }
    add_action(
        'wpforms_frontend_not_loaded',
        function () use ( $form_id ) {
            $message = wpforms_inventory_get_sold_out_message( $form_id );
            if ( ! $message ) {
                return;
            }
            echo '<div class="wpforms-container">';
            echo '<div class="form-locked-message">';
            echo wp_kses_post( wpautop( $message ) );
            echo '</div>';
            echo '</div>';
        },
        10
    );
    return false;
}
add_filter( 'wpforms_frontend_load', 'wpforms_inventory_lock_frontend', 20, 2 );
/**
 * Inject the availability counter into an HTML field.
 *
 * Replaces the content of the configured HTML field with a live availability
 * message on every frontend page load. HTML fields render their content from
 * $field['code'], which is what this function writes into.
 *
 * Display format:
 *   "Available: 51 of 71"   — when units remain
 *   Sold-out message        — when 0 units remain
 *
 * @link https://wpforms.com/developers/wpforms_field_data/
 *
 * @param array $field     Field data and settings.
 * @param array $form_data Form data and settings.
 *
 * @return array
 */
function wpforms_inventory_inject_counter( $field, $form_data ) {
    $config         = wpforms_inventory_get_config();
    $counter_config = wpforms_inventory_get_counter_config();
    $form_id        = absint( $form_data['id'] );
    $field_id       = absint( $field['id'] );
    if (
        ! isset( $counter_config[ $form_id ] ) ||
        (int) $counter_config[ $form_id ] !== $field_id
    ) {
        return $field;
    }
    if ( $field['type'] !== 'html' ) {
        return $field;
    }
    if ( ! isset( $config[ $form_id ] ) ) {
        return $field;
    }
    $quantity_field_ids = $config[ $form_id ];
    $stock_limit        = wpforms_inventory_get_stock_limit( $form_id );
    $available          = wpforms_inventory_get_units_available( $form_id, $quantity_field_ids );
    if ( $available > 0 ) {
        $counter_html = sprintf(
            '<p class="wpforms-inventory-counter"><strong>%s</strong></p>',
            sprintf(
                /* translators: %1$d = units available, %2$d = total stock. */
                esc_html__( 'Available: %1$d of %2$d', 'wpforms-lite' ),
                $available,
                $stock_limit
            )
        );
    } else {
        $counter_html = sprintf(
            '<p class="wpforms-inventory-counter wpforms-inventory-sold-out"><strong>%s</strong></p>',
            wp_strip_all_tags( wpforms_inventory_get_sold_out_message( $form_id ) )
        );
    }
    $field['code'] = $counter_html;
    return $field;
}
add_filter( 'wpforms_field_data', 'wpforms_inventory_inject_counter', 10, 2 );
/**
 * Output real-time client-side inventory validation.
 *
 * Injects a JavaScript block into the page footer that registers a custom
 * jQuery Validate method (inventory_limit) and applies it to each configured
 * quantity select. The error appears immediately when the customer selects a
 * quantity that exceeds available stock, before they click Submit.
 *
 * The available stock value is passed from PHP to JS as an inline config
 * object so both sides always use the same number.
 *
 * The error is placed below the field description (if present) rather than
 * inline next to the quantity dropdown, matching the visual style of other
 * WPForms field errors. The server-side check in wpforms_inventory_block_overpurchase
 * still runs on submission as a backstop when JavaScript is disabled.
 *
 * @link https://wpforms.com/developers/wpforms_wp_footer/
 */
function wpforms_inventory_realtime_validation() {
    $config    = wpforms_inventory_get_config();
    $js_config = [];
    foreach ( $config as $form_id => $field_ids ) {
        $available = wpforms_inventory_get_units_available( $form_id, $field_ids );
        foreach ( $field_ids as $field_id ) {
            $js_config[ $form_id ][ $field_id ] = $available;
        }
    }
    if ( empty( $js_config ) ) {
        return;
    }
    ?>
    <script type="text/javascript">
    ( function( $ ) {
        var wpformsInventory = <?php echo wp_json_encode( $js_config ); ?>;
        $( document ).on( 'wpformsReady', function() {
            if ( typeof $.fn.validate === 'undefined' ) {
                return;
            }
            // Register the custom validation method.
            // param = units available (integer passed from PHP).
            // value = selected quantity (string from the <select>).
            $.validator.addMethod(
                'inventory_limit',
                function( value, element, param ) {
                    return this.optional( element ) || parseInt( value, 10 ) <= param;
                },
                function( param ) {
                    if ( param === 1 ) {
                        return 'Only 1 unit is available. Please reduce your quantity.';
                    }
                    return 'Only ' + param + ' units are available. Please reduce your quantity.';
                }
            );
            // Override errorPlacement for inventory_limit errors so the message
            // appears below the field description rather than next to the select.
            $( '.wpforms-form' ).each( function() {
                var validator = $( this ).data( 'validator' );
                if ( ! validator ) {
                    return;
                }
                var originalErrorPlacement = validator.settings.errorPlacement;
                validator.settings.errorPlacement = function( error, element ) {
                    if ( element.hasClass( 'wpforms-inventory-quantity' ) ) {
                        var $description = element
                            .closest( '.wpforms-field' )
                            .find( '.wpforms-field-description' );
                        if ( $description.length ) {
                            error.insertAfter( $description );
                        } else {
                            error.insertAfter(
                                element.closest( '.wpforms-single-item-price-content' )
                            );
                        }
                        return;
                    }
                    if ( typeof originalErrorPlacement === 'function' ) {
                        originalErrorPlacement.call( this, error, element );
                    } else {
                        error.insertAfter( element );
                    }
                };
            } );
            // Apply the inventory_limit rule to each configured quantity select.
            $.each( wpformsInventory, function( formId, fields ) {
                $.each( fields, function( fieldId, available ) {
                    var $select = $( '#wpforms-' + formId + '-field_' + fieldId + '-quantity' );
                    if ( ! $select.length ) {
                        return;
                    }
                    $select.addClass( 'wpforms-inventory-quantity' );
                    $select.rules( 'add', {
                        inventory_limit: available,
                        messages: {
                            inventory_limit: available === 1
                                ? 'Only 1 unit is available. Please reduce your quantity.'
                                : 'Only ' + available + ' units are available. Please reduce your quantity.'
                        }
                    } );
                } );
            } );
        } );
    } )( jQuery );
    </script>
    <?php
}
add_action( 'wpforms_wp_footer_end', 'wpforms_inventory_realtime_validation', 30 );

Comments

Add a Comment