Browse Source

New Features

master
Alan Woodman 4 months ago
parent
commit
6b127537eb
  1. 159
      blueprints/main.py
  2. 139
      static/css/custom.css
  3. 48
      templates/main/batch_detail.html
  4. 28
      templates/main/payment_detail.html
  5. 136
      templates/main/single_payment.html
  6. 48
      templates/main/single_payments_list.html

159
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/<int:splynx_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/<stripe_customer_id>')
@login_required
def api_stripe_payment_methods(stripe_customer_id):

139
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;
}

48
templates/main/batch_detail.html

@ -273,6 +273,16 @@
{% endif %}
</td>
<td>
{% if payment.Error %}
{% set error_alert = payment | error_alert %}
{% if error_alert %}
<div class="error-alert-compact {{ error_alert.type }}" title="{{ error_alert.message }} - {{ error_alert.suggestion }}">
<span class="icon"><i class="fas {{ error_alert.icon }}"></i></span>
<span>{{ error_alert.title }}</span>
</div>
{% endif %}
{% endif %}
<div class="buttons are-small">
{% if payment.PI_JSON %}
<button class="button is-info is-outlined" onclick="showModal('json-modal-{{ payment.id }}')">
@ -410,21 +420,49 @@
<!-- Error Modal -->
{% if payment.Error %}
{% set error_alert = payment | error_alert %}
<div class="modal" id="error-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('error-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment Error - Payment #{{ payment.id }}</p>
<header class="modal-card-head has-background-danger">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
Payment Error - Payment #{{ payment.id }}
</p>
<button class="delete" aria-label="close" onclick="hideModal('error-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
{% if error_alert %}
<div class="error-alert {{ error_alert.type }}">
<div class="error-alert-header">
<span class="icon"><i class="fas {{ error_alert.icon }}"></i></span>
<span class="error-title">{{ error_alert.title }}</span>
</div>
<div class="error-alert-body">
<p class="error-message">{{ error_alert.message }}</p>
<p class="error-suggestion"><strong>Suggested Action:</strong> {{ error_alert.suggestion }}</p>
<details class="error-details">
<summary>View Technical Details</summary>
<pre>{{ error_alert.raw_error }}</pre>
</details>
</div>
</div>
{% else %}
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
<h5 class="title is-6">Payment Error</h5>
<p>An error occurred during payment processing.</p>
<pre class="mt-3">{{ payment.Error }}</pre>
</div>
<button class="button is-small is-danger" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
{% endif %}
<div class="field is-grouped mt-4">
<div class="control">
<button class="button is-small is-info" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy Error</span>
<span>Copy Error Details</span>
</button>
</div>
</div>
<div id="error-content-{{ payment.id }}" style="display: none;">{{ payment.Error }}</div>
</section>
</div>

28
templates/main/payment_detail.html

@ -236,15 +236,39 @@
<!-- Error Information -->
{% if payment.Error %}
{% set error_alert = payment | error_alert %}
<div class="box">
<h3 class="title is-5 has-text-danger">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
Error Information
Payment Error Details
</h3>
{% if error_alert %}
<div class="error-alert {{ error_alert.type }}">
<div class="error-alert-header">
<span class="icon"><i class="fas {{ error_alert.icon }}"></i></span>
<span class="error-title">{{ error_alert.title }}</span>
</div>
<div class="error-alert-body">
<p class="error-message">{{ error_alert.message }}</p>
<p class="error-suggestion"><strong>Suggested Action:</strong> {{ error_alert.suggestion }}</p>
<details class="error-details">
<summary>View Technical Details</summary>
<pre>{{ error_alert.raw_error }}</pre>
</details>
</div>
</div>
{% else %}
<!-- Fallback for unclassified errors -->
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
<h5 class="title is-6">Payment Error</h5>
<p>An error occurred during payment processing.</p>
<details class="mt-3">
<summary class="has-text-grey">Technical Details</summary>
<pre class="mt-2">{{ payment.Error }}</pre>
</details>
</div>
{% endif %}
</div>
{% endif %}

136
templates/main/single_payment.html

