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
← All changes | includes/Services/QuoteNumberService.php +135 -23 2.2.02.4.0 View file →
@@ -13,10 +13,17 @@
13 13 *
14 14 * Handles automatic generation of quote numbers based on settings.
15 15 */
16 16 class QuoteNumberService {
17 -
17 +
18 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 + /**
19 26 * Generate the next quote number
20 27 *
21 28 * @return string The generated quote number
22 29 */
@@ -22,19 +29,31 @@
22 29 */
23 30 public function generateNextNumber(): string {
24 31 // Get settings
25 32 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
26 -
27 - // Get the next number to use
28 - $next_number = get_option('easy_invoice_next_quote_number', 1);
29 -
30 - // Find the next unique number
31 - $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
32 -
33 - // Update the counter to the number we actually used + 1 for next time
34 - update_option('easy_invoice_next_quote_number', $unique_number + 1);
35 -
36 - return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
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 + }
37 56 }
38 57
39 58 /**
40 59 * Get the next quote number without incrementing
@@ -114,19 +133,112 @@
114 133 */
115 134 public function generateUniqueNumber(): string {
116 135 // Get settings
117 136 $prefix = get_option('easy_invoice_quote_prefix', 'QT-');
118 -
119 - // Get the next number to use from the current settings
120 - $next_number = get_option('easy_invoice_next_quote_number', 1);
121 -
122 - // Find the next unique number
123 - $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
124 -
125 - // Update the counter to the number we actually used + 1 for next time
126 - update_option('easy_invoice_next_quote_number', $unique_number + 1);
127 -
128 - return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
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 + ));
129 241 }
130 242
131 243 /**
132 244 * Get the highest quote number from existing quotes