id; } // Special handling for total field if ($name === 'total') { return $this->getTotal(); } // Return from dynamic data array return $this->data[$name] ?? null; } /** * Magic method to set dynamic properties * * @since 1.0.0 * @param string $name Property name * @param mixed $value Property value */ public function __set($name, $value) { // Handle special properties if ($name === 'id') { $this->id = $value; return; } // Store in dynamic data array $this->data[$name] = $value; $this->is_modified = true; } /** * Magic method to check if property exists * * @since 1.0.0 * @param string $name Property name * @return bool */ public function __isset($name) { if ($name === 'id') { return isset($this->id); } return isset($this->data[$name]); } /** * Constructor * * @since 1.0.0 * @param \WP_Post|int $invoice Invoice post object or ID */ public function __construct($invoice = null) { // Initialize data array dynamically from field configuration $this->data = $this->getDefaultValuesFromConfiguration(); if ($invoice instanceof \WP_Post) { $this->loadFromPost($invoice); } elseif (is_numeric($invoice)) { $post = get_post($invoice); if ($post && $post->post_type === PostTypes::EASY_INVOICE_POST_TYPE) { $this->loadFromPost($post); } } do_action('easy_invoice_invoice_model_constructed', $this); } /** * Get default values from field configuration * * @since 1.0.0 * @return array */ private function getDefaultValuesFromConfiguration(): array { $default_values = []; // Get field definitions to determine default values $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration(); // Initialize the field registration to ensure fields are registered $field_registration->registerDefaultTabs(); $field_registration->registerDefaultFields(); $field_definitions = []; $tabs = $field_registration->getTabs(); foreach ($tabs as $tab_id => $tab) { $tab_fields = $field_registration->getFields($tab_id); foreach ($tab_fields as $field) { $field_definitions[] = $field; } } // Extract default values from field configuration foreach ($field_definitions as $field) { $field_name = $field['name'] ?? ''; if (empty($field_name)) { continue; } $default_value = $field['default_value'] ?? null; // Handle callable default values if (is_callable($default_value)) { $default_value = $default_value(); } // Set appropriate default based on field type if ($default_value !== null) { $default_values[$field_name] = $default_value; } else { // Set type-appropriate defaults $field_type = $field['type'] ?? 'text'; switch ($field_type) { case 'number': $default_values[$field_name] = 0.0; break; case 'checkbox': $default_values[$field_name] = false; break; case 'select': $default_values[$field_name] = ''; break; case 'array': $default_values[$field_name] = []; break; default: $default_values[$field_name] = ''; break; } } } return $default_values; } /** * Load invoice data from WP_Post * * @since 1.0.0 * @param \WP_Post $post */ private function loadFromPost(\WP_Post $post): void { $this->id = $post->ID; $this->data['title'] = $post->post_title ?: ''; $this->data['created_date'] = $post->post_date ?: ''; $this->data['modified_date'] = $post->post_modified ?: ''; // Load meta data using configuration-driven approach $this->loadMetaData(); // Load items $this->loadItems(); // Auto-calculate totals if they're 0 or if we have items but no totals if ((($this->data['total'] ?? 0) == 0 && !empty($this->items)) || (($this->data['subtotal'] ?? 0) == 0 && !empty($this->items))) { $this->calculateTotals(); } // Populate client information if we have a client_id but no customer data if (($this->data['client_id'] ?? 0) > 0 && (empty($this->data['customer_name'] ?? '') || empty($this->data['customer_email'] ?? ''))) { $this->populateClientInfo(); } } /** * Load meta data from database * * @since 1.0.0 */ private function loadMetaData(): void { if (!$this->id) { return; } // Get field definitions to determine which meta keys to load $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration(); // Initialize the field registration to ensure fields are registered $field_registration->registerDefaultTabs(); $field_registration->registerDefaultFields(); $field_definitions = []; $tabs = $field_registration->getTabs(); foreach ($tabs as $tab_id => $tab) { $tab_fields = $field_registration->getFields($tab_id); foreach ($tab_fields as $field) { $field_definitions[] = $field; } } // Load meta data for each field definition foreach ($field_definitions as $field) { $field_name = $field['name'] ?? ''; if (empty($field_name)) { continue; } $meta_key = '_easy_invoice_' . $field_name; $value = get_post_meta($this->id, $meta_key, true); if ($value !== '') { // Store in dynamic data array $this->data[$field_name] = $value; } } } /** * Load invoice items * * @since 1.0.0 */ private function loadItems(): void { if (!$this->id) { return; } $items_data = get_post_meta($this->id, '_easy_invoice_items', true) ?: []; $this->items = []; foreach ($items_data as $item_data) { $this->items[] = new \EasyInvoice\Models\InvoiceItem($item_data); } } /** * Ensure the invoice has a proper slug for pretty permalinks * * @since 1.0.0 * @return bool True if successful, false otherwise */ public function ensureProperSlug(): bool { if (!$this->id) { return false; } $post = get_post($this->id); if (!$post || empty($post->post_name)) { // Generate a slug from the title $post_title = $this->data['title'] ?: $this->data['number'] ?: 'Untitled Invoice'; $post_name = sanitize_title($post_title); // Ensure uniqueness $original_slug = $post_name; $counter = 1; while (get_page_by_path($post_name, OBJECT, PostTypes::EASY_INVOICE_POST_TYPE)) { $post_name = $original_slug . '-' . $counter; $counter++; } // Update the post with the new slug $result = wp_update_post([ 'ID' => $this->id, 'post_name' => $post_name, 'post_status' => 'publish' // Ensure it's published for proper permalinks ]); return !is_wp_error($result); } return true; } /** * Save the invoice * Always publish invoices to ensure proper permalinks * Invoice status will be stored in post meta (_easy_invoice_status) * * @since 1.0.0 * @return bool True if successful, false otherwise */ public function save(): bool { // Allow plugins to modify the invoice before saving do_action('easy_invoice_model_before_save', $this); // Always publish invoices to ensure proper permalinks // Invoice status will be stored in post meta (_easy_invoice_status) $post_status = 'publish'; // Generate a proper slug for pretty permalinks $post_title = $this->data['title'] ?: $this->data['number'] ?: 'Untitled Invoice'; $post_name = ''; if ($this->id) { // For existing posts, get the current post to preserve slug if it exists $existing_post = get_post($this->id); if ($existing_post && !empty($existing_post->post_name)) { $post_name = $existing_post->post_name; } } // If no slug exists, generate one from the title if (empty($post_name)) { $post_name = sanitize_title($post_title); // Ensure uniqueness by appending number if needed $original_slug = $post_name; $counter = 1; while (get_page_by_path($post_name, OBJECT, PostTypes::EASY_INVOICE_POST_TYPE)) { $post_name = $original_slug . '-' . $counter; $counter++; } } // Prepare post data $post_data = [ 'post_title' => $post_title, 'post_content' => $this->data['notes'] ?: '', // Ensure post_content is never null 'post_status' => $post_status, // Always publish for proper permalinks 'post_type' => PostTypes::EASY_INVOICE_POST_TYPE, 'post_name' => $post_name, // Add the generated slug ]; // Update existing post or create new one if ($this->id) { $post_data['ID'] = $this->id; $post_id = wp_update_post($post_data); } else { $post_id = wp_insert_post($post_data); } if (is_wp_error($post_id)) { return false; } // Update the ID if this was a new post if (!$this->id) { $this->id = $post_id; } // Calculate totals from items before saving $this->calculateTotals(); // Save meta data (including invoice status) $this->saveMetaData(); // Allow plugins to perform actions after saving do_action('easy_invoice_model_after_save', $this); $this->is_modified = false; return true; } /** * Save meta data to database * * @since 1.0.0 */ private function saveMetaData(): void { if (!$this->id) { return; } // Get field definitions to determine which meta keys to save $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration(); // Initialize the field registration to ensure fields are registered $field_registration->registerDefaultTabs(); $field_registration->registerDefaultFields(); $field_definitions = []; $tabs = $field_registration->getTabs(); foreach ($tabs as $tab_id => $tab) { $tab_fields = $field_registration->getFields($tab_id); foreach ($tab_fields as $field) { $field_definitions[] = $field; } } // Save meta data for each field definition foreach ($field_definitions as $field) { $field_name = $field['name'] ?? ''; if (empty($field_name)) { continue; } $meta_key = '_easy_invoice_' . $field_name; // Get the value from dynamic data array // Save all fields that exist in the data array (including empty strings to allow clearing fields) // If a field exists in $this->data, it means it was explicitly set, so we should save it if (array_key_exists($field_name, $this->data)) { $value = $this->data[$field_name]; update_post_meta($this->id, $meta_key, $value); } } // Save items $this->saveItems(); // Allow plugins to save additional meta data do_action('easy_invoice_model_save_meta_data', $this); } /** * Save invoice items * * @since 1.0.0 */ private function saveItems(): void { if (!$this->id) { return; } $items_data = []; foreach ($this->items as $item) { if (is_object($item) && method_exists($item, 'toArray')) { $items_data[] = $item->toArray(); } else { $items_data[] = $item; } } update_post_meta($this->id, '_easy_invoice_items', $items_data); } /** * Calculate totals from items * * @since 1.0.0 */ public function calculateTotals(): void { // Per-invoice tax_enabled override. Falls back to the global // Settings → Tax → Enable Tax option when the per-invoice // value is unset (eg. invoices created before the per-invoice // override was introduced). Set tax_rate to 0 when disabled // so the rest of the math runs unchanged — keeps tax_rate as // an authored value the user can flip back on without // re-entering it. $tax_enabled_meta = $this->data['tax_enabled'] ?? null; if ($tax_enabled_meta === null || $tax_enabled_meta === '') { $tax_enabled = get_option('easy_invoice_tax_enabled', 'no') === 'yes'; } else { $tax_enabled = ($tax_enabled_meta === 'yes' || $tax_enabled_meta === '1' || $tax_enabled_meta === 1 || $tax_enabled_meta === true); } $prices_include_tax = ($this->data['prices_include_tax'] ?? 'no') === 'yes'; $tax_rate = $tax_enabled ? floatval($this->data['tax_rate'] ?? 0) : 0; // Initialize totals $subtotal = 0; $taxable_subtotal = 0; $this->data['tax_amount'] = 0; $this->data['discount_amount'] = 0; // First pass: Calculate raw totals foreach ($this->items as $index => $item) { $quantity = 0; $price = 0; $adjust_percentage = 0; $is_taxable = true; if (is_object($item) && method_exists($item, 'getAmount')) { $quantity = $item->getQuantity(); $price = $item->getPrice(); $adjust_percentage = $item->getAdjustPercentage(); $is_taxable = $item->isTaxable(); } elseif (is_array($item)) { $quantity = isset($item['quantity']) ? (float) $item['quantity'] : 0; $price = isset($item['price']) ? (float) $item['price'] : 0; $adjust_percentage = isset($item['adjust_percentage']) ? (float) $item['adjust_percentage'] : 0; $is_taxable = isset($item['taxable']) ? (bool) $item['taxable'] : true; } // If prices include tax and item is taxable, remove tax from price if ($prices_include_tax && $is_taxable && $tax_rate > 0) { $price = $price / (1 + ($tax_rate / 100)); } // Calculate item total $item_total = $quantity * $price; // Only apply adjust percentage if the adjust field is enabled if ($adjust_percentage != 0 && \EasyInvoice\Controllers\SettingsController::shouldShowInvoiceAdjustField()) { $item_total = $item_total * (1 + $adjust_percentage / 100); } $subtotal += $item_total; if ($is_taxable) { $taxable_subtotal += $item_total; } } $this->data['subtotal'] = $subtotal; // Get discount calculation method (default to before_tax) $discount_calculation_method = $this->data['discount_calculation_method'] ?? 'before_tax'; // Calculate initial discount amount if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $subtotal > 0) { $this->data['discount_amount'] = ($subtotal * $this->data['discount_value']) / 100; } elseif (($this->data['discount_type'] ?? '') === 'fixed' && ($this->data['discount_value'] ?? 0) > 0) { $this->data['discount_amount'] = $this->data['discount_value']; } else { $this->data['discount_amount'] = 0; } // Calculate tax and total based on discount calculation method if ($discount_calculation_method === 'before_tax') { // For before_tax: Apply discount first, then calculate tax on remaining taxable amount $discount_ratio = ($subtotal > 0) ? ($this->data['discount_amount'] / $subtotal) : 0; $taxable_amount = $taxable_subtotal * (1 - $discount_ratio); if ($tax_rate > 0) { $this->data['tax_amount'] = ($taxable_amount * $tax_rate) / 100; } $this->data['total'] = $subtotal - $this->data['discount_amount'] + $this->data['tax_amount']; } else { // For after_tax: Calculate tax first, then apply discount if ($tax_rate > 0) { $this->data['tax_amount'] = ($taxable_subtotal * $tax_rate) / 100; } $total_before_discount = $subtotal + $this->data['tax_amount']; // Recalculate percentage discount based on total including tax if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $total_before_discount > 0) { $this->data['discount_amount'] = ($total_before_discount * $this->data['discount_value']) / 100; } $this->data['total'] = $total_before_discount - $this->data['discount_amount']; } // Allow plugins to modify calculations do_action('easy_invoice_model_calculate_totals', $this); } /** * Populate client information from client repository * * @since 1.0.0 */ public function populateClientInfo(): void { if (($this->data['client_id'] ?? 0) > 0) { $client_repository = new \EasyInvoice\Repositories\ClientRepository(); $client = $client_repository->find($this->data['client_id']); if ($client) { $this->data['customer_name'] = $client->getBusinessClientName() ?: ''; $this->data['customer_email'] = $client->getEmail() ?: ''; $this->data['customer_address'] = $client->getAddress() ?: ''; } } } /** * Force populate client info and save (for existing invoices that need client data) * * @since 1.0.0 * @return bool */ public function forcePopulateClientInfo(): bool { $this->populateClientInfo(); return $this->save(); } /** * Recalculate totals and save (for existing invoices) * * @since 1.0.0 * @return bool */ public function recalculateAndSave(): bool { // Populate client information if we have a client_id $this->populateClientInfo(); // Calculate totals from items $this->calculateTotals(); // Save the updated invoice return $this->save(); } /** * Convert to array * * @since 1.0.0 * @return array */ public function toArray(): array { $items_array = []; foreach ($this->items as $item) { if (is_object($item) && method_exists($item, 'toArray')) { $items_array[] = $item->toArray(); } else { $items_array[] = $item; } } // Start with dynamic data $data = $this->data; // Add special properties $data['id'] = $this->id; $data['items'] = $items_array; // Allow plugins to modify the array data return apply_filters('easy_invoice_model_to_array', $data, $this); } /** * Dynamic getter method * * @since 1.0.0 * @param string $name Method name * @param array $arguments Method arguments * @return mixed */ public function __call($name, $arguments) { // Handle getter methods (getFieldName) if (strpos($name, 'get') === 0) { $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'get' prefix return $this->__get($field_name); } // Handle setter methods (setFieldName) if (strpos($name, 'set') === 0) { $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'set' prefix $value = $arguments[0] ?? null; $this->__set($field_name, $value); return null; } // Handle isset methods (isFieldName) if (strpos($name, 'is') === 0) { $field_name = $this->camelCaseToSnakeCase(substr($name, 2)); // Remove 'is' prefix return (bool) $this->__get($field_name); } // Handle has methods (hasFieldName) if (strpos($name, 'has') === 0) { $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'has' prefix return !empty($this->__get($field_name)); } throw new \BadMethodCallException("Method $name does not exist"); } /** * Convert camelCase to snake_case * * @since 1.0.0 * @param string $camelCase * @return string */ private function camelCaseToSnakeCase($camelCase) { return strtolower(preg_replace('/(?id ?? 0; } public function setId(int $id): void { $this->id = $id; } public function getItems(): array { return $this->items; } /** * Check if invoice exists * * @return bool */ public function exists(): bool { return $this->id > 0 && get_post($this->id) !== null; } public function setItems(array $items): void { $this->items = []; foreach ($items as $item) { if (is_array($item)) { $this->items[] = new InvoiceItem($item); } elseif (is_object($item) && $item instanceof InvoiceItem) { $this->items[] = $item; } } $this->is_modified = true; } public function isModified(): bool { return $this->is_modified; } /** * Set meta data for the invoice * * @since 1.0.0 * @param string $key Meta key * @param mixed $value Meta value */ public function setMetaData(string $key, $value): void { // Store in dynamic data array without the _easy_invoice_ prefix $field_name = easy_invoice_str_replace('_easy_invoice_', '', $key); $this->data[$field_name] = $value; $this->is_modified = true; } /** * Get currency code * * @since 1.0.0 * @return string Currency code */ public function getCurrencyCode(): string { $currency_code = $this->data['currency_code'] ?? 'USD'; // If currency is set to 'global', resolve to actual global setting if ($currency_code === 'global') { $currency_code = get_option('easy_invoice_currency_code', 'USD'); } // Ensure currency code is uppercase for consistency return strtoupper($currency_code); } /** * Set currency code * * @since 1.0.0 * @param string $currency_code Currency code */ public function setCurrencyCode(string $currency_code): void { $this->data['currency_code'] = $currency_code; $this->is_modified = true; } /** * Get currency position * * @since 1.0.0 * @return string Currency position */ public function getCurrencyPosition(): string { $currency_position = $this->data['currency_position'] ?? 'left'; // If currency position is set to 'global', resolve to actual global setting if ($currency_position === 'global') { $currency_position = get_option('easy_invoice_currency_position', 'left'); } // Map form format to global settings format if ($currency_position === 'before') { $currency_position = 'left'; } elseif ($currency_position === 'after') { $currency_position = 'right'; } return $currency_position; } /** * Set currency position * * @since 1.0.0 * @param string $currency_position Currency position */ public function setCurrencyPosition(string $currency_position): void { $this->data['currency_position'] = $currency_position; $this->is_modified = true; } /** * Get raw currency code (without resolving global) * * @since 1.0.0 * @return string Raw currency code */ public function getRawCurrencyCode(): string { return $this->data['currency_code'] ?? 'global'; } /** * Get raw currency position (without resolving global) * * @since 1.0.0 * @return string Raw currency position */ public function getRawCurrencyPosition(): string { return $this->data['currency_position'] ?? 'global'; } public function getDescription(): string { return $this->data['description'] ?? ''; } public function setDescription(string $description): void { $this->data['description'] = $description; $this->is_modified = true; } public function getTemplate(): string { return $this->data['invoice_template'] ?? 'standard'; } public function setTemplate(string $template): void { $this->data['invoice_template'] = $template; $this->is_modified = true; } /** * Get discount type (percentage, fixed, none) * * @since 1.0.0 * @return string */ public function getDiscountType(): string { return $this->data['discount_type'] ?? 'none'; } /** * Get discount value (percentage or fixed amount) * * @since 1.0.0 * @return float */ public function getDiscountValue(): float { return floatval($this->data['discount_value'] ?? 0); } /** * Get discount amount (calculated value) * * @since 1.0.0 * @return float */ public function getDiscountAmount(): float { $this->calculateTotals(); return floatval($this->data['discount_amount'] ?? 0); } /** * Get tax rate percentage * * @since 1.0.0 * @return float */ public function getTaxRate(): float { return floatval($this->data['tax_rate'] ?? 0); } /** * Get tax amount (calculated value) * * @since 1.0.0 * @return float */ public function getTaxAmount(): float { $this->calculateTotals(); return floatval($this->data['tax_amount'] ?? 0); } /** * Get the total amount for this invoice * Calculate from items * * @since 1.0.0 * @return float */ public function getTotal(): float { // Calculate from items $this->calculateTotals(); $total = (float) ($this->data['total'] ?? 0); // Allow plugins to modify the total return apply_filters('easy_invoice_invoice_total', $total, $this); } }