From 6b127537ebb5d06b59a76b18dc1aaaf237ad1a75 Mon Sep 17 00:00:00 2001 From: Alan Woodman Date: Fri, 22 Aug 2025 14:35:21 +0800 Subject: [PATCH] New Features --- blueprints/main.py | 159 ++++++++++++++++++++++- static/css/custom.css | 139 +++++++++++++++++++- templates/main/batch_detail.html | 52 +++++++- templates/main/payment_detail.html | 28 +++- templates/main/single_payment.html | 136 ++++++++++++++++++- templates/main/single_payments_list.html | 52 +++++++- 6 files changed, 543 insertions(+), 23 deletions(-) diff --git a/blueprints/main.py b/blueprints/main.py index 336074d..6e9439f 100644 --- a/blueprints/main.py +++ b/blueprints/main.py @@ -9,9 +9,141 @@ from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET from stripe_payment_processor import StripePaymentProcessor from config import Config from services import log_activity +import re splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) +def classify_payment_error(error_text, json_data=None): + """ + Classify payment errors into user-friendly categories. + + Args: + error_text (str): The error text from the Error field + json_data (str): Optional JSON data containing additional error details + + Returns: + dict: Error classification with type, title, message, suggestion, and icon + """ + if not error_text: + return None + + # Parse JSON data if provided + parsed_json = None + if json_data: + try: + parsed_json = json.loads(json_data) + except: + pass + + # Extract decline code from JSON if available + decline_code = None + if parsed_json: + decline_code = parsed_json.get('decline_code') + if not decline_code and 'error' in parsed_json: + error_obj = parsed_json['error'] + if isinstance(error_obj, dict): + decline_code = error_obj.get('decline_code') + + # Convert to lowercase for easier matching + error_lower = error_text.lower() + + # Insufficient Funds + if (decline_code in ['insufficient_funds', 'card_declined'] and 'insufficient' in error_lower) or \ + 'insufficient funds' in error_lower or 'insufficient_funds' in error_lower: + return { + 'type': 'insufficient-funds', + 'title': 'Insufficient Funds', + 'message': 'Customer does not have sufficient funds in their account', + 'suggestion': 'Customer should check their account balance or try a different payment method', + 'icon': 'fa-credit-card' + } + + # Incorrect Card Information + if decline_code in ['incorrect_number', 'incorrect_cvc', 'incorrect_zip', 'expired_card', 'invalid_expiry_month', 'invalid_expiry_year'] or \ + any(phrase in error_lower for phrase in ['incorrect', 'invalid', 'expired', 'wrong', 'bad']): + return { + 'type': 'incorrect-card', + 'title': 'Incorrect Card Information', + 'message': 'Card information is incorrect, invalid, or expired', + 'suggestion': 'Customer should verify their card details or use a different card', + 'icon': 'fa-exclamation-triangle' + } + + # Bank Contact Required + if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \ + any(phrase in error_lower for phrase in ['call', 'contact', 'bank', 'issuer', 'restricted', 'blocked']): + return { + 'type': 'bank-contact', + 'title': 'Bank Contact Required', + 'message': 'Customer needs to contact their bank or card issuer', + 'suggestion': 'Customer should call the phone number on the back of their card', + 'icon': 'fa-phone' + } + + # Processing Errors + if decline_code in ['processing_error', 'try_again_later'] or \ + 'error_type' in error_lower and any(phrase in error_lower for phrase in ['processing', 'temporary', 'try again', 'timeout']): + return { + 'type': 'processing-error', + 'title': 'Processing Error', + 'message': 'Temporary payment processing issue occurred', + 'suggestion': 'Please try the payment again in a few minutes', + 'icon': 'fa-sync-alt' + } + + # Network Errors + if 'network' in error_lower or 'connection' in error_lower or 'timeout' in error_lower: + return { + 'type': 'network-error', + 'title': 'Network Error', + 'message': 'Network connection issue during payment processing', + 'suggestion': 'Please check your connection and try again', + 'icon': 'fa-wifi' + } + + # General Decline (catch-all for other card declines) + if decline_code in ['generic_decline', 'do_not_honor', 'card_not_supported', 'currency_not_supported'] or \ + any(phrase in error_lower for phrase in ['declined', 'decline', 'not supported', 'do not honor']): + return { + 'type': 'general-decline', + 'title': 'Payment Declined', + 'message': 'Payment was declined by the card issuer', + 'suggestion': 'Customer should try a different payment method or contact their bank', + 'icon': 'fa-ban' + } + + # Default for unclassified errors + return { + 'type': 'general-decline', + 'title': 'Payment Error', + 'message': 'An error occurred during payment processing', + 'suggestion': 'Please try again or contact support if the issue persists', + 'icon': 'fa-exclamation-circle' + } + +def get_error_alert_data(payment): + """ + Get error alert data for template rendering. + + Args: + payment: Payment object with Error and PI_JSON fields + + Returns: + dict: Error alert data or None if no error + """ + if not payment.Error: + return None + + # Use PI_JSON if available, otherwise try PI_FollowUp_JSON + json_data = payment.PI_JSON or payment.PI_FollowUp_JSON + + error_classification = classify_payment_error(payment.Error, json_data) + + if error_classification: + error_classification['raw_error'] = payment.Error + + return error_classification + def processPaymentResult(pay_id, result, key): """Process payment result and update database record.""" from datetime import datetime @@ -185,6 +317,11 @@ def currency_filter(value): except (ValueError, TypeError): return '$0.00' +@main_bp.app_template_filter('error_alert') +def error_alert_filter(payment): + """Get error alert data for a payment.""" + return get_error_alert_data(payment) + @main_bp.route('/') @login_required def index(): @@ -401,9 +538,10 @@ def process_single_payment(): # Get form data splynx_id = request.form.get('splynx_id') amount = request.form.get('amount') + payment_method = request.form.get('payment_method') # Validate inputs - if not splynx_id or not amount: + if not splynx_id or not amount or not payment_method: return jsonify({'success': False, 'error': 'Missing required fields'}), 400 try: @@ -449,12 +587,13 @@ def process_single_payment(): processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) print(f"stripe_customer_id: {stripe_customer_id}") - # Process payment + # Process payment with specified payment method result = processor.process_payment( customer_id=stripe_customer_id, amount=amount, currency="aud", - description=f"Single Payment - Splynx ID: {splynx_id} - Payment ID: {payment_record.id}" + description=f"Single Payment - Splynx ID: {splynx_id} - Payment ID: {payment_record.id}", + stripe_pm=payment_method ) # Update payment record with results @@ -821,6 +960,20 @@ def payment_plans_detail(plan_id): plan=plan, associated_payments=associated_payments) +@main_bp.route('/api/stripe-customer-id/') +@login_required +def api_stripe_customer_id(splynx_id): + """Get Stripe customer ID for a Splynx customer.""" + try: + stripe_customer_id = get_stripe_customer_id(splynx_id) + if stripe_customer_id: + return jsonify({'success': True, 'stripe_customer_id': stripe_customer_id}) + else: + return jsonify({'success': False, 'error': 'Customer does not have a Stripe customer ID'}), 404 + except Exception as e: + print(f"Error fetching Stripe customer ID: {e}") + return jsonify({'success': False, 'error': 'Failed to fetch Stripe customer ID'}), 500 + @main_bp.route('/api/stripe-payment-methods/') @login_required def api_stripe_payment_methods(stripe_customer_id): diff --git a/static/css/custom.css b/static/css/custom.css index b72060a..fc8d5cb 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -395,32 +395,169 @@ code { /* Error Alert Styling */ .error-alert { margin-bottom: 0.5rem; - padding: 0.5rem 0.75rem; + padding: 0.75rem; border-radius: 0.375rem; font-size: 0.875rem; border: 1px solid; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } +.error-alert-header { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.error-alert-header .icon { + margin-right: 0.5rem; +} + +.error-alert-body { + margin-left: 1.5rem; +} + +.error-message { + margin-bottom: 0.25rem; + font-weight: 500; +} + +.error-suggestion { + margin-bottom: 0.5rem; + font-size: 0.8rem; + opacity: 0.9; +} + +.error-details { + margin-top: 0.5rem; +} + +.error-details summary { + cursor: pointer; + font-size: 0.75rem; + color: #666; + margin-bottom: 0.25rem; +} + +.error-details pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 0.5rem; + border-radius: 0.25rem; + font-size: 0.7rem; + max-height: 100px; + overflow-y: auto; +} + +/* Error Type Specific Styling */ .error-alert.insufficient-funds { background-color: #ffe4e1; color: #8b0000; border-color: #dc143c; } +.error-alert.insufficient-funds .icon { + color: #dc143c; +} + .error-alert.incorrect-card { background-color: #fff8dc; color: #8b4513; border-color: #daa520; } +.error-alert.incorrect-card .icon { + color: #daa520; +} + .error-alert.general-decline { background-color: #ffebcd; color: #a0522d; border-color: #cd853f; } +.error-alert.general-decline .icon { + color: #cd853f; +} + .error-alert.bank-contact { background-color: #e6e6fa; color: #4b0082; border-color: #9370db; +} + +.error-alert.bank-contact .icon { + color: #9370db; +} + +.error-alert.processing-error { + background-color: #e0f6ff; + color: #006b96; + border-color: #0088cc; +} + +.error-alert.processing-error .icon { + color: #0088cc; +} + +.error-alert.network-error { + background-color: #f0f0f0; + color: #555; + border-color: #999; +} + +.error-alert.network-error .icon { + color: #999; +} + +/* Compact Error Alert for Table Rows */ +.error-alert-compact { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid; + margin-right: 0.25rem; +} + +.error-alert-compact .icon { + margin-right: 0.25rem; + font-size: 0.7rem; +} + +.error-alert-compact.insufficient-funds { + background-color: #ffe4e1; + color: #8b0000; + border-color: #dc143c; +} + +.error-alert-compact.incorrect-card { + background-color: #fff8dc; + color: #8b4513; + border-color: #daa520; +} + +.error-alert-compact.general-decline { + background-color: #ffebcd; + color: #a0522d; + border-color: #cd853f; +} + +.error-alert-compact.bank-contact { + background-color: #e6e6fa; + color: #4b0082; + border-color: #9370db; +} + +.error-alert-compact.processing-error { + background-color: #e0f6ff; + color: #006b96; + border-color: #0088cc; +} + +.error-alert-compact.network-error { + background-color: #f0f0f0; + color: #555; + border-color: #999; } \ No newline at end of file diff --git a/templates/main/batch_detail.html b/templates/main/batch_detail.html index 8dce5a5..b9ece7c 100644 --- a/templates/main/batch_detail.html +++ b/templates/main/batch_detail.html @@ -273,6 +273,16 @@ {% endif %} + {% if payment.Error %} + {% set error_alert = payment | error_alert %} + {% if error_alert %} +
+ + {{ error_alert.title }} +
+ {% endif %} + {% endif %} +
{% if payment.PI_JSON %}
diff --git a/templates/main/payment_detail.html b/templates/main/payment_detail.html index dd78421..c489684 100644 --- a/templates/main/payment_detail.html +++ b/templates/main/payment_detail.html @@ -236,15 +236,39 @@ {% if payment.Error %} +{% set error_alert = payment | error_alert %}

