import json import pymysql from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app from flask_login import login_required, current_user from sqlalchemy import func, case from datetime import datetime from app import db from typing import Dict, Any, List from models import PaymentBatch, Payments, SinglePayments, PaymentPlans, Logs, Users 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 from permissions import admin_required, finance_required, helpdesk_required from notification_service import NotificationService import re import time splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) def create_customer_friendly_message(payment_data: dict, error_details: str) -> str: """ Create a customer-friendly ticket message for failed payments. Args: payment_data: Dictionary containing payment information error_details: Raw error details Returns: str: HTML formatted customer-friendly message """ try: # Extract payment details amount = abs(payment_data.get('amount', 0)) splynx_id = payment_data.get('splynx_id', 'Unknown') # Parse PI_JSON for payment method details if available pi_json = payment_data.get('pi_json') payment_method_type = "unknown" last4 = "****" if pi_json: try: import json parsed_json = json.loads(pi_json) payment_method_type = parsed_json.get('payment_method_type', 'unknown') # Get last 4 digits from various possible locations in JSON if 'payment_method_details' in parsed_json: pm_details = parsed_json['payment_method_details'] if payment_method_type == 'card' and 'card' in pm_details: last4 = pm_details['card'].get('last4', '****') elif payment_method_type == 'au_becs_debit' and 'au_becs_debit' in pm_details: last4 = pm_details['au_becs_debit'].get('last4', '****') elif 'last4' in parsed_json: last4 = parsed_json.get('last4', '****') except: pass # Format payment method for display if payment_method_type == 'au_becs_debit': payment_method_display = f"Bank Account ending in {last4}" elif payment_method_type == 'card': payment_method_display = f"Card ending in {last4}" else: payment_method_display = "Payment method" # Get current datetime from datetime import datetime current_time = datetime.now().strftime("%d/%m/%Y at %I:%M %p") # Get customer-friendly error explanation error_classification = classify_payment_error(error_details, pi_json) if error_classification: error_message = error_classification['message'] else: error_message = "An error occurred during payment processing" # Create customer-friendly HTML message customer_message = f"""
Your payment attempt was unsuccessful.

Payment Details:
• Amount: ${amount:.2f} AUD
• Date/Time: {current_time}
• {payment_method_display}

Issue: {error_message}

