gatewayManager = EasyInvoice::getInstance()->getGatewayManager(); } /** * Initialize the controller */ public function init() { add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']); add_action('wp_ajax_easy_invoice_process_payment', [$this, 'processPayment']); add_action('wp_ajax_nopriv_easy_invoice_process_payment', [$this, 'processPayment']); add_action('wp_ajax_easy_invoice_update_payment', [$this, 'updatePayment']); add_action('wp_ajax_easy_invoice_record_payment', [$this, 'recordPayment']); add_action('wp_ajax_easy_invoice_payment_callback', [$this, 'handleCallback']); add_action('wp_ajax_nopriv_easy_invoice_payment_callback', [$this, 'handleCallback']); add_action('wp_ajax_easy_invoice_verify_manual_payment', [$this, 'verifyManualPayment']); add_action('wp_ajax_easy_invoice_reject_manual_payment', [$this, 'rejectManualPayment']); // Handler for submitting payment proof for manual gateways add_action('wp_ajax_easy_invoice_submit_payment_proof', [$this, 'submitPaymentProof']); add_action('wp_ajax_nopriv_easy_invoice_submit_payment_proof', [$this, 'submitPaymentProof']); // Handler for manual payment submission add_action('wp_ajax_easy_invoice_submit_manual_payment', [$this, 'submitManualPayment']); add_action('wp_ajax_nopriv_easy_invoice_submit_manual_payment', [$this, 'submitManualPayment']); // Handler for getting payment instructions for manual gateways add_action('wp_ajax_easy_invoice_get_payment_instructions', [$this, 'getPaymentInstructions']); add_action('wp_ajax_nopriv_easy_invoice_get_payment_instructions', [$this, 'getPaymentInstructions']); // Enqueue frontend scripts add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']); // Handler for admin to mark an invoice as paid add_action('wp_ajax_easy_invoice_approve_payment', [$this, 'mark_invoice_paid_ajax']); // Stripe payment handlers moved to Pro plugin add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); // Add reminder CRON job for pending payments add_action('easy_invoice_payment_reminder', [$this, 'sendPaymentReminders']); if (!wp_next_scheduled('easy_invoice_payment_reminder')) { wp_schedule_event(time(), 'daily', 'easy_invoice_payment_reminder'); } // Handle bulk actions add_action('admin_init', [$this, 'handleBulkActions']); } /** * Get payment instructions for manual gateways */ public function getPaymentInstructions() { // Verify nonce. $_POST['nonce'] was read unguarded, raising an // undefined-index warning before the check could run. $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!wp_verify_nonce($nonce, 'easy_invoice_payment')) { wp_send_json_error(['message' => 'Security check failed']); return; } $gateway = sanitize_text_field(($_POST['gateway'] ?? '')); $invoice_id = intval(($_POST['invoice_id'] ?? '')); if (!$gateway || !$invoice_id) { wp_send_json_error(['message' => 'Missing required parameters']); return; } // Get invoice $invoice_post = get_post($invoice_id); if (!$invoice_post || $invoice_post->post_type !== 'easy_invoice') { wp_send_json_error(['message' => 'Invalid invoice']); return; } $invoice = new \EasyInvoice\Models\Invoice($invoice_post); // Authorisation. // // The previous guard here was `!easy_invoice_user_can('ei_view_invoices') && // $invoice_post->post_status !== 'publish'`. That never fired: Models\Invoice // writes every invoice with post_status 'publish' regardless of workflow // status, so the second condition was always false. This endpoint is // registered nopriv and the nonce it checks is a shared, page-wide one, so // any caller could read the rendered payment instructions — which include // invoice-specific detail — for an arbitrary invoice id. // // Same check as everywhere else: valid ?ik= / access_token, administrator, or // the signed-in client the invoice belongs to. if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) { wp_send_json_error(['message' => __('Invoice not found', 'easy-invoice')]); return; } // Get gateway instance $gateway_instance = $this->gatewayManager->getGateway($gateway); if (!$gateway_instance) { wp_send_json_error(['message' => 'Gateway not found']); return; } // Get instructions using the hook system ob_start(); do_action('easy_invoice_payment_gateways_after', $invoice, $gateway); $instructions = ob_get_clean(); if ($instructions) { wp_send_json_success(['instructions' => $instructions]); } else { wp_send_json_error(['message' => 'No instructions available']); } } /** * Enqueue admin assets */ public function enqueueAssets() { $screen = get_current_screen(); if (!$screen || !property_exists($screen, 'id') || strpos($screen->id, 'easy-invoice') === false) { return; } // Enqueue manual payment script wp_enqueue_script( 'easy-invoice-manual-payment', EASY_INVOICE_PLUGIN_URL . 'assets/js/manual-payment.js', ['jquery'], '1.0.0', true ); // Localize script wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('easy_invoice_payment') ]); } /** * Enqueue frontend assets */ public function enqueueFrontendAssets() { // Only load on invoice pages if (is_singular('easy_invoice')) { wp_enqueue_script( 'easy-invoice-manual-payment', EASY_INVOICE_PLUGIN_URL . 'assets/js/manual-payment.js', ['jquery'], '1.0.0', true ); // Forward the per-invoice access token from the URL to the JS // so the manual-payment AJAX request can present it back to // canSubmitPaymentForInvoice. Without this the legitimate // email-link recipient flow would break — they'd hit the gate. $access_token = isset($_GET['ik']) ? sanitize_text_field(wp_unslash($_GET['ik'])) : ''; /** This filter is documented in includes/Controllers/InvoiceController.php */ $access_token = (string) apply_filters('easy_invoice_presented_access_token', $access_token, 'invoice'); wp_localize_script('easy-invoice-manual-payment', 'easy_invoice_ajax', [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('easy_invoice_payment'), 'access_token' => $access_token, ]); } } /** * Display method implementation * * @param array $args Display arguments */ public function display(array $args = []) { $page = isset($args['page']) ? $args['page'] : ''; switch ($page) { case PagesSlugs::PAYMENTS: $this->displayPaymentsPage(); break; case PagesSlugs::PAYMENT_NEW: $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/new.php'); break; case 'view': $payment_id = isset($_GET['id']) ? intval($_GET['id']) : 0; if ($payment_id) { $payment_post = get_post($payment_id); if ($payment_post && $payment_post->post_type === 'easy_invoice_payment') { try { $payment = new Payment($payment_post); $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/view.php', ['payment' => $payment]); } catch (\Exception $e) { wp_die(esc_html__('Invalid payment ID', 'easy-invoice')); } } else { wp_die(esc_html__('Invalid payment ID', 'easy-invoice')); } } else { wp_die(esc_html__('Payment ID is required', 'easy-invoice')); } break; case 'edit': $payment_id = isset($_GET['id']) ? intval($_GET['id']) : 0; if ($payment_id) { $payment_post = get_post($payment_id); if ($payment_post && $payment_post->post_type === 'easy_invoice_payment') { try { $payment = new Payment($payment_post); $this->displayTemplate(EASY_INVOICE_PLUGIN_DIR . 'templates/payments/edit.php', ['payment' => $payment]); } catch (\Exception $e) { wp_die(esc_html__('Invalid payment ID', 'easy-invoice')); } } else { wp_die(esc_html__('Invalid payment ID', 'easy-invoice')); } } else { wp_die(esc_html__('Payment ID is required', 'easy-invoice')); } break; default: $this->displayPaymentsPage(); break; } } /** * Display payments page with pagination */ protected function displayPaymentsPage() { // Get current view (all, trash) $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all'; // Get status filter $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : ''; // Pagination settings $per_page = 20; $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // Build query arguments $args = array( 'post_type' => 'easy_invoice_payment', 'posts_per_page' => $per_page, 'paged' => $current_page, 'orderby' => 'ID', 'order' => 'DESC', 'no_found_rows' => false, // We need this for pagination ); // Set post status based on current view if ($current_view === 'trash') { $args['post_status'] = 'trash'; } else { $args['post_status'] = 'publish'; } // Add status filter if set if (!empty($status_filter)) { $args['meta_query'] = array( array( 'key' => '_status', 'value' => $status_filter, ), ); } // Allow plugins to modify query arguments $args = apply_filters('easy_invoice_payment_controller_query_args', $args, $current_view, $status_filter); // Get paginated payments using WordPress query $wp_query = new \WP_Query($args); $payments = []; if ($wp_query->have_posts()) { while ($wp_query->have_posts()) { $wp_query->the_post(); $post = get_post(); $payment = new Payment($post); $payments[] = $payment; } } wp_reset_postdata(); // Allow plugins to modify the payments array $payments = apply_filters('easy_invoice_payment_controller_payments_list', $payments, $wp_query); // Get pagination info from WordPress query $total_payments = $wp_query->found_posts; $total_pages = $wp_query->max_num_pages; // Statistics over ALL payments (not just the current page), in one SQL // pass. Loading every payment as a model to add them up did not scale. global $wpdb; $stats_status = 'trash' === $current_view ? 'trash' : 'publish'; $stat_rows = $wpdb->get_results( $wpdb->prepare( "SELECT st.meta_value AS status, COUNT(*) AS n, SUM(CAST(COALESCE(NULLIF(a.meta_value, ''), '0') AS DECIMAL(18,4))) AS amount FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' LEFT JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = '_amount' WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = %s GROUP BY st.meta_value", $stats_status ), ARRAY_A ); $stats = [ 'total_payments' => 0, 'total_amount' => 0, 'completed_payments' => 0, 'pending_payments' => 0, 'failed_payments' => 0, ]; foreach ( (array) $stat_rows as $row ) { $stats['total_payments'] += (int) $row['n']; $stats['total_amount'] += (float) $row['amount']; $key = $row['status'] . '_payments'; if ( isset( $stats[ $key ] ) ) { $stats[ $key ] += (int) $row['n']; } } $stats = array_merge([ 'total_payments' => 0, 'total_amount' => 0, 'completed_payments' => 0, 'pending_payments' => 0, 'failed_payments' => 0 ], $stats); // Get trash count for tab display $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'easy_invoice_payment' AND post_status = 'trash'" ); // Define available status filters $status_filters = array( 'completed' => 'Completed', 'pending' => 'Pending', 'failed' => 'Failed' ); // Prepare template data $template_data = [ 'payments' => $payments, 'current_view' => $current_view, 'status_filter' => $status_filter, 'status_filters' => $status_filters, 'trash_count' => $trash_count, 'stats' => $stats, 'current_page' => $current_page, 'per_page' => $per_page, 'total_payments' => $total_payments, 'total_pages' => $total_pages, 'wp_query' => $wp_query ]; // Allow plugins to modify template data $template_data = apply_filters('easy_invoice_payment_controller_template_data', $template_data); // Display the template $this->displayTemplate( EASY_INVOICE_PLUGIN_DIR . 'templates/payments/list.php', $template_data ); // Allow plugins to perform actions after displaying payments page do_action('easy_invoice_payment_controller_after_display_payments_page', $template_data); } /** * Enqueue required scripts and styles */ public function enqueueScripts(): void { // Check if scripts are already enqueued if (wp_script_is('easy-invoice-payment', 'enqueued')) { return; } // The payment panel exists on the public invoice page only; every // other front-end page of the site has no use for the script (or the // jQuery it pulls in). /** * Filter whether the payment script loads on the current front-end request. * * @param bool $load Default: on a public invoice page. */ if (!apply_filters('easy_invoice_load_payment_assets', is_singular(PostTypes::EASY_INVOICE_POST_TYPE))) { return; } // Enqueue our custom scripts wp_enqueue_script( 'easy-invoice-payment', EASY_INVOICE_URL . 'assets/js/payment.js', ['jquery'], EASY_INVOICE_VERSION, true ); // Get currency settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $settings = $settings_controller->getSettings(); $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD'; $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); // Localize script variables for payment form // Forward the per-invoice access token (?ik=...) the same way the manual // payment script already does. The payment endpoints authorise on it, and // without this an anonymous client following an emailed link would have a // token in their URL that never reached the AJAX request. $ei_access_token = isset($_GET['ik']) ? sanitize_text_field(wp_unslash($_GET['ik'])) : ''; /** This filter is documented in includes/Controllers/InvoiceController.php */ $ei_access_token = (string) apply_filters('easy_invoice_presented_access_token', $ei_access_token, 'invoice'); wp_localize_script('easy-invoice-payment', 'easy_invoice_vars', [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('easy_invoice_payment'), 'access_token' => $ei_access_token, 'currency_symbol' => $currency_symbol, 'currency_code' => $currency_code ]); } // Stripe methods moved to Pro plugin /** * Process payment via AJAX */ public function processPayment() { check_ajax_referer('easy_invoice_payment', 'payment_nonce'); $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $payment_method_slug = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : ''; $invoice_post = $invoice_id ? get_post($invoice_id) : null; if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]); return; } $invoice = new \EasyInvoice\Models\Invoice($invoice_post); // Authorisation. This endpoint is nopriv and previously relied on a shared, // page-wide nonce alone, so a caller holding one could start a payment // against any invoice id and read back its amount and gateway details. // Legitimate callers reach this from the invoice page, which forwards the // per-invoice access token (see payment.js / payment-section.php). // // This MUST stay above the `easy_invoice_before_process_payment` filter // below. That filter is not a notification — it is a dispatch point that // short-circuits the whole request, and Easy Invoice Pro attaches four // handlers to it (Stripe, Authorize.Net, Moneris and Partial Payments). // While the check sat after the filter, those four gateways — every card // gateway Pro ships — completed payments without the token ever being // examined, so the gate only really covered the free plugin's own // gateways. Authorising before dispatch is the whole point of the gate. if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) { // A Pro build older than this plugin cannot forward the token — see // legacyProPaymentFallbackAllowed(). Refusing here would take the // customer's money on Stripe without recording the payment. if (!$this->legacyProPaymentFallbackAllowed($payment_method_slug)) { wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]); return; } } // Add filter for extensions to handle custom payment logic (e.g., partial payments) $custom_result = apply_filters('easy_invoice_before_process_payment', null, $invoice_id, $_POST); if (is_array($custom_result) && isset($custom_result['handled']) && $custom_result['handled']) { if ($custom_result['success']) { wp_send_json_success($custom_result); } else { wp_send_json_error(['message' => $custom_result['message'] ?? __('Payment failed.', 'easy-invoice')]); } return; } if (!$payment_method_slug) { wp_send_json_error(['message' => __('Missing required fields.', 'easy-invoice')]); return; } // Charge what is owed, not the face value: a part-paid or partly // credited invoice must not be collected twice. $due = \EasyInvoice\Services\InvoiceBalance::due($invoice); $amount = $due; if ($due <= 0) { wp_send_json_error(['message' => __('Nothing is owed on this invoice.', 'easy-invoice')]); return; } // A smaller amount is charged only when something (the Partial // Payments addon) says this invoice may be paid in instalments. $requested = isset($_POST['payment_amount']) ? round((float) str_replace(',', '', sanitize_text_field(wp_unslash($_POST['payment_amount']))), 2) : 0.0; $is_partial = isset($_POST['is_partial_payment']) && '1' === (string) sanitize_text_field(wp_unslash($_POST['is_partial_payment'])); if ($is_partial && $requested > 0 && $requested < $due) { /** * Filter whether the client may pay less than the amount due. * * @param bool $allow Default false. * @param object $invoice Invoice model. * @param float $requested Amount the client asked to pay. */ if (apply_filters('easy_invoice_allow_partial_payment_amount', false, $invoice, $requested)) { $amount = $requested; } } $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug); if (!$gateway_instance || !$gateway_instance->isEnabled() || !$gateway_instance->isAvailable()) { wp_send_json_error(['message' => __('Selected payment gateway is not available or configured correctly.', 'easy-invoice')]); return; } try { // Pass the entire $_POST array to the gateway $result = $gateway_instance->processPayment($amount, $_POST); if (isset($result['success']) && $result['success']) { // An offline gateway with no follow-up step (cash, the free // manual gateway) leaves the invoice pending here; bank // transfer and cheque notify the admin themselves once the // proof or cheque details arrive. $offline_status = (string) ($result['payment_status'] ?? ($result['data']['status'] ?? '')); if (in_array($payment_method_slug, ['manual', 'cash'], true) && 0 === strpos($offline_status, 'pending')) { do_action('easy_invoice_manual_payment_submitted', $invoice_id, $payment_method_slug); } wp_send_json_success($result); } else { wp_send_json_error(['message' => $result['message'] ?? __('Payment processing failed with the gateway.', 'easy-invoice')]); } } catch (\Exception $e) { error_log('Easy Invoice Payment Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ' on line ' . $e->getLine()); wp_send_json_error(['message' => __('An unexpected error occurred during payment processing. Please check plugin logs or contact support.', 'easy-invoice')]); } } /** * Whether to accept a payment that presented no per-invoice access token, * because the Easy Invoice Pro build installed alongside cannot send one. * * Why this exists * --------------- * Pro's Stripe and Authorize.Net scripts post to `easy_invoice_process_payment`, * which is a free-plugin endpoint, and from 2.4.0 that endpoint authorises on the * per-invoice access token. Pro only began forwarding the token in 2.3.0. * * The two plugins update through different channels — free auto-updates from * WordPress.org, Pro arrives from the licence server — so "free is newer than Pro" * is not an edge case, it is the normal state for a while after release. Without * this fallback, that pairing breaks client payments, and for Stripe it breaks them * in the worst possible way: the script confirms the charge with Stripe FIRST and * only then posts here to record it, so a refusal means the customer has paid and * the invoice still says unpaid. * * What it does and does not allow * ------------------------------- * The relaxation is deliberately narrow, and is never wider than the behaviour * that already shipped in 2.3.8: * * - Only when Pro is active AND older than 2.3.0. It disappears by itself the * moment Pro is updated; there is nothing to remember to turn off. * - Only when NO token was presented at all. A request carrying a wrong or * expired token is a forgery attempt, not an old client script, and is refused. * - Only for gateways provided by Pro. The free plugin's own scripts always * forward the token, so a free gateway reaching here without one is not a * version-skew case. * - The shared `easy_invoice_payment` nonce has already been verified by the * caller before this is consulted. * - `getPaymentInstructions()` does NOT use this. That is the information * disclosure path and stays fully gated regardless of Pro's version. * * Site owners who would rather fail the payment than accept the older * authorisation can return false from * `easy_invoice_allow_legacy_pro_payment_fallback`. * * @param string $payment_method_slug Gateway slug from the request. * @return bool */ private function legacyProPaymentFallbackAllowed(string $payment_method_slug): bool { if (!function_exists('easy_invoice_has_pro') || !easy_invoice_has_pro()) { return false; } // An older Pro that predates token forwarding. Treat a missing version // constant as "older", since every build that defines it is >= 2.1. $pro_version = defined('EASY_INVOICE_PRO_VERSION') ? (string) EASY_INVOICE_PRO_VERSION : '0'; if (version_compare($pro_version, self::PRO_TOKEN_FORWARDING_VERSION, '>=')) { return false; } // A presented-but-invalid token is an attack, not version skew. if (isset($_POST['access_token']) && $_POST['access_token'] !== '') { return false; } if (isset($_GET['ik']) && $_GET['ik'] !== '') { return false; } // Restrict to gateways Pro actually provides. $gateway_instance = $this->gatewayManager->getGateway($payment_method_slug); if (!$gateway_instance || strpos(get_class($gateway_instance), 'EasyInvoicePro\\') !== 0) { return false; } /** * Filter the legacy Pro payment fallback. * * @param bool $allowed Whether to accept the payment. * @param string $pro_version Version of Easy Invoice Pro detected. * @param string $payment_method_slug Gateway slug from the request. */ $allowed = (bool) apply_filters( 'easy_invoice_allow_legacy_pro_payment_fallback', true, $pro_version, $payment_method_slug ); if ($allowed) { update_option('easy_invoice_legacy_pro_payment_seen', $pro_version, false); } return $allowed; } /** * Handle payment callback/webhook */ public function handleCallback(): void { check_ajax_referer('easy_invoice_payment', 'payment_nonce'); $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $gateway = isset($_POST['gateway']) ? sanitize_text_field($_POST['gateway']) : ''; if (!$invoice_id || !$gateway) { wp_send_json_error(['message' => __('Invalid request', 'easy-invoice')]); } // Authorisation. // // This endpoint is registered nopriv and the only thing standing in front of // it was the shared, page-wide `easy_invoice_payment` nonce, which is rendered // on every public invoice page — so anyone able to view a single invoice could // lift one and then call this for any id they liked. The id was passed straight // to the gateway without even confirming it was an invoice. // // That mattered because the cheque gateway's callback writes: it stores the // cheque number, bank name, date and an uploaded image against whatever id it // is handed. An unauthenticated caller could therefore attach forged cheque // details, and a file, to any invoice on the site — or to any post at all. // // Same rule as everywhere else: valid per-invoice access key, administrator, or // the signed-in client the invoice belongs to. $invoice_post = get_post($invoice_id); if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]); } $invoice = new \EasyInvoice\Models\Invoice($invoice_post); if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice) && !$this->legacyProPaymentFallbackAllowed($gateway)) { wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]); } $gateway_instance = $this->gatewayManager->getGateway($gateway); if (!$gateway_instance) { wp_send_json_error(['message' => __('Invalid payment gateway', 'easy-invoice')]); } $result = $gateway_instance->handleCallback($_POST); // Tell the admin an offline payment is waiting for verification. Pro's // bank-transfer and cheque gateways email the admin themselves from // handleCallback(); the free manual gateway and Pro's cash gateway do // not. (This used to test for 'bank' and 'cheque' — ids no gateway // has — so it never fired.) if ($result['success'] && in_array($gateway, ['manual', 'cash'], true)) { do_action('easy_invoice_manual_payment_submitted', $invoice_id, $gateway); } if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result); } } /** * Get available payment gateways for an invoice * * @param int $invoice_id * @return array */ public function getAvailableGateways(int $invoice_id): array { $post = get_post($invoice_id); if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { return []; } $invoice = new \EasyInvoice\Models\Invoice($post); $invoice_status = $invoice->getStatus(); // Anything that still has a balance can be paid: an overdue invoice is the // one a client most needs to settle, and a partially paid one still owes. // Drafts, paid, cancelled and "awaiting verification" stay closed. $payable_statuses = apply_filters('easy_invoice_payable_statuses', [ 'unpaid', 'available', 'overdue', 'partial', 'sent', 'pending' ]); if (!in_array($invoice_status, $payable_statuses, true)) { return []; } $enabled_gateways = $this->gatewayManager->getEnabledGateways(); if (empty($enabled_gateways)) { return []; } // Get invoice-specific gateways (comma-separated string or empty) $invoice_gateways = $invoice->getPaymentGateways(); $selected_gateways = []; // Handle both string and array formats if (!empty($invoice_gateways)) { if (is_string($invoice_gateways)) { // If it's a string, split by comma $selected_gateways = array_filter(array_map('trim', explode(',', $invoice_gateways))); } elseif (is_array($invoice_gateways)) { // If it's already an array, use it directly $selected_gateways = array_filter($invoice_gateways); } } $available_gateways = []; $gateway_manager = \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager(); // $enabled_gateways is an associative array with gateway_id as key and gateway object as value foreach ($enabled_gateways as $gateway_id => $gateway) { // If invoice has custom gateways selected, only show those // If no custom gateways are selected (empty array), show all enabled gateways if (!empty($selected_gateways) && !in_array($gateway_id, $selected_gateways, true)) { continue; } $is_available = $gateway->isAvailable(); if ($is_available) { $available_gateways[] = [ 'id' => $gateway_id, 'title' => $gateway_manager->getGatewayDisplayName($gateway_id), 'icon' => $gateway->getIcon(), 'description' => $gateway->getDescription() ]; } } return $available_gateways; } /** * Update payment via AJAX */ public function updatePayment() { check_ajax_referer('easy_invoice_payment', 'payment_nonce'); // Authorisation: this handler mutates payment-record fields // (amount, method, status, notes) and on status=completed it // can flip the linked invoice to paid via // updateInvoiceStatusIfPaid(). The shared `easy_invoice_payment` // nonce is rendered on every public invoice page so any // authenticated visitor can obtain a valid one — the nonce is // CSRF defense, NOT authorisation. Gate on the same payment- // management capability as the sibling verifyManualPayment / // rejectManualPayment / mark_invoice_paid_ajax handlers. if (!easy_invoice_user_can('ei_record_payment')) { wp_send_json_error(['message' => __('You do not have permission to update payments.', 'easy-invoice')]); return; } $payment_id = isset($_POST['payment_id']) ? intval($_POST['payment_id']) : 0; $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0; $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : ''; $payment_date = isset($_POST['payment_date']) ? sanitize_text_field($_POST['payment_date']) : current_time('Y-m-d'); $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'pending'; $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : ''; if (!$payment_id || !$invoice_id || !$amount || !$payment_method) { wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]); return; } try { // Check if payment post exists before instantiating $payment_post = get_post($payment_id); if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') { wp_send_json_error(['message' => __('Invalid payment', 'easy-invoice')]); return; } $payment = new Payment($payment_post); // Get the old payment status before updating $old_status = $payment->getStatus(); $post = get_post($invoice_id); if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]); return; } $invoice = new Invoice($post); $payment_data = [ 'invoice_id' => $invoice_id, 'amount' => $amount, 'payment_method' => $payment_method, 'payment_date' => $payment_date, 'status' => $status, 'notes' => $notes, 'gateway_response' => [ 'method' => $payment_method, 'date' => $payment_date, 'notes' => $notes ] ]; $result = $payment->update($payment_data); if ($result) { // Update invoice status based on payment status change if ($status === 'completed' && $old_status !== 'completed') { // Payment changed TO completed - check if invoice should be marked as paid $invoice->setMeta('_payment_method', $payment_method); $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual'); } elseif ($status !== 'completed' && $old_status === 'completed') { // Payment changed FROM completed to another status (failed, pending, etc.) // Recalculate total payments and update invoice status accordingly $total_payments = $this->calculateTotalPaymentsForInvoice($invoice_id); $invoice_total = $invoice->getTotal(); if ($total_payments < $invoice_total) { // Not enough payments any more: part paid if anything // remains, otherwise back to awaiting payment. An issued // invoice never returns to draft. $invoice->setStatus($total_payments > 0 ? 'partial' : 'available'); $invoice->save(); } else { // Still enough payments from other completed payments $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual'); } } wp_send_json_success([ 'message' => __('Payment updated successfully', 'easy-invoice') ]); } else { wp_send_json_error(['message' => __('Failed to update payment', 'easy-invoice')]); } } catch (\Exception $e) { wp_send_json_error(['message' => $e->getMessage()]); } } /** * Verify manual payment */ public function verifyManualPayment(): void { // Check permissions if (!easy_invoice_user_can('ei_record_payment')) { wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]); return; } // Verify nonce check_ajax_referer('easy_invoice_admin', 'nonce'); $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $amount = isset($_POST['amount']) ? floatval($_POST['amount']) : 0; $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : ''; $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : ''; $transaction_id = isset($_POST['transaction_id']) ? sanitize_text_field($_POST['transaction_id']) : ''; if (!$invoice_id || !$amount || !$payment_method) { wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]); return; } // Get the invoice $post = get_post($invoice_id); if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]); return; } $invoice = new Invoice($post); // Get currency settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $settings = $settings_controller->getSettings(); $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD'; $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); $payment_data = [ 'invoice_id' => $invoice_id, 'amount' => $amount, 'payment_method' => $payment_method, 'payment_date' => current_time('mysql'), 'notes' => $notes, 'status' => 'completed', 'payment_type' => 'full', 'transaction_id' => $transaction_id, 'recurring_id' => '', 'parent_payment_id' => '', 'currency' => $currency_code, 'currency_symbol' => $currency_symbol, 'gateway_response' => [ 'admin_verified' => true, 'verification_date' => current_time('mysql'), 'verification_user' => get_current_user_id() ] ]; try { $payment = Payment::create($payment_data); // Store payment details before updating status (for the hook) $invoice->setMeta('_payment_method', $payment_method); if ($transaction_id) { $invoice->setMeta('_transaction_id', $transaction_id); } // Update invoice status to paid only if total payments are sufficient // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'manual'); // Send confirmation email to customer $this->sendPaymentConfirmationEmail($invoice_id, $payment->getId()); wp_send_json_success([ 'message' => __('Payment verified successfully', 'easy-invoice'), 'payment_id' => $payment->getId() ]); } catch (\Exception $e) { wp_send_json_error(['message' => $e->getMessage()]); } } /** * Reject manual payment */ public function rejectManualPayment(): void { // Check permissions — rejecting a manual payment is a record-payment // operation (it transitions state, doesn't refund money). if (!easy_invoice_user_can('ei_record_payment')) { wp_send_json_error(['message' => __('You do not have permission to perform this action', 'easy-invoice')]); return; } // Verify nonce check_ajax_referer('easy_invoice_admin', 'nonce'); $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $reason = isset($_POST['reason']) ? sanitize_textarea_field($_POST['reason']) : ''; if (!$invoice_id) { wp_send_json_error(['message' => __('Invoice ID is required', 'easy-invoice')]); return; } // Get the invoice $post = get_post($invoice_id); if (!$post || $post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]); return; } $invoice = new Invoice($post); // Update invoice status update_post_meta($invoice_id, '_payment_status', 'rejected'); // Add rejection reason update_post_meta($invoice_id, '_payment_rejection_reason', $reason); update_post_meta($invoice_id, '_payment_rejection_date', current_time('mysql')); update_post_meta($invoice_id, '_payment_rejection_user', get_current_user_id()); // Send rejection email to customer $this->sendPaymentRejectionEmail($invoice_id, $reason); wp_send_json_success([ 'message' => __('Payment rejected successfully', 'easy-invoice') ]); } /** * Send payment confirmation email to customer * * @param int $invoice_id * @param int $payment_id */ private function sendPaymentConfirmationEmail($invoice_id, $payment_id): void { $invoice = new Invoice(get_post($invoice_id)); if (!$invoice || !$invoice->getId()) { return; } // Use EmailManager to send payment confirmation using proper template system // This will check if payment email is enabled in settings $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $email_manager->sendInvoiceEmail($invoice, 'paid', [ 'payment_id' => $payment_id, 'skip_bcc' => true // Skip BCC to admin since this is a direct call ]); } /** * Send payment rejection email to customer * * @param int $invoice_id * @param string $reason */ private function sendPaymentRejectionEmail($invoice_id, $reason): void { $invoice = new Invoice(get_post($invoice_id)); if (!$invoice || !$invoice->getId()) { return; } // Use EmailManager to send payment rejection $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $email_manager->sendPaymentRejectionEmail($invoice, $reason); } /** * Send payment reminders for pending manual payments */ public function sendPaymentReminders(): void { // Get invoices with pending manual payments $pending_invoices = get_posts([ 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE, 'posts_per_page' => -1, 'meta_query' => [ 'relation' => 'AND', [ 'key' => '_payment_status', 'value' => ['pending-bank', 'pending-cheque'], 'compare' => 'IN' ], [ 'key' => '_payment_reminder_sent', 'compare' => 'NOT EXISTS' ] ] ]); if (!empty($pending_invoices)) { // Get currency settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $settings = $settings_controller->getSettings(); $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD'; $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); foreach ($pending_invoices as $post) { $invoice = new Invoice($post); if (!$invoice || !$invoice->getId()) { continue; } // Use EmailManager to send payment reminder $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $result = $email_manager->sendInvoiceEmail($invoice, 'reminder', [ 'payment_method' => get_post_meta($invoice->getId(), '_payment_method', true) ]); // Mark reminder as sent if email was sent successfully if ($result['success']) { update_post_meta($invoice->getId(), '_payment_reminder_sent', current_time('mysql')); } } wp_reset_postdata(); } } /** * Submit manual payment */ public function submitManualPayment(): void { // CSRF defense — keep the existing nonce check. The nonce is // global (`easy_invoice_payment`) so any public invoice page leaks // a valid value; the REAL authorisation gate is the ownership // check below. if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_payment')) { wp_send_json_error(['message' => __('Security check failed', 'easy-invoice')]); return; } $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $payment_type = isset($_POST['payment_type']) ? sanitize_text_field($_POST['payment_type']) : ''; $payment_notes = isset($_POST['payment_notes']) ? sanitize_textarea_field($_POST['payment_notes']) : ''; if (!$invoice_id || !$payment_type) { wp_send_json_error(['message' => __('Missing required fields', 'easy-invoice')]); return; } // Get invoice $invoice_post = get_post($invoice_id); if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice', 'easy-invoice')]); return; } $invoice = new \EasyInvoice\Models\Invoice($invoice_post); // Authorisation: reject unless the caller is the legitimate email // recipient (per-invoice access token), an admin, or the // logged-in client bound to this invoice. Without this gate the // public AJAX endpoint allowed any visitor with a harvested // global nonce to flood arbitrary invoices into // `pending_verification` and attach payment-proof uploads. if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) { wp_send_json_error([ 'message' => __('You do not have permission to submit a payment for this invoice.', 'easy-invoice'), ]); return; } $currency_code = $invoice->getCurrencyCode() ?: 'USD'; if ($currency_code === 'global') { $currency_code = get_option('easy_invoice_currency_code', 'USD'); } $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); // Handle file upload (never trust client MIME or filename extension — use WordPress filetype APIs) $proof_url = ''; if (isset($_FILES['payment_proof']) && $_FILES['payment_proof']['error'] === UPLOAD_ERR_OK) { $file = $_FILES['payment_proof']; if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { wp_send_json_error(['message' => __('Invalid upload.', 'easy-invoice')]); return; } $max_size = 5 * 1024 * 1024; // 5MB if ($file['size'] > $max_size) { wp_send_json_error(['message' => __('File size must be less than 5MB.', 'easy-invoice')]); return; } $allowed_mimes = [ 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'pdf' => 'application/pdf', ]; $checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowed_mimes); if (empty($checked['ext']) || empty($checked['type'])) { wp_send_json_error(['message' => __('Invalid file type. Only JPG, PNG, GIF, and PDF files are allowed.', 'easy-invoice')]); return; } $allowed_types = array_values($allowed_mimes); if (!in_array($checked['type'], $allowed_types, true)) { wp_send_json_error(['message' => __('Invalid file type. Only JPG, PNG, GIF, and PDF files are allowed.', 'easy-invoice')]); return; } $upload_dir = wp_upload_dir(); $proof_dir = $upload_dir['basedir'] . '/easy-invoice/payment-proofs/'; if (!wp_mkdir_p($proof_dir)) { wp_send_json_error(['message' => __('Could not create upload directory.', 'easy-invoice')]); return; } // Hand the move to WordPress rather than move_uploaded_file(): it // applies the site's filesystem method and permissions, and lets // the usual upload filters see the file. The directory is pointed // at our proofs folder for the duration of this one call. // Random, not time-based: a receipt carries bank details and the URL // is public, so the name must not be guessable. $filename = 'payment_proof_' . wp_generate_password(24, false, false) . '.' . $checked['ext']; $proof_url = $upload_dir['baseurl'] . '/easy-invoice/payment-proofs/'; $to_proofs = static function ($dirs) use ($proof_dir, $proof_url) { $dirs['path'] = untrailingslashit($proof_dir); $dirs['url'] = untrailingslashit($proof_url); $dirs['subdir'] = '/easy-invoice/payment-proofs'; return $dirs; }; if (!function_exists('wp_handle_upload')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } add_filter('upload_dir', $to_proofs); \EasyInvoice\Helpers\UploadGuard::protectDirectory((wp_upload_dir())['basedir'] . '/easy-invoice/payment-proofs'); $moved = wp_handle_upload($file, [ 'test_form' => false, 'mimes' => $allowed_mimes, 'unique_filename_callback' => static function () use ($filename) { return $filename; }, ]); remove_filter('upload_dir', $to_proofs); if (!is_array($moved) || !empty($moved['error']) || empty($moved['url'])) { wp_send_json_error(['message' => __('Failed to save payment proof file.', 'easy-invoice')]); return; } $proof_url = $moved['url']; } // Create payment record $payment_data = [ 'post_title' => sprintf('Manual Payment (%s) for Invoice #%s', ucfirst($payment_type), $invoice->getNumber()), 'post_type' => 'easy_invoice_payment', 'post_status' => 'publish', 'post_author' => get_current_user_id(), ]; $payment_id = wp_insert_post($payment_data); if (is_wp_error($payment_id)) { wp_send_json_error(['message' => __('Failed to create payment record', 'easy-invoice')]); return; } // Save payment metadata update_post_meta($payment_id, '_invoice_id', $invoice_id); update_post_meta($payment_id, '_amount', $invoice->getTotal()); update_post_meta($payment_id, '_payment_method', 'manual'); update_post_meta($payment_id, '_payment_type', $payment_type); update_post_meta($payment_id, '_status', 'pending'); update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time()); update_post_meta($payment_id, '_payment_date', current_time('mysql')); update_post_meta($payment_id, '_notes', $payment_notes); update_post_meta($payment_id, '_currency', $currency_code); update_post_meta($payment_id, '_currency_symbol', $currency_symbol); update_post_meta($payment_id, '_payment_proof', $proof_url); // Update invoice status to pending verification $invoice->setStatus('pending_verification'); $invoice->save(); // Store payment details on invoice $invoice->setMeta('_payment_method', 'manual'); $invoice->setMeta('_payment_type', $payment_type); $invoice->setMeta('_payment_status', 'pending'); $invoice->setMeta('_manual_payment_id', $payment_id); $invoice->setMeta('_manual_payment_proof', $proof_url); $invoice->setMeta('_manual_payment_notes', $payment_notes); // Send admin notification do_action('easy_invoice_manual_payment_submitted', $invoice_id, $payment_type); wp_send_json_success([ 'message' => __('Payment submitted successfully! Your payment will be verified by the administrator.', 'easy-invoice'), 'payment_id' => $payment_id ]); } /** * Handle submission of payment proof for manual gateways (Bank Transfer, Cheque) */ public function submitPaymentProof(): void { $gateway_name = isset($_POST['gateway']) ? sanitize_text_field($_POST['gateway']) : ''; $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; if (empty($gateway_name) || empty($invoice_id)) { wp_send_json_error(['message' => __('Invalid request. Missing gateway or invoice ID.', 'easy-invoice')]); return; } // Nonce verification (make nonce name consistent or check based on gateway) $nonce_action = 'easy_invoice_payment_proof_' . $invoice_id; // Bank transfer nonce $nonce_value = isset($_POST['payment_proof_nonce']) ? sanitize_text_field($_POST['payment_proof_nonce']) : ''; if ($gateway_name === 'cheque') { $nonce_action = 'easy_invoice_cheque_notification_' . $invoice_id; // Cheque nonce $nonce_value = isset($_POST['cheque_notification_nonce']) ? sanitize_text_field($_POST['cheque_notification_nonce']) : ''; } if (!wp_verify_nonce($nonce_value, $nonce_action)) { wp_send_json_error(['message' => __('Nonce verification failed. Please try again.', 'easy-invoice')]); return; } // Optional: Add capability check if this can be submitted by logged-in users only from frontend // if (is_user_logged_in() && !current_user_can('read_invoice', $invoice_id)) { // Example capability // wp_send_json_error(['message' => __('You do not have permission to submit proof for this invoice.', 'easy-invoice')]); // return; // } $gateway = $this->gatewayManager->getGateway($gateway_name); if (!$gateway || !method_exists($gateway, 'handleProofSubmission')) { wp_send_json_error(['message' => __('Invalid payment gateway or submission handler not found.', 'easy-invoice')]); return; } // Prepare data for the gateway handler $post_data = stripslashes_deep($_POST); $files_data = $_FILES; $result = $gateway->handleProofSubmission($post_data, $files_data); if ($result['success']) { wp_send_json_success(['message' => $result['message']]); } else { wp_send_json_error(['message' => $result['message']]); } } /** * AJAX handler for admin to mark an invoice as paid. */ public function mark_invoice_paid_ajax(): void { $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; $nonce = isset($_POST['nonce']) ? sanitize_text_field($_POST['nonce']) : ''; $notes = isset($_POST['notes']) ? sanitize_textarea_field($_POST['notes']) : ''; if (empty($invoice_id) || !wp_verify_nonce($nonce, 'easy_invoice_approve_payment')) { easy_invoice_toast_error(__('Invalid request or security check failed.', 'easy-invoice')); return; } // Mark-as-paid is a record-payment action — gated by the matching cap. if (!easy_invoice_user_can('ei_record_payment')) { easy_invoice_toast_error(__('You do not have permission to perform this action.', 'easy-invoice')); return; } $invoice_post = get_post($invoice_id); if (!$invoice_post || $invoice_post->post_type !== \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) { wp_send_json_error(['message' => __('Invalid invoice.', 'easy-invoice')]); return; } $invoice = new Invoice($invoice_post); // For manual approval, always use 'manual' as payment method $payment_method = 'manual'; // Update invoice post status to 'publish' (or your primary paid status) wp_update_post(['ID' => $invoice_id, 'post_status' => 'publish']); update_post_meta($invoice_id, '_payment_status', 'completed'); // General completed status for payments // Allow plugins to control invoice status update $should_update_invoice_status = apply_filters('easy_invoice_should_update_invoice_status', true, $invoice_id); if ($should_update_invoice_status) { update_post_meta($invoice_id, InvoiceFields::STATUS, 'paid'); // Specific invoice status field if used by model } // Use submitted notes or default note $payment_notes = !empty($notes) ? $notes : __('Payment manually verified by admin.', 'easy-invoice'); // Find existing pending payment records for this invoice $existing_payment_args = [ 'post_type' => 'easy_invoice_payment', 'posts_per_page' => 1, 'meta_query' => [ 'relation' => 'AND', [ 'key' => '_invoice_id', 'value' => $invoice_id, ], [ 'key' => '_status', 'value' => ['pending-bank', 'pending-cheque', 'pending'], // Check against pending statuses 'compare' => 'IN' ] ] ]; $existing_payments = get_posts($existing_payment_args); $payment_id = null; if (!empty($existing_payments)) { // Update existing pending payment instead of creating new one $payment_id = $existing_payments[0]->ID; update_post_meta($payment_id, '_status', 'completed'); // Update status to completed update_post_meta($payment_id, '_payment_method', 'manual'); // Set payment method to manual update_post_meta($payment_id, '_transaction_id', 'MANUAL-' . $invoice_id . '-' . time()); update_post_meta($payment_id, '_payment_date', current_time('mysql')); update_post_meta($payment_id, '_notes', $payment_notes); // Update notes on existing payment } else { // Only create a new payment if no pending payments exist // This prevents creating duplicate payment records $existing_payments = get_posts([ 'post_type' => 'easy_invoice_payment', 'posts_per_page' => -1, 'meta_query' => [ [ 'key' => '_invoice_id', 'value' => $invoice_id, ] ] ]); if (!empty($existing_payments)) { // If payments exist but none are pending, don't create a new one // Just update the invoice status easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice')); return; } // Get currency from invoice $currency_code = get_post_meta($invoice_id, '_easy_invoice_currency_code', true); if (empty($currency_code) || $currency_code === 'global') { $currency_code = get_option('easy_invoice_currency_code', 'USD'); } $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); $payment_data = [ 'invoice_id' => $invoice_id, 'amount' => $invoice->getTotal(), // Or get amount from proof submission if it varies 'payment_method' => $payment_method, 'status' => 'completed', 'transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id, 'payment_date' => current_time('mysql'), 'notes' => $payment_notes, // Use provided notes 'payment_type' => 'manual', 'currency' => $currency_code, 'currency_symbol' => $currency_symbol, 'gateway_response' => json_encode([ 'admin_verified' => true, 'user' => get_current_user_id(), 'verification_date' => current_time('mysql'), 'notes' => $payment_notes // Store notes in response JSON as well ]) ]; try { // Create payment record using WordPress post creation $payment_post_data = [ 'post_title' => sprintf('Manual Payment for Invoice #%s', $invoice->getNumber()), 'post_type' => 'easy_invoice_payment', 'post_status' => 'publish', 'post_author' => get_current_user_id(), 'meta_input' => [ '_invoice_id' => $invoice_id, '_amount' => $invoice->getTotal(), '_payment_method' => $payment_method, '_status' => 'completed', '_transaction_id' => get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id, '_payment_date' => current_time('mysql'), '_notes' => $payment_notes, '_payment_type' => 'manual', '_currency' => $currency_code, '_currency_symbol' => $currency_symbol, '_gateway_response' => json_encode([ 'admin_verified' => true, 'user' => get_current_user_id(), 'verification_date' => current_time('mysql'), 'notes' => $payment_notes ]) ] ]; $payment_id = wp_insert_post($payment_post_data); if (is_wp_error($payment_id)) { easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $payment_id->get_error_message()); return; } } catch (\Exception $e) { easy_invoice_toast_error(__('Error creating payment record:', 'easy-invoice') . ' ' . $e->getMessage()); return; } } // Store payment details before updating status (for the hook) $transaction_id = get_post_meta($invoice_id, '_' . $payment_method . '_transaction_id', true) ?: 'MANUAL-' . $invoice_id; $invoice->setMeta('_payment_method', $payment_method); $invoice->setMeta('_transaction_id', $transaction_id); // Update invoice status to paid // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification $invoice->setStatus('paid'); $invoice->save(); // Trigger the payment completed hook manually since we're updating status directly do_action('easy_invoice_payment_completed', $invoice_id, $invoice, [ 'payment_method' => $payment_method, 'gateway_name' => 'manual', 'transaction_id' => $transaction_id, 'amount' => $invoice->getTotal() ]); // Trigger email confirmation and actions only if we have a payment_id if ($payment_id) { // Send confirmation email to customer $this->sendPaymentConfirmationEmail($invoice_id, $payment_id); do_action('easy_invoice_manual_payment_confirmed', $invoice_id, $payment_id, $payment_method); } easy_invoice_toast_success(__('Invoice marked as paid successfully.', 'easy-invoice')); } /** * Record money received, from the admin "Add New Payment" form. * * The form used to post to the customer checkout endpoint, which runs a * gateway (bank-transfer instructions, a card form) — not what an admin * typing in a cheque they were handed wants. This books a completed * payment and settles the invoice: paid when the total is covered, * partial otherwise. */ public function recordPayment() { if (!isset($_POST['payment_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['payment_nonce'])), 'easy_invoice_payment')) { wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]); } if (!easy_invoice_user_can('ei_record_payment')) { wp_send_json_error(['message' => __('You do not have permission to record payments.', 'easy-invoice')]); } $invoice_id = isset($_POST['invoice_id']) ? absint($_POST['invoice_id']) : 0; $amount = isset($_POST['amount']) ? (float) str_replace(',', '', sanitize_text_field(wp_unslash($_POST['amount']))) : 0.0; $method = isset($_POST['payment_method']) ? sanitize_key(wp_unslash($_POST['payment_method'])) : ''; $date = isset($_POST['payment_date']) ? sanitize_text_field(wp_unslash($_POST['payment_date'])) : ''; $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : ''; $invoice = $invoice_id > 0 ? \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id) : null; if (!$invoice) { wp_send_json_error(['message' => __('Choose the invoice the payment is for.', 'easy-invoice')]); } if ($amount <= 0) { wp_send_json_error(['message' => __('Enter an amount greater than zero.', 'easy-invoice')]); } if ('' === $method) { $method = 'manual'; } $when = $date && strtotime($date) ? gmdate('Y-m-d H:i:s', strtotime($date)) : current_time('mysql'); $currency_code = $invoice->getCurrencyCode() ?: get_option('easy_invoice_currency_code', 'USD'); $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); $payment_id = wp_insert_post([ 'post_title' => sprintf('Payment for Invoice #%s', $invoice->getNumber()), 'post_type' => 'easy_invoice_payment', 'post_status' => 'publish', 'post_author' => get_current_user_id(), 'meta_input' => [ '_invoice_id' => $invoice_id, '_amount' => round($amount, 2), '_payment_method' => $method, '_status' => 'completed', '_transaction_id' => 'MANUAL-' . $invoice_id . '-' . time(), '_payment_date' => $when, '_notes' => $notes, '_payment_type' => 'manual', '_currency' => $currency_code, '_currency_symbol' => $currency_symbol, '_gateway_response' => wp_json_encode(['recorded_by' => get_current_user_id(), 'recorded_at' => current_time('mysql'), 'notes' => $notes]), ], ]); if (is_wp_error($payment_id) || !$payment_id) { wp_send_json_error(['message' => __('The payment could not be saved.', 'easy-invoice')]); } $new_status = \EasyInvoice\Services\InvoiceBalance::isSettled($invoice) ? 'paid' : 'partial'; update_post_meta($invoice_id, '_easy_invoice_payment_method', $method); $invoice->setStatus($new_status); $invoice->save(); $payment_event = [ 'payment_method' => $method, 'gateway_name' => 'manual', 'transaction_id' => get_post_meta($payment_id, '_transaction_id', true), 'amount' => $amount, 'date' => $date, ]; if ('paid' === $new_status) { do_action('easy_invoice_payment_completed', $invoice_id, $invoice, $payment_event); } else { /** * Fires when a payment is recorded that leaves a balance owing. * * @param int $invoice_id Invoice. * @param object $invoice Invoice model. * @param array $payment payment_method, gateway_name, transaction_id, amount, date. */ do_action('easy_invoice_payment_received', $invoice_id, $invoice, $payment_event); } /** * Fires after an administrator records a payment by hand. * * @param int $payment_id Payment record. * @param int $invoice_id Invoice. * @param float $amount Amount recorded. * @param string $new_status Invoice status afterwards. */ do_action('easy_invoice_payment_recorded', $payment_id, $invoice_id, $amount, $new_status); wp_send_json_success([ 'payment_id' => $payment_id, 'status' => $new_status, 'message' => 'paid' === $new_status ? __('Payment recorded — the invoice is paid.', 'easy-invoice') : sprintf(/* translators: %s: amount still owed. */ __('Payment recorded — %s still due.', 'easy-invoice'), $currency_symbol . number_format_i18n(\EasyInvoice\Services\InvoiceBalance::due($invoice), 2)), ]); } /** * Handle bulk actions for payments */ public function handleBulkActions() { // Check if we're processing a bulk action if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_payment_bulk_action') { return; } // Check nonce and capability if (!wp_verify_nonce(($_POST['easy_invoice_payment_bulk_nonce'] ?? ''), 'easy_invoice_payment_bulk_action')) { wp_die(esc_html__('Security check failed.', 'easy-invoice')); } // Bulk action on payments — record-payment cap is the right gate // (covers trash/restore/delete which all change payment state). if (!easy_invoice_user_can('ei_record_payment')) { wp_die(esc_html__('You do not have permission to perform this action.', 'easy-invoice')); } // Check if we have payment IDs if (!isset($_POST['payment_ids']) || !is_array($_POST['payment_ids']) || empty($_POST['payment_ids'])) { wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=no_selection')); exit; } // Get bulk action and payment IDs $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : ''; $payment_ids = array_map('intval', $_POST['payment_ids']); // Process based on action $processed = 0; $invoice_updates = array(); // Track invoice updates needed switch ($bulk_action) { case 'trash': foreach ($payment_ids as $id) { // Get payment info before trashing for invoice status update $payment_post = get_post($id); if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') { continue; } $payment = new Payment($payment_post); $payment_status = $payment->getStatus(); $invoice_id = $payment->getInvoiceId(); $payment_amount = $payment->getAmount(); if (wp_trash_post($id)) { $processed++; // Track invoice updates needed for completed payments if ($payment_status === 'completed' && $invoice_id) { if (!isset($invoice_updates[$invoice_id])) { $invoice_updates[$invoice_id] = 0; } $invoice_updates[$invoice_id] += $payment_amount; } } } // Update invoice statuses for completed payments that were trashed foreach (array_keys($invoice_updates) as $invoice_id) { $this->syncInvoiceStatusWithPayments($invoice_id); } wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_trashed=' . $processed)); break; case 'restore': foreach ($payment_ids as $id) { // Get payment info before restoring for invoice status update $payment_post = get_post($id); if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') { continue; } $payment = new Payment($payment_post); $payment_status = $payment->getStatus(); $invoice_id = $payment->getInvoiceId(); $payment_amount = $payment->getAmount(); if (wp_untrash_post($id)) { // Also set status to publish (since WordPress sets it to draft by default) wp_update_post(array( 'ID' => $id, 'post_status' => 'publish' )); $processed++; // Track invoice updates needed for completed payments if ($payment_status === 'completed' && $invoice_id) { if (!isset($invoice_updates[$invoice_id])) { $invoice_updates[$invoice_id] = 0; } $invoice_updates[$invoice_id] += $payment_amount; } } } // Update invoice statuses for completed payments that were restored foreach (array_keys($invoice_updates) as $invoice_id) { $this->syncInvoiceStatusWithPayments($invoice_id); } wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_restored=' . $processed)); break; case 'delete': foreach ($payment_ids as $id) { // Get payment info before deletion for invoice status update $payment_post = get_post($id); if (!$payment_post || $payment_post->post_type !== 'easy_invoice_payment') { continue; } $payment = new Payment($payment_post); $payment_status = $payment->getStatus(); $invoice_id = $payment->getInvoiceId(); $payment_amount = $payment->getAmount(); if (wp_delete_post($id, true)) { $processed++; // Track invoice updates needed for completed payments if ($payment_status === 'completed' && $invoice_id) { if (!isset($invoice_updates[$invoice_id])) { $invoice_updates[$invoice_id] = 0; } $invoice_updates[$invoice_id] += $payment_amount; } } } // Update invoice statuses for completed payments that were deleted foreach (array_keys($invoice_updates) as $invoice_id) { $this->syncInvoiceStatusWithPayments($invoice_id); } wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_deleted=' . $processed)); break; default: wp_safe_redirect(admin_url('admin.php?page=easy-invoice-payments&bulk_error=invalid_action')); } exit; } /** * Put an invoice's status back in line with the completed payments and * credit notes it actually has — after a payment is trashed, restored or * deleted. Paid when nothing is owed, part-paid when something has been * received, otherwise awaiting payment; an issued invoice never returns * to draft. (This used to write the status to a meta key the invoice * does not use, so a trashed payment left the invoice "paid".) * * @param int $invoice_id Invoice. */ private function syncInvoiceStatusWithPayments($invoice_id) { $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find((int) $invoice_id); if (!$invoice || !$invoice->getId()) { return; } $current = (string) $invoice->getStatus(); if (in_array($current, ['draft', 'cancelled', 'canceled'], true)) { return; } $paid = \EasyInvoice\Services\InvoiceBalance::paid((int) $invoice_id); if (\EasyInvoice\Services\InvoiceBalance::isSettled($invoice)) { $new = 'paid'; } elseif ($paid > 0) { $new = 'partial'; } else { $new = in_array($current, ['unpaid', 'available'], true) ? $current : 'available'; } if ($new !== $current) { $invoice->setStatus($new); $invoice->save(); } } // Stripe payment recording moved to Pro plugin }