diff --git a/blueprints/main.py b/blueprints/main.py index 894b302..0bb9a6f 100644 --- a/blueprints/main.py +++ b/blueprints/main.py @@ -4,7 +4,7 @@ from sqlalchemy import func, case import json import pymysql from app import db -from models import PaymentBatch, Payments, SinglePayments, PaymentPlans +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 @@ -69,6 +69,28 @@ def classify_payment_error(error_text, json_data=None): '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']): @@ -339,6 +361,7 @@ def batch_list(): 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)\ @@ -1133,6 +1156,273 @@ def process_single_payment_refund(payment_id): print(f"Error processing single payment refund: {e}") return jsonify({'success': False, 'error': 'Internal server error'}), 500 +@main_bp.route('/logs') +@login_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/') +@login_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') +@login_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): @@ -1253,4 +1543,242 @@ def api_splynx_customer(id): 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 \ No newline at end of file + 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) + + 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') + 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 + diff --git a/config.py b/config.py index 2fbf4b7..c2ac91a 100644 --- a/config.py +++ b/config.py @@ -30,10 +30,24 @@ class Config: # False = Sandbox - Default PROCESS_LIVE = True + # Threading configuration MAX_PAYMENT_THREADS = 5 # Number of concurrent payment processing threads THREAD_TIMEOUT = 60 # Timeout in seconds for payment processing threads # Stripe API Keys STRIPE_LIVE_API_KEY = os.environ.get('STRIPE_LIVE_API_KEY') or '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' - STRIPE_TEST_API_KEY = os.environ.get('STRIPE_TEST_API_KEY') or 'sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx' \ No newline at end of file + STRIPE_TEST_API_KEY = os.environ.get('STRIPE_TEST_API_KEY') or 'sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx' + + # Stripe Publishable Keys (for frontend) + STRIPE_LIVE_PUBLISHABLE_KEY = os.environ.get('STRIPE_LIVE_PUBLISHABLE_KEY') or 'pk_live_51LVotrBSms8QKWWA8pnSoc7ZdQsJb8g1wksUwBhTJWB1Hrt3vldhfWljM6ZZ14GduEwIXnofEARtRxBPuCxlySyS00rRr0sUf7' + STRIPE_TEST_PUBLISHABLE_KEY = os.environ.get('STRIPE_TEST_PUBLISHABLE_KEY') or 'pk_test_51Rsi9gPfYyg6zE1SwH7Fr65S9FOyR13ZP14DG8CH6iKQpmI1wwWCB4k6KO3C1AaXgjmxzFVunVYubXdtLWpPQUvm00YBfDR0nd' + + # Select keys based on PROCESS_LIVE setting + @property + def STRIPE_SECRET_KEY(self): + return self.STRIPE_LIVE_API_KEY if self.PROCESS_LIVE else self.STRIPE_TEST_API_KEY + + @property + def STRIPE_PUBLISHABLE_KEY(self): + return self.STRIPE_LIVE_PUBLISHABLE_KEY if self.PROCESS_LIVE else self.STRIPE_TEST_PUBLISHABLE_KEY \ No newline at end of file diff --git a/query_mysql.py b/query_mysql.py index b4fdfd7..7994eee 100644 --- a/query_mysql.py +++ b/query_mysql.py @@ -554,8 +554,8 @@ def process_payintent_mode(processor): pi.PI_FollowUp = False pi.PI_Last_Check = datetime.now() pi.Success = True - if intent_result.get('charge_id').startswith('ch_'): - pi.Stripe_Charge_ID = intent_result.get('charge_id') + #if intent_result.get('charge_id').startswith('ch_'): + # pi.Stripe_Charge_ID = intent_result.get('charge_id') processPaymentResult(pay_id=pi.id, result=intent_result, key=key) succeeded_count += 1 elif intent_result['status'] == "failed": @@ -567,7 +567,13 @@ def process_payintent_mode(processor): # Still pending pi.PI_FollowUp_JSON = json.dumps(intent_result) pi.PI_Last_Check = datetime.now() - still_pending += 1 + if intent_result.get('failure_reason'): + processPaymentResult(pay_id=pi.id, result=intent_result, key=key) + pi.PI_FollowUp = False + pi.Error = json.dumps(intent_result) + failed_count += 1 + else: + still_pending += 1 db.session.commit() except Exception as e: diff --git a/static/css/custom.css b/static/css/custom.css index fc8d5cb..202a126 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -560,4 +560,91 @@ code { background-color: #f0f0f0; color: #555; border-color: #999; +} + +/* Payment Method Management Styles */ +.payment-method-card { + transition: all 0.3s ease; + cursor: pointer; +} + +.payment-method-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.payment-method-selected { + border-color: var(--plutus-blue) !important; + background-color: rgba(50, 115, 220, 0.1) !important; +} + +.stripe-elements-container { + border: 1px solid #dbdbdb; + border-radius: 4px; + padding: 12px; + background-color: white; + min-height: 48px; +} + +.payment-method-type-icon { + font-size: 2.5rem; + margin-bottom: 1rem; +} + +.setup-progress { + margin-bottom: 2rem; +} + +.setup-step { + display: flex; + align-items: center; + margin-bottom: 0.5rem; +} + +.setup-step-number { + width: 30px; + height: 30px; + border-radius: 50%; + background-color: #dbdbdb; + color: white; + display: flex; + align-items: center; + justify-content: center; + margin-right: 1rem; + font-weight: bold; +} + +.setup-step.is-active .setup-step-number { + background-color: var(--plutus-gold); +} + +.setup-step.is-completed .setup-step-number { + background-color: var(--plutus-success); +} + +.payment-method-summary { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-left: 4px solid var(--plutus-gold); +} + +/* Success animations */ +.success-checkmark { + animation: checkmark 0.6s ease-in-out; +} + +@keyframes checkmark { + 0% { transform: scale(0); opacity: 0; } + 50% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} + +/* Responsive improvements */ +@media (max-width: 768px) { + .payment-type-selection .column { + margin-bottom: 1rem; + } + + .setup-progress { + font-size: 0.9rem; + } } \ No newline at end of file diff --git a/stripe_payment_processor.py b/stripe_payment_processor.py index 0d98d3b..04f959e 100644 --- a/stripe_payment_processor.py +++ b/stripe_payment_processor.py @@ -545,50 +545,50 @@ class StripePaymentProcessor: Returns: list: List of payment methods with details """ - try: - self._log('info', f"Retrieving payment methods for customer: {customer_id}") - - # Get payment methods for the customer - payment_methods = stripe.PaymentMethod.list( - customer=customer_id, - limit=10 - ) - print(json.dumps(payment_methods,indent=2)) - methods_list = [] + #try: + self._log('info', f"Retrieving payment methods for customer: {customer_id}") + + # Get payment methods for the customer + payment_methods = stripe.PaymentMethod.list( + customer=customer_id, + limit=10 + ) + #print(json.dumps(payment_methods,indent=2)) + methods_list = [] + + for pm in payment_methods.data: + pm_info = { + 'id': pm.id, + 'type': pm.type, + 'created': pm.created + } - for pm in payment_methods.data: - pm_info = { - 'id': pm.id, - 'type': pm.type, - 'created': pm.created + if pm_info['type'] == "card": + pm_info['card'] = { + 'brand': pm.card.brand, + 'last4': pm.card.last4, + 'country': pm.card.country, + 'exp_month': pm.card.exp_month, + 'exp_year': pm.card.exp_year + } + elif pm_info['type'] == "au_becs_debit": + pm_info['au_becs_debit'] = { + 'bsb_number': pm.au_becs_debit.bsb_number, + 'last4': pm.au_becs_debit.last4 } - - if pm.card: - pm_info['card'] = { - 'brand': pm.card.brand, - 'last4': pm.card.last4, - 'country': pm.card.country, - 'exp_month': pm.card.exp_month, - 'exp_year': pm.card.exp_year - } - elif pm.au_becs_debit: - pm_info['au_becs_debit'] = { - 'bsb_number': pm.au_becs_debit.bsb_number, - 'last4': pm.au_becs_debit.last4 - } - - methods_list.append(pm_info) - - self._log('info', f"Found {len(methods_list)} payment methods") - print(methods_list) - return methods_list - except stripe.StripeError as e: - self._log('error', f"Stripe error retrieving payment methods: {str(e)}") - return [] - except Exception as e: - self._log('error', f"Unexpected error retrieving payment methods: {str(e)}") - return [] + methods_list.append(pm_info) + + self._log('info', f"Found {len(methods_list)} payment methods") + print(f"methods_list: {methods_list}") + return methods_list + + #except stripe.StripeError as e: + # self._log('error', f"Stripe error retrieving payment methods: {str(e)}") + # return [] + #except Exception as e: + # self._log('error', f"Unexpected error retrieving payment methods: {str(e)}") + # return [] def check_payment_intent(self, payment_intent_id: str) -> Dict[str, Any]: """ @@ -969,6 +969,343 @@ class StripePaymentProcessor: current_result['pi_status'] = final_status return final_result + def create_setup_intent(self, customer_id: str, payment_method_types: list = None) -> Dict[str, Any]: + """ + Create a Setup Intent to collect and save payment method details for future use. + + Args: + customer_id (str): Stripe customer ID + payment_method_types (list): List of payment method types (e.g., ['card', 'au_becs_debit']) + + Returns: + dict: Setup Intent creation result with client_secret for frontend + """ + #customer_id = "cus_SoQqMGLmCjiBDZ" + try: + if not customer_id or not isinstance(customer_id, str): + return { + 'success': False, + 'error': 'Invalid customer_id provided', + 'error_type': 'validation_error' + } + + # Default payment method types if none provided + if not payment_method_types: + payment_method_types = ['card', 'au_becs_debit'] + + self._log('info', f"Creating setup intent for customer: {customer_id}") + + # Verify customer exists + try: + customer = stripe.Customer.retrieve(customer_id) + except stripe.InvalidRequestError: + return { + 'success': False, + 'error': f'Customer {customer_id} not found', + 'error_type': 'customer_not_found' + } + + # Create Setup Intent + setup_intent = stripe.SetupIntent.create( + customer=customer_id, + payment_method_types=payment_method_types, + usage='off_session' # For future payments + ) + + response = { + 'success': True, + 'setup_intent_id': setup_intent.id, + 'client_secret': setup_intent.client_secret, + 'status': setup_intent.status, + 'customer_id': customer_id, + 'payment_method_types': payment_method_types, + 'timestamp': datetime.now().isoformat() + } + + self._log('info', f"✅ Setup intent created: {setup_intent.id}") + return response + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'customer_id': customer_id, + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'customer_id': customer_id, + 'timestamp': datetime.now().isoformat() + } + + def get_setup_intent_status(self, setup_intent_id: str) -> Dict[str, Any]: + """ + Check the status of a Setup Intent and retrieve payment method details if succeeded. + + Args: + setup_intent_id (str): Stripe Setup Intent ID + + Returns: + dict: Setup Intent status and payment method details + """ + try: + if not setup_intent_id or not setup_intent_id.startswith('seti_'): + return { + 'success': False, + 'error': 'Invalid setup_intent_id provided', + 'error_type': 'validation_error' + } + + self._log('info', f"Checking setup intent status: {setup_intent_id}") + + # Retrieve setup intent + setup_intent = stripe.SetupIntent.retrieve(setup_intent_id) + + response = { + 'success': True, + 'setup_intent_id': setup_intent.id, + 'status': setup_intent.status, + 'customer_id': setup_intent.customer, + 'timestamp': datetime.now().isoformat() + } + + # If succeeded, get payment method details + if setup_intent.status == 'succeeded' and setup_intent.payment_method: + payment_method = stripe.PaymentMethod.retrieve(setup_intent.payment_method) + + pm_details = { + 'id': payment_method.id, + 'type': payment_method.type, + 'created': payment_method.created + } + + if payment_method.card: + pm_details['card'] = { + 'brand': payment_method.card.brand, + 'last4': payment_method.card.last4, + 'country': payment_method.card.country, + 'exp_month': payment_method.card.exp_month, + 'exp_year': payment_method.card.exp_year + } + elif payment_method.au_becs_debit: + pm_details['au_becs_debit'] = { + 'bsb_number': payment_method.au_becs_debit.bsb_number, + 'last4': payment_method.au_becs_debit.last4 + } + + response['payment_method'] = pm_details + self._log('info', f"✅ Setup intent succeeded with payment method: {payment_method.id}") + + elif setup_intent.status in ['requires_payment_method', 'requires_confirmation']: + response['next_action'] = 'Setup still requires user action' + + elif setup_intent.status == 'processing': + response['next_action'] = 'Setup is processing' + + elif setup_intent.status in ['canceled', 'failed']: + response['success'] = False + response['error'] = f'Setup intent {setup_intent.status}' + if setup_intent.last_setup_error: + response['error_details'] = { + 'code': setup_intent.last_setup_error.code, + 'message': setup_intent.last_setup_error.message, + 'type': setup_intent.last_setup_error.type + } + + return response + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'setup_intent_id': setup_intent_id, + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'setup_intent_id': setup_intent_id, + 'timestamp': datetime.now().isoformat() + } + + def attach_payment_method(self, payment_method_id: str, customer_id: str) -> Dict[str, Any]: + """ + Attach a payment method to a customer (if not already attached). + + Args: + payment_method_id (str): Stripe Payment Method ID + customer_id (str): Stripe customer ID + + Returns: + dict: Attachment result + """ + try: + if not payment_method_id or not payment_method_id.startswith('pm_'): + return { + 'success': False, + 'error': 'Invalid payment_method_id provided', + 'error_type': 'validation_error' + } + + if not customer_id: + return { + 'success': False, + 'error': 'Invalid customer_id provided', + 'error_type': 'validation_error' + } + + self._log('info', f"Attaching payment method {payment_method_id} to customer {customer_id}") + + # Try to attach (may already be attached) + try: + payment_method = stripe.PaymentMethod.attach( + payment_method_id, + customer=customer_id + ) + self._log('info', f"✅ Payment method attached successfully") + except stripe.InvalidRequestError as e: + if 'already attached' in str(e).lower(): + # Already attached, just retrieve it + payment_method = stripe.PaymentMethod.retrieve(payment_method_id) + self._log('info', f"Payment method was already attached") + else: + raise e + + return { + 'success': True, + 'payment_method_id': payment_method.id, + 'customer_id': customer_id, + 'type': payment_method.type, + 'timestamp': datetime.now().isoformat() + } + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'payment_method_id': payment_method_id, + 'customer_id': customer_id, + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'payment_method_id': payment_method_id, + 'customer_id': customer_id, + 'timestamp': datetime.now().isoformat() + } + + def set_default_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]: + """ + Set a payment method as the default for a customer. + + Args: + customer_id (str): Stripe customer ID + payment_method_id (str): Stripe Payment Method ID + + Returns: + dict: Update result + """ + try: + if not customer_id or not payment_method_id: + return { + 'success': False, + 'error': 'Both customer_id and payment_method_id are required', + 'error_type': 'validation_error' + } + + self._log('info', f"Setting default payment method for customer {customer_id}") + + # Update customer's default payment method + customer = stripe.Customer.modify( + customer_id, + invoice_settings={ + 'default_payment_method': payment_method_id + } + ) + + return { + 'success': True, + 'customer_id': customer_id, + 'default_payment_method': payment_method_id, + 'timestamp': datetime.now().isoformat() + } + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'customer_id': customer_id, + 'payment_method_id': payment_method_id, + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'customer_id': customer_id, + 'payment_method_id': payment_method_id, + 'timestamp': datetime.now().isoformat() + } + + def detach_payment_method(self, payment_method_id: str) -> Dict[str, Any]: + """ + Detach a payment method from its customer. + + Args: + payment_method_id (str): Stripe Payment Method ID + + Returns: + dict: Detachment result + """ + try: + if not payment_method_id or not payment_method_id.startswith('pm_'): + return { + 'success': False, + 'error': 'Invalid payment_method_id provided', + 'error_type': 'validation_error' + } + + self._log('info', f"Detaching payment method: {payment_method_id}") + + payment_method = stripe.PaymentMethod.detach(payment_method_id) + + return { + 'success': True, + 'payment_method_id': payment_method.id, + 'detached': True, + 'timestamp': datetime.now().isoformat() + } + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'payment_method_id': payment_method_id, + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'payment_method_id': payment_method_id, + 'timestamp': datetime.now().isoformat() + } + def update_payment_fees(self, needs_fee_update) -> Dict[str, Any]: """ Update fees for a payment that was previously marked as needing a fee update. diff --git a/templates/base.html b/templates/base.html index 3b70b0d..51c42cf 100644 --- a/templates/base.html +++ b/templates/base.html @@ -64,6 +64,13 @@ New Payment + + + + + + Add Payment Method + + {% if current_user.Permissions == 'Admin' %} + + + + + System Logs + + {% endif %} {% endif %} diff --git a/templates/main/add_payment_method.html b/templates/main/add_payment_method.html new file mode 100644 index 0000000..a68b88a --- /dev/null +++ b/templates/main/add_payment_method.html @@ -0,0 +1,748 @@ +{% extends "base.html" %} + +{% block title %}Add Payment Method - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Add Payment Method

+

Add credit cards or BECS Direct Debit to customer accounts

+
+
+
+ + +
+ +
+

+ + Customer Lookup +

+ +
+ +
+ +
+

Enter the Splynx customer ID to fetch customer details

+
+ + + + + + + +
+
+ +
+
+
+ + + + + + + + + +
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/main/batch_list.html b/templates/main/batch_list.html index 2a3b040..70860ae 100644 --- a/templates/main/batch_list.html +++ b/templates/main/batch_list.html @@ -59,6 +59,9 @@ {% if batch.successful_count %} {{ batch.successful_count }} Success {% endif %} + {% if batch.pending_count %} + {{ batch.pending_count }} Pending + {% endif %} {% if batch.failed_count %} {{ batch.failed_count }} Failed {% endif %} diff --git a/templates/main/logs_list.html b/templates/main/logs_list.html new file mode 100644 index 0000000..b78387f --- /dev/null +++ b/templates/main/logs_list.html @@ -0,0 +1,510 @@ +{% extends "base.html" %} + +{% block title %}System Logs - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

System Logs

+

User activity and system audit trail

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

+ + Filters +

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

+ + Log Entries +

+ + {% if logs %} +
+ + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + + {% endfor %} + +
TimestampUserActionEntityDetailsIP AddressActions
+ {{ log.Added.strftime('%Y-%m-%d') }}
+ {{ log.Added.strftime('%H:%M:%S') }} +
+
+
+ {{ log.user_name or 'System' }} + {% if log.User_ID %} +
ID: {{ log.User_ID }} + {% endif %} +
+
+
+ {% if log.Action %} + {{ log.Action }} + {% else %} + - + {% endif %} + + {% if log.Entity_Type %} +
+ {{ log.Entity_Type }} + {% if log.Entity_ID %} +
ID: {{ log.Entity_ID }} + {% endif %} +
+ {% else %} + - + {% endif %} +
+ {% if log.Log_Entry %} +
+ {{ log.Log_Entry[:100] }}{% if log.Log_Entry|length > 100 %}...{% endif %} +
+ {% else %} + - + {% endif %} +
+ {% if log.IP_Address %} + {{ log.IP_Address }} + {% else %} + - + {% endif %} + +
+ +
+
+
+ + + {% if pagination %} + + {% endif %} + + {% else %} +
+

No log entries found.

+
+ {% endif %} +
+ + + + + +{% endblock %} \ No newline at end of file