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
← All changes | includes/Controllers/PaymentController.php +489 -261 2.3.32.4.0 View file →
@@ -29,8 +29,15 @@
29 29 use TemplateTrait;
30 30 use PaymentCalculationTrait;
31 31
32 32 /**
33 + * First Easy Invoice Pro release whose gateway scripts forward the per-invoice
34 + * access token to `easy_invoice_process_payment`. Older builds need the
35 + * compatibility path in legacyProPaymentFallbackAllowed().
36 + */
37 + const PRO_TOKEN_FORWARDING_VERSION = '2.3.0';
38 +
39 + /**
33 40 * Payment gateway manager instance
34 41 *
35 42 * @var PaymentGatewayManager
36 43 */
@@ -50,8 +57,9 @@
50 57 add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
51 58 add_action('wp_ajax_easy_invoice_process_payment', [$this, 'processPayment']);
52 59 add_action('wp_ajax_nopriv_easy_invoice_process_payment', [$this, 'processPayment']);
53 60 add_action('wp_ajax_easy_invoice_update_payment', [$this, 'updatePayment']);
61 + add_action('wp_ajax_easy_invoice_record_payment', [$this, 'recordPayment']);
54 62 add_action('wp_ajax_easy_invoice_payment_callback', [$this, 'handleCallback']);
55 63 add_action('wp_ajax_nopriv_easy_invoice_payment_callback', [$this, 'handleCallback']);
56 64 add_action('wp_ajax_easy_invoice_verify_manual_payment', [$this, 'verifyManualPayment']);
57 65 add_action('wp_ajax_easy_invoice_reject_manual_payment', [$this, 'rejectManualPayment']);
@@ -79,15 +87,8 @@
79 87 // Stripe payment handlers moved to Pro plugin
80 88
81 89 add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
82 90
83 - // Add filter to show pending payments in admin
84 - add_filter('easy_invoice_admin_payment_statuses', [$this, 'addPendingPaymentStatuses']);
85 -
86 - // Add custom columns to payments list
87 - add_filter('manage_easy-payment_posts_columns', [$this, 'addPaymentMethodColumn']);
88 - add_action('manage_easy-payment_posts_custom_column', [$this, 'renderPaymentMethodColumn'], 10, 2);
89 -
90 91 // Add reminder CRON job for pending payments
91 92 add_action('easy_invoice_payment_reminder', [$this, 'sendPaymentReminders']);
92 93 if (!wp_next_scheduled('easy_invoice_payment_reminder')) {
93 94 wp_schedule_event(time(), 'daily', 'easy_invoice_payment_reminder');
@@ -100,16 +101,18 @@
100 101 /**
101 102 * Get payment instructions for manual gateways
102 103 */
103 104 public function getPaymentInstructions() {
104 - // Verify nonce
105 - if (!wp_verify_nonce($_POST['nonce'], 'easy_invoice_payment')) {
105 + // Verify nonce. $_POST['nonce'] was read unguarded, raising an
106 + // undefined-index warning before the check could run.
107 + $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
108 + if (!wp_verify_nonce($nonce, 'easy_invoice_payment')) {
106 109 wp_send_json_error(['message' => 'Security check failed']);
107 110 return;
108 111 }
109 112
110 - $gateway = sanitize_text_field($_POST['gateway']);
111 - $invoice_id = intval($_POST['invoice_id']);
113 + $gateway = sanitize_text_field(($_POST['gateway'] ?? ''));
114 + $invoice_id = intval(($_POST['invoice_id'] ?? ''));
112 115
113 116 if (!$gateway || !$invoice_id) {
114 117 wp_send_json_error(['message' => 'Missing required parameters']);
115 118 return;
@@ -121,16 +124,27 @@
121 124 wp_send_json_error(['message' => 'Invalid invoice']);
122 125 return;
123 126 }
124 127
125 - // Guests may only load instructions for published invoices (avoid leaking draft/private details).
126 - if (!easy_invoice_user_can('ei_view_invoices') && $invoice_post->post_status !== 'publish') {
128 + $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
129 +
130 + // Authorisation.
131 + //
132 + // The previous guard here was `!easy_invoice_user_can('ei_view_invoices') &&
133 + // $invoice_post->post_status !== 'publish'`. That never fired: Models\Invoice
134 + // writes every invoice with post_status 'publish' regardless of workflow
135 + // status, so the second condition was always false. This endpoint is
136 + // registered nopriv and the nonce it checks is a shared, page-wide one, so
137 + // any caller could read the rendered payment instructions — which include
138 + // invoice-specific detail — for an arbitrary invoice id.
139 + //
140 + // Same check as everywhere else: valid ?ik= / access_token, administrator, or
141 + // the signed-in client the invoice belongs to.
142 + if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
127 143 wp_send_json_error(['message' => __('Invoice not found', 'easy-invoice')]);
128 144 return;
129 145 }
130 146
131 - $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
132 -
133 147 // Get gateway instance
134 148 $gateway_instance = $this->gatewayManager->getGateway($gateway);
135 149
136 150 if (!$gateway_instance) {
@@ -188,12 +202,22 @@
188 202 '1.0.0',
189 203 true
190 204 );
191 205
192 - // Localize script
206 + // Forward the per-invoice access token from the URL to the JS
207 + // so the manual-payment AJAX request can present it back to
208 + // canSubmitPaymentForInvoice. Without this the legitimate
209 + // email-link recipient flow would break — they'd hit the gate.
210 + $access_token = isset($_GET['ik'])
211 + ? sanitize_text_field(wp_unslash($_GET['ik']))
212 + : '';
213 + /** This filter is documented in includes/Controllers/InvoiceController.php */
214 + $access_token = (string) apply_filters('easy_invoice_presented_access_token', $access_token, 'invoice');
215 +
193 216 wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [
194 - 'ajax_url' => admin_url('admin-ajax.php'),
195 - 'nonce' => wp_create_nonce('easy_invoice_payment')
217 + 'ajax_url' => admin_url('admin-ajax.php'),
218 + 'nonce' => wp_create_nonce('easy_invoice_payment'),
219 + 'access_token' => $access_token,
196 220 ]);
197 221 }
198 222 }
199 223
@@ -222,15 +246,15 @@
222 246 try {
223 247 $payment = new Payment($payment_post);
224 248 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/view.php', ['payment' => $payment]);
225 249 } catch (\Exception $e) {
226 - wp_die(__('Invalid payment ID', 'easy-invoice'));
250 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
227 251 }
228 252 } else {
229 - wp_die(__('Invalid payment ID', 'easy-invoice'));
253 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
230 254 }
231 255 } else {
232 - wp_die(__('Payment ID is required', 'easy-invoice'));
256 + wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
233 257 }
234 258 break;
235 259
236 260 case 'edit':
@@ -241,15 +265,15 @@
241 265 try {
242 266 $payment = new Payment($payment_post);
243 267 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/edit.php', ['payment' => $payment]);
244 268 } catch (\Exception $e) {
245 - wp_die(__('Invalid payment ID', 'easy-invoice'));
269 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
246 270 }
247 271 } else {
248 - wp_die(__('Invalid payment ID', 'easy-invoice'));
272 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
249 273 }
250 274 } else {
251 - wp_die(__('Payment ID is required', 'easy-invoice'));
275 + wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
252 276 }
253 277 break;
254 278
255 279 default:
@@ -326,64 +350,37 @@
326 350 // Get pagination info from WordPress query
327 351 $total_payments = $wp_query->found_posts;
328 352 $total_pages = $wp_query->max_num_pages;
329 353
330 - // Calculate statistics from ALL payments (not just current page)
331 - $stats_args = array(
332 - 'post_type' => 'easy_invoice_payment',
333 - 'posts_per_page' => -1, // Get all payments
334 - 'meta_query' => array(
335 - array(
336 - 'key' => '_status',
337 - 'compare' => 'EXISTS',
338 - ),
339 - ),
340 - );
341 -
342 - // Set post status for stats based on current view
343 - if ($current_view === 'trash') {
344 - $stats_args['post_status'] = 'trash';
345 - } else {
346 - $stats_args['post_status'] = 'publish';
347 - }
348 -
349 - $stats_query = new \WP_Query($stats_args);
350 -
354 + // Statistics over ALL payments (not just the current page), in one SQL
355 + // pass. Loading every payment as a model to add them up did not scale.
356 + global $wpdb;
357 + $stats_status = 'trash' === $current_view ? 'trash' : 'publish';
358 + $stat_rows = $wpdb->get_results( $wpdb->prepare(
359 + "SELECT st.meta_value AS status, COUNT(*) AS n, SUM(CAST(COALESCE(NULLIF(a.meta_value, ''), '0') AS DECIMAL(18,4))) AS amount
360 + FROM {$wpdb->posts} p
361 + INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status'
362 + LEFT JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = '_amount'
363 + WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = %s
364 + GROUP BY st.meta_value",
365 + $stats_status
366 + ), ARRAY_A );
351 367 $stats = [
352 - 'total_payments' => $stats_query->found_posts,
353 - 'total_amount' => 0,
368 + 'total_payments' => 0,
369 + 'total_amount' => 0,
354 370 'completed_payments' => 0,
355 - 'pending_payments' => 0,
356 - 'failed_payments' => 0
371 + 'pending_payments' => 0,
372 + 'failed_payments' => 0,
357 373 ];
358 -
359 - // Calculate stats from the query results
360 - if ($stats_query->have_posts()) {
361 - while ($stats_query->have_posts()) {
362 - $stats_query->the_post();
363 - $payment = new Payment(get_post());
364 -
365 - $amount = floatval($payment->getAmount());
366 - $status = $payment->getStatus();
367 -
368 - $stats['total_amount'] += $amount;
369 -
370 - switch ($status) {
371 - case 'completed':
372 - $stats['completed_payments']++;
373 - break;
374 - case 'pending':
375 - $stats['pending_payments']++;
376 - break;
377 - case 'failed':
378 - $stats['failed_payments']++;
379 - break;
380 - }
374 + foreach ( (array) $stat_rows as $row ) {
375 + $stats['total_payments'] += (int) $row['n'];
376 + $stats['total_amount'] += (float) $row['amount'];
377 + $key = $row['status'] . '_payments';
378 + if ( isset( $stats[ $key ] ) ) {
379 + $stats[ $key ] += (int) $row['n'];
381 380 }
382 381 }
383 - wp_reset_postdata();
384 382
385 - // Ensure all required keys exist with default values
386 383 $stats = array_merge([
387 384 'total_payments' => 0,
388 385 'total_amount' => 0,
389 386 'completed_payments' => 0,
@@ -391,15 +388,9 @@
391 388 'failed_payments' => 0
392 389 ], $stats);
393 390
394 391 // Get trash count for tab display
395 - $trash_args = array(
396 - 'post_type' => 'easy_invoice_payment',
397 - 'post_status' => 'trash',
398 - 'posts_per_page' => -1
399 - );
400 - $trash_query = new \WP_Query($trash_args);
401 - $trash_count = $trash_query->found_posts;
392 + $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'easy_invoice_payment' AND post_status = 'trash'" );
402 393
403 394 // Define available status filters
404 395 $status_filters = array(
405 396 'completed' => 'Completed',
@@ -442,11 +433,19 @@
442 433 // Check if scripts are already enqueued
443 434 if (wp_script_is('easy-invoice-payment', 'enqueued')) {
444 435 return;
445 436 }
446 - if(!is_singular(PostTypes::EASY_INVOICE_POST_TYPE)){
447 - //return;
448 - }
437 + // The payment panel exists on the public invoice page only; every
438 + // other front-end page of the site has no use for the script (or the
439 + // jQuery it pulls in).
440 + /**
441 + * Filter whether the payment script loads on the current front-end request.
442 + *
443 + * @param bool $load Default: on a public invoice page.
444 + */
445 + if (!apply_filters('easy_invoice_load_payment_assets', is_singular(PostTypes::EASY_INVOICE_POST_TYPE))) {
446 + return;
447 + }
449 448
450 449 // Enqueue our custom scripts
451 450 wp_enqueue_script(
452 451 'easy-invoice-payment',
@@ -462,11 +461,22 @@
462 461 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
463 462 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
464 463
465 464 // Localize script variables for payment form
465 + // Forward the per-invoice access token (?ik=...) the same way the manual
466 + // payment script already does. The payment endpoints authorise on it, and
467 + // without this an anonymous client following an emailed link would have a
468 + // token in their URL that never reached the AJAX request.
469 + $ei_access_token = isset($_GET['ik'])
470 + ? sanitize_text_field(wp_unslash($_GET['ik']))
471 + : '';
472 + /** This filter is documented in includes/Controllers/InvoiceController.php */
473 + $ei_access_token = (string) apply_filters('easy_invoice_presented_access_token', $ei_access_token, 'invoice');
474 +
466 475 wp_localize_script('easy-invoice-payment', 'easy_invoice_vars', [
467 476 'ajax_url' => admin_url('admin-ajax.php'),
468 477 'nonce' => wp_create_nonce('easy_invoice_payment'),
478 + 'access_token' => $ei_access_token,
469 479 'currency_symbol' => $currency_symbol,
470 480 'currency_code' => $currency_code
471 481 ]);
472 482 }
@@ -481,8 +491,40 @@
481 491
482 492 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
483 493 $payment_method_slug = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
484 494
495 + $invoice_post = $invoice_id ? get_post($invoice_id) : null;
496 + if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
497 + wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
498 + return;
499 + }
500 +
501 + $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
502 +
503 + // Authorisation. This endpoint is nopriv and previously relied on a shared,
504 + // page-wide nonce alone, so a caller holding one could start a payment
505 + // against any invoice id and read back its amount and gateway details.
506 + // Legitimate callers reach this from the invoice page, which forwards the
507 + // per-invoice access token (see payment.js / payment-section.php).
508 + //
509 + // This MUST stay above the `easy_invoice_before_process_payment` filter
510 + // below. That filter is not a notification — it is a dispatch point that
511 + // short-circuits the whole request, and Easy Invoice Pro attaches four
512 + // handlers to it (Stripe, Authorize.Net, Moneris and Partial Payments).
513 + // While the check sat after the filter, those four gateways — every card
514 + // gateway Pro ships — completed payments without the token ever being
515 + // examined, so the gate only really covered the free plugin's own
516 + // gateways. Authorising before dispatch is the whole point of the gate.
517 + if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
518 + // A Pro build older than this plugin cannot forward the token — see
519 + // legacyProPaymentFallbackAllowed(). Refusing here would take the
520 + // customer's money on Stripe without recording the payment.
521 + if (!$this->legacyProPaymentFallbackAllowed($payment_method_slug)) {
522 + wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
523 + return;
524 + }
525 + }
526 +
485 527 // Add filter for extensions to handle custom payment logic (e.g., partial payments)
486 528 $custom_result = apply_filters('easy_invoice_before_process_payment', null, $invoice_id, $_POST);
487 529
488 530 if (is_array($custom_result) && isset($custom_result['handled']) && $custom_result['handled']) {
@@ -493,24 +535,39 @@
493 535 }
494 536 return;
495 537 }
496 538
497 - if (!$invoice_id || !$payment_method_slug) {
539 + if (!$payment_method_slug) {
498 540 wp_send_json_error(['message' => __('Missing required fields.', 'easy-invoice')]);
499 541 return;
500 542 }
501 543
502 - $invoice_post = get_post($invoice_id);
503 - if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
504 - wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
544 + // Charge what is owed, not the face value: a part-paid or partly
545 + // credited invoice must not be collected twice.
546 + $due = \EasyInvoice\Services\InvoiceBalance::due($invoice);
547 + $amount = $due;
548 + if ($due <= 0) {
549 + wp_send_json_error(['message' => __('Nothing is owed on this invoice.', 'easy-invoice')]);
505 550 return;
506 551 }
507 552
508 - $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
509 - $amount = $invoice->total ?? 0;
553 + // A smaller amount is charged only when something (the Partial
554 + // Payments addon) says this invoice may be paid in instalments.
555 + $requested = isset($_POST['payment_amount']) ? round((float) str_replace(',', '', sanitize_text_field(wp_unslash($_POST['payment_amount']))), 2) : 0.0;
556 + $is_partial = isset($_POST['is_partial_payment']) && '1' === (string) sanitize_text_field(wp_unslash($_POST['is_partial_payment']));
557 + if ($is_partial && $requested > 0 && $requested < $due) {
558 + /**
559 + * Filter whether the client may pay less than the amount due.
560 + *
561 + * @param bool $allow Default false.
562 + * @param object $invoice Invoice model.
563 + * @param float $requested Amount the client asked to pay.
564 + */
565 + if (apply_filters('easy_invoice_allow_partial_payment_amount', false, $invoice, $requested)) {
566 + $amount = $requested;
567 + }
568 + }
510 569
511 - // Log the payment processing details
512 -
513 570 $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug);
514 571
515 572 if (!$gateway_instance || !$gateway_instance->isEnabled() || !$gateway_instance->isAvailable()) {
516 573 wp_send_json_error(['message' => __('Selected payment gateway is not available or configured correctly.', 'easy-invoice')]);
@@ -521,8 +578,16 @@
521 578 // Pass the entire $_POST array to the gateway
522 579 $result = $gateway_instance->processPayment($amount, $_POST);
523 580
524 581 if (isset($result['success']) && $result['success']) {
582 + // An offline gateway with no follow-up step (cash, the free
583 + // manual gateway) leaves the invoice pending here; bank
584 + // transfer and cheque notify the admin themselves once the
585 + // proof or cheque details arrive.
586 + $offline_status = (string) ($result['payment_status'] ?? ($result['data']['status'] ?? ''));
587 + if (in_array($payment_method_slug, ['manual', 'cash'], true) && 0 === strpos($offline_status, 'pending')) {
588 + do_action('easy_invoice_manual_payment_submitted', $invoice_id, $payment_method_slug);
589 + }
525 590 wp_send_json_success($result);
526 591 } else {
527 592 wp_send_json_error(['message' => $result['message'] ?? __('Payment processing failed with the gateway.', 'easy-invoice')]);
528 593 }
@@ -533,8 +598,97 @@
533 598 }
534 599 }
535 600
536 601 /**
602 + * Whether to accept a payment that presented no per-invoice access token,
603 + * because the Easy Invoice Pro build installed alongside cannot send one.
604 + *
605 + * Why this exists
606 + * ---------------
607 + * Pro's Stripe and Authorize.Net scripts post to `easy_invoice_process_payment`,
608 + * which is a free-plugin endpoint, and from 2.4.0 that endpoint authorises on the
609 + * per-invoice access token. Pro only began forwarding the token in 2.3.0.
610 + *
611 + * The two plugins update through different channels — free auto-updates from
612 + * WordPress.org, Pro arrives from the licence server — so "free is newer than Pro"
613 + * is not an edge case, it is the normal state for a while after release. Without
614 + * this fallback, that pairing breaks client payments, and for Stripe it breaks them
615 + * in the worst possible way: the script confirms the charge with Stripe FIRST and
616 + * only then posts here to record it, so a refusal means the customer has paid and
617 + * the invoice still says unpaid.
618 + *
619 + * What it does and does not allow
620 + * -------------------------------
621 + * The relaxation is deliberately narrow, and is never wider than the behaviour
622 + * that already shipped in 2.3.8:
623 + *
624 + * - Only when Pro is active AND older than 2.3.0. It disappears by itself the
625 + * moment Pro is updated; there is nothing to remember to turn off.
626 + * - Only when NO token was presented at all. A request carrying a wrong or
627 + * expired token is a forgery attempt, not an old client script, and is refused.
628 + * - Only for gateways provided by Pro. The free plugin's own scripts always
629 + * forward the token, so a free gateway reaching here without one is not a
630 + * version-skew case.
631 + * - The shared `easy_invoice_payment` nonce has already been verified by the
632 + * caller before this is consulted.
633 + * - `getPaymentInstructions()` does NOT use this. That is the information
634 + * disclosure path and stays fully gated regardless of Pro's version.
635 + *
636 + * Site owners who would rather fail the payment than accept the older
637 + * authorisation can return false from
638 + * `easy_invoice_allow_legacy_pro_payment_fallback`.
639 + *
640 + * @param string $payment_method_slug Gateway slug from the request.
641 + * @return bool
642 + */
643 + private function legacyProPaymentFallbackAllowed(string $payment_method_slug): bool {
644 + if (!function_exists('easy_invoice_has_pro') || !easy_invoice_has_pro()) {
645 + return false;
646 + }
647 +
648 + // An older Pro that predates token forwarding. Treat a missing version
649 + // constant as "older", since every build that defines it is >= 2.1.
650 + $pro_version = defined('EASY_INVOICE_PRO_VERSION') ? (string) EASY_INVOICE_PRO_VERSION : '0';
651 + if (version_compare($pro_version, self::PRO_TOKEN_FORWARDING_VERSION, '>=')) {
652 + return false;
653 + }
654 +
655 + // A presented-but-invalid token is an attack, not version skew.
656 + if (isset($_POST['access_token']) && $_POST['access_token'] !== '') {
657 + return false;
658 + }
659 + if (isset($_GET['ik']) && $_GET['ik'] !== '') {
660 + return false;
661 + }
662 +
663 + // Restrict to gateways Pro actually provides.
664 + $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug);
665 + if (!$gateway_instance || strpos(get_class($gateway_instance), 'EasyInvoicePro\\') !== 0) {
666 + return false;
667 + }
668 +
669 + /**
670 + * Filter the legacy Pro payment fallback.
671 + *
672 + * @param bool $allowed Whether to accept the payment.
673 + * @param string $pro_version Version of Easy Invoice Pro detected.
674 + * @param string $payment_method_slug Gateway slug from the request.
675 + */
676 + $allowed = (bool) apply_filters(
677 + 'easy_invoice_allow_legacy_pro_payment_fallback',
678 + true,
679 + $pro_version,
680 + $payment_method_slug
681 + );
682 +
683 + if ($allowed) {
684 + update_option('easy_invoice_legacy_pro_payment_seen', $pro_version, false);
685 + }
686 +
687 + return $allowed;
688 + }
689 +
690 + /**
537 691 * Handle payment callback/webhook
538 692 */
539 693 public function handleCallback(): void {
540 694 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
@@ -545,8 +699,34 @@
545 699 if (!$invoice_id || !$gateway) {
546 700 wp_send_json_error(['message' => __('Invalid request', 'easy-invoice')]);
547 701 }
548 702
703 + // Authorisation.
704 + //
705 + // This endpoint is registered nopriv and the only thing standing in front of
706 + // it was the shared, page-wide `easy_invoice_payment` nonce, which is rendered
707 + // on every public invoice page — so anyone able to view a single invoice could
708 + // lift one and then call this for any id they liked. The id was passed straight
709 + // to the gateway without even confirming it was an invoice.
710 + //
711 + // That mattered because the cheque gateway's callback writes: it stores the
712 + // cheque number, bank name, date and an uploaded image against whatever id it
713 + // is handed. An unauthenticated caller could therefore attach forged cheque
714 + // details, and a file, to any invoice on the site — or to any post at all.
715 + //
716 + // Same rule as everywhere else: valid per-invoice access key, administrator, or
717 + // the signed-in client the invoice belongs to.
718 + $invoice_post = get_post($invoice_id);
719 + if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
720 + wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
721 + }
722 +
723 + $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
724 + if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)
725 + && !$this->legacyProPaymentFallbackAllowed($gateway)) {
726 + wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
727 + }
728 +
549 729 $gateway_instance = $this->gatewayManager->getGateway($gateway);
550 730 if (!$gateway_instance) {
551 731 wp_send_json_error(['message' => __('Invalid payment gateway', 'easy-invoice')]);
552 732 }
@@ -552,10 +732,14 @@
552 732 }
553 733
554 734 $result = $gateway_instance->handleCallback($_POST);
555 735
556 - // Send admin notification for manual payments
557 - if ($result['success'] && in_array($gateway, ['bank', 'cheque'])) {
736 + // Tell the admin an offline payment is waiting for verification. Pro's
737 + // bank-transfer and cheque gateways email the admin themselves from
738 + // handleCallback(); the free manual gateway and Pro's cash gateway do
739 + // not. (This used to test for 'bank' and 'cheque' — ids no gateway
740 + // has — so it never fired.)
741 + if ($result['success'] && in_array($gateway, ['manual', 'cash'], true)) {
558 742 do_action('easy_invoice_manual_payment_submitted', $invoice_id, $gateway);
559 743 }
560 744
561 745 if ($result['success']) {
@@ -579,9 +763,13 @@
579 763
580 764 $invoice = new \EasyInvoice\Models\Invoice($post);
581 765 $invoice_status = $invoice->getStatus();
582 766
583 - if (!in_array($invoice_status, [ 'unpaid', 'available'])) {
767 + // Anything that still has a balance can be paid: an overdue invoice is the
768 + // one a client most needs to settle, and a partially paid one still owes.
769 + // Drafts, paid, cancelled and "awaiting verification" stay closed.
770 + $payable_statuses = apply_filters('easy_invoice_payable_statuses', [ 'unpaid', 'available', 'overdue', 'partial', 'sent', 'pending' ]);
771 + if (!in_array($invoice_status, $payable_statuses, true)) {
584 772 return [];
585 773 }
586 774
587 775 $enabled_gateways = $this->gatewayManager->getEnabledGateways();
@@ -636,13 +824,27 @@
636 824 */
637 825 public function updatePayment() {
638 826 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
639 827
828 + // Authorisation: this handler mutates payment-record fields
829 + // (amount, method, status, notes) and on status=completed it
830 + // can flip the linked invoice to paid via
831 + // updateInvoiceStatusIfPaid(). The shared `easy_invoice_payment`
832 + // nonce is rendered on every public invoice page so any
833 + // authenticated visitor can obtain a valid one — the nonce is
834 + // CSRF defense, NOT authorisation. Gate on the same payment-
835 + // management capability as the sibling verifyManualPayment /
836 + // rejectManualPayment / mark_invoice_paid_ajax handlers.
837 + if (!easy_invoice_user_can('ei_record_payment')) {
838 + wp_send_json_error(['message' => __('You do not have permission to update payments.', 'easy-invoice')]);
839 + return;
840 + }
841 +
640 842 $payment_id = isset($_POST['payment_id']) ? intval($_POST['payment_id']) : 0;
641 843 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
642 844 $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
643 845 $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
644 - $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : date('Y-m-d');
846 + $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : current_time('Y-m-d');
645 847 $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'pending';
646 848 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
647 849
648 850 if (!$payment_id || !$invoice_id || !$amount || !$payment_method) {
@@ -698,13 +900,13 @@
698 900 $total_payments = $this->calculateTotalPaymentsForInvoice($invoice_id);
699 901 $invoice_total = $invoice->getTotal();
700 902
701 903 if ($total_payments < $invoice_total) {
702 - // Not enough payments anymore, revert invoice to draft/pending
703 - $invoice->setStatus('draft');
904 + // Not enough payments any more: part paid if anything
905 + // remains, otherwise back to awaiting payment. An issued
906 + // invoice never returns to draft.
907 + $invoice->setStatus($total_payments > 0 ? 'partial' : 'available');
704 908 $invoice->save();
705 -
706 - error_log("Easy Invoice: Invoice #$invoice_id status reverted to 'draft' - payment marked as $status");
707 909 } else {
708 910 // Still enough payments from other completed payments
709 911 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
710 912 }
@@ -893,58 +1095,12 @@
893 1095 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
894 1096 $email_manager->sendPaymentRejectionEmail($invoice, $reason);
895 1097 }
896 1098
897 - /**
898 - * Add pending payment statuses to admin filters
899 - *
900 - * @param array $statuses
901 - * @return array
902 - */
903 - public function addPendingPaymentStatuses($statuses): array {
904 - $statuses['pending-bank'] = __('Pending Bank Transfer', 'easy-invoice');
905 - $statuses['pending-cheque'] = __('Pending Cheque', 'easy-invoice');
906 - return $statuses;
907 - }
908 1099
909 - /**
910 - * Add payment method column to payments list
911 - *
912 - * @param array $columns
913 - * @return array
914 - */
915 - public function addPaymentMethodColumn($columns): array {
916 - $new_columns = [];
917 1100
918 - foreach ($columns as $key => $value) {
919 - $new_columns[$key] = $value;
920 1101
921 - if ($key === 'title') {
922 - $new_columns['payment_method'] = __('Payment Method', 'easy-invoice');
923 - }
924 - }
925 -
926 - return $new_columns;
927 - }
928 -
929 1102 /**
930 - * Render payment method column
931 - *
932 - * @param string $column
933 - * @param int $post_id
934 - */
935 - public function renderPaymentMethodColumn($column, $post_id): void {
936 - if ($column === 'payment_method') {
937 - $payment_method = get_post_meta($post_id, '_payment_method', true);
938 - $payment_methods = [
939 - 'paypal' => __('PayPal', 'easy-invoice')
940 - ];
941 -
942 - echo isset($payment_methods[$payment_method]) ? esc_html($payment_methods[$payment_method]) : esc_html($payment_method);
943 - }
944 - }
945 -
946 - /**
947 1103 * Send payment reminders for pending manual payments
948 1104 */
949 1105 public function sendPaymentReminders(): void {
950 1106 // Get invoices with pending manual payments
@@ -998,10 +1154,13 @@
998 1154 /**
999 1155 * Submit manual payment
1000 1156 */
1001 1157 public function submitManualPayment(): void {
1002 - // Verify nonce
1003 - if (!wp_verify_nonce($_POST['nonce'], 'easy_invoice_payment')) {
1158 + // CSRF defense — keep the existing nonce check. The nonce is
1159 + // global (`easy_invoice_payment`) so any public invoice page leaks
1160 + // a valid value; the REAL authorisation gate is the ownership
1161 + // check below.
1162 + if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_payment')) {
1004 1163 wp_send_json_error(['message' => __('Security check failed', 'easy-invoice')]);
1005 1164 return;
1006 1165 }
1007 1166
@@ -1007,9 +1166,9 @@
1007 1166
1008 1167 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1009 1168 $payment_type = isset($_POST['payment_type']) ? sanitize_text_field($_POST['payment_type']) : '';
1010 1169 $payment_notes = isset($_POST['payment_notes']) ? sanitize_textarea_field($_POST['payment_notes']) : '';
1011 -
1170 +
1012 1171 if (!$invoice_id || !$payment_type) {
1013 1172 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
1014 1173 return;
1015 1174 }
@@ -1021,8 +1180,21 @@
1021 1180 return;
1022 1181 }
1023 1182
1024 1183 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
1184 +
1185 + // Authorisation: reject unless the caller is the legitimate email
1186 + // recipient (per-invoice access token), an admin, or the
1187 + // logged-in client bound to this invoice. Without this gate the
1188 + // public AJAX endpoint allowed any visitor with a harvested
1189 + // global nonce to flood arbitrary invoices into
1190 + // `pending_verification` and attach payment-proof uploads.
1191 + if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
1192 + wp_send_json_error([
1193 + 'message' => __('You do not have permission to submit a payment for this invoice.', 'easy-invoice'),
1194 + ]);
1195 + return;
1196 + }
1025 1197 $currency_code = $invoice->getCurrencyCode() ?: 'USD';
1026 1198 if ($currency_code === 'global') {
1027 1199 $currency_code = get_option('easy_invoice_currency_code', 'USD');
1028 1200 }
@@ -1070,18 +1242,41 @@
1070 1242 wp_send_json_error(['message' => __('Could not create upload directory.', 'easy-invoice')]);
1071 1243 return;
1072 1244 }
1073 1245
1074 - $filename = uniqid('payment_proof_', true) . '.' . $checked['ext'];
1075 - $filepath = $proof_dir . $filename;
1246 + // Hand the move to WordPress rather than move_uploaded_file(): it
1247 + // applies the site's filesystem method and permissions, and lets
1248 + // the usual upload filters see the file. The directory is pointed
1249 + // at our proofs folder for the duration of this one call.
1250 + // Random, not time-based: a receipt carries bank details and the URL
1251 + // is public, so the name must not be guessable.
1252 + $filename = 'payment_proof_' . wp_generate_password(24, false, false) . '.' . $checked['ext'];
1253 + $proof_url = $upload_dir['baseurl'] . '/easy-invoice/payment-proofs/';
1254 + $to_proofs = static function ($dirs) use ($proof_dir, $proof_url) {
1255 + $dirs['path'] = untrailingslashit($proof_dir);
1256 + $dirs['url'] = untrailingslashit($proof_url);
1257 + $dirs['subdir'] = '/easy-invoice/payment-proofs';
1258 + return $dirs;
1259 + };
1260 + if (!function_exists('wp_handle_upload')) {
1261 + require_once ABSPATH . 'wp-admin/includes/file.php';
1262 + }
1263 + add_filter('upload_dir', $to_proofs);
1264 + \EasyInvoice\Helpers\UploadGuard::protectDirectory((wp_upload_dir())['basedir'] . '/easy-invoice/payment-proofs');
1265 + $moved = wp_handle_upload($file, [
1266 + 'test_form' => false,
1267 + 'mimes' => $allowed_mimes,
1268 + 'unique_filename_callback' => static function () use ($filename) {
1269 + return $filename;
1270 + },
1271 + ]);
1272 + remove_filter('upload_dir', $to_proofs);
1076 1273
1077 - if (!move_uploaded_file($file['tmp_name'], $filepath)) {
1274 + if (!is_array($moved) || !empty($moved['error']) || empty($moved['url'])) {
1078 1275 wp_send_json_error(['message' => __('Failed to save payment proof file.', 'easy-invoice')]);
1079 1276 return;
1080 1277 }
1081 -
1082 - chmod($filepath, 0644);
1083 - $proof_url = $upload_dir['baseurl'] . '/easy-invoice/payment-proofs/' . $filename;
1278 + $proof_url = $moved['url'];
1084 1279 }
1085 1280
1086 1281 // Create payment record
1087 1282 $payment_data = [
@@ -1367,8 +1562,109 @@
1367 1562 easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
1368 1563 }
1369 1564
1370 1565 /**
1566 + * Record money received, from the admin "Add New Payment" form.
1567 + *
1568 + * The form used to post to the customer checkout endpoint, which runs a
1569 + * gateway (bank-transfer instructions, a card form) — not what an admin
1570 + * typing in a cheque they were handed wants. This books a completed
1571 + * payment and settles the invoice: paid when the total is covered,
1572 + * partial otherwise.
1573 + */
1574 + public function recordPayment() {
1575 + if (!isset($_POST['payment_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['payment_nonce'])), 'easy_invoice_payment')) {
1576 + wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]);
1577 + }
1578 + if (!easy_invoice_user_can('ei_record_payment')) {
1579 + wp_send_json_error(['message' => __('You do not have permission to record payments.', 'easy-invoice')]);
1580 + }
1581 + $invoice_id = isset($_POST['invoice_id']) ? absint($_POST['invoice_id']) : 0;
1582 + $amount = isset($_POST['amount']) ? (float) str_replace(',', '', sanitize_text_field(wp_unslash($_POST['amount']))) : 0.0;
1583 + $method = isset($_POST['payment_method']) ? sanitize_key(wp_unslash($_POST['payment_method'])) : '';
1584 + $date = isset($_POST['payment_date']) ? sanitize_text_field(wp_unslash($_POST['payment_date'])) : '';
1585 + $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : '';
1586 +
1587 + $invoice = $invoice_id > 0 ? \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id) : null;
1588 + if (!$invoice) {
1589 + wp_send_json_error(['message' => __('Choose the invoice the payment is for.', 'easy-invoice')]);
1590 + }
1591 + if ($amount <= 0) {
1592 + wp_send_json_error(['message' => __('Enter an amount greater than zero.', 'easy-invoice')]);
1593 + }
1594 + if ('' === $method) {
1595 + $method = 'manual';
1596 + }
1597 + $when = $date && strtotime($date) ? gmdate('Y-m-d H:i:s', strtotime($date)) : current_time('mysql');
1598 +
1599 + $currency_code = $invoice->getCurrencyCode() ?: get_option('easy_invoice_currency_code', 'USD');
1600 + $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1601 + $payment_id = wp_insert_post([
1602 + 'post_title' => sprintf('Payment for Invoice #%s', $invoice->getNumber()),
1603 + 'post_type' => 'easy_invoice_payment',
1604 + 'post_status' => 'publish',
1605 + 'post_author' => get_current_user_id(),
1606 + 'meta_input' => [
1607 + '_invoice_id' => $invoice_id,
1608 + '_amount' => round($amount, 2),
1609 + '_payment_method' => $method,
1610 + '_status' => 'completed',
1611 + '_transaction_id' => 'MANUAL-' . $invoice_id . '-' . time(),
1612 + '_payment_date' => $when,
1613 + '_notes' => $notes,
1614 + '_payment_type' => 'manual',
1615 + '_currency' => $currency_code,
1616 + '_currency_symbol' => $currency_symbol,
1617 + '_gateway_response' => wp_json_encode(['recorded_by' => get_current_user_id(), 'recorded_at' => current_time('mysql'), 'notes' => $notes]),
1618 + ],
1619 + ]);
1620 + if (is_wp_error($payment_id) || !$payment_id) {
1621 + wp_send_json_error(['message' => __('The payment could not be saved.', 'easy-invoice')]);
1622 + }
1623 +
1624 + $new_status = \EasyInvoice\Services\InvoiceBalance::isSettled($invoice) ? 'paid' : 'partial';
1625 + update_post_meta($invoice_id, '_easy_invoice_payment_method', $method);
1626 + $invoice->setStatus($new_status);
1627 + $invoice->save();
1628 + $payment_event = [
1629 + 'payment_method' => $method,
1630 + 'gateway_name' => 'manual',
1631 + 'transaction_id' => get_post_meta($payment_id, '_transaction_id', true),
1632 + 'amount' => $amount,
1633 + 'date' => $date,
1634 + ];
1635 + if ('paid' === $new_status) {
1636 + do_action('easy_invoice_payment_completed', $invoice_id, $invoice, $payment_event);
1637 + } else {
1638 + /**
1639 + * Fires when a payment is recorded that leaves a balance owing.
1640 + *
1641 + * @param int $invoice_id Invoice.
1642 + * @param object $invoice Invoice model.
1643 + * @param array $payment payment_method, gateway_name, transaction_id, amount, date.
1644 + */
1645 + do_action('easy_invoice_payment_received', $invoice_id, $invoice, $payment_event);
1646 + }
1647 + /**
1648 + * Fires after an administrator records a payment by hand.
1649 + *
1650 + * @param int $payment_id Payment record.
1651 + * @param int $invoice_id Invoice.
1652 + * @param float $amount Amount recorded.
1653 + * @param string $new_status Invoice status afterwards.
1654 + */
1655 + do_action('easy_invoice_payment_recorded', $payment_id, $invoice_id, $amount, $new_status);
1656 +
1657 + wp_send_json_success([
1658 + 'payment_id' => $payment_id,
1659 + 'status' => $new_status,
1660 + 'message' => 'paid' === $new_status
1661 + ? __('Payment recorded — the invoice is paid.', 'easy-invoice')
1662 + : sprintf(/* translators: %s: amount still owed. */ __('Payment recorded — %s still due.', 'easy-invoice'), $currency_symbol . number_format_i18n(\EasyInvoice\Services\InvoiceBalance::due($invoice), 2)),
1663 + ]);
1664 + }
1665 +
1666 + /**
1371 1667 * Handle bulk actions for payments
1372 1668 */
1373 1669 public function handleBulkActions() {
1374 1670 // Check if we're processing a bulk action
@@ -1376,21 +1672,21 @@
1376 1672 return;
1377 1673 }
1378 1674
1379 1675 // Check nonce and capability
1380 - if (!wp_verify_nonce($_POST['easy_invoice_payment_bulk_nonce'], 'easy_invoice_payment_bulk_action')) {
1381 - wp_die(__('Security check failed.', 'easy-invoice'));
1676 + if (!wp_verify_nonce(($_POST['easy_invoice_payment_bulk_nonce'] ?? ''), 'easy_invoice_payment_bulk_action')) {
1677 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1382 1678 }
1383 1679
1384 1680 // Bulk action on payments — record-payment cap is the right gate
1385 1681 // (covers trash/restore/delete which all change payment state).
1386 1682 if (!easy_invoice_user_can('ei_record_payment')) {
1387 - wp_die(__('You do not have permission to perform this action.', 'easy-invoice'));
1683 + wp_die(esc_html__('You do not have permission to perform this action.', 'easy-invoice'));
1388 1684 }
1389 1685
1390 1686 // Check if we have payment IDs
1391 1687 if (!isset($_POST['payment_ids']) || !is_array($_POST['payment_ids']) || empty($_POST['payment_ids'])) {
1392 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection'));
1688 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection'));
1393 1689 exit;
1394 1690 }
1395 1691
1396 1692 // Get bulk action and payment IDs
@@ -1427,13 +1723,13 @@
1427 1723 }
1428 1724 }
1429 1725
1430 1726 // Update invoice statuses for completed payments that were trashed
1431 - foreach ($invoice_updates as $invoice_id => $deleted_amount) {
1432 - $this->updateInvoiceStatusAfterPaymentDeletion($invoice_id, $deleted_amount);
1727 + foreach (array_keys($invoice_updates) as $invoice_id) {
1728 + $this->syncInvoiceStatusWithPayments($invoice_id);
1433 1729 }
1434 1730
1435 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed));
1731 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed));
1436 1732 break;
1437 1733
1438 1734 case 'restore':
1439 1735 foreach ($payment_ids as $id) {
@@ -1465,13 +1761,13 @@
1465 1761 }
1466 1762 }
1467 1763
1468 1764 // Update invoice statuses for completed payments that were restored
1469 - foreach ($invoice_updates as $invoice_id => $restored_amount) {
1470 - $this->updateInvoiceStatusAfterPaymentRestoration($invoice_id, $restored_amount);
1765 + foreach (array_keys($invoice_updates) as $invoice_id) {
1766 + $this->syncInvoiceStatusWithPayments($invoice_id);
1471 1767 }
1472 1768
1473 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed));
1769 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed));
1474 1770 break;
1475 1771
1476 1772 case 'delete':
1477 1773 foreach ($payment_ids as $id) {
@@ -1498,17 +1794,17 @@
1498 1794 }
1499 1795 }
1500 1796
1501 1797 // Update invoice statuses for completed payments that were deleted
1502 - foreach ($invoice_updates as $invoice_id => $deleted_amount) {
1503 - $this->updateInvoiceStatusAfterPaymentDeletion($invoice_id, $deleted_amount);
1798 + foreach (array_keys($invoice_updates) as $invoice_id) {
1799 + $this->syncInvoiceStatusWithPayments($invoice_id);
1504 1800 }
1505 1801
1506 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed));
1802 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed));
1507 1803 break;
1508 1804
1509 1805 default:
1510 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action'));
1806 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action'));
1511 1807 }
1512 1808
1513 1809 exit;
1514 1810 }
@@ -1513,108 +1809,40 @@
1513 1809 exit;
1514 1810 }
1515 1811
1516 1812 /**
1517 - * Update invoice status after payment deletion
1813 + * Put an invoice's status back in line with the completed payments and
1814 + * credit notes it actually has — after a payment is trashed, restored or
1815 + * deleted. Paid when nothing is owed, part-paid when something has been
1816 + * received, otherwise awaiting payment; an issued invoice never returns
1817 + * to draft. (This used to write the status to a meta key the invoice
1818 + * does not use, so a trashed payment left the invoice "paid".)
1819 + *
1820 + * @param int $invoice_id Invoice.
1518 1821 */
1519 - private function updateInvoiceStatusAfterPaymentDeletion($invoice_id, $deleted_amount) {
1520 - $invoice = new Invoice($invoice_id);
1521 -
1522 - if (!$invoice->getId()) {
1822 + private function syncInvoiceStatusWithPayments($invoice_id) {
1823 + $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find((int) $invoice_id);
1824 + if (!$invoice || !$invoice->getId()) {
1523 1825 return;
1524 1826 }
1525 -
1526 - // Get all remaining payments for this invoice
1527 - $remaining_payments = get_posts(array(
1528 - 'post_type' => 'easy_invoice_payment',
1529 - 'post_status' => 'publish',
1530 - 'meta_query' => array(
1531 - array(
1532 - 'key' => '_invoice_id',
1533 - 'value' => $invoice_id,
1534 - 'compare' => '='
1535 - ),
1536 - array(
1537 - 'key' => '_status',
1538 - 'value' => 'completed',
1539 - 'compare' => '='
1540 - )
1541 - ),
1542 - 'posts_per_page' => -1
1543 - ));
1544 -
1545 - // Calculate total remaining payments
1546 - $total_remaining = 0;
1547 - foreach ($remaining_payments as $payment_post) {
1548 - $payment = new Payment($payment_post);
1549 - $total_remaining += floatval($payment->getAmount());
1827 + $current = (string) $invoice->getStatus();
1828 + if (in_array($current, ['draft', 'cancelled', 'canceled'], true)) {
1829 + return;
1550 1830 }
1551 -
1552 - $invoice_total = floatval($invoice->getTotal());
1553 -
1554 - // Update invoice status based on remaining payments
1555 - if ($total_remaining >= $invoice_total) {
1556 - // Still fully paid
1557 - update_post_meta($invoice_id, '_status', 'paid');
1558 - } elseif ($total_remaining > 0) {
1559 - // Partially paid
1560 - update_post_meta($invoice_id, '_status', 'partial');
1831 + $paid = \EasyInvoice\Services\InvoiceBalance::paid((int) $invoice_id);
1832 + if (\EasyInvoice\Services\InvoiceBalance::isSettled($invoice)) {
1833 + $new = 'paid';
1834 + } elseif ($paid > 0) {
1835 + $new = 'partial';
1561 1836 } else {
1562 - // No payments remaining
1563 - update_post_meta($invoice_id, '_status', 'unpaid');
1837 + $new = in_array($current, ['unpaid', 'available'], true) ? $current : 'available';
1564 1838 }
1839 + if ($new !== $current) {
1840 + $invoice->setStatus($new);
1841 + $invoice->save();
1842 + }
1565 1843 }
1566 1844
1567 - /**
1568 - * Update invoice status after payment restoration
1569 - */
1570 - private function updateInvoiceStatusAfterPaymentRestoration($invoice_id, $restored_amount) {
1571 - $invoice = new Invoice($invoice_id);
1572 -
1573 - if (!$invoice->getId()) {
1574 - return;
1575 - }
1576 -
1577 - // Get all payments for this invoice (including the restored one)
1578 - $all_payments = get_posts(array(
1579 - 'post_type' => 'easy_invoice_payment',
1580 - 'post_status' => 'publish',
1581 - 'meta_query' => array(
1582 - array(
1583 - 'key' => '_invoice_id',
1584 - 'value' => $invoice_id,
1585 - 'compare' => '='
1586 - ),
1587 - array(
1588 - 'key' => '_status',
1589 - 'value' => 'completed',
1590 - 'compare' => '='
1591 - )
1592 - ),
1593 - 'posts_per_page' => -1
1594 - ));
1595 -
1596 - // Calculate total payments (including restored ones)
1597 - $total_payments = 0;
1598 - foreach ($all_payments as $payment_post) {
1599 - $payment = new Payment($payment_post);
1600 - $total_payments += floatval($payment->getAmount());
1601 - }
1602 -
1603 - $invoice_total = floatval($invoice->getTotal());
1604 -
1605 - // Update invoice status based on total payments
1606 - if ($total_payments >= $invoice_total) {
1607 - // Fully paid
1608 - update_post_meta($invoice_id, '_status', 'paid');
1609 - } elseif ($total_payments > 0) {
1610 - // Partially paid
1611 - update_post_meta($invoice_id, '_status', 'partial');
1612 - } else {
1613 - // No payments
1614 - update_post_meta($invoice_id, '_status', 'unpaid');
1615 - }
1616 - }
1617 1845
1618 1846 // Stripe payment recording moved to Pro plugin
1619 1847
1620 1848