PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
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
← All changes | includes/Models/Quote.php +193 -29 2.2.02.4.1 View file →
@@ -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 }