[], 'br' => [], 'strong' => [], 'em' => [], 'i' => [], 'b' => [], 'u' => [], 'ul' => [], 'ol' => [], 'li' => [], 'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'h6' => [], 'a' => [ 'href' => [], 'title' => [], 'target' => [], ], 'span' => [], 'div' => [], ]; /** * Sanitize textarea field allowing basic HTML tags * * @since 1.0.0 * @param string $value The value to sanitize * @return string Sanitized value with allowed HTML tags */ public static function sanitizeTextareaWithHtml(string $value): string { // Use wp_kses to allow only safe HTML tags return wp_kses($value, self::$allowed_textarea_tags); } /** * Process form data * * @since 1.0.0 * @param array $raw_data Raw form data * @param array $field_definitions Field definitions for reference * @return array Processed data with error handling */ public function processFormData(array $raw_data, array $field_definitions): array { $processed_data = []; $errors = []; // Handle payment_gateways field specially $payment_gateways_value = ''; if (isset($raw_data['payment_gateways_hidden'])) { $payment_gateways_value = $raw_data['payment_gateways_hidden']; } elseif (isset($raw_data['payment_gateways'])) { $payment_gateways_value = $raw_data['payment_gateways']; } // Always include payment_gateways in raw_data $raw_data['payment_gateways'] = $payment_gateways_value; $known_field_names = []; foreach ($field_definitions as $field) { $field_name = $field['name'] ?? ''; if ($field_name) { $known_field_names[] = $field_name; } $required = $field['required'] ?? false; $field_type = $field['type'] ?? 'text'; $raw_value = $raw_data[$field_name] ?? ''; // A browser omits an unchecked checkbox from the post. Treating "absent" // as "leave it alone" meant the box could never be turned off: with tax // on globally, "Apply Tax to This Invoice" always came back checked and // saved as yes. Absent now means unchecked, the way a browser means it. if ('checkbox' === $field_type && ! array_key_exists($field_name, $raw_data)) { $raw_value = '0'; } // Check required fields if ($required && empty($raw_value)) { $errors[$field_name] = sprintf( /* translators: %s: field name. */ __('%s is required.', 'easy-invoice'), $field['label'] ?? $field_name ); continue; } // Always include textarea fields (like description) even when empty // This allows users to clear these fields by submitting empty values // Attachments too: an emptied list must reach the save, or the // last file can never be removed. $always_include_fields = ['description', 'notes', 'terms', 'internal_notes', 'attachments']; $should_always_include = in_array($field_name, $always_include_fields) || in_array($field_type, ['textarea', 'attachments'], true); // Skip empty non-required fields (unless they should always be included) if (empty($raw_value) && !$required && $raw_value !== '0' && !$should_always_include) { continue; } // Apply field-specific sanitization if defined $processed_value = $this->applyFieldSanitization($field, $raw_value); // Handle currency fields with "global" option - save "global" as the value if (in_array($field_name, ['currency_code', 'currency_position']) && $processed_value === 'global') { $processed_value = 'global'; } // Apply field-specific processing if defined $processed_value = $this->applyFieldProcessing($field, $processed_value); // Apply field-specific validation if defined $validation_result = $this->applyFieldValidation($field, $processed_value); if ($validation_result !== true) { $errors[$field_name] = $validation_result; continue; } $processed_data[$field_name] = $processed_value; } // A percentage discount above 100 or a negative discount is a typo, not a // deal; say so instead of saving a document whose total the model has to clamp. if (isset($processed_data['discount_type']) && 'percentage' === $processed_data['discount_type'] && (float) ($processed_data['discount_value'] ?? 0) > 100) { $errors['discount_value'] = __('A percentage discount cannot be more than 100%.', 'easy-invoice'); } if (isset($processed_data['discount_value']) && '' !== $processed_data['discount_value'] && (float) $processed_data['discount_value'] < 0) { $errors['discount_value'] = __('The discount cannot be negative.', 'easy-invoice'); } // Always include payment_gateways in processed data $processed_data['payment_gateways'] = $payment_gateways_value; // Allow plugins to modify the processed data $processed_data = apply_filters('easy_invoice_form_processor_data', $processed_data, $raw_data); return [ 'data' => $processed_data, 'errors' => $errors ]; } /** * Process item data * * @since 1.0.0 * @param array $raw_item_data Raw item data * @param array $item_field_definitions Item field definitions * @return array Processed item data */ public function processItemData(array $raw_item_data, array $item_field_definitions): array { $processed_item = []; foreach ($item_field_definitions as $field) { $field_name = $field['name'] ?? ''; $required = $field['required'] ?? false; if (empty($field_name)) { continue; } $raw_value = $raw_item_data[$field_name] ?? ''; // Check required fields if ($required && empty($raw_value) && $field['type'] !== 'checkbox') { // For items, we'll skip invalid items rather than throwing errors continue; } // For non-required fields, include them even if empty (but not for checkboxes) if ($field['type'] === 'checkbox') { // For checkboxes, only include if they have a value if (isset($raw_item_data[$field_name])) { $processed_value = $this->applyFieldSanitization($field, $raw_value); $processed_item[$field_name] = $processed_value; } } else { // For non-checkbox fields, include them even if empty $processed_value = $this->applyFieldSanitization($field, $raw_value); $processed_item[$field_name] = $processed_value; } } return $processed_item; } /** * Process items data * * @since 1.0.0 * @param array $raw_items_data Raw items data * @param array $item_field_definitions Item field definitions * @return array Processed items data */ public function processItemsData(array $raw_items_data, array $item_field_definitions): array { $processed_items = []; foreach ($raw_items_data as $item_index => $raw_item_data) { // Ensure all checkbox fields are set foreach ($item_field_definitions as $field) { if (($field['type'] ?? '') === 'checkbox') { $field_name = $field['name'] ?? ''; if ($field_name && !isset($raw_item_data[$field_name])) { $raw_item_data[$field_name] = '0'; } } } $processed_item = $this->processItemData($raw_item_data, $item_field_definitions); if (!empty($processed_item)) { $processed_items[] = $processed_item; } } return $processed_items; } /** * Save form data to database using field configuration * * @since 1.0.0 * @param array $form_data Processed form data * @param array $field_definitions Field definitions for reference * @param object $model The model object (Invoice, Quote, etc.) * @return void */ public function saveFormDataToDatabase(array $form_data, array $field_definitions, $model): void { // Process each field from the configuration foreach ($field_definitions as $field) { $field_name = $field['name'] ?? ''; if (empty($field_name)) { continue; } // Generate database key with _easy_invoice_ prefix $db_key = '_easy_invoice_' . $field_name; // Get value from form data $value = $form_data[$field_name] ?? null; // Fields that should always be saved, even if empty (to allow clearing) $always_save_fields = ['description', 'notes', 'terms', 'internal_notes', 'attachments']; $should_always_save = in_array($field_name, $always_save_fields); // Save if value exists and is not empty (or is 0), OR if it's a field that should always be saved if (($value !== null && $value !== '') || ($should_always_save && array_key_exists($field_name, $form_data))) { // Use custom save callback if provided if (isset($field['save_callback']) && is_callable($field['save_callback'])) { $field['save_callback']($value ?? '', $model); } else { // Default save to meta data if (method_exists($model, 'setMetaData')) { $model->setMetaData($db_key, $value ?? ''); } } } } // Also process any extra fields that might not be in the configuration $always_save_fields = ['description', 'notes', 'terms', 'internal_notes', 'attachments']; foreach ($form_data as $field_name => $value) { $should_always_save = in_array($field_name, $always_save_fields); if (($value !== null && $value !== '') || ($should_always_save && array_key_exists($field_name, $form_data))) { $db_key = '_easy_invoice_' . $field_name; // Check if this field wasn't already processed above $already_processed = false; foreach ($field_definitions as $field) { if (($field['name'] ?? '') === $field_name) { $already_processed = true; break; } } if (!$already_processed && method_exists($model, 'setMetaData')) { $model->setMetaData($db_key, $value ?? ''); } } } } /** * Generate database key from field name * * @since 1.0.0 * @param string $field_name The field name * @return string Database key with _easy_invoice_ prefix */ public function generateDatabaseKey(string $field_name): string { return '_easy_invoice_' . $field_name; } /** * Process field value based on field type * * @since 1.0.0 * @param array $field Field configuration * @param mixed $value Raw value * @return mixed Processed value */ private function processFieldValue(array $field, $value) { $type = $field['type'] ?? 'text'; switch ($type) { case 'number': return is_numeric($value) ? floatval($value) : 0; case 'checkbox': if (is_string($value)) { $value = strtolower($value); return ($value === '1' || $value === 'true' || $value === 'yes' || $value === 'on') ? '1' : '0'; } return ($value == '1' || $value === true) ? '1' : '0'; case 'payment_gateways': // Handle array of selected payment gateways if (is_array($value)) { // Sanitize each gateway ID and filter out empty values $sanitized_gateways = array_filter(array_map('sanitize_text_field', $value)); return implode(',', $sanitized_gateways); } elseif (is_string($value)) { // If it's already a string (comma-separated), sanitize it return sanitize_text_field($value); } return ''; case 'textarea': // Allow basic HTML tags in textarea fields return self::sanitizeTextareaWithHtml($value); case 'email': return sanitize_email($value); case 'url': return esc_url_raw($value); case 'date': return sanitize_text_field($value); case 'select': case 'text': default: return sanitize_text_field($value); } } /** * Apply field-specific sanitization if defined * * @since 1.0.0 * @param array $field Field configuration * @param mixed $value Raw value * @return mixed Processed value */ private function applyFieldSanitization(array $field, $value) { // Check for sanitize_callback in field configuration $sanitize_callback = $field['sanitize_callback'] ?? null; if (is_callable($sanitize_callback)) { // If callback is 'sanitize_textarea_field', use our HTML-allowing version for textarea fields if ($sanitize_callback === 'sanitize_textarea_field' && ($field['type'] ?? '') === 'textarea') { return self::sanitizeTextareaWithHtml($value); } return $sanitize_callback($value); } // Fallback to basic field type processing return $this->processFieldValue($field, $value); } /** * Apply field-specific processing if defined * * @since 1.0.0 * @param array $field Field configuration * @param mixed $value Processed value * @return mixed Processed value */ private function applyFieldProcessing(array $field, $value) { // Check for process_callback in field configuration $process_callback = $field['process_callback'] ?? null; if (is_callable($process_callback)) { return $process_callback($value); } return $value; } /** * Apply field-specific validation if defined * * @since 1.0.0 * @param array $field Field configuration * @param mixed $value Processed value * @return bool|string True if valid, error message if invalid */ private function applyFieldValidation(array $field, $value) { // Check for validate_callback in field configuration $validate_callback = $field['validate_callback'] ?? null; if (is_callable($validate_callback)) { return $validate_callback($value); } return true; } /** * Get global currency value for a specific field * * @since 1.0.0 * @param string $field_name Field name (currency_code or currency_position) * @return string Global currency value */ private function getGlobalCurrencyValue(string $field_name): string { switch ($field_name) { case 'currency_code': return get_option('easy_invoice_currency_code', 'USD'); case 'currency_position': $position = get_option('easy_invoice_currency_position', 'left'); // Convert from old format if needed if ($position === 'l') return 'before'; if ($position === 'r') return 'after'; return $position; default: return ''; } } }