acquireNumberLock(); try { // Get the next number to use $next_number = get_option('easy_invoice_next_invoice_number', 1); // 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 = get_option('easy_invoice_next_invoice_number', 1); // 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. */ 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); } }