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

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

244 lines 8.1 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 = get_option('easy_invoice_next_invoice_number', 1);
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 = get_option('easy_invoice_next_invoice_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_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 private function acquireNumberLock(): bool {
151 global $wpdb;
152 $result = $wpdb->get_var($wpdb->prepare(
153 'SELECT GET_LOCK(%s, %d)',
154 self::NUMBER_LOCK_NAME,
155 3
156 ));
157 return (int) $result === 1;
158 }
159
160 /**
161 * Release the MySQL named lock. Safe to call multiple times — if
162 * the lock isn't held by this connection, RELEASE_LOCK returns
163 * NULL and the call is a no-op.
164 */
165 private function releaseNumberLock(): void {
166 global $wpdb;
167 $wpdb->query($wpdb->prepare(
168 'SELECT RELEASE_LOCK(%s)',
169 self::NUMBER_LOCK_NAME
170 ));
171 }
172
173 /**
174 * Find the next unique number starting from the given number
175 *
176 * @param int $start_number The number to start checking from
177 * @param string $prefix The prefix to use for checking
178 * @return int The next unique number
179 */
180 private function findNextUniqueNumber(int $start_number, string $prefix): int {
181 $current_number = $start_number;
182 $max_attempts = 1000; // Prevent infinite loops
183 $attempts = 0;
184
185 while ($attempts < $max_attempts) {
186 $invoice_number = $prefix . str_pad($current_number, 6, '0', STR_PAD_LEFT);
187
188 if (!$this->numberExists($invoice_number)) {
189 return $current_number;
190 }
191
192 $current_number++;
193 $attempts++;
194 }
195
196 // If we can't find a unique number, add timestamp to ensure uniqueness
197 return $current_number + time();
198 }
199
200 /**
201 * Get the highest invoice number from existing invoices
202 *
203 * @return int The highest invoice number found
204 */
205 public function getHighestInvoiceNumber(): int {
206 global $wpdb;
207
208 $prefix = get_option('easy_invoice_invoice_prefix', 'INV-');
209
210 // Get all invoice numbers from the database
211 $results = $wpdb->get_results($wpdb->prepare(
212 "SELECT meta_value FROM {$wpdb->postmeta}
213 WHERE meta_key = '_easy_invoice_number'
214 AND meta_value LIKE %s
215 ORDER BY meta_value DESC
216 LIMIT 1",
217 $prefix . '%'
218 ));
219
220 if (empty($results)) {
221 return 0;
222 }
223
224 $highest_number = $results[0]->meta_value;
225
226 // Extract the number part (remove prefix and padding)
227 $number_part = str_replace($prefix, '', $highest_number);
228 $number_part = ltrim($number_part, '0');
229
230 return intval($number_part);
231 }
232
233 /**
234 * Format an invoice number with custom formatting
235 *
236 * @param int $number The number to format
237 * @param string $prefix The prefix to use
238 * @param int $padding The number of digits to pad to
239 * @return string The formatted invoice number
240 */
241 public function formatNumber(int $number, string $prefix = 'INV-', int $padding = 6): string {
242 return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
243 }
244 }