Please contact us if you need assistance with your payment.
""" return customer_message.strip() except Exception as e: # Fallback message if there's any error creating the friendly message return f"""
Your payment attempt was unsuccessful. Please contact us for assistance.
""" 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 Account Closed if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \ any(phrase in error_lower for phrase in ['closed']): return { 'type': 'bank-contact', 'title': 'Bank Account Closed', 'message': 'The customer bank account has been closed.', 'suggestion': 'Customer should call the phone number on the back of their card', 'icon': 'fa-phone' } # Bank Account Not Found if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \ any(phrase in error_lower for phrase in ['located']): return { 'type': 'bank-contact', 'title': 'Account Not Located', 'message': 'The customer bank account could not be located.', 'suggestion': 'Customer should call the phone number on the back of their card', 'icon': 'fa-phone' } # 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 if key == "pay": payment = db.session.query(Payments).filter(Payments.id == pay_id).first() elif key == "singlepay": payment = db.session.query(SinglePayments).filter(SinglePayments.id == pay_id).first() try: if result.get('error') and not result.get('needs_fee_update'): payment.Error = f"Error Type: {result['error_type']}\nError: {result['error']}" payment.Success = result['success'] payment.PI_JSON = json.dumps(result) else: if result.get('needs_fee_update'): payment.PI_FollowUp = True payment.Payment_Intent = result['payment_intent_id'] payment.Success = result['success'] if result['success'] and Config.PROCESS_LIVE and key == "singlepay": # Only update Splynx for successful single payments in live mode find_pay_splynx_invoices(payment.Splynx_ID) add_payment_splynx( splynx_id=payment.Splynx_ID, pi_id=result['payment_intent_id'], pay_id=payment.id, amount=payment.Payment_Amount ) if result.get('payment_method_type') == "card": payment.Payment_Method = result['estimated_fee_details']['card_display_brand'] elif result.get('payment_method_type') == "au_becs_debit": payment.Payment_Method = result['payment_method_type'] if payment.PI_JSON: combined = {**json.loads(payment.PI_JSON), **result} payment.PI_JSON = json.dumps(combined) else: payment.PI_JSON = json.dumps(result) if result.get('fee_details'): payment.Fee_Total = result['fee_details']['total_fee'] for fee_type in result['fee_details']['fee_breakdown']: if fee_type['type'] == "tax": payment.Fee_Tax = fee_type['amount'] elif fee_type['type'] == "stripe_fee": payment.Fee_Stripe = fee_type['amount'] except Exception as e: print(f"processPaymentResult error: {e}\n{json.dumps(result)}") payment.PI_FollowUp = True def find_pay_splynx_invoices(splynx_id: int, splynx_pay_id: int, invoice_ids: List[int]) -> List[int]: """Mark Splynx invoices as paid for the given customer ID.""" #result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid") print(f"\n\nInvoice IDs to Pay: {invoice_ids} of type {type(invoice_ids)}\n") #params = { # 'main_attributes': { # 'customer_id': splynx_id, # 'status': ['IN', ['not_paid', 'pending']] # }, #} #query_string = splynx.build_splynx_query_params(params) #result = splynx.get(url=f"/api/2.0/admin/finance/invoices?{query_string}") invoice_pay = { "status": "paid", "payment_id": splynx_pay_id, "date_payment": datetime.now().strftime("%Y-%m-%d") } #for pay in result: for invoice in invoice_ids: res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice}", params=invoice_pay) return res def find_set_pending_splynx_invoices(splynx_id: int, invoice_list: List): """Mark Splynx invoices as pending for the given customer ID.""" #params = { # 'main_attributes': { # 'customer_id': splynx_id, # 'status': 'not_paid' # }, #} #query_string = splynx.build_splynx_query_params(params) #result = splynx.get(url=f"/api/2.0/admin/finance/invoices?{query_string}") invoice_pending = { "status": "pending" } updated_invoices = [] for invoice in invoice_list: res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice}", params=invoice_pending) if res: updated_invoices.append(res) return updated_invoices def add_payment_splynx(splynx_id, pi_id, pay_id, amount, invoice_id): """Add a payment record to Splynx.""" from datetime import datetime stripe_pay = { "customer_id": splynx_id, "amount": amount, "date": str(datetime.now().strftime('%Y-%m-%d')), "field_1": pi_id, "field_2": f"Single Payment_ID: {pay_id}", "invoice_id": invoice_id } res = splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay) if res: return res['id'] else: return False def get_customer_data_for_notification(splynx_id): """Get customer data from Splynx for notifications.""" try: customer_data = splynx.Customer(splynx_id) if customer_data != 'unknown': return customer_data else: return {'name': 'Unknown Customer'} except: return {'name': 'Unknown Customer'} def search_stripe_customer_by_email(email): """Search for a Stripe customer by email address.""" try: import stripe # Use appropriate API key based on config if Config.PROCESS_LIVE: stripe.api_key = "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" else: stripe.api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" customers = stripe.Customer.search(query=f"email:'{email}'") if customers.get('data') and len(customers['data']) > 0: # Return the most recent customer if multiple exist return customers['data'][-1]['id'] else: return None except Exception as e: print(f"Error searching Stripe customer by email {email}: {e}") return None def create_stripe_customer(customer_data, splynx_id): """Create a new Stripe customer with the provided data.""" try: import stripe # Use appropriate API key based on config if Config.PROCESS_LIVE: stripe.api_key = "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" else: stripe.api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" customer = stripe.Customer.create( name=customer_data.get('name', ''), description=customer_data.get('name', ''), email=customer_data.get('billing_email', ''), metadata={ 'splynx_id': str(splynx_id) } ) return customer.id except Exception as e: print(f"Error creating Stripe customer for Splynx ID {splynx_id}: {e}") return None def update_splynx_customer_stripe_id(splynx_id, stripe_customer_id): """Update Splynx customer with Stripe customer ID.""" try: params = { 'additional_attributes': { 'stripe_customer_id': stripe_customer_id } } update_result = splynx.put(url=f"/api/2.0/admin/customers/customer/{splynx_id}", params=params) return update_result is not None except Exception as e: print(f"Error updating Splynx customer {splynx_id} with Stripe ID {stripe_customer_id}: {e}") return False def get_stripe_customer_id(splynx_id): """ Get Stripe customer ID for a given Splynx customer ID. Enhanced logic: 1. First check MySQL database for existing Stripe customer ID 2. If not found, check Splynx additional_attributes for stripe_customer_id 3. If not valid, search Stripe by customer email 4. If still not found, create new Stripe customer 5. Store the Stripe customer ID back to Splynx """ connection = None try: # Step 1: Check MySQL database first (existing logic) connection = pymysql.connect( host=Config.MYSQL_CONFIG['host'], database=Config.MYSQL_CONFIG['database'], user=Config.MYSQL_CONFIG['user'], password=Config.MYSQL_CONFIG['password'], port=Config.MYSQL_CONFIG['port'], autocommit=False, cursorclass=pymysql.cursors.DictCursor ) query = """ SELECT cb.customer_id, cb.deposit, cb.payment_method, pad.field_1 AS stripe_customer_id FROM customer_billing cb LEFT OUTER JOIN payment_account_data pad ON cb.customer_id = pad.customer_id WHERE cb.customer_id = %s ORDER BY cb.payment_method ASC LIMIT 1 """ with connection.cursor() as cursor: cursor.execute(query, (splynx_id,)) result = cursor.fetchone() print(f"MYSQL: {result}") if result and result['stripe_customer_id']: print(f"Found Stripe customer ID in MySQL: {result['stripe_customer_id']}") return result['stripe_customer_id'] # Step 2: MySQL lookup failed, get customer from Splynx print(f"No Stripe customer ID found in MySQL for Splynx ID {splynx_id}, checking Splynx...") cust = splynx.Customer(splynx_id) if not cust or cust == 'unknown': print(f"Customer {splynx_id} not found in Splynx") return None # Step 3: Check Splynx additional_attributes for stripe_customer_id additional_attrs = cust.get('additional_attributes', {}) existing_stripe_id = additional_attrs.get('stripe_customer_id', '') if existing_stripe_id and existing_stripe_id.startswith('cus_'): print(f"Found valid Stripe customer ID in Splynx: {existing_stripe_id}") return existing_stripe_id # Step 4: Search Stripe by customer email customer_email = cust.get('billing_email', '') if customer_email: print(f"Searching Stripe for customer with email: {customer_email}") stripe_customer_id = search_stripe_customer_by_email(customer_email) if stripe_customer_id: print(f"Found existing Stripe customer: {stripe_customer_id}") # Store the ID back to Splynx if update_splynx_customer_stripe_id(splynx_id, stripe_customer_id): print(f"Updated Splynx customer {splynx_id} with Stripe ID {stripe_customer_id}") else: print(f"Failed to update Splynx customer {splynx_id} with Stripe ID") return stripe_customer_id # Step 5: Create new Stripe customer if none found print(f"No existing Stripe customer found, creating new customer for Splynx ID {splynx_id}") stripe_customer_id = create_stripe_customer(cust, splynx_id) if stripe_customer_id: print(f"Created new Stripe customer: {stripe_customer_id}") # Store the new ID back to Splynx if update_splynx_customer_stripe_id(splynx_id, stripe_customer_id): print(f"Updated Splynx customer {splynx_id} with new Stripe ID {stripe_customer_id}") else: print(f"Failed to update Splynx customer {splynx_id} with new Stripe ID") return stripe_customer_id else: print(f"Failed to create Stripe customer for Splynx ID {splynx_id}") return None except pymysql.Error as e: print(f"MySQL Error in get_stripe_customer_id: {e}") return None except Exception as e: print(f"Unexpected Error in get_stripe_customer_id: {e}") return None finally: if connection: connection.close() def get_stripe_payment_methods(stripe_customer_id): """Get payment methods for a Stripe customer.""" try: # Initialize Stripe processor if Config.PROCESS_LIVE: api_key = "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" else: api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" print(api_key) processor = StripePaymentProcessor(api_key=api_key, enable_logging=False) # Get payment methods from Stripe #stripe_customer_id = "cus_SoMyDihTxRsa7U" payment_methods = processor.get_payment_methods(stripe_customer_id) return payment_methods except Exception as e: print(f"Error fetching payment methods: {e}") return [] main_bp = Blueprint('main', __name__) @main_bp.app_template_filter('format_json') def format_json_filter(json_string): """Format JSON string with proper indentation.""" if not json_string: return '' try: # Parse the JSON string and format it with indentation parsed = json.loads(json_string) return json.dumps(parsed, indent=2, ensure_ascii=False) except (json.JSONDecodeError, TypeError): # If it's not valid JSON, return as-is return json_string @main_bp.app_template_filter('currency') def currency_filter(value): """Format number as currency with digit grouping.""" if value is None: return '$0.00' try: # Convert to float if it's not already num_value = float(value) # Format with comma separators and 2 decimal places return f"${num_value:,.2f}" 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(): return render_template('main/index.html') @main_bp.route('/batches') @finance_required def batch_list(): """Display list of all payment batches with summary information.""" # Query all batches with summary statistics batches = db.session.query( PaymentBatch.id, PaymentBatch.Created, func.count(Payments.id).label('payment_count'), func.sum(Payments.Payment_Amount).label('total_amount'), func.sum(Payments.Fee_Stripe).label('total_fees'), func.sum(case((Payments.Success == True, 1), else_=0)).label('successful_count'), func.sum(case((Payments.PI_FollowUp == True, 1), else_=0)).label('pending_count'), func.sum(case((Payments.Success == False, 1), else_=0)).label('failed_count'), func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count') ).outerjoin(Payments, PaymentBatch.id == Payments.PaymentBatch_ID)\ .group_by(PaymentBatch.id, PaymentBatch.Created)\ .order_by(PaymentBatch.Created.desc()).all() return render_template('main/batch_list.html', batches=batches) @main_bp.route('/batch/') @finance_required def batch_detail(batch_id): """Display detailed view of a specific payment batch.""" # Get batch information batch = PaymentBatch.query.get_or_404(batch_id) # Get summary statistics for this batch summary = db.session.query( func.count(Payments.id).label('payment_count'), func.sum(Payments.Payment_Amount).label('total_amount'), func.sum(Payments.Fee_Stripe).label('total_fees'), func.sum(case((Payments.Success == True, 1), else_=0)).label('successful_count'), func.sum(case((Payments.Success == False, 1), else_=0)).label('failed_count'), func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count') ).filter(Payments.PaymentBatch_ID == batch_id).first() # Get all payments for this batch ordered by Splynx_ID payments = Payments.query.filter_by(PaymentBatch_ID=batch_id)\ .order_by(Payments.Splynx_ID.asc()).all() return render_template('main/batch_detail.html', batch=batch, summary=summary, payments=payments) @main_bp.route('/single-payment') @helpdesk_required def single_payment(): """Display single payment form page.""" return render_template('main/single_payment.html') @main_bp.route('/single-payments') @helpdesk_required def single_payments_list(): """Display list of all single payments with summary information.""" # Query all single payments with user information from models import Users payments = db.session.query( SinglePayments.id, SinglePayments.Splynx_ID, SinglePayments.Stripe_Customer_ID, SinglePayments.Payment_Intent, SinglePayments.Payment_Method, SinglePayments.Payment_Amount, SinglePayments.Fee_Stripe, SinglePayments.Fee_Total, SinglePayments.Success, SinglePayments.Error, SinglePayments.PI_JSON, SinglePayments.Created, SinglePayments.PI_FollowUp, SinglePayments.Refund, SinglePayments.Refund_FollowUp, Users.FullName.label('processed_by') ).outerjoin(Users, SinglePayments.Who == Users.id)\ .order_by(SinglePayments.Created.desc()).all() # Calculate summary statistics total_payments = len(payments) successful_payments = sum(1 for p in payments if p.Success == True) failed_payments = sum(1 for p in payments if p.Success == False) pending_payments = sum(1 for p in payments if p.Success == None) total_amount = sum(p.Payment_Amount or 0 for p in payments if p.Success == True) total_fees = sum(p.Fee_Stripe or 0 for p in payments if p.Success == True) summary = { 'total_payments': total_payments, 'successful_payments': successful_payments, 'failed_payments': failed_payments, 'pending_payments': pending_payments, 'total_amount': total_amount, 'total_fees': total_fees, 'success_rate': (successful_payments / total_payments * 100) if total_payments > 0 else 0 } return render_template('main/single_payments_list.html', payments=payments, summary=summary) @main_bp.route('/single-payment/detail/') @login_required def single_payment_detail(payment_id): """Display detailed view of a specific single payment.""" # Get payment information from models import Users payment = db.session.query( SinglePayments.id, SinglePayments.Splynx_ID, SinglePayments.Stripe_Customer_ID, SinglePayments.Payment_Intent, SinglePayments.PI_FollowUp, SinglePayments.PI_Last_Check, SinglePayments.Payment_Method, SinglePayments.Fee_Tax, SinglePayments.Fee_Stripe, SinglePayments.Fee_Total, SinglePayments.Payment_Amount, SinglePayments.PI_JSON, SinglePayments.PI_FollowUp_JSON, SinglePayments.Refund_JSON, SinglePayments.Error, SinglePayments.Success, SinglePayments.Refund, SinglePayments.Stripe_Refund_ID, SinglePayments.Stripe_Refund_Created, SinglePayments.Created, Users.FullName.label('processed_by') ).outerjoin(Users, SinglePayments.Who == Users.id)\ .filter(SinglePayments.id == payment_id).first() if not payment: flash('Payment not found.', 'error') return redirect(url_for('main.single_payments_list')) return render_template('main/single_payment_detail.html', payment=payment) @main_bp.route('/payment/detail/') @login_required def payment_detail(payment_id): """Display detailed view of a specific batch payment.""" # Get payment information with all fields needed for the detail view payment = db.session.query(Payments).filter(Payments.id == payment_id).first() if not payment: current_app.logger.warning(f"Payment ID {payment_id} not found in Payments table") flash(f'Payment #{payment_id} not found in batch payments.', 'error') return redirect(url_for('main.batch_list')) # Log the payment detail view access log_activity( user_id=current_user.id, action="view_payment_detail", entity_type="payment", entity_id=payment_id, details=f"Viewed batch payment detail for payment ID {payment_id}" ) # Batch payments don't store individual user info, so set to Batch Processor payment.processed_by = 'Batch Processor' # Render the batch payment detail template current_app.logger.info(f"Rendering batch payment detail for payment {payment_id} (batch {payment.PaymentBatch_ID})") return render_template('main/batch_payment_detail.html', payment=payment) @main_bp.route('/single-payment/check-intent/', methods=['POST']) @login_required def check_payment_intent(payment_id): """Check the status of a payment intent and update the record.""" from datetime import datetime try: # Get the payment record payment = SinglePayments.query.get_or_404(payment_id) if not payment.Payment_Intent: return jsonify({'success': False, 'error': 'No payment intent found'}), 400 # Initialize Stripe processor if Config.PROCESS_LIVE: api_key = "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" else: api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) # Check payment intent status intent_result = processor.check_payment_intent(payment.Payment_Intent) print(json.dumps(intent_result, indent=2)) if intent_result['status'] == "succeeded": payment.PI_FollowUp_JSON = json.dumps(intent_result) payment.PI_FollowUp = False payment.PI_Last_Check = datetime.now() processPaymentResult(pay_id=payment.id, result=intent_result, key="singlepay") else: payment.PI_FollowUp_JSON = json.dumps(intent_result) payment.PI_Last_Check = datetime.now() db.session.commit() return jsonify({ 'success': True, 'status': intent_result['status'], 'payment_succeeded': intent_result['status'] == "succeeded", 'message': f'Payment intent status: {intent_result["status"]}' }) except Exception as e: db.session.rollback() print(f"Check payment intent error: {e}") return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500 @main_bp.route('/api/splynx/invoices/', methods=['GET']) @helpdesk_required def get_customer_invoices(splynx_id): """Fetch unpaid invoices for a customer from Splynx.""" try: params = { 'main_attributes': { 'customer_id': splynx_id, 'status': 'not_paid' } } query_string = splynx.build_splynx_query_params(params) invoices = splynx.get(url=f"/api/2.0/admin/finance/invoices?{query_string}") # Format invoice data for frontend formatted_invoices = [] for inv in invoices: formatted_invoices.append({ 'id': inv['id'], 'number': inv.get('number', 'N/A'), 'date': inv.get('date', 'N/A'), 'total': float(inv.get('total', 0)), 'status': inv.get('status', 'unknown'), 'description': inv.get('memo', 'No description') }) return jsonify({'success': True, 'invoices': formatted_invoices}) except Exception as e: print(f"Error fetching invoices: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @main_bp.route('/single-payment/process', methods=['POST']) @helpdesk_required def process_single_payment(): """Process a single payment using Stripe.""" try: # Get form data splynx_id = request.form.get('splynx_id') amount = request.form.get('amount') payment_method = request.form.get('payment_method') invoice_ids = request.form.get('invoice_ids', []) invoice_list = invoice_ids.split(",") if invoice_ids else [0] # Validate inputs if not splynx_id or not amount or not payment_method: return jsonify({'success': False, 'error': 'Missing required fields'}), 400 try: splynx_id = int(splynx_id) amount = float(amount) except (ValueError, TypeError): return jsonify({'success': False, 'error': 'Invalid input format'}), 400 if amount <= 0: return jsonify({'success': False, 'error': 'Amount must be greater than 0'}), 400 # Get customer details from Splynx customer_data = splynx.Customer(splynx_id) if not customer_data: return jsonify({'success': False, 'error': 'Customer not found in Splynx'}), 404 # Get Stripe customer ID from MySQL stripe_customer_id = get_stripe_customer_id(splynx_id) if not stripe_customer_id: return jsonify({'success': False, 'error': 'Customer does not have a valid Stripe payment method'}), 400 # Create payment record in database payment_record = SinglePayments( Splynx_ID=splynx_id, Stripe_Customer_ID=stripe_customer_id, Payment_Amount=amount, Who=current_user.id, Invoices_to_Pay=invoice_ids ) db.session.add(payment_record) db.session.commit() # Commit to get the payment ID # Initialize Stripe processor if Config.PROCESS_LIVE: print("LIVE Payment") api_key = "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" else: print("SANDBOX Payment") api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" # Use test customer for sandbox #import random #test_customers = ['cus_SoNAgAbkbFo8ZY', 'cus_SoMyDihTxRsa7U', 'cus_SoQedaG3q2ecKG', 'cus_SoMVPWxdYstYbr'] #stripe_customer_id = random.choice(test_customers) processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) print(f"stripe_customer_id: {stripe_customer_id}") # 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}", stripe_pm=payment_method ) # Update payment record with results payment_record.Success = result.get('success', False) payment_record.Payment_Intent = result.get('payment_intent_id') payment_record.PI_JSON = json.dumps(result) if result.get('error') and not result.get('needs_fee_update'): payment_record.Error = f"Error Type: {result.get('error_type', 'Unknown')}\nError: {result['error']}" # Send notification and create ticket for failed single payments try: # Initialize notification service notification_service = NotificationService() # Get customer information customer_data = get_customer_data_for_notification(splynx_id) # Prepare payment data for notification payment_data = { 'payment_id': payment_record.id, 'splynx_id': splynx_id, 'amount': amount, 'error': payment_record.Error, 'payment_method': payment_method, 'customer_name': customer_data.get('name', 'Unknown Customer'), 'payment_type': 'single', 'stripe_customer_id': stripe_customer_id, 'payment_intent': result.get('payment_intent_id') } # Send notification and create ticket (only in live mode) #if Config.PROCESS_LIVE: # Send email notification email_sent = notification_service.send_payment_failure_notification(payment_data) # Create Splynx ticket ticket_result = splynx.create_ticket( customer_id=splynx_id, subject=f"Payment Failure - ${amount:.2f}", type_id=1, group_up=7, status_id=1, priority="medium" ) internal_message=f"""
Single payment processing has failed for customer {customer_data.get('name', 'Unknown')} (ID: {splynx_id}).

