PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
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
easy-invoice / includes / Models / Invoice.php

Invoice.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at includes/Models/Invoice.php

1,106 lines 37.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Invoice Model
4 *
5 * @package EasyInvoice
6 * @author Your Name
7 * @copyright Copyright (c) 2023, Your Company
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.0.0
10 */
11
12 namespace EasyInvoice\Models;
13
14 use EasyInvoice\Constants\PostTypes;
15 use EasyInvoice\Models\InvoiceItem;
16
17 /**
18 * Invoice Model
19 *
20 * Represents an invoice in the system with extensible architecture for pro features.
21 *
22 * @since 1.0.0
23 */
24 class Invoice {
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 /**
56 * Invoice ID
57 *
58 * @var int
59 */
60 private $id;
61
62 /**
63 * Dynamic data storage for all fields
64 *
65 * @var array
66 */
67 private $data = [];
68
69 /**
70 * Items
71 *
72 * @var array
73 */
74 private $items = [];
75
76 /**
77 * Modified flag
78 *
79 * @var bool
80 */
81 private $is_modified = false;
82
83 /**
84 * Magic method to get dynamic properties
85 *
86 * @since 1.0.0
87 * @param string $name Property name
88 * @return mixed Property value
89 */
90 public function __get($name) {
91 // Handle special properties
92 if ($name === 'id') {
93 return $this->id;
94 }
95
96 // Special handling for total field
97 if ($name === 'total') {
98 return $this->getTotal();
99 }
100
101 // Return from dynamic data array
102 return $this->data[$name] ?? null;
103 }
104
105 /**
106 * Magic method to set dynamic properties
107 *
108 * @since 1.0.0
109 * @param string $name Property name
110 * @param mixed $value Property value
111 */
112 public function __set($name, $value) {
113 // Handle special properties
114 if ($name === 'id') {
115 $this->id = $value;
116 return;
117 }
118
119 // Store in dynamic data array
120 $this->data[$name] = $value;
121 $this->is_modified = true;
122 }
123
124 /**
125 * Magic method to check if property exists
126 *
127 * @since 1.0.0
128 * @param string $name Property name
129 * @return bool
130 */
131 public function __isset($name) {
132 if ($name === 'id') {
133 return isset($this->id);
134 }
135
136 return isset($this->data[$name]);
137 }
138
139 /**
140 * Constructor
141 *
142 * @since 1.0.0
143 * @param \WP_Post|int $invoice Invoice post object or ID
144 */
145 public function __construct($invoice = null) {
146 // Initialize data array dynamically from field configuration
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 );
154
155 if ($invoice instanceof \WP_Post) {
156 $this->loadFromPost($invoice);
157 } elseif (is_numeric($invoice)) {
158 $post = get_post($invoice);
159 if ($post && $post->post_type === PostTypes::EASY_INVOICE_POST_TYPE) {
160 $this->loadFromPost($post);
161 }
162 }
163
164 do_action('easy_invoice_invoice_model_constructed', $this);
165 }
166
167 /**
168 * Get default values from field configuration
169 *
170 * @since 1.0.0
171 * @return array
172 */
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 {
189 $default_values = [];
190
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 }
200 }
201 self::$field_definitions_cache = $field_definitions;
202 }
203 $field_definitions = self::$field_definitions_cache;
204
205 // Extract default values from field configuration
206 foreach ($field_definitions as $field) {
207 $field_name = $field['name'] ?? '';
208 if (empty($field_name)) {
209 continue;
210 }
211
212 $default_value = $field['default_value'] ?? null;
213
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.
218 if (is_callable($default_value)) {
219 $default_value = ($evaluate_callables || 'number' !== $field_name) ? $default_value() : null;
220 }
221
222 // Set appropriate default based on field type
223 if ($default_value !== null) {
224 $default_values[$field_name] = $default_value;
225 } else {
226 // Set type-appropriate defaults
227 $field_type = $field['type'] ?? 'text';
228 switch ($field_type) {
229 case 'number':
230 $default_values[$field_name] = 0.0;
231 break;
232 case 'checkbox':
233 $default_values[$field_name] = false;
234 break;
235 case 'select':
236 $default_values[$field_name] = '';
237 break;
238 case 'array':
239 $default_values[$field_name] = [];
240 break;
241 default:
242 $default_values[$field_name] = '';
243 break;
244 }
245 }
246 }
247
248 return $default_values;
249 }
250
251 /**
252 * Load invoice data from WP_Post
253 *
254 * @since 1.0.0
255 * @param \WP_Post $post
256 */
257 private function loadFromPost(\WP_Post $post): void {
258 $this->id = $post->ID;
259 $this->data['title'] = $post->post_title ?: '';
260 $this->data['created_date'] = $post->post_date ?: '';
261 $this->data['modified_date'] = $post->post_modified ?: '';
262
263 // Load meta data using configuration-driven approach
264 $this->loadMetaData();
265
266 // Load items
267 $this->loadItems();
268
269 // Auto-calculate totals if they're 0 or if we have items but no totals
270 if ((($this->data['total'] ?? 0) == 0 && !empty($this->items)) ||
271 (($this->data['subtotal'] ?? 0) == 0 && !empty($this->items))) {
272 $this->calculateTotals();
273 }
274
275 // Populate client information if we have a client_id but no customer data
276 if (($this->data['client_id'] ?? 0) > 0 && (empty($this->data['customer_name'] ?? '') || empty($this->data['customer_email'] ?? ''))) {
277 $this->populateClientInfo();
278 }
279 }
280
281 /**
282 * Load meta data from database
283 *
284 * @since 1.0.0
285 */
286 private function loadMetaData(): void {
287 if (!$this->id) {
288 return;
289 }
290
291 // Get field definitions to determine which meta keys to load
292 $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration();
293
294 // Initialize the field registration to ensure fields are registered
295 $field_registration->registerDefaultTabs();
296 $field_registration->registerDefaultFields();
297
298 $field_definitions = [];
299 $tabs = $field_registration->getTabs();
300 foreach ($tabs as $tab_id => $tab) {
301 $tab_fields = $field_registration->getFields($tab_id);
302 foreach ($tab_fields as $field) {
303 $field_definitions[] = $field;
304 }
305 }
306
307 // Load meta data for each field definition
308 foreach ($field_definitions as $field) {
309 $field_name = $field['name'] ?? '';
310 if (empty($field_name)) {
311 continue;
312 }
313
314 $meta_key = '_easy_invoice_' . $field_name;
315 $value = get_post_meta($this->id, $meta_key, true);
316
317 if ($value !== '') {
318 // Store in dynamic data array
319 $this->data[$field_name] = $value;
320 }
321 }
322
323
324
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 }
338 }
339
340 /**
341 * Load invoice items
342 *
343 * @since 1.0.0
344 */
345 private function loadItems(): void {
346 if (!$this->id) {
347 return;
348 }
349
350 $items_data = get_post_meta($this->id, '_easy_invoice_items', true) ?: [];
351 $this->items = [];
352
353 foreach ($items_data as $item_data) {
354 $this->items[] = new \EasyInvoice\Models\InvoiceItem($item_data);
355 }
356 }
357
358 /**
359 * Ensure the invoice has a proper slug for pretty permalinks
360 *
361 * @since 1.0.0
362 * @return bool True if successful, false otherwise
363 */
364 public function ensureProperSlug(): bool {
365 if (!$this->id) {
366 return false;
367 }
368
369 $post = get_post($this->id);
370 if (!$post || empty($post->post_name)) {
371 // Generate a slug from the title
372 $post_title = $this->data['title'] ?: $this->data['number'] ?: 'Untitled Invoice';
373 $post_name = sanitize_title($post_title);
374
375 // Ensure uniqueness
376 $original_slug = $post_name;
377 $counter = 1;
378 while (get_page_by_path($post_name, OBJECT, PostTypes::EASY_INVOICE_POST_TYPE)) {
379 $post_name = $original_slug . '-' . $counter;
380 $counter++;
381 }
382
383 // Update the post with the new slug
384 $result = wp_update_post([
385 'ID' => $this->id,
386 'post_name' => $post_name,
387 'post_status' => 'publish' // Ensure it's published for proper permalinks
388 ]);
389
390 return !is_wp_error($result);
391 }
392
393 return true;
394 }
395
396 /**
397 * Save the invoice
398 * Always publish invoices to ensure proper permalinks
399 * Invoice status will be stored in post meta (_easy_invoice_status)
400 *
401 * @since 1.0.0
402 * @return bool True if successful, false otherwise
403 */
404 public function save(): bool {
405 // Allow plugins to modify the invoice before saving
406 do_action('easy_invoice_model_before_save', $this);
407
408 // Always publish invoices to ensure proper permalinks
409 // Invoice status will be stored in post meta (_easy_invoice_status)
410 $post_status = 'publish';
411
412 // Generate a proper slug for pretty permalinks
413 $post_title = $this->data['title'] ?: $this->data['number'] ?: 'Untitled Invoice';
414 $post_name = '';
415
416 if ($this->id) {
417 // For existing posts, get the current post to preserve slug if it exists
418 $existing_post = get_post($this->id);
419 if ($existing_post && !empty($existing_post->post_name)) {
420 $post_name = $existing_post->post_name;
421 }
422 }
423
424 // If no slug exists, generate one from the title
425 if (empty($post_name)) {
426 $post_name = sanitize_title($post_title);
427
428 // Ensure uniqueness by appending number if needed
429 $original_slug = $post_name;
430 $counter = 1;
431 while (get_page_by_path($post_name, OBJECT, PostTypes::EASY_INVOICE_POST_TYPE)) {
432 $post_name = $original_slug . '-' . $counter;
433 $counter++;
434 }
435 }
436
437 // Prepare post data
438 $post_data = [
439 'post_title' => $post_title,
440 'post_content' => $this->data['notes'] ?: '', // Ensure post_content is never null
441 'post_status' => $post_status, // Always publish for proper permalinks
442 'post_type' => PostTypes::EASY_INVOICE_POST_TYPE,
443 'post_name' => $post_name, // Add the generated slug
444 ];
445
446 // Update existing post or create new one
447 if ($this->id) {
448 $post_data['ID'] = $this->id;
449 $post_id = wp_update_post($post_data);
450 } else {
451 $post_id = wp_insert_post($post_data);
452 }
453
454 if (is_wp_error($post_id)) {
455 return false;
456 }
457
458 // Update the ID if this was a new post
459 if (!$this->id) {
460 $this->id = $post_id;
461 }
462
463 // Calculate totals from items before saving
464 $this->calculateTotals();
465
466 // Save meta data (including invoice status)
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 = [];
472
473 // Persist the computed total so lists, the dashboard and reports can sum in SQL.
474 \EasyInvoice\Services\InvoiceTotalsCache::store($this);
475
476 // Allow plugins to perform actions after saving
477 do_action('easy_invoice_model_after_save', $this);
478
479 $this->is_modified = false;
480 return true;
481 }
482
483 /**
484 * Save meta data to database
485 *
486 * @since 1.0.0
487 */
488 private function saveMetaData(): void {
489 if (!$this->id) {
490 return;
491 }
492
493 // Get field definitions to determine which meta keys to save
494 $field_registration = new \EasyInvoice\Forms\Invoice\InvoiceFieldRegistration();
495
496 // Initialize the field registration to ensure fields are registered
497 $field_registration->registerDefaultTabs();
498 $field_registration->registerDefaultFields();
499
500 $field_definitions = [];
501 $tabs = $field_registration->getTabs();
502 foreach ($tabs as $tab_id => $tab) {
503 $tab_fields = $field_registration->getFields($tab_id);
504 foreach ($tab_fields as $field) {
505 $field_definitions[] = $field;
506 }
507 }
508
509 // Save meta data for each field definition
510 foreach ($field_definitions as $field) {
511 $field_name = $field['name'] ?? '';
512 if (empty($field_name)) {
513 continue;
514 }
515
516 $meta_key = '_easy_invoice_' . $field_name;
517
518 // Get the value from dynamic data array
519 // Save all fields that exist in the data array (including empty strings to allow clearing fields)
520 // If a field exists in $this->data, it means it was explicitly set, so we should save it
521 if (array_key_exists($field_name, $this->data)) {
522 $value = $this->data[$field_name];
523 update_post_meta($this->id, $meta_key, $value);
524 }
525 }
526
527
528
529
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
539 // Save items
540 $this->saveItems();
541
542 // Allow plugins to save additional meta data
543 do_action('easy_invoice_model_save_meta_data', $this);
544 }
545
546 /**
547 * Save invoice items
548 *
549 * @since 1.0.0
550 */
551 private function saveItems(): void {
552 if (!$this->id) {
553 return;
554 }
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
566 $items_data = [];
567 foreach ($this->items as $item) {
568 if (is_object($item) && method_exists($item, 'toArray')) {
569 $items_data[] = $item->toArray();
570 } else {
571 $items_data[] = $item;
572 }
573 }
574 update_post_meta($this->id, '_easy_invoice_items', $items_data);
575 }
576
577 /**
578 * Calculate totals from items
579 *
580 * @since 1.0.0
581 */
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
611 $prices_include_tax = ($this->data['prices_include_tax'] ?? 'no') === 'yes';
612 $tax_rate = $tax_enabled ? floatval($this->data['tax_rate'] ?? 0) : 0;
613
614 // Initialize totals
615 $subtotal = 0;
616 $taxable_subtotal = 0;
617 $this->data['tax_amount'] = 0;
618 $this->data['discount_amount'] = 0;
619
620 // First pass: Calculate raw totals
621 foreach ($this->items as $index => $item) {
622 $quantity = 0;
623 $price = 0;
624 $adjust_percentage = 0;
625 $is_taxable = true;
626
627 if (is_object($item) && method_exists($item, 'getAmount')) {
628 $quantity = $item->getQuantity();
629 $price = $item->getPrice();
630 $adjust_percentage = $item->getAdjustPercentage();
631 $is_taxable = $item->isTaxable();
632 } elseif (is_array($item)) {
633 $quantity = isset($item['quantity']) ? (float) $item['quantity'] : 0;
634 $price = isset($item['price']) ? (float) $item['price'] : 0;
635 $adjust_percentage = isset($item['adjust_percentage']) ? (float) $item['adjust_percentage'] : 0;
636 $is_taxable = isset($item['taxable']) ? (bool) $item['taxable'] : true;
637 }
638
639 // If prices include tax and item is taxable, remove tax from price
640 if ($prices_include_tax && $is_taxable && $tax_rate > 0) {
641 $price = $price / (1 + ($tax_rate / 100));
642 }
643
644 // Calculate item total
645 $item_total = $quantity * $price;
646 // Only apply adjust percentage if the adjust field is enabled
647 if ($adjust_percentage != 0 && \EasyInvoice\Controllers\SettingsController::shouldShowInvoiceAdjustField()) {
648 $item_total = $item_total * (1 + $adjust_percentage / 100);
649 }
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);
654 $subtotal += $item_total;
655 if ($is_taxable) {
656 $taxable_subtotal += $item_total;
657 }
658 }
659
660 $this->data['subtotal'] = $subtotal;
661
662 // Get discount calculation method (default to before_tax)
663 $discount_calculation_method = $this->data['discount_calculation_method'] ?? 'before_tax';
664
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.
668 if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $subtotal > 0) {
669 $this->data['discount_amount'] = easy_invoice_round_money(($subtotal * min(100, (float) $this->data['discount_value'])) / 100);
670 } elseif (($this->data['discount_type'] ?? '') === 'fixed' && ($this->data['discount_value'] ?? 0) > 0) {
671 $this->data['discount_amount'] = min((float) $this->data['discount_value'], (float) $subtotal);
672 } else {
673 $this->data['discount_amount'] = 0;
674 }
675
676 // Calculate tax and total based on discount calculation method
677 if ($discount_calculation_method === 'before_tax') {
678 // For before_tax: Apply discount first, then calculate tax on remaining taxable amount
679 $discount_ratio = ($subtotal > 0) ? ($this->data['discount_amount'] / $subtotal) : 0;
680 $taxable_amount = $taxable_subtotal * (1 - $discount_ratio);
681
682 if ($tax_rate > 0) {
683 $this->data['tax_amount'] = easy_invoice_round_money(($taxable_amount * $tax_rate) / 100);
684 }
685
686 $this->data['total'] = max(0.0, easy_invoice_round_money($subtotal - $this->data['discount_amount'] + $this->data['tax_amount']));
687 } else {
688 // For after_tax: Calculate tax first, then apply discount
689 if ($tax_rate > 0) {
690 $this->data['tax_amount'] = easy_invoice_round_money(($taxable_subtotal * $tax_rate) / 100);
691 }
692
693 $total_before_discount = $subtotal + $this->data['tax_amount'];
694
695 // Recalculate percentage discount based on total including tax
696 if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $total_before_discount > 0) {
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);
700 }
701
702 $this->data['total'] = max(0.0, easy_invoice_round_money($total_before_discount - $this->data['discount_amount']));
703 }
704
705 // Allow plugins to modify calculations
706 do_action('easy_invoice_model_calculate_totals', $this);
707 }
708
709 /**
710 * Populate client information from client repository
711 *
712 * @since 1.0.0
713 */
714 public function populateClientInfo(): void {
715 if (($this->data['client_id'] ?? 0) > 0) {
716 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
717 $client = $client_repository->find($this->data['client_id']);
718
719 if ($client) {
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;
729 $this->data['customer_email'] = $client->getEmail() ?: '';
730 $this->data['customer_address'] = $client->getAddress() ?: '';
731 }
732 }
733 }
734
735 /**
736 * Force populate client info and save (for existing invoices that need client data)
737 *
738 * @since 1.0.0
739 * @return bool
740 */
741 public function forcePopulateClientInfo(): bool {
742 $this->populateClientInfo();
743 return $this->save();
744 }
745
746 /**
747 * Recalculate totals and save (for existing invoices)
748 *
749 * @since 1.0.0
750 * @return bool
751 */
752 public function recalculateAndSave(): bool {
753 // Populate client information if we have a client_id
754 $this->populateClientInfo();
755
756 // Calculate totals from items
757 $this->calculateTotals();
758
759 // Save the updated invoice
760 return $this->save();
761 }
762
763 /**
764 * Convert to array
765 *
766 * @since 1.0.0
767 * @return array
768 */
769 public function toArray(): array {
770 $items_array = [];
771 foreach ($this->items as $item) {
772 if (is_object($item) && method_exists($item, 'toArray')) {
773 $items_array[] = $item->toArray();
774 } else {
775 $items_array[] = $item;
776 }
777 }
778
779 // Start with dynamic data
780 $data = $this->data;
781
782 // Add special properties
783 $data['id'] = $this->id;
784 $data['items'] = $items_array;
785
786 // Allow plugins to modify the array data
787 return apply_filters('easy_invoice_model_to_array', $data, $this);
788 }
789
790 /**
791 * Dynamic getter method
792 *
793 * @since 1.0.0
794 * @param string $name Method name
795 * @param array $arguments Method arguments
796 * @return mixed
797 */
798 public function __call($name, $arguments) {
799 // Handle getter methods (getFieldName)
800 if (strpos($name, 'get') === 0) {
801 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'get' prefix
802 return $this->__get($field_name);
803 }
804
805 // Handle setter methods (setFieldName)
806 if (strpos($name, 'set') === 0) {
807 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'set' prefix
808 $value = $arguments[0] ?? null;
809 $this->__set($field_name, $value);
810 return null;
811 }
812
813 // Handle isset methods (isFieldName)
814 if (strpos($name, 'is') === 0) {
815 $field_name = $this->camelCaseToSnakeCase(substr($name, 2)); // Remove 'is' prefix
816 return (bool) $this->__get($field_name);
817 }
818
819 // Handle has methods (hasFieldName)
820 if (strpos($name, 'has') === 0) {
821 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'has' prefix
822 return !empty($this->__get($field_name));
823 }
824
825 throw new \BadMethodCallException(esc_html("Method $name does not exist"));
826 }
827
828 /**
829 * Convert camelCase to snake_case
830 *
831 * @since 1.0.0
832 * @param string $camelCase
833 * @return string
834 */
835 private function camelCaseToSnakeCase($camelCase) {
836 return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $camelCase));
837 }
838
839 // Essential methods only
840 public function getId(): int { return $this->id ?? 0; }
841 public function setId(int $id): void { $this->id = $id; }
842 public function getItems(): array { return $this->items; }
843
844 /**
845 * Check if invoice exists
846 *
847 * @return bool
848 */
849 public function exists(): bool {
850 return $this->id > 0 && get_post($this->id) !== null;
851 }
852 public function setItems(array $items): void {
853 $this->items = [];
854 foreach ($items as $item) {
855 if (is_array($item)) {
856 $this->items[] = new InvoiceItem($item);
857 } elseif (is_object($item) && $item instanceof InvoiceItem) {
858 $this->items[] = $item;
859 }
860 }
861 $this->is_modified = true;
862 }
863 public function isModified(): bool { return $this->is_modified; }
864
865 /**
866 * Set meta data for the invoice
867 *
868 * @since 1.0.0
869 * @param string $key Meta key
870 * @param mixed $value Meta value
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
928 public function setMetaData(string $key, $value): void {
929 // Store in dynamic data array without the _easy_invoice_ prefix
930 $field_name = easy_invoice_str_replace('_easy_invoice_', '', $key);
931 $this->data[$field_name] = $value;
932 $this->is_modified = true;
933 }
934
935 /**
936 * Get currency code
937 *
938 * @since 1.0.0
939 * @return string Currency code
940 */
941 public function getCurrencyCode(): string {
942 $currency_code = $this->data['currency_code'] ?? 'USD';
943
944 // If currency is set to 'global', resolve to actual global setting
945 if ($currency_code === 'global') {
946 $currency_code = get_option('easy_invoice_currency_code', 'USD');
947 }
948
949 // Ensure currency code is uppercase for consistency
950 return strtoupper($currency_code);
951 }
952
953 /**
954 * Set currency code
955 *
956 * @since 1.0.0
957 * @param string $currency_code Currency code
958 */
959 public function setCurrencyCode(string $currency_code): void {
960 $this->data['currency_code'] = $currency_code;
961 $this->is_modified = true;
962 }
963
964 /**
965 * Get currency position
966 *
967 * @since 1.0.0
968 * @return string Currency position
969 */
970 public function getCurrencyPosition(): string {
971 $currency_position = $this->data['currency_position'] ?? 'left';
972
973 // If currency position is set to 'global', resolve to actual global setting
974 if ($currency_position === 'global') {
975 $currency_position = get_option('easy_invoice_currency_position', 'left');
976 }
977
978 // Map form format to global settings format
979 if ($currency_position === 'before') {
980 $currency_position = 'left';
981 } elseif ($currency_position === 'after') {
982 $currency_position = 'right';
983 }
984
985 return $currency_position;
986 }
987
988 /**
989 * Set currency position
990 *
991 * @since 1.0.0
992 * @param string $currency_position Currency position
993 */
994 public function setCurrencyPosition(string $currency_position): void {
995 $this->data['currency_position'] = $currency_position;
996 $this->is_modified = true;
997 }
998
999 /**
1000 * Get raw currency code (without resolving global)
1001 *
1002 * @since 1.0.0
1003 * @return string Raw currency code
1004 */
1005 public function getRawCurrencyCode(): string {
1006 return $this->data['currency_code'] ?? 'global';
1007 }
1008
1009 /**
1010 * Get raw currency position (without resolving global)
1011 *
1012 * @since 1.0.0
1013 * @return string Raw currency position
1014 */
1015 public function getRawCurrencyPosition(): string {
1016 return $this->data['currency_position'] ?? 'global';
1017 }
1018
1019 public function getDescription(): string {
1020 return $this->data['description'] ?? '';
1021 }
1022
1023 public function setDescription(string $description): void {
1024 $this->data['description'] = $description;
1025 $this->is_modified = true;
1026 }
1027
1028 public function getTemplate(): string {
1029 return $this->data['invoice_template'] ?? 'standard';
1030 }
1031
1032 public function setTemplate(string $template): void {
1033 $this->data['invoice_template'] = $template;
1034 $this->is_modified = true;
1035 }
1036
1037 /**
1038 * Get discount type (percentage, fixed, none)
1039 *
1040 * @since 1.0.0
1041 * @return string
1042 */
1043 public function getDiscountType(): string {
1044 return $this->data['discount_type'] ?? 'none';
1045 }
1046
1047 /**
1048 * Get discount value (percentage or fixed amount)
1049 *
1050 * @since 1.0.0
1051 * @return float
1052 */
1053 public function getDiscountValue(): float {
1054 return floatval($this->data['discount_value'] ?? 0);
1055 }
1056
1057 /**
1058 * Get discount amount (calculated value)
1059 *
1060 * @since 1.0.0
1061 * @return float
1062 */
1063 public function getDiscountAmount(): float {
1064 $this->calculateTotals();
1065 return floatval($this->data['discount_amount'] ?? 0);
1066 }
1067
1068 /**
1069 * Get tax rate percentage
1070 *
1071 * @since 1.0.0
1072 * @return float
1073 */
1074 public function getTaxRate(): float {
1075 return floatval($this->data['tax_rate'] ?? 0);
1076 }
1077
1078 /**
1079 * Get tax amount (calculated value)
1080 *
1081 * @since 1.0.0
1082 * @return float
1083 */
1084 public function getTaxAmount(): float {
1085 $this->calculateTotals();
1086 return floatval($this->data['tax_amount'] ?? 0);
1087 }
1088
1089 /**
1090 * Get the total amount for this invoice
1091 * Calculate from items
1092 *
1093 * @since 1.0.0
1094 * @return float
1095 */
1096 public function getTotal(): float {
1097 // Calculate from items
1098 $this->calculateTotals();
1099 $total = (float) ($this->data['total'] ?? 0);
1100
1101 // Allow plugins to modify the total
1102 return apply_filters('easy_invoice_invoice_total', $total, $this);
1103 }
1104
1105
1106 }