PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.2
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.2
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
← All changes | includes/Controllers/PaymentController.php +998 -435 2.1.14 → 2.4.2 View file →
@@ -29,8 +29,15 @@
29 29 use TemplateTrait;
30 30 use PaymentCalculationTrait;
31 31
32 32 /**
33 + * First Easy Invoice Pro release whose gateway scripts forward the per-invoice
34 + * access token to `easy_invoice_process_payment`. Older builds need the
35 + * compatibility path in legacyProPaymentFallbackAllowed().
36 + */
37 + const PRO_TOKEN_FORWARDING_VERSION = '2.3.0';
38 +
39 + /**
33 40 * Payment gateway manager instance
34 41 *
35 42 * @var PaymentGatewayManager
36 43 */
@@ -50,8 +57,9 @@
50 57 add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
51 58 add_action('wp_ajax_easy_invoice_process_payment', [$this, 'processPayment']);
52 59 add_action('wp_ajax_nopriv_easy_invoice_process_payment', [$this, 'processPayment']);
53 60 add_action('wp_ajax_easy_invoice_update_payment', [$this, 'updatePayment']);
61 + add_action('wp_ajax_easy_invoice_record_payment', [$this, 'recordPayment']);
54 62 add_action('wp_ajax_easy_invoice_payment_callback', [$this, 'handleCallback']);
55 63 add_action('wp_ajax_nopriv_easy_invoice_payment_callback', [$this, 'handleCallback']);
56 64 add_action('wp_ajax_easy_invoice_verify_manual_payment', [$this, 'verifyManualPayment']);
57 65 add_action('wp_ajax_easy_invoice_reject_manual_payment', [$this, 'rejectManualPayment']);
@@ -60,27 +68,33 @@
60 68
61 69 // Handler for submitting payment proof for manual gateways
62 70 add_action('wp_ajax_easy_invoice_submit_payment_proof', [$this, 'submitPaymentProof']);
63 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']);
64 76
65 77 // Handler for getting payment instructions for manual gateways
66 78 add_action('wp_ajax_easy_invoice_get_payment_instructions', [$this, 'getPaymentInstructions']);
67 79 add_action('wp_ajax_nopriv_easy_invoice_get_payment_instructions', [$this, 'getPaymentInstructions']);
68 80
81 + // Enqueue frontend scripts
82 + add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']);
83 +
69 84 // Handler for admin to mark an invoice as paid
70 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']);
71 92
72 93 // Stripe payment handlers moved to Pro plugin
73 94
74 95 add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
75 96
76 - // Add filter to show pending payments in admin
77 - add_filter('easy_invoice_admin_payment_statuses', [$this, 'addPendingPaymentStatuses']);
78 -
79 - // Add custom columns to payments list
80 - add_filter('manage_easy-payment_posts_columns', [$this, 'addPaymentMethodColumn']);
81 - add_action('manage_easy-payment_posts_custom_column', [$this, 'renderPaymentMethodColumn'], 10, 2);
82 -
83 97 // Add reminder CRON job for pending payments
84 98 add_action('easy_invoice_payment_reminder', [$this, 'sendPaymentReminders']);
85 99 if (!wp_next_scheduled('easy_invoice_payment_reminder')) {
86 100 wp_schedule_event(time(), 'daily', 'easy_invoice_payment_reminder');
@@ -93,16 +107,18 @@
93 107 /**
94 108 * Get payment instructions for manual gateways
95 109 */
96 110 public function getPaymentInstructions() {
97 - // Verify nonce
98 - if (!wp_verify_nonce($_POST['nonce'], 'easy_invoice_payment')) {
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')) {
99 115 wp_send_json_error(['message' => 'Security check failed']);
100 116 return;
101 117 }
102 118
103 - $gateway = sanitize_text_field($_POST['gateway']);
104 - $invoice_id = intval($_POST['invoice_id']);
119 + $gateway = sanitize_text_field(($_POST['gateway'] ?? ''));
120 + $invoice_id = intval(($_POST['invoice_id'] ?? ''));
105 121
106 122 if (!$gateway || !$invoice_id) {
107 123 wp_send_json_error(['message' => 'Missing required parameters']);
108 124 return;
@@ -116,8 +132,25 @@
116 132 }
117 133
118 134 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
119 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 +
120 153 // Get gateway instance
121 154 $gateway_instance = $this->gatewayManager->getGateway($gateway);
122 155
123 156 if (!$gateway_instance) {
@@ -145,12 +178,57 @@
145 178 if (!$screen || !property_exists($screen, 'id') || strpos($screen->id, 'easy-invoice') === false) {
146 179 return;
147 180 }
148 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 + );
149 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 + ]);
150 196 }
151 197
152 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 + /**
153 231 * Display method implementation
154 232 *
155 233 * @param array $args Display arguments
156 234 */
@@ -174,15 +252,15 @@
174 252 try {
175 253 $payment = new Payment($payment_post);
176 254 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/view.php', ['payment' => $payment]);
177 255 } catch (\Exception $e) {
178 - wp_die(__('Invalid payment ID', 'easy-invoice'));
256 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
179 257 }
180 258 } else {
181 - wp_die(__('Invalid payment ID', 'easy-invoice'));
259 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
182 260 }
183 261 } else {
184 - wp_die(__('Payment ID is required', 'easy-invoice'));
262 + wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
185 263 }
186 264 break;
187 265
188 266 case 'edit':
@@ -193,15 +271,15 @@
193 271 try {
194 272 $payment = new Payment($payment_post);
195 273 $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/edit.php', ['payment' => $payment]);
196 274 } catch (\Exception $e) {
197 - wp_die(__('Invalid payment ID', 'easy-invoice'));
275 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
198 276 }
199 277 } else {
200 - wp_die(__('Invalid payment ID', 'easy-invoice'));
278 + wp_die(esc_html__('Invalid payment ID', 'easy-invoice'));
201 279 }
202 280 } else {
203 - wp_die(__('Payment ID is required', 'easy-invoice'));
281 + wp_die(esc_html__('Payment ID is required', 'easy-invoice'));
204 282 }
205 283 break;
206 284
207 285 default:
@@ -242,12 +320,14 @@
242 320 }
243 321
244 322 // Add status filter if set
245 323 if (!empty($status_filter)) {
246 - $args['meta_query'] = array(
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
247 326 array(
248 - 'key' => '_status',
249 - 'value' => $status_filter,
327 + 'key' => '_status',
328 + 'value' => 'pending' === $status_filter ? \EasyInvoice\Services\OfflinePayments::pendingStatuses() : $status_filter,
329 + 'compare' => 'pending' === $status_filter ? 'IN' : '=',
250 330 ),
251 331 );
252 332 }
253 333
@@ -278,64 +358,45 @@
278 358 // Get pagination info from WordPress query
279 359 $total_payments = $wp_query->found_posts;
280 360 $total_pages = $wp_query->max_num_pages;
281 361
282 - // Calculate statistics from ALL payments (not just current page)
283 - $stats_args = array(
284 - 'post_type' => 'easy_invoice_payment',
285 - 'posts_per_page' => -1, // Get all payments
286 - 'meta_query' => array(
287 - array(
288 - 'key' => '_status',
289 - 'compare' => 'EXISTS',
290 - ),
291 - ),
292 - );
293 -
294 - // Set post status for stats based on current view
295 - if ($current_view === 'trash') {
296 - $stats_args['post_status'] = 'trash';
297 - } else {
298 - $stats_args['post_status'] = 'publish';
299 - }
300 -
301 - $stats_query = new \WP_Query($stats_args);
302 -
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 );
303 375 $stats = [
304 - 'total_payments' => $stats_query->found_posts,
305 - 'total_amount' => 0,
376 + 'total_payments' => 0,
377 + 'total_amount' => 0,
306 378 'completed_payments' => 0,
307 - 'pending_payments' => 0,
308 - 'failed_payments' => 0
379 + 'pending_payments' => 0,
380 + 'failed_payments' => 0,
309 381 ];
310 -
311 - // Calculate stats from the query results
312 - if ($stats_query->have_posts()) {
313 - while ($stats_query->have_posts()) {
314 - $stats_query->the_post();
315 - $payment = new Payment(get_post());
316 -
317 - $amount = floatval($payment->getAmount());
318 - $status = $payment->getStatus();
319 -
320 - $stats['total_amount'] += $amount;
321 -
322 - switch ($status) {
323 - case 'completed':
324 - $stats['completed_payments']++;
325 - break;
326 - case 'pending':
327 - $stats['pending_payments']++;
328 - break;
329 - case 'failed':
330 - $stats['failed_payments']++;
331 - break;
332 - }
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'];
333 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 + }
334 397 }
335 - wp_reset_postdata();
336 398
337 - // Ensure all required keys exist with default values
338 399 $stats = array_merge([
339 400 'total_payments' => 0,
340 401 'total_amount' => 0,
341 402 'completed_payments' => 0,
@@ -342,22 +403,48 @@
342 403 'pending_payments' => 0,
343 404 'failed_payments' => 0
344 405 ], $stats);
345 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 +
346 438 // Get trash count for tab display
347 - $trash_args = array(
348 - 'post_type' => 'easy_invoice_payment',
349 - 'post_status' => 'trash',
350 - 'posts_per_page' => -1
351 - );
352 - $trash_query = new \WP_Query($trash_args);
353 - $trash_count = $trash_query->found_posts;
439 + $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'easy_invoice_payment' AND post_status = 'trash'" );
354 440
355 441 // Define available status filters
356 442 $status_filters = array(
357 - 'completed' => 'Completed',
358 - 'pending' => 'Pending',
359 - 'failed' => 'Failed'
443 + 'completed' => __('Completed', 'easy-invoice'),
444 + 'pending' => __('Awaiting confirmation', 'easy-invoice'),
445 + 'rejected' => __('Rejected', 'easy-invoice'),
446 + 'failed' => __('Failed', 'easy-invoice'),
360 447 );
361 448
362 449 // Prepare template data
363 450 $template_data = [
@@ -366,8 +453,9 @@
366 453 'status_filter' => $status_filter,
367 454 'status_filters' => $status_filters,
368 455 'trash_count' => $trash_count,
369 456 'stats' => $stats,
457 + 'amounts_by_currency' => $amounts_by_currency,
370 458 'current_page' => $current_page,
371 459 'per_page' => $per_page,
372 460 'total_payments' => $total_payments,
373 461 'total_pages' => $total_pages,
@@ -394,11 +482,19 @@
394 482 // Check if scripts are already enqueued
395 483 if (wp_script_is('easy-invoice-payment', 'enqueued')) {
396 484 return;
397 485 }
398 - if(!is_singular(PostTypes::EASY_INVOICE_POST_TYPE)){
399 - //return;
400 - }
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 + }
401 497
402 498 // Enqueue our custom scripts
403 499 wp_enqueue_script(
404 500 'easy-invoice-payment',
@@ -414,11 +510,22 @@
414 510 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
415 511 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
416 512
417 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 +
418 524 wp_localize_script('easy-invoice-payment', 'easy_invoice_vars', [
419 525 'ajax_url' => admin_url('admin-ajax.php'),
420 526 'nonce' => wp_create_nonce('easy_invoice_payment'),
527 + 'access_token' => $ei_access_token,
421 528 'currency_symbol' => $currency_symbol,
422 529 'currency_code' => $currency_code
423 530 ]);
424 531 }
@@ -433,8 +540,40 @@
433 540
434 541 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
435 542 $payment_method_slug = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
436 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 +
437 576 // Add filter for extensions to handle custom payment logic (e.g., partial payments)
438 577 $custom_result = apply_filters('easy_invoice_before_process_payment', null, $invoice_id, $_POST);
439 578
440 579 if (is_array($custom_result) && isset($custom_result['handled']) && $custom_result['handled']) {
@@ -445,24 +584,39 @@
445 584 }
446 585 return;
447 586 }
448 587
449 - if (!$invoice_id || !$payment_method_slug) {
588 + if (!$payment_method_slug) {
450 589 wp_send_json_error(['message' => __('Missing required fields.', 'easy-invoice')]);
451 590 return;
452 591 }
453 592
454 - $invoice_post = get_post($invoice_id);
455 - if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
456 - wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
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')]);
457 599 return;
458 600 }
459 601
460 - $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
461 - $amount = $invoice->total ?? 0;
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 + }
462 618
463 - // Log the payment processing details
464 -
465 619 $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug);
466 620
467 621 if (!$gateway_instance || !$gateway_instance->isEnabled() || !$gateway_instance->isAvailable()) {
468 622 wp_send_json_error(['message' => __('Selected payment gateway is not available or configured correctly.', 'easy-invoice')]);
@@ -473,8 +627,15 @@
473 627 // Pass the entire $_POST array to the gateway
474 628 $result = $gateway_instance->processPayment($amount, $_POST);
475 629
476 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.
477 638 wp_send_json_success($result);
478 639 } else {
479 640 wp_send_json_error(['message' => $result['message'] ?? __('Payment processing failed with the gateway.', 'easy-invoice')]);
480 641 }
@@ -485,8 +646,97 @@
485 646 }
486 647 }
487 648
488 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 + /**
489 739 * Handle payment callback/webhook
490 740 */
491 741 public function handleCallback(): void {
492 742 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
@@ -497,8 +747,34 @@
497 747 if (!$invoice_id || !$gateway) {
498 748 wp_send_json_error(['message' => __('Invalid request', 'easy-invoice')]);
499 749 }
500 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 +
501 777 $gateway_instance = $this->gatewayManager->getGateway($gateway);
502 778 if (!$gateway_instance) {
503 779 wp_send_json_error(['message' => __('Invalid payment gateway', 'easy-invoice')]);
504 780 }
@@ -504,10 +780,14 @@
504 780 }
505 781
506 782 $result = $gateway_instance->handleCallback($_POST);
507 783
508 - // Send admin notification for manual payments
509 - if ($result['success'] && in_array($gateway, ['bank', 'cheque'])) {
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)) {
510 790 do_action('easy_invoice_manual_payment_submitted', $invoice_id, $gateway);
511 791 }
512 792
513 793 if ($result['success']) {
@@ -531,9 +811,13 @@
531 811
532 812 $invoice = new \EasyInvoice\Models\Invoice($post);
533 813 $invoice_status = $invoice->getStatus();
534 814
535 - if (!in_array($invoice_status, [ 'unpaid', 'available'])) {
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)) {
536 820 return [];
537 821 }
538 822
539 823 $enabled_gateways = $this->gatewayManager->getEnabledGateways();
@@ -559,8 +843,17 @@
559 843
560 844 $available_gateways = [];
561 845 $gateway_manager = \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager();
562 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 +
563 856 // $enabled_gateways is an associative array with gateway_id as key and gateway object as value
564 857 foreach ($enabled_gateways as $gateway_id => $gateway) {
565 858 // If invoice has custom gateways selected, only show those
566 859 // If no custom gateways are selected (empty array), show all enabled gateways
@@ -588,13 +881,27 @@
588 881 */
589 882 public function updatePayment() {
590 883 check_ajax_referer('easy_invoice_payment', 'payment_nonce');
591 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 +
592 899 $payment_id = isset($_POST['payment_id']) ? intval($_POST['payment_id']) : 0;
593 900 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
594 901 $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0;
595 902 $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : '';
596 - $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : date('Y-m-d');
903 + $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : current_time('Y-m-d');
597 904 $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'pending';
598 905 $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
599 906
600 907 if (!$payment_id || !$invoice_id || !$amount || !$payment_method) {
@@ -650,13 +957,13 @@
650 957 $total_payments = $this->calculateTotalPaymentsForInvoice($invoice_id);
651 958 $invoice_total = $invoice->getTotal();
652 959
653 960 if ($total_payments < $invoice_total) {
654 - // Not enough payments anymore, revert invoice to draft/pending
655 - $invoice->setStatus('draft');
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');
656 965 $invoice->save();
657 -
658 - error_log("Easy Invoice: Invoice #$invoice_id status reverted to 'draft' - payment marked as $status");
659 966 } else {
660 967 // Still enough payments from other completed payments
661 968 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual');
662 969 }
@@ -677,9 +984,9 @@
677 984 * Verify manual payment
678 985 */
679 986 public function verifyManualPayment(): void {
680 987 // Check permissions
681 - if (!current_user_can('manage_options')) {
988 + if (!easy_invoice_user_can('ei_record_payment')) {
682 989 wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]);
683 990 return;
684 991 }
685 992
@@ -760,10 +1067,11 @@
760 1067 /**
761 1068 * Reject manual payment
762 1069 */
763 1070 public function rejectManualPayment(): void {
764 - // Check permissions
765 - if (!current_user_can('manage_options')) {
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')) {
766 1074 wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]);
767 1075 return;
768 1076 }
769 1077
@@ -817,12 +1125,14 @@
817 1125 if (!$invoice || !$invoice->getId()) {
818 1126 return;
819 1127 }
820 1128
821 - // Use EmailManager to send payment confirmation
1129 + // Use EmailManager to send payment confirmation using proper template system
1130 + // This will check if payment email is enabled in settings
822 1131 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
823 - $email_manager->sendPaymentConfirmationEmail($invoice, [
824 - 'payment_id' => $payment_id
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
825 1135 ]);
826 1136 }
827 1137
828 1138 /**
@@ -842,107 +1152,280 @@
842 1152 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
843 1153 $email_manager->sendPaymentRejectionEmail($invoice, $reason);
844 1154 }
845 1155
1156 +
1157 +
1158 +
846 1159 /**
847 - * Add pending payment statuses to admin filters
1160 + * Daily payment reminder (free plugin).
848 1161 *
849 - * @param array $statuses
850 - * @return array
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.
851 1167 */
852 - public function addPendingPaymentStatuses($statuses): array {
853 - $statuses['pending-bank'] = __('Pending Bank Transfer', 'easy-invoice');
854 - $statuses['pending-cheque'] = __('Pending Cheque', 'easy-invoice');
855 - return $statuses;
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 + }
856 1252 }
857 1253
858 1254 /**
859 - * Add payment method column to payments list
860 - *
861 - * @param array $columns
862 - * @return array
1255 + * Submit manual payment
863 1256 */
864 - public function addPaymentMethodColumn($columns): array {
865 - $new_columns = [];
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 + }
866 1266
867 - foreach ($columns as $key => $value) {
868 - $new_columns[$key] = $value;
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']) : '';
869 1270
870 - if ($key === 'title') {
871 - $new_columns['payment_method'] = __('Payment Method', 'easy-invoice');
872 - }
1271 + if (!$invoice_id || !$payment_type) {
1272 + wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]);
1273 + return;
873 1274 }
874 1275
875 - return $new_columns;
876 - }
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 + }
877 1282
878 - /**
879 - * Render payment method column
880 - *
881 - * @param string $column
882 - * @param int $post_id
883 - */
884 - public function renderPaymentMethodColumn($column, $post_id): void {
885 - if ($column === 'payment_method') {
886 - $payment_method = get_post_meta($post_id, '_payment_method', true);
887 - $payment_methods = [
888 - 'paypal' => __('PayPal', 'easy-invoice')
889 - ];
1283 + $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
890 1284
891 - echo isset($payment_methods[$payment_method]) ? esc_html($payment_methods[$payment_method]) : esc_html($payment_method);
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;
892 1296 }
893 - }
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);
894 1302
895 - /**
896 - * Send payment reminders for pending manual payments
897 - */
898 - public function sendPaymentReminders(): void {
899 - // Get invoices with pending manual payments
900 - $pending_invoices = get_posts([
901 - 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
902 - 'posts_per_page' => -1,
903 - 'meta_query' => [
904 - 'relation' => 'AND',
905 - [
906 - 'key' => '_payment_status',
907 - 'value' => ['pending-bank', 'pending-cheque'],
908 - 'compare' => 'IN'
909 - ],
910 - [
911 - 'key' => '_payment_reminder_sent',
912 - 'compare' => 'NOT EXISTS'
913 - ]
914 - ]
915 - ]);
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'];
916 1307
917 - if (!empty($pending_invoices)) {
918 - // Get currency settings
919 - $settings_controller = new \EasyInvoice\Controllers\SettingsController();
920 - $settings = $settings_controller->getSettings();
921 - $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
922 - $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
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 + }
923 1312
924 - foreach ($pending_invoices as $post) {
925 - $invoice = new Invoice($post);
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 + }
926 1318
927 - if (!$invoice || !$invoice->getId()) {
928 - continue;
929 - }
1319 + $allowed_mimes = [
1320 + 'jpg|jpeg|jpe' => 'image/jpeg',
1321 + 'png' => 'image/png',
1322 + 'gif' => 'image/gif',
1323 + 'pdf' => 'application/pdf',
1324 + ];
930 1325
931 - // Use EmailManager to send payment reminder
932 - $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
933 - $result = $email_manager->sendInvoiceEmail($invoice, 'reminder', [
934 - 'payment_method' => get_post_meta($invoice->getId(), '_payment_method', true)
935 - ]);
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 + }
936 1331
937 - // Mark reminder as sent if email was sent successfully
938 - if ($result['success']) {
939 - update_post_meta($invoice->getId(), '_payment_reminder_sent', current_time('mysql'));
940 - }
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;
941 1336 }
942 1337
943 - wp_reset_postdata();
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'];
944 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 + ]);
945 1428 }
946 1429
947 1430 /**
948 1431 * Handle submission of payment proof for manual gateways (Bank Transfer, Cheque)
@@ -995,189 +1478,335 @@
995 1478 }
996 1479 }
997 1480
998 1481 /**
999 - * AJAX handler for admin to mark an invoice as paid.
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.
1000 1489 */
1001 1490 public function mark_invoice_paid_ajax(): void {
1002 - $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1003 - $nonce = isset($_POST['nonce']) ? sanitize_text_field($_POST['nonce']) : '';
1004 - $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
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'])) : '';
1005 1495
1006 - if (empty($invoice_id) || !wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1496 + if (!wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1007 1497 easy_invoice_toast_error(__('Invalid request or security check failed.', 'easy-invoice'));
1008 1498 return;
1009 1499 }
1010 -
1011 - // Use manage_options capability which administrators have
1012 - if (!current_user_can('manage_options')) {
1500 + if (!easy_invoice_user_can('ei_record_payment')) {
1013 1501 easy_invoice_toast_error(__('You do not have permission to perform this action.', 'easy-invoice'));
1014 1502 return;
1015 1503 }
1016 1504
1017 - $invoice_post = get_post($invoice_id);
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;
1018 1518 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1019 - wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
1519 + easy_invoice_toast_error(__('Invalid invoice.', 'easy-invoice'));
1020 1520 return;
1021 1521 }
1522 + $invoice = new Invoice($invoice_post);
1523 + $pending = \EasyInvoice\Services\OfflinePayments::pendingStatuses();
1022 1524
1023 - $invoice = new Invoice($invoice_post);
1024 - // For manual approval, always use 'manual' as payment method
1025 - $payment_method = 'manual';
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 + }
1026 1540
1027 - // Update invoice post status to 'publish' (or your primary paid status)
1028 - wp_update_post(['ID' => $invoice_id, 'post_status' => 'publish']);
1029 - update_post_meta($invoice_id, '_payment_status', 'completed'); // General completed status for payments
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);
1030 1546
1031 - // Allow plugins to control invoice status update
1032 - $should_update_invoice_status = apply_filters('easy_invoice_should_update_invoice_status', true, $invoice_id);
1033 - if ($should_update_invoice_status) {
1034 - update_post_meta($invoice_id, InvoiceFields::STATUS, 'paid'); // Specific invoice status field if used by model
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 + }
1035 1605 }
1036 1606
1037 - // Use submitted notes or default note
1038 - $payment_notes = !empty($notes)
1039 - ? $notes
1040 - : __('Payment manually verified by admin.', 'easy-invoice');
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);
1041 1612
1042 - // Find existing pending payment records for this invoice
1043 - $existing_payment_args = [
1044 - 'post_type' => 'easy_invoice_payment',
1045 - 'posts_per_page' => 1,
1046 - 'meta_query' => [
1047 - 'relation' => 'AND',
1048 - [
1049 - 'key' => '_invoice_id',
1050 - 'value' => $invoice_id,
1051 - ],
1052 - [
1053 - 'key' => '_status',
1054 - 'value' => ['pending-bank', 'pending-cheque', 'pending'], // Check against pending statuses
1055 - 'compare' => 'IN'
1056 - ]
1057 - ]
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,
1058 1631 ];
1059 - $existing_payments = get_posts($existing_payment_args);
1060 - $payment_id = null;
1061 -
1062 - if (!empty($existing_payments)) {
1063 - // Update existing pending payment instead of creating new one
1064 - $payment_id = $existing_payments[0]->ID;
1065 - update_post_meta($payment_id, '_status', 'completed'); // Update status to completed
1066 - update_post_meta($payment_id, '_payment_method', 'manual'); // Set payment method to manual
1067 - update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time());
1068 - update_post_meta($payment_id, '_payment_date', current_time('mysql'));
1069 - update_post_meta($payment_id, '_notes', $payment_notes); // Update notes on existing payment
1632 + if ('paid' === $new_status) {
1633 + do_action('easy_invoice_payment_completed', $invoice_id, $invoice, $payment_event);
1070 1634 } else {
1071 - // Only create a new payment if no pending payments exist
1072 - // This prevents creating duplicate payment records
1073 - $existing_payments = get_posts([
1074 - 'post_type' => 'easy_invoice_payment',
1075 - 'posts_per_page' => -1,
1076 - 'meta_query' => [
1077 - [
1078 - 'key' => '_invoice_id',
1079 - 'value' => $invoice_id,
1080 - ]
1081 - ]
1082 - ]);
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);
1083 1647
1084 - if (!empty($existing_payments)) {
1085 - // If payments exist but none are pending, don't create a new one
1086 - // Just update the invoice status
1087 - easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
1088 - return;
1089 - }
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 + }
1090 1654
1091 - // Get currency from invoice
1092 - $currency_code = get_post_meta($invoice_id, '_easy_invoice_currency_code', true);
1093 - if (empty($currency_code) || $currency_code === 'global') {
1094 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
1095 - }
1096 - $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
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'])) : '';
1097 1664
1098 - $payment_data = [
1099 - 'invoice_id' => $invoice_id,
1100 - 'amount' => $invoice->getTotal(), // Or get amount from proof submission if it varies
1101 - 'payment_method' => $payment_method,
1102 - 'status' => 'completed',
1103 - 'transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id,
1104 - 'payment_date' => current_time('mysql'),
1105 - 'notes' => $payment_notes, // Use provided notes
1106 - 'payment_type' => 'manual',
1107 - 'currency' => $currency_code,
1108 - 'currency_symbol' => $currency_symbol,
1109 - 'gateway_response' => json_encode([
1110 - 'admin_verified' => true,
1111 - 'user' => get_current_user_id(),
1112 - 'verification_date' => current_time('mysql'),
1113 - 'notes' => $payment_notes // Store notes in response JSON as well
1114 - ])
1115 - ];
1116 - try {
1117 - // Create payment record using WordPress post creation
1118 - $payment_post_data = [
1119 - 'post_title' => sprintf('Manual Payment for Invoice #%s', $invoice->getNumber()),
1120 - 'post_type' => 'easy_invoice_payment',
1121 - 'post_status' => 'publish',
1122 - 'post_author' => get_current_user_id(),
1123 - 'meta_input' => [
1124 - '_invoice_id' => $invoice_id,
1125 - '_amount' => $invoice->getTotal(),
1126 - '_payment_method' => $payment_method,
1127 - '_status' => 'completed',
1128 - '_transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id,
1129 - '_payment_date' => current_time('mysql'),
1130 - '_notes' => $payment_notes,
1131 - '_payment_type' => 'manual',
1132 - '_currency' => $currency_code,
1133 - '_currency_symbol' => $currency_symbol,
1134 - '_gateway_response' => json_encode([
1135 - 'admin_verified' => true,
1136 - 'user' => get_current_user_id(),
1137 - 'verification_date' => current_time('mysql'),
1138 - 'notes' => $payment_notes
1139 - ])
1140 - ]
1141 - ];
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);
1142 1683
1143 - $payment_id = wp_insert_post($payment_post_data);
1144 - if (is_wp_error($payment_id)) {
1145 - easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $payment_id->get_error_message());
1146 - return;
1147 - }
1148 - } catch (\Exception $e) {
1149 - easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $e->getMessage());
1150 - return;
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');
1151 1696 }
1152 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);
1153 1706
1154 - // Store payment details before updating status (for the hook)
1155 - $transaction_id = get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id;
1156 - $invoice->setMeta('_payment_method', $payment_method);
1157 - $invoice->setMeta('_transaction_id', $transaction_id);
1707 + easy_invoice_toast_success(__('Payment rejected. The invoice still shows the amount as due.', 'easy-invoice'));
1708 + }
1158 1709
1159 - // Update invoice status to paid
1160 - // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification
1161 - $invoice->setStatus('paid');
1162 - $invoice->save();
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'])) : '';
1163 1731
1164 - // Trigger the payment completed hook manually since we're updating status directly
1165 - do_action('easy_invoice_payment_completed', $invoice_id, $invoice, [
1166 - 'payment_method' => $payment_method,
1167 - 'gateway_name' => 'manual',
1168 - 'transaction_id' => $transaction_id,
1169 - 'amount' => $invoice->getTotal()
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 + ],
1170 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 + }
1171 1768
1172 - // Trigger email confirmation and actions only if we have a payment_id
1173 - if ($payment_id) {
1174 - // Send confirmation email to customer
1175 - $this->sendPaymentConfirmationEmail($invoice_id, $payment_id);
1176 - do_action('easy_invoice_manual_payment_confirmed', $invoice_id, $payment_id, $payment_method);
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);
1177 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);
1178 1801
1179 - easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
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 + ]);
1180 1809 }
1181 1810
1182 1811 /**
1183 1812 * Handle bulk actions for payments
@@ -1188,19 +1817,21 @@
1188 1817 return;
1189 1818 }
1190 1819
1191 1820 // Check nonce and capability
1192 - if (!wp_verify_nonce($_POST['easy_invoice_payment_bulk_nonce'], 'easy_invoice_payment_bulk_action')) {
1193 - wp_die(__('Security check failed.', 'easy-invoice'));
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'));
1194 1823 }
1195 1824
1196 - if (!current_user_can('manage_options')) {
1197 - wp_die(__('You do not have permission to perform this action.', 'easy-invoice'));
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'));
1198 1829 }
1199 1830
1200 1831 // Check if we have payment IDs
1201 1832 if (!isset($_POST['payment_ids']) || !is_array($_POST['payment_ids']) || empty($_POST['payment_ids'])) {
1202 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection'));
1833 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection'));
1203 1834 exit;
1204 1835 }
1205 1836
1206 1837 // Get bulk action and payment IDs
@@ -1237,13 +1868,13 @@
1237 1868 }
1238 1869 }
1239 1870
1240 1871 // Update invoice statuses for completed payments that were trashed
1241 - foreach ($invoice_updates as $invoice_id => $deleted_amount) {
1242 - $this->updateInvoiceStatusAfterPaymentDeletion($invoice_id, $deleted_amount);
1872 + foreach (array_keys($invoice_updates) as $invoice_id) {
1873 + $this->syncInvoiceStatusWithPayments($invoice_id);
1243 1874 }
1244 1875
1245 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed));
1876 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed));
1246 1877 break;
1247 1878
1248 1879 case 'restore':
1249 1880 foreach ($payment_ids as $id) {
@@ -1275,13 +1906,13 @@
1275 1906 }
1276 1907 }
1277 1908
1278 1909 // Update invoice statuses for completed payments that were restored
1279 - foreach ($invoice_updates as $invoice_id => $restored_amount) {
1280 - $this->updateInvoiceStatusAfterPaymentRestoration($invoice_id, $restored_amount);
1910 + foreach (array_keys($invoice_updates) as $invoice_id) {
1911 + $this->syncInvoiceStatusWithPayments($invoice_id);
1281 1912 }
1282 1913
1283 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed));
1914 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed));
1284 1915 break;
1285 1916
1286 1917 case 'delete':
1287 1918 foreach ($payment_ids as $id) {
@@ -1308,17 +1939,17 @@
1308 1939 }
1309 1940 }
1310 1941
1311 1942 // Update invoice statuses for completed payments that were deleted
1312 - foreach ($invoice_updates as $invoice_id => $deleted_amount) {
1313 - $this->updateInvoiceStatusAfterPaymentDeletion($invoice_id, $deleted_amount);
1943 + foreach (array_keys($invoice_updates) as $invoice_id) {
1944 + $this->syncInvoiceStatusWithPayments($invoice_id);
1314 1945 }
1315 1946
1316 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed));
1947 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed));
1317 1948 break;
1318 1949
1319 1950 default:
1320 - wp_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action'));
1951 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action'));
1321 1952 }
1322 1953
1323 1954 exit;
1324 1955 }
@@ -1323,108 +1954,40 @@
1323 1954 exit;
1324 1955 }
1325 1956
1326 1957 /**
1327 - * Update invoice status after payment deletion
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.
1328 1966 */
1329 - private function updateInvoiceStatusAfterPaymentDeletion($invoice_id, $deleted_amount) {
1330 - $invoice = new Invoice($invoice_id);
1331 -
1332 - if (!$invoice->getId()) {
1967 + private function syncInvoiceStatusWithPayments($invoice_id) {
1968 + $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find((int) $invoice_id);
1969 + if (!$invoice || !$invoice->getId()) {
1333 1970 return;
1334 1971 }
1335 -
1336 - // Get all remaining payments for this invoice
1337 - $remaining_payments = get_posts(array(
1338 - 'post_type' => 'easy_invoice_payment',
1339 - 'post_status' => 'publish',
1340 - 'meta_query' => array(
1341 - array(
1342 - 'key' => '_invoice_id',
1343 - 'value' => $invoice_id,
1344 - 'compare' => '='
1345 - ),
1346 - array(
1347 - 'key' => '_status',
1348 - 'value' => 'completed',
1349 - 'compare' => '='
1350 - )
1351 - ),
1352 - 'posts_per_page' => -1
1353 - ));
1354 -
1355 - // Calculate total remaining payments
1356 - $total_remaining = 0;
1357 - foreach ($remaining_payments as $payment_post) {
1358 - $payment = new Payment($payment_post);
1359 - $total_remaining += floatval($payment->getAmount());
1972 + $current = (string) $invoice->getStatus();
1973 + if (in_array($current, ['draft', 'cancelled', 'canceled'], true)) {
1974 + return;
1360 1975 }
1361 -
1362 - $invoice_total = floatval($invoice->getTotal());
1363 -
1364 - // Update invoice status based on remaining payments
1365 - if ($total_remaining >= $invoice_total) {
1366 - // Still fully paid
1367 - update_post_meta($invoice_id, '_status', 'paid');
1368 - } elseif ($total_remaining > 0) {
1369 - // Partially paid
1370 - update_post_meta($invoice_id, '_status', 'partial');
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';
1371 1981 } else {
1372 - // No payments remaining
1373 - update_post_meta($invoice_id, '_status', 'unpaid');
1982 + $new = in_array($current, ['unpaid', 'available'], true) ? $current : 'available';
1374 1983 }
1984 + if ($new !== $current) {
1985 + $invoice->setStatus($new);
1986 + $invoice->save();
1987 + }
1375 1988 }
1376 1989
1377 - /**
1378 - * Update invoice status after payment restoration
1379 - */
1380 - private function updateInvoiceStatusAfterPaymentRestoration($invoice_id, $restored_amount) {
1381 - $invoice = new Invoice($invoice_id);
1382 -
1383 - if (!$invoice->getId()) {
1384 - return;
1385 - }
1386 -
1387 - // Get all payments for this invoice (including the restored one)
1388 - $all_payments = get_posts(array(
1389 - 'post_type' => 'easy_invoice_payment',
1390 - 'post_status' => 'publish',
1391 - 'meta_query' => array(
1392 - array(
1393 - 'key' => '_invoice_id',
1394 - 'value' => $invoice_id,
1395 - 'compare' => '='
1396 - ),
1397 - array(
1398 - 'key' => '_status',
1399 - 'value' => 'completed',
1400 - 'compare' => '='
1401 - )
1402 - ),
1403 - 'posts_per_page' => -1
1404 - ));
1405 -
1406 - // Calculate total payments (including restored ones)
1407 - $total_payments = 0;
1408 - foreach ($all_payments as $payment_post) {
1409 - $payment = new Payment($payment_post);
1410 - $total_payments += floatval($payment->getAmount());
1411 - }
1412 -
1413 - $invoice_total = floatval($invoice->getTotal());
1414 -
1415 - // Update invoice status based on total payments
1416 - if ($total_payments >= $invoice_total) {
1417 - // Fully paid
1418 - update_post_meta($invoice_id, '_status', 'paid');
1419 - } elseif ($total_payments > 0) {
1420 - // Partially paid
1421 - update_post_meta($invoice_id, '_status', 'partial');
1422 - } else {
1423 - // No payments
1424 - update_post_meta($invoice_id, '_status', 'unpaid');
1425 - }
1426 - }
1427 1990
1428 1991 // Stripe payment recording moved to Pro plugin
1429 1992
1430 1993