| @@ -22,8 +22,19 @@ | ||
| 22 | 22 | * @since 1.0.0 |
| 23 | 23 | */ |
| 24 | 24 | class Quote { |
| 25 | 25 | /** |
| 26 | + * Fields set by the workflow or the repository that are not always | |
| 27 | + * builder fields — the builder hides the tax block while the site's | |
| 28 | + * global tax switch is off, and the field-driven load/save loops would | |
| 29 | + * then skip them. Written and read regardless (same as Invoice). | |
| 30 | + */ | |
| 31 | + const ALWAYS_PERSISTED = [ | |
| 32 | + 'customer_name', 'customer_email', 'customer_address', | |
| 33 | + 'tax_enabled', 'tax_rate', 'prices_include_tax', | |
| 34 | + ]; | |
| 35 | + | |
| 36 | + /** | |
| 26 | 37 | * Quote ID |
| 27 | 38 | * |
| 28 | 39 | * @var int |
| 29 | 40 | */ |
| @@ -135,9 +146,9 @@ | ||
| 135 | 146 | $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'has' prefix |
| 136 | 147 | return !empty($this->__get($field_name)); |
| 137 | 148 | } |
| 138 | 149 | |
| 139 | - throw new \BadMethodCallException("Method $name does not exist"); | |
| 150 | + throw new \BadMethodCallException(esc_html("Method $name does not exist")); | |
| 140 | 151 | } |
| 141 | 152 | |
| 142 | 153 | /** |
| 143 | 154 | * Convert camelCase to snake_case |
| @@ -157,9 +168,15 @@ | ||
| 157 | 168 | * @param \WP_Post|int $quote Quote post object or ID |
| 158 | 169 | */ |
| 159 | 170 | public function __construct($quote = null) { |
| 160 | 171 | // Initialize data array dynamically from field configuration |
| 161 | - $this->data = $this->getDefaultValuesFromConfiguration(); | |
| 172 | + // Computed defaults (the next document number, today's date) are only | |
| 173 | + // worth evaluating for a brand-new document; for one loaded from the | |
| 174 | + // database every field is overwritten by loadFromPost() a moment | |
| 175 | + // later, and the number default alone cost a "next free number" | |
| 176 | + // lookup per model — three queries times every invoice on a list. | |
| 177 | + $is_new = ! ( $quote instanceof \WP_Post ) && ! is_numeric( $quote ); | |
| 178 | + $this->data = $this->getDefaultValuesFromConfiguration( $is_new ); | |
| 162 | 179 | |
| 163 | 180 | if ($quote instanceof \WP_Post) { |
| 164 | 181 | $this->loadFromPost($quote); |
| 165 | 182 | } elseif (is_numeric($quote)) { |
| @@ -177,26 +194,39 @@ | ||
| 177 | 194 | * |
| 178 | 195 | * @since 1.0.0 |
| 179 | 196 | * @return array |
| 180 | 197 | */ |
| 181 | - private function getDefaultValuesFromConfiguration(): array { | |
| 198 | + /** | |
| 199 | + * Field definitions, registered once per request: every model used to | |
| 200 | + * rebuild the whole registration (tabs, fields, addon filters). | |
| 201 | + * | |
| 202 | + * @var array|null | |
| 203 | + */ | |
| 204 | + private static $field_definitions_cache = null; | |
| 205 | + | |
| 206 | + /** | |
| 207 | + * Forget the cached field definitions (they depend on plugin settings). | |
| 208 | + */ | |
| 209 | + public static function flushFieldDefinitionsCache(): void { | |
| 210 | + self::$field_definitions_cache = null; | |
| 211 | + } | |
| 212 | + | |
| 213 | + private function getDefaultValuesFromConfiguration( bool $evaluate_callables = true ): array { | |
| 182 | 214 | $default_values = []; |
| 183 | 215 | |
| 184 | - // Get field definitions to determine default values | |
| 185 | - $field_registration = new \EasyInvoice\Forms\Quote\QuoteFieldRegistration(); | |
| 186 | - | |
| 187 | - // Initialize the field registration to ensure fields are registered | |
| 188 | - $field_registration->registerDefaultTabs(); | |
| 189 | - $field_registration->registerDefaultFields(); | |
| 190 | - | |
| 191 | - $field_definitions = []; | |
| 192 | - $tabs = $field_registration->getTabs(); | |
| 193 | - foreach ($tabs as $tab_id => $tab) { | |
| 194 | - $tab_fields = $field_registration->getFields($tab_id); | |
| 195 | - foreach ($tab_fields as $field) { | |
| 196 | - $field_definitions[] = $field; | |
| 216 | + if ( null === self::$field_definitions_cache ) { | |
| 217 | + $field_registration = new \EasyInvoice\Forms\Quote\QuoteFieldRegistration(); | |
| 218 | + $field_registration->registerDefaultTabs(); | |
| 219 | + $field_registration->registerDefaultFields(); | |
| 220 | + $field_definitions = []; | |
| 221 | + foreach ( $field_registration->getTabs() as $tab_id => $tab ) { | |
| 222 | + foreach ( $field_registration->getFields( $tab_id ) as $field ) { | |
| 223 | + $field_definitions[] = $field; | |
| 224 | + } | |
| 197 | 225 | } |
| 226 | + self::$field_definitions_cache = $field_definitions; | |
| 198 | 227 | } |
| 228 | + $field_definitions = self::$field_definitions_cache; | |
| 199 | 229 | |
| 200 | 230 | // Extract default values from field configuration |
| 201 | 231 | foreach ($field_definitions as $field) { |
| 202 | 232 | $field_name = $field['name'] ?? ''; |
| @@ -205,11 +235,14 @@ | ||
| 205 | 235 | } |
| 206 | 236 | |
| 207 | 237 | $default_value = $field['default_value'] ?? null; |
| 208 | 238 | |
| 209 | - // Handle callable default values | |
| 239 | + // Handle callable default values. Only the document number is skipped for a | |
| 240 | + // loaded document (it is the one expensive default and is always overwritten | |
| 241 | + // by the stored value); the tax and terms defaults are cheap option reads and | |
| 242 | + // must still apply to documents saved without those meta keys. | |
| 210 | 243 | if (is_callable($default_value)) { |
| 211 | - $default_value = $default_value(); | |
| 244 | + $default_value = ($evaluate_callables || 'number' !== $field_name) ? $default_value() : null; | |
| 212 | 245 | } |
| 213 | 246 | |
| 214 | 247 | // Set appropriate default based on field type |
| 215 | 248 | if ($default_value !== null) { |
| @@ -350,11 +383,28 @@ | ||
| 350 | 383 | // Store in dynamic data array |
| 351 | 384 | $this->data[$field_name] = $value; |
| 352 | 385 | } |
| 353 | 386 | } |
| 354 | - | |
| 355 | 387 | |
| 388 | + // See ALWAYS_PERSISTED. Read only when actually stored. | |
| 389 | + foreach (self::ALWAYS_PERSISTED as $field_name) { | |
| 390 | + if (array_key_exists($field_name, $this->data) && '' !== (string) $this->data[$field_name]) { | |
| 391 | + continue; | |
| 392 | + } | |
| 393 | + $value = get_post_meta($this->id, '_easy_invoice_quote_' . $field_name, true); | |
| 394 | + if ($value !== '' && $value !== false) { | |
| 395 | + $this->data[$field_name] = $value; | |
| 396 | + } | |
| 397 | + } | |
| 356 | 398 | |
| 399 | + // Decision details written by the accept / decline handlers (see saveMetaData). | |
| 400 | + foreach (['accepted_date', 'accepted_by', 'declined_date', 'declined_by', 'decline_reason', 'converted_invoice_id'] as $workflow_field) { | |
| 401 | + $value = get_post_meta($this->id, '_easy_invoice_quote_' . $workflow_field, true); | |
| 402 | + if ($value !== '' && $value !== false && $value !== null) { | |
| 403 | + $this->data[$workflow_field] = $value; | |
| 404 | + } | |
| 405 | + } | |
| 406 | + | |
| 357 | 407 | // Auto-calculate totals if they're 0 or if we have items but no totals |
| 358 | 408 | if (((isset($this->data['total']) ? $this->data['total'] : 0) == 0 && !empty($this->items)) || |
| 359 | 409 | ((isset($this->data['subtotal']) ? $this->data['subtotal'] : 0) == 0 && !empty($this->items))) { |
| 360 | 410 | $this->calculateTotals(); |
| @@ -438,10 +488,17 @@ | ||
| 438 | 488 | $this->calculateTotals(); |
| 439 | 489 | |
| 440 | 490 | // Save meta data (including quote status) |
| 441 | 491 | $this->saveMetaData(); |
| 492 | + foreach ($this->pending_meta as $pending_key => $pending_value) { | |
| 493 | + update_post_meta($this->id, $pending_key, $pending_value); | |
| 494 | + } | |
| 495 | + $this->pending_meta = []; | |
| 442 | 496 | |
| 443 | 497 | // Allow plugins to perform actions after saving |
| 498 | + // Persist the computed total so the quote list can sum by currency in SQL. | |
| 499 | + \EasyInvoice\Services\QuoteTotals::store($this); | |
| 500 | + | |
| 444 | 501 | do_action('easy_invoice_quote_after_save', $this); |
| 445 | 502 | |
| 446 | 503 | $this->is_modified = false; |
| 447 | 504 | return true; |
| @@ -492,8 +549,24 @@ | ||
| 492 | 549 | update_post_meta($this->id, $meta_key, $value); |
| 493 | 550 | } |
| 494 | 551 | } |
| 495 | 552 | |
| 553 | + // See ALWAYS_PERSISTED. | |
| 554 | + foreach (self::ALWAYS_PERSISTED as $field_name) { | |
| 555 | + if (array_key_exists($field_name, $this->data) && null !== $this->data[$field_name]) { | |
| 556 | + update_post_meta($this->id, '_easy_invoice_quote_' . $field_name, $this->data[$field_name]); | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + // Decision details are set by the accept / decline handlers but are | |
| 561 | + // not builder fields, so the loop above never wrote them: the accepted | |
| 562 | + // date the client sees and the export reads was always empty. | |
| 563 | + foreach (['accepted_date', 'accepted_by', 'declined_date', 'declined_by', 'decline_reason', 'converted_invoice_id'] as $workflow_field) { | |
| 564 | + if (array_key_exists($workflow_field, $this->data)) { | |
| 565 | + update_post_meta($this->id, '_easy_invoice_quote_' . $workflow_field, $this->data[$workflow_field]); | |
| 566 | + } | |
| 567 | + } | |
| 568 | + | |
| 496 | 569 | // Save items |
| 497 | 570 | $this->saveItems(); |
| 498 | 571 | |
| 499 | 572 | // Allow plugins to save additional meta data |
| @@ -536,10 +609,20 @@ | ||
| 536 | 609 | * |
| 537 | 610 | * @since 1.0.0 |
| 538 | 611 | */ |
| 539 | 612 | public function calculateTotals(): void { |
| 613 | + // Per-quote tax_enabled override. Same semantics as the | |
| 614 | + // Invoice model — see Invoice::calculateTotals() for rationale. | |
| 615 | + $tax_enabled_meta = $this->data['tax_enabled'] ?? null; | |
| 616 | + if ($tax_enabled_meta === null || $tax_enabled_meta === '') { | |
| 617 | + $tax_enabled = get_option('easy_invoice_tax_enabled', 'no') === 'yes'; | |
| 618 | + } else { | |
| 619 | + $tax_enabled = ($tax_enabled_meta === 'yes' || $tax_enabled_meta === '1' | |
| 620 | + || $tax_enabled_meta === 1 || $tax_enabled_meta === true); | |
| 621 | + } | |
| 622 | + | |
| 540 | 623 | $prices_include_tax = ($this->data['prices_include_tax'] ?? 'no') === 'yes'; |
| 541 | - $tax_rate = floatval($this->data['tax_rate'] ?? 0); | |
| 624 | + $tax_rate = $tax_enabled ? floatval($this->data['tax_rate'] ?? 0) : 0; | |
| 542 | 625 | |
| 543 | 626 | // Initialize totals |
| 544 | 627 | $subtotal = 0; |
| 545 | 628 | $taxable_subtotal = 0; |
| @@ -576,8 +659,11 @@ | ||
| 576 | 659 | if ($adjust_percentage != 0 && \EasyInvoice\Controllers\SettingsController::shouldShowQuoteAdjustField()) { |
| 577 | 660 | $item_total = $item_total * (1 + $adjust_percentage / 100); |
| 578 | 661 | } |
| 579 | 662 | |
| 663 | + // Money lives in cents: round each line before it is summed, so the | |
| 664 | + // lines printed on the document add up to the printed subtotal. | |
| 665 | + $item_total = easy_invoice_round_money($item_total); | |
| 580 | 666 | $subtotal += $item_total; |
| 581 | 667 | if ($is_taxable) { |
| 582 | 668 | $taxable_subtotal += $item_total; |
| 583 | 669 | } |
| @@ -588,12 +674,14 @@ | ||
| 588 | 674 | // Get discount calculation method (default to before_tax) |
| 589 | 675 | $discount_calculation_method = $this->data['discount_calculation_method'] ?? 'before_tax'; |
| 590 | 676 | |
| 591 | 677 | // Calculate initial discount amount |
| 678 | + // A discount can never exceed what it is taken from: a percentage is capped | |
| 679 | + // at 100 and a fixed amount at the subtotal, so a total is never negative. | |
| 592 | 680 | if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0) { |
| 593 | - $this->data['discount_amount'] = ($subtotal * $this->data['discount_value']) / 100; | |
| 681 | + $this->data['discount_amount'] = easy_invoice_round_money(($subtotal * min(100, (float) $this->data['discount_value'])) / 100); | |
| 594 | 682 | } elseif (($this->data['discount_type'] ?? '') === 'fixed' && ($this->data['discount_value'] ?? 0) > 0) { |
| 595 | - $this->data['discount_amount'] = $this->data['discount_value']; | |
| 683 | + $this->data['discount_amount'] = min((float) $this->data['discount_value'], (float) $subtotal); | |
| 596 | 684 | } |
| 597 | 685 | |
| 598 | 686 | // Calculate tax and total based on discount calculation method |
| 599 | 687 | if ($discount_calculation_method === 'before_tax') { |
| @@ -605,16 +693,16 @@ | ||
| 605 | 693 | $taxable_amount = 0; |
| 606 | 694 | } |
| 607 | 695 | |
| 608 | 696 | if ($tax_rate > 0) { |
| 609 | - $this->data['tax_amount'] = ($taxable_amount * $tax_rate) / 100; | |
| 697 | + $this->data['tax_amount'] = easy_invoice_round_money(($taxable_amount * $tax_rate) / 100); | |
| 610 | 698 | } |
| 611 | 699 | |
| 612 | - $this->data['total'] = $subtotal - $this->data['discount_amount'] + $this->data['tax_amount']; | |
| 700 | + $this->data['total'] = max(0.0, easy_invoice_round_money($subtotal - $this->data['discount_amount'] + $this->data['tax_amount'])); | |
| 613 | 701 | } else { |
| 614 | 702 | // For after_tax: Calculate tax first, then apply discount |
| 615 | 703 | if ($tax_rate > 0) { |
| 616 | - $this->data['tax_amount'] = ($taxable_subtotal * $tax_rate) / 100; | |
| 704 | + $this->data['tax_amount'] = easy_invoice_round_money(($taxable_subtotal * $tax_rate) / 100); | |
| 617 | 705 | } |
| 618 | 706 | |
| 619 | 707 | $total_before_discount = $subtotal + $this->data['tax_amount']; |
| 620 | 708 | |
| @@ -619,12 +707,14 @@ | ||
| 619 | 707 | $total_before_discount = $subtotal + $this->data['tax_amount']; |
| 620 | 708 | |
| 621 | 709 | // Recalculate percentage discount based on total including tax |
| 622 | 710 | if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $total_before_discount > 0) { |
| 623 | - $this->data['discount_amount'] = ($total_before_discount * $this->data['discount_value']) / 100; | |
| 711 | + $this->data['discount_amount'] = easy_invoice_round_money(($total_before_discount * min(100, (float) $this->data['discount_value'])) / 100); | |
| 712 | + } elseif (($this->data['discount_type'] ?? '') === 'fixed') { | |
| 713 | + $this->data['discount_amount'] = min((float) $this->data['discount_amount'], (float) $total_before_discount); | |
| 624 | 714 | } |
| 625 | 715 | |
| 626 | - $this->data['total'] = $total_before_discount - $this->data['discount_amount']; | |
| 716 | + $this->data['total'] = max(0.0, easy_invoice_round_money($total_before_discount - $this->data['discount_amount'])); | |
| 627 | 717 | } |
| 628 | 718 | } |
| 629 | 719 | |
| 630 | 720 | /** |
| @@ -637,9 +727,17 @@ | ||
| 637 | 727 | $client_repository = new \EasyInvoice\Repositories\ClientRepository(); |
| 638 | 728 | $client = $client_repository->find($this->data['client_id']); |
| 639 | 729 | |
| 640 | 730 | if ($client) { |
| 641 | - $this->data['customer_name'] = $client->getBusinessClientName() ?: ''; | |
| 731 | + // Business name when there is one; otherwise the person's | |
| 732 | + // name — an invoice to an individual must still say who it | |
| 733 | + // is for. | |
| 734 | + $person = trim( (string) $client->getFirstName() . ' ' . (string) $client->getLastName() ); | |
| 735 | + if ( '' === $person ) { | |
| 736 | + $user = get_userdata( (int) $this->data['client_id'] ); | |
| 737 | + $person = $user ? (string) $user->display_name : ''; | |
| 738 | + } | |
| 739 | + $this->data['customer_name'] = $client->getBusinessClientName() ?: $person; | |
| 642 | 740 | $this->data['customer_email'] = $client->getEmail() ?: ''; |
| 643 | 741 | $this->data['customer_address'] = $client->getAddress() ?: ''; |
| 644 | 742 | } |
| 645 | 743 | } |
| @@ -712,8 +810,64 @@ | ||
| 712 | 810 | * @since 1.0.0 |
| 713 | 811 | * @param string $key Meta key |
| 714 | 812 | * @param mixed $value Meta value |
| 715 | 813 | */ |
| 814 | + /** @var array<string,mixed> Meta queued by setMeta() before the post exists. */ | |
| 815 | + private $pending_meta = []; | |
| 816 | + | |
| 817 | + /** | |
| 818 | + * Raw post meta on this document, read from the database. | |
| 819 | + * | |
| 820 | + * Before this existed, `$model->getMeta('key')` fell through to __call() | |
| 821 | + * as a getter for a field named "meta" and returned null for every key. | |
| 822 | + * | |
| 823 | + * @param string $key Meta key (any key, prefixed or not). | |
| 824 | + * @param mixed $default Returned when the meta is absent or empty. | |
| 825 | + * @return mixed | |
| 826 | + */ | |
| 827 | + public function getMeta(string $key, $default = '') { | |
| 828 | + if (array_key_exists($key, $this->pending_meta)) { | |
| 829 | + return $this->pending_meta[$key]; | |
| 830 | + } | |
| 831 | + if (!$this->id) { | |
| 832 | + return $default; | |
| 833 | + } | |
| 834 | + $value = get_post_meta($this->id, $key, true); | |
| 835 | + return ('' === $value || null === $value) ? $default : $value; | |
| 836 | + } | |
| 837 | + | |
| 838 | + /** | |
| 839 | + * Write post meta: at once when the post exists, otherwise on save(). | |
| 840 | + * A prefixed key also updates the model's own field so getters agree. | |
| 841 | + * | |
| 842 | + * @param string $key Meta key. | |
| 843 | + * @param mixed $value Value. | |
| 844 | + */ | |
| 845 | + public function setMeta(string $key, $value): void { | |
| 846 | + if (0 === strpos($key, '_easy_invoice_')) { | |
| 847 | + $this->setMetaData($key, $value); | |
| 848 | + } | |
| 849 | + if ($this->id) { | |
| 850 | + update_post_meta($this->id, $key, $value); | |
| 851 | + } else { | |
| 852 | + $this->pending_meta[$key] = $value; | |
| 853 | + } | |
| 854 | + } | |
| 855 | + | |
| 856 | + /** | |
| 857 | + * A model field by its meta key, falling back to stored post meta. | |
| 858 | + * | |
| 859 | + * @param string $key Meta key, with or without the _easy_invoice_ prefix. | |
| 860 | + * @return mixed Null when unknown. | |
| 861 | + */ | |
| 862 | + public function getMetaData(string $key) { | |
| 863 | + $field = easy_invoice_str_replace('_easy_invoice_', '', $key); | |
| 864 | + if (array_key_exists($field, $this->data)) { | |
| 865 | + return $this->data[$field]; | |
| 866 | + } | |
| 867 | + return $this->getMeta($key, null); | |
| 868 | + } | |
| 869 | + | |
| 716 | 870 | public function setMetaData(string $key, $value): void { |
| 717 | 871 | // Store in dynamic data array without the _easy_invoice_ prefix |
| 718 | 872 | $field_name = easy_invoice_str_replace('_easy_invoice_', '', $key); |
| 719 | 873 | $this->data[$field_name] = $value; |
| @@ -887,7 +1041,17 @@ | ||
| 887 | 1041 | * @return float |
| 888 | 1042 | */ |
| 889 | 1043 | public function getTotal(): float { |
| 890 | 1044 | $this->calculateTotals(); |
| 891 | - return floatval($this->data['total'] ?? 0); | |
| 1045 | + $total = floatval($this->data['total'] ?? 0); | |
| 1046 | + | |
| 1047 | + /** | |
| 1048 | + * Filter the quote total. The invoice model has had this seam since | |
| 1049 | + * 1.0; Pro's Additional Tax listens on it for quotes and, without it, | |
| 1050 | + * printed its line under a total that did not include it. | |
| 1051 | + * | |
| 1052 | + * @param float $total | |
| 1053 | + * @param Quote $quote | |
| 1054 | + */ | |
| 1055 | + return (float) apply_filters('easy_invoice_quote_total', $total, $this); | |
| 892 | 1056 | } |
| 893 | 1057 | } |