PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.3
2.4.3 2.4.4 2.4.2 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 All 60 releases
easy-invoice / includes / Controllers / PaymentController.php

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

1,995 lines 89.5 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 add_action('wp_ajax_easy_invoice_reject_payment', [$this, 'rejectPayment']);
87 // Receipts clients attach to offline payments; staff-only, streamed by PHP.
88 add_action('admin_post_' . \EasyInvoice\Services\OfflinePayments::PROOF_ACTION, ['\\EasyInvoice\\Services\\OfflinePayments', 'serveProof']);
89 // Signed-out staff following the emailed link are sent to log in and back.
90 add_action('before_delete_post', ['\\EasyInvoice\\Services\\OfflinePayments', 'deleteProofWithPayment'], 10, 2);
91 add_action('admin_post_nopriv_' . \EasyInvoice\Services\OfflinePayments::PROOF_ACTION, ['\\EasyInvoice\\Services\\OfflinePayments', 'serveProof']);
92
93 // Stripe payment handlers moved to Pro plugin
94
95 add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
96
97 // Add reminder CRON job for pending payments
98 add_action('easy_invoice_payment_reminder', [$this, 'sendPaymentReminders']);
99 if (!wp_next_scheduled('easy_invoice_payment_reminder')) {
100 wp_schedule_event(time(), 'daily', 'easy_invoice_payment_reminder');
101 }
102
103 // Handle bulk actions
104 add_action('admin_init', [$this, 'handleBulkActions']);
105 }
106
107 /**
108 * Get payment instructions for manual gateways
109 */
110 public function getPaymentInstructions() {
111 // Verify nonce. $_POST['nonce'] was read unguarded, raising an
112 // undefined-index warning before the check could run.
113 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
114 if (!wp_verify_nonce($nonce, 'easy_invoice_payment')) {
115 wp_send_json_error(['message' => 'Security check failed']);
116 return;
117 }
118
119 $gateway = sanitize_text_field(($_POST['gateway'] ?? ''));
120 $invoice_id = intval(($_POST['invoice_id'] ?? ''));
121
122 if (!$gateway || !$invoice_id) {
123 wp_send_json_error(['message' => 'Missing required parameters']);
124 return;
125 }
126
127 // Get invoice
128 $invoice_post = get_post($invoice_id);
129 if (!$invoice_post || $invoice_post->post_type !== 'easy_invoice') {
130 wp_send_json_error(['message' => 'Invalid invoice']);
131 return;
132 }
133
134 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
135
136 // Authorisation.
137 //
138 // The previous guard here was `!easy_invoice_user_can('ei_view_invoices') &&
139 // $invoice_post->post_status !== 'publish'`. That never fired: Models\Invoice
140 // writes every invoice with post_status 'publish' regardless of workflow
141 // status, so the second condition was always false. This endpoint is
142 // registered nopriv and the nonce it checks is a shared, page-wide one, so
143 // any caller could read the rendered payment instructions — which include
144 // invoice-specific detail — for an arbitrary invoice id.
145 //
146 // Same check as everywhere else: valid ?ik= / access_token, administrator, or
147 // the signed-in client the invoice belongs to.
148 if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
149 wp_send_json_error(['message' => __('Invoice not found', 'easy-invoice')]);
150 return;
151 }
152
153 // Get gateway instance
154 $gateway_instance = $this->gatewayManager->getGateway($gateway);
155
156 if (!$gateway_instance) {
157 wp_send_json_error(['message' => 'Gateway not found']);
158 return;
159 }
160
161 // Get instructions using the hook system
162 ob_start();
163 do_action('easy_invoice_payment_gateways_after', $invoice, $gateway);
164 $instructions = ob_get_clean();
165
166 if ($instructions) {
167 wp_send_json_success(['instructions' => $instructions]);
168 } else {
169 wp_send_json_error(['message' => 'No instructions available']);
170 }
171 }
172
173 /**
174 * Enqueue admin assets
175 */
176 public function enqueueAssets() {
177 $screen = get_current_screen();
178 if (!$screen || !property_exists($screen, 'id') || strpos($screen->id, 'easy-invoice') === false) {
179 return;
180 }
181
182 // Enqueue manual payment script
183 wp_enqueue_script(
184 'easy-invoice-manual-payment',
185 EASY_INVOICE_PLUGIN_URL . 'assets/js/manual-payment.js',
186 ['jquery'],
187 '1.0.0',
188 true
189 );
190
191 // Localize script
192 wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [
193 'ajax_url' => admin_url('admin-ajax.php'),
194 'nonce' => wp_create_nonce('easy_invoice_payment')
195 ]);
196 }
197
198 /**
199 * Enqueue frontend assets
200 */
201 public function enqueueFrontendAssets() {
202 // Only load on invoice pages
203 if (is_singular('easy_invoice')) {
204 wp_enqueue_script(
205 'easy-invoice-manual-payment',
206 EASY_INVOICE_PLUGIN_URL . 'assets/js/manual-payment.js',
207 ['jquery'],
208 '1.0.0',
209 true
210 );
211
212 // Forward the per-invoice access token from the URL to the JS
213 // so the manual-payment AJAX request can present it back to
214 // canSubmitPaymentForInvoice. Without this the legitimate
215 // email-link recipient flow would break — they'd hit the gate.
216 $access_token = isset($_GET['ik'])
217 ? sanitize_text_field(wp_unslash($_GET['ik']))
218 : '';
219 /** This filter is documented in includes/Controllers/InvoiceController.php */
220 $access_token = (string) apply_filters('easy_invoice_presented_access_token', $access_token, 'invoice');
221
222 wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [
223 'ajax_url' => admin_url('admin-ajax.php'),
224 'nonce' => wp_create_nonce('easy_invoice_payment'),
225 'access_token' => $access_token,
226 ]);
227 }
228 }
229
230 /**
231 * Display method implementation
232 *
233 * @param array $args Display arguments
234 */
235 public function display(array $args = []) {
236 $page = isset($args['page']) ? $args['page'] : '';
237
238 switch ($page) {
239 case PagesSlugs::PAYMENTS:
240 $this->displayPaymentsPage();
241 break;
242
243 case PagesSlugs::PAYMENT_NEW:
244 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/new.php');
245 break;
246
247 case 'view':
248 $payment_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
249 if ($payment_id) {
250 $payment_post = get_post($payment_id);
251 if ($payment_post && $payment_post->post_type === 'easy_invoice_payment') {
252 try {
253 $payment = new Payment($payment_post);
254 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/view.php', ['payment' => $payment]);
255 } catch (\Exception $e) {
256 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
257 }
258 } else {
259 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
260 }
261 } else {
262 wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
263 }
264 break;
265
266 case 'edit':
267 $payment_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
268 if ($payment_id) {
269 $payment_post = get_post($payment_id);
270 if ($payment_post && $payment_post->post_type === 'easy_invoice_payment') {
271 try {
272 $payment = new Payment($payment_post);
273 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/edit.php', ['payment' => $payment]);
274 } catch (\Exception $e) {
275 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
276 }
277 } else {
278 wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
279 }
280 } else {
281 wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
282 }
283 break;
284
285 default:
286 $this->displayPaymentsPage();
287 break;
288 }
289 }
290
291 /**
292 * Display payments page with pagination
293 */
294 protected function displayPaymentsPage() {
295 // Get current view (all, trash)
296 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
297
298 // Get status filter
299 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
300
301 // Pagination settings
302 $per_page = 20;
303 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
304
305 // Build query arguments
306 $args = array(
307 'post_type' => 'easy_invoice_payment',
308 'posts_per_page' => $per_page,
309 'paged' => $current_page,
310 'orderby' => 'ID',
311 'order' => 'DESC',
312 'no_found_rows' => false, // We need this for pagination
313 );
314
315 // Set post status based on current view
316 if ($current_view === 'trash') {
317 $args['post_status'] = 'trash';
318 } else {
319 $args['post_status'] = 'publish';
320 }
321
322 // Add status filter if set
323 if (!empty($status_filter)) {
324 // "pending" covers every awaiting-confirmation variant older versions wrote.
325 $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
326 array(
327 'key' => '_status',
328 'value' => 'pending' === $status_filter ? \EasyInvoice\Services\OfflinePayments::pendingStatuses() : $status_filter,
329 'compare' => 'pending' === $status_filter ? 'IN' : '=',
330 ),
331 );
332 }
333
334 // Allow plugins to modify query arguments
335 $args = apply_filters('easy_invoice_payment_controller_query_args', $args, $current_view, $status_filter);
336
337
338 // Get paginated payments using WordPress query
339 $wp_query = new \WP_Query($args);
340
341
342 $payments = [];
343
344 if ($wp_query->have_posts()) {
345 while ($wp_query->have_posts()) {
346 $wp_query->the_post();
347 $post = get_post();
348 $payment = new Payment($post);
349 $payments[] = $payment;
350 }
351 }
352
353 wp_reset_postdata();
354
355 // Allow plugins to modify the payments array
356 $payments = apply_filters('easy_invoice_payment_controller_payments_list', $payments, $wp_query);
357
358 // Get pagination info from WordPress query
359 $total_payments = $wp_query->found_posts;
360 $total_pages = $wp_query->max_num_pages;
361
362 // Statistics over ALL payments (not just the current page), in one SQL
363 // pass. Loading every payment as a model to add them up did not scale.
364 global $wpdb;
365 $stats_status = 'trash' === $current_view ? 'trash' : 'publish';
366 $stat_rows = $wpdb->get_results( $wpdb->prepare(
367 "SELECT st.meta_value AS status, COUNT(*) AS n, SUM(CAST(COALESCE(NULLIF(a.meta_value, ''), '0') AS DECIMAL(18,4))) AS amount
368 FROM {$wpdb->posts} p
369 INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status'
370 LEFT JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = '_amount'
371 WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = %s
372 GROUP BY st.meta_value",
373 $stats_status
374 ), ARRAY_A );
375 $stats = [
376 'total_payments' => 0,
377 'total_amount' => 0,
378 'completed_payments' => 0,
379 'pending_payments' => 0,
380 'failed_payments' => 0,
381 ];
382 foreach ( (array) $stat_rows as $row ) {
383 $status = (string) $row['status'];
384 $stats['total_payments'] += (int) $row['n'];
385 // "Total amount" is money confirmed; submissions still waiting
386 // for a decision, rejected and failed ones are not counted.
387 if ( 'completed' === $status ) {
388 $stats['total_amount'] += (float) $row['amount'];
389 }
390 if ( in_array( $status, \EasyInvoice\Services\OfflinePayments::pendingStatuses(), true ) ) {
391 $status = 'pending';
392 }
393 $key = $status . '_payments';
394 if ( isset( $stats[ $key ] ) ) {
395 $stats[ $key ] += (int) $row['n'];
396 }
397 }
398
399 $stats = array_merge([
400 'total_payments' => 0,
401 'total_amount' => 0,
402 'completed_payments' => 0,
403 'pending_payments' => 0,
404 'failed_payments' => 0
405 ], $stats);
406
407 // Money received, by currency, across every confirmed payment — the
408 // header card used to add up only the rows on the page the admin
409 // happened to be looking at.
410 $currency_rows = $wpdb->get_results( $wpdb->prepare(
411 "SELECT UPPER(COALESCE(NULLIF(c.meta_value, ''), %s)) AS currency,
412 MAX(sym.meta_value) AS symbol,
413 SUM(CAST(COALESCE(NULLIF(a.meta_value, ''), '0') AS DECIMAL(18,4))) AS amount
414 FROM {$wpdb->posts} p
415 INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' AND st.meta_value = 'completed'
416 LEFT JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = '_amount'
417 LEFT JOIN {$wpdb->postmeta} c ON c.post_id = p.ID AND c.meta_key = '_currency'
418 LEFT JOIN {$wpdb->postmeta} sym ON sym.post_id = p.ID AND sym.meta_key = '_currency_symbol'
419 WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = %s
420 GROUP BY currency",
421 get_option('easy_invoice_currency_code', 'USD'),
422 $stats_status
423 ), ARRAY_A );
424 $amounts_by_currency = [];
425 foreach ( (array) $currency_rows as $row ) {
426 $code = 'GLOBAL' === $row['currency'] || '' === (string) $row['currency']
427 ? strtoupper( (string) get_option('easy_invoice_currency_code', 'USD') )
428 : (string) $row['currency'];
429 if ( ! isset( $amounts_by_currency[ $code ] ) ) {
430 $amounts_by_currency[ $code ] = [
431 'amount' => 0.0,
432 'symbol' => (string) ( $row['symbol'] ?: \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol( $code ) ),
433 ];
434 }
435 $amounts_by_currency[ $code ]['amount'] += (float) $row['amount'];
436 }
437
438 // Get trash count for tab display
439 $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'easy_invoice_payment' AND post_status = 'trash'" );
440
441 // Define available status filters
442 $status_filters = array(
443 'completed' => __('Completed', 'easy-invoice'),
444 'pending' => __('Awaiting confirmation', 'easy-invoice'),
445 'rejected' => __('Rejected', 'easy-invoice'),
446 'failed' => __('Failed', 'easy-invoice'),
447 );
448
449 // Prepare template data
450 $template_data = [
451 'payments' => $payments,
452 'current_view' => $current_view,
453 'status_filter' => $status_filter,
454 'status_filters' => $status_filters,
455 'trash_count' => $trash_count,
456 'stats' => $stats,
457 'amounts_by_currency' => $amounts_by_currency,
458 'current_page' => $current_page,
459 'per_page' => $per_page,
460 'total_payments' => $total_payments,
461 'total_pages' => $total_pages,
462 'wp_query' => $wp_query
463 ];
464
465 // Allow plugins to modify template data
466 $template_data = apply_filters('easy_invoice_payment_controller_template_data', $template_data);
467
468 // Display the template
469 $this->displayTemplate(
470 EASY_INVOICE_PLUGIN_DIR . 'templates/payments/list.php',
471 $template_data
472 );
473
474 // Allow plugins to perform actions after displaying payments page
475 do_action('easy_invoice_payment_controller_after_display_payments_page', $template_data);
476 }
477
478 /**
479 * Enqueue required scripts and styles
480 */
481 public function enqueueScripts(): void {
482 // Check if scripts are already enqueued
483 if (wp_script_is('easy-invoice-payment', 'enqueued')) {
484 return;
485 }
486 // The payment panel exists on the public invoice page only; every
487 // other front-end page of the site has no use for the script (or the
488 // jQuery it pulls in).
489 /**
490 * Filter whether the payment script loads on the current front-end request.
491 *
492 * @param bool $load Default: on a public invoice page.
493 */
494 if (!apply_filters('easy_invoice_load_payment_assets', is_singular(PostTypes::EASY_INVOICE_POST_TYPE))) {
495 return;
496 }
497
498 // Enqueue our custom scripts
499 wp_enqueue_script(
500 'easy-invoice-payment',
501 EASY_INVOICE_URL . 'assets/js/payment.js',
502 ['jquery'],
503 EASY_INVOICE_VERSION,
504 true
505 );
506
507 // Get currency settings
508 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
509 $settings = $settings_controller->getSettings();
510 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
511 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
512
513 // Localize script variables for payment form
514 // Forward the per-invoice access token (?ik=...) the same way the manual
515 // payment script already does. The payment endpoints authorise on it, and
516 // without this an anonymous client following an emailed link would have a
517 // token in their URL that never reached the AJAX request.
518 $ei_access_token = isset($_GET['ik'])
519 ? sanitize_text_field(wp_unslash($_GET['ik']))
520 : '';
521 /** This filter is documented in includes/Controllers/InvoiceController.php */
522 $ei_access_token = (string) apply_filters('easy_invoice_presented_access_token', $ei_access_token, 'invoice');
523
524 wp_localize_script('easy-invoice-payment', 'easy_invoice_vars', [
525 'ajax_url' => admin_url('admin-ajax.php'),
526 'nonce' => wp_create_nonce('easy_invoice_payment'),
527 'access_token' => $ei_access_token,
528 'currency_symbol' => $currency_symbol,
529 'currency_code' => $currency_code
530 ]);
531 }
532
533 // Stripe methods moved to Pro plugin
534
535 /**
536 * Process payment via AJAX
537 */
538 public function processPayment() {
539 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
540
541 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
542 $payment_method_slug = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
543
544 $invoice_post = $invoice_id ? get_post($invoice_id) : null;
545 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
546 wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
547 return;
548 }
549
550 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
551
552 // Authorisation. This endpoint is nopriv and previously relied on a shared,
553 // page-wide nonce alone, so a caller holding one could start a payment
554 // against any invoice id and read back its amount and gateway details.
555 // Legitimate callers reach this from the invoice page, which forwards the
556 // per-invoice access token (see payment.js / payment-section.php).
557 //
558 // This MUST stay above the `easy_invoice_before_process_payment` filter
559 // below. That filter is not a notification — it is a dispatch point that
560 // short-circuits the whole request, and Easy Invoice Pro attaches four
561 // handlers to it (Stripe, Authorize.Net, Moneris and Partial Payments).
562 // While the check sat after the filter, those four gateways — every card
563 // gateway Pro ships — completed payments without the token ever being
564 // examined, so the gate only really covered the free plugin's own
565 // gateways. Authorising before dispatch is the whole point of the gate.
566 if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
567 // A Pro build older than this plugin cannot forward the token — see
568 // legacyProPaymentFallbackAllowed(). Refusing here would take the
569 // customer's money on Stripe without recording the payment.
570 if (!$this->legacyProPaymentFallbackAllowed($payment_method_slug)) {
571 wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
572 return;
573 }
574 }
575
576 // Add filter for extensions to handle custom payment logic (e.g., partial payments)
577 $custom_result = apply_filters('easy_invoice_before_process_payment', null, $invoice_id, $_POST);
578
579 if (is_array($custom_result) && isset($custom_result['handled']) && $custom_result['handled']) {
580 if ($custom_result['success']) {
581 wp_send_json_success($custom_result);
582 } else {
583 wp_send_json_error(['message' => $custom_result['message'] ?? __('Payment failed.', 'easy-invoice')]);
584 }
585 return;
586 }
587
588 if (!$payment_method_slug) {
589 wp_send_json_error(['message' => __('Missing required fields.', 'easy-invoice')]);
590 return;
591 }
592
593 // Charge what is owed, not the face value: a part-paid or partly
594 // credited invoice must not be collected twice.
595 $due = \EasyInvoice\Services\InvoiceBalance::due($invoice);
596 $amount = $due;
597 if ($due <= 0) {
598 wp_send_json_error(['message' => __('Nothing is owed on this invoice.', 'easy-invoice')]);
599 return;
600 }
601
602 // A smaller amount is charged only when something (the Partial
603 // Payments addon) says this invoice may be paid in instalments.
604 $requested = isset($_POST['payment_amount']) ? round((float) str_replace(',', '', sanitize_text_field(wp_unslash($_POST['payment_amount']))), 2) : 0.0;
605 $is_partial = isset($_POST['is_partial_payment']) && '1' === (string) sanitize_text_field(wp_unslash($_POST['is_partial_payment']));
606 if ($is_partial && $requested > 0 && $requested < $due) {
607 /**
608 * Filter whether the client may pay less than the amount due.
609 *
610 * @param bool $allow Default false.
611 * @param object $invoice Invoice model.
612 * @param float $requested Amount the client asked to pay.
613 */
614 if (apply_filters('easy_invoice_allow_partial_payment_amount', false, $invoice, $requested)) {
615 $amount = $requested;
616 }
617 }
618
619 $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug);
620
621 if (!$gateway_instance || !$gateway_instance->isEnabled() || !$gateway_instance->isAvailable()) {
622 wp_send_json_error(['message' => __('Selected payment gateway is not available or configured correctly.', 'easy-invoice')]);
623 return;
624 }
625
626 try {
627 // Pass the entire $_POST array to the gateway
628 $result = $gateway_instance->processPayment($amount, $_POST);
629
630 if (isset($result['success']) && $result['success']) {
631 // An offline gateway with no follow-up step (cash, the free
632 // manual gateway) leaves the invoice pending here; bank
633 // transfer and cheque notify the admin themselves once the
634 // proof or cheque details arrive.
635 // Offline gateways (OfflineGateway) record their own pending
636 // payment and fire easy_invoice_manual_payment_submitted with
637 // the record's id; nothing to add here.
638 wp_send_json_success($result);
639 } else {
640 wp_send_json_error(['message' => $result['message'] ?? __('Payment processing failed with the gateway.', 'easy-invoice')]);
641 }
642
643 } catch (\Exception $e) {
644 error_log('Easy Invoice Payment Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ' on line ' . $e->getLine());
645 wp_send_json_error(['message' => __('An unexpected error occurred during payment processing. Please check plugin logs or contact support.', 'easy-invoice')]);
646 }
647 }
648
649 /**
650 * Whether to accept a payment that presented no per-invoice access token,
651 * because the Easy Invoice Pro build installed alongside cannot send one.
652 *
653 * Why this exists
654 * ---------------
655 * Pro's Stripe and Authorize.Net scripts post to `easy_invoice_process_payment`,
656 * which is a free-plugin endpoint, and from 2.4.0 that endpoint authorises on the
657 * per-invoice access token. Pro only began forwarding the token in 2.3.0.
658 *
659 * The two plugins update through different channels — free auto-updates from
660 * WordPress.org, Pro arrives from the licence server — so "free is newer than Pro"
661 * is not an edge case, it is the normal state for a while after release. Without
662 * this fallback, that pairing breaks client payments, and for Stripe it breaks them
663 * in the worst possible way: the script confirms the charge with Stripe FIRST and
664 * only then posts here to record it, so a refusal means the customer has paid and
665 * the invoice still says unpaid.
666 *
667 * What it does and does not allow
668 * -------------------------------
669 * The relaxation is deliberately narrow, and is never wider than the behaviour
670 * that already shipped in 2.3.8:
671 *
672 * - Only when Pro is active AND older than 2.3.0. It disappears by itself the
673 * moment Pro is updated; there is nothing to remember to turn off.
674 * - Only when NO token was presented at all. A request carrying a wrong or
675 * expired token is a forgery attempt, not an old client script, and is refused.
676 * - Only for gateways provided by Pro. The free plugin's own scripts always
677 * forward the token, so a free gateway reaching here without one is not a
678 * version-skew case.
679 * - The shared `easy_invoice_payment` nonce has already been verified by the
680 * caller before this is consulted.
681 * - `getPaymentInstructions()` does NOT use this. That is the information
682 * disclosure path and stays fully gated regardless of Pro's version.
683 *
684 * Site owners who would rather fail the payment than accept the older
685 * authorisation can return false from
686 * `easy_invoice_allow_legacy_pro_payment_fallback`.
687 *
688 * @param string $payment_method_slug Gateway slug from the request.
689 * @return bool
690 */
691 private function legacyProPaymentFallbackAllowed(string $payment_method_slug): bool {
692 if (!function_exists('easy_invoice_has_pro') || !easy_invoice_has_pro()) {
693 return false;
694 }
695
696 // An older Pro that predates token forwarding. Treat a missing version
697 // constant as "older", since every build that defines it is >= 2.1.
698 $pro_version = defined('EASY_INVOICE_PRO_VERSION') ? (string) EASY_INVOICE_PRO_VERSION : '0';
699 if (version_compare($pro_version, self::PRO_TOKEN_FORWARDING_VERSION, '>=')) {
700 return false;
701 }
702
703 // A presented-but-invalid token is an attack, not version skew.
704 if (isset($_POST['access_token']) && $_POST['access_token'] !== '') {
705 return false;
706 }
707 if (isset($_GET['ik']) && $_GET['ik'] !== '') {
708 return false;
709 }
710
711 // Restrict to gateways Pro actually provides.
712 $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug);
713 if (!$gateway_instance || strpos(get_class($gateway_instance), 'EasyInvoicePro\\') !== 0) {
714 return false;
715 }
716
717 /**
718 * Filter the legacy Pro payment fallback.
719 *
720 * @param bool $allowed Whether to accept the payment.
721 * @param string $pro_version Version of Easy Invoice Pro detected.
722 * @param string $payment_method_slug Gateway slug from the request.
723 */
724 $allowed = (bool) apply_filters(
725 'easy_invoice_allow_legacy_pro_payment_fallback',
726 true,
727 $pro_version,
728 $payment_method_slug
729 );
730
731 if ($allowed) {
732 update_option('easy_invoice_legacy_pro_payment_seen', $pro_version, false);
733 }
734
735 return $allowed;
736 }
737
738 /**
739 * Handle payment callback/webhook
740 */
741 public function handleCallback(): void {
742 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
743
744 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
745 $gateway = isset($_POST['gateway']) ? sanitize_text_field($_POST['gateway']) : '';
746
747 if (!$invoice_id || !$gateway) {
748 wp_send_json_error(['message' => __('Invalid request', 'easy-invoice')]);
749 }
750
751 // Authorisation.
752 //
753 // This endpoint is registered nopriv and the only thing standing in front of
754 // it was the shared, page-wide `easy_invoice_payment` nonce, which is rendered
755 // on every public invoice page — so anyone able to view a single invoice could
756 // lift one and then call this for any id they liked. The id was passed straight
757 // to the gateway without even confirming it was an invoice.
758 //
759 // That mattered because the cheque gateway's callback writes: it stores the
760 // cheque number, bank name, date and an uploaded image against whatever id it
761 // is handed. An unauthenticated caller could therefore attach forged cheque
762 // details, and a file, to any invoice on the site — or to any post at all.
763 //
764 // Same rule as everywhere else: valid per-invoice access key, administrator, or
765 // the signed-in client the invoice belongs to.
766 $invoice_post = get_post($invoice_id);
767 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
768 wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
769 }
770
771 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
772 if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)
773 && !$this->legacyProPaymentFallbackAllowed($gateway)) {
774 wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
775 }
776
777 $gateway_instance = $this->gatewayManager->getGateway($gateway);
778 if (!$gateway_instance) {
779 wp_send_json_error(['message' => __('Invalid payment gateway', 'easy-invoice')]);
780 }
781
782 $result = $gateway_instance->handleCallback($_POST);
783
784 // Tell the admin an offline payment is waiting for verification. Pro's
785 // bank-transfer and cheque gateways email the admin themselves from
786 // handleCallback(); the free manual gateway and Pro's cash gateway do
787 // not. (This used to test for 'bank' and 'cheque' — ids no gateway
788 // has — so it never fired.)
789 if ($result['success'] && in_array($gateway, ['manual', 'cash'], true)) {
790 do_action('easy_invoice_manual_payment_submitted', $invoice_id, $gateway);
791 }
792
793 if ($result['success']) {
794 wp_send_json_success($result);
795 } else {
796 wp_send_json_error($result);
797 }
798 }
799
800 /**
801 * Get available payment gateways for an invoice
802 *
803 * @param int $invoice_id
804 * @return array
805 */
806 public function getAvailableGateways(int $invoice_id): array {
807 $post = get_post($invoice_id);
808 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
809 return [];
810 }
811
812 $invoice = new \EasyInvoice\Models\Invoice($post);
813 $invoice_status = $invoice->getStatus();
814
815 // Anything that still has a balance can be paid: an overdue invoice is the
816 // one a client most needs to settle, and a partially paid one still owes.
817 // Drafts, paid, cancelled and "awaiting verification" stay closed.
818 $payable_statuses = apply_filters('easy_invoice_payable_statuses', [ 'unpaid', 'available', 'overdue', 'partial', 'sent', 'pending' ]);
819 if (!in_array($invoice_status, $payable_statuses, true)) {
820 return [];
821 }
822
823 $enabled_gateways = $this->gatewayManager->getEnabledGateways();
824
825 if (empty($enabled_gateways)) {
826 return [];
827 }
828
829 // Get invoice-specific gateways (comma-separated string or empty)
830 $invoice_gateways = $invoice->getPaymentGateways();
831 $selected_gateways = [];
832
833 // Handle both string and array formats
834 if (!empty($invoice_gateways)) {
835 if (is_string($invoice_gateways)) {
836 // If it's a string, split by comma
837 $selected_gateways = array_filter(array_map('trim', explode(',', $invoice_gateways)));
838 } elseif (is_array($invoice_gateways)) {
839 // If it's already an array, use it directly
840 $selected_gateways = array_filter($invoice_gateways);
841 }
842 }
843
844 $available_gateways = [];
845 $gateway_manager = \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager();
846
847 // An invoice saved before 2.4.2 could name the old "manual" gateway;
848 // that meant the offline methods, which are gateways of their own now.
849 if (!empty($selected_gateways) && in_array('manual', $selected_gateways, true)) {
850 $selected_gateways = array_values(array_unique(array_merge(
851 array_diff($selected_gateways, ['manual']),
852 \EasyInvoice\Services\OfflinePayments::ids()
853 )));
854 }
855
856 // $enabled_gateways is an associative array with gateway_id as key and gateway object as value
857 foreach ($enabled_gateways as $gateway_id => $gateway) {
858 // If invoice has custom gateways selected, only show those
859 // If no custom gateways are selected (empty array), show all enabled gateways
860 if (!empty($selected_gateways) && !in_array($gateway_id, $selected_gateways, true)) {
861 continue;
862 }
863
864 $is_available = $gateway->isAvailable();
865
866 if ($is_available) {
867 $available_gateways[] = [
868 'id' => $gateway_id,
869 'title' => $gateway_manager->getGatewayDisplayName($gateway_id),
870 'icon' => $gateway->getIcon(),
871 'description' => $gateway->getDescription()
872 ];
873 }
874 }
875
876 return $available_gateways;
877 }
878
879 /**
880 * Update payment via AJAX
881 */
882 public function updatePayment() {
883 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
884
885 // Authorisation: this handler mutates payment-record fields
886 // (amount, method, status, notes) and on status=completed it
887 // can flip the linked invoice to paid via
888 // updateInvoiceStatusIfPaid(). The shared `easy_invoice_payment`
889 // nonce is rendered on every public invoice page so any
890 // authenticated visitor can obtain a valid one — the nonce is
891 // CSRF defense, NOT authorisation. Gate on the same payment-
892 // management capability as the sibling verifyManualPayment /
893 // rejectManualPayment / mark_invoice_paid_ajax handlers.
894 if (!easy_invoice_user_can('ei_record_payment')) {
895 wp_send_json_error(['message' => __('You do not have permission to update payments.', 'easy-invoice')]);
896 return;
897 }
898
899 $payment_id = isset($_POST['payment_id']) ? intval($_POST['payment_id']) : 0;
900 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
901 $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
902 $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
903 $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : current_time('Y-m-d');
904 $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'pending';
905 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
906
907 if (!$payment_id || !$invoice_id || !$amount || !$payment_method) {
908 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
909 return;
910 }
911
912 try {
913 // Check if payment post exists before instantiating
914 $payment_post = get_post($payment_id);
915 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
916 wp_send_json_error(['message' => __('Invalid payment', 'easy-invoice')]);
917 return;
918 }
919
920 $payment = new Payment($payment_post);
921
922 // Get the old payment status before updating
923 $old_status = $payment->getStatus();
924
925 $post = get_post($invoice_id);
926 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
927 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
928 return;
929 }
930 $invoice = new Invoice($post);
931
932 $payment_data = [
933 'invoice_id' => $invoice_id,
934 'amount' => $amount,
935 'payment_method' => $payment_method,
936 'payment_date' => $payment_date,
937 'status' => $status,
938 'notes' => $notes,
939 'gateway_response' => [
940 'method' => $payment_method,
941 'date' => $payment_date,
942 'notes' => $notes
943 ]
944 ];
945
946 $result = $payment->update($payment_data);
947
948 if ($result) {
949 // Update invoice status based on payment status change
950 if ($status === 'completed' && $old_status !== 'completed') {
951 // Payment changed TO completed - check if invoice should be marked as paid
952 $invoice->setMeta('_payment_method', $payment_method);
953 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
954 } elseif ($status !== 'completed' && $old_status === 'completed') {
955 // Payment changed FROM completed to another status (failed, pending, etc.)
956 // Recalculate total payments and update invoice status accordingly
957 $total_payments = $this->calculateTotalPaymentsForInvoice($invoice_id);
958 $invoice_total = $invoice->getTotal();
959
960 if ($total_payments < $invoice_total) {
961 // Not enough payments any more: part paid if anything
962 // remains, otherwise back to awaiting payment. An issued
963 // invoice never returns to draft.
964 $invoice->setStatus($total_payments > 0 ? 'partial' : 'available');
965 $invoice->save();
966 } else {
967 // Still enough payments from other completed payments
968 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
969 }
970 }
971
972 wp_send_json_success([
973 'message' => __('Payment updated successfully', 'easy-invoice')
974 ]);
975 } else {
976 wp_send_json_error(['message' => __('Failed to update payment', 'easy-invoice')]);
977 }
978 } catch (\Exception $e) {
979 wp_send_json_error(['message' => $e->getMessage()]);
980 }
981 }
982
983 /**
984 * Verify manual payment
985 */
986 public function verifyManualPayment(): void {
987 // Check permissions
988 if (!easy_invoice_user_can('ei_record_payment')) {
989 wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]);
990 return;
991 }
992
993 // Verify nonce
994 check_ajax_referer('easy_invoice_admin', 'nonce');
995
996 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
997 $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
998 $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
999 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
1000 $transaction_id = isset($_POST['transaction_id']) ? sanitize_text_field($_POST['transaction_id']) : '';
1001
1002 if (!$invoice_id || !$amount || !$payment_method) {
1003 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
1004 return;
1005 }
1006
1007 // Get the invoice
1008 $post = get_post($invoice_id);
1009 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1010 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
1011 return;
1012 }
1013
1014 $invoice = new Invoice($post);
1015
1016 // Get currency settings
1017 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1018 $settings = $settings_controller->getSettings();
1019 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
1020 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1021
1022 $payment_data = [
1023 'invoice_id' => $invoice_id,
1024 'amount' => $amount,
1025 'payment_method' => $payment_method,
1026 'payment_date' => current_time('mysql'),
1027 'notes' => $notes,
1028 'status' => 'completed',
1029 'payment_type' => 'full',
1030 'transaction_id' => $transaction_id,
1031 'recurring_id' => '',
1032 'parent_payment_id' => '',
1033 'currency' => $currency_code,
1034 'currency_symbol' => $currency_symbol,
1035 'gateway_response' => [
1036 'admin_verified' => true,
1037 'verification_date' => current_time('mysql'),
1038 'verification_user' => get_current_user_id()
1039 ]
1040 ];
1041
1042 try {
1043 $payment = Payment::create($payment_data);
1044
1045 // Store payment details before updating status (for the hook)
1046 $invoice->setMeta('_payment_method', $payment_method);
1047 if ($transaction_id) {
1048 $invoice->setMeta('_transaction_id', $transaction_id);
1049 }
1050
1051 // Update invoice status to paid only if total payments are sufficient
1052 // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification
1053 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
1054
1055 // Send confirmation email to customer
1056 $this->sendPaymentConfirmationEmail($invoice_id, $payment->getId());
1057
1058 wp_send_json_success([
1059 'message' => __('Payment verified successfully', 'easy-invoice'),
1060 'payment_id' => $payment->getId()
1061 ]);
1062 } catch (\Exception $e) {
1063 wp_send_json_error(['message' => $e->getMessage()]);
1064 }
1065 }
1066
1067 /**
1068 * Reject manual payment
1069 */
1070 public function rejectManualPayment(): void {
1071 // Check permissions — rejecting a manual payment is a record-payment
1072 // operation (it transitions state, doesn't refund money).
1073 if (!easy_invoice_user_can('ei_record_payment')) {
1074 wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]);
1075 return;
1076 }
1077
1078 // Verify nonce
1079 check_ajax_referer('easy_invoice_admin', 'nonce');
1080
1081 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1082 $reason = isset($_POST['reason']) ? sanitize_textarea_field($_POST['reason']) : '';
1083
1084 if (!$invoice_id) {
1085 wp_send_json_error(['message' => __('Invoice ID is required', 'easy-invoice')]);
1086 return;
1087 }
1088
1089 // Get the invoice
1090 $post = get_post($invoice_id);
1091 if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1092 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
1093 return;
1094 }
1095
1096 $invoice = new Invoice($post);
1097
1098 // Update invoice status
1099 update_post_meta($invoice_id, '_payment_status', 'rejected');
1100
1101 // Add rejection reason
1102 update_post_meta($invoice_id, '_payment_rejection_reason', $reason);
1103 update_post_meta($invoice_id, '_payment_rejection_date', current_time('mysql'));
1104 update_post_meta($invoice_id, '_payment_rejection_user', get_current_user_id());
1105
1106 // Send rejection email to customer
1107 $this->sendPaymentRejectionEmail($invoice_id, $reason);
1108
1109 wp_send_json_success([
1110 'message' => __('Payment rejected successfully', 'easy-invoice')
1111 ]);
1112 }
1113
1114
1115
1116 /**
1117 * Send payment confirmation email to customer
1118 *
1119 * @param int $invoice_id
1120 * @param int $payment_id
1121 */
1122 private function sendPaymentConfirmationEmail($invoice_id, $payment_id): void {
1123 $invoice = new Invoice(get_post($invoice_id));
1124
1125 if (!$invoice || !$invoice->getId()) {
1126 return;
1127 }
1128
1129 // Use EmailManager to send payment confirmation using proper template system
1130 // This will check if payment email is enabled in settings
1131 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1132 $email_manager->sendInvoiceEmail($invoice, 'paid', [
1133 'payment_id' => $payment_id,
1134 'skip_bcc' => true // Skip BCC to admin since this is a direct call
1135 ]);
1136 }
1137
1138 /**
1139 * Send payment rejection email to customer
1140 *
1141 * @param int $invoice_id
1142 * @param string $reason
1143 */
1144 private function sendPaymentRejectionEmail($invoice_id, $reason): void {
1145 $invoice = new Invoice(get_post($invoice_id));
1146
1147 if (!$invoice || !$invoice->getId()) {
1148 return;
1149 }
1150
1151 // Use EmailManager to send payment rejection
1152 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1153 $email_manager->sendPaymentRejectionEmail($invoice, $reason);
1154 }
1155
1156
1157
1158
1159 /**
1160 * Daily payment reminder (free plugin).
1161 *
1162 * Emails the "reminder" template once to every invoice still owed a
1163 * set number of days after its due date (Settings → Advanced → Payment
1164 * reminder). Invoices with a payment awaiting confirmation are left
1165 * alone. When Easy Invoice Pro is active its own reminder (or the Smart
1166 * Reminders addon) takes over and this does nothing.
1167 */
1168 public function sendPaymentReminders(): void {
1169 if (function_exists('easy_invoice_has_pro') && easy_invoice_has_pro()) {
1170 return;
1171 }
1172 $raw = get_option('easy_invoice_payment_reminder_days', 3);
1173 if ('' === trim((string) $raw)) {
1174 return; // Switched off in Settings.
1175 }
1176 /**
1177 * Filter how many days after the due date the free reminder goes out
1178 * (0 = on the due date); return a negative number to disable it.
1179 *
1180 * @param int $days Days.
1181 */
1182 $days = (int) apply_filters('easy_invoice_payment_reminder_days', (int) $raw);
1183 if ($days < 0) {
1184 return;
1185 }
1186 $cutoff = gmdate('Y-m-d', strtotime(current_time('Y-m-d') . ' -' . $days . ' days'));
1187
1188 $ids = get_posts([
1189 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
1190 'post_status' => 'publish',
1191 'posts_per_page' => 200,
1192 'fields' => 'ids',
1193 'orderby' => 'ID',
1194 'order' => 'ASC',
1195 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1196 'relation' => 'AND',
1197 [
1198 'key' => \EasyInvoice\Constants\InvoiceFields::STATUS,
1199 'value' => ['available', 'unpaid', 'partial', 'overdue', 'sent'],
1200 'compare' => 'IN',
1201 ],
1202 [
1203 'key' => \EasyInvoice\Constants\InvoiceFields::DUE_DATE,
1204 'value' => $cutoff,
1205 'compare' => '<=',
1206 'type' => 'DATE',
1207 ],
1208 [
1209 'key' => '_payment_reminder_sent',
1210 'compare' => 'NOT EXISTS',
1211 ],
1212 ],
1213 ]);
1214 if (!$ids) {
1215 return;
1216 }
1217
1218 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1219 foreach ($ids as $invoice_id) {
1220 $invoice_id = (int) $invoice_id;
1221 // A client who has told us they paid should not be chased.
1222 if ('pending' === (string) get_post_meta($invoice_id, '_payment_status', true)) {
1223 continue;
1224 }
1225 $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id);
1226 if (!$invoice || \EasyInvoice\Services\InvoiceBalance::due($invoice) <= 0) {
1227 continue;
1228 }
1229 /**
1230 * Filter whether the free reminder is sent for this invoice.
1231 *
1232 * @param bool $send Default true.
1233 * @param object $invoice Invoice model.
1234 */
1235 if (!apply_filters('easy_invoice_send_payment_reminder', true, $invoice)) {
1236 continue;
1237 }
1238 $result = $email_manager->sendInvoiceEmail($invoice, 'reminder', [
1239 'payment_method' => (string) get_post_meta($invoice_id, '_easy_invoice_payment_method', true),
1240 ]);
1241 if (!empty($result['success'])) {
1242 update_post_meta($invoice_id, '_payment_reminder_sent', current_time('mysql'));
1243 /**
1244 * Fires after the free reminder email for an invoice was sent.
1245 *
1246 * @param int $invoice_id Invoice.
1247 * @param object $invoice Invoice model.
1248 */
1249 do_action('easy_invoice_payment_reminder_sent', $invoice_id, $invoice);
1250 }
1251 }
1252 }
1253
1254 /**
1255 * Submit manual payment
1256 */
1257 public function submitManualPayment(): void {
1258 // CSRF defense — keep the existing nonce check. The nonce is
1259 // global (`easy_invoice_payment`) so any public invoice page leaks
1260 // a valid value; the REAL authorisation gate is the ownership
1261 // check below.
1262 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_payment')) {
1263 wp_send_json_error(['message' => __('Security check failed', 'easy-invoice')]);
1264 return;
1265 }
1266
1267 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1268 $payment_type = isset($_POST['payment_type']) ? sanitize_text_field($_POST['payment_type']) : '';
1269 $payment_notes = isset($_POST['payment_notes']) ? sanitize_textarea_field($_POST['payment_notes']) : '';
1270
1271 if (!$invoice_id || !$payment_type) {
1272 wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
1273 return;
1274 }
1275
1276 // Get invoice
1277 $invoice_post = get_post($invoice_id);
1278 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1279 wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]);
1280 return;
1281 }
1282
1283 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
1284
1285 // Authorisation: reject unless the caller is the legitimate email
1286 // recipient (per-invoice access token), an admin, or the
1287 // logged-in client bound to this invoice. Without this gate the
1288 // public AJAX endpoint allowed any visitor with a harvested
1289 // global nonce to flood arbitrary invoices into
1290 // `pending_verification` and attach payment-proof uploads.
1291 if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
1292 wp_send_json_error([
1293 'message' => __('You do not have permission to submit a payment for this invoice.', 'easy-invoice'),
1294 ]);
1295 return;
1296 }
1297 $currency_code = $invoice->getCurrencyCode() ?: 'USD';
1298 if ($currency_code === 'global') {
1299 $currency_code = get_option('easy_invoice_currency_code', 'USD');
1300 }
1301 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1302
1303 // Handle file upload (never trust client MIME or filename extension — use WordPress filetype APIs)
1304 $proof_url = '';
1305 if (isset($_FILES['payment_proof']) && $_FILES['payment_proof']['error'] === UPLOAD_ERR_OK) {
1306 $file = $_FILES['payment_proof'];
1307
1308 if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
1309 wp_send_json_error(['message' => __('Invalid upload.', 'easy-invoice')]);
1310 return;
1311 }
1312
1313 $max_size = 5 * 1024 * 1024; // 5MB
1314 if ($file['size'] > $max_size) {
1315 wp_send_json_error(['message' => __('File size must be less than 5MB.', 'easy-invoice')]);
1316 return;
1317 }
1318
1319 $allowed_mimes = [
1320 'jpg|jpeg|jpe' => 'image/jpeg',
1321 'png' => 'image/png',
1322 'gif' => 'image/gif',
1323 'pdf' => 'application/pdf',
1324 ];
1325
1326 $checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowed_mimes);
1327 if (empty($checked['ext']) || empty($checked['type'])) {
1328 wp_send_json_error(['message' => __('Invalid file type. Only JPG, PNG, GIF, and PDF files are allowed.', 'easy-invoice')]);
1329 return;
1330 }
1331
1332 $allowed_types = array_values($allowed_mimes);
1333 if (!in_array($checked['type'], $allowed_types, true)) {
1334 wp_send_json_error(['message' => __('Invalid file type. Only JPG, PNG, GIF, and PDF files are allowed.', 'easy-invoice')]);
1335 return;
1336 }
1337
1338 $upload_dir = wp_upload_dir();
1339 $proof_dir = $upload_dir['basedir'] . '/easy-invoice/payment-proofs/';
1340
1341 if (!wp_mkdir_p($proof_dir)) {
1342 wp_send_json_error(['message' => __('Could not create upload directory.', 'easy-invoice')]);
1343 return;
1344 }
1345
1346 // Hand the move to WordPress rather than move_uploaded_file(): it
1347 // applies the site's filesystem method and permissions, and lets
1348 // the usual upload filters see the file. The directory is pointed
1349 // at our proofs folder for the duration of this one call.
1350 // Random, not time-based: a receipt carries bank details and the URL
1351 // is public, so the name must not be guessable.
1352 $filename = 'payment_proof_' . wp_generate_password(24, false, false) . '.' . $checked['ext'];
1353 $proof_url = $upload_dir['baseurl'] . '/easy-invoice/payment-proofs/';
1354 $to_proofs = static function ($dirs) use ($proof_dir, $proof_url) {
1355 $dirs['path'] = untrailingslashit($proof_dir);
1356 $dirs['url'] = untrailingslashit($proof_url);
1357 $dirs['subdir'] = '/easy-invoice/payment-proofs';
1358 return $dirs;
1359 };
1360 if (!function_exists('wp_handle_upload')) {
1361 require_once ABSPATH . 'wp-admin/includes/file.php';
1362 }
1363 add_filter('upload_dir', $to_proofs);
1364 \EasyInvoice\Helpers\UploadGuard::protectDirectory((wp_upload_dir())['basedir'] . '/easy-invoice/payment-proofs');
1365 $moved = wp_handle_upload($file, [
1366 'test_form' => false,
1367 'mimes' => $allowed_mimes,
1368 'unique_filename_callback' => static function () use ($filename) {
1369 return $filename;
1370 },
1371 ]);
1372 remove_filter('upload_dir', $to_proofs);
1373
1374 if (!is_array($moved) || !empty($moved['error']) || empty($moved['url'])) {
1375 wp_send_json_error(['message' => __('Failed to save payment proof file.', 'easy-invoice')]);
1376 return;
1377 }
1378 $proof_url = $moved['url'];
1379 }
1380
1381 // Create payment record
1382 $payment_data = [
1383 'post_title' => sprintf('Manual Payment (%s) for Invoice #%s', ucfirst($payment_type), $invoice->getNumber()),
1384 'post_type' => 'easy_invoice_payment',
1385 'post_status' => 'publish',
1386 'post_author' => get_current_user_id(),
1387 ];
1388
1389 $payment_id = wp_insert_post($payment_data);
1390
1391 if (is_wp_error($payment_id)) {
1392 wp_send_json_error(['message' => __('Failed to create payment record', 'easy-invoice')]);
1393 return;
1394 }
1395
1396 // Save payment metadata
1397 update_post_meta($payment_id, '_invoice_id', $invoice_id);
1398 update_post_meta($payment_id, '_amount', $invoice->getTotal());
1399 update_post_meta($payment_id, '_payment_method', 'manual');
1400 update_post_meta($payment_id, '_payment_type', $payment_type);
1401 update_post_meta($payment_id, '_status', 'pending');
1402 update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time());
1403 update_post_meta($payment_id, '_payment_date', current_time('mysql'));
1404 update_post_meta($payment_id, '_notes', $payment_notes);
1405 update_post_meta($payment_id, '_currency', $currency_code);
1406 update_post_meta($payment_id, '_currency_symbol', $currency_symbol);
1407 update_post_meta($payment_id, '_payment_proof', $proof_url);
1408
1409 // Update invoice status to pending verification
1410 $invoice->setStatus('pending_verification');
1411 $invoice->save();
1412
1413 // Store payment details on invoice
1414 $invoice->setMeta('_payment_method', 'manual');
1415 $invoice->setMeta('_payment_type', $payment_type);
1416 $invoice->setMeta('_payment_status', 'pending');
1417 $invoice->setMeta('_manual_payment_id', $payment_id);
1418 $invoice->setMeta('_manual_payment_proof', $proof_url);
1419 $invoice->setMeta('_manual_payment_notes', $payment_notes);
1420
1421 // Send admin notification
1422 do_action('easy_invoice_manual_payment_submitted', $invoice_id, $payment_type);
1423
1424 wp_send_json_success([
1425 'message' => __('Payment submitted successfully! Your payment will be verified by the administrator.', 'easy-invoice'),
1426 'payment_id' => $payment_id
1427 ]);
1428 }
1429
1430 /**
1431 * Handle submission of payment proof for manual gateways (Bank Transfer, Cheque)
1432 */
1433 public function submitPaymentProof(): void {
1434 $gateway_name = isset($_POST['gateway']) ? sanitize_text_field($_POST['gateway']) : '';
1435 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1436
1437 if (empty($gateway_name) || empty($invoice_id)) {
1438 wp_send_json_error(['message' => __('Invalid request. Missing gateway or invoice ID.', 'easy-invoice')]);
1439 return;
1440 }
1441
1442 // Nonce verification (make nonce name consistent or check based on gateway)
1443 $nonce_action = 'easy_invoice_payment_proof_' . $invoice_id; // Bank transfer nonce
1444 $nonce_value = isset($_POST['payment_proof_nonce']) ? sanitize_text_field($_POST['payment_proof_nonce']) : '';
1445 if ($gateway_name === 'cheque') {
1446 $nonce_action = 'easy_invoice_cheque_notification_' . $invoice_id; // Cheque nonce
1447 $nonce_value = isset($_POST['cheque_notification_nonce']) ? sanitize_text_field($_POST['cheque_notification_nonce']) : '';
1448 }
1449
1450 if (!wp_verify_nonce($nonce_value, $nonce_action)) {
1451 wp_send_json_error(['message' => __('Nonce verification failed. Please try again.', 'easy-invoice')]);
1452 return;
1453 }
1454
1455 // Optional: Add capability check if this can be submitted by logged-in users only from frontend
1456 // if (is_user_logged_in() && !current_user_can('read_invoice', $invoice_id)) { // Example capability
1457 // wp_send_json_error(['message' => __('You do not have permission to submit proof for this invoice.', 'easy-invoice')]);
1458 // return;
1459 // }
1460
1461 $gateway = $this->gatewayManager->getGateway($gateway_name);
1462
1463 if (!$gateway || !method_exists($gateway, 'handleProofSubmission')) {
1464 wp_send_json_error(['message' => __('Invalid payment gateway or submission handler not found.', 'easy-invoice')]);
1465 return;
1466 }
1467
1468 // Prepare data for the gateway handler
1469 $post_data = stripslashes_deep($_POST);
1470 $files_data = $_FILES;
1471
1472 $result = $gateway->handleProofSubmission($post_data, $files_data);
1473
1474 if ($result['success']) {
1475 wp_send_json_success(['message' => $result['message']]);
1476 } else {
1477 wp_send_json_error(['message' => $result['message']]);
1478 }
1479 }
1480
1481 /**
1482 * Confirm an offline payment a client told us about (or mark an invoice
1483 * paid by hand when nothing is pending).
1484 *
1485 * Expects `invoice_id`, the `easy_invoice_approve_payment` nonce, and
1486 * ideally `payment_id` — the pending record the reviewer looked at.
1487 * Only that record is completed; the invoice becomes Paid when the
1488 * confirmed payments and credit notes cover it, Partially paid otherwise.
1489 */
1490 public function mark_invoice_paid_ajax(): void {
1491 $invoice_id = isset($_POST['invoice_id']) ? absint($_POST['invoice_id']) : 0;
1492 $payment_id = isset($_POST['payment_id']) ? absint($_POST['payment_id']) : 0;
1493 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
1494 $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : '';
1495
1496 if (!wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1497 easy_invoice_toast_error(__('Invalid request or security check failed.', 'easy-invoice'));
1498 return;
1499 }
1500 if (!easy_invoice_user_can('ei_record_payment')) {
1501 easy_invoice_toast_error(__('You do not have permission to perform this action.', 'easy-invoice'));
1502 return;
1503 }
1504
1505 // A payment id alone is enough: the invoice is the one it belongs to.
1506 if ($payment_id) {
1507 $linked = (int) get_post_meta($payment_id, '_invoice_id', true);
1508 if ($linked && !$invoice_id) {
1509 $invoice_id = $linked;
1510 }
1511 if (!$linked || $linked !== $invoice_id || 'easy_invoice_payment' !== get_post_type($payment_id)) {
1512 easy_invoice_toast_error(__('That payment does not belong to this invoice.', 'easy-invoice'));
1513 return;
1514 }
1515 }
1516
1517 $invoice_post = $invoice_id ? get_post($invoice_id) : null;
1518 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1519 easy_invoice_toast_error(__('Invalid invoice.', 'easy-invoice'));
1520 return;
1521 }
1522 $invoice = new Invoice($invoice_post);
1523 $pending = \EasyInvoice\Services\OfflinePayments::pendingStatuses();
1524
1525 if (!$payment_id) {
1526 // Older callers pass only the invoice: take its oldest pending submission.
1527 $waiting = get_posts([
1528 'post_type' => 'easy_invoice_payment',
1529 'post_status' => 'any',
1530 'posts_per_page' => 1,
1531 'orderby' => 'date',
1532 'order' => 'ASC',
1533 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1534 ['key' => '_invoice_id', 'value' => $invoice_id],
1535 ['key' => '_status', 'value' => $pending, 'compare' => 'IN'],
1536 ],
1537 ]);
1538 $payment_id = $waiting ? (int) $waiting[0]->ID : 0;
1539 }
1540
1541 $currency_code = $invoice->getCurrencyCode() ?: get_option('easy_invoice_currency_code', 'USD');
1542 if ('global' === $currency_code) {
1543 $currency_code = get_option('easy_invoice_currency_code', 'USD');
1544 }
1545 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1546
1547 if ($payment_id) {
1548 $status = (string) get_post_meta($payment_id, '_status', true);
1549 if (!in_array($status, $pending, true)) {
1550 easy_invoice_toast_error(__('This payment has already been confirmed or rejected.', 'easy-invoice'));
1551 return;
1552 }
1553 $amount = round((float) get_post_meta($payment_id, '_amount', true), 2);
1554 $due = \EasyInvoice\Services\InvoiceBalance::due($invoice);
1555 if ($amount <= 0 || $amount > $due + 0.005) {
1556 // The client's figure was blank or more than is owed: confirm what is owed.
1557 $amount = round(max(0.0, $due), 2);
1558 update_post_meta($payment_id, '_amount', $amount);
1559 }
1560 if ($amount <= 0) {
1561 easy_invoice_toast_error(__('Nothing is owed on this invoice; reject the submission instead.', 'easy-invoice'));
1562 return;
1563 }
1564 update_post_meta($payment_id, '_status', 'completed');
1565 update_post_meta($payment_id, '_verified_by', get_current_user_id());
1566 update_post_meta($payment_id, '_verified_at', current_time('mysql'));
1567 if ('' !== $notes) {
1568 $existing = (string) get_post_meta($payment_id, '_notes', true);
1569 update_post_meta($payment_id, '_notes', trim($existing . ('' !== $existing ? "\n" : '') . __('Confirmed:', 'easy-invoice') . ' ' . $notes));
1570 }
1571 $method = (string) get_post_meta($payment_id, '_payment_method', true) ?: 'manual';
1572 } else {
1573 // Nothing was submitted: staff are recording the balance as paid by hand.
1574 $amount = round(\EasyInvoice\Services\InvoiceBalance::due($invoice), 2);
1575 if ($amount <= 0) {
1576 easy_invoice_toast_success(__('Nothing is owed on this invoice.', 'easy-invoice'));
1577 return;
1578 }
1579 $method = 'manual';
1580 $payment_id = wp_insert_post([
1581 'post_title' => sprintf('Manual Payment for Invoice #%s', $invoice->getNumber()),
1582 'post_type' => 'easy_invoice_payment',
1583 'post_status' => 'publish',
1584 'post_author' => get_current_user_id(),
1585 'meta_input' => [
1586 '_invoice_id' => $invoice_id,
1587 '_amount' => $amount,
1588 '_payment_method' => $method,
1589 '_status' => 'completed',
1590 '_transaction_id' => 'MANUAL-' . $invoice_id . '-' . time(),
1591 '_payment_date' => current_time('mysql'),
1592 '_notes' => $notes,
1593 '_payment_type' => 'manual',
1594 '_currency' => $currency_code,
1595 '_currency_symbol' => $currency_symbol,
1596 '_verified_by' => get_current_user_id(),
1597 '_verified_at' => current_time('mysql'),
1598 '_gateway_response' => wp_json_encode(['admin_verified' => true, 'user' => get_current_user_id(), 'verification_date' => current_time('mysql'), 'notes' => $notes]),
1599 ],
1600 ], true);
1601 if (is_wp_error($payment_id) || !$payment_id) {
1602 easy_invoice_toast_error(__('The payment could not be saved.', 'easy-invoice'));
1603 return;
1604 }
1605 }
1606
1607 \EasyInvoice\Services\InvoiceBalance::forget($invoice_id);
1608 $new_status = \EasyInvoice\Services\InvoiceBalance::isSettled($invoice) ? 'paid' : 'partial';
1609 $still_open = \EasyInvoice\Services\OfflinePayments::pendingForInvoice($invoice_id);
1610 update_post_meta($invoice_id, '_payment_status', $still_open ? 'pending' : ('paid' === $new_status ? 'completed' : 'partial'));
1611 update_post_meta($invoice_id, '_easy_invoice_payment_method', $method);
1612
1613 /**
1614 * Filter whether confirming a payment updates the invoice status.
1615 *
1616 * @param bool $update Default true.
1617 * @param int $invoice_id Invoice.
1618 */
1619 if (apply_filters('easy_invoice_should_update_invoice_status', true, $invoice_id)) {
1620 $invoice->setStatus($new_status);
1621 $invoice->save();
1622 }
1623
1624 $payment_event = [
1625 'payment_method' => $method,
1626 'gateway_name' => $method,
1627 'transaction_id' => (string) get_post_meta($payment_id, '_transaction_id', true),
1628 'amount' => $amount,
1629 'date' => (string) get_post_meta($payment_id, '_payment_date', true),
1630 'payment_id' => (int) $payment_id,
1631 ];
1632 if ('paid' === $new_status) {
1633 do_action('easy_invoice_payment_completed', $invoice_id, $invoice, $payment_event);
1634 } else {
1635 /** This action is documented in recordPayment(). */
1636 do_action('easy_invoice_payment_received', $invoice_id, $invoice, $payment_event);
1637 }
1638 /**
1639 * Fires when staff confirm an offline payment (or mark an invoice paid by hand).
1640 *
1641 * @param int $payment_id Payment record, now completed.
1642 * @param int $invoice_id Invoice.
1643 * @param float $amount Amount confirmed.
1644 * @param string $new_status Invoice status afterwards: paid or partial.
1645 */
1646 do_action('easy_invoice_payment_approved', (int) $payment_id, $invoice_id, $amount, $new_status);
1647
1648 easy_invoice_toast_success(
1649 'paid' === $new_status
1650 ? __('Payment confirmed — the invoice is paid.', 'easy-invoice')
1651 : sprintf(/* translators: %s: amount still owed. */ __('Payment confirmed — %s still due.', 'easy-invoice'), $currency_symbol . number_format_i18n(\EasyInvoice\Services\InvoiceBalance::due($invoice), 2))
1652 );
1653 }
1654
1655 /**
1656 * Turn down an offline payment a client told us about: the record stays
1657 * (marked rejected, with the reason) so the trail is complete, nothing
1658 * counts toward the balance, and the invoice keeps its status.
1659 */
1660 public function rejectPayment(): void {
1661 $payment_id = isset($_POST['payment_id']) ? absint($_POST['payment_id']) : 0;
1662 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
1663 $reason = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : '';
1664
1665 if (!$payment_id || !wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1666 easy_invoice_toast_error(__('Invalid request or security check failed.', 'easy-invoice'));
1667 return;
1668 }
1669 if (!easy_invoice_user_can('ei_record_payment')) {
1670 easy_invoice_toast_error(__('You do not have permission to perform this action.', 'easy-invoice'));
1671 return;
1672 }
1673 if ('easy_invoice_payment' !== get_post_type($payment_id)) {
1674 easy_invoice_toast_error(__('Payment not found.', 'easy-invoice'));
1675 return;
1676 }
1677 $status = (string) get_post_meta($payment_id, '_status', true);
1678 if (!in_array($status, \EasyInvoice\Services\OfflinePayments::pendingStatuses(), true)) {
1679 easy_invoice_toast_error(__('Only a pending payment can be rejected.', 'easy-invoice'));
1680 return;
1681 }
1682 $invoice_id = (int) get_post_meta($payment_id, '_invoice_id', true);
1683
1684 update_post_meta($payment_id, '_status', 'rejected');
1685 update_post_meta($payment_id, '_rejected_by', get_current_user_id());
1686 update_post_meta($payment_id, '_rejected_at', current_time('mysql'));
1687 if ('' !== $reason) {
1688 $existing = (string) get_post_meta($payment_id, '_notes', true);
1689 update_post_meta($payment_id, '_notes', trim($existing . ('' !== $existing ? "\n" : '') . __('Rejected:', 'easy-invoice') . ' ' . $reason));
1690 }
1691 if ($invoice_id) {
1692 if (\EasyInvoice\Services\OfflinePayments::pendingForInvoice($invoice_id)) {
1693 update_post_meta($invoice_id, '_payment_status', 'pending');
1694 } else {
1695 delete_post_meta($invoice_id, '_payment_status');
1696 }
1697 }
1698 /**
1699 * Fires when staff reject an offline payment submission.
1700 *
1701 * @param int $payment_id Payment record, now rejected.
1702 * @param int $invoice_id Invoice.
1703 * @param string $reason Reason given, if any.
1704 */
1705 do_action('easy_invoice_payment_rejected', $payment_id, $invoice_id, $reason);
1706
1707 easy_invoice_toast_success(__('Payment rejected. The invoice still shows the amount as due.', 'easy-invoice'));
1708 }
1709
1710 /**
1711 * Record money received, from the admin "Add New Payment" form.
1712 *
1713 * The form used to post to the customer checkout endpoint, which runs a
1714 * gateway (bank-transfer instructions, a card form) — not what an admin
1715 * typing in a cheque they were handed wants. This books a completed
1716 * payment and settles the invoice: paid when the total is covered,
1717 * partial otherwise.
1718 */
1719 public function recordPayment() {
1720 if (!isset($_POST['payment_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['payment_nonce'])), 'easy_invoice_payment')) {
1721 wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]);
1722 }
1723 if (!easy_invoice_user_can('ei_record_payment')) {
1724 wp_send_json_error(['message' => __('You do not have permission to record payments.', 'easy-invoice')]);
1725 }
1726 $invoice_id = isset($_POST['invoice_id']) ? absint($_POST['invoice_id']) : 0;
1727 $amount = isset($_POST['amount']) ? (float) str_replace(',', '', sanitize_text_field(wp_unslash($_POST['amount']))) : 0.0;
1728 $method = isset($_POST['payment_method']) ? sanitize_key(wp_unslash($_POST['payment_method'])) : '';
1729 $date = isset($_POST['payment_date']) ? sanitize_text_field(wp_unslash($_POST['payment_date'])) : '';
1730 $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : '';
1731
1732 $invoice = $invoice_id > 0 ? \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id) : null;
1733 if (!$invoice) {
1734 wp_send_json_error(['message' => __('Choose the invoice the payment is for.', 'easy-invoice')]);
1735 }
1736 if ($amount <= 0) {
1737 wp_send_json_error(['message' => __('Enter an amount greater than zero.', 'easy-invoice')]);
1738 }
1739 if ('' === $method) {
1740 $method = 'manual';
1741 }
1742 $when = $date && strtotime($date) ? gmdate('Y-m-d H:i:s', strtotime($date)) : current_time('mysql');
1743
1744 $currency_code = $invoice->getCurrencyCode() ?: get_option('easy_invoice_currency_code', 'USD');
1745 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1746 $payment_id = wp_insert_post([
1747 'post_title' => sprintf('Payment for Invoice #%s', $invoice->getNumber()),
1748 'post_type' => 'easy_invoice_payment',
1749 'post_status' => 'publish',
1750 'post_author' => get_current_user_id(),
1751 'meta_input' => [
1752 '_invoice_id' => $invoice_id,
1753 '_amount' => round($amount, 2),
1754 '_payment_method' => $method,
1755 '_status' => 'completed',
1756 '_transaction_id' => 'MANUAL-' . $invoice_id . '-' . time(),
1757 '_payment_date' => $when,
1758 '_notes' => $notes,
1759 '_payment_type' => 'manual',
1760 '_currency' => $currency_code,
1761 '_currency_symbol' => $currency_symbol,
1762 '_gateway_response' => wp_json_encode(['recorded_by' => get_current_user_id(), 'recorded_at' => current_time('mysql'), 'notes' => $notes]),
1763 ],
1764 ]);
1765 if (is_wp_error($payment_id) || !$payment_id) {
1766 wp_send_json_error(['message' => __('The payment could not be saved.', 'easy-invoice')]);
1767 }
1768
1769 $new_status = \EasyInvoice\Services\InvoiceBalance::isSettled($invoice) ? 'paid' : 'partial';
1770 update_post_meta($invoice_id, '_easy_invoice_payment_method', $method);
1771 $invoice->setStatus($new_status);
1772 $invoice->save();
1773 $payment_event = [
1774 'payment_method' => $method,
1775 'gateway_name' => 'manual',
1776 'transaction_id' => get_post_meta($payment_id, '_transaction_id', true),
1777 'amount' => $amount,
1778 'date' => $date,
1779 ];
1780 if ('paid' === $new_status) {
1781 do_action('easy_invoice_payment_completed', $invoice_id, $invoice, $payment_event);
1782 } else {
1783 /**
1784 * Fires when a payment is recorded that leaves a balance owing.
1785 *
1786 * @param int $invoice_id Invoice.
1787 * @param object $invoice Invoice model.
1788 * @param array $payment payment_method, gateway_name, transaction_id, amount, date.
1789 */
1790 do_action('easy_invoice_payment_received', $invoice_id, $invoice, $payment_event);
1791 }
1792 /**
1793 * Fires after an administrator records a payment by hand.
1794 *
1795 * @param int $payment_id Payment record.
1796 * @param int $invoice_id Invoice.
1797 * @param float $amount Amount recorded.
1798 * @param string $new_status Invoice status afterwards.
1799 */
1800 do_action('easy_invoice_payment_recorded', $payment_id, $invoice_id, $amount, $new_status);
1801
1802 wp_send_json_success([
1803 'payment_id' => $payment_id,
1804 'status' => $new_status,
1805 'message' => 'paid' === $new_status
1806 ? __('Payment recorded — the invoice is paid.', 'easy-invoice')
1807 : sprintf(/* translators: %s: amount still owed. */ __('Payment recorded — %s still due.', 'easy-invoice'), $currency_symbol . number_format_i18n(\EasyInvoice\Services\InvoiceBalance::due($invoice), 2)),
1808 ]);
1809 }
1810
1811 /**
1812 * Handle bulk actions for payments
1813 */
1814 public function handleBulkActions() {
1815 // Check if we're processing a bulk action
1816 if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_payment_bulk_action') {
1817 return;
1818 }
1819
1820 // Check nonce and capability
1821 if (!wp_verify_nonce(($_POST['easy_invoice_payment_bulk_nonce'] ?? ''), 'easy_invoice_payment_bulk_action')) {
1822 wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1823 }
1824
1825 // Bulk action on payments — record-payment cap is the right gate
1826 // (covers trash/restore/delete which all change payment state).
1827 if (!easy_invoice_user_can('ei_record_payment')) {
1828 wp_die(esc_html__('You do not have permission to perform this action.', 'easy-invoice'));
1829 }
1830
1831 // Check if we have payment IDs
1832 if (!isset($_POST['payment_ids']) || !is_array($_POST['payment_ids']) || empty($_POST['payment_ids'])) {
1833 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection'));
1834 exit;
1835 }
1836
1837 // Get bulk action and payment IDs
1838 $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
1839 $payment_ids = array_map('intval', $_POST['payment_ids']);
1840
1841 // Process based on action
1842 $processed = 0;
1843 $invoice_updates = array(); // Track invoice updates needed
1844
1845 switch ($bulk_action) {
1846 case 'trash':
1847 foreach ($payment_ids as $id) {
1848 // Get payment info before trashing for invoice status update
1849 $payment_post = get_post($id);
1850 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
1851 continue;
1852 }
1853 $payment = new Payment($payment_post);
1854 $payment_status = $payment->getStatus();
1855 $invoice_id = $payment->getInvoiceId();
1856 $payment_amount = $payment->getAmount();
1857
1858 if (wp_trash_post($id)) {
1859 $processed++;
1860
1861 // Track invoice updates needed for completed payments
1862 if ($payment_status === 'completed' && $invoice_id) {
1863 if (!isset($invoice_updates[$invoice_id])) {
1864 $invoice_updates[$invoice_id] = 0;
1865 }
1866 $invoice_updates[$invoice_id] += $payment_amount;
1867 }
1868 }
1869 }
1870
1871 // Update invoice statuses for completed payments that were trashed
1872 foreach (array_keys($invoice_updates) as $invoice_id) {
1873 $this->syncInvoiceStatusWithPayments($invoice_id);
1874 }
1875
1876 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed));
1877 break;
1878
1879 case 'restore':
1880 foreach ($payment_ids as $id) {
1881 // Get payment info before restoring for invoice status update
1882 $payment_post = get_post($id);
1883 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
1884 continue;
1885 }
1886 $payment = new Payment($payment_post);
1887 $payment_status = $payment->getStatus();
1888 $invoice_id = $payment->getInvoiceId();
1889 $payment_amount = $payment->getAmount();
1890
1891 if (wp_untrash_post($id)) {
1892 // Also set status to publish (since WordPress sets it to draft by default)
1893 wp_update_post(array(
1894 'ID' => $id,
1895 'post_status' => 'publish'
1896 ));
1897 $processed++;
1898
1899 // Track invoice updates needed for completed payments
1900 if ($payment_status === 'completed' && $invoice_id) {
1901 if (!isset($invoice_updates[$invoice_id])) {
1902 $invoice_updates[$invoice_id] = 0;
1903 }
1904 $invoice_updates[$invoice_id] += $payment_amount;
1905 }
1906 }
1907 }
1908
1909 // Update invoice statuses for completed payments that were restored
1910 foreach (array_keys($invoice_updates) as $invoice_id) {
1911 $this->syncInvoiceStatusWithPayments($invoice_id);
1912 }
1913
1914 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed));
1915 break;
1916
1917 case 'delete':
1918 foreach ($payment_ids as $id) {
1919 // Get payment info before deletion for invoice status update
1920 $payment_post = get_post($id);
1921 if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') {
1922 continue;
1923 }
1924 $payment = new Payment($payment_post);
1925 $payment_status = $payment->getStatus();
1926 $invoice_id = $payment->getInvoiceId();
1927 $payment_amount = $payment->getAmount();
1928
1929 if (wp_delete_post($id, true)) {
1930 $processed++;
1931
1932 // Track invoice updates needed for completed payments
1933 if ($payment_status === 'completed' && $invoice_id) {
1934 if (!isset($invoice_updates[$invoice_id])) {
1935 $invoice_updates[$invoice_id] = 0;
1936 }
1937 $invoice_updates[$invoice_id] += $payment_amount;
1938 }
1939 }
1940 }
1941
1942 // Update invoice statuses for completed payments that were deleted
1943 foreach (array_keys($invoice_updates) as $invoice_id) {
1944 $this->syncInvoiceStatusWithPayments($invoice_id);
1945 }
1946
1947 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed));
1948 break;
1949
1950 default:
1951 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action'));
1952 }
1953
1954 exit;
1955 }
1956
1957 /**
1958 * Put an invoice's status back in line with the completed payments and
1959 * credit notes it actually has — after a payment is trashed, restored or
1960 * deleted. Paid when nothing is owed, part-paid when something has been
1961 * received, otherwise awaiting payment; an issued invoice never returns
1962 * to draft. (This used to write the status to a meta key the invoice
1963 * does not use, so a trashed payment left the invoice "paid".)
1964 *
1965 * @param int $invoice_id Invoice.
1966 */
1967 private function syncInvoiceStatusWithPayments($invoice_id) {
1968 $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find((int) $invoice_id);
1969 if (!$invoice || !$invoice->getId()) {
1970 return;
1971 }
1972 $current = (string) $invoice->getStatus();
1973 if (in_array($current, ['draft', 'cancelled', 'canceled'], true)) {
1974 return;
1975 }
1976 $paid = \EasyInvoice\Services\InvoiceBalance::paid((int) $invoice_id);
1977 if (\EasyInvoice\Services\InvoiceBalance::isSettled($invoice)) {
1978 $new = 'paid';
1979 } elseif ($paid > 0) {
1980 $new = 'partial';
1981 } else {
1982 $new = in_array($current, ['unpaid', 'available'], true) ? $current : 'available';
1983 }
1984 if ($new !== $current) {
1985 $invoice->setStatus($new);
1986 $invoice->save();
1987 }
1988 }
1989
1990
1991 // Stripe payment recording moved to Pro plugin
1992
1993
1994 }
1995