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