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
easy-invoice / includes / Repositories / QuoteRepository.php

QuoteRepository.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at includes/Repositories/QuoteRepository.php

655 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Quote Repository
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\Repositories;
13
14 use EasyInvoice\Models\Quote;
15 use EasyInvoice\Constants\PostTypes;
16 use EasyInvoice\Interfaces\QuoteRepositoryInterface;
17
18 /**
19 * Quote Repository
20 *
21 * Handles database operations for quotes with extensible architecture for pro features.
22 *
23 * @since 1.0.0
24 */
25 class QuoteRepository implements QuoteRepositoryInterface {
26
27 /**
28 * Find quote by ID
29 *
30 * @since 1.0.0
31 * @param int $id Quote ID
32 * @return Quote|null
33 */
34 public function find(int $id): ?Quote {
35 $post = get_post($id);
36 if ($post && $post->post_type === PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) {
37 $quote = new Quote($post);
38
39 // Ensure quote has proper slug for pretty URLs
40 $quote->ensureProperSlug();
41
42 // Allow plugins to modify the found quote
43 return apply_filters('easy_invoice_quote_found', $quote, $id);
44 }
45 return null;
46 }
47
48 /**
49 * Find all quotes
50 *
51 * @since 1.0.0
52 * @param array $args Query arguments
53 * @return array
54 */
55 public function findAll(array $args = []): array {
56 $default_args = [
57 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
58 'post_status' => ['publish', 'draft', 'private', 'pending'],
59 'posts_per_page' => -1,
60 'orderby' => 'date',
61 'order' => 'DESC'
62 ];
63
64 $query_args = wp_parse_args($args, $default_args);
65
66 // Allow plugins to modify query arguments
67 $query_args = apply_filters('easy_invoice_quote_query_args', $query_args);
68
69 $posts = get_posts($query_args);
70
71 $quotes = [];
72 foreach ($posts as $post) {
73 $quote = new Quote($post);
74
75 // Ensure quote has proper slug for pretty URLs
76 $quote->ensureProperSlug();
77
78 $quotes[] = $quote;
79 }
80
81 // Allow plugins to modify the results
82 return apply_filters('easy_invoice_quotes_found', $quotes, $query_args);
83 }
84
85 /**
86 * Find all published quotes (for public access)
87 *
88 * @since 1.0.0
89 * @param array $args Query arguments
90 * @return array
91 */
92 public function findAllPublished(array $args = []): array {
93 $default_args = [
94 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
95 'post_status' => 'publish',
96 'posts_per_page' => -1,
97 'orderby' => 'date',
98 'order' => 'DESC'
99 ];
100
101 $query_args = wp_parse_args($args, $default_args);
102
103 // Allow plugins to modify query arguments
104 $query_args = apply_filters('easy_invoice_quote_published_query_args', $query_args);
105
106 $posts = get_posts($query_args);
107
108 $quotes = [];
109 foreach ($posts as $post) {
110 $quote = new Quote($post);
111
112 // Ensure quote has proper slug for pretty URLs
113 $quote->ensureProperSlug();
114
115 $quotes[] = $quote;
116 }
117
118 // Allow plugins to modify the results
119 return apply_filters('easy_invoice_quotes_published_found', $quotes, $query_args);
120 }
121
122 /**
123 * Find quotes by status
124 *
125 * @since 1.0.0
126 * @param string $status Quote status
127 * @return array
128 */
129 public function findByStatus(string $status): array {
130 $quotes = $this->findBy(['status' => $status]);
131
132 // Allow plugins to modify the filtered results
133 return apply_filters('easy_invoice_quotes_by_status', $quotes, $status);
134 }
135
136 /**
137 * Find all quote IDs by status
138 *
139 * @since 1.0.0
140 * @param string $status Quote status
141 * @return array
142 */
143 public function findAllIdsByStatus(string $status): array {
144 global $wpdb;
145
146 $post_ids = $wpdb->get_col($wpdb->prepare(
147 "SELECT p.ID
148 FROM {$wpdb->posts} p
149 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
150 WHERE p.post_type = %s
151 AND pm.meta_key = '_easy_invoice_quote_status'
152 AND pm.meta_value = %s",
153 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
154 $status
155 ));
156
157 $post_ids = array_map('intval', $post_ids);
158
159 // Allow plugins to modify the results
160 return apply_filters('easy_invoice_quote_ids_by_status', $post_ids, $status);
161 }
162
163 /**
164 * Find quotes by client
165 *
166 * @since 1.0.0
167 * @param int $client_id Client ID
168 * @return array
169 */
170 public function findByClient(int $client_id): array {
171 $quotes = $this->findAll();
172 $filtered_quotes = array_filter($quotes, function($quote) use ($client_id) {
173 return $quote->getClientId() === $client_id;
174 });
175
176 // Allow plugins to modify the filtered results
177 return apply_filters('easy_invoice_quotes_by_client', $filtered_quotes, $client_id);
178 }
179
180 /**
181 * Quotes for a client — the name QuoteService calls; it did not exist.
182 *
183 * @param int $customer_id Client id.
184 * @return array
185 */
186 public function findByCustomer($customer_id): array {
187 return $this->findByClient((int) $customer_id);
188 }
189
190 /**
191 * Create new quote
192 *
193 * @since 1.0.0
194 * @param array $data Quote data
195 * @return Quote|null
196 */
197 public function create(array $data): ?Quote {
198 // Allow plugins to modify the data before creation
199 $data = apply_filters('easy_invoice_quote_create_data', $data);
200
201 $quote = new Quote();
202 // Same default as invoices: discount before tax unless chosen otherwise.
203 if (empty($data['discount_calculation_method'])) {
204 $data['discount_calculation_method'] = 'before_tax';
205 }
206 // Reserve a number under the number lock. Left to the form default,
207 // the model peeked at the next number without taking it, so quotes
208 // created at the same moment (or by two people with the builder
209 // open) shared one number.
210 $requested = (string) ($data['number'] ?? $data['quote_number'] ?? '');
211 $data['number'] = (new \EasyInvoice\Services\QuoteNumberService())->claimOrGenerate($requested);
212 unset($data['quote_number']);
213 $this->setQuoteData($quote, $data);
214
215 // Same as invoices: a client id without customer details is filled in
216 // from the client record before the first save.
217 if (($data['client_id'] ?? 0) > 0 && (empty($data['customer_email']) || empty($data['customer_name'])) && method_exists($quote, 'populateClientInfo')) {
218 $quote->populateClientInfo();
219 }
220
221 // Allow plugins to modify the quote before saving
222 do_action('easy_invoice_quote_before_create', $quote, $data);
223
224 if ($quote->save()) {
225 // Allow plugins to perform actions after creation
226 do_action('easy_invoice_quote_created', $quote, $data);
227 return $quote;
228 }
229
230 return null;
231 }
232
233 /**
234 * Update existing quote
235 *
236 * @since 1.0.0
237 * @param int $id Quote ID
238 * @param array $data Quote data
239 * @param Quote|null $existing_quote Optional existing quote object to update
240 * @return Quote|null
241 */
242 public function update(int $id, array $data, ?Quote $existing_quote = null): ?Quote {
243 // Use existing quote object if provided, otherwise find from database
244 $quote = $existing_quote ?: $this->find($id);
245 if (!$quote) {
246 return null;
247 }
248
249 // Prevent quote number from being changed on update
250 unset($data['number'], $data['quote-number']);
251
252 // Allow plugins to modify the data before update
253 $data = apply_filters('easy_invoice_quote_update_data', $data, $quote);
254
255 $this->setQuoteData($quote, $data);
256
257 // Allow plugins to modify the quote before saving
258 do_action('easy_invoice_quote_before_update', $quote, $data);
259
260 if ($quote->save()) {
261 // Allow plugins to perform actions after update
262 do_action('easy_invoice_quote_updated', $quote, $data);
263 return $quote;
264 }
265
266 return null;
267 }
268
269 /**
270 * Delete quote
271 *
272 * @since 1.0.0
273 * @param int $id Quote ID
274 * @return bool
275 */
276 public function delete(int $id): bool {
277 $quote = $this->find($id);
278 if (!$quote) {
279 return false;
280 }
281
282 // Allow plugins to perform actions before deletion
283 do_action('easy_invoice_quote_before_delete', $quote);
284
285 $result = wp_delete_post($id, true);
286
287 if ($result) {
288 // Allow plugins to perform actions after deletion
289 do_action('easy_invoice_quote_deleted', $id);
290 }
291
292 return $result !== false;
293 }
294
295 /**
296 * Find a quote by its number (stored in post meta)
297 *
298 * @param string $number Quote number
299 * @return Quote|null
300 */
301 public function findByNumber(string $number): ?Quote {
302 $args = [
303 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
304 'post_status' => ['publish', 'draft', 'private', 'pending'],
305 'posts_per_page' => 1,
306 'meta_query' => [
307 [
308 'key' => '_easy_invoice_quote_number',
309 'value' => $number,
310 'compare' => '=',
311 ],
312 ],
313 'orderby' => 'date',
314 'order' => 'DESC',
315 ];
316
317 $posts = get_posts($args);
318 if (!empty($posts)) {
319 $post = $posts[0];
320 if ($post && $post->post_type === PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) {
321 $quote = new Quote($post);
322 $quote->ensureProperSlug();
323 return apply_filters('easy_invoice_quote_found_by_number', $quote, $number);
324 }
325 }
326 return null;
327 }
328
329 /**
330 * Force publish quote (for URL fixes)
331 *
332 * @since 1.0.0
333 * @param int $id Quote ID
334 * @return bool
335 */
336 public function forcePublish($id) {
337 $quote = $this->find($id);
338 if (!$quote) {
339 return false;
340 }
341
342 // Save the quote (this will publish it and ensure proper permalinks)
343 return $quote->save();
344 }
345
346 /**
347 * Find published quote by ID
348 *
349 * @since 1.0.0
350 * @param int $id Quote ID
351 * @return Quote|null
352 */
353 public function findPublished(int $id): ?Quote {
354 $post = get_post($id);
355
356 if (!$post || $post->post_type !== PostTypes::EASY_INVOICE_QUOTE_POST_TYPE || $post->post_status !== 'publish') {
357 return null;
358 }
359
360 $quote = new Quote($post);
361
362 // Ensure quote has proper slug for pretty URLs
363 $quote->ensureProperSlug();
364
365 return $quote;
366 }
367
368 /**
369 * Find quotes by criteria
370 *
371 * @param array $criteria Array of criteria to filter by
372 * @return Quote[] Array of Quote objects
373 */
374 public function findBy(array $criteria = []): array {
375 $args = [
376 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
377 'posts_per_page' => -1,
378 'post_status' => ['publish', 'draft', 'private', 'pending', 'trash']
379 ];
380
381 // Handle status criteria
382 if (isset($criteria['status'])) {
383 $args['meta_query'][] = [
384 'key' => '_easy_invoice_quote_status',
385 'value' => $criteria['status']
386 ];
387 }
388
389 // Handle client criteria
390 if (isset($criteria['client_id'])) {
391 $args['meta_query'][] = [
392 'key' => '_easy_invoice_quote_client_id',
393 'value' => $criteria['client_id']
394 ];
395 }
396
397 // Handle date range
398 if (isset($criteria['date_from'])) {
399 $args['date_query']['after'] = $criteria['date_from'];
400 }
401 if (isset($criteria['date_to'])) {
402 $args['date_query']['before'] = $criteria['date_to'];
403 }
404
405 $posts = get_posts($args);
406 $quotes = [];
407
408 foreach ($posts as $post) {
409 $quotes[] = new Quote($post);
410 }
411
412 return $quotes;
413 }
414
415 /**
416 * Update all existing draft quotes to published status for proper permalinks
417 *
418 * @since 1.0.0
419 * @return int Number of quotes updated
420 */
421 public function updateAllExistingQuotes(): int {
422 global $wpdb;
423
424 // Find all draft quotes
425 $draft_quotes = $wpdb->get_col($wpdb->prepare(
426 "SELECT ID FROM {$wpdb->posts}
427 WHERE post_type = %s
428 AND post_status = 'draft'",
429 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
430 ));
431
432 $updated_count = 0;
433
434 foreach ($draft_quotes as $quote_id) {
435 $quote = $this->find($quote_id);
436 if ($quote) {
437 // Save the quote (this will publish it and ensure proper permalinks)
438 if ($quote->save()) {
439 $updated_count++;
440 }
441 }
442 }
443
444 // Allow plugins to perform actions after bulk update
445 do_action('easy_invoice_quotes_bulk_updated', $updated_count);
446
447 return $updated_count;
448 }
449
450 /**
451 * Get statistics
452 *
453 * @since 1.0.0
454 * @return array
455 */
456 public function getStatistics(): array {
457 $quotes = $this->findAll();
458
459 $stats = [
460 'total' => count($quotes),
461 'draft' => count($this->findByStatus('draft')),
462 'sent' => count($this->findByStatus('sent')),
463 'accepted' => count($this->findByStatus('accepted')),
464 'declined' => count($this->findByStatus('declined')),
465 'expired' => count($this->findByStatus('expired')),
466 ];
467
468 // Allow plugins to modify statistics
469 return apply_filters('easy_invoice_quote_statistics', $stats, $quotes);
470 }
471
472 /**
473 * Set quote data from array
474 *
475 * @since 1.0.0
476 * @param Quote $quote
477 * @param array $data
478 */
479 private function setQuoteData(Quote $quote, array $data): void {
480 // Allow plugins to modify the data setting process
481 do_action('easy_invoice_quote_set_data_before', $quote, $data);
482
483 // Set basic properties
484 if (isset($data['title'])) {
485 $quote->setTitle($data['title']);
486 }
487
488 if (isset($data['number']) && !empty($data['number'])) {
489 $quote->setNumber($data['number']);
490 }
491
492 if (isset($data['status'])) {
493 $quote->setStatus($data['status']);
494 }
495
496 if (isset($data['issue_date'])) {
497 $quote->setIssueDate($data['issue_date']);
498 }
499
500 if (isset($data['expiry_date'])) {
501 $quote->setExpiryDate($data['expiry_date']);
502 }
503
504 if (isset($data['client_id'])) {
505 $quote->setClientId($data['client_id']);
506 }
507
508 if (isset($data['customer_name'])) {
509 $quote->setCustomerName($data['customer_name']);
510 }
511
512 if (isset($data['customer_email'])) {
513 $quote->setCustomerEmail($data['customer_email']);
514 }
515
516 if (isset($data['customer_address'])) {
517 $quote->setCustomerAddress($data['customer_address']);
518 }
519
520 if (isset($data['notes'])) {
521 $quote->setNotes($data['notes']);
522 }
523
524 // Always update description if it exists in data (even if empty, to allow clearing)
525 if (array_key_exists('description', $data)) {
526 $quote->setDescription($data['description'] ?? '');
527 }
528
529 if (isset($data['terms'])) {
530 $quote->setTerms($data['terms']);
531 }
532
533 if (isset($data['internal_notes'])) {
534 $quote->setInternalNotes($data['internal_notes']);
535 }
536
537 if (isset($data['template'])) {
538 $quote->setTemplate($data['template']);
539 }
540
541 if (isset($data['items']) && is_array($data['items'])) {
542 // Process each item to ensure taxable field is properly set
543 $processed_items = array_map(function($item) {
544 if (is_array($item)) {
545 // Convert taxable field to boolean
546 $taxable = isset($item['taxable']) ? $item['taxable'] : true;
547 if (is_string($taxable)) {
548 $taxable = strtolower($taxable);
549 $taxable = $taxable === '1' || $taxable === 'true' || $taxable === 'yes' || $taxable === 'on';
550 }
551 $item['taxable'] = (bool) $taxable;
552 }
553 return $item;
554 }, $data['items']);
555
556 $quote->setItems($processed_items);
557 }
558
559 if (isset($data['discount_type'])) {
560 $quote->setDiscountType($data['discount_type']);
561 }
562
563 if (isset($data['discount_value'])) {
564 $quote->setDiscountValue($data['discount_value']);
565 }
566
567 // Per-quote tax switch. The invoice repository picks this up through the
568 // form processor's pass-through; quotes set every field by hand, and this
569 // one was missing, so a quote built with tax on lost it (and passed a
570 // tax-free invoice on conversion) whenever the site's global tax was off.
571 if (array_key_exists('tax_enabled', $data)) {
572 $enabled = $data['tax_enabled'];
573 $quote->setTaxEnabled(($enabled === true || $enabled === 1 || in_array(strtolower((string) $enabled), ['1', 'yes', 'true', 'on'], true)) ? 'yes' : 'no');
574 }
575
576 if (isset($data['tax_rate'])) {
577 $quote->setTaxRate($data['tax_rate']);
578 }
579
580 if (isset($data['prices_include_tax'])) {
581 $quote->setPricesIncludeTax($data['prices_include_tax']);
582 }
583
584 if (isset($data['discount_calculation_method'])) {
585 $quote->setDiscountCalculationMethod($data['discount_calculation_method']);
586 } else {
587 // Default to before_tax if not set
588 $quote->setDiscountCalculationMethod('before_tax');
589 }
590
591 if (isset($data['currency_code'])) {
592 $quote->setCurrencyCode($data['currency_code']);
593 }
594
595 if (isset($data['currency_position'])) {
596 $quote->setCurrencyPosition($data['currency_position']);
597 }
598
599 // Handle new quote fields
600 if (isset($data['footer_text'])) {
601 $quote->setFooterText($data['footer_text']);
602 }
603
604 if (isset($data['accept_button'])) {
605 $quote->setAcceptButton($data['accept_button']);
606 }
607
608 if (isset($data['accept_action'])) {
609 $quote->setAcceptAction($data['accept_action']);
610 }
611
612 if (isset($data['accept_text'])) {
613 $quote->setAcceptText($data['accept_text']);
614 }
615
616 if (isset($data['accepted_message'])) {
617 $quote->setAcceptedMessage($data['accepted_message']);
618 }
619
620 if (isset($data['declined_message'])) {
621 $quote->setDeclinedMessage($data['declined_message']);
622 }
623
624 // Handle custom fields
625 if (isset($data['custom_fields']) && is_array($data['custom_fields'])) {
626 $quote->setCustomFields($data['custom_fields']);
627 }
628
629 // Populate client information if we have a client_id
630 if (is_numeric($quote->getClientId()) && (int) $quote->getClientId() > 0) {
631 $this->populateCustomerFromClient($quote, (int) $quote->getClientId());
632 }
633
634 // Allow plugins to modify the data setting process
635 do_action('easy_invoice_quote_set_data_after', $quote, $data);
636 }
637
638 /**
639 * Populate customer information from client
640 *
641 * @since 1.0.0
642 * @param Quote $quote
643 * @param int $client_id
644 */
645 private function populateCustomerFromClient(Quote $quote, int $client_id): void {
646 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
647 $client = $client_repository->find($client_id);
648
649 if ($client) {
650 $quote->setCustomerName($client->getBusinessClientName() ?: '');
651 $quote->setCustomerEmail($client->getEmail() ?: '');
652 $quote->setCustomerAddress($client->getAddress() ?: '');
653 }
654 }
655 }