PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
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.4.0, at includes/Services/QuoteNumberService.php

287 lines 9.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 = $this->freshCounter();
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 = $this->freshCounter();
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 /**
169 * Keep a number a form submitted, or hand out a fresh one.
170 *
171 * The builder pre-fills its number field with the next number without
172 * taking it, so two people who opened "New" at the same time both post
173 * the same number. Under the number lock: a number nobody uses yet is
174 * kept, and if it is the counter's next value the counter moves past
175 * it; a number already in use is replaced by a freshly generated one.
176 *
177 * @param string $requested The number the form sent, possibly empty.
178 * @return string The number to save.
179 */
180 public function claimOrGenerate(string $requested): string {
181 $requested = trim($requested);
182 if ($requested === '') {
183 return $this->generateUniqueNumber();
184 }
185 $lock_acquired = $this->acquireNumberLock();
186 try {
187 if ($this->numberExists($requested)) {
188 return $this->generateUniqueNumber();
189 }
190 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
191 $next = $this->freshCounter();
192 if ($requested === $prefix . str_pad((string) $next, 6, '0', STR_PAD_LEFT)) {
193 update_option('easy_invoice_next_quote_number', $next + 1);
194 }
195 return $requested;
196 } finally {
197 if ($lock_acquired) {
198 $this->releaseNumberLock();
199 }
200 }
201 }
202
203 /**
204 * The counter as the database holds it right now.
205 *
206 * Every request loads the options table into memory at boot, before
207 * it queues for the number lock, so a plain get_option() inside the
208 * lock returns whatever the counter was when *this* request started —
209 * and twelve simultaneous saves all "uniquely" took the same number.
210 * Drop the cached copy and read it again once the lock is held.
211 *
212 * @return int
213 */
214 private function freshCounter(): int {
215 wp_cache_delete('easy_invoice_next_quote_number', 'options');
216 wp_cache_delete('alloptions', 'options');
217 return (int) get_option('easy_invoice_next_quote_number', 1);
218 }
219
220 private function acquireNumberLock(): bool {
221 global $wpdb;
222 $result = $wpdb->get_var($wpdb->prepare(
223 'SELECT GET_LOCK(%s, %d)',
224 self::NUMBER_LOCK_NAME,
225 3
226 ));
227 return (int) $result === 1;
228 }
229
230 /**
231 * Release the MySQL named lock. Safe to call even when the lock
232 * isn't held by this connection — RELEASE_LOCK returns NULL and
233 * the call is a no-op.
234 */
235 private function releaseNumberLock(): void {
236 global $wpdb;
237 $wpdb->query($wpdb->prepare(
238 'SELECT RELEASE_LOCK(%s)',
239 self::NUMBER_LOCK_NAME
240 ));
241 }
242
243 /**
244 * Get the highest quote number from existing quotes
245 *
246 * @return int The highest quote number found
247 */
248 public function getHighestQuoteNumber(): int {
249 global $wpdb;
250
251 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
252
253 // Get all quote numbers from the database
254 $results = $wpdb->get_results($wpdb->prepare(
255 "SELECT meta_value FROM {$wpdb->postmeta}
256 WHERE meta_key = '_easy_invoice_quote_number'
257 AND meta_value LIKE %s
258 ORDER BY meta_value DESC
259 LIMIT 1",
260 $prefix . '%'
261 ));
262
263 if (empty($results)) {
264 return 0;
265 }
266
267 $highest_number = $results[0]->meta_value;
268
269 // Extract the number part (remove prefix and padding)
270 $number_part = str_replace($prefix, '', $highest_number);
271 $number_part = ltrim($number_part, '0');
272
273 return intval($number_part);
274 }
275
276 /**
277 * Format a quote number with custom formatting
278 *
279 * @param int $number The number to format
280 * @param string $prefix The prefix to use
281 * @param int $padding The number of digits to pad to
282 * @return string The formatted quote number
283 */
284 public function formatNumber(int $number, string $prefix = 'QT-', int $padding = 6): string {
285 return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
286 }
287 }