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/InvoiceNumberService.php +144 -25 2.2.02.4.0 View file →
@@ -13,10 +13,17 @@
13 13 *
14 14 * Handles automatic generation of invoice numbers based on settings.
15 15 */
16 16 class InvoiceNumberService {
17 -
17 +
18 18 /**
19 + * MySQL named lock used to serialise concurrent invoice-number
20 + * generation. Distinct from the quote-number lock so the two flows
21 + * never block each other.
22 + */
23 + private const NUMBER_LOCK_NAME = 'easy_invoice_invoice_number_gen';
24 +
25 + /**
19 26 * Generate the next invoice number
20 27 *
21 28 * @return string The generated invoice number
22 29 */
@@ -22,19 +29,33 @@
22 29 */
23 30 public function generateNextNumber(): string {
24 31 // Get settings
25 32 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
26 -
27 - // Get the next number to use
28 - $next_number = get_option('easy_invoice_next_invoice_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_invoice_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-invoice
36 + // requests can both read the same counter value, both pass
37 + // findNextUniqueNumber() (because neither has written to
38 + // wp_postmeta yet), and both emit the same invoice number.
39 + // The lock auto-releases on MySQL connection close, so we
40 + // can't leak it on a fatal PHP error.
41 + $lock_acquired = $this->acquireNumberLock();
42 + try {
43 + // Get the next number to use
44 + $next_number = $this->freshCounter();
45 +
46 + // Find the next unique number
47 + $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
48 +
49 + // Update the counter to the number we actually used + 1 for next time
50 + update_option('easy_invoice_next_invoice_number', $unique_number + 1);
51 +
52 + return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
53 + } finally {
54 + if ($lock_acquired) {
55 + $this->releaseNumberLock();
56 + }
57 + }
37 58 }
38 59
39 60 /**
40 61 * Get the next invoice number without incrementing
@@ -87,21 +108,119 @@
87 108 */
88 109 public function generateUniqueNumber(): string {
89 110 // Get settings
90 111 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
91 -
92 - // Get the next number to use from the current settings
93 - $next_number = get_option('easy_invoice_next_invoice_number', 1);
94 -
95 - // Find the next unique number
96 - $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
97 -
98 - // Update the counter to the number we actually used + 1 for next time
99 - update_option('easy_invoice_next_invoice_number', $unique_number + 1);
100 -
101 - $final_number = $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
102 -
103 - return $final_number;
112 +
113 + // Same concurrency guard as generateNextNumber() — see comment
114 + // there for the rationale. These two methods are duplicate
115 + // public entry points kept for backward-compat; both need the
116 + // lock so neither call site is a race window.
117 + $lock_acquired = $this->acquireNumberLock();
118 + try {
119 + // Get the next number to use from the current settings
120 + $next_number = $this->freshCounter();
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_invoice_number', $unique_number + 1);
127 +
128 + $final_number = $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
129 +
130 + return $final_number;
131 + } finally {
132 + if ($lock_acquired) {
133 + $this->releaseNumberLock();
134 + }
135 + }
136 + }
137 +
138 + /**
139 + * Acquire a MySQL named lock for the read-check-write triplet.
140 + * Returns true if the lock was acquired (and must be released by
141 + * the caller), false on timeout or backend failure (caller falls
142 + * through to the unsynchronised path — the secondary
143 + * numberExists() check in findNextUniqueNumber() still defends
144 + * against the worst case).
145 + *
146 + * Timeout is 3s — if the database is so contended that even this
147 + * fails, blocking the user's create-invoice request longer is
148 + * worse than the residual race risk.
149 + */
150 + /**
151 + * Keep a number a form submitted, or hand out a fresh one.
152 + *
153 + * The builder pre-fills its number field with the next number without
154 + * taking it, so two people who opened "New" at the same time both post
155 + * the same number. Under the number lock: a number nobody uses yet is
156 + * kept, and if it is the counter's next value the counter moves past
157 + * it; a number already in use is replaced by a freshly generated one.
158 + *
159 + * @param string $requested The number the form sent, possibly empty.
160 + * @return string The number to save.
161 + */
162 + public function claimOrGenerate(string $requested): string {
163 + $requested = trim($requested);
164 + if ($requested === '') {
165 + return $this->generateUniqueNumber();
166 + }
167 + $lock_acquired = $this->acquireNumberLock();
168 + try {
169 + if ($this->numberExists($requested)) {
170 + return $this->generateUniqueNumber();
171 + }
172 + $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
173 + $next = $this->freshCounter();
174 + if ($requested === $prefix . str_pad((string) $next, 6, '0', STR_PAD_LEFT)) {
175 + update_option('easy_invoice_next_invoice_number', $next + 1);
176 + }
177 + return $requested;
178 + } finally {
179 + if ($lock_acquired) {
180 + $this->releaseNumberLock();
181 + }
182 + }
183 + }
184 +
185 + /**
186 + * The counter as the database holds it right now.
187 + *
188 + * Every request loads the options table into memory at boot, before
189 + * it queues for the number lock, so a plain get_option() inside the
190 + * lock returns whatever the counter was when *this* request started —
191 + * and twelve simultaneous saves all "uniquely" took the same number.
192 + * Drop the cached copy and read it again once the lock is held.
193 + *
194 + * @return int
195 + */
196 + private function freshCounter(): int {
197 + wp_cache_delete('easy_invoice_next_invoice_number', 'options');
198 + wp_cache_delete('alloptions', 'options');
199 + return (int) get_option('easy_invoice_next_invoice_number', 1);
200 + }
201 +
202 + private function acquireNumberLock(): bool {
203 + global $wpdb;
204 + $result = $wpdb->get_var($wpdb->prepare(
205 + 'SELECT GET_LOCK(%s, %d)',
206 + self::NUMBER_LOCK_NAME,
207 + 3
208 + ));
209 + return (int) $result === 1;
210 + }
211 +
212 + /**
213 + * Release the MySQL named lock. Safe to call multiple times — if
214 + * the lock isn't held by this connection, RELEASE_LOCK returns
215 + * NULL and the call is a no-op.
216 + */
217 + private function releaseNumberLock(): void {
218 + global $wpdb;
219 + $wpdb->query($wpdb->prepare(
220 + 'SELECT RELEASE_LOCK(%s)',
221 + self::NUMBER_LOCK_NAME
222 + ));
104 223 }
105 224
106 225 /**
107 226 * Find the next unique number starting from the given number