Payment Details:
  • Payment ID: {payment_record.id} (single payment)
  • Amount: ${amount:.2f} AUD
  • Payment Method: {payment_method}
  • Stripe Customer: {stripe_customer_id}
  • Payment Intent: {result.get('payment_intent_id', 'N/A')}
  • Processed by: {current_user.FullName}

Error Information:
{payment_record.Error}

This ticket was automatically created by the Plutus Payment System.
""" # Create customer-friendly message payment_data_for_msg = { 'amount': amount, 'splynx_id': splynx_id, 'pi_json': result.get('pi_json') or json.dumps(result) } cust_message = create_customer_friendly_message(payment_data_for_msg, result.get('error', 'Unknown error')) # Add Internal Note add_internal_note = splynx.add_ticket_message( ticket_id=ticket_result['ticket_id'], message=internal_message, is_admin=False, hide_for_customer=True, message_type="note" ) # Customer Message add_message = splynx.add_ticket_message( ticket_id=ticket_result['ticket_id'], message=cust_message, is_admin=False, hide_for_customer=False, message_type="message" ) print(f"Notification sent: {email_sent}, Ticket created: {ticket_result.get('success', False)}") except Exception as e: print(f"Error sending notification for failed single payment: {e}") if result.get('needs_fee_update'): payment_record.PI_FollowUp = True # Mark invoices as pending when PI_FollowUp is set #if Config.PROCESS_LIVE: try: pending_invoices = find_set_pending_splynx_invoices(splynx_id, invoice_list) if invoice_list[0] == 0: payment_record.Invoices_to_Pay = ','.join(pending_invoices) except Exception as e: print(f"⚠️ Error setting invoices to pending: {e}") if result.get('payment_method_type') == "card": payment_record.Payment_Method = result.get('estimated_fee_details', {}).get('card_display_brand', 'card') elif result.get('payment_method_type') == "au_becs_debit": payment_record.Payment_Method = result['payment_method_type'] if result.get('fee_details') and result.get('fee_details').get('fee_breakdown'): payment_record.Fee_Total = result['fee_details']['total_fee'] for fee_type in result['fee_details']['fee_breakdown']: if fee_type['type'] == "tax": payment_record.Fee_Tax = fee_type['amount'] elif fee_type['type'] == "stripe_fee": payment_record.Fee_Stripe = fee_type['amount'] # Commit the updated payment record db.session.commit() # Check if payment was actually successful if result.get('success'): # Payment succeeded - update Splynx if in live mode #if Config.PROCESS_LIVE: try: # Add payment record to Splynx splynx_payment_id = add_payment_splynx( splynx_id=splynx_id, pi_id=result.get('payment_intent_id'), pay_id=payment_record.id, amount=amount, invoice_id=invoice_list[0] ) if splynx_payment_id: print(f"✅ Splynx payment record created: {splynx_payment_id}") # Mark invoices as paid in Splynx find_pay_splynx_invoices(splynx_id, splynx_payment_id, invoice_list) else: print("⚠️ Failed to create Splynx payment record") except Exception as splynx_error: print(f"❌ Error updating Splynx: {splynx_error}") # Continue processing even if Splynx update fails # Log successful payment log_activity( current_user.id, "PAYMENT_SUCCESS", "SinglePayment", payment_record.id, details=f"Single payment successful: ${amount:,.2f} for customer {splynx_id} ({customer_data.get('name', 'Unknown')})" ) # Payment succeeded return jsonify({ 'success': True, 'payment_success': True, 'payment_id': payment_record.id, 'payment_intent': result.get('payment_intent_id'), 'amount': amount, 'customer_name': customer_data.get('name'), 'message': f'Payment processed successfully for {customer_data.get("name")}' }) else: # Payment failed - log the failure log_activity( current_user.id, "PAYMENT_FAILED", "SinglePayment", payment_record.id, details=f"Single payment failed: ${amount:,.2f} for customer {splynx_id} ({customer_data.get('name', 'Unknown')}) - {result.get('error', 'Unknown error')}" ) # Payment failed - return the specific error if result.get('needs_fee_update'): fee_update = True else: fee_update = False return jsonify({ 'success': False, 'payment_success': False, 'fee_update': fee_update, 'payment_id': payment_record.id, 'error': result.get('error', 'Payment failed'), 'error_type': result.get('error_type', 'unknown_error'), 'stripe_error': result.get('error', 'Unknown payment error'), 'customer_name': customer_data.get('name') }), 422 # 422 Unprocessable Entity for business logic failures except Exception as e: db.session.rollback() print(f"Single payment processing error: {e}") return jsonify({'success': False, 'error': 'Payment processing failed. Please try again.'}), 500 @main_bp.route('/payment-plans') @finance_required def payment_plans_list(): """Display list of all payment plans with summary information.""" from models import Users # Query all payment plans with user information plans = db.session.query( PaymentPlans.id, PaymentPlans.Splynx_ID, PaymentPlans.Amount, PaymentPlans.Frequency, PaymentPlans.Start_Date, PaymentPlans.Stripe_Payment_Method, PaymentPlans.Enabled, PaymentPlans.Created, Users.FullName.label('created_by') ).outerjoin(Users, PaymentPlans.Who == Users.id)\ .order_by(PaymentPlans.Created.desc()).all() # Calculate summary statistics total_plans = len(plans) active_plans = sum(1 for p in plans if p.Enabled == True) inactive_plans = sum(1 for p in plans if p.Enabled == False) total_recurring_amount = sum(p.Amount or 0 for p in plans if p.Enabled == True) summary = { 'total_plans': total_plans, 'active_plans': active_plans, 'inactive_plans': inactive_plans, 'total_recurring_amount': total_recurring_amount } return render_template('main/payment_plans_list.html', plans=plans, summary=summary) @main_bp.route('/payment-plans/create') @finance_required def payment_plans_create(): """Display payment plan creation form.""" return render_template('main/payment_plans_form.html', edit_mode=False) @main_bp.route('/payment-plans/create', methods=['POST']) @finance_required def payment_plans_create_post(): """Handle payment plan creation.""" try: # Get form data splynx_id = request.form.get('splynx_id') amount = request.form.get('amount') frequency = request.form.get('frequency') start_date = request.form.get('start_date') stripe_payment_method = request.form.get('stripe_payment_method') # Validate inputs if not all([splynx_id, amount, frequency, start_date, stripe_payment_method]): flash('All fields are required.', 'error') return redirect(url_for('main.payment_plans_create')) try: splynx_id = int(splynx_id) amount = float(amount) from datetime import datetime start_date = datetime.strptime(start_date, '%Y-%m-%d') except (ValueError, TypeError): flash('Invalid input format.', 'error') return redirect(url_for('main.payment_plans_create')) if amount <= 0: flash('Amount must be greater than 0.', 'error') return redirect(url_for('main.payment_plans_create')) # Validate customer exists in Splynx customer_data = splynx.Customer(splynx_id) if not customer_data: flash('Customer not found in Splynx.', 'error') return redirect(url_for('main.payment_plans_create')) # Create payment plan record payment_plan = PaymentPlans( Splynx_ID=splynx_id, Amount=amount, Frequency=frequency, Start_Date=start_date, Stripe_Payment_Method=stripe_payment_method, Who=current_user.id ) db.session.add(payment_plan) db.session.commit() # Log payment plan creation log_activity( current_user.id, "PAYPLAN_CREATED", "PaymentPlan", payment_plan.id, details=f"Payment plan created: ${amount:,.2f} {frequency} for customer {splynx_id} ({customer_data.get('name', 'Unknown')})" ) flash(f'Payment plan created successfully for {customer_data.get("name", "customer")}.', 'success') return redirect(url_for('main.payment_plans_detail', plan_id=payment_plan.id)) except Exception as e: db.session.rollback() print(f"Payment plan creation error: {e}") flash('Failed to create payment plan. Please try again.', 'error') return redirect(url_for('main.payment_plans_create')) @main_bp.route('/payment-plans/edit/') @finance_required def payment_plans_edit(plan_id): """Display payment plan edit form.""" plan = PaymentPlans.query.get_or_404(plan_id) return render_template('main/payment_plans_form.html', plan=plan, edit_mode=True) @main_bp.route('/payment-plans/edit/', methods=['POST']) @finance_required def payment_plans_edit_post(plan_id): """Handle payment plan updates.""" try: plan = PaymentPlans.query.get_or_404(plan_id) # Get form data amount = request.form.get('amount') frequency = request.form.get('frequency') start_date = request.form.get('start_date') stripe_payment_method = request.form.get('stripe_payment_method') # Validate inputs if not all([amount, frequency, start_date, stripe_payment_method]): flash('All fields are required.', 'error') return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) try: amount = float(amount) from datetime import datetime start_date = datetime.strptime(start_date, '%Y-%m-%d') except (ValueError, TypeError): flash('Invalid input format.', 'error') return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) if amount <= 0: flash('Amount must be greater than 0.', 'error') return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) # Update payment plan plan.Amount = amount plan.Frequency = frequency plan.Start_Date = start_date plan.Stripe_Payment_Method = stripe_payment_method db.session.commit() # Log payment plan update log_activity( current_user.id, "PAYPLAN_UPDATED", "PaymentPlan", plan.id, details=f"Payment plan updated: ${amount:,.2f} {frequency} starting {start_date.strftime('%Y-%m-%d')}" ) flash('Payment plan updated successfully.', 'success') return redirect(url_for('main.payment_plans_detail', plan_id=plan.id)) except Exception as e: db.session.rollback() print(f"Payment plan update error: {e}") flash('Failed to update payment plan. Please try again.', 'error') return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) @main_bp.route('/payment-plans/delete/', methods=['POST']) @finance_required def payment_plans_delete(plan_id): """Handle payment plan deletion (soft delete).""" try: plan = PaymentPlans.query.get_or_404(plan_id) # Soft delete by setting Enabled to False plan.Enabled = False db.session.commit() flash('Payment plan has been disabled.', 'success') return redirect(url_for('main.payment_plans_list')) except Exception as e: db.session.rollback() print(f"Payment plan deletion error: {e}") flash('Failed to disable payment plan. Please try again.', 'error') return redirect(url_for('main.payment_plans_detail', plan_id=plan_id)) @main_bp.route('/payment-plans/toggle/', methods=['POST']) @login_required def payment_plans_toggle(plan_id): """Toggle payment plan enabled status.""" try: plan = PaymentPlans.query.get_or_404(plan_id) # Toggle enabled status plan.Enabled = not plan.Enabled db.session.commit() # Log payment plan toggle action = "PAYPLAN_ENABLED" if plan.Enabled else "PAYPLAN_DISABLED" log_activity( current_user.id, action, "PaymentPlan", plan.id, details=f"Payment plan {'enabled' if plan.Enabled else 'disabled'}: ${plan.Amount:,.2f} {plan.Frequency}" ) status = "enabled" if plan.Enabled else "disabled" flash(f'Payment plan has been {status}.', 'success') return redirect(url_for('main.payment_plans_detail', plan_id=plan_id)) except Exception as e: db.session.rollback() print(f"Payment plan toggle error: {e}") flash('Failed to update payment plan status. Please try again.', 'error') return redirect(url_for('main.payment_plans_detail', plan_id=plan_id)) @main_bp.route('/payment-plans/detail/') @login_required def payment_plans_detail(plan_id): """Display detailed view of a specific payment plan.""" from models import Users # Get payment plan with user information plan = db.session.query( PaymentPlans.id, PaymentPlans.Splynx_ID, PaymentPlans.Amount, PaymentPlans.Frequency, PaymentPlans.Start_Date, PaymentPlans.Stripe_Payment_Method, PaymentPlans.Enabled, PaymentPlans.Created, Users.FullName.label('created_by') ).outerjoin(Users, PaymentPlans.Who == Users.id)\ .filter(PaymentPlans.id == plan_id).first() if not plan: flash('Payment plan not found.', 'error') return redirect(url_for('main.payment_plans_list')) # Get associated single payments associated_payments = db.session.query( Payments.id, Payments.Payment_Amount, Payments.Success, Payments.Error, Payments.Created, Payments.Payment_Intent)\ .filter(Payments.PaymentPlan_ID == plan_id)\ .order_by(Payments.Created.desc()).all() return render_template('main/payment_plans_detail.html', 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.""" print(f"\n\nSplynx/Stripe finding customer\n\n") 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): """Get Stripe payment methods for a customer.""" try: payment_methods = get_stripe_payment_methods(stripe_customer_id) return jsonify({'success': True, 'payment_methods': payment_methods}) except Exception as e: print(f"Error fetching payment methods: {e}") return jsonify({'success': False, 'error': 'Failed to fetch payment methods'}), 500 @main_bp.route('/payment/check-intent/', methods=['POST']) @login_required def check_batch_payment_intent(payment_id): """Check the status of a batch payment intent and update the record.""" from datetime import datetime try: # Get the payment record from Payments table (batch payments) payment = Payments.query.get_or_404(payment_id) if not payment.Payment_Intent: return jsonify({'success': False, 'error': 'No payment intent found'}), 400 # Initialize Stripe processor with correct API key if Config.PROCESS_LIVE: api_key = Config.STRIPE_LIVE_API_KEY else: api_key = Config.STRIPE_TEST_API_KEY processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) # Check payment intent status intent_result = processor.check_payment_intent(payment.Payment_Intent) if intent_result['status'] == "succeeded": payment.PI_FollowUp_JSON = json.dumps(intent_result) payment.PI_FollowUp = False payment.PI_Last_Check = datetime.now() payment.Success = True if intent_result.get('charge_id'): payment.Stripe_Charge_ID = intent_result.get('charge_id') processPaymentResult(pay_id=payment.id, result=intent_result, key="pay") elif intent_result['status'] == "failed": payment.PI_FollowUp_JSON = json.dumps(intent_result) payment.PI_FollowUp = False payment.PI_Last_Check = datetime.now() payment.Success = False else: # Still pending payment.PI_FollowUp_JSON = json.dumps(intent_result) payment.PI_Last_Check = datetime.now() db.session.commit() # Log the intent check activity log_activity( user_id=current_user.id, action="check_payment_intent", entity_type="payment", entity_id=payment_id, details=f"Checked payment intent {payment.Payment_Intent}, status: {intent_result['status']}" ) return jsonify({ 'success': True, 'status': intent_result['status'], 'payment_succeeded': intent_result['status'] == "succeeded", 'message': f'Payment intent status: {intent_result["status"]}' }) except Exception as e: db.session.rollback() print(f"Check batch payment intent error: {e}") return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500 @main_bp.route('/single-payment/refund/', methods=['POST']) @login_required def process_single_payment_refund(payment_id): """Process refund for a single payment.""" try: # Get the payment record payment = db.session.query(SinglePayments).filter(SinglePayments.id == payment_id).first() if not payment: return jsonify({'success': False, 'error': 'Payment not found'}), 404 # Check if payment can be refunded if payment.Success != True: return jsonify({'success': False, 'error': 'Cannot refund unsuccessful payment'}), 400 if payment.Refund == True: return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400 if not payment.Payment_Intent: return jsonify({'success': False, 'error': 'No payment intent found for this payment'}), 400 # Get refund reason from request data = request.get_json() reason = data.get('reason', 'requested_by_customer') if data else 'requested_by_customer' # Initialize Stripe if Config.PROCESS_LIVE: api_key = "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" else: api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" import stripe stripe.api_key = api_key # Process refund through Stripe refund = stripe.Refund.create( payment_intent=payment.Payment_Intent, reason=reason, metadata={ 'splynx_customer_id': str(payment.Splynx_ID), 'payment_id': str(payment_id), 'processed_by': current_user.FullName } ) # Update payment record based on refund status payment.Refund_JSON = json.dumps(refund, default=str) payment.Stripe_Refund_ID = refund.id # Convert timestamp to datetime if hasattr(refund, 'created') and refund.created: from datetime import datetime payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created) # Handle refund status - check if it's succeeded or pending if refund.status == "succeeded": payment.Refund = True refund_status_message = "succeeded" elif refund.status == "pending": payment.Refund_FollowUp = True refund_status_message = "pending" else: # For any other status, just store the refund data but don't mark as complete refund_status_message = refund.status db.session.commit() # Log the refund activity log_activity( user_id=current_user.id, action="process_refund", entity_type="single_payment", entity_id=payment_id, details=f"Processed refund for single payment ID {payment_id}, amount ${payment.Payment_Amount}, reason: {reason}, status: {refund_status_message}" ) return jsonify({ 'success': True, 'pending': refund.status == "pending", 'refund_id': refund.id, 'refund_status': refund.status, 'amount_refunded': f"${payment.Payment_Amount:.2f}", 'reason': reason }) except stripe.StripeError as e: return jsonify({'success': False, 'error': f'Stripe error: {str(e)}'}), 500 except Exception as e: print(f"Error processing single payment refund: {e}") return jsonify({'success': False, 'error': 'Internal server error'}), 500 @main_bp.route('/single-payment/check-refund/', methods=['POST']) @login_required def check_single_payment_refund_status(payment_id): """Check the status of a pending refund for a single payment.""" try: # Get the payment record payment = db.session.query(SinglePayments).filter(SinglePayments.id == payment_id).first() if not payment: return jsonify({'success': False, 'error': 'Payment not found'}), 404 if not payment.Stripe_Refund_ID: return jsonify({'success': False, 'error': 'No refund ID found for this payment'}), 400 # Initialize Stripe import stripe if Config.PROCESS_LIVE: stripe.api_key = Config.STRIPE_LIVE_API_KEY else: stripe.api_key = Config.STRIPE_TEST_API_KEY # Get refund details from Stripe refund = stripe.Refund.retrieve(payment.Stripe_Refund_ID) # Update payment record based on refund status if refund.status == "succeeded": payment.Refund = True payment.Refund_FollowUp = False refund_completed = True elif refund.status == "pending": # Still pending, no change needed refund_completed = False elif refund.status in ["failed", "canceled"]: # Refund failed, update status payment.Refund_FollowUp = False refund_completed = False else: refund_completed = False # Update the Refund_JSON with latest data payment.Refund_JSON = json.dumps(refund, default=str) db.session.commit() # Log the refund status check log_activity( user_id=current_user.id, action="check_refund_status", entity_type="single_payment", entity_id=payment_id, details=f"Checked refund status for single payment ID {payment_id}, status: {refund.status}" ) return jsonify({ 'success': True, 'refund_completed': refund_completed, 'status': refund.status, 'refund_id': refund.id, 'amount_refunded': f"${refund.amount/100:.2f}" }) except stripe.StripeError as e: return jsonify({'success': False, 'error': f'Stripe error: {str(e)}'}), 500 except Exception as e: print(f"Error checking refund status: {e}") return jsonify({'success': False, 'error': 'Internal server error'}), 500 @main_bp.route('/payment/check-refund/', methods=['POST']) @login_required def check_batch_payment_refund_status(payment_id): """Check the status of a pending refund for a batch payment.""" try: # Get the payment record from Payments table (batch payments) payment = Payments.query.get_or_404(payment_id) if not payment.Stripe_Refund_ID: return jsonify({'success': False, 'error': 'No refund ID found for this payment'}), 400 # Initialize Stripe import stripe if Config.PROCESS_LIVE: stripe.api_key = Config.STRIPE_LIVE_API_KEY else: stripe.api_key = Config.STRIPE_TEST_API_KEY # Get refund details from Stripe refund = stripe.Refund.retrieve(payment.Stripe_Refund_ID) # Update payment record based on refund status if refund.status == "succeeded": payment.Refund = True payment.Refund_FollowUp = False refund_completed = True elif refund.status == "pending": # Still pending, no change needed refund_completed = False elif refund.status in ["failed", "canceled"]: # Refund failed, update status payment.Refund_FollowUp = False refund_completed = False else: refund_completed = False # Update the Refund_JSON with latest data payment.Refund_JSON = json.dumps(refund, default=str) db.session.commit() # Log the refund status check log_activity( user_id=current_user.id, action="check_refund_status", entity_type="payment", entity_id=payment_id, details=f"Checked refund status for batch payment ID {payment_id}, status: {refund.status}" ) return jsonify({ 'success': True, 'refund_completed': refund_completed, 'status': refund.status, 'refund_id': refund.id, 'amount_refunded': f"${refund.amount/100:.2f}" }) except stripe.StripeError as e: return jsonify({'success': False, 'error': f'Stripe error: {str(e)}'}), 500 except Exception as e: print(f"Error checking batch refund status: {e}") return jsonify({'success': False, 'error': 'Internal server error'}), 500 @main_bp.route('/logs') @finance_required def logs_list(): """Display system logs with filtering and pagination.""" # Get filter parameters page = request.args.get('page', 1, type=int) search = request.args.get('search', '') user_filter = request.args.get('user', '', type=int) action_filter = request.args.get('action', '') entity_type_filter = request.args.get('entity_type', '') date_from = request.args.get('date_from', '') date_to = request.args.get('date_to', '') # Build query with joins and filters from models import Users query = db.session.query( Logs.id, Logs.User_ID, Logs.Log_Entry, Logs.Added, Logs.Action, Logs.Entity_Type, Logs.Entity_ID, Logs.IP_Address, Users.FullName.label('user_name') ).outerjoin(Users, Logs.User_ID == Users.id) # Apply filters if search: search_filter = f"%{search}%" query = query.filter(db.or_( Logs.Log_Entry.like(search_filter), Logs.Action.like(search_filter), Logs.Entity_Type.like(search_filter), Users.FullName.like(search_filter), Logs.IP_Address.like(search_filter) )) if user_filter: query = query.filter(Logs.User_ID == user_filter) if action_filter: query = query.filter(Logs.Action == action_filter) if entity_type_filter: query = query.filter(Logs.Entity_Type == entity_type_filter) if date_from: try: from datetime import datetime date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') query = query.filter(Logs.Added >= date_from_obj) except ValueError: pass if date_to: try: from datetime import datetime date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') # Add one day to include the entire date_to day date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) query = query.filter(Logs.Added <= date_to_obj) except ValueError: pass # Order by most recent first query = query.order_by(Logs.Added.desc()) # Paginate results per_page = 50 # Show 50 logs per page pagination = query.paginate( page=page, per_page=per_page, error_out=False ) logs = pagination.items # Get unique users for filter dropdown users = db.session.query(Users.id, Users.FullName).filter( Users.id.in_( db.session.query(Logs.User_ID).distinct() ) ).order_by(Users.FullName).all() # Get unique actions for filter dropdown actions = db.session.query(Logs.Action).filter( Logs.Action.isnot(None) ).distinct().order_by(Logs.Action).all() actions = [action[0] for action in actions if action[0]] # Get unique entity types for filter dropdown entity_types = db.session.query(Logs.Entity_Type).filter( Logs.Entity_Type.isnot(None) ).distinct().order_by(Logs.Entity_Type).all() entity_types = [entity_type[0] for entity_type in entity_types if entity_type[0]] # Log this page access log_activity( user_id=current_user.id, action="view_logs", entity_type="logs", details=f"Viewed system logs page {page} with filters: search={search}, user={user_filter}, action={action_filter}", ip_address=request.remote_addr ) return render_template( 'main/logs_list.html', logs=logs, pagination=pagination, users=users, actions=actions, entity_types=entity_types ) @main_bp.route('/logs/detail/') @finance_required def log_detail(log_id): """Get detailed information for a specific log entry.""" log = db.session.query(Logs).filter(Logs.id == log_id).first() if not log: return jsonify({'success': False, 'error': 'Log entry not found'}), 404 # Get user name if available from models import Users user = db.session.query(Users).filter(Users.id == log.User_ID).first() if log.User_ID else None log_data = { 'id': log.id, 'User_ID': log.User_ID, 'user_name': user.FullName if user else None, 'Log_Entry': log.Log_Entry, 'timestamp': log.Added.strftime('%Y-%m-%d %H:%M:%S') if log.Added else None, 'Action': log.Action, 'Entity_Type': log.Entity_Type, 'Entity_ID': log.Entity_ID, 'IP_Address': log.IP_Address } # Log this detail view access log_activity( user_id=current_user.id, action="view_log_detail", entity_type="log", entity_id=log_id, details=f"Viewed details for log entry {log_id}", ip_address=request.remote_addr ) return jsonify({'success': True, 'log': log_data}) @main_bp.route('/logs/export') @finance_required def export_logs(): """Export logs as CSV file with current filters applied.""" # Get filter parameters (same as logs_list) search = request.args.get('search', '') user_filter = request.args.get('user', '', type=int) action_filter = request.args.get('action', '') entity_type_filter = request.args.get('entity_type', '') date_from = request.args.get('date_from', '') date_to = request.args.get('date_to', '') # Build query with same filters as logs_list from models import Users query = db.session.query( Logs.id, Logs.User_ID, Logs.Log_Entry, Logs.Added, Logs.Action, Logs.Entity_Type, Logs.Entity_ID, Logs.IP_Address, Users.FullName.label('user_name') ).outerjoin(Users, Logs.User_ID == Users.id) # Apply same filters as in logs_list if search: search_filter = f"%{search}%" query = query.filter(db.or_( Logs.Log_Entry.like(search_filter), Logs.Action.like(search_filter), Logs.Entity_Type.like(search_filter), Users.FullName.like(search_filter), Logs.IP_Address.like(search_filter) )) if user_filter: query = query.filter(Logs.User_ID == user_filter) if action_filter: query = query.filter(Logs.Action == action_filter) if entity_type_filter: query = query.filter(Logs.Entity_Type == entity_type_filter) if date_from: try: from datetime import datetime date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') query = query.filter(Logs.Added >= date_from_obj) except ValueError: pass if date_to: try: from datetime import datetime date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) query = query.filter(Logs.Added <= date_to_obj) except ValueError: pass # Order by most recent first logs = query.order_by(Logs.Added.desc()).limit(10000).all() # Limit to 10k records for export # Generate CSV import csv from io import StringIO from flask import Response output = StringIO() writer = csv.writer(output) # Write headers writer.writerow([ 'ID', 'Timestamp', 'User ID', 'User Name', 'Action', 'Entity Type', 'Entity ID', 'Details', 'IP Address' ]) # Write data for log in logs: writer.writerow([ log.id, log.Added.strftime('%Y-%m-%d %H:%M:%S') if log.Added else '', log.User_ID or '', log.user_name or '', log.Action or '', log.Entity_Type or '', log.Entity_ID or '', (log.Log_Entry or '').replace('\n', ' ').replace('\r', ' '), # Clean newlines for CSV log.IP_Address or '' ]) # Log the export activity log_activity( user_id=current_user.id, action="export_logs", entity_type="logs", details=f"Exported {len(logs)} log entries with filters applied", ip_address=request.remote_addr ) # Create response from datetime import datetime timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"system_logs_{timestamp}.csv" response = Response( output.getvalue(), mimetype='text/csv', headers={'Content-Disposition': f'attachment; filename={filename}'} ) return response @main_bp.route('/payment/refund/', methods=['POST']) @login_required def process_payment_refund(payment_id): """Process a refund for a batch payment.""" from datetime import datetime import stripe try: # Get the payment record from Payments table (batch payments) payment = Payments.query.get_or_404(payment_id) # Validate payment can be refunded if not payment.Success: return jsonify({'success': False, 'error': 'Cannot refund an unsuccessful payment'}), 400 if payment.Refund: return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400 #if not payment.Stripe_Charge_ID: # return jsonify({'success': False, 'error': 'No Stripe charge ID found for this payment'}), 400 # Get refund reason from request data = request.get_json() reason = data.get('reason', 'requested_by_customer') # Initialize Stripe with correct API key if Config.PROCESS_LIVE: stripe.api_key = Config.STRIPE_LIVE_API_KEY else: stripe.api_key = Config.STRIPE_TEST_API_KEY # Create refund parameters #refund_params = { # 'charge': payment.Stripe_Charge_ID, # 'reason': reason #} # Process the refund with Stripe #refund = stripe.Refund.create(**refund_params) refund = stripe.Refund.create( payment_intent=payment.Payment_Intent, reason=reason, metadata={ 'splynx_customer_id': str(payment.Splynx_ID), 'payment_id': str(payment_id), 'processed_by': current_user.FullName } ) print(f"refund: {refund}") time.sleep(3) refunds = stripe.Refund.list(payment_intent=payment.Payment_Intent) #refund = stripe.Refund.retrieve(payment_intent=payment.Payment_Intent) #for refund in refunds.data: if refunds.count == 1: refund = refunds.data[-1] else: # Log the refund activity log_activity( user_id=current_user.id, action="process_refund", entity_type="refund_error", entity_id=payment_id, details=f"Error in refund count ({refunds.count} for Pay Intent: {payment.Payment_Intent})" ) if refund['status'] in ["succeeded", "pending"]: # Update payment record with refund information payment.Stripe_Refund_ID = refund.id payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created) payment.Refund_JSON = json.dumps(refund) if refund['status'] == "succeeded": payment.Refund = True elif refund['status'] == "pending": payment.Refund_FollowUp = True db.session.commit() # Log the refund activity log_activity( user_id=current_user.id, action="process_refund", entity_type="payment", entity_id=payment_id, details=f"Processed refund {refund.id} for payment {payment_id}, amount: ${refund.amount/100:.2f}" ) if refund['status'] == "succeeded": return jsonify({ 'success': True, 'pending': False, 'refund_id': refund.id, 'amount_refunded': f"${refund.amount/100:.2f}", 'status': refund.status, 'message': 'Refund processed successfully' }) elif refund['status'] == "pending": return jsonify({ 'success': True, 'pending': True, 'refund_id': refund.id, 'amount_refunded': f"${refund.amount/100:.2f}", 'status': refund.status, 'message': 'Refund is being processed. Refund should occur within the next few days.' }) else: # Refund failed payment.Refund = False payment.Refund_JSON = json.dumps(refund) db.session.commit() return jsonify({ 'success': False, 'error': f'Refund failed with status: {refund.status}' }), 400 except stripe.error.InvalidRequestError as e: # Handle Stripe-specific errors error_msg = str(e) if "has already been refunded" in error_msg: # Mark as refunded in our database even if Stripe says it's already refunded payment.Refund = True payment.Stripe_Refund_Created = datetime.now() db.session.commit() return jsonify({'success': False, 'error': 'Payment has already been refunded in Stripe'}), 400 else: return jsonify({'success': False, 'error': f'Stripe error: {error_msg}'}), 400 except Exception as e: db.session.rollback() print(f"Process payment refund error: {e}") return jsonify({'success': False, 'error': 'Failed to process refund'}), 500 @main_bp.route('/api/splynx/') @login_required def api_splynx_customer(id): """ Get Splynx customer information by ID Security: Restricted to operational and financial staff who need customer data access """ try: log_activity(current_user.id, "API_ACCESS", "SplynxCustomer", id, details=f"Accessed Splynx customer API for customer {id}") print(f"Splynx Customer API: {id}") res = splynx.Customer(id) if res: log_activity(current_user.id, "API_SUCCESS", "SplynxCustomer", id, details=f"Successfully retrieved Splynx customer {id}") return res else: log_activity(current_user.id, "API_NOT_FOUND", "SplynxCustomer", id, details=f"Splynx customer {id} not found") return {"error": "Customer not found"}, 404 except Exception as e: log_activity(current_user.id, "API_ERROR", "SplynxCustomer", id, details=f"Splynx customer API error: {str(e)}") return {"error": "Internal server error"}, 500 # ============ Payment Method Management Routes ============ @main_bp.route('/single-payments/add-payment-method') @login_required def add_payment_method(): """ Display the payment method addition form. """ log_activity( user_id=current_user.id, action="view_add_payment_method", entity_type="payment_method", details="Accessed add payment method page", ip_address=request.remote_addr ) return render_template('main/add_payment_method.html') @main_bp.route('/api/create-setup-intent', methods=['POST']) @login_required def create_setup_intent(): """ Create a Stripe Setup Intent for collecting payment method details. """ try: data = request.get_json() stripe_customer_id = data.get('stripe_customer_id') #stripe_customer_id = 'cus_SoQqMGLmCjiBDZ' payment_method_types = data.get('payment_method_types', ['card', 'au_becs_debit']) if not stripe_customer_id: return jsonify({ 'success': False, 'error': 'stripe_customer_id is required' }), 400 # Initialize Stripe processor config = Config() processor = StripePaymentProcessor(api_key=config.STRIPE_SECRET_KEY) # Create setup intent result = processor.create_setup_intent( customer_id=stripe_customer_id, payment_method_types=payment_method_types ) print(f"main result: {result}") if result['success']: # Add the publishable key for frontend result['stripe_publishable_key'] = config.STRIPE_PUBLISHABLE_KEY log_activity( user_id=current_user.id, action="create_setup_intent", entity_type="setup_intent", entity_id=0, details=f"Created setup intent for customer {stripe_customer_id} using Setup Intent: {result['setup_intent_id']}", ip_address=request.remote_addr ) else: log_activity( user_id=current_user.id, action="create_setup_intent_failed", entity_type="setup_intent", details=f"Failed to create setup intent for customer {stripe_customer_id}: {result.get('error')}", ip_address=request.remote_addr ) return jsonify(result) except Exception as e: log_activity( user_id=current_user.id, action="create_setup_intent_error", entity_type="setup_intent", details=f"Setup intent creation error: {str(e)}", ip_address=request.remote_addr ) return jsonify({ 'success': False, 'error': f'Setup intent creation failed: {str(e)}' }), 500 @main_bp.route('/api/finalize-payment-method', methods=['POST']) @login_required def finalize_payment_method(): """ Finalize payment method setup after Stripe confirmation. """ #try: data = request.get_json() setup_intent_id = data.get('setup_intent_id') stripe_customer_id = data.get('stripe_customer_id') #stripe_customer_id = "cus_SoQqMGLmCjiBDZ" set_as_default = data.get('set_as_default', False) splynx_id = data.get('splynx_id') if not all([setup_intent_id, stripe_customer_id]): return jsonify({ 'success': False, 'error': 'setup_intent_id and stripe_customer_id are required' }), 400 # Initialize Stripe processor config = Config() processor = StripePaymentProcessor(api_key=config.STRIPE_SECRET_KEY) # Check setup intent status setup_result = processor.get_setup_intent_status(setup_intent_id) print(f"setup_result: {setup_result}") if not setup_result['success']: return jsonify({ 'success': False, 'error': f'Setup intent check failed: {setup_result.get("error")}' }), 400 if setup_result['status'] != 'succeeded': return jsonify({ 'success': False, 'error': f'Setup intent not succeeded. Status: {setup_result["status"]}' }), 400 payment_method = setup_result.get('payment_method') print(f"payment_method: {payment_method}") if not payment_method: return jsonify({ 'success': False, 'error': 'No payment method found in setup intent' }), 400 # Attach payment method to customer (if not already attached) attach_result = processor.attach_payment_method( payment_method['id'], stripe_customer_id ) print(f"attach_result: {attach_result}") if not attach_result['success']: return jsonify({ 'success': False, 'error': f'Failed to attach payment method: {attach_result.get("error")}' }), 500 # Set as default if requested if set_as_default: default_result = processor.set_default_payment_method( stripe_customer_id, payment_method['id'] ) if not default_result['success']: # Log warning but don't fail the request log_activity( user_id=current_user.id, action="set_default_payment_method_failed", entity_type="payment_method", entity_id=None, details=f"Failed to set as default: {default_result.get('error')}", ip_address=request.remote_addr ) # Log successful addition log_activity( user_id=current_user.id, action="add_payment_method", entity_type="payment_method", entity_id=payment_method['id'], details=f"Added {payment_method['type']} payment method for customer {stripe_customer_id} (Splynx ID: {splynx_id}). Set as default: {set_as_default}", ip_address=request.remote_addr ) return jsonify({ 'success': True, 'payment_method': payment_method, 'is_default': set_as_default, 'setup_intent_id': setup_intent_id, 'customer_id': stripe_customer_id }) #except Exception as e: # log_activity( # user_id=current_user.id, # action="finalize_payment_method_error", # entity_type="payment_method", # details=f"Payment method finalization error: {str(e)}", # ip_address=request.remote_addr # ) # return jsonify({ # 'success': False, # 'error': f'Payment method finalization failed: {str(e)}' # }), 500 @main_bp.route('/api/get-payment-methods', methods=['POST']) @login_required def get_payment_methods_api(): """ Get payment methods for a Stripe customer. """ try: data = request.get_json() stripe_customer_id = data.get('stripe_customer_id') if not stripe_customer_id: return jsonify({ 'success': False, 'error': 'stripe_customer_id is required' }), 400 # Initialize Stripe processor config = Config() processor = StripePaymentProcessor(api_key=config.STRIPE_SECRET_KEY) # Get payment methods payment_methods = processor.get_payment_methods(stripe_customer_id) log_activity( user_id=current_user.id, action="get_payment_methods", entity_type="payment_method", details=f"Retrieved {len(payment_methods)} payment methods for customer {stripe_customer_id}", ip_address=request.remote_addr ) return jsonify(payment_methods) except Exception as e: log_activity( user_id=current_user.id, action="get_payment_methods_error", entity_type="payment_method", details=f"Get payment methods error: {str(e)}", ip_address=request.remote_addr ) return jsonify({ 'success': False, 'error': f'Failed to get payment methods: {str(e)}' }), 500 @main_bp.route('/test') @login_required def test(): payment_data = { 'payment_id': 111, 'splynx_id': 31, 'amount': 11.11, 'error': 'payment_record.Error', 'payment_method': 'payment_method', 'customer_name': 'Alan', 'payment_type': 'single', 'stripe_customer_id': 'cus_31', 'payment_intent': 'pi_' } # Send notification and create ticket (only in live mode) #if Config.PROCESS_LIVE: # Send email notification #email_sent = notification_service.send_payment_failure_notification(payment_data) # Create Splynx ticket ticket_result = splynx.create_ticket( customer_id=31, subject=f"Single Payment Failure - Customer 31 - $11.11", message=f""" Single payment processing has failed for customer Alan (ID: 31). Payment Details: - Payment ID: 12345 (single payment) - Amount: $11.11 AUD - Payment Method: pm_ - Stripe Customer: cus_31 - Payment Intent: pi_ - Processed by: Me Error Information: Some error This ticket was automatically created by the Plutus Payment System. """, priority="medium" ) print(f"Ticket created: {ticket_result.get('success', False)}") return ticket_result