# easy-invoice/2.4.1/includes/Controllers/PaymentController.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.1. 1,850 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Controllers/PaymentController.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.1/raw/includes/Controllers/PaymentController.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Controllers/PaymentController.php#L10-L20`.

```php
<?php
/**
 * Payment Controller
 *
 * @package Easy_Invoice
 */

namespace EasyInvoice\Controllers;

use EasyInvoice\Constants\PostTypes;
use EasyInvoice\PaymentGatewayManager;
use EasyInvoice\EasyInvoice;
use EasyInvoice\Models\Invoice;
use EasyInvoice\Models\Payment;
use EasyInvoice\Traits\TemplateTrait;
use EasyInvoice\Traits\PaymentCalculationTrait;
use EasyInvoice\Constants\PagesSlugs;
use EasyInvoice\Constants\InvoiceFields;
use EasyInvoice\Constants\InvoiceMetaKeys;
use EasyInvoice\Helpers\Sanitization;
use EasyInvoice\Providers\InvoiceServiceProvider; // Assuming this is used elsewhere or for future

/**
 * Class PaymentController
 *
 * @package EasyInvoice\Controllers
 */
class PaymentController extends BaseController {
    use TemplateTrait;
    use PaymentCalculationTrait;

    /**
     * First Easy Invoice Pro release whose gateway scripts forward the per-invoice
     * access token to `easy_invoice_process_payment`. Older builds need the
     * compatibility path in legacyProPaymentFallbackAllowed().
     */
    const PRO_TOKEN_FORWARDING_VERSION = '2.3.0';

    /**
     * Payment gateway manager instance
     *
     * @var PaymentGatewayManager
     */
    private $gatewayManager;

    /**
     * Constructor
     */
    public function __construct() {
        $this->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


}

```
