| @@ -7,12 +7,21 @@ | ||
| 7 | 7 | * @package EasyInvoice |
| 8 | 8 | * @since 1.0.0 |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 12 | + exit; | |
| 13 | +} | |
| 11 | 14 | // Get invoice data |
| 12 | 15 | $invoice_id = get_the_ID(); |
| 16 | +// The access token the AJAX calls below present back to the payment endpoints: | |
| 17 | +// the ?ik= from an emailed link, or whatever another proof of access (a signed | |
| 18 | +// secure link) supplies through the filter. | |
| 19 | +/** This filter is documented in includes/Controllers/InvoiceController.php */ | |
| 20 | +$ei_ps_access_token = (string) apply_filters('easy_invoice_presented_access_token', isset($_GET['ik']) ? sanitize_text_field(wp_unslash($_GET['ik'])) : '', 'invoice'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 13 | 21 | $invoice = new \EasyInvoice\Models\Invoice(get_post($invoice_id)); |
| 14 | -$total_amount = $invoice->getTotal(); | |
| 22 | +// What is owed now: total less payments received and credit notes. | |
| 23 | +$total_amount = \EasyInvoice\Services\InvoiceBalance::due($invoice); | |
| 15 | 24 | $currency_code = $invoice->getCurrencyCode() ?: 'USD'; |
| 16 | 25 | |
| 17 | 26 | // If currency is "global", use the global setting |
| 18 | 27 | if ($currency_code === 'global') { |
| @@ -29,14 +38,47 @@ | ||
| 29 | 38 | $payment_nonce = wp_create_nonce('easy_invoice_payment'); |
| 30 | 39 | |
| 31 | 40 | |
| 32 | 41 | ?> |
| 42 | +<?php | |
| 43 | +if (!function_exists('easy_invoice_payment_icon')) { | |
| 44 | + /** | |
| 45 | + * An inline icon for a payment method. The public page loads no icon | |
| 46 | + * font, so Font Awesome class names rendered as empty boxes; a gateway | |
| 47 | + * may still hand over an image URL. | |
| 48 | + * | |
| 49 | + * @param string $gateway_id Gateway id. | |
| 50 | + * @param string $icon Icon the gateway declared (class name or URL). | |
| 51 | + * @return string HTML. | |
| 52 | + */ | |
| 53 | + function easy_invoice_payment_icon(string $gateway_id, string $icon = ''): string { | |
| 54 | + if ('' !== $icon && preg_match('#^(https?:)?//|^/#', $icon)) { | |
| 55 | + return '<img src="' . esc_url($icon) . '" alt="" style="width:22px;height:22px;object-fit:contain;">'; | |
| 56 | + } | |
| 57 | + $a = 'width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; | |
| 58 | + $paths = [ | |
| 59 | + 'bank' => '<path d="M3 10h18M5 10v9M9 10v9M15 10v9M19 10v9M2 19h20M12 3l9 6H3l9-6z"/>', | |
| 60 | + 'cheque' => '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M7 10h6M7 14h3M15 14h2"/>', | |
| 61 | + 'cash' => '<rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M6 9h.01M18 15h.01"/>', | |
| 62 | + 'card' => '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h4"/>', | |
| 63 | + 'wallet' => '<path d="M3 7a2 2 0 0 1 2-2h14v4"/><rect x="3" y="7" width="18" height="12" rx="2"/><path d="M16 13h.01"/>', | |
| 64 | + ]; | |
| 65 | + $id = strtolower($gateway_id); | |
| 66 | + $key = 'card'; | |
| 67 | + if (false !== strpos($id, 'bank') || false !== strpos($id, 'transfer') || false !== strpos($id, 'wire')) { $key = 'bank'; } | |
| 68 | + elseif (false !== strpos($id, 'cheque') || false !== strpos($id, 'check')) { $key = 'cheque'; } | |
| 69 | + elseif (false !== strpos($id, 'cash')) { $key = 'cash'; } | |
| 70 | + elseif (false !== strpos($id, 'paypal') || false !== strpos($id, 'paystack') || false !== strpos($id, 'mollie')) { $key = 'wallet'; } | |
| 71 | + return '<svg ' . $a . '>' . $paths[$key] . '</svg>'; | |
| 72 | + } | |
| 73 | +} | |
| 74 | +?> | |
| 33 | 75 | |
| 76 | + | |
| 34 | 77 | <div class="easy-invoice-payment-sidebar"> |
| 35 | - <!-- Header --> | |
| 36 | 78 | <div class="payment-header"> |
| 37 | 79 | <div class="header-content"> |
| 38 | - <h2><?php _e('Payment', 'easy-invoice'); ?></h2> | |
| 80 | + <h2><?php esc_html_e('Payment', 'easy-invoice'); ?></h2> | |
| 39 | 81 | <div class="invoice-summary"> |
| 40 | 82 | <span class="invoice-number"><?php echo esc_html($invoice->getNumber()); ?></span> |
| 41 | 83 | <span class="invoice-date"><?php echo esc_html(date_i18n('M j, Y', strtotime($invoice->getIssueDate()))); ?></span> |
| 42 | 84 | </div> |
| @@ -42,11 +84,11 @@ | ||
| 42 | 84 | </div> |
| 43 | 85 | </div> |
| 44 | 86 | |
| 45 | 87 | <div class="amount-display"> |
| 46 | - <div class="amount-label"><?php _e('Total Amount', 'easy-invoice'); ?></div> | |
| 88 | + <div class="amount-label"><?php echo esc_html((float) $invoice->getTotal() - $total_amount > 0.005 ? __('Amount Due', 'easy-invoice') : __('Total Amount', 'easy-invoice')); ?></div> | |
| 47 | 89 | <div class="amount-value"> |
| 48 | - <?php echo esc_html($currency_symbol . number_format($total_amount, 2)); ?> | |
| 90 | + <?php echo esc_html(easy_invoice_format_money($total_amount, $invoice)); ?> | |
| 49 | 91 | </div> |
| 50 | 92 | </div> |
| 51 | 93 | </div> |
| 52 | 94 | |
| @@ -53,12 +95,12 @@ | ||
| 53 | 95 | <?php if (empty($available_gateways)): ?> |
| 54 | 96 | <div class="no-payment-methods"> |
| 55 | 97 | <div class="empty-state"> |
| 56 | 98 | <div class="empty-icon"> |
| 57 | - <i class="fas fa-credit-card"></i> | |
| 99 | + <?php echo easy_invoice_payment_icon('card'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> | |
| 58 | 100 | </div> |
| 59 | - <h3><?php _e('No Payment Methods', 'easy-invoice'); ?></h3> | |
| 60 | - <p><?php _e('Payment methods are not configured for this invoice.', 'easy-invoice'); ?></p> | |
| 101 | + <h3><?php esc_html_e('No Payment Methods', 'easy-invoice'); ?></h3> | |
| 102 | + <p><?php esc_html_e('Payment methods are not configured for this invoice.', 'easy-invoice'); ?></p> | |
| 61 | 103 | </div> |
| 62 | 104 | </div> |
| 63 | 105 | <?php else: ?> |
| 64 | 106 | <form id="easy-invoice-payment-form" class="payment-form"> |
| @@ -75,11 +117,10 @@ | ||
| 75 | 117 | do_action('easy_invoice_payment_gateways_before', $invoice); |
| 76 | 118 | |
| 77 | 119 | ?> |
| 78 | 120 | |
| 79 | - <!-- Payment Methods --> | |
| 80 | 121 | <div class="payment-methods"> |
| 81 | - <label class="section-label"><?php _e('Payment Method', 'easy-invoice'); ?></label> | |
| 122 | + <label class="section-label"><?php esc_html_e('Payment Method', 'easy-invoice'); ?></label> | |
| 82 | 123 | |
| 83 | 124 | <div class="method-options"> |
| 84 | 125 | <?php foreach ($available_gateways as $gateway): ?> |
| 85 | 126 | <div class="method-option" data-gateway="<?php echo esc_attr($gateway['id']); ?>"> |
| @@ -91,13 +132,9 @@ | ||
| 91 | 132 | |
| 92 | 133 | <label for="payment_method_<?php echo esc_attr($gateway['id']); ?>" class="method-label"> |
| 93 | 134 | <div class="method-content"> |
| 94 | 135 | <div class="method-icon"> |
| 95 | - <?php if (!empty($gateway['icon'])): ?> | |
| 96 | - <i class="<?php echo esc_attr($gateway['icon']); ?>"></i> | |
| 97 | - <?php else: ?> | |
| 98 | - <i class="fas fa-credit-card"></i> | |
| 99 | - <?php endif; ?> | |
| 136 | + <?php echo easy_invoice_payment_icon((string) $gateway['id'], (string) ($gateway['icon'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- inline SVG / escaped img. ?> | |
| 100 | 137 | </div> |
| 101 | 138 | |
| 102 | 139 | <div class="method-details"> |
| 103 | 140 | <div class="method-name"><?php echo esc_html($gateway['title']); ?></div> |
| @@ -111,9 +148,8 @@ | ||
| 111 | 148 | </div> |
| 112 | 149 | </div> |
| 113 | 150 | </label> |
| 114 | 151 | |
| 115 | - <!-- Gateway-specific content will be loaded here --> | |
| 116 | 152 | <div class="gateway-content" id="gateway-content-<?php echo esc_attr($gateway['id']); ?>" style="display: none;"></div> |
| 117 | 153 | </div> |
| 118 | 154 | <?php endforeach; ?> |
| 119 | 155 | </div> |
| @@ -118,24 +154,22 @@ | ||
| 118 | 154 | <?php endforeach; ?> |
| 119 | 155 | </div> |
| 120 | 156 | </div> |
| 121 | 157 | |
| 122 | - <!-- Messages --> | |
| 123 | 158 | <div class="message-area" id="payment-message-area"></div> |
| 124 | 159 | |
| 125 | - <!-- Submit Button --> | |
| 126 | 160 | <div class="submit-section"> |
| 127 | 161 | <button type="submit" id="pay-now-button" class="submit-button" disabled> |
| 128 | - <span class="button-text"><?php _e('Pay', 'easy-invoice'); ?> <?php echo esc_html($currency_symbol . number_format($total_amount, 2)); ?></span> | |
| 162 | + <span class="button-text"><?php esc_html_e('Pay', 'easy-invoice'); ?> <?php echo esc_html(easy_invoice_format_money($total_amount, $invoice)); ?></span> | |
| 129 | 163 | <div class="button-loader hidden"> |
| 130 | 164 | <div class="loader"></div> |
| 131 | - <span><?php _e('Processing...', 'easy-invoice'); ?></span> | |
| 165 | + <span><?php esc_html_e('Processing...', 'easy-invoice'); ?></span> | |
| 132 | 166 | </div> |
| 133 | 167 | </button> |
| 134 | 168 | |
| 135 | 169 | <div class="security-note"> |
| 136 | - <i class="fas fa-lock"></i> | |
| 137 | - <span><?php _e('Your payment is secure and encrypted', 'easy-invoice'); ?></span> | |
| 170 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:4px;"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg> | |
| 171 | + <span><?php esc_html_e('Your payment is secure and encrypted', 'easy-invoice'); ?></span> | |
| 138 | 172 | </div> |
| 139 | 173 | </div> |
| 140 | 174 | </form> |
| 141 | 175 | <?php endif; ?> |
| @@ -730,9 +764,8 @@ | ||
| 730 | 764 | |
| 731 | 765 | // Also call it after a short delay to ensure all elements are loaded |
| 732 | 766 | setTimeout(() => { |
| 733 | 767 | updatePayButtonText(); |
| 734 | - console.log('updatePayButtonText called on page load (delayed)'); | |
| 735 | 768 | }, 500); |
| 736 | 769 | |
| 737 | 770 | // Handle form submission |
| 738 | 771 | paymentForm.addEventListener('submit', handlePaymentSubmit); |
| @@ -739,12 +772,9 @@ | ||
| 739 | 772 | |
| 740 | 773 | function handlePaymentMethodChange(e) { |
| 741 | 774 | window.selectedGateway = e.target.value; |
| 742 | 775 | const methodEntry = e.target.closest('.method-option'); |
| 743 | - | |
| 744 | - console.log('Payment method changed to:', window.selectedGateway); | |
| 745 | - console.log('Pay button before:', payButton.disabled); | |
| 746 | - | |
| 776 | + | |
| 747 | 777 | // Update UI |
| 748 | 778 | document.querySelectorAll('.method-option').forEach(entry => { |
| 749 | 779 | entry.classList.remove('selected'); |
| 750 | 780 | }); |
| @@ -757,10 +787,9 @@ | ||
| 757 | 787 | } |
| 758 | 788 | |
| 759 | 789 | // Enable pay button |
| 760 | 790 | payButton.disabled = false; |
| 761 | - console.log('Pay button after:', payButton.disabled); | |
| 762 | - | |
| 791 | + | |
| 763 | 792 | // Load gateway-specific content |
| 764 | 793 | loadGatewayContent(window.selectedGateway); |
| 765 | 794 | } |
| 766 | 795 | |
| @@ -782,24 +811,23 @@ | ||
| 782 | 811 | contentDiv.innerHTML = ''; |
| 783 | 812 | |
| 784 | 813 | |
| 785 | 814 | // Load gateway content via AJAX |
| 786 | - console.log('Loading gateway content for:', gateway); | |
| 787 | - console.log('Content div:', contentDiv); | |
| 788 | - | |
| 789 | - // Get form data for the request | |
| 790 | 815 | const formData = new FormData(); |
| 791 | 816 | formData.append('action', 'easy_invoice_get_payment_instructions'); |
| 792 | 817 | formData.append('gateway', gateway); |
| 793 | 818 | formData.append('invoice_id', document.querySelector('input[name="invoice_id"]').value); |
| 794 | 819 | formData.append('nonce', document.querySelector('input[name="payment_nonce"]').value); |
| 795 | - | |
| 796 | - fetch('<?php echo admin_url('admin-ajax.php'); ?>', { | |
| 820 | + // Per-invoice access token from the emailed link (?ik=...); the server | |
| 821 | + // authorises the request on it. Without it an anonymous client holding a | |
| 822 | + // legitimate link could not load their own payment instructions. | |
| 823 | + formData.append('access_token', <?php echo wp_json_encode($ei_ps_access_token); ?>); | |
| 824 | + | |
| 825 | + fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', { | |
| 797 | 826 | method: 'POST', |
| 798 | 827 | body: formData |
| 799 | 828 | }) |
| 800 | 829 | .then(response => { |
| 801 | - console.log('Response status:', response.status); | |
| 802 | 830 | if (!response.ok) { |
| 803 | 831 | throw new Error('Network response was not ok'); |
| 804 | 832 | } |
| 805 | 833 | return response.json(); |
| @@ -804,15 +832,12 @@ | ||
| 804 | 832 | } |
| 805 | 833 | return response.json(); |
| 806 | 834 | }) |
| 807 | 835 | .then(response => { |
| 808 | - console.log('Response:', response); | |
| 809 | 836 | if (response.success && response.data.instructions) { |
| 810 | - console.log('Instructions found, displaying content'); | |
| 811 | 837 | contentDiv.innerHTML = response.data.instructions; |
| 812 | 838 | contentDiv.style.display = 'block'; |
| 813 | 839 | } else { |
| 814 | - console.log('No instructions found, hiding content'); | |
| 815 | 840 | contentDiv.style.display = 'none'; |
| 816 | 841 | } |
| 817 | 842 | }) |
| 818 | 843 | .catch(error => { |
| @@ -821,47 +846,30 @@ | ||
| 821 | 846 | }); |
| 822 | 847 | } |
| 823 | 848 | |
| 824 | 849 | function loadGatewayContentViaAjax(gateway, contentDiv) { |
| 825 | - console.log('Loading gateway content via AJAX for:', gateway); | |
| 826 | - | |
| 827 | - // Get form data for the request | |
| 828 | 850 | const formData = new FormData(); |
| 829 | 851 | formData.append('action', 'easy_invoice_get_payment_instructions'); |
| 830 | 852 | formData.append('gateway', gateway); |
| 831 | 853 | formData.append('invoice_id', document.querySelector('input[name="invoice_id"]').value); |
| 832 | 854 | formData.append('nonce', document.querySelector('input[name="payment_nonce"]').value); |
| 833 | - | |
| 834 | - // Fetch instructions from the server | |
| 835 | - fetch('<?php echo admin_url('admin-ajax.php'); ?>', { | |
| 855 | + formData.append('access_token', <?php echo wp_json_encode($ei_ps_access_token); ?>); | |
| 856 | + | |
| 857 | + fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', { | |
| 836 | 858 | method: 'POST', |
| 837 | 859 | body: formData |
| 838 | 860 | }) |
| 861 | + .then(response => response.json()) | |
| 839 | 862 | .then(response => { |
| 840 | - console.log('Response status:', response.status); | |
| 841 | - return response.json(); | |
| 842 | - }) | |
| 843 | - .then(response => { | |
| 844 | - console.log('Response:', response); | |
| 845 | 863 | if (response.success && response.data.instructions) { |
| 846 | - console.log('Content div before insertion:', contentDiv); | |
| 847 | - console.log('Instructions to insert:', response.data.instructions); | |
| 848 | 864 | contentDiv.innerHTML = response.data.instructions; |
| 849 | - console.log('Content div after insertion:', contentDiv); | |
| 850 | - console.log('Content div innerHTML:', contentDiv.innerHTML); | |
| 851 | - console.log('Content div display style:', contentDiv.style.display); | |
| 852 | - console.log('Instructions loaded successfully'); | |
| 853 | - // Show the content div only after successful AJAX response | |
| 854 | 865 | contentDiv.style.display = 'block'; |
| 855 | 866 | } else { |
| 856 | - console.log('No instructions received for gateway:', gateway); | |
| 857 | - console.log('Hiding gateway content div'); | |
| 858 | 867 | contentDiv.style.display = 'none'; |
| 859 | 868 | } |
| 860 | 869 | }) |
| 861 | 870 | .catch(error => { |
| 862 | 871 | console.error('Error loading gateway content:', error); |
| 863 | - console.log('Hiding gateway content div due to error'); | |
| 864 | 872 | contentDiv.style.display = 'none'; |
| 865 | 873 | }); |
| 866 | 874 | } |
| 867 | 875 | |
| @@ -887,20 +895,18 @@ | ||
| 887 | 895 | |
| 888 | 896 | |
| 889 | 897 | |
| 890 | 898 | function processStandardPayment() { |
| 891 | - console.log('Processing standard payment'); | |
| 892 | - | |
| 893 | - // Get form data | |
| 894 | 899 | const formData = new FormData(paymentForm); |
| 895 | - | |
| 896 | - // Add payment method to form data | |
| 897 | 900 | formData.append('payment_method', window.selectedGateway); |
| 898 | - | |
| 899 | - console.log('Form data:', Object.fromEntries(formData)); | |
| 900 | - | |
| 901 | + // The per-invoice access token from the emailed link. processPayment() | |
| 902 | + // authorises on it before anything else; without it a client who | |
| 903 | + // arrived by their own link was refused with "Invalid invoice" the | |
| 904 | + // moment they chose bank transfer, cheque or cash. | |
| 905 | + formData.append('access_token', <?php echo wp_json_encode($ei_ps_access_token); ?>); | |
| 906 | + | |
| 901 | 907 | // Get AJAX URL from WordPress |
| 902 | - const ajaxUrl = '<?php echo admin_url('admin-ajax.php'); ?>'; | |
| 908 | + const ajaxUrl = '<?php echo esc_url(admin_url('admin-ajax.php')); ?>'; | |
| 903 | 909 | |
| 904 | 910 | fetch(ajaxUrl, { |
| 905 | 911 | method: 'POST', |
| 906 | 912 | body: formData |
| @@ -975,9 +981,8 @@ | ||
| 975 | 981 | } |
| 976 | 982 | |
| 977 | 983 | messageArea.innerHTML = ` |
| 978 | 984 | <div class="payment-message ${type}"> |
| 979 | - <i class="fas fa-${icon}"></i> | |
| 980 | 985 | ${message} |
| 981 | 986 | </div> |
| 982 | 987 | `; |
| 983 | 988 | } |
| @@ -982,24 +987,28 @@ | ||
| 982 | 987 | `; |
| 983 | 988 | } |
| 984 | 989 | |
| 985 | 990 | function updatePayButtonText() { |
| 986 | - // Get payment amount from the hidden field (updated by partial payments) | |
| 991 | + // No button when the panel has no payment methods to offer. | |
| 992 | + if (!payButton || !payButton.querySelector('.button-text')) { return; } | |
| 987 | 993 | const hiddenPaymentAmount = document.getElementById('payment_amount'); |
| 988 | 994 | const paymentAmount = hiddenPaymentAmount ? hiddenPaymentAmount.value : '0'; |
| 989 | - const currencySymbol = '<?php echo esc_js($currency_symbol); ?>'; | |
| 990 | - | |
| 991 | - console.log('updatePayButtonText called'); | |
| 992 | - console.log('Hidden payment amount:', paymentAmount); | |
| 993 | - console.log('Currency symbol:', currencySymbol); | |
| 994 | - | |
| 995 | + const money = <?php echo wp_json_encode(easy_invoice_money_format_spec($invoice)); ?>; | |
| 996 | + const payLabel = <?php echo wp_json_encode(__('Pay', 'easy-invoice')); ?>; | |
| 997 | + | |
| 995 | 998 | if (paymentAmount && paymentAmount > 0) { |
| 996 | - const formattedAmount = parseFloat(paymentAmount).toFixed(2); | |
| 997 | - payButton.querySelector('.button-text').textContent = `Pay ${currencySymbol}${formattedAmount}`; | |
| 998 | - console.log('Updated button text to:', `Pay ${currencySymbol}${formattedAmount}`); | |
| 999 | + // Same rules as the PHP formatter: separators, precision, symbol position. | |
| 1000 | + const n = parseFloat(paymentAmount).toFixed(money.precision); | |
| 1001 | + const [int, dec] = n.split('.'); | |
| 1002 | + const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, money.thousands); | |
| 1003 | + const number = dec !== undefined ? grouped + money.decimal + dec : grouped; | |
| 1004 | + const formatted = (money.position === 'after' || money.position === 'right') ? number + money.symbol | |
| 1005 | + : (money.position === 'right_space') ? number + ' ' + money.symbol | |
| 1006 | + : (money.position === 'left_space') ? money.symbol + ' ' + number | |
| 1007 | + : money.symbol + number; | |
| 1008 | + payButton.querySelector('.button-text').textContent = `${payLabel} ${formatted}`; | |
| 999 | 1009 | } else { |
| 1000 | 1010 | payButton.querySelector('.button-text').textContent = 'Pay'; |
| 1001 | - console.log('Updated button text to: Pay'); | |
| 1002 | 1011 | } |
| 1003 | 1012 | } |
| 1004 | 1013 | |
| 1005 | 1014 | // Make the function globally accessible |