@ -87,9 +87,28 @@
<p class="help">Enter the amount to charge (maximum $10,000)</p>
</div>
<div class="field">
<label class="label" for="payment_method_select">Payment Method</label>
<div class="control has-icons-left" id="payment_method_container">
<div class="select is-fullwidth is-loading" id="payment_method_loading">
<select disabled>
<option>Loading payment methods...</option>
</select>
</div>
<span class="icon is-small is-left">
<i class="fas fa-credit-card"></i>
</span>
</div>
<div id="payment_method_error" class="notification is-danger is-light is-hidden mt-2">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span>Unable to load payment methods. Customer may not have any valid payment methods.</span>
</div>
<p class="help">Select which payment method to use for this payment</p>
</div>
<div class="notification is-info is-light">
<span class="icon"><i class="fas fa-info-circle"></i></span>
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.
</div>
</form>
@ -127,14 +146,18 @@
<div class="box has-background-light">
<div class="columns">
<div class="column is-half">
<div class="column is-one-third">
<strong>Customer:</strong><br>
<span id="confirmCustomerName">-</span>
</div>
<div class="column is-half">
<div class="column is-one-third">
<strong>Amount:</strong><br>
<span id="confirmAmount" class="has-text-weight-bold is-size-4">$0.00</span>
</div>
<div class="column is-one-third">
<strong>Payment Method:</strong><br>
<span id="confirmPaymentMethod" class="tag is-info">-</span>
</div>
</div>
</div>
@ -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 = `
<div class="select is-fullwidth">
<select id="payment_method_select" name="payment_method" required>
<option value="">Select a payment method</option>
${paymentMethods.map(pm => {
let displayText = '';
if (pm.type === 'card') {
displayText = `${pm.display_brand.toUpperCase()} ending in ${pm.last4}`;
} else if (pm.type === 'au_becs_debit') {
displayText = `AU Bank Account ending in ${pm.last4}`;
} else {
displayText = pm.type.toUpperCase();
}
return `<option value="${pm.id}">${displayText}</option>`;
}).join('')}
</select>
</div>
`;
container.innerHTML = selectHtml + `
<span class="icon is-small is-left">
<i class="fas fa-credit-card"></i>
</span>
`;
// 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 = `
<div class="select is-fullwidth">
<select disabled>
<option>No payment methods available</option>
</select>
</div>
<span class="icon is-small is-left">
<i class="fas fa-exclamation-triangle"></i>
</span>
`;
// 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 = `
<div class="select is-fullwidth is-loading" id="payment_method_loading">
<select disabled>
<option>Loading payment methods...</option>
</select>
</div>
<span class="icon is-small is-left">
<i class="fas fa-credit-card"></i>
</span>
`;
}
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');

48
templates/main/single_payments_list.html

@ -195,6 +195,16 @@
<span class="is-size-7">{{ payment.processed_by or 'Unknown' }}</span>
</td>
<td>
{% if payment.Error %}
{% set error_alert = payment | error_alert %}
{% if error_alert %}
<div class="error-alert-compact {{ error_alert.type }}" title="{{ error_alert.message }} - {{ error_alert.suggestion }}">
<span class="icon"><i class="fas {{ error_alert.icon }}"></i></span>
<span>{{ error_alert.title }}</span>
</div>
{% endif %}
{% endif %}
<div class="buttons are-small">
{% if payment.PI_JSON %}
<button class="button is-info is-outlined" onclick="showModal('json-modal-{{ payment.id }}')">
@ -304,21 +314,49 @@
<!-- Error Modal -->
{% if payment.Error %}
{% set error_alert = payment | error_alert %}
<div class="modal" id="error-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('error-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment Error - Payment #{{ payment.id }}</p>
<header class="modal-card-head has-background-danger">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
Payment Error - Payment #{{ payment.id }}
</p>
<button class="delete" aria-label="close" onclick="hideModal('error-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
{% if error_alert %}
<div class="error-alert {{ error_alert.type }}">
<div class="error-alert-header">
<span class="icon"><i class="fas {{ error_alert.icon }}"></i></span>
<span class="error-title">{{ error_alert.title }}</span>
</div>
<div class="error-alert-body">
<p class="error-message">{{ error_alert.message }}</p>
<p class="error-suggestion"><strong>Suggested Action:</strong> {{ error_alert.suggestion }}</p>
<details class="error-details">
<summary>View Technical Details</summary>
<pre>{{ error_alert.raw_error }}</pre>
</details>
</div>
</div>
{% else %}
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
<h5 class="title is-6">Payment Error</h5>
<p>An error occurred during payment processing.</p>
<pre class="mt-3">{{ payment.Error }}</pre>
</div>
<button class="button is-small is-danger" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
{% endif %}
<div class="field is-grouped mt-4">
<div class="control">
<button class="button is-small is-info" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy Error</span>
<span>Copy Error Details</span>
</button>
</div>
</div>
<div id="error-content-{{ payment.id }}" style="display: none;">{{ payment.Error }}</div>
</section>
</div>

Loading…
Cancel
Save