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 / Controllers / PaymentController.php

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

1,850 lines 80.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Payment Controller
4 *
5 * @package Easy_Invoice
6 */
7
8 namespace EasyInvoice\Controllers;
9
10 use EasyInvoice\Constants\PostTypes;
11 use EasyInvoice\PaymentGatewayManager;
12 use EasyInvoice\EasyInvoice;
13 use EasyInvoice\Models\Invoice;
14 use EasyInvoice\Models\Payment;
15 use EasyInvoice\Traits\TemplateTrait;
16 use EasyInvoice\Traits\PaymentCalculationTrait;
17 use EasyInvoice\Constants\PagesSlugs;
18 use EasyInvoice\Constants\InvoiceFields;
19 use EasyInvoice\Constants\InvoiceMetaKeys;
20 use EasyInvoice\Helpers\Sanitization;
21 use EasyInvoice\Providers\InvoiceServiceProvider; // Assuming this is used elsewhere or for future
22
23 /**
24 * Class PaymentController
25 *
26 * @package EasyInvoice\Controllers
27 */
28 class PaymentController extends BaseController {
29 use TemplateTrait;
30 use PaymentCalculationTrait;
31
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 /**
40 * Payment gateway manager instance
41 *
42 * @var PaymentGatewayManager
43 */
44 private $gatewayManager;
45
46 /**
47 * Constructor
48 */
49 public function __construct() {
50 $this->gatewayManager = EasyInvoice::getInstance()->getGatewayManager();
51 }
52
53 /**
54 * Initialize the controller
55 */
56 public function init() {
57 add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
58 add_action('wp_ajax_easy_invoice_process_payment', [$this, 'processPayment']);
59 add_action('wp_ajax_nopriv_easy_invoice_process_payment', [$this, 'processPayment']);
60 add_action('wp_ajax_easy_invoice_update_payment', [$this, 'updatePayment']);
61 add_action('wp_ajax_easy_invoice_record_payment', [$this, 'recordPayment']);
62 add_action('wp_ajax_easy_invoice_payment_callback', [$this, 'handleCallback']);
63 add_action('wp_ajax_nopriv_easy_invoice_payment_callback', [$this, 'handleCallback']);
64 add_action('wp_ajax_easy_invoice_verify_manual_payment', [$this, 'verifyManualPayment']);
65 add_action('wp_ajax_easy_invoice_reject_manual_payment', [$this, 'rejectManualPayment']);
66
67
68
69 // Handler for submitting payment proof for manual gateways
70 add_action('wp_ajax_easy_invoice_submit_payment_proof', [$this, 'submitPaymentProof']);
71 add_action('wp_ajax_nopriv_easy_invoice_submit_payment_proof', [$this, 'submitPaymentProof']);
72
73 // Handler for manual payment submission
74 add_action('wp_ajax_easy_invoice_submit_manual_payment', [$this, 'submitManualPayment']);
75 add_action('wp_ajax_nopriv_easy_invoice_submit_manual_payment', [$this, 'submitManualPayment']);
76
77 // Handler for getting payment instructions for manual gateways
78 add_action('wp_ajax_easy_invoice_get_payment_instructions', [$this, 'getPaymentInstructions']);
79 add_action('wp_ajax_nopriv_easy_invoice_get_payment_instructions', [$this, 'getPaymentInstructions']);
80
81 // Enqueue frontend scripts
82 add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']);
83
84 // Handler for admin to mark an invoice as paid
85 add_action('wp_ajax_easy_invoice_approve_payment', [$this, 'mark_invoice_paid_ajax']);
86
87 // Stripe payment handlers moved to Pro plugin
88
89 add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
90
91 // Add reminder CRON job for pending payments
92 add_action('easy_invoice_payment_reminder', [$this, 'sendPaymentReminders']);
93 if (!wp_next_scheduled('easy_invoice_payment_reminder')) {
94 wp_schedule_event(time(), 'daily', 'easy_invoice_payment_reminder');
95 }
96
97 // Handle bulk actions
98 add_action('admin_init', [$this, 'handleBulkActions']);
99 }
100
101 /**
102 * Get payment instructions for manual gateways
103 */
104 public function getPaymentInstructions() {
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')) {
109 wp_send_json_error(['message' => 'Security check failed']);
110 return;
111 }
112
113 $gateway = sanitize_text_field(($_POST['gateway'] ?? ''));
114 $invoice_id = intval(($_POST['invoice_id'] ?? ''));
115
116 if (!$gateway || !$invoice_id) {
117 wp_send_json_error(['message' => 'Missing required parameters']);
118 return;
119 }
120
121 // Get invoice
122 $invoice_post = get_post($invoice_id);
123 if (!$invoice_post || $invoice_post->post_type !== 'easy_invoice') {
124 wp_send_json_error(['message' => 'Invalid invoice']);
125 return;
126 }
127
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)) {
143 wp_send_json_error(['message' => __('Invoice not found', 'easy-invoice')]);
144 return;
145 }
146
147 // Get gateway instance
148 $gateway_instance = $this->gatewayManager->getGateway($gateway);
149
150 if (!$gateway_instance) {
151 wp_send_json_error(['message' => 'Gateway not found']);
152 return;
153 }
154
155 // Get instructions using the hook system
156 ob_start();
157 do_action('easy_invoice_payment_gateways_after', $invoice, $gateway);
158 $instructions = ob_get_clean();
159
160 if ($instructions) {
161 wp_send_json_success(['instructions' => $instructions]);
162 } else {
163 wp_send_json_error(['message' => 'No instructions available']);
164 }
165 }
166
167 /**
168 * Enqueue admin assets
169 */
170 public function enqueueAssets() {
171 $screen = get_current_screen();
172 if (!$screen || !property_exists($screen, 'id') || strpos($screen->id, 'easy-invoice') === false) {
173 return;
174 }
175
176 // Enqueue manual payment script
177 wp_enqueue_script(
178 'easy-invoice-manual-payment',
179 EASY_INVOICE_PLUGIN_URL . 'assets/js/manual-payment.js',
180 ['jquery'],
181 '1.0.0',
182 true
183 );
184
185 // Localize script
186 wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [
187 'ajax_url' => admin_url('admin-ajax.php'),
188 'nonce' => wp_create_nonce('easy_invoice_payment')
189 ]);
190 }
191
192 /**
193 * Enqueue frontend assets
194 */
195 public function enqueueFrontendAssets() {
196 // Only load on invoice pages
197 if (is_singular('easy_invoice')) {
198 wp_enqueue_script(
199 'easy-invoice-manual-payment',
200 EASY_INVOICE_PLUGIN_URL . 'assets/js/manual-payment.js',
201 ['jquery'],
202 '1.0.0',
203 true
204 );
205
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
216 wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [
217 'ajax_url' => admin_url('admin-ajax.php'),
218 'nonce' => wp_create_nonce('easy_invoice_payment'),
219 'access_token' => $access_token,
220 ]);
221 }
222 }
223
224 /**
225 * Display method implementation
226 *
227 * @param array $args Display arguments
228 */
229 public function display(array $args = []) {
230 $page = isset($args['page']) ? $args['page'] : '';
231
232 switch ($page) {
233 case PagesSlugs::PAYMENTS:
234 $this->displayPaymentsPage();
235 break;
236
237 case PagesSlugs::PAYMENT_NEW:
238 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/new.php');
239 break;
240
241 case 'view':
242 $payment_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
243 if ($payment_id) {
244 $payment_post = get_post($payment_id);
245 if ($payment_post && $payment_post->post_type === 'easy_invoice_payment') {
246 try {
247 $payment = new Payment($payment_post);
248 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/view.php', ['payment' => $payment]);
249 } catch (\Exception $e) {
250 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
251 }
252 } else {
253 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
254 }
255 } else {
256 wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
257 }
258 break;
259
260 case 'edit':
261 $payment_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
262 if ($payment_id) {
263 $payment_post = get_post($payment_id);
264 if ($payment_post && $payment_post->post_type === 'easy_invoice_payment') {
265 try {
266 $payment = new Payment($payment_post);
267 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/edit.php', ['payment' => $payment]);
268 } catch (\Exception $e) {
269 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
270 }
271 } else {
272 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
273 }
274 } else {
275 wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
276 }
277 break;
278
279 default:
280 $this->displayPaymentsPage();
281 break;
282 }
283 }
284
285 /**
286 * Display payments page with pagination
287 */
288 protected function displayPaymentsPage() {
289 // Get current view (all, trash)
290 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
291
292 // Get status filter
293 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
294
295 // Pagination settings
296 $per_page = 20;
297 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
298
299 // Build query arguments
300 $args = array(
301 'post_type' => 'easy_invoice_payment',
302 'posts_per_page' => $per_page,
303 'paged' => $current_page,
304 'orderby' => 'ID',
305 'order' => 'DESC',
306 'no_found_rows' => false, // We need this for pagination
307 );
308
309 // Set post status based on current view
310 if ($current_view === 'trash') {
311 $args['post_status'] = 'trash';
312 } else {
313 $args['post_status'] = 'publish';
314 }
315
316 // Add status filter if set
317 if (!empty($status_filter)) {
318 $args['meta_query'] = array(
319 array(
320 'key' => '_status',
321 'value' => $status_filter,
322 ),
323 );
324 }
325
326 // Allow plugins to modify query arguments
327 $args = apply_filters('easy_invoice_payment_controller_query_args', $args, $current_view, $status_filter);
328
329
330 // Get paginated payments using WordPress query
331 $wp_query = new \WP_Query($args);
332
333
334 $payments = [];
335
336 if ($wp_query->have_posts()) {
337 while ($wp_query->have_posts()) {
338 $wp_query->the_post();
339 $post = get_post();
340 $payment = new Payment($post);
341 $payments[] = $payment;
342 }
343 }
344
345 wp_reset_postdata();
346
347 // Allow plugins to modify the payments array
348 $payments = apply_filters('easy_invoice_payment_controller_payments_list', $payments, $wp_query);
349
350 // Get pagination info from WordPress query
351 $total_payments = $wp_query->found_posts;
352 $total_pages = $wp_query->max_num_pages;
353
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 );
367 $stats = [
368 'total_payments' => 0,
369 'total_amount' => 0,
370 'completed_payments' => 0,
371 'pending_payments' => 0,
372 'failed_payments' => 0,
373 ];
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'];
380 }
381 }
382
383 $stats = array_merge([
384 'total_payments' => 0,
385 'total_amount' => 0,
386 'completed_payments' => 0,
387 'pending_payments' => 0,
388 'failed_payments' => 0
389 ], $stats);
390
391 // Get trash count for tab display
392 $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'easy_invoice_payment' AND post_status = 'trash'" );
393
394 // Define available status filters
395 $status_filters = array(
396 'completed' => 'Completed',
397 'pending' => 'Pending',
398 'failed' => 'Failed'
399 );
400
401 // Prepare template data
402 $template_data = [
403 'payments' => $payments,
404 'current_view' => $current_view,
405 'status_filter' => $status_filter,
406 'status_filters' => $status_filters,
407 'trash_count' => $trash_count,
408 'stats' => $stats,
409 'current_page' => $current_page,
410 'per_page' => $per_page,
411 'total_payments' => $total_payments,
412 'total_pages' => $total_pages,
413 'wp_query' => $wp_query
414 ];
415
416 // Allow plugins to modify template data
417 $template_data = apply_filters('easy_invoice_payment_controller_template_data', $template_data);
418
419 // Display the template
420 $this->displayTemplate(
421 EASY_INVOICE_PLUGIN_DIR . 'templates/payments/list.php',
422 $template_data
423 );
424
425 // Allow plugins to perform actions after displaying payments page
426 do_action('easy_invoice_payment_controller_after_display_payments_page', $template_data);
427 }
428
429 /**
430 * Enqueue required scripts and styles
431 */
432 public function enqueueScripts(): void {
433 // Check if scripts are already enqueued
434 if (wp_script_is('easy-invoice-payment', 'enqueued')) {
435 return;
436 }
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 }
448
449 // Enqueue our custom scripts
450 wp_enqueue_script(
451 'easy-invoice-payment',
452 EASY_INVOICE_URL . 'assets/js/payment.js',
453 ['jquery'],
454 EASY_INVOICE_VERSION,
455 true
456 );
457
458 // Get currency settings
459 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
460 $settings = $settings_controller->getSettings();
461 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
462 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
463
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
475 wp_localize_script('easy-invoice-payment', 'easy_invoice_vars', [
476 'ajax_url' => admin_url('admin-ajax.php'),
477 'nonce' => wp_create_nonce('easy_invoice_payment'),
478 'access_token' => $ei_access_token,
479 'currency_symbol' => $currency_symbol,
480 'currency_code' => $currency_code
481 ]);
482 }
483
484 // Stripe methods moved to Pro plugin
485
486 /**
487 * Process payment via AJAX
488 */
489 public function processPayment() {
490 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
491
492 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
493 $payment_method_slug = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
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
527 // Add filter for extensions to handle custom payment logic (e.g., partial payments)
528 $custom_result = apply_filters('easy_invoice_before_process_payment', null, $invoice_id, $_POST);
529
530 if (is_array($custom_result) && isset($custom_result['handled']) && $custom_result['handled']) {
531 if ($custom_result['success']) {
532 wp_send_json_success($custom_result);
533 } else {
534 wp_send_json_error(['message' => $custom_result['message'] ?? __('Payment failed.', 'easy-invoice')]);
535 }
536 return;
537 }
538
539 if (!$payment_method_slug) {
540 wp_send_json_error(['message' => __('Missing required fields.', 'easy-invoice')]);
541 return;
542 }
543
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')]);
550 return;
551 }
552
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 }
569
570 $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug);
571
572 if (!$gateway_instance || !$gateway_instance->isEnabled() || !$gateway_instance->isAvailable()) {
573 wp_send_json_error(['message' => __('Selected payment gateway is not available or configured correctly.', 'easy-invoice')]);
574 return;
575 }
576
577 try {
578 // Pass the entire $_POST array to the gateway
579 $result = $gateway_instance->processPayment($amount, $_POST);
580
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 }
590 wp_send_json_success($result);
591 } else {
592 wp_send_json_error(['message' => $result['message'] ?? __('Payment processing failed with the gateway.', 'easy-invoice')]);
593 }
594
595 } catch (\Exception $e) {
596 error_log('Easy Invoice Payment Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ' on line ' . $e->getLine());
597 wp_send_json_error(['message' => __('An unexpected error occurred during payment processing. Please check plugin logs or contact support.', 'easy-invoice')]);
598 }
599 }
600
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 /**
691 * Handle payment callback/webhook
692 */
693 public function handleCallback(): void {
694 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
695
696 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
697 $gateway = isset($_POST['gateway']) ? sanitize_text_field($_POST['gateway']) : '';
698
699 if (!$invoice_id || !$gateway) {
700 wp_send_json_error(['message' => __('Invalid request', 'easy-invoice')]);
701 }
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
729 $gateway_instance = $this->gatewayManager->getGateway($gateway);
730 if (!$gateway_instance) {
731 wp_send_json_error(['message' => __('Invalid payment gateway', 'easy-invoice')]);
732 }
733
734 $result = $gateway_instance->handleCallback($_POST);
735
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)) {
742 do_action('easy_invoice_manual_payment_submitted', $invoice_id, $gateway);
743 }
744
745 if ($result['success']) {
746 wp_send_json_success($result);
747 } else {
748 wp_send_json_error($result);
749 }
750 }
751
752 /**
753 * Get available payment gateways for an invoice
754 *
755 * @param int $invoice_id
756 * @return array
757 */
758 public function getAvailableGateways(int $invoice_id): array {
759 $post = get_post($invoice_id);
760 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
761 return [];
762 }
763
764 $invoice = new \EasyInvoice\Models\Invoice($post);
765 $invoice_status = $invoice->getStatus();
766
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)) {
772 return [];
773 }
774
775 $enabled_gateways = $this->gatewayManager->getEnabledGateways();
776
777 if (empty($enabled_gateways)) {
778 return [];
779 }
780
781 // Get invoice-specific gateways (comma-separated string or empty)
782 $invoice_gateways = $invoice->getPaymentGateways();
783 $selected_gateways = [];
784
785 // Handle both string and array formats
786 if (!empty($invoice_gateways)) {
787 if (is_string($invoice_gateways)) {
788 // If it's a string, split by comma
789 $selected_gateways = array_filter(array_map('trim', explode(',', $invoice_gateways)));
790 } elseif (is_array($invoice_gateways)) {
791 // If it's already an array, use it directly
792 $selected_gateways = array_filter($invoice_gateways);
793 }
794 }
795
796 $available_gateways = [];
797 $gateway_manager = \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager();
798
799 // $enabled_gateways is an associative array with gateway_id as key and gateway object as value
800 foreach ($enabled_gateways as $gateway_id => $gateway) {
801 // If invoice has custom gateways selected, only show those
802 // If no custom gateways are selected (empty array), show all enabled gateways
803 if (!empty($selected_gateways) && !in_array($gateway_id, $selected_gateways, true)) {
804 continue;
805 }
806
807 $is_available = $gateway->isAvailable();
808
809 if ($is_available) {
810 $available_gateways[] = [
811 'id' => $gateway_id,
812 'title' => $gateway_manager->getGatewayDisplayName($gateway_id),
813 'icon' => $gateway->getIcon(),
814 'description' => $gateway->getDescription()
815 ];
816 }
817 }
818
819 return $available_gateways;
820 }
821
822 /**
823 * Update payment via AJAX
824 */
825 public function updatePayment() {
826 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
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
842 $payment_id = isset($_POST['payment_id']) ? intval($_POST['payment_id']) : 0;
843 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
844 $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
845 $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
846 $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : current_time('Y-m-d');
847 $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'pending';
848 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
849
850 if (!$payment_id || !$invoice_id || !$amount || !$payment_method) {
851 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
852 return;
853 }
854
855 try {
856 // Check if payment post exists before instantiating
857 $payment_post = get_post($payment_id);
858 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
859 wp_send_json_error(['message' => __('Invalid payment', 'easy-invoice')]);
860 return;
861 }
862
863 $payment = new Payment($payment_post);
864
865 // Get the old payment status before updating
866 $old_status = $payment->getStatus();
867
868 $post = get_post($invoice_id);
869 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
870 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
871 return;
872 }
873 $invoice = new Invoice($post);
874
875 $payment_data = [
876 'invoice_id' => $invoice_id,
877 'amount' => $amount,
878 'payment_method' => $payment_method,
879 'payment_date' => $payment_date,
880 'status' => $status,
881 'notes' => $notes,
882 'gateway_response' => [
883 'method' => $payment_method,
884 'date' => $payment_date,
885 'notes' => $notes
886 ]
887 ];
888
889 $result = $payment->update($payment_data);
890
891 if ($result) {
892 // Update invoice status based on payment status change
893 if ($status === 'completed' && $old_status !== 'completed') {
894 // Payment changed TO completed - check if invoice should be marked as paid
895 $invoice->setMeta('_payment_method', $payment_method);
896 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
897 } elseif ($status !== 'completed' && $old_status === 'completed') {
898 // Payment changed FROM completed to another status (failed, pending, etc.)
899 // Recalculate total payments and update invoice status accordingly
900 $total_payments = $this->calculateTotalPaymentsForInvoice($invoice_id);
901 $invoice_total = $invoice->getTotal();
902
903 if ($total_payments < $invoice_total) {
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');
908 $invoice->save();
909 } else {
910 // Still enough payments from other completed payments
911 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
912 }
913 }
914
915 wp_send_json_success([
916 'message' => __('Payment updated successfully', 'easy-invoice')
917 ]);
918 } else {
919 wp_send_json_error(['message' => __('Failed to update payment', 'easy-invoice')]);
920 }
921 } catch (\Exception $e) {
922 wp_send_json_error(['message' => $e->getMessage()]);
923 }
924 }
925
926 /**
927 * Verify manual payment
928 */
929 public function verifyManualPayment(): void {
930 // Check permissions
931 if (!easy_invoice_user_can('ei_record_payment')) {
932 wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]);
933 return;
934 }
935
936 // Verify nonce
937 check_ajax_referer('easy_invoice_admin', 'nonce');
938
939 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
940 $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
941 $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
942 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
943 $transaction_id = isset($_POST['transaction_id']) ? sanitize_text_field($_POST['transaction_id']) : '';
944
945 if (!$invoice_id || !$amount || !$payment_method) {
946 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
947 return;
948 }
949
950 // Get the invoice
951 $post = get_post($invoice_id);
952 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
953 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
954 return;
955 }
956
957 $invoice = new Invoice($post);
958
959 // Get currency settings
960 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
961 $settings = $settings_controller->getSettings();
962 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
963 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
964
965 $payment_data = [
966 'invoice_id' => $invoice_id,
967 'amount' => $amount,
968 'payment_method' => $payment_method,
969 'payment_date' => current_time('mysql'),
970 'notes' => $notes,
971 'status' => 'completed',
972 'payment_type' => 'full',
973 'transaction_id' => $transaction_id,
974 'recurring_id' => '',
975 'parent_payment_id' => '',
976 'currency' => $currency_code,
977 'currency_symbol' => $currency_symbol,
978 'gateway_response' => [
979 'admin_verified' => true,
980 'verification_date' => current_time('mysql'),
981 'verification_user' => get_current_user_id()
982 ]
983 ];
984
985 try {
986 $payment = Payment::create($payment_data);
987
988 // Store payment details before updating status (for the hook)
989 $invoice->setMeta('_payment_method', $payment_method);
990 if ($transaction_id) {
991 $invoice->setMeta('_transaction_id', $transaction_id);
992 }
993
994 // Update invoice status to paid only if total payments are sufficient
995 // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification
996 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
997
998 // Send confirmation email to customer
999 $this->sendPaymentConfirmationEmail($invoice_id, $payment->getId());
1000
1001 wp_send_json_success([
1002 'message' => __('Payment verified successfully', 'easy-invoice'),
1003 'payment_id' => $payment->getId()
1004 ]);
1005 } catch (\Exception $e) {
1006 wp_send_json_error(['message' => $e->getMessage()]);
1007 }
1008 }
1009
1010 /**
1011 * Reject manual payment
1012 */
1013 public function rejectManualPayment(): void {
1014 // Check permissions — rejecting a manual payment is a record-payment
1015 // operation (it transitions state, doesn't refund money).
1016 if (!easy_invoice_user_can('ei_record_payment')) {
1017 wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]);
1018 return;
1019 }
1020
1021 // Verify nonce
1022 check_ajax_referer('easy_invoice_admin', 'nonce');
1023
1024 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1025 $reason = isset($_POST['reason']) ? sanitize_textarea_field($_POST['reason']) : '';
1026
1027 if (!$invoice_id) {
1028 wp_send_json_error(['message' => __('Invoice ID is required', 'easy-invoice')]);
1029 return;
1030 }
1031
1032 // Get the invoice
1033 $post = get_post($invoice_id);
1034 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1035 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
1036 return;
1037 }
1038
1039 $invoice = new Invoice($post);
1040
1041 // Update invoice status
1042 update_post_meta($invoice_id, '_payment_status', 'rejected');
1043
1044 // Add rejection reason
1045 update_post_meta($invoice_id, '_payment_rejection_reason', $reason);
1046 update_post_meta($invoice_id, '_payment_rejection_date', current_time('mysql'));
1047 update_post_meta($invoice_id, '_payment_rejection_user', get_current_user_id());
1048
1049 // Send rejection email to customer
1050 $this->sendPaymentRejectionEmail($invoice_id, $reason);
1051
1052 wp_send_json_success([
1053 'message' => __('Payment rejected successfully', 'easy-invoice')
1054 ]);
1055 }
1056
1057
1058
1059 /**
1060 * Send payment confirmation email to customer
1061 *
1062 * @param int $invoice_id
1063 * @param int $payment_id
1064 */
1065 private function sendPaymentConfirmationEmail($invoice_id, $payment_id): void {
1066 $invoice = new Invoice(get_post($invoice_id));
1067
1068 if (!$invoice || !$invoice->getId()) {
1069 return;
1070 }
1071
1072 // Use EmailManager to send payment confirmation using proper template system
1073 // This will check if payment email is enabled in settings
1074 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1075 $email_manager->sendInvoiceEmail($invoice, 'paid', [
1076 'payment_id' => $payment_id,
1077 'skip_bcc' => true // Skip BCC to admin since this is a direct call
1078 ]);
1079 }
1080
1081 /**
1082 * Send payment rejection email to customer
1083 *
1084 * @param int $invoice_id
1085 * @param string $reason
1086 */
1087 private function sendPaymentRejectionEmail($invoice_id, $reason): void {
1088 $invoice = new Invoice(get_post($invoice_id));
1089
1090 if (!$invoice || !$invoice->getId()) {
1091 return;
1092 }
1093
1094 // Use EmailManager to send payment rejection
1095 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1096 $email_manager->sendPaymentRejectionEmail($invoice, $reason);
1097 }
1098
1099
1100
1101
1102 /**
1103 * Send payment reminders for pending manual payments
1104 */
1105 public function sendPaymentReminders(): void {
1106 // Get invoices with pending manual payments
1107 $pending_invoices = get_posts([
1108 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
1109 'posts_per_page' => -1,
1110 'meta_query' => [
1111 'relation' => 'AND',
1112 [
1113 'key' => '_payment_status',
1114 'value' => ['pending-bank', 'pending-cheque'],
1115 'compare' => 'IN'
1116 ],
1117 [
1118 'key' => '_payment_reminder_sent',
1119 'compare' => 'NOT EXISTS'
1120 ]
1121 ]
1122 ]);
1123
1124 if (!empty($pending_invoices)) {
1125 // Get currency settings
1126 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1127 $settings = $settings_controller->getSettings();
1128 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
1129 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1130
1131 foreach ($pending_invoices as $post) {
1132 $invoice = new Invoice($post);
1133
1134 if (!$invoice || !$invoice->getId()) {
1135 continue;
1136 }
1137
1138 // Use EmailManager to send payment reminder
1139 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1140 $result = $email_manager->sendInvoiceEmail($invoice, 'reminder', [
1141 'payment_method' => get_post_meta($invoice->getId(), '_payment_method', true)
1142 ]);
1143
1144 // Mark reminder as sent if email was sent successfully
1145 if ($result['success']) {
1146 update_post_meta($invoice->getId(), '_payment_reminder_sent', current_time('mysql'));
1147 }
1148 }
1149
1150 wp_reset_postdata();
1151 }
1152 }
1153
1154 /**
1155 * Submit manual payment
1156 */
1157 public function submitManualPayment(): void {
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')) {
1163 wp_send_json_error(['message' => __('Security check failed', 'easy-invoice')]);
1164 return;
1165 }
1166
1167 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1168 $payment_type = isset($_POST['payment_type']) ? sanitize_text_field($_POST['payment_type']) : '';
1169 $payment_notes = isset($_POST['payment_notes']) ? sanitize_textarea_field($_POST['payment_notes']) : '';
1170
1171 if (!$invoice_id || !$payment_type) {
1172 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
1173 return;
1174 }
1175
1176 // Get invoice
1177 $invoice_post = get_post($invoice_id);
1178 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1179 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
1180 return;
1181 }
1182
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 }
1197 $currency_code = $invoice->getCurrencyCode() ?: 'USD';
1198 if ($currency_code === 'global') {
1199 $currency_code = get_option('easy_invoice_currency_code', 'USD');
1200 }
1201 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1202
1203 // Handle file upload (never trust client MIME or filename extension — use WordPress filetype APIs)
1204 $proof_url = '';
1205 if (isset($_FILES['payment_proof']) && $_FILES['payment_proof']['error'] === UPLOAD_ERR_OK) {
1206 $file = $_FILES['payment_proof'];
1207
1208 if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
1209 wp_send_json_error(['message' => __('Invalid upload.', 'easy-invoice')]);
1210 return;
1211 }
1212
1213 $max_size = 5 * 1024 * 1024; // 5MB
1214 if ($file['size'] > $max_size) {
1215 wp_send_json_error(['message' => __('File size must be less than 5MB.', 'easy-invoice')]);
1216 return;
1217 }
1218
1219 $allowed_mimes = [
1220 'jpg|jpeg|jpe' => 'image/jpeg',
1221 'png' => 'image/png',
1222 'gif' => 'image/gif',
1223 'pdf' => 'application/pdf',
1224 ];
1225
1226 $checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowed_mimes);
1227 if (empty($checked['ext']) || empty($checked['type'])) {
1228 wp_send_json_error(['message' => __('Invalid file type. Only JPG, PNG, GIF, and PDF files are allowed.', 'easy-invoice')]);
1229 return;
1230 }
1231
1232 $allowed_types = array_values($allowed_mimes);
1233 if (!in_array($checked['type'], $allowed_types, true)) {
1234 wp_send_json_error(['message' => __('Invalid file type. Only JPG, PNG, GIF, and PDF files are allowed.', 'easy-invoice')]);
1235 return;
1236 }
1237
1238 $upload_dir = wp_upload_dir();
1239 $proof_dir = $upload_dir['basedir'] . '/easy-invoice/payment-proofs/';
1240
1241 if (!wp_mkdir_p($proof_dir)) {
1242 wp_send_json_error(['message' => __('Could not create upload directory.', 'easy-invoice')]);
1243 return;
1244 }
1245
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);
1273
1274 if (!is_array($moved) || !empty($moved['error']) || empty($moved['url'])) {
1275 wp_send_json_error(['message' => __('Failed to save payment proof file.', 'easy-invoice')]);
1276 return;
1277 }
1278 $proof_url = $moved['url'];
1279 }
1280
1281 // Create payment record
1282 $payment_data = [
1283 'post_title' => sprintf('Manual Payment (%s) for Invoice #%s', ucfirst($payment_type), $invoice->getNumber()),
1284 'post_type' => 'easy_invoice_payment',
1285 'post_status' => 'publish',
1286 'post_author' => get_current_user_id(),
1287 ];
1288
1289 $payment_id = wp_insert_post($payment_data);
1290
1291 if (is_wp_error($payment_id)) {
1292 wp_send_json_error(['message' => __('Failed to create payment record', 'easy-invoice')]);
1293 return;
1294 }
1295
1296 // Save payment metadata
1297 update_post_meta($payment_id, '_invoice_id', $invoice_id);
1298 update_post_meta($payment_id, '_amount', $invoice->getTotal());
1299 update_post_meta($payment_id, '_payment_method', 'manual');
1300 update_post_meta($payment_id, '_payment_type', $payment_type);
1301 update_post_meta($payment_id, '_status', 'pending');
1302 update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time());
1303 update_post_meta($payment_id, '_payment_date', current_time('mysql'));
1304 update_post_meta($payment_id, '_notes', $payment_notes);
1305 update_post_meta($payment_id, '_currency', $currency_code);
1306 update_post_meta($payment_id, '_currency_symbol', $currency_symbol);
1307 update_post_meta($payment_id, '_payment_proof', $proof_url);
1308
1309 // Update invoice status to pending verification
1310 $invoice->setStatus('pending_verification');
1311 $invoice->save();
1312
1313 // Store payment details on invoice
1314 $invoice->setMeta('_payment_method', 'manual');
1315 $invoice->setMeta('_payment_type', $payment_type);
1316 $invoice->setMeta('_payment_status', 'pending');
1317 $invoice->setMeta('_manual_payment_id', $payment_id);
1318 $invoice->setMeta('_manual_payment_proof', $proof_url);
1319 $invoice->setMeta('_manual_payment_notes', $payment_notes);
1320
1321 // Send admin notification
1322 do_action('easy_invoice_manual_payment_submitted', $invoice_id, $payment_type);
1323
1324 wp_send_json_success([
1325 'message' => __('Payment submitted successfully! Your payment will be verified by the administrator.', 'easy-invoice'),
1326 'payment_id' => $payment_id
1327 ]);
1328 }
1329
1330 /**
1331 * Handle submission of payment proof for manual gateways (Bank Transfer, Cheque)
1332 */
1333 public function submitPaymentProof(): void {
1334 $gateway_name = isset($_POST['gateway']) ? sanitize_text_field($_POST['gateway']) : '';
1335 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1336
1337 if (empty($gateway_name) || empty($invoice_id)) {
1338 wp_send_json_error(['message' => __('Invalid request. Missing gateway or invoice ID.', 'easy-invoice')]);
1339 return;
1340 }
1341
1342 // Nonce verification (make nonce name consistent or check based on gateway)
1343 $nonce_action = 'easy_invoice_payment_proof_' . $invoice_id; // Bank transfer nonce
1344 $nonce_value = isset($_POST['payment_proof_nonce']) ? sanitize_text_field($_POST['payment_proof_nonce']) : '';
1345 if ($gateway_name === 'cheque') {
1346 $nonce_action = 'easy_invoice_cheque_notification_' . $invoice_id; // Cheque nonce
1347 $nonce_value = isset($_POST['cheque_notification_nonce']) ? sanitize_text_field($_POST['cheque_notification_nonce']) : '';
1348 }
1349
1350 if (!wp_verify_nonce($nonce_value, $nonce_action)) {
1351 wp_send_json_error(['message' => __('Nonce verification failed. Please try again.', 'easy-invoice')]);
1352 return;
1353 }
1354
1355 // Optional: Add capability check if this can be submitted by logged-in users only from frontend
1356 // if (is_user_logged_in() && !current_user_can('read_invoice', $invoice_id)) { // Example capability
1357 // wp_send_json_error(['message' => __('You do not have permission to submit proof for this invoice.', 'easy-invoice')]);
1358 // return;
1359 // }
1360
1361 $gateway = $this->gatewayManager->getGateway($gateway_name);
1362
1363 if (!$gateway || !method_exists($gateway, 'handleProofSubmission')) {
1364 wp_send_json_error(['message' => __('Invalid payment gateway or submission handler not found.', 'easy-invoice')]);
1365 return;
1366 }
1367
1368 // Prepare data for the gateway handler
1369 $post_data = stripslashes_deep($_POST);
1370 $files_data = $_FILES;
1371
1372 $result = $gateway->handleProofSubmission($post_data, $files_data);
1373
1374 if ($result['success']) {
1375 wp_send_json_success(['message' => $result['message']]);
1376 } else {
1377 wp_send_json_error(['message' => $result['message']]);
1378 }
1379 }
1380
1381 /**
1382 * AJAX handler for admin to mark an invoice as paid.
1383 */
1384 public function mark_invoice_paid_ajax(): void {
1385 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1386 $nonce = isset($_POST['nonce']) ? sanitize_text_field($_POST['nonce']) : '';
1387 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
1388
1389 if (empty($invoice_id) || !wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1390 easy_invoice_toast_error(__('Invalid request or security check failed.', 'easy-invoice'));
1391 return;
1392 }
1393
1394 // Mark-as-paid is a record-payment action — gated by the matching cap.
1395 if (!easy_invoice_user_can('ei_record_payment')) {
1396 easy_invoice_toast_error(__('You do not have permission to perform this action.', 'easy-invoice'));
1397 return;
1398 }
1399
1400 $invoice_post = get_post($invoice_id);
1401 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1402 wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
1403 return;
1404 }
1405
1406 $invoice = new Invoice($invoice_post);
1407 // For manual approval, always use 'manual' as payment method
1408 $payment_method = 'manual';
1409
1410 // Update invoice post status to 'publish' (or your primary paid status)
1411 wp_update_post(['ID' => $invoice_id, 'post_status' => 'publish']);
1412 update_post_meta($invoice_id, '_payment_status', 'completed'); // General completed status for payments
1413
1414 // Allow plugins to control invoice status update
1415 $should_update_invoice_status = apply_filters('easy_invoice_should_update_invoice_status', true, $invoice_id);
1416 if ($should_update_invoice_status) {
1417 update_post_meta($invoice_id, InvoiceFields::STATUS, 'paid'); // Specific invoice status field if used by model
1418 }
1419
1420 // Use submitted notes or default note
1421 $payment_notes = !empty($notes)
1422 ? $notes
1423 : __('Payment manually verified by admin.', 'easy-invoice');
1424
1425 // Find existing pending payment records for this invoice
1426 $existing_payment_args = [
1427 'post_type' => 'easy_invoice_payment',
1428 'posts_per_page' => 1,
1429 'meta_query' => [
1430 'relation' => 'AND',
1431 [
1432 'key' => '_invoice_id',
1433 'value' => $invoice_id,
1434 ],
1435 [
1436 'key' => '_status',
1437 'value' => ['pending-bank', 'pending-cheque', 'pending'], // Check against pending statuses
1438 'compare' => 'IN'
1439 ]
1440 ]
1441 ];
1442 $existing_payments = get_posts($existing_payment_args);
1443 $payment_id = null;
1444
1445 if (!empty($existing_payments)) {
1446 // Update existing pending payment instead of creating new one
1447 $payment_id = $existing_payments[0]->ID;
1448 update_post_meta($payment_id, '_status', 'completed'); // Update status to completed
1449 update_post_meta($payment_id, '_payment_method', 'manual'); // Set payment method to manual
1450 update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time());
1451 update_post_meta($payment_id, '_payment_date', current_time('mysql'));
1452 update_post_meta($payment_id, '_notes', $payment_notes); // Update notes on existing payment
1453 } else {
1454 // Only create a new payment if no pending payments exist
1455 // This prevents creating duplicate payment records
1456 $existing_payments = get_posts([
1457 'post_type' => 'easy_invoice_payment',
1458 'posts_per_page' => -1,
1459 'meta_query' => [
1460 [
1461 'key' => '_invoice_id',
1462 'value' => $invoice_id,
1463 ]
1464 ]
1465 ]);
1466
1467 if (!empty($existing_payments)) {
1468 // If payments exist but none are pending, don't create a new one
1469 // Just update the invoice status
1470 easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
1471 return;
1472 }
1473
1474 // Get currency from invoice
1475 $currency_code = get_post_meta($invoice_id, '_easy_invoice_currency_code', true);
1476 if (empty($currency_code) || $currency_code === 'global') {
1477 $currency_code = get_option('easy_invoice_currency_code', 'USD');
1478 }
1479 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1480
1481 $payment_data = [
1482 'invoice_id' => $invoice_id,
1483 'amount' => $invoice->getTotal(), // Or get amount from proof submission if it varies
1484 'payment_method' => $payment_method,
1485 'status' => 'completed',
1486 'transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id,
1487 'payment_date' => current_time('mysql'),
1488 'notes' => $payment_notes, // Use provided notes
1489 'payment_type' => 'manual',
1490 'currency' => $currency_code,
1491 'currency_symbol' => $currency_symbol,
1492 'gateway_response' => json_encode([
1493 'admin_verified' => true,
1494 'user' => get_current_user_id(),
1495 'verification_date' => current_time('mysql'),
1496 'notes' => $payment_notes // Store notes in response JSON as well
1497 ])
1498 ];
1499 try {
1500 // Create payment record using WordPress post creation
1501 $payment_post_data = [
1502 'post_title' => sprintf('Manual Payment for Invoice #%s', $invoice->getNumber()),
1503 'post_type' => 'easy_invoice_payment',
1504 'post_status' => 'publish',
1505 'post_author' => get_current_user_id(),
1506 'meta_input' => [
1507 '_invoice_id' => $invoice_id,
1508 '_amount' => $invoice->getTotal(),
1509 '_payment_method' => $payment_method,
1510 '_status' => 'completed',
1511 '_transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id,
1512 '_payment_date' => current_time('mysql'),
1513 '_notes' => $payment_notes,
1514 '_payment_type' => 'manual',
1515 '_currency' => $currency_code,
1516 '_currency_symbol' => $currency_symbol,
1517 '_gateway_response' => json_encode([
1518 'admin_verified' => true,
1519 'user' => get_current_user_id(),
1520 'verification_date' => current_time('mysql'),
1521 'notes' => $payment_notes
1522 ])
1523 ]
1524 ];
1525
1526 $payment_id = wp_insert_post($payment_post_data);
1527 if (is_wp_error($payment_id)) {
1528 easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $payment_id->get_error_message());
1529 return;
1530 }
1531 } catch (\Exception $e) {
1532 easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $e->getMessage());
1533 return;
1534 }
1535 }
1536
1537 // Store payment details before updating status (for the hook)
1538 $transaction_id = get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id;
1539 $invoice->setMeta('_payment_method', $payment_method);
1540 $invoice->setMeta('_transaction_id', $transaction_id);
1541
1542 // Update invoice status to paid
1543 // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification
1544 $invoice->setStatus('paid');
1545 $invoice->save();
1546
1547 // Trigger the payment completed hook manually since we're updating status directly
1548 do_action('easy_invoice_payment_completed', $invoice_id, $invoice, [
1549 'payment_method' => $payment_method,
1550 'gateway_name' => 'manual',
1551 'transaction_id' => $transaction_id,
1552 'amount' => $invoice->getTotal()
1553 ]);
1554
1555 // Trigger email confirmation and actions only if we have a payment_id
1556 if ($payment_id) {
1557 // Send confirmation email to customer
1558 $this->sendPaymentConfirmationEmail($invoice_id, $payment_id);
1559 do_action('easy_invoice_manual_payment_confirmed', $invoice_id, $payment_id, $payment_method);
1560 }
1561
1562 easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
1563 }
1564
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 /**
1667 * Handle bulk actions for payments
1668 */
1669 public function handleBulkActions() {
1670 // Check if we're processing a bulk action
1671 if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_payment_bulk_action') {
1672 return;
1673 }
1674
1675 // Check nonce and capability
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'));
1678 }
1679
1680 // Bulk action on payments — record-payment cap is the right gate
1681 // (covers trash/restore/delete which all change payment state).
1682 if (!easy_invoice_user_can('ei_record_payment')) {
1683 wp_die(esc_html__('You do not have permission to perform this action.', 'easy-invoice'));
1684 }
1685
1686 // Check if we have payment IDs
1687 if (!isset($_POST['payment_ids']) || !is_array($_POST['payment_ids']) || empty($_POST['payment_ids'])) {
1688 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection'));
1689 exit;
1690 }
1691
1692 // Get bulk action and payment IDs
1693 $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
1694 $payment_ids = array_map('intval', $_POST['payment_ids']);
1695
1696 // Process based on action
1697 $processed = 0;
1698 $invoice_updates = array(); // Track invoice updates needed
1699
1700 switch ($bulk_action) {
1701 case 'trash':
1702 foreach ($payment_ids as $id) {
1703 // Get payment info before trashing for invoice status update
1704 $payment_post = get_post($id);
1705 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
1706 continue;
1707 }
1708 $payment = new Payment($payment_post);
1709 $payment_status = $payment->getStatus();
1710 $invoice_id = $payment->getInvoiceId();
1711 $payment_amount = $payment->getAmount();
1712
1713 if (wp_trash_post($id)) {
1714 $processed++;
1715
1716 // Track invoice updates needed for completed payments
1717 if ($payment_status === 'completed' && $invoice_id) {
1718 if (!isset($invoice_updates[$invoice_id])) {
1719 $invoice_updates[$invoice_id] = 0;
1720 }
1721 $invoice_updates[$invoice_id] += $payment_amount;
1722 }
1723 }
1724 }
1725
1726 // Update invoice statuses for completed payments that were trashed
1727 foreach (array_keys($invoice_updates) as $invoice_id) {
1728 $this->syncInvoiceStatusWithPayments($invoice_id);
1729 }
1730
1731 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed));
1732 break;
1733
1734 case 'restore':
1735 foreach ($payment_ids as $id) {
1736 // Get payment info before restoring for invoice status update
1737 $payment_post = get_post($id);
1738 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
1739 continue;
1740 }
1741 $payment = new Payment($payment_post);
1742 $payment_status = $payment->getStatus();
1743 $invoice_id = $payment->getInvoiceId();
1744 $payment_amount = $payment->getAmount();
1745
1746 if (wp_untrash_post($id)) {
1747 // Also set status to publish (since WordPress sets it to draft by default)
1748 wp_update_post(array(
1749 'ID' => $id,
1750 'post_status' => 'publish'
1751 ));
1752 $processed++;
1753
1754 // Track invoice updates needed for completed payments
1755 if ($payment_status === 'completed' && $invoice_id) {
1756 if (!isset($invoice_updates[$invoice_id])) {
1757 $invoice_updates[$invoice_id] = 0;
1758 }
1759 $invoice_updates[$invoice_id] += $payment_amount;
1760 }
1761 }
1762 }
1763
1764 // Update invoice statuses for completed payments that were restored
1765 foreach (array_keys($invoice_updates) as $invoice_id) {
1766 $this->syncInvoiceStatusWithPayments($invoice_id);
1767 }
1768
1769 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed));
1770 break;
1771
1772 case 'delete':
1773 foreach ($payment_ids as $id) {
1774 // Get payment info before deletion for invoice status update
1775 $payment_post = get_post($id);
1776 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
1777 continue;
1778 }
1779 $payment = new Payment($payment_post);
1780 $payment_status = $payment->getStatus();
1781 $invoice_id = $payment->getInvoiceId();
1782 $payment_amount = $payment->getAmount();
1783
1784 if (wp_delete_post($id, true)) {
1785 $processed++;
1786
1787 // Track invoice updates needed for completed payments
1788 if ($payment_status === 'completed' && $invoice_id) {
1789 if (!isset($invoice_updates[$invoice_id])) {
1790 $invoice_updates[$invoice_id] = 0;
1791 }
1792 $invoice_updates[$invoice_id] += $payment_amount;
1793 }
1794 }
1795 }
1796
1797 // Update invoice statuses for completed payments that were deleted
1798 foreach (array_keys($invoice_updates) as $invoice_id) {
1799 $this->syncInvoiceStatusWithPayments($invoice_id);
1800 }
1801
1802 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed));
1803 break;
1804
1805 default:
1806 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action'));
1807 }
1808
1809 exit;
1810 }
1811
1812 /**
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.
1821 */
1822 private function syncInvoiceStatusWithPayments($invoice_id) {
1823 $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find((int) $invoice_id);
1824 if (!$invoice || !$invoice->getId()) {
1825 return;
1826 }
1827 $current = (string) $invoice->getStatus();
1828 if (in_array($current, ['draft', 'cancelled', 'canceled'], true)) {
1829 return;
1830 }
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';
1836 } else {
1837 $new = in_array($current, ['unpaid', 'available'], true) ? $current : 'available';
1838 }
1839 if ($new !== $current) {
1840 $invoice->setStatus($new);
1841 $invoice->save();
1842 }
1843 }
1844
1845
1846 // Stripe payment recording moved to Pro plugin
1847
1848
1849 }
1850