PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.7
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.7
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Services / QuoteNumberService.php

QuoteNumberService.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.3.7, at includes/Services/QuoteNumberService.php

235 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Quote Number Service
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 /**
12 * QuoteNumberService Class
13 *
14 * Handles automatic generation of quote numbers based on settings.
15 */
16 class QuoteNumberService {
17
18 /**
19 * MySQL named lock used to serialise concurrent quote-number
20 * generation. Distinct from the invoice-number lock so the two
21 * flows never block each other.
22 */
23 private const NUMBER_LOCK_NAME = 'easy_invoice_quote_number_gen';
24
25 /**
26 * Generate the next quote number
27 *
28 * @return string The generated quote number
29 */
30 public function generateNextNumber(): string {
31 // Get settings
32 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
33
34 // Serialise the read-check-write triplet against concurrent
35 // generation. Without this, two simultaneous create-quote
36 // requests can both read the same counter, both pass
37 // findNextUniqueNumber() (because neither has written to
38 // wp_postmeta yet), and both emit the same quote number.
39 $lock_acquired = $this->acquireNumberLock();
40 try {
41 // Get the next number to use
42 $next_number = get_option('easy_invoice_next_quote_number', 1);
43
44 // Find the next unique number
45 $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
46
47 // Update the counter to the number we actually used + 1 for next time
48 update_option('easy_invoice_next_quote_number', $unique_number + 1);
49
50 return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
51 } finally {
52 if ($lock_acquired) {
53 $this->releaseNumberLock();
54 }
55 }
56 }
57
58 /**
59 * Get the next quote number without incrementing
60 *
61 * @return string The next quote number
62 */
63 public function getNextNumber(): string {
64 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
65 $next_number = get_option('easy_invoice_next_quote_number', 1);
66
67 // Find what the next unique number would be
68 $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
69
70 return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
71 }
72
73 /**
74 * Find the next unique number starting from the given number
75 *
76 * @param int $start_number The number to start checking from
77 * @param string $prefix The prefix to use for checking
78 * @return int The next unique number
79 */
80 private function findNextUniqueNumber(int $start_number, string $prefix): int {
81 $current_number = $start_number;
82 $max_attempts = 1000; // Prevent infinite loops
83 $attempts = 0;
84
85 while ($attempts < $max_attempts) {
86 $quote_number = $prefix . str_pad($current_number, 6, '0', STR_PAD_LEFT);
87
88 if (!$this->numberExists($quote_number)) {
89 return $current_number;
90 }
91
92 $current_number++;
93 $attempts++;
94 }
95
96 // If we can't find a unique number, add timestamp to ensure uniqueness
97 return $current_number + time();
98 }
99
100 /**
101 * Reset the quote number counter
102 *
103 * @param int $new_starting_number The new starting number
104 * @return void
105 */
106 public function resetCounter(int $new_starting_number = 1): void {
107 update_option('easy_invoice_next_quote_number', $new_starting_number);
108 }
109
110 /**
111 * Check if a quote number already exists
112 *
113 * @param string $quote_number The quote number to check
114 * @return bool True if the number exists, false otherwise
115 */
116 public function numberExists(string $quote_number): bool {
117 global $wpdb;
118
119 $result = $wpdb->get_var($wpdb->prepare(
120 "SELECT COUNT(*) FROM {$wpdb->postmeta}
121 WHERE meta_key = '_easy_invoice_quote_number'
122 AND meta_value = %s",
123 $quote_number
124 ));
125
126 return intval($result) > 0;
127 }
128
129 /**
130 * Generate a unique quote number (handles duplicates)
131 *
132 * @return string A unique quote number
133 */
134 public function generateUniqueNumber(): string {
135 // Get settings
136 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
137
138 // Same concurrency guard as generateNextNumber() — see comment
139 // there for rationale. These two methods are duplicate public
140 // entry points kept for backward-compat; both need the lock.
141 $lock_acquired = $this->acquireNumberLock();
142 try {
143 // Get the next number to use from the current settings
144 $next_number = get_option('easy_invoice_next_quote_number', 1);
145
146 // Find the next unique number
147 $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
148
149 // Update the counter to the number we actually used + 1 for next time
150 update_option('easy_invoice_next_quote_number', $unique_number + 1);
151
152 return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
153 } finally {
154 if ($lock_acquired) {
155 $this->releaseNumberLock();
156 }
157 }
158 }
159
160 /**
161 * Acquire a MySQL named lock for the read-check-write triplet.
162 * Returns true if the lock was acquired (and must be released by
163 * the caller), false on timeout or backend failure (caller falls
164 * through to the unsynchronised path — the secondary
165 * numberExists() check in findNextUniqueNumber() still defends
166 * against the worst case).
167 */
168 private function acquireNumberLock(): bool {
169 global $wpdb;
170 $result = $wpdb->get_var($wpdb->prepare(
171 'SELECT GET_LOCK(%s, %d)',
172 self::NUMBER_LOCK_NAME,
173 3
174 ));
175 return (int) $result === 1;
176 }
177
178 /**
179 * Release the MySQL named lock. Safe to call even when the lock
180 * isn't held by this connection — RELEASE_LOCK returns NULL and
181 * the call is a no-op.
182 */
183 private function releaseNumberLock(): void {
184 global $wpdb;
185 $wpdb->query($wpdb->prepare(
186 'SELECT RELEASE_LOCK(%s)',
187 self::NUMBER_LOCK_NAME
188 ));
189 }
190
191 /**
192 * Get the highest quote number from existing quotes
193 *
194 * @return int The highest quote number found
195 */
196 public function getHighestQuoteNumber(): int {
197 global $wpdb;
198
199 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
200
201 // Get all quote numbers from the database
202 $results = $wpdb->get_results($wpdb->prepare(
203 "SELECT meta_value FROM {$wpdb->postmeta}
204 WHERE meta_key = '_easy_invoice_quote_number'
205 AND meta_value LIKE %s
206 ORDER BY meta_value DESC
207 LIMIT 1",
208 $prefix . '%'
209 ));
210
211 if (empty($results)) {
212 return 0;
213 }
214
215 $highest_number = $results[0]->meta_value;
216
217 // Extract the number part (remove prefix and padding)
218 $number_part = str_replace($prefix, '', $highest_number);
219 $number_part = ltrim($number_part, '0');
220
221 return intval($number_part);
222 }
223
224 /**
225 * Format a quote number with custom formatting
226 *
227 * @param int $number The number to format
228 * @param string $prefix The prefix to use
229 * @param int $padding The number of digits to pad to
230 * @return string The formatted quote number
231 */
232 public function formatNumber(int $number, string $prefix = 'QT-', int $padding = 6): string {
233 return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
234 }
235 }