id; } // 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]); } /** * 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(esc_html("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('/(?data = $this->getDefaultValuesFromConfiguration( $is_new ); if ($quote instanceof \WP_Post) { $this->loadFromPost($quote); } elseif (is_numeric($quote)) { $post = get_post($quote); if ($post && $post->post_type === PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) { $this->loadFromPost($post); } } do_action('easy_invoice_quote_model_constructed', $this); } /** * Get default values from field configuration * * @since 1.0.0 * @return array */ /** * Field definitions, registered once per request: every model used to * rebuild the whole registration (tabs, fields, addon filters). * * @var array|null */ private static $field_definitions_cache = null; /** * Forget the cached field definitions (they depend on plugin settings). */ public static function flushFieldDefinitionsCache(): void { self::$field_definitions_cache = null; } private function getDefaultValuesFromConfiguration( bool $evaluate_callables = true ): array { $default_values = []; if ( null === self::$field_definitions_cache ) { $field_registration = new \EasyInvoice\Forms\Quote\QuoteFieldRegistration(); $field_registration->registerDefaultTabs(); $field_registration->registerDefaultFields(); $field_definitions = []; foreach ( $field_registration->getTabs() as $tab_id => $tab ) { foreach ( $field_registration->getFields( $tab_id ) as $field ) { $field_definitions[] = $field; } } self::$field_definitions_cache = $field_definitions; } $field_definitions = self::$field_definitions_cache; // 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. Only the document number is skipped for a // loaded document (it is the one expensive default and is always overwritten // by the stored value); the tax and terms defaults are cheap option reads and // must still apply to documents saved without those meta keys. if (is_callable($default_value)) { $default_value = ($evaluate_callables || 'number' !== $field_name) ? $default_value() : null; } // 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 quote data from WP_Post object * * @since 1.0.0 * @param \WP_Post $post Post object */ 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 items first so they're available for total calculations $this->loadItems(); // Load meta data using configuration-driven approach $this->loadMetaData(); // Allow plugins to load additional data do_action('easy_invoice_quote_loaded_from_post', $this, $post); // Ensure totals are calculated $this->calculateTotals(); } /** * Load quote items * * @since 1.0.0 */ private function loadItems(): void { $items_data = get_post_meta($this->id, '_easy_invoice_quote_items', true); if (is_array($items_data)) { $this->items = []; // Clear existing items foreach ($items_data as $item_data) { if (is_array($item_data)) { // Ensure required fields have default values // Ensure taxable field is properly set $taxable = isset($item_data['taxable']) ? $item_data['taxable'] : true; if (is_string($taxable)) { $taxable = strtolower($taxable); $taxable = $taxable === '1' || $taxable === 'true' || $taxable === 'yes' || $taxable === 'on'; } $taxable = (bool) $taxable; $item_data = array_merge([ 'quantity' => 0, 'price' => 0, 'adjust_percentage' => 0, 'taxable' => $taxable, 'name' => '', 'description' => '' ], $item_data); $this->items[] = new QuoteItem($item_data); } } } } /** * Load meta data using configuration-driven approach * * @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\Quote\QuoteFieldRegistration(); // 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_quote_' . $field_name; $value = get_post_meta($this->id, $meta_key, true); // Handle special cases for certain fields if ($field_name === 'prices_include_tax') { $this->data[$field_name] = $value === '1' || $value === 'yes' ? 'yes' : 'no'; } else if ($field_name === 'discount_type' && empty($value)) { $this->data[$field_name] = 'none'; } else if ($field_name === 'discount_calculation_method' && empty($value)) { $this->data[$field_name] = 'before_tax'; } else if ($field_name === 'tax_rate' && empty($value)) { $this->data[$field_name] = 0; } else if ($field_name === 'discount_value' && empty($value)) { $this->data[$field_name] = 0; } else if ($value !== '') { // Store in dynamic data array $this->data[$field_name] = $value; } } // See ALWAYS_PERSISTED. Read only when actually stored. foreach (self::ALWAYS_PERSISTED as $field_name) { if (array_key_exists($field_name, $this->data) && '' !== (string) $this->data[$field_name]) { continue; } $value = get_post_meta($this->id, '_easy_invoice_quote_' . $field_name, true); if ($value !== '' && $value !== false) { $this->data[$field_name] = $value; } } // Decision details written by the accept / decline handlers (see saveMetaData). foreach (['accepted_date', 'accepted_by', 'declined_date', 'declined_by', 'decline_reason', 'converted_invoice_id'] as $workflow_field) { $value = get_post_meta($this->id, '_easy_invoice_quote_' . $workflow_field, true); if ($value !== '' && $value !== false && $value !== null) { $this->data[$workflow_field] = $value; } } // Auto-calculate totals if they're 0 or if we have items but no totals if (((isset($this->data['total']) ? $this->data['total'] : 0) == 0 && !empty($this->items)) || ((isset($this->data['subtotal']) ? $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(); } } /** * Ensure quote has proper post_name (slug) for pretty URLs * * @since 1.0.0 * @return bool */ public function ensureProperSlug(): bool { if (!$this->id) { return false; } $post = get_post($this->id); if (!$post || empty($post->post_name)) { // Generate a proper slug for this quote $post_title = $this->data['title'] ?: $this->data['number'] ?: 'Untitled Quote'; $post_name = sanitize_title($post_title); // Ensure uniqueness $original_slug = $post_name; $counter = 1; while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_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 ]); return $result !== 0; } return true; } /** * Save quote to database * * @since 1.0.0 * @return bool True if successful, false otherwise */ public function save(): bool { // Prepare post data $post_data = [ 'post_title' => $this->data['title'] ?? '', 'post_content' => $this->data['description'] ?? '', 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, 'post_status' => 'publish' ]; 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 quote status) $this->saveMetaData(); foreach ($this->pending_meta as $pending_key => $pending_value) { update_post_meta($this->id, $pending_key, $pending_value); } $this->pending_meta = []; // Allow plugins to perform actions after saving // Persist the computed total so the quote list can sum by currency in SQL. \EasyInvoice\Services\QuoteTotals::store($this); do_action('easy_invoice_quote_after_save', $this); $this->is_modified = false; return true; } /** * Save quote meta data * * @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\Quote\QuoteFieldRegistration(); // 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_quote_' . $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); } } // See ALWAYS_PERSISTED. foreach (self::ALWAYS_PERSISTED as $field_name) { if (array_key_exists($field_name, $this->data) && null !== $this->data[$field_name]) { update_post_meta($this->id, '_easy_invoice_quote_' . $field_name, $this->data[$field_name]); } } // Decision details are set by the accept / decline handlers but are // not builder fields, so the loop above never wrote them: the accepted // date the client sees and the export reads was always empty. foreach (['accepted_date', 'accepted_by', 'declined_date', 'declined_by', 'decline_reason', 'converted_invoice_id'] as $workflow_field) { if (array_key_exists($workflow_field, $this->data)) { update_post_meta($this->id, '_easy_invoice_quote_' . $workflow_field, $this->data[$workflow_field]); } } // Save items $this->saveItems(); // Allow plugins to save additional meta data do_action('easy_invoice_quote_save_meta_data', $this); } /** * Save quote items * * @since 1.0.0 */ private function saveItems(): void { $items_data = []; // Process items for saving foreach ($this->items as $item) { if (is_object($item) && method_exists($item, 'toArray')) { $item_data = $item->toArray(); // Ensure taxable field is properly set as a string '1' or '0' $item_data['taxable'] = $item->isTaxable() ? '1' : '0'; $items_data[] = $item_data; } elseif (is_array($item)) { // Convert array to QuoteItem object for proper saving $quote_item = new QuoteItem($item); $item_data = $quote_item->toArray(); // Ensure taxable field is properly set as a string '1' or '0' $item_data['taxable'] = $quote_item->isTaxable() ? '1' : '0'; $items_data[] = $item_data; } } // Save items to meta update_post_meta($this->id, '_easy_invoice_quote_items', $items_data); } /** * Calculate totals from items * * @since 1.0.0 */ public function calculateTotals(): void { // Per-quote tax_enabled override. Same semantics as the // Invoice model — see Invoice::calculateTotals() for rationale. $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 $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::shouldShowQuoteAdjustField()) { $item_total = $item_total * (1 + $adjust_percentage / 100); } // Money lives in cents: round each line before it is summed, so the // lines printed on the document add up to the printed subtotal. $item_total = easy_invoice_round_money($item_total); $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 // A discount can never exceed what it is taken from: a percentage is capped // at 100 and a fixed amount at the subtotal, so a total is never negative. if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0) { $this->data['discount_amount'] = easy_invoice_round_money(($subtotal * min(100, (float) $this->data['discount_value'])) / 100); } elseif (($this->data['discount_type'] ?? '') === 'fixed' && ($this->data['discount_value'] ?? 0) > 0) { $this->data['discount_amount'] = min((float) $this->data['discount_value'], (float) $subtotal); } // 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 if ($subtotal > 0) { $discount_ratio = $this->data['discount_amount'] / $subtotal; $taxable_amount = $taxable_subtotal * (1 - $discount_ratio); } else { $taxable_amount = 0; } if ($tax_rate > 0) { $this->data['tax_amount'] = easy_invoice_round_money(($taxable_amount * $tax_rate) / 100); } $this->data['total'] = max(0.0, easy_invoice_round_money($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'] = easy_invoice_round_money(($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'] = easy_invoice_round_money(($total_before_discount * min(100, (float) $this->data['discount_value'])) / 100); } elseif (($this->data['discount_type'] ?? '') === 'fixed') { $this->data['discount_amount'] = min((float) $this->data['discount_amount'], (float) $total_before_discount); } $this->data['total'] = max(0.0, easy_invoice_round_money($total_before_discount - $this->data['discount_amount'])); } } /** * Populate client information from client_id * * @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) { // Business name when there is one; otherwise the person's // name — an invoice to an individual must still say who it // is for. $person = trim( (string) $client->getFirstName() . ' ' . (string) $client->getLastName() ); if ( '' === $person ) { $user = get_userdata( (int) $this->data['client_id'] ); $person = $user ? (string) $user->display_name : ''; } $this->data['customer_name'] = $client->getBusinessClientName() ?: $person; $this->data['customer_email'] = $client->getEmail() ?: ''; $this->data['customer_address'] = $client->getAddress() ?: ''; } } } /** * Recalculate totals and save (for existing quotes) * * @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 quote 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_quote_model_to_array', $data, $this); } // Essential methods only public function getId(): int { return $this->id ?? 0; } public function setId(int $id): void { $this->id = $id; } public function getItems(): array { return $this->items; } public function setItems(array $items): void { $this->items = []; foreach ($items as $item) { if (is_array($item)) { $this->items[] = new QuoteItem($item); } elseif (is_object($item) && $item instanceof QuoteItem) { $this->items[] = $item; } } $this->is_modified = true; } public function isModified(): bool { return $this->is_modified; } /** * Set meta data for the quote * * @since 1.0.0 * @param string $key Meta key * @param mixed $value Meta value */ /** @var array Meta queued by setMeta() before the post exists. */ private $pending_meta = []; /** * Raw post meta on this document, read from the database. * * Before this existed, `$model->getMeta('key')` fell through to __call() * as a getter for a field named "meta" and returned null for every key. * * @param string $key Meta key (any key, prefixed or not). * @param mixed $default Returned when the meta is absent or empty. * @return mixed */ public function getMeta(string $key, $default = '') { if (array_key_exists($key, $this->pending_meta)) { return $this->pending_meta[$key]; } if (!$this->id) { return $default; } $value = get_post_meta($this->id, $key, true); return ('' === $value || null === $value) ? $default : $value; } /** * Write post meta: at once when the post exists, otherwise on save(). * A prefixed key also updates the model's own field so getters agree. * * @param string $key Meta key. * @param mixed $value Value. */ public function setMeta(string $key, $value): void { if (0 === strpos($key, '_easy_invoice_')) { $this->setMetaData($key, $value); } if ($this->id) { update_post_meta($this->id, $key, $value); } else { $this->pending_meta[$key] = $value; } } /** * A model field by its meta key, falling back to stored post meta. * * @param string $key Meta key, with or without the _easy_invoice_ prefix. * @return mixed Null when unknown. */ public function getMetaData(string $key) { $field = easy_invoice_str_replace('_easy_invoice_', '', $key); if (array_key_exists($field, $this->data)) { return $this->data[$field]; } return $this->getMeta($key, null); } 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'); } 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['quote_template'] ?? 'standard'; } public function setTemplate(string $template): void { $this->data['quote_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 subtotal (sum of all items) * * @since 1.0.0 * @return float */ public function getSubtotal(): float { $this->calculateTotals(); return floatval($this->data['subtotal'] ?? 0); } /** * Get total (final amount including tax and discount) * * @since 1.0.0 * @return float */ public function getTotal(): float { $this->calculateTotals(); $total = floatval($this->data['total'] ?? 0); /** * Filter the quote total. The invoice model has had this seam since * 1.0; Pro's Additional Tax listens on it for quotes and, without it, * printed its line under a total that did not include it. * * @param float $total * @param Quote $quote */ return (float) apply_filters('easy_invoice_quote_total', $total, $this); } }