- Error Information + Payment Error Details

+ {% if error_alert %} +
+
+ + {{ error_alert.title }} +
+
+

{{ error_alert.message }}

+

Suggested Action: {{ error_alert.suggestion }}

+
+ View Technical Details +
{{ error_alert.raw_error }}
+
+
+
+ {% else %} +
-
{{ payment.Error }}
+
Payment Error
+

An error occurred during payment processing.

+
+ Technical Details +
{{ payment.Error }}
+
+ {% endif %}
{% endif %} diff --git a/templates/main/single_payment.html b/templates/main/single_payment.html index 6fdf029..5d55bc8 100644 --- a/templates/main/single_payment.html +++ b/templates/main/single_payment.html @@ -87,9 +87,28 @@

Enter the amount to charge (maximum $10,000)

+
+ +
+
+ +
+ + + +
+ +

Select which payment method to use for this payment

+
+
- This payment will be processed immediately using the customer's default Stripe payment method. + This payment will be processed immediately using the selected payment method.
@@ -127,14 +146,18 @@
-
+
Customer:
-
-
+
Amount:
$0.00
+
+ Payment Method:
+ - +
@@ -378,6 +401,9 @@ function displayCustomerDetails(customer) { document.getElementById('customerDetails').innerHTML = detailsHtml; document.getElementById('confirmed_splynx_id').value = customer.id; + + // Fetch payment methods for this customer + fetchPaymentMethods(customer.id); } function showError(message) { @@ -385,6 +411,89 @@ function showError(message) { document.getElementById('customerError').classList.remove('is-hidden'); } +function fetchPaymentMethods(splynxId) { + // Get the Stripe customer ID from MySQL first + fetch(`/api/stripe-customer-id/${splynxId}`) + .then(response => response.json()) + .then(data => { + if (data.success && data.stripe_customer_id) { + // Now fetch payment methods for this Stripe customer + return fetch(`/api/stripe-payment-methods/${data.stripe_customer_id}`); + } else { + throw new Error('Customer does not have a Stripe customer ID'); + } + }) + .then(response => response.json()) + .then(data => { + if (data.success && data.payment_methods) { + displayPaymentMethods(data.payment_methods); + } else { + showPaymentMethodError(); + } + }) + .catch(error => { + console.error('Error fetching payment methods:', error); + showPaymentMethodError(); + }); +} + +function displayPaymentMethods(paymentMethods) { + const container = document.getElementById('payment_method_container'); + + if (!paymentMethods || paymentMethods.length === 0) { + showPaymentMethodError(); + return; + } + + // Create the select element + const selectHtml = ` +
+ +
+ `; + + container.innerHTML = selectHtml + ` + + + + `; + + // Hide any error messages + document.getElementById('payment_method_error').classList.add('is-hidden'); +} + +function showPaymentMethodError() { + const container = document.getElementById('payment_method_container'); + + // Show a disabled select with error message + container.innerHTML = ` +
+ +
+ + + + `; + + // Show error notification + document.getElementById('payment_method_error').classList.remove('is-hidden'); +} + function goToStep2() { // Hide step 1, show step 2 document.getElementById('step1').classList.add('is-hidden'); @@ -401,19 +510,39 @@ function goBackToStep1() { // Clear any errors document.getElementById('customerError').classList.add('is-hidden'); + document.getElementById('payment_method_error').classList.add('is-hidden'); // Clear form document.getElementById('payment_amount').value = ''; + + // Reset payment method selector to loading state + const container = document.getElementById('payment_method_container'); + container.innerHTML = ` +
+ +
+ + + + `; } function showConfirmationModal() { const amount = document.getElementById('payment_amount').value; + const paymentMethodSelect = document.getElementById('payment_method_select'); if (!amount || parseFloat(amount) <= 0) { alert('Please enter a valid payment amount'); return; } + if (!paymentMethodSelect || !paymentMethodSelect.value) { + alert('Please select a payment method'); + return; + } + if (!currentCustomerData) { alert('Customer data not found. Please restart the process.'); return; @@ -422,6 +551,7 @@ function showConfirmationModal() { // Update confirmation modal content document.getElementById('confirmCustomerName').textContent = currentCustomerData.name || 'Unknown'; document.getElementById('confirmAmount').textContent = `$${parseFloat(amount).toFixed(2)}`; + document.getElementById('confirmPaymentMethod').textContent = paymentMethodSelect.options[paymentMethodSelect.selectedIndex].text; // Show modal document.getElementById('confirmationModal').classList.add('is-active'); diff --git a/templates/main/single_payments_list.html b/templates/main/single_payments_list.html index 5f14c78..42101d5 100644 --- a/templates/main/single_payments_list.html +++ b/templates/main/single_payments_list.html @@ -195,6 +195,16 @@ {{ payment.processed_by or 'Unknown' }} + {% if payment.Error %} + {% set error_alert = payment | error_alert %} + {% if error_alert %} +
+ + {{ error_alert.title }} +
+ {% endif %} + {% endif %} +
{% if payment.PI_JSON %}