# easy-invoice/2.4.0/includes/Services/QuoteNumberService.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.0. 287 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Services/QuoteNumberService.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.0/raw/includes/Services/QuoteNumberService.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Services/QuoteNumberService.php#L10-L20`.

```php
<?php
/**
 * Quote Number Service
 *
 * @package Easy_Invoice
 * @subpackage Services
 */

namespace EasyInvoice\Services;

/**
 * QuoteNumberService Class
 * 
 * Handles automatic generation of quote numbers based on settings.
 */
class QuoteNumberService {

    /**
     * MySQL named lock used to serialise concurrent quote-number
     * generation. Distinct from the invoice-number lock so the two
     * flows never block each other.
     */
    private const NUMBER_LOCK_NAME = 'easy_invoice_quote_number_gen';

    /**
     * Generate the next quote number
     *
     * @return string The generated quote number
     */
    public function generateNextNumber(): string {
        // Get settings
        $prefix = get_option('easy_invoice_quote_prefix', 'QT-');

        // Serialise the read-check-write triplet against concurrent
        // generation. Without this, two simultaneous create-quote
        // requests can both read the same counter, both pass
        // findNextUniqueNumber() (because neither has written to
        // wp_postmeta yet), and both emit the same quote number.
        $lock_acquired = $this->acquireNumberLock();
        try {
            // Get the next number to use
            $next_number = $this->freshCounter();

            // Find the next unique number
            $unique_number = $this->findNextUniqueNumber($next_number, $prefix);

            // Update the counter to the number we actually used + 1 for next time
            update_option('easy_invoice_next_quote_number', $unique_number + 1);

            return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
        } finally {
            if ($lock_acquired) {
                $this->releaseNumberLock();
            }
        }
    }
    
    /**
     * Get the next quote number without incrementing
     *
     * @return string The next quote number
     */
    public function getNextNumber(): string {
        $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
        $next_number = get_option('easy_invoice_next_quote_number', 1);
        
        // Find what the next unique number would be
        $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
        
        return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
    }
    
    /**
     * Find the next unique number starting from the given number
     *
     * @param int $start_number The number to start checking from
     * @param string $prefix The prefix to use for checking
     * @return int The next unique number
     */
    private function findNextUniqueNumber(int $start_number, string $prefix): int {
        $current_number = $start_number;
        $max_attempts = 1000; // Prevent infinite loops
        $attempts = 0;
        
        while ($attempts < $max_attempts) {
            $quote_number = $prefix . str_pad($current_number, 6, '0', STR_PAD_LEFT);
            
            if (!$this->numberExists($quote_number)) {
                return $current_number;
            }
            
            $current_number++;
            $attempts++;
        }
        
        // If we can't find a unique number, add timestamp to ensure uniqueness
        return $current_number + time();
    }
    
    /**
     * Reset the quote number counter
     *
     * @param int $new_starting_number The new starting number
     * @return void
     */
    public function resetCounter(int $new_starting_number = 1): void {
        update_option('easy_invoice_next_quote_number', $new_starting_number);
    }
    
    /**
     * Check if a quote number already exists
     *
     * @param string $quote_number The quote number to check
     * @return bool True if the number exists, false otherwise
     */
    public function numberExists(string $quote_number): bool {
        global $wpdb;
        
        $result = $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->postmeta} 
            WHERE meta_key = '_easy_invoice_quote_number' 
            AND meta_value = %s",
            $quote_number
        ));
        
        return intval($result) > 0;
    }
    
    /**
     * Generate a unique quote number (handles duplicates)
     *
     * @return string A unique quote number
     */
    public function generateUniqueNumber(): string {
        // Get settings
        $prefix = get_option('easy_invoice_quote_prefix', 'QT-');

        // Same concurrency guard as generateNextNumber() — see comment
        // there for rationale. These two methods are duplicate public
        // entry points kept for backward-compat; both need the lock.
        $lock_acquired = $this->acquireNumberLock();
        try {
            // Get the next number to use from the current settings
            $next_number = $this->freshCounter();

            // Find the next unique number
            $unique_number = $this->findNextUniqueNumber($next_number, $prefix);

            // Update the counter to the number we actually used + 1 for next time
            update_option('easy_invoice_next_quote_number', $unique_number + 1);

            return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
        } finally {
            if ($lock_acquired) {
                $this->releaseNumberLock();
            }
        }
    }

    /**
     * Acquire a MySQL named lock for the read-check-write triplet.
     * Returns true if the lock was acquired (and must be released by
     * the caller), false on timeout or backend failure (caller falls
     * through to the unsynchronised path — the secondary
     * numberExists() check in findNextUniqueNumber() still defends
     * against the worst case).
     */
    /**
     * Keep a number a form submitted, or hand out a fresh one.
     *
     * The builder pre-fills its number field with the next number without
     * taking it, so two people who opened "New" at the same time both post
     * the same number. Under the number lock: a number nobody uses yet is
     * kept, and if it is the counter's next value the counter moves past
     * it; a number already in use is replaced by a freshly generated one.
     *
     * @param string $requested The number the form sent, possibly empty.
     * @return string The number to save.
     */
    public function claimOrGenerate(string $requested): string {
        $requested = trim($requested);
        if ($requested === '') {
            return $this->generateUniqueNumber();
        }
        $lock_acquired = $this->acquireNumberLock();
        try {
            if ($this->numberExists($requested)) {
                return $this->generateUniqueNumber();
            }
            $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
            $next   = $this->freshCounter();
            if ($requested === $prefix . str_pad((string) $next, 6, '0', STR_PAD_LEFT)) {
                update_option('easy_invoice_next_quote_number', $next + 1);
            }
            return $requested;
        } finally {
            if ($lock_acquired) {
                $this->releaseNumberLock();
            }
        }
    }

    /**
     * The counter as the database holds it right now.
     *
     * Every request loads the options table into memory at boot, before
     * it queues for the number lock, so a plain get_option() inside the
     * lock returns whatever the counter was when *this* request started —
     * and twelve simultaneous saves all "uniquely" took the same number.
     * Drop the cached copy and read it again once the lock is held.
     *
     * @return int
     */
    private function freshCounter(): int {
        wp_cache_delete('easy_invoice_next_quote_number', 'options');
        wp_cache_delete('alloptions', 'options');
        return (int) get_option('easy_invoice_next_quote_number', 1);
    }

    private function acquireNumberLock(): bool {
        global $wpdb;
        $result = $wpdb->get_var($wpdb->prepare(
            'SELECT GET_LOCK(%s, %d)',
            self::NUMBER_LOCK_NAME,
            3
        ));
        return (int) $result === 1;
    }

    /**
     * Release the MySQL named lock. Safe to call even when the lock
     * isn't held by this connection — RELEASE_LOCK returns NULL and
     * the call is a no-op.
     */
    private function releaseNumberLock(): void {
        global $wpdb;
        $wpdb->query($wpdb->prepare(
            'SELECT RELEASE_LOCK(%s)',
            self::NUMBER_LOCK_NAME
        ));
    }
    
    /**
     * Get the highest quote number from existing quotes
     *
     * @return int The highest quote number found
     */
    public function getHighestQuoteNumber(): int {
        global $wpdb;
        
        $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
        
        // Get all quote numbers from the database
        $results = $wpdb->get_results($wpdb->prepare(
            "SELECT meta_value FROM {$wpdb->postmeta} 
            WHERE meta_key = '_easy_invoice_quote_number' 
            AND meta_value LIKE %s
            ORDER BY meta_value DESC
            LIMIT 1",
            $prefix . '%'
        ));
        
        if (empty($results)) {
            return 0;
        }
        
        $highest_number = $results[0]->meta_value;
        
        // Extract the number part (remove prefix and padding)
        $number_part = str_replace($prefix, '', $highest_number);
        $number_part = ltrim($number_part, '0');
        
        return intval($number_part);
    }
    
    /**
     * Format a quote number with custom formatting
     *
     * @param int $number The number to format
     * @param string $prefix The prefix to use
     * @param int $padding The number of digits to pad to
     * @return string The formatted quote number
     */
    public function formatNumber(int $number, string $prefix = 'QT-', int $padding = 6): string {
        return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
    }
} 
```
