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); } }