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 / InvoiceNumberService.php

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

296 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Invoice Number Service
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 /**
12 * InvoiceNumberService Class
13 *
14 * Handles automatic generation of invoice numbers based on settings.
15 */
16 class InvoiceNumberService {
17
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 /**
26 * Generate the next invoice number
27 *
28 * @return string The generated invoice number
29 */
30 public function generateNextNumber(): string {
31 // Get settings
32 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
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 }
58 }
59
60 /**
61 * Get the next invoice number without incrementing
62 *
63 * @return string The next invoice number
64 */
65 public function getNextNumber(): string {
66 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
67 $next_number = get_option('easy_invoice_next_invoice_number', 1);
68
69 // Find what the next unique number would be
70 $unique_number = $this->findNextUniqueNumber($next_number, $prefix);
71
72 return $prefix . str_pad($unique_number, 6, '0', STR_PAD_LEFT);
73 }
74
75 /**
76 * Reset the invoice number counter
77 *
78 * @param int $new_starting_number The new starting number
79 * @return void
80 */
81 public function resetCounter(int $new_starting_number = 1): void {
82 update_option('easy_invoice_next_invoice_number', $new_starting_number);
83 }
84
85 /**
86 * Check if an invoice number already exists
87 *
88 * @param string $invoice_number The invoice number to check
89 * @return bool True if the number exists, false otherwise
90 */
91 public function numberExists(string $invoice_number): bool {
92 global $wpdb;
93
94 $result = $wpdb->get_var($wpdb->prepare(
95 "SELECT COUNT(*) FROM {$wpdb->postmeta}
96 WHERE meta_key = '_easy_invoice_number'
97 AND meta_value = %s",
98 $invoice_number
99 ));
100
101 return intval($result) > 0;
102 }
103
104 /**
105 * Generate a unique invoice number (handles duplicates)
106 *
107 * @return string A unique invoice number
108 */
109 public function generateUniqueNumber(): string {
110 // Get settings
111 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
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 ));
223 }
224
225 /**
226 * Find the next unique number starting from the given number
227 *
228 * @param int $start_number The number to start checking from
229 * @param string $prefix The prefix to use for checking
230 * @return int The next unique number
231 */
232 private function findNextUniqueNumber(int $start_number, string $prefix): int {
233 $current_number = $start_number;
234 $max_attempts = 1000; // Prevent infinite loops
235 $attempts = 0;
236
237 while ($attempts < $max_attempts) {
238 $invoice_number = $prefix . str_pad($current_number, 6, '0', STR_PAD_LEFT);
239
240 if (!$this->numberExists($invoice_number)) {
241 return $current_number;
242 }
243
244 $current_number++;
245 $attempts++;
246 }
247
248 // If we can't find a unique number, add timestamp to ensure uniqueness
249 return $current_number + time();
250 }
251
252 /**
253 * Get the highest invoice number from existing invoices
254 *
255 * @return int The highest invoice number found
256 */
257 public function getHighestInvoiceNumber(): int {
258 global $wpdb;
259
260 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
261
262 // Get all invoice numbers from the database
263 $results = $wpdb->get_results($wpdb->prepare(
264 "SELECT meta_value FROM {$wpdb->postmeta}
265 WHERE meta_key = '_easy_invoice_number'
266 AND meta_value LIKE %s
267 ORDER BY meta_value DESC
268 LIMIT 1",
269 $prefix . '%'
270 ));
271
272 if (empty($results)) {
273 return 0;
274 }
275
276 $highest_number = $results[0]->meta_value;
277
278 // Extract the number part (remove prefix and padding)
279 $number_part = str_replace($prefix, '', $highest_number);
280 $number_part = ltrim($number_part, '0');
281
282 return intval($number_part);
283 }
284
285 /**
286 * Format an invoice number with custom formatting
287 *
288 * @param int $number The number to format
289 * @param string $prefix The prefix to use
290 * @param int $padding The number of digits to pad to
291 * @return string The formatted invoice number
292 */
293 public function formatNumber(int $number, string $prefix = 'INV-', int $padding = 6): string {
294 return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
295 }
296 }