PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.3
2.4.3 2.4.4 2.4.2 2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 All 60 releases
← All changes | includes/Controllers/PaymentController.php +345 -200 2.4.0 → 2.4.3 View file →
@@ -82,8 +82,14 @@
82 82 add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']);
83 83
84 84 // Handler for admin to mark an invoice as paid
85 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']);
86 92
87 93 // Stripe payment handlers moved to Pro plugin
88 94
89 95 add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
@@ -314,12 +320,14 @@
314 320 }
315 321
316 322 // Add status filter if set
317 323 if (!empty($status_filter)) {
318 - $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
319 326 array(
320 - 'key' => '_status',
321 - 'value' => $status_filter,
327 + 'key' => '_status',
328 + 'value' => 'pending' === $status_filter ? \EasyInvoice\Services\OfflinePayments::pendingStatuses() : $status_filter,
329 + 'compare' => 'pending' === $status_filter ? 'IN' : '=',
322 330 ),
323 331 );
324 332 }
325 333
@@ -371,11 +379,19 @@
371 379 'pending_payments' => 0,
372 380 'failed_payments' => 0,
373 381 ];
374 382 foreach ( (array) $stat_rows as $row ) {
383 + $status = (string) $row['status'];
375 384 $stats['total_payments'] += (int) $row['n'];
376 - $stats['total_amount'] += (float) $row['amount'];
377 - $key = $row['status'] . '_payments';
385 + // "Total amount" is money confirmed; submissions still waiting
386 + // for a decision, rejected and failed ones are not counted.
387 + if ( 'completed' === $status ) {
388 + $stats['total_amount'] += (float) $row['amount'];
389 + }
390 + if ( in_array( $status, \EasyInvoice\Services\OfflinePayments::pendingStatuses(), true ) ) {
391 + $status = 'pending';
392 + }
393 + $key = $status . '_payments';
378 394 if ( isset( $stats[ $key ] ) ) {
379 395 $stats[ $key ] += (int) $row['n'];
380 396 }
381 397 }
@@ -387,16 +403,48 @@
387 403 'pending_payments' => 0,
388 404 'failed_payments' => 0
389 405 ], $stats);
390 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 +
391 438 // Get trash count for tab display
392 439 $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'easy_invoice_payment' AND post_status = 'trash'" );
393 440
394 441 // Define available status filters
395 442 $status_filters = array(
396 - 'completed' => 'Completed',
397 - 'pending' => 'Pending',
398 - 'failed' => 'Failed'
443 + 'completed' => __('Completed', 'easy-invoice'),
444 + 'pending' => __('Awaiting confirmation', 'easy-invoice'),
445 + 'rejected' => __('Rejected', 'easy-invoice'),
446 + 'failed' => __('Failed', 'easy-invoice'),
399 447 );
400 448
401 449 // Prepare template data
402 450 $template_data = [
@@ -405,8 +453,9 @@
405 453 'status_filter' => $status_filter,
406 454 'status_filters' => $status_filters,
407 455 'trash_count' => $trash_count,
408 456 'stats' => $stats,
457 + 'amounts_by_currency' => $amounts_by_currency,
409 458 'current_page' => $current_page,
410 459 'per_page' => $per_page,
411 460 'total_payments' => $total_payments,
412 461 'total_pages' => $total_pages,
@@ -582,12 +631,11 @@
582 631 // An offline gateway with no follow-up step (cash, the free
583 632 // manual gateway) leaves the invoice pending here; bank
584 633 // transfer and cheque notify the admin themselves once the
585 634 // 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 - }
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.
590 638 wp_send_json_success($result);
591 639 } else {
592 640 wp_send_json_error(['message' => $result['message'] ?? __('Payment processing failed with the gateway.', 'easy-invoice')]);
593 641 }
@@ -795,8 +843,17 @@
795 843
796 844 $available_gateways = [];
797 845 $gateway_manager = \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager();
798 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 +
799 856 // $enabled_gateways is an associative array with gateway_id as key and gateway object as value
800 857 foreach ($enabled_gateways as $gateway_id => $gateway) {
801 858 // If invoice has custom gateways selected, only show those
802 859 // If no custom gateways are selected (empty array), show all enabled gateways
@@ -1099,56 +1156,99 @@
1099 1156
1100 1157
1101 1158
1102 1159 /**
1103 - * Send payment reminders for pending manual payments
1160 + * Daily payment reminder (free plugin).
1161 + *
1162 + * Emails the "reminder" template once to every invoice still owed a
1163 + * set number of days after its due date (Settings → Advanced → Payment
1164 + * reminder). Invoices with a payment awaiting confirmation are left
1165 + * alone. When Easy Invoice Pro is active its own reminder (or the Smart
1166 + * Reminders addon) takes over and this does nothing.
1104 1167 */
1105 1168 public function sendPaymentReminders(): void {
1106 - // Get invoices with pending manual payments
1107 - $pending_invoices = get_posts([
1108 - 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
1109 - 'posts_per_page' => -1,
1110 - 'meta_query' => [
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
1111 1196 'relation' => 'AND',
1112 1197 [
1113 - 'key' => '_payment_status',
1114 - 'value' => ['pending-bank', 'pending-cheque'],
1115 - 'compare' => 'IN'
1198 + 'key' => \EasyInvoice\Constants\InvoiceFields::STATUS,
1199 + 'value' => ['available', 'unpaid', 'partial', 'overdue', 'sent'],
1200 + 'compare' => 'IN',
1116 1201 ],
1117 1202 [
1118 - 'key' => '_payment_reminder_sent',
1119 - 'compare' => 'NOT EXISTS'
1120 - ]
1121 - ]
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 + ],
1122 1213 ]);
1214 + if (!$ids) {
1215 + return;
1216 + }
1123 1217
1124 - if (!empty($pending_invoices)) {
1125 - // Get currency settings
1126 - $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1127 - $settings = $settings_controller->getSettings();
1128 - $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
1129 - $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1130 -
1131 - foreach ($pending_invoices as $post) {
1132 - $invoice = new Invoice($post);
1133 -
1134 - if (!$invoice || !$invoice->getId()) {
1135 - continue;
1136 - }
1137 -
1138 - // Use EmailManager to send payment reminder
1139 - $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1140 - $result = $email_manager->sendInvoiceEmail($invoice, 'reminder', [
1141 - 'payment_method' => get_post_meta($invoice->getId(), '_payment_method', true)
1142 - ]);
1143 -
1144 - // Mark reminder as sent if email was sent successfully
1145 - if ($result['success']) {
1146 - update_post_meta($invoice->getId(), '_payment_reminder_sent', current_time('mysql'));
1147 - }
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;
1148 1224 }
1149 -
1150 - wp_reset_postdata();
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 + }
1151 1251 }
1152 1252 }
1153 1253
1154 1254 /**
@@ -1378,189 +1478,234 @@
1378 1478 }
1379 1479 }
1380 1480
1381 1481 /**
1382 - * 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.
1383 1489 */
1384 1490 public function mark_invoice_paid_ajax(): void {
1385 - $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1386 - $nonce = isset($_POST['nonce']) ? sanitize_text_field($_POST['nonce']) : '';
1387 - $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : '';
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'])) : '';
1388 1495
1389 - if (empty($invoice_id) || !wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1496 + if (!wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) {
1390 1497 easy_invoice_toast_error(__('Invalid request or security check failed.', 'easy-invoice'));
1391 1498 return;
1392 1499 }
1393 -
1394 - // Mark-as-paid is a record-payment action — gated by the matching cap.
1395 1500 if (!easy_invoice_user_can('ei_record_payment')) {
1396 1501 easy_invoice_toast_error(__('You do not have permission to perform this action.', 'easy-invoice'));
1397 1502 return;
1398 1503 }
1399 1504
1400 - $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;
1401 1518 if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
1402 - wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]);
1519 + easy_invoice_toast_error(__('Invalid invoice.', 'easy-invoice'));
1403 1520 return;
1404 1521 }
1405 -
1406 1522 $invoice = new Invoice($invoice_post);
1407 - // For manual approval, always use 'manual' as payment method
1408 - $payment_method = 'manual';
1523 + $pending = \EasyInvoice\Services\OfflinePayments::pendingStatuses();
1409 1524
1410 - // Update invoice post status to 'publish' (or your primary paid status)
1411 - wp_update_post(['ID' => $invoice_id, 'post_status' => 'publish']);
1412 - update_post_meta($invoice_id, '_payment_status', 'completed'); // General completed status for payments
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 + }
1413 1540
1414 - // Allow plugins to control invoice status update
1415 - $should_update_invoice_status = apply_filters('easy_invoice_should_update_invoice_status', true, $invoice_id);
1416 - if ($should_update_invoice_status) {
1417 - update_post_meta($invoice_id, InvoiceFields::STATUS, 'paid'); // Specific invoice status field if used by model
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');
1418 1544 }
1545 + $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1419 1546
1420 - // Use submitted notes or default note
1421 - $payment_notes = !empty($notes)
1422 - ? $notes
1423 - : __('Payment manually verified by admin.', 'easy-invoice');
1424 -
1425 - // Find existing pending payment records for this invoice
1426 - $existing_payment_args = [
1427 - 'post_type' => 'easy_invoice_payment',
1428 - 'posts_per_page' => 1,
1429 - 'meta_query' => [
1430 - 'relation' => 'AND',
1431 - [
1432 - 'key' => '_invoice_id',
1433 - 'value' => $invoice_id,
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]),
1434 1599 ],
1435 - [
1436 - 'key' => '_status',
1437 - 'value' => ['pending-bank', 'pending-cheque', 'pending'], // Check against pending statuses
1438 - 'compare' => 'IN'
1439 - ]
1440 - ]
1441 - ];
1442 - $existing_payments = get_posts($existing_payment_args);
1443 - $payment_id = null;
1444 -
1445 - if (!empty($existing_payments)) {
1446 - // Update existing pending payment instead of creating new one
1447 - $payment_id = $existing_payments[0]->ID;
1448 - update_post_meta($payment_id, '_status', 'completed'); // Update status to completed
1449 - update_post_meta($payment_id, '_payment_method', 'manual'); // Set payment method to manual
1450 - update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time());
1451 - update_post_meta($payment_id, '_payment_date', current_time('mysql'));
1452 - update_post_meta($payment_id, '_notes', $payment_notes); // Update notes on existing payment
1453 - } else {
1454 - // Only create a new payment if no pending payments exist
1455 - // This prevents creating duplicate payment records
1456 - $existing_payments = get_posts([
1457 - 'post_type' => 'easy_invoice_payment',
1458 - 'posts_per_page' => -1,
1459 - 'meta_query' => [
1460 - [
1461 - 'key' => '_invoice_id',
1462 - 'value' => $invoice_id,
1463 - ]
1464 - ]
1465 - ]);
1466 -
1467 - if (!empty($existing_payments)) {
1468 - // If payments exist but none are pending, don't create a new one
1469 - // Just update the invoice status
1470 - easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
1600 + ], true);
1601 + if (is_wp_error($payment_id) || !$payment_id) {
1602 + easy_invoice_toast_error(__('The payment could not be saved.', 'easy-invoice'));
1471 1603 return;
1472 1604 }
1605 + }
1473 1606
1474 - // Get currency from invoice
1475 - $currency_code = get_post_meta($invoice_id, '_easy_invoice_currency_code', true);
1476 - if (empty($currency_code) || $currency_code === 'global') {
1477 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
1478 - }
1479 - $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
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);
1480 1612
1481 - $payment_data = [
1482 - 'invoice_id' => $invoice_id,
1483 - 'amount' => $invoice->getTotal(), // Or get amount from proof submission if it varies
1484 - 'payment_method' => $payment_method,
1485 - 'status' => 'completed',
1486 - 'transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id,
1487 - 'payment_date' => current_time('mysql'),
1488 - 'notes' => $payment_notes, // Use provided notes
1489 - 'payment_type' => 'manual',
1490 - 'currency' => $currency_code,
1491 - 'currency_symbol' => $currency_symbol,
1492 - 'gateway_response' => json_encode([
1493 - 'admin_verified' => true,
1494 - 'user' => get_current_user_id(),
1495 - 'verification_date' => current_time('mysql'),
1496 - 'notes' => $payment_notes // Store notes in response JSON as well
1497 - ])
1498 - ];
1499 - try {
1500 - // Create payment record using WordPress post creation
1501 - $payment_post_data = [
1502 - 'post_title' => sprintf('Manual Payment for Invoice #%s', $invoice->getNumber()),
1503 - 'post_type' => 'easy_invoice_payment',
1504 - 'post_status' => 'publish',
1505 - 'post_author' => get_current_user_id(),
1506 - 'meta_input' => [
1507 - '_invoice_id' => $invoice_id,
1508 - '_amount' => $invoice->getTotal(),
1509 - '_payment_method' => $payment_method,
1510 - '_status' => 'completed',
1511 - '_transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id,
1512 - '_payment_date' => current_time('mysql'),
1513 - '_notes' => $payment_notes,
1514 - '_payment_type' => 'manual',
1515 - '_currency' => $currency_code,
1516 - '_currency_symbol' => $currency_symbol,
1517 - '_gateway_response' => json_encode([
1518 - 'admin_verified' => true,
1519 - 'user' => get_current_user_id(),
1520 - 'verification_date' => current_time('mysql'),
1521 - 'notes' => $payment_notes
1522 - ])
1523 - ]
1524 - ];
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 + }
1525 1623
1526 - $payment_id = wp_insert_post($payment_post_data);
1527 - if (is_wp_error($payment_id)) {
1528 - easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $payment_id->get_error_message());
1529 - return;
1530 - }
1531 - } catch (\Exception $e) {
1532 - easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $e->getMessage());
1533 - return;
1534 - }
1624 + $payment_event = [
1625 + 'payment_method' => $method,
1626 + 'gateway_name' => $method,
1627 + 'transaction_id' => (string) get_post_meta($payment_id, '_transaction_id', true),
1628 + 'amount' => $amount,
1629 + 'date' => (string) get_post_meta($payment_id, '_payment_date', true),
1630 + 'payment_id' => (int) $payment_id,
1631 + ];
1632 + if ('paid' === $new_status) {
1633 + do_action('easy_invoice_payment_completed', $invoice_id, $invoice, $payment_event);
1634 + } else {
1635 + /** This action is documented in recordPayment(). */
1636 + do_action('easy_invoice_payment_received', $invoice_id, $invoice, $payment_event);
1535 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);
1536 1647
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);
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 + }
1541 1654
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();
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'])) : '';
1546 1664
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 - ]);
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);
1554 1683
1555 - // Trigger email confirmation and actions only if we have a payment_id
1556 - if ($payment_id) {
1557 - // Send confirmation email to customer
1558 - $this->sendPaymentConfirmationEmail($invoice_id, $payment_id);
1559 - do_action('easy_invoice_manual_payment_confirmed', $invoice_id, $payment_id, $payment_method);
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));
1560 1690 }
1691 + if ($invoice_id) {
1692 + if (\EasyInvoice\Services\OfflinePayments::pendingForInvoice($invoice_id)) {
1693 + update_post_meta($invoice_id, '_payment_status', 'pending');
1694 + } else {
1695 + delete_post_meta($invoice_id, '_payment_status');
1696 + }
1697 + }
1698 + /**
1699 + * Fires when staff reject an offline payment submission.
1700 + *
1701 + * @param int $payment_id Payment record, now rejected.
1702 + * @param int $invoice_id Invoice.
1703 + * @param string $reason Reason given, if any.
1704 + */
1705 + do_action('easy_invoice_payment_rejected', $payment_id, $invoice_id, $reason);
1561 1706
1562 - easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice'));
1707 + easy_invoice_toast_success(__('Payment rejected. The invoice still shows the amount as due.', 'easy-invoice'));
1563 1708 }
1564 1709
1565 1710 /**
1566 1711 * Record money received, from the admin "Add New Payment" form.