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 / Forms / FormProcessor.php

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

463 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Form Processor Class
4 *
5 * @package EasyInvoice
6 * @author Your Name
7 * @copyright Copyright (c) 2023, Your Company
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.0.0
10 */
11
12 namespace EasyInvoice\Forms;
13
14 /**
15 * Form Processor
16 *
17 * Handles form data processing and mapping to database structure.
18 * Field validation and sanitization are handled by field callbacks.
19 *
20 * @since 1.0.0
21 */
22 class FormProcessor {
23
24 /**
25 * Allowed HTML tags for textarea fields
26 *
27 * @since 1.0.0
28 * @var array
29 */
30 private static $allowed_textarea_tags = [
31 'p' => [],
32 'br' => [],
33 'strong' => [],
34 'em' => [],
35 'i' => [],
36 'b' => [],
37 'u' => [],
38 'ul' => [],
39 'ol' => [],
40 'li' => [],
41 'h1' => [],
42 'h2' => [],
43 'h3' => [],
44 'h4' => [],
45 'h5' => [],
46 'h6' => [],
47 'a' => [
48 'href' => [],
49 'title' => [],
50 'target' => [],
51 ],
52 'span' => [],
53 'div' => [],
54 ];
55
56 /**
57 * Sanitize textarea field allowing basic HTML tags
58 *
59 * @since 1.0.0
60 * @param string $value The value to sanitize
61 * @return string Sanitized value with allowed HTML tags
62 */
63 public static function sanitizeTextareaWithHtml(string $value): string {
64 // Use wp_kses to allow only safe HTML tags
65 return wp_kses($value, self::$allowed_textarea_tags);
66 }
67
68 /**
69 * Process form data
70 *
71 * @since 1.0.0
72 * @param array $raw_data Raw form data
73 * @param array $field_definitions Field definitions for reference
74 * @return array Processed data with error handling
75 */
76 public function processFormData(array $raw_data, array $field_definitions): array {
77 $processed_data = [];
78 $errors = [];
79
80 // Handle payment_gateways field specially
81 $payment_gateways_value = '';
82 if (isset($raw_data['payment_gateways_hidden'])) {
83 $payment_gateways_value = $raw_data['payment_gateways_hidden'];
84 } elseif (isset($raw_data['payment_gateways'])) {
85 $payment_gateways_value = $raw_data['payment_gateways'];
86 }
87
88 // Always include payment_gateways in raw_data
89 $raw_data['payment_gateways'] = $payment_gateways_value;
90
91 $known_field_names = [];
92 foreach ($field_definitions as $field) {
93 $field_name = $field['name'] ?? '';
94 if ($field_name) {
95 $known_field_names[] = $field_name;
96 }
97 $required = $field['required'] ?? false;
98 $field_type = $field['type'] ?? 'text';
99 $raw_value = $raw_data[$field_name] ?? '';
100
101 // A browser omits an unchecked checkbox from the post. Treating "absent"
102 // as "leave it alone" meant the box could never be turned off: with tax
103 // on globally, "Apply Tax to This Invoice" always came back checked and
104 // saved as yes. Absent now means unchecked, the way a browser means it.
105 if ('checkbox' === $field_type && ! array_key_exists($field_name, $raw_data)) {
106 $raw_value = '0';
107 }
108
109 // Check required fields
110 if ($required && empty($raw_value)) {
111 $errors[$field_name] = sprintf(
112 /* translators: %s: field name. */
113 __('%s is required.', 'easy-invoice'),
114 $field['label'] ?? $field_name
115 );
116 continue;
117 }
118
119 // Always include textarea fields (like description) even when empty
120 // This allows users to clear these fields by submitting empty values
121 // Attachments too: an emptied list must reach the save, or the
122 // last file can never be removed.
123 $always_include_fields = ['description', 'notes', 'terms', 'internal_notes', 'attachments'];
124 $should_always_include = in_array($field_name, $always_include_fields) || in_array($field_type, ['textarea', 'attachments'], true);
125
126 // Skip empty non-required fields (unless they should always be included)
127 if (empty($raw_value) && !$required && $raw_value !== '0' && !$should_always_include) {
128 continue;
129 }
130
131 // Apply field-specific sanitization if defined
132 $processed_value = $this->applyFieldSanitization($field, $raw_value);
133
134 // Handle currency fields with "global" option - save "global" as the value
135 if (in_array($field_name, ['currency_code', 'currency_position']) && $processed_value === 'global') {
136 $processed_value = 'global';
137 }
138
139 // Apply field-specific processing if defined
140 $processed_value = $this->applyFieldProcessing($field, $processed_value);
141
142 // Apply field-specific validation if defined
143 $validation_result = $this->applyFieldValidation($field, $processed_value);
144 if ($validation_result !== true) {
145 $errors[$field_name] = $validation_result;
146 continue;
147 }
148
149 $processed_data[$field_name] = $processed_value;
150 }
151
152 // A percentage discount above 100 or a negative discount is a typo, not a
153 // deal; say so instead of saving a document whose total the model has to clamp.
154 if (isset($processed_data['discount_type']) && 'percentage' === $processed_data['discount_type'] && (float) ($processed_data['discount_value'] ?? 0) > 100) {
155 $errors['discount_value'] = __('A percentage discount cannot be more than 100%.', 'easy-invoice');
156 }
157 if (isset($processed_data['discount_value']) && '' !== $processed_data['discount_value'] && (float) $processed_data['discount_value'] < 0) {
158 $errors['discount_value'] = __('The discount cannot be negative.', 'easy-invoice');
159 }
160
161 // Always include payment_gateways in processed data
162 $processed_data['payment_gateways'] = $payment_gateways_value;
163
164 // Allow plugins to modify the processed data
165 $processed_data = apply_filters('easy_invoice_form_processor_data', $processed_data, $raw_data);
166
167 return [
168 'data' => $processed_data,
169 'errors' => $errors
170 ];
171 }
172
173 /**
174 * Process item data
175 *
176 * @since 1.0.0
177 * @param array $raw_item_data Raw item data
178 * @param array $item_field_definitions Item field definitions
179 * @return array Processed item data
180 */
181 public function processItemData(array $raw_item_data, array $item_field_definitions): array {
182 $processed_item = [];
183
184 foreach ($item_field_definitions as $field) {
185 $field_name = $field['name'] ?? '';
186 $required = $field['required'] ?? false;
187
188 if (empty($field_name)) {
189 continue;
190 }
191
192 $raw_value = $raw_item_data[$field_name] ?? '';
193
194 // Check required fields
195 if ($required && empty($raw_value) && $field['type'] !== 'checkbox') {
196 // For items, we'll skip invalid items rather than throwing errors
197 continue;
198 }
199
200 // For non-required fields, include them even if empty (but not for checkboxes)
201 if ($field['type'] === 'checkbox') {
202 // For checkboxes, only include if they have a value
203 if (isset($raw_item_data[$field_name])) {
204 $processed_value = $this->applyFieldSanitization($field, $raw_value);
205 $processed_item[$field_name] = $processed_value;
206 }
207 } else {
208 // For non-checkbox fields, include them even if empty
209 $processed_value = $this->applyFieldSanitization($field, $raw_value);
210 $processed_item[$field_name] = $processed_value;
211 }
212 }
213
214 return $processed_item;
215 }
216
217 /**
218 * Process items data
219 *
220 * @since 1.0.0
221 * @param array $raw_items_data Raw items data
222 * @param array $item_field_definitions Item field definitions
223 * @return array Processed items data
224 */
225 public function processItemsData(array $raw_items_data, array $item_field_definitions): array {
226 $processed_items = [];
227 foreach ($raw_items_data as $item_index => $raw_item_data) {
228 // Ensure all checkbox fields are set
229 foreach ($item_field_definitions as $field) {
230
231 if (($field['type'] ?? '') === 'checkbox') {
232 $field_name = $field['name'] ?? '';
233 if ($field_name && !isset($raw_item_data[$field_name])) {
234 $raw_item_data[$field_name] = '0';
235 }
236 }
237 }
238
239 $processed_item = $this->processItemData($raw_item_data, $item_field_definitions);
240 if (!empty($processed_item)) {
241 $processed_items[] = $processed_item;
242 }
243 }
244
245 return $processed_items;
246 }
247
248 /**
249 * Save form data to database using field configuration
250 *
251 * @since 1.0.0
252 * @param array $form_data Processed form data
253 * @param array $field_definitions Field definitions for reference
254 * @param object $model The model object (Invoice, Quote, etc.)
255 * @return void
256 */
257 public function saveFormDataToDatabase(array $form_data, array $field_definitions, $model): void {
258
259
260 // Process each field from the configuration
261 foreach ($field_definitions as $field) {
262 $field_name = $field['name'] ?? '';
263 if (empty($field_name)) {
264 continue;
265 }
266
267 // Generate database key with _easy_invoice_ prefix
268 $db_key = '_easy_invoice_' . $field_name;
269
270 // Get value from form data
271 $value = $form_data[$field_name] ?? null;
272
273 // Fields that should always be saved, even if empty (to allow clearing)
274 $always_save_fields = ['description', 'notes', 'terms', 'internal_notes', 'attachments'];
275 $should_always_save = in_array($field_name, $always_save_fields);
276
277 // Save if value exists and is not empty (or is 0), OR if it's a field that should always be saved
278 if (($value !== null && $value !== '') || ($should_always_save && array_key_exists($field_name, $form_data))) {
279 // Use custom save callback if provided
280 if (isset($field['save_callback']) && is_callable($field['save_callback'])) {
281 $field['save_callback']($value ?? '', $model);
282 } else {
283 // Default save to meta data
284 if (method_exists($model, 'setMetaData')) {
285 $model->setMetaData($db_key, $value ?? '');
286 }
287 }
288 }
289 }
290
291 // Also process any extra fields that might not be in the configuration
292 $always_save_fields = ['description', 'notes', 'terms', 'internal_notes', 'attachments'];
293 foreach ($form_data as $field_name => $value) {
294 $should_always_save = in_array($field_name, $always_save_fields);
295
296 if (($value !== null && $value !== '') || ($should_always_save && array_key_exists($field_name, $form_data))) {
297 $db_key = '_easy_invoice_' . $field_name;
298
299 // Check if this field wasn't already processed above
300 $already_processed = false;
301 foreach ($field_definitions as $field) {
302 if (($field['name'] ?? '') === $field_name) {
303 $already_processed = true;
304 break;
305 }
306 }
307
308 if (!$already_processed && method_exists($model, 'setMetaData')) {
309 $model->setMetaData($db_key, $value ?? '');
310 }
311 }
312 }
313 }
314
315 /**
316 * Generate database key from field name
317 *
318 * @since 1.0.0
319 * @param string $field_name The field name
320 * @return string Database key with _easy_invoice_ prefix
321 */
322 public function generateDatabaseKey(string $field_name): string {
323 return '_easy_invoice_' . $field_name;
324 }
325
326 /**
327 * Process field value based on field type
328 *
329 * @since 1.0.0
330 * @param array $field Field configuration
331 * @param mixed $value Raw value
332 * @return mixed Processed value
333 */
334 private function processFieldValue(array $field, $value) {
335 $type = $field['type'] ?? 'text';
336
337 switch ($type) {
338 case 'number':
339 return is_numeric($value) ? floatval($value) : 0;
340
341 case 'checkbox':
342 if (is_string($value)) {
343 $value = strtolower($value);
344 return ($value === '1' || $value === 'true' || $value === 'yes' || $value === 'on') ? '1' : '0';
345 }
346 return ($value == '1' || $value === true) ? '1' : '0';
347
348 case 'payment_gateways':
349 // Handle array of selected payment gateways
350 if (is_array($value)) {
351 // Sanitize each gateway ID and filter out empty values
352 $sanitized_gateways = array_filter(array_map('sanitize_text_field', $value));
353 return implode(',', $sanitized_gateways);
354 } elseif (is_string($value)) {
355 // If it's already a string (comma-separated), sanitize it
356 return sanitize_text_field($value);
357 }
358 return '';
359
360 case 'textarea':
361 // Allow basic HTML tags in textarea fields
362 return self::sanitizeTextareaWithHtml($value);
363
364 case 'email':
365 return sanitize_email($value);
366
367 case 'url':
368 return esc_url_raw($value);
369
370 case 'date':
371 return sanitize_text_field($value);
372
373 case 'select':
374 case 'text':
375 default:
376 return sanitize_text_field($value);
377 }
378 }
379
380 /**
381 * Apply field-specific sanitization if defined
382 *
383 * @since 1.0.0
384 * @param array $field Field configuration
385 * @param mixed $value Raw value
386 * @return mixed Processed value
387 */
388 private function applyFieldSanitization(array $field, $value) {
389 // Check for sanitize_callback in field configuration
390 $sanitize_callback = $field['sanitize_callback'] ?? null;
391
392 if (is_callable($sanitize_callback)) {
393 // If callback is 'sanitize_textarea_field', use our HTML-allowing version for textarea fields
394 if ($sanitize_callback === 'sanitize_textarea_field' && ($field['type'] ?? '') === 'textarea') {
395 return self::sanitizeTextareaWithHtml($value);
396 }
397 return $sanitize_callback($value);
398 }
399
400 // Fallback to basic field type processing
401 return $this->processFieldValue($field, $value);
402 }
403
404 /**
405 * Apply field-specific processing if defined
406 *
407 * @since 1.0.0
408 * @param array $field Field configuration
409 * @param mixed $value Processed value
410 * @return mixed Processed value
411 */
412 private function applyFieldProcessing(array $field, $value) {
413 // Check for process_callback in field configuration
414 $process_callback = $field['process_callback'] ?? null;
415
416 if (is_callable($process_callback)) {
417 return $process_callback($value);
418 }
419
420 return $value;
421 }
422
423 /**
424 * Apply field-specific validation if defined
425 *
426 * @since 1.0.0
427 * @param array $field Field configuration
428 * @param mixed $value Processed value
429 * @return bool|string True if valid, error message if invalid
430 */
431 private function applyFieldValidation(array $field, $value) {
432 // Check for validate_callback in field configuration
433 $validate_callback = $field['validate_callback'] ?? null;
434
435 if (is_callable($validate_callback)) {
436 return $validate_callback($value);
437 }
438
439 return true;
440 }
441
442 /**
443 * Get global currency value for a specific field
444 *
445 * @since 1.0.0
446 * @param string $field_name Field name (currency_code or currency_position)
447 * @return string Global currency value
448 */
449 private function getGlobalCurrencyValue(string $field_name): string {
450 switch ($field_name) {
451 case 'currency_code':
452 return get_option('easy_invoice_currency_code', 'USD');
453 case 'currency_position':
454 $position = get_option('easy_invoice_currency_position', 'left');
455 // Convert from old format if needed
456 if ($position === 'l') return 'before';
457 if ($position === 'r') return 'after';
458 return $position;
459 default:
460 return '';
461 }
462 }
463 }