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 / Quote.php

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

1,057 lines 36.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Quote 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\QuoteItem;
16
17 /**
18 * Quote Model
19 *
20 * Represents a quote in the system with extensible architecture for pro features.
21 *
22 * @since 1.0.0
23 */
24 class Quote {
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 /**
37 * Quote ID
38 *
39 * @var int
40 */
41 private $id;
42
43 /**
44 * Dynamic data storage for all fields
45 *
46 * @var array
47 */
48 private $data = [];
49
50 /**
51 * Items
52 *
53 * @var array
54 */
55 private $items = [];
56
57 /**
58 * Modified flag
59 *
60 * @var bool
61 */
62 private $is_modified = false;
63
64 /**
65 * Magic method to get dynamic properties
66 *
67 * @since 1.0.0
68 * @param string $name Property name
69 * @return mixed Property value
70 */
71 public function __get($name) {
72 // Handle special properties
73 if ($name === 'id') {
74 return $this->id;
75 }
76
77 // Return from dynamic data array
78 return $this->data[$name] ?? null;
79 }
80
81 /**
82 * Magic method to set dynamic properties
83 *
84 * @since 1.0.0
85 * @param string $name Property name
86 * @param mixed $value Property value
87 */
88 public function __set($name, $value) {
89 // Handle special properties
90 if ($name === 'id') {
91 $this->id = $value;
92 return;
93 }
94
95 // Store in dynamic data array
96 $this->data[$name] = $value;
97 $this->is_modified = true;
98 }
99
100 /**
101 * Magic method to check if property exists
102 *
103 * @since 1.0.0
104 * @param string $name Property name
105 * @return bool
106 */
107 public function __isset($name) {
108 if ($name === 'id') {
109 return isset($this->id);
110 }
111
112 return isset($this->data[$name]);
113 }
114
115 /**
116 * Dynamic getter method
117 *
118 * @since 1.0.0
119 * @param string $name Method name
120 * @param array $arguments Method arguments
121 * @return mixed
122 */
123 public function __call($name, $arguments) {
124 // Handle getter methods (getFieldName)
125 if (strpos($name, 'get') === 0) {
126 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'get' prefix
127 return $this->__get($field_name);
128 }
129
130 // Handle setter methods (setFieldName)
131 if (strpos($name, 'set') === 0) {
132 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'set' prefix
133 $value = $arguments[0] ?? null;
134 $this->__set($field_name, $value);
135 return null;
136 }
137
138 // Handle isset methods (isFieldName)
139 if (strpos($name, 'is') === 0) {
140 $field_name = $this->camelCaseToSnakeCase(substr($name, 2)); // Remove 'is' prefix
141 return (bool) $this->__get($field_name);
142 }
143
144 // Handle has methods (hasFieldName)
145 if (strpos($name, 'has') === 0) {
146 $field_name = $this->camelCaseToSnakeCase(substr($name, 3)); // Remove 'has' prefix
147 return !empty($this->__get($field_name));
148 }
149
150 throw new \BadMethodCallException(esc_html("Method $name does not exist"));
151 }
152
153 /**
154 * Convert camelCase to snake_case
155 *
156 * @since 1.0.0
157 * @param string $camelCase
158 * @return string
159 */
160 private function camelCaseToSnakeCase($camelCase) {
161 return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $camelCase));
162 }
163
164 /**
165 * Constructor
166 *
167 * @since 1.0.0
168 * @param \WP_Post|int $quote Quote post object or ID
169 */
170 public function __construct($quote = null) {
171 // Initialize data array dynamically from field configuration
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 );
179
180 if ($quote instanceof \WP_Post) {
181 $this->loadFromPost($quote);
182 } elseif (is_numeric($quote)) {
183 $post = get_post($quote);
184 if ($post && $post->post_type === PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) {
185 $this->loadFromPost($post);
186 }
187 }
188
189 do_action('easy_invoice_quote_model_constructed', $this);
190 }
191
192 /**
193 * Get default values from field configuration
194 *
195 * @since 1.0.0
196 * @return array
197 */
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 {
214 $default_values = [];
215
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 }
225 }
226 self::$field_definitions_cache = $field_definitions;
227 }
228 $field_definitions = self::$field_definitions_cache;
229
230 // Extract default values from field configuration
231 foreach ($field_definitions as $field) {
232 $field_name = $field['name'] ?? '';
233 if (empty($field_name)) {
234 continue;
235 }
236
237 $default_value = $field['default_value'] ?? null;
238
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.
243 if (is_callable($default_value)) {
244 $default_value = ($evaluate_callables || 'number' !== $field_name) ? $default_value() : null;
245 }
246
247 // Set appropriate default based on field type
248 if ($default_value !== null) {
249 $default_values[$field_name] = $default_value;
250 } else {
251 // Set type-appropriate defaults
252 $field_type = $field['type'] ?? 'text';
253 switch ($field_type) {
254 case 'number':
255 $default_values[$field_name] = 0.0;
256 break;
257 case 'checkbox':
258 $default_values[$field_name] = false;
259 break;
260 case 'select':
261 $default_values[$field_name] = '';
262 break;
263 case 'array':
264 $default_values[$field_name] = [];
265 break;
266 default:
267 $default_values[$field_name] = '';
268 break;
269 }
270 }
271 }
272
273 return $default_values;
274 }
275
276 /**
277 * Load quote data from WP_Post object
278 *
279 * @since 1.0.0
280 * @param \WP_Post $post Post object
281 */
282 private function loadFromPost(\WP_Post $post): void {
283 $this->id = $post->ID;
284 $this->data['title'] = $post->post_title ?: '';
285 $this->data['created_date'] = $post->post_date ?: '';
286 $this->data['modified_date'] = $post->post_modified ?: '';
287
288 // Load items first so they're available for total calculations
289 $this->loadItems();
290
291 // Load meta data using configuration-driven approach
292 $this->loadMetaData();
293
294 // Allow plugins to load additional data
295 do_action('easy_invoice_quote_loaded_from_post', $this, $post);
296
297 // Ensure totals are calculated
298 $this->calculateTotals();
299 }
300
301 /**
302 * Load quote items
303 *
304 * @since 1.0.0
305 */
306 private function loadItems(): void {
307 $items_data = get_post_meta($this->id, '_easy_invoice_quote_items', true);
308 if (is_array($items_data)) {
309 $this->items = []; // Clear existing items
310 foreach ($items_data as $item_data) {
311 if (is_array($item_data)) {
312 // Ensure required fields have default values
313 // Ensure taxable field is properly set
314 $taxable = isset($item_data['taxable']) ? $item_data['taxable'] : true;
315 if (is_string($taxable)) {
316 $taxable = strtolower($taxable);
317 $taxable = $taxable === '1' || $taxable === 'true' || $taxable === 'yes' || $taxable === 'on';
318 }
319 $taxable = (bool) $taxable;
320
321 $item_data = array_merge([
322 'quantity' => 0,
323 'price' => 0,
324 'adjust_percentage' => 0,
325 'taxable' => $taxable,
326 'name' => '',
327 'description' => ''
328 ], $item_data);
329 $this->items[] = new QuoteItem($item_data);
330 }
331 }
332 }
333 }
334
335 /**
336 * Load meta data using configuration-driven approach
337 *
338 * @since 1.0.0
339 */
340 private function loadMetaData(): void {
341 if (!$this->id) {
342 return;
343 }
344
345 // Get field definitions to determine which meta keys to load
346 $field_registration = new \EasyInvoice\Forms\Quote\QuoteFieldRegistration();
347
348 // Initialize the field registration to ensure fields are registered
349 $field_registration->registerDefaultTabs();
350 $field_registration->registerDefaultFields();
351
352 $field_definitions = [];
353 $tabs = $field_registration->getTabs();
354 foreach ($tabs as $tab_id => $tab) {
355 $tab_fields = $field_registration->getFields($tab_id);
356 foreach ($tab_fields as $field) {
357 $field_definitions[] = $field;
358 }
359 }
360
361 // Load meta data for each field definition
362 foreach ($field_definitions as $field) {
363 $field_name = $field['name'] ?? '';
364 if (empty($field_name)) {
365 continue;
366 }
367
368 $meta_key = '_easy_invoice_quote_' . $field_name;
369 $value = get_post_meta($this->id, $meta_key, true);
370
371 // Handle special cases for certain fields
372 if ($field_name === 'prices_include_tax') {
373 $this->data[$field_name] = $value === '1' || $value === 'yes' ? 'yes' : 'no';
374 } else if ($field_name === 'discount_type' && empty($value)) {
375 $this->data[$field_name] = 'none';
376 } else if ($field_name === 'discount_calculation_method' && empty($value)) {
377 $this->data[$field_name] = 'before_tax';
378 } else if ($field_name === 'tax_rate' && empty($value)) {
379 $this->data[$field_name] = 0;
380 } else if ($field_name === 'discount_value' && empty($value)) {
381 $this->data[$field_name] = 0;
382 } else if ($value !== '') {
383 // Store in dynamic data array
384 $this->data[$field_name] = $value;
385 }
386 }
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 }
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
407 // Auto-calculate totals if they're 0 or if we have items but no totals
408 if (((isset($this->data['total']) ? $this->data['total'] : 0) == 0 && !empty($this->items)) ||
409 ((isset($this->data['subtotal']) ? $this->data['subtotal'] : 0) == 0 && !empty($this->items))) {
410 $this->calculateTotals();
411 }
412
413 // Populate client information if we have a client_id but no customer data
414 if (($this->data['client_id'] ?? 0) > 0 && (empty($this->data['customer_name']) || empty($this->data['customer_email']))) {
415 $this->populateClientInfo();
416 }
417 }
418
419 /**
420 * Ensure quote has proper post_name (slug) for pretty URLs
421 *
422 * @since 1.0.0
423 * @return bool
424 */
425 public function ensureProperSlug(): bool {
426 if (!$this->id) {
427 return false;
428 }
429
430 $post = get_post($this->id);
431 if (!$post || empty($post->post_name)) {
432 // Generate a proper slug for this quote
433 $post_title = $this->data['title'] ?: $this->data['number'] ?: 'Untitled Quote';
434 $post_name = sanitize_title($post_title);
435
436 // Ensure uniqueness
437 $original_slug = $post_name;
438 $counter = 1;
439 while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
440 $post_name = $original_slug . '-' . $counter;
441 $counter++;
442 }
443
444 // Update the post with the new slug
445 $result = wp_update_post([
446 'ID' => $this->id,
447 'post_name' => $post_name
448 ]);
449
450 return $result !== 0;
451 }
452
453 return true;
454 }
455
456 /**
457 * Save quote to database
458 *
459 * @since 1.0.0
460 * @return bool True if successful, false otherwise
461 */
462 public function save(): bool {
463 // Prepare post data
464 $post_data = [
465 'post_title' => $this->data['title'] ?? '',
466 'post_content' => $this->data['description'] ?? '',
467 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
468 'post_status' => 'publish'
469 ];
470
471 if ($this->id) {
472 $post_data['ID'] = $this->id;
473 $post_id = wp_update_post($post_data);
474 } else {
475 $post_id = wp_insert_post($post_data);
476 }
477
478 if (is_wp_error($post_id)) {
479 return false;
480 }
481
482 // Update the ID if this was a new post
483 if (!$this->id) {
484 $this->id = $post_id;
485 }
486
487 // Calculate totals from items before saving
488 $this->calculateTotals();
489
490 // Save meta data (including quote status)
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 = [];
496
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
501 do_action('easy_invoice_quote_after_save', $this);
502
503 $this->is_modified = false;
504 return true;
505 }
506
507 /**
508 * Save quote meta data
509 *
510 * @since 1.0.0
511 */
512 private function saveMetaData(): void {
513 if (!$this->id) {
514 return;
515 }
516
517
518
519 // Get field definitions to determine which meta keys to save
520 $field_registration = new \EasyInvoice\Forms\Quote\QuoteFieldRegistration();
521
522 // Initialize the field registration to ensure fields are registered
523 $field_registration->registerDefaultTabs();
524 $field_registration->registerDefaultFields();
525
526 $field_definitions = [];
527 $tabs = $field_registration->getTabs();
528 foreach ($tabs as $tab_id => $tab) {
529 $tab_fields = $field_registration->getFields($tab_id);
530 foreach ($tab_fields as $field) {
531 $field_definitions[] = $field;
532 }
533 }
534
535 // Save meta data for each field definition
536 foreach ($field_definitions as $field) {
537 $field_name = $field['name'] ?? '';
538 if (empty($field_name)) {
539 continue;
540 }
541
542 $meta_key = '_easy_invoice_quote_' . $field_name;
543
544 // Get the value from dynamic data array
545 // Save all fields that exist in the data array (including empty strings to allow clearing fields)
546 // If a field exists in $this->data, it means it was explicitly set, so we should save it
547 if (array_key_exists($field_name, $this->data)) {
548 $value = $this->data[$field_name];
549 update_post_meta($this->id, $meta_key, $value);
550 }
551 }
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
569 // Save items
570 $this->saveItems();
571
572 // Allow plugins to save additional meta data
573 do_action('easy_invoice_quote_save_meta_data', $this);
574 }
575
576 /**
577 * Save quote items
578 *
579 * @since 1.0.0
580 */
581 private function saveItems(): void {
582 $items_data = [];
583
584 // Process items for saving
585
586 foreach ($this->items as $item) {
587 if (is_object($item) && method_exists($item, 'toArray')) {
588 $item_data = $item->toArray();
589 // Ensure taxable field is properly set as a string '1' or '0'
590 $item_data['taxable'] = $item->isTaxable() ? '1' : '0';
591 $items_data[] = $item_data;
592 } elseif (is_array($item)) {
593 // Convert array to QuoteItem object for proper saving
594 $quote_item = new QuoteItem($item);
595 $item_data = $quote_item->toArray();
596 // Ensure taxable field is properly set as a string '1' or '0'
597 $item_data['taxable'] = $quote_item->isTaxable() ? '1' : '0';
598 $items_data[] = $item_data;
599 }
600 }
601
602 // Save items to meta
603
604 update_post_meta($this->id, '_easy_invoice_quote_items', $items_data);
605 }
606
607 /**
608 * Calculate totals from items
609 *
610 * @since 1.0.0
611 */
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
623 $prices_include_tax = ($this->data['prices_include_tax'] ?? 'no') === 'yes';
624 $tax_rate = $tax_enabled ? floatval($this->data['tax_rate'] ?? 0) : 0;
625
626 // Initialize totals
627 $subtotal = 0;
628 $taxable_subtotal = 0;
629 $this->data['tax_amount'] = 0;
630 $this->data['discount_amount'] = 0;
631
632 // First pass: Calculate raw totals
633 foreach ($this->items as $item) {
634 $quantity = 0;
635 $price = 0;
636 $adjust_percentage = 0;
637 $is_taxable = true;
638
639 if (is_object($item) && method_exists($item, 'getAmount')) {
640 $quantity = $item->getQuantity();
641 $price = $item->getPrice();
642 $adjust_percentage = $item->getAdjustPercentage();
643 $is_taxable = $item->isTaxable();
644 } elseif (is_array($item)) {
645 $quantity = isset($item['quantity']) ? (float) $item['quantity'] : 0;
646 $price = isset($item['price']) ? (float) $item['price'] : 0;
647 $adjust_percentage = isset($item['adjust_percentage']) ? (float) $item['adjust_percentage'] : 0;
648 $is_taxable = isset($item['taxable']) ? (bool) $item['taxable'] : true;
649 }
650
651 // If prices include tax and item is taxable, remove tax from price
652 if ($prices_include_tax && $is_taxable && $tax_rate > 0) {
653 $price = $price / (1 + ($tax_rate / 100));
654 }
655
656 // Calculate item total
657 $item_total = $quantity * $price;
658 // Only apply adjust percentage if the adjust field is enabled
659 if ($adjust_percentage != 0 && \EasyInvoice\Controllers\SettingsController::shouldShowQuoteAdjustField()) {
660 $item_total = $item_total * (1 + $adjust_percentage / 100);
661 }
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);
666 $subtotal += $item_total;
667 if ($is_taxable) {
668 $taxable_subtotal += $item_total;
669 }
670 }
671
672 $this->data['subtotal'] = $subtotal;
673
674 // Get discount calculation method (default to before_tax)
675 $discount_calculation_method = $this->data['discount_calculation_method'] ?? 'before_tax';
676
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.
680 if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0) {
681 $this->data['discount_amount'] = easy_invoice_round_money(($subtotal * min(100, (float) $this->data['discount_value'])) / 100);
682 } elseif (($this->data['discount_type'] ?? '') === 'fixed' && ($this->data['discount_value'] ?? 0) > 0) {
683 $this->data['discount_amount'] = min((float) $this->data['discount_value'], (float) $subtotal);
684 }
685
686 // Calculate tax and total based on discount calculation method
687 if ($discount_calculation_method === 'before_tax') {
688 // For before_tax: Apply discount first, then calculate tax on remaining taxable amount
689 if ($subtotal > 0) {
690 $discount_ratio = $this->data['discount_amount'] / $subtotal;
691 $taxable_amount = $taxable_subtotal * (1 - $discount_ratio);
692 } else {
693 $taxable_amount = 0;
694 }
695
696 if ($tax_rate > 0) {
697 $this->data['tax_amount'] = easy_invoice_round_money(($taxable_amount * $tax_rate) / 100);
698 }
699
700 $this->data['total'] = max(0.0, easy_invoice_round_money($subtotal - $this->data['discount_amount'] + $this->data['tax_amount']));
701 } else {
702 // For after_tax: Calculate tax first, then apply discount
703 if ($tax_rate > 0) {
704 $this->data['tax_amount'] = easy_invoice_round_money(($taxable_subtotal * $tax_rate) / 100);
705 }
706
707 $total_before_discount = $subtotal + $this->data['tax_amount'];
708
709 // Recalculate percentage discount based on total including tax
710 if (($this->data['discount_type'] ?? '') === 'percentage' && ($this->data['discount_value'] ?? 0) > 0 && $total_before_discount > 0) {
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);
714 }
715
716 $this->data['total'] = max(0.0, easy_invoice_round_money($total_before_discount - $this->data['discount_amount']));
717 }
718 }
719
720 /**
721 * Populate client information from client_id
722 *
723 * @since 1.0.0
724 */
725 public function populateClientInfo(): void {
726 if (($this->data['client_id'] ?? 0) > 0) {
727 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
728 $client = $client_repository->find($this->data['client_id']);
729
730 if ($client) {
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;
740 $this->data['customer_email'] = $client->getEmail() ?: '';
741 $this->data['customer_address'] = $client->getAddress() ?: '';
742 }
743 }
744 }
745
746 /**
747 * Recalculate totals and save (for existing quotes)
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 quote
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_quote_model_to_array', $data, $this);
788 }
789
790 // Essential methods only
791 public function getId(): int { return $this->id ?? 0; }
792 public function setId(int $id): void { $this->id = $id; }
793 public function getItems(): array { return $this->items; }
794 public function setItems(array $items): void {
795 $this->items = [];
796 foreach ($items as $item) {
797 if (is_array($item)) {
798 $this->items[] = new QuoteItem($item);
799 } elseif (is_object($item) && $item instanceof QuoteItem) {
800 $this->items[] = $item;
801 }
802 }
803 $this->is_modified = true;
804 }
805 public function isModified(): bool { return $this->is_modified; }
806
807 /**
808 * Set meta data for the quote
809 *
810 * @since 1.0.0
811 * @param string $key Meta key
812 * @param mixed $value Meta value
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
870 public function setMetaData(string $key, $value): void {
871 // Store in dynamic data array without the _easy_invoice_ prefix
872 $field_name = easy_invoice_str_replace('_easy_invoice_', '', $key);
873 $this->data[$field_name] = $value;
874 $this->is_modified = true;
875
876
877 }
878
879 /**
880 * Get currency code
881 *
882 * @since 1.0.0
883 * @return string Currency code
884 */
885 public function getCurrencyCode(): string {
886 $currency_code = $this->data['currency_code'] ?? 'USD';
887
888 // If currency is set to 'global', resolve to actual global setting
889 if ($currency_code === 'global') {
890 $currency_code = get_option('easy_invoice_currency_code', 'USD');
891 }
892
893 // Ensure currency code is uppercase for consistency
894 return strtoupper($currency_code);
895 }
896
897 /**
898 * Set currency code
899 *
900 * @since 1.0.0
901 * @param string $currency_code Currency code
902 */
903 public function setCurrencyCode(string $currency_code): void {
904 $this->data['currency_code'] = $currency_code;
905 $this->is_modified = true;
906 }
907
908 /**
909 * Get currency position
910 *
911 * @since 1.0.0
912 * @return string Currency position
913 */
914 public function getCurrencyPosition(): string {
915 $currency_position = $this->data['currency_position'] ?? 'left';
916
917 // If currency position is set to 'global', resolve to actual global setting
918 if ($currency_position === 'global') {
919 $currency_position = get_option('easy_invoice_currency_position', 'left');
920 }
921
922 return $currency_position;
923 }
924
925 /**
926 * Set currency position
927 *
928 * @since 1.0.0
929 * @param string $currency_position Currency position
930 */
931 public function setCurrencyPosition(string $currency_position): void {
932 $this->data['currency_position'] = $currency_position;
933 $this->is_modified = true;
934 }
935
936 /**
937 * Get raw currency code (without resolving global)
938 *
939 * @since 1.0.0
940 * @return string Raw currency code
941 */
942 public function getRawCurrencyCode(): string {
943 return $this->data['currency_code'] ?? 'global';
944 }
945
946 /**
947 * Get raw currency position (without resolving global)
948 *
949 * @since 1.0.0
950 * @return string Raw currency position
951 */
952 public function getRawCurrencyPosition(): string {
953 return $this->data['currency_position'] ?? 'global';
954 }
955
956 public function getDescription(): string {
957 return $this->data['description'] ?? '';
958 }
959
960 public function setDescription(string $description): void {
961 $this->data['description'] = $description;
962 $this->is_modified = true;
963 }
964
965 public function getTemplate(): string {
966 return $this->data['quote_template'] ?? 'standard';
967 }
968
969 public function setTemplate(string $template): void {
970 $this->data['quote_template'] = $template;
971 $this->is_modified = true;
972 }
973
974 /**
975 * Get discount type (percentage, fixed, none)
976 *
977 * @since 1.0.0
978 * @return string
979 */
980 public function getDiscountType(): string {
981 return $this->data['discount_type'] ?? 'none';
982 }
983
984 /**
985 * Get discount value (percentage or fixed amount)
986 *
987 * @since 1.0.0
988 * @return float
989 */
990 public function getDiscountValue(): float {
991 return floatval($this->data['discount_value'] ?? 0);
992 }
993
994 /**
995 * Get discount amount (calculated value)
996 *
997 * @since 1.0.0
998 * @return float
999 */
1000 public function getDiscountAmount(): float {
1001 $this->calculateTotals();
1002 return floatval($this->data['discount_amount'] ?? 0);
1003 }
1004
1005 /**
1006 * Get tax rate percentage
1007 *
1008 * @since 1.0.0
1009 * @return float
1010 */
1011 public function getTaxRate(): float {
1012 return floatval($this->data['tax_rate'] ?? 0);
1013 }
1014
1015 /**
1016 * Get tax amount (calculated value)
1017 *
1018 * @since 1.0.0
1019 * @return float
1020 */
1021 public function getTaxAmount(): float {
1022 $this->calculateTotals();
1023 return floatval($this->data['tax_amount'] ?? 0);
1024 }
1025
1026 /**
1027 * Get subtotal (sum of all items)
1028 *
1029 * @since 1.0.0
1030 * @return float
1031 */
1032 public function getSubtotal(): float {
1033 $this->calculateTotals();
1034 return floatval($this->data['subtotal'] ?? 0);
1035 }
1036
1037 /**
1038 * Get total (final amount including tax and discount)
1039 *
1040 * @since 1.0.0
1041 * @return float
1042 */
1043 public function getTotal(): float {
1044 $this->calculateTotals();
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);
1056 }
1057 }