| @@ -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. |