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/Invoice.php +215 -27 2.2.02.4.1 View file →
@@ -21,9 +21,39 @@
21 21 *
22 22 * @since 1.0.0
23 23 */
24 24 class Invoice {
25 +
25 26 /**
27 + * Fields that persist whether or not the invoice form shows them.
28 + *
29 + * Persistence used to be driven entirely by the form's field registration:
30 + * a field the builder did not render was neither saved nor loaded. Two
31 + * things fell through that gap.
32 + *
33 + * The customer's name, email and address are not form fields -- the builder
34 + * picks a client and the model fills them from the client record -- so an
35 + * invoice for someone who is not a client (a WooCommerce guest, a quote
36 + * converted without a client) had nowhere to keep them: setInvoiceData()
37 + * accepted them and save() silently threw them away.
38 + *
39 + * The tax fields are hidden from the form when tax is switched off site-
40 + * wide. That is a sensible UI decision, but it also stopped an integration
41 + * from setting a per-invoice rate on such a site, even though
42 + * calculateTotals() honours one -- a WooCommerce order that charged tax
43 + * would produce an invoice that said it did not.
44 + *
45 + * A field in this list is written when set and read when present. It is
46 + * never invented: an invoice with a client and no stored name still takes
47 + * its name from the client, exactly as before.
48 + */
49 + const ALWAYS_PERSISTED = [
50 + 'customer_name', 'customer_email', 'customer_address',
51 + 'tax_enabled', 'tax_rate', 'prices_include_tax',
52 + // Set by the repository for API- and import-created invoices; not builder fields.
53 + 'footer_text', 'payment_instructions',
54 + ];
55 + /**
26 56 * Invoice ID
27 57 *
28 58 * @var int
29 59 */
@@ -113,9 +143,15 @@
113 143 * @param \WP_Post|int $invoice Invoice post object or ID
114 144 */
115 145 public function __construct($invoice = null) {
116 146 // Initialize data array dynamically from field configuration
117 - $this->data = $this->getDefaultValuesFromConfiguration();
147 + // Computed defaults (the next document number, today's date) are only
148 + // worth evaluating for a brand-new document; for one loaded from the
149 + // database every field is overwritten by loadFromPost() a moment
150 + // later, and the number default alone cost a "next free number"
151 + // lookup per model — three queries times every invoice on a list.
152 + $is_new = ! ( $invoice instanceof \WP_Post ) && ! is_numeric( $invoice );
153 + $this->data = $this->getDefaultValuesFromConfiguration( $is_new );
118 154
119 155 if ($invoice instanceof \WP_Post) {
120 156 $this->loadFromPost($invoice);
121 157 } elseif (is_numeric($invoice)) {
@@ -133,26 +169,39 @@
133 169 *
134 170 * @since 1.0.0
135 171 * @return array
136 172 */
137 - private function getDefaultValuesFromConfiguration(): array {
173 + /**
174 + * Field definitions, registered once per request: every model used to
175 + * rebuild the whole registration (tabs, fields, addon filters).
176 + *
177 + * @var array|null
178 + */
179 + private static $field_definitions_cache = null;
180 +
181 + /**
182 + * Forget the cached field definitions (they depend on plugin settings).
183 + */
184 + public static function flushFieldDefinitionsCache(): void {
185 + self::$field_definitions_cache = null;
186 + }
187 +
188 + private function getDefaultValuesFromConfiguration( bool $evaluate_callables = true ): array {
138 189 $default_values = [];
139 190
140 - // Get field definitions to determine default values
141 - $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration();
142 -
143 - // Initialize the field registration to ensure fields are registered
144 - $field_registration->registerDefaultTabs();
145 - $field_registration->registerDefaultFields();
146 -
147 - $field_definitions = [];
148 - $tabs = $field_registration->getTabs();
149 - foreach ($tabs as $tab_id => $tab) {
150 - $tab_fields = $field_registration->getFields($tab_id);
151 - foreach ($tab_fields as $field) {
152 - $field_definitions[] = $field;
191 + if ( null === self::$field_definitions_cache ) {
192 + $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration();
193 + $field_registration->registerDefaultTabs();
194 + $field_registration->registerDefaultFields();
195 + $field_definitions = [];
196 + foreach ( $field_registration->getTabs() as $tab_id => $tab ) {
197 + foreach ( $field_registration->getFields( $tab_id ) as $field ) {
198 + $field_definitions[] = $field;
199 + }
153 200 }
201 + self::$field_definitions_cache = $field_definitions;
154 202 }
203 + $field_definitions = self::$field_definitions_cache;
155 204
156 205 // Extract default values from field configuration
157 206 foreach ($field_definitions as $field) {
158 207 $field_name = $field['name'] ?? '';
@@ -161,11 +210,14 @@
161 210 }
162 211
163 212 $default_value = $field['default_value'] ?? null;
164 213
165 - // Handle callable default values
214 + // Handle callable default values. Only the document number is skipped for a
215 + // loaded document (it is the one expensive default and is always overwritten
216 + // by the stored value); the tax and terms defaults are cheap option reads and
217 + // must still apply to documents saved without those meta keys.
166 218 if (is_callable($default_value)) {
167 - $default_value = $default_value();
219 + $default_value = ($evaluate_callables || 'number' !== $field_name) ? $default_value() : null;
168 220 }
169 221
170 222 // Set appropriate default based on field type
171 223 if ($default_value !== null) {
@@ -270,8 +322,20 @@
270 322
271 323
272 324
273 325
326 +
327 + // See ALWAYS_PERSISTED. Read only when actually stored, so an invoice
328 + // that never had these behaves exactly as it did.
329 + foreach (self::ALWAYS_PERSISTED as $field_name) {
330 + if (array_key_exists($field_name, $this->data) && '' !== (string) $this->data[$field_name]) {
331 + continue;
332 + }
333 + $value = get_post_meta($this->id, '_easy_invoice_' . $field_name, true);
334 + if ($value !== '' && $value !== false) {
335 + $this->data[$field_name] = $value;
336 + }
337 + }
274 338 }
275 339
276 340 /**
277 341 * Load invoice items
@@ -400,9 +464,16 @@
400 464 $this->calculateTotals();
401 465
402 466 // Save meta data (including invoice status)
403 467 $this->saveMetaData();
468 + foreach ($this->pending_meta as $pending_key => $pending_value) {
469 + update_post_meta($this->id, $pending_key, $pending_value);
470 + }
471 + $this->pending_meta = [];
404 472
473 + // Persist the computed total so lists, the dashboard and reports can sum in SQL.
474 + \EasyInvoice\Services\InvoiceTotalsCache::store($this);
475 +
405 476 // Allow plugins to perform actions after saving
406 477 do_action('easy_invoice_model_after_save', $this);
407 478
408 479 $this->is_modified = false;
@@ -456,8 +527,16 @@
456 527
457 528
458 529
459 530
531 +
532 + // See ALWAYS_PERSISTED.
533 + foreach (self::ALWAYS_PERSISTED as $field_name) {
534 + if (array_key_exists($field_name, $this->data) && null !== $this->data[$field_name]) {
535 + update_post_meta($this->id, '_easy_invoice_' . $field_name, $this->data[$field_name]);
536 + }
537 + }
538 +
460 539 // Save items
461 540 $this->saveItems();
462 541
463 542 // Allow plugins to save additional meta data
@@ -473,8 +552,18 @@
473 552 if (!$this->id) {
474 553 return;
475 554 }
476 555
556 + // Belt-and-suspenders edit gate. Addons (e.g. PartialPayments)
557 + // can return false here to freeze the items list once an
558 + // invoice has reached a lifecycle stage where edits would
559 + // invalidate linked records — currently used to lock paid
560 + // deposit invoices so the deposit/balance pair stays
561 + // consistent. Default: true (no listener registered).
562 + if (!apply_filters('easy_invoice_can_edit_invoice', true, $this->id)) {
563 + return;
564 + }
565 +
477 566 $items_data = [];
478 567 foreach ($this->items as $item) {
479 568 if (is_object($item) && method_exists($item, 'toArray')) {
480 569 $items_data[] = $item->toArray();
@@ -490,10 +579,38 @@
490 579 *
491 580 * @since 1.0.0
492 581 */
493 582 public function calculateTotals(): void {
583 + // Per-invoice tax_enabled override. Falls back to the global
584 + // Settings → Tax → Enable Tax option when the per-invoice
585 + // value is unset (eg. invoices created before the per-invoice
586 + // override was introduced). Set tax_rate to 0 when disabled
587 + // so the rest of the math runs unchanged — keeps tax_rate as
588 + // an authored value the user can flip back on without
589 + // re-entering it.
590 + $tax_enabled_meta = $this->data['tax_enabled'] ?? null;
591 + if ($tax_enabled_meta === null || $tax_enabled_meta === '') {
592 + $tax_enabled = get_option('easy_invoice_tax_enabled', 'no') === 'yes';
593 + } else {
594 + $tax_enabled = ($tax_enabled_meta === 'yes' || $tax_enabled_meta === '1'
595 + || $tax_enabled_meta === 1 || $tax_enabled_meta === true);
596 + }
597 +
598 + // A tax treatment can zero the rate for a legitimate reason — an intra-EU
599 + // reverse charge, or an export. This runs after the enabled check and can
600 + // only ever remove tax, never add it.
601 + //
602 + // TaxTreatment answers "standard rate" unless the merchant has switched
603 + // reverse-charge determination on AND supplied both parties' countries and
604 + // VAT numbers, so an existing invoice's total cannot change on upgrade.
605 + if ($tax_enabled && class_exists('\EasyInvoice\Services\TaxTreatment')) {
606 + if (!\EasyInvoice\Services\TaxTreatment::chargesTax($this)) {
607 + $tax_enabled = false;
608 + }
609 + }
610 +
494 611 $prices_include_tax = ($this->data['prices_include_tax'] ?? 'no') === 'yes';
495 - $tax_rate = floatval($this->data['tax_rate'] ?? 0);
612 + $tax_rate = $tax_enabled ? floatval($this->data['tax_rate'] ?? 0) : 0;
496 613
497 614 // Initialize totals
498 615 $subtotal = 0;
499 616 $taxable_subtotal = 0;
@@ -530,8 +647,11 @@
530 647 if ($adjust_percentage != 0 && \EasyInvoice\Controllers\SettingsController::shouldShowInvoiceAdjustField()) {
531 648 $item_total = $item_total * (1 + $adjust_percentage / 100);
532 649 }
533 650
651 + // Money lives in cents: round each line before it is summed, so the
652 + // lines printed on the document add up to the printed subtotal.
653 + $item_total = easy_invoice_round_money($item_total);
534 654 $subtotal += $item_total;
535 655 if ($is_taxable) {
536 656 $taxable_subtotal += $item_total;
537 657 }
@@ -542,12 +662,14 @@
542 662 // Get discount calculation method (default to before_tax)
543 663 $discount_calculation_method = $this->data['discount_calculation_method'] ?? 'before_tax';
544 664
545 665 // Calculate initial discount amount
666 + // A discount can never exceed what it is taken from: a percentage is capped
667 + // at 100 and a fixed amount at the subtotal, so a total is never negative.
546 668 if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $subtotal > 0) {
547 - $this->data['discount_amount'] = ($subtotal * $this->data['discount_value']) / 100;
669 + $this->data['discount_amount'] = easy_invoice_round_money(($subtotal * min(100, (float) $this->data['discount_value'])) / 100);
548 670 } elseif (($this->data['discount_type'] ?? '') === 'fixed' && ($this->data['discount_value'] ?? 0) > 0) {
549 - $this->data['discount_amount'] = $this->data['discount_value'];
671 + $this->data['discount_amount'] = min((float) $this->data['discount_value'], (float) $subtotal);
550 672 } else {
551 673 $this->data['discount_amount'] = 0;
552 674 }
553 675
@@ -557,16 +679,16 @@
557 679 $discount_ratio = ($subtotal > 0) ? ($this->data['discount_amount'] / $subtotal) : 0;
558 680 $taxable_amount = $taxable_subtotal * (1 - $discount_ratio);
559 681
560 682 if ($tax_rate > 0) {
561 - $this->data['tax_amount'] = ($taxable_amount * $tax_rate) / 100;
683 + $this->data['tax_amount'] = easy_invoice_round_money(($taxable_amount * $tax_rate) / 100);
562 684 }
563 685
564 - $this->data['total'] = $subtotal - $this->data['discount_amount'] + $this->data['tax_amount'];
686 + $this->data['total'] = max(0.0, easy_invoice_round_money($subtotal - $this->data['discount_amount'] + $this->data['tax_amount']));
565 687 } else {
566 688 // For after_tax: Calculate tax first, then apply discount
567 689 if ($tax_rate > 0) {
568 - $this->data['tax_amount'] = ($taxable_subtotal * $tax_rate) / 100;
690 + $this->data['tax_amount'] = easy_invoice_round_money(($taxable_subtotal * $tax_rate) / 100);
569 691 }
570 692
571 693 $total_before_discount = $subtotal + $this->data['tax_amount'];
572 694
@@ -571,12 +693,14 @@
571 693 $total_before_discount = $subtotal + $this->data['tax_amount'];
572 694
573 695 // Recalculate percentage discount based on total including tax
574 696 if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $total_before_discount > 0) {
575 - $this->data['discount_amount'] = ($total_before_discount * $this->data['discount_value']) / 100;
697 + $this->data['discount_amount'] = easy_invoice_round_money(($total_before_discount * min(100, (float) $this->data['discount_value'])) / 100);
698 + } elseif (($this->data['discount_type'] ?? '') === 'fixed') {
699 + $this->data['discount_amount'] = min((float) $this->data['discount_amount'], (float) $total_before_discount);
576 700 }
577 701
578 - $this->data['total'] = $total_before_discount - $this->data['discount_amount'];
702 + $this->data['total'] = max(0.0, easy_invoice_round_money($total_before_discount - $this->data['discount_amount']));
579 703 }
580 704
581 705 // Allow plugins to modify calculations
582 706 do_action('easy_invoice_model_calculate_totals', $this);
@@ -592,9 +716,17 @@
592 716 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
593 717 $client = $client_repository->find($this->data['client_id']);
594 718
595 719 if ($client) {
596 - $this->data['customer_name'] = $client->getBusinessClientName() ?: '';
720 + // Business name when there is one; otherwise the person's
721 + // name — an invoice to an individual must still say who it
722 + // is for.
723 + $person = trim( (string) $client->getFirstName() . ' ' . (string) $client->getLastName() );
724 + if ( '' === $person ) {
725 + $user = get_userdata( (int) $this->data['client_id'] );
726 + $person = $user ? (string) $user->display_name : '';
727 + }
728 + $this->data['customer_name'] = $client->getBusinessClientName() ?: $person;
597 729 $this->data['customer_email'] = $client->getEmail() ?: '';
598 730 $this->data['customer_address'] = $client->getAddress() ?: '';
599 731 }
600 732 }
@@ -689,9 +821,9 @@
689 821 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'has' prefix
690 822 return !empty($this->__get($field_name));
691 823 }
692 824
693 - throw new \BadMethodCallException("Method $name does not exist");
825 + throw new \BadMethodCallException(esc_html("Method $name does not exist"));
694 826 }
695 827
696 828 /**
697 829 * Convert camelCase to snake_case
@@ -736,8 +868,64 @@
736 868 * @since 1.0.0
737 869 * @param string $key Meta key
738 870 * @param mixed $value Meta value
739 871 */
872 + /** @var array<string,mixed> Meta queued by setMeta() before the post exists. */
873 + private $pending_meta = [];
874 +
875 + /**
876 + * Raw post meta on this document, read from the database.
877 + *
878 + * Before this existed, `$model->getMeta('key')` fell through to __call()
879 + * as a getter for a field named "meta" and returned null for every key.
880 + *
881 + * @param string $key Meta key (any key, prefixed or not).
882 + * @param mixed $default Returned when the meta is absent or empty.
883 + * @return mixed
884 + */
885 + public function getMeta(string $key, $default = '') {
886 + if (array_key_exists($key, $this->pending_meta)) {
887 + return $this->pending_meta[$key];
888 + }
889 + if (!$this->id) {
890 + return $default;
891 + }
892 + $value = get_post_meta($this->id, $key, true);
893 + return ('' === $value || null === $value) ? $default : $value;
894 + }
895 +
896 + /**
897 + * Write post meta: at once when the post exists, otherwise on save().
898 + * A prefixed key also updates the model's own field so getters agree.
899 + *
900 + * @param string $key Meta key.
901 + * @param mixed $value Value.
902 + */
903 + public function setMeta(string $key, $value): void {
904 + if (0 === strpos($key, '_easy_invoice_')) {
905 + $this->setMetaData($key, $value);
906 + }
907 + if ($this->id) {
908 + update_post_meta($this->id, $key, $value);
909 + } else {
910 + $this->pending_meta[$key] = $value;
911 + }
912 + }
913 +
914 + /**
915 + * A model field by its meta key, falling back to stored post meta.
916 + *
917 + * @param string $key Meta key, with or without the _easy_invoice_ prefix.
918 + * @return mixed Null when unknown.
919 + */
920 + public function getMetaData(string $key) {
921 + $field = easy_invoice_str_replace('_easy_invoice_', '', $key);
922 + if (array_key_exists($field, $this->data)) {
923 + return $this->data[$field];
924 + }
925 + return $this->getMeta($key, null);
926 + }
927 +
740 928 public function setMetaData(string $key, $value): void {
741 929 // Store in dynamic data array without the _easy_invoice_ prefix
742 930 $field_name = easy_invoice_str_replace('_easy_invoice_', '', $key);
743 931 $this->data[$field_name] = $value;