| @@ -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; |