# easy-invoice/2.4.1/includes/Services/InvoiceNumberService.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.1. 296 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Services/InvoiceNumberService.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.1/raw/includes/Services/InvoiceNumberService.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.1/code/includes/Services/InvoiceNumberService.php#L10-L20`.

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

namespace EasyInvoice\Services;

/**
 * InvoiceNumberService Class
 * 
 * Handles automatic generation of invoice numbers based on settings.
 */
class InvoiceNumberService {

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

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

        // Serialise the read-check-write triplet against concurrent
        // generation. Without this, two simultaneous create-invoice
        // requests can both read the same counter value, both pass
        // findNextUniqueNumber() (because neither has written to
        // wp_postmeta yet), and both emit the same invoice number.
        // The lock auto-releases on MySQL connection close, so we
        // can't leak it on a fatal PHP error.
        $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_invoice_number', $unique_number + 1);

            return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
        } finally {
            if ($lock_acquired) {
                $this->releaseNumberLock();
            }
        }
    }
    
    /**
     * Get the next invoice number without incrementing
     *
     * @return string The next invoice number
     */
    public function getNextNumber(): string {
        $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
        $next_number = get_option('easy_invoice_next_invoice_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);
    }
    
    /**
     * Reset the invoice 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_invoice_number', $new_starting_number);
    }
    
    /**
     * Check if an invoice number already exists
     *
     * @param string $invoice_number The invoice number to check
     * @return bool True if the number exists, false otherwise
     */
    public function numberExists(string $invoice_number): bool {
        global $wpdb;
        
        $result = $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->postmeta} 
            WHERE meta_key = '_easy_invoice_number' 
            AND meta_value = %s",
            $invoice_number
        ));
        
        return intval($result) > 0;
    }
    
    /**
     * Generate a unique invoice number (handles duplicates)
     *
     * @return string A unique invoice number
     */
    public function generateUniqueNumber(): string {
        // Get settings
        $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');

        // Same concurrency guard as generateNextNumber() — see comment
        // there for the rationale. These two methods are duplicate
        // public entry points kept for backward-compat; both need the
        // lock so neither call site is a race window.
        $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_invoice_number', $unique_number + 1);

            $final_number = $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);

            return $final_number;
        } 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).
     *
     * Timeout is 3s — if the database is so contended that even this
     * fails, blocking the user's create-invoice request longer is
     * worse than the residual race risk.
     */
    /**
     * 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_invoice_prefix', 'INV-');
            $next   = $this->freshCounter();
            if ($requested === $prefix . str_pad((string) $next, 6, '0', STR_PAD_LEFT)) {
                update_option('easy_invoice_next_invoice_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_invoice_number', 'options');
        wp_cache_delete('alloptions', 'options');
        return (int) get_option('easy_invoice_next_invoice_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 multiple times — if
     * 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
        ));
    }
    
    /**
     * 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) {
            $invoice_number = $prefix . str_pad($current_number, 6, '0', STR_PAD_LEFT);
            
            if (!$this->numberExists($invoice_number)) {
                return $current_number;
            }
            
            $current_number++;
            $attempts++;
        }
        
        // If we can't find a unique number, add timestamp to ensure uniqueness
        return $current_number + time();
    }
    
    /**
     * Get the highest invoice number from existing invoices
     *
     * @return int The highest invoice number found
     */
    public function getHighestInvoiceNumber(): int {
        global $wpdb;
        
        $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
        
        // Get all invoice numbers from the database
        $results = $wpdb->get_results($wpdb->prepare(
            "SELECT meta_value FROM {$wpdb->postmeta} 
            WHERE meta_key = '_easy_invoice_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 an invoice 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 invoice number
     */
    public function formatNumber(int $number, string $prefix = 'INV-', int $padding = 6): string {
        return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
    }
} 
```
