Browse Source

Lots more features added

master
Alan Woodman 4 months ago
parent
commit
389bbe79f4
  1. 532
      blueprints/main.py
  2. 16
      config.py
  3. 12
      query_mysql.py
  4. 87
      static/css/custom.css
  5. 419
      stripe_payment_processor.py
  6. 15
      templates/base.html
  7. 748
      templates/main/add_payment_method.html
  8. 3
      templates/main/batch_list.html
  9. 510
      templates/main/logs_list.html

532
blueprints/main.py

@ -4,7 +4,7 @@ from sqlalchemy import func, case
import json import json
import pymysql import pymysql
from app import db 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 splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET
from stripe_payment_processor import StripePaymentProcessor from stripe_payment_processor import StripePaymentProcessor
from config import Config from config import Config
@ -69,6 +69,28 @@ def classify_payment_error(error_text, json_data=None):
'icon': 'fa-exclamation-triangle' '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 # Bank Contact Required
if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \ 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']): 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.Payment_Amount).label('total_amount'),
func.sum(Payments.Fee_Stripe).label('total_fees'), 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 == 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.Success == False, 1), else_=0)).label('failed_count'),
func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count') func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count')
).outerjoin(Payments, PaymentBatch.id == Payments.PaymentBatch_ID)\ ).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}") print(f"Error processing single payment refund: {e}")
return jsonify({'success': False, 'error': 'Internal server error'}), 500 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/<int:log_id>')
@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/<int:payment_id>', methods=['POST']) @main_bp.route('/payment/refund/<int:payment_id>', methods=['POST'])
@login_required @login_required
def process_payment_refund(payment_id): def process_payment_refund(payment_id):
@ -1253,4 +1543,242 @@ def api_splynx_customer(id):
except Exception as e: except Exception as e:
log_activity(current_user.id, "API_ERROR", "SplynxCustomer", id, log_activity(current_user.id, "API_ERROR", "SplynxCustomer", id,
details=f"Splynx customer API error: {str(e)}") details=f"Splynx customer API error: {str(e)}")
return {"error": "Internal server error"}, 500 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

16
config.py

@ -30,10 +30,24 @@ class Config:
# False = Sandbox - Default # False = Sandbox - Default
PROCESS_LIVE = True PROCESS_LIVE = True
# Threading configuration # Threading configuration
MAX_PAYMENT_THREADS = 5 # Number of concurrent payment processing threads MAX_PAYMENT_THREADS = 5 # Number of concurrent payment processing threads
THREAD_TIMEOUT = 60 # Timeout in seconds for payment processing threads THREAD_TIMEOUT = 60 # Timeout in seconds for payment processing threads
# Stripe API Keys # Stripe API Keys
STRIPE_LIVE_API_KEY = os.environ.get('STRIPE_LIVE_API_KEY') or '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' 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' 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

12
query_mysql.py

@ -554,8 +554,8 @@ def process_payintent_mode(processor):
pi.PI_FollowUp = False pi.PI_FollowUp = False
pi.PI_Last_Check = datetime.now() pi.PI_Last_Check = datetime.now()
pi.Success = True pi.Success = True
if intent_result.get('charge_id').startswith('ch_'): #if intent_result.get('charge_id').startswith('ch_'):
pi.Stripe_Charge_ID = intent_result.get('charge_id') # pi.Stripe_Charge_ID = intent_result.get('charge_id')
processPaymentResult(pay_id=pi.id, result=intent_result, key=key) processPaymentResult(pay_id=pi.id, result=intent_result, key=key)
succeeded_count += 1 succeeded_count += 1
elif intent_result['status'] == "failed": elif intent_result['status'] == "failed":
@ -567,7 +567,13 @@ def process_payintent_mode(processor):
# Still pending # Still pending
pi.PI_FollowUp_JSON = json.dumps(intent_result) pi.PI_FollowUp_JSON = json.dumps(intent_result)
pi.PI_Last_Check = datetime.now() 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() db.session.commit()
except Exception as e: except Exception as e:

87
static/css/custom.css

@ -560,4 +560,91 @@ code {
background-color: #f0f0f0; background-color: #f0f0f0;
color: #555; color: #555;
border-color: #999; 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;
}
} }

419
stripe_payment_processor.py

@ -545,50 +545,50 @@ class StripePaymentProcessor:
Returns: Returns:
list: List of payment methods with details list: List of payment methods with details
""" """
try: #try:
self._log('info', f"Retrieving payment methods for customer: {customer_id}") self._log('info', f"Retrieving payment methods for customer: {customer_id}")
# Get payment methods for the customer # Get payment methods for the customer
payment_methods = stripe.PaymentMethod.list( payment_methods = stripe.PaymentMethod.list(
customer=customer_id, customer=customer_id,
limit=10 limit=10
) )
print(json.dumps(payment_methods,indent=2)) #print(json.dumps(payment_methods,indent=2))
methods_list = [] 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: if pm_info['type'] == "card":
pm_info = { pm_info['card'] = {
'id': pm.id, 'brand': pm.card.brand,
'type': pm.type, 'last4': pm.card.last4,
'created': pm.created '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: methods_list.append(pm_info)
self._log('error', f"Stripe error retrieving payment methods: {str(e)}")
return [] self._log('info', f"Found {len(methods_list)} payment methods")
except Exception as e: print(f"methods_list: {methods_list}")
self._log('error', f"Unexpected error retrieving payment methods: {str(e)}") return methods_list
return []
#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]: def check_payment_intent(self, payment_intent_id: str) -> Dict[str, Any]:
""" """
@ -969,6 +969,343 @@ class StripePaymentProcessor:
current_result['pi_status'] = final_status current_result['pi_status'] = final_status
return final_result 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]: 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. Update fees for a payment that was previously marked as needing a fee update.

15
templates/base.html

@ -64,6 +64,13 @@
</span> </span>
<span>New Payment</span> <span>New Payment</span>
</a> </a>
<hr class="navbar-divider">
<a class="navbar-item" href="{{ url_for('main.add_payment_method') }}">
<span class="icon">
<i class="fas fa-credit-card"></i>
</span>
<span>Add Payment Method</span>
</a>
</div> </div>
</div> </div>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
@ -88,6 +95,14 @@
</a> </a>
</div> </div>
</div> </div>
{% if current_user.Permissions == 'Admin' %}
<a class="navbar-item" href="{{ url_for('main.logs_list') }}">
<span class="icon">
<i class="fas fa-file-alt"></i>
</span>
<span>System Logs</span>
</a>
{% endif %}
{% endif %} {% endif %}
</div> </div>

748
templates/main/add_payment_method.html

@ -0,0 +1,748 @@
{% extends "base.html" %}
{% block title %}Add Payment Method - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li><a href="{{ url_for('main.single_payments_list') }}">Single Payments</a></li>
<li class="is-active"><a href="#" aria-current="page">Add Payment Method</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Add Payment Method</h1>
<p class="subtitle">Add credit cards or BECS Direct Debit to customer accounts</p>
</div>
</div>
</div>
<!-- Add Payment Method Form -->
<div class="box">
<!-- Step 1: Enter Splynx ID -->
<div id="step1" class="payment-step">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-search"></i></span>
Customer Lookup
</h2>
<div class="field">
<label class="label" for="lookup_splynx_id">Splynx Customer ID</label>
<div class="control">
<input class="input" type="number" id="lookup_splynx_id" placeholder="Enter customer ID" required>
</div>
<p class="help">Enter the Splynx customer ID to fetch customer details</p>
</div>
<!-- Loading State -->
<div id="loading" class="has-text-centered py-5 is-hidden">
<div class="spinner"></div>
<p class="mt-3">Fetching customer details...</p>
</div>
<!-- Error State -->
<div id="customerError" class="notification is-danger is-hidden">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span id="errorMessage">Customer not found or error occurred</span>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" id="nextBtn" onclick="fetchCustomerDetails()">
<span class="icon"><i class="fas fa-arrow-right"></i></span>
<span>Next</span>
</button>
</div>
</div>
</div>
<!-- Step 2: Confirm Customer & Select Payment Method Type -->
<div id="step2" class="payment-step is-hidden">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-user-check"></i></span>
Confirm Customer & Select Payment Method Type
</h2>
<div class="box has-background-light mb-5">
<h3 class="subtitle is-5">Customer Information</h3>
<div id="customerDetails">
<!-- Customer details will be populated here -->
</div>
</div>
<!-- Current Payment Methods -->
<div class="box has-background-info-light mb-5">
<h3 class="subtitle is-5">
<span class="icon"><i class="fas fa-credit-card"></i></span>
Current Payment Methods
</h3>
<div id="currentPaymentMethods">
<div class="has-text-centered py-4">
<div class="spinner"></div>
<p class="mt-3">Loading current payment methods...</p>
</div>
</div>
</div>
<div class="field">
<label class="label">Payment Method Type</label>
<div class="control">
<div class="columns">
<div class="column">
<div class="box payment-type-card" onclick="selectPaymentType('card')" id="cardOption">
<div class="has-text-centered">
<span class="icon is-large has-text-info">
<i class="fas fa-credit-card fa-2x"></i>
</span>
<h4 class="title is-5 mt-3">Credit/Debit Card</h4>
<p class="content">Add a new credit or debit card</p>
<ul class="content is-small">
<li>Domestic cards: 1.7% + $0.30</li>
<li>International cards: 3.5% + $0.30</li>
</ul>
</div>
</div>
</div>
<div class="column">
<div class="box payment-type-card" onclick="selectPaymentType('au_becs_debit')" id="becsOption">
<div class="has-text-centered">
<span class="icon is-large has-text-success">
<i class="fas fa-university fa-2x"></i>
</span>
<h4 class="title is-5 mt-3">BECS Direct Debit</h4>
<p class="content">Add Australian bank account</p>
<ul class="content is-small">
<li>1.0% + $0.30 (capped at $3.50)</li>
<li>Lower fees than cards</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-light" onclick="goBackToStep1()">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back</span>
</button>
</div>
<div class="control">
<button class="button is-primary" id="continueBtn" onclick="setupPaymentMethod()" disabled>
<span class="icon"><i class="fas fa-arrow-right"></i></span>
<span>Continue</span>
</button>
</div>
</div>
</div>
<!-- Step 3: Payment Method Collection -->
<div id="step3" class="payment-step is-hidden">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
Add Payment Method
</h2>
<div class="box has-background-light mb-5">
<h3 class="subtitle is-5">Selected Type</h3>
<div id="selectedTypeDisplay">
<!-- Selected payment method type will be shown here -->
</div>
</div>
<!-- Stripe Elements Container -->
<div class="field">
<label class="label" id="paymentElementLabel">Payment Details</label>
<div class="control">
<div id="payment-element" class="stripe-element">
<!-- Stripe Elements will be mounted here -->
</div>
</div>
<div id="payment-element-errors" role="alert" class="help is-danger is-hidden"></div>
</div>
<!-- BECS Mandate Agreement (only shown for BECS) -->
<div id="becsMandate" class="notification is-info is-hidden">
<div class="content">
<h4>Direct Debit Request Service Agreement</h4>
<p>By providing your bank account details and confirming this payment, you acknowledge that:</p>
<ul>
<li>You have read and agree to the Direct Debit Request Service Agreement</li>
<li>You authorize debits to your account according to the arrangement outlined</li>
<li>This authorization will remain in effect until cancelled by you</li>
</ul>
<label class="checkbox">
<input type="checkbox" id="becsAgreement">
I agree to the Direct Debit Request Service Agreement
</label>
</div>
</div>
<!-- Set as Default Option -->
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" id="setAsDefault" checked>
Set as default payment method for this customer
</label>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-light" onclick="goBackToStep2()">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back</span>
</button>
</div>
<div class="control">
<button class="button is-primary" id="savePaymentMethodBtn" onclick="savePaymentMethod()">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save Payment Method</span>
</button>
</div>
</div>
<!-- Processing State -->
<div id="processingPayment" class="notification is-info is-hidden">
<div class="has-text-centered py-4">
<div class="spinner"></div>
<p class="mt-3">Processing payment method...</p>
</div>
</div>
</div>
<!-- Step 4: Success -->
<div id="step4" class="payment-step is-hidden">
<h2 class="title is-4 has-text-success">
<span class="icon"><i class="fas fa-check-circle"></i></span>
Payment Method Added Successfully
</h2>
<div class="box has-background-success-light">
<div id="successDetails">
<!-- Success details will be populated here -->
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-success" onclick="addAnotherPaymentMethod()">
<span class="icon"><i class="fas fa-plus"></i></span>
<span>Add Another Payment Method</span>
</button>
</div>
<div class="control">
<a class="button is-info" href="{{ url_for('main.single_payments_list') }}">
<span class="icon"><i class="fas fa-list"></i></span>
<span>View Payments</span>
</a>
</div>
</div>
</div>
</div>
<!-- Include Stripe.js -->
<script src="https://js.stripe.com/v3/"></script>
<script>
// Global variables
let currentCustomer = null;
let selectedPaymentType = null;
let stripe = null;
let elements = null;
let paymentElement = null;
let setupIntentClientSecret = null;
// Initialize Stripe (we'll get the publishable key from the backend)
document.addEventListener('DOMContentLoaded', function() {
// We'll initialize Stripe when we need it in step 3
});
function fetchCustomerDetails() {
const splynxId = document.getElementById('lookup_splynx_id').value;
if (!splynxId) {
showError('Please enter a Splynx ID');
return;
}
showLoading(true);
hideError();
// Use existing API endpoint to get Stripe customer ID
fetch(`/api/stripe-customer-id/${splynxId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
// Get Splynx customer details
return fetch(`/api/splynx/${splynxId}`)
.then(response => response.json())
.then(splynxData => {
// Combine the data
const combinedData = {
success: true,
splynx_id: splynxId,
stripe_customer_id: data.stripe_customer_id,
customer_name: `${splynxData.name || ''} ${splynxData.lastname || ''}`.trim(),
customer_email: splynxData.email || '',
splynx_data: splynxData
};
currentCustomer = combinedData;
displayCustomerDetails(combinedData);
loadCurrentPaymentMethods(data.stripe_customer_id);
showStep(2);
showLoading(false);
});
} else {
showLoading(false);
showError(data.error || 'Customer not found or no Stripe customer ID available');
}
})
.catch(error => {
showLoading(false);
showError('Error fetching customer details: ' + error.message);
});
}
function displayCustomerDetails(customer) {
const html = `
<div class="columns">
<div class="column">
<strong>Name:</strong> ${customer.customer_name || 'N/A'}<br>
<strong>Email:</strong> ${customer.customer_email || 'N/A'}<br>
<strong>Splynx ID:</strong> ${customer.splynx_id}
</div>
<div class="column">
<strong>Stripe Customer ID:</strong> ${customer.stripe_customer_id}<br>
<strong>Status:</strong> <span class="tag is-success">Active</span>
</div>
</div>
`;
document.getElementById('customerDetails').innerHTML = html;
}
function loadCurrentPaymentMethods(stripeCustomerId) {
fetch(`/api/stripe-payment-methods/${stripeCustomerId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayCurrentPaymentMethods(data.payment_methods);
} else {
document.getElementById('currentPaymentMethods').innerHTML =
'<p class="has-text-danger">Error loading payment methods: ' + (data.error || 'Unknown error') + '</p>';
}
})
.catch(error => {
document.getElementById('currentPaymentMethods').innerHTML =
'<p class="has-text-danger">Error loading payment methods: ' + error.message + '</p>';
});
}
function displayCurrentPaymentMethods(methods) {
const container = document.getElementById('currentPaymentMethods');
if (!methods || methods.length === 0) {
container.innerHTML = '<p class="has-text-grey">No payment methods found for this customer.</p>';
return;
}
let html = '<div class="columns is-multiline">';
methods.forEach(method => {
let methodInfo = '';
let icon = '';
if (method.type === 'card' && method.card) {
icon = 'fas fa-credit-card';
methodInfo = `${method.card.brand.toUpperCase()} ending in ${method.card.last4}`;
} else if (method.type === 'au_becs_debit' && method.au_becs_debit) {
icon = 'fas fa-university';
methodInfo = `Bank account ${method.au_becs_debit.bsb_number} ending in ${method.au_becs_debit.last4}`;
} else {
icon = 'fas fa-question-circle';
methodInfo = `${method.type} payment method`;
}
html += `
<div class="column is-half">
<div class="box is-small">
<div class="media">
<div class="media-left">
<span class="icon has-text-info">
<i class="${icon}"></i>
</span>
</div>
<div class="media-content">
<p class="is-size-6">${methodInfo}</p>
<p class="is-size-7 has-text-grey">Added: ${new Date(method.created * 1000).toLocaleDateString()}</p>
</div>
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function selectPaymentType(type) {
selectedPaymentType = type;
// Update UI
document.querySelectorAll('.payment-type-card').forEach(card => {
card.classList.remove('has-background-primary-light', 'has-border-primary');
});
const selectedCard = document.getElementById(type === 'card' ? 'cardOption' : 'becsOption');
selectedCard.classList.add('has-background-primary-light', 'has-border-primary');
document.getElementById('continueBtn').disabled = false;
}
function setupPaymentMethod() {
if (!selectedPaymentType) {
showError('Please select a payment method type');
return;
}
// Update selected type display
const typeDisplay = selectedPaymentType === 'card' ?
'<span class="icon"><i class="fas fa-credit-card"></i></span> Credit/Debit Card' :
'<span class="icon"><i class="fas fa-university"></i></span> BECS Direct Debit';
document.getElementById('selectedTypeDisplay').innerHTML = typeDisplay;
// Show/hide BECS mandate
if (selectedPaymentType === 'au_becs_debit') {
document.getElementById('becsMandate').classList.remove('is-hidden');
document.getElementById('paymentElementLabel').textContent = 'Bank Account Details';
} else {
document.getElementById('becsMandate').classList.add('is-hidden');
document.getElementById('paymentElementLabel').textContent = 'Card Details';
}
showStep(3);
initializeStripeElements();
}
function initializeStripeElements() {
console.log('Initializing Stripe elements for payment type:', selectedPaymentType);
console.log('Customer:', currentCustomer);
// Get Stripe publishable key and create setup intent
fetch('/api/create-setup-intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
stripe_customer_id: currentCustomer.stripe_customer_id,
payment_method_types: [selectedPaymentType]
})
})
.then(response => {
console.log('Setup intent response status:', response.status);
return response.json();
})
.then(data => {
console.log('Setup intent response data:', data);
if (data.success) {
setupIntentClientSecret = data.client_secret;
console.log('Client secret received:', data.client_secret.substring(0, 20) + '...');
initializeStripe(data.stripe_publishable_key, data.client_secret);
} else {
console.error('Setup intent failed:', data.error);
showError('Failed to initialize payment method setup: ' + data.error);
}
})
.catch(error => {
console.error('Setup intent error:', error);
showError('Error initializing setup: ' + error.message);
});
}
function initializeStripe(publishableKey, clientSecret) {
console.log('Initializing Stripe with publishable key:', publishableKey.substring(0, 12) + '...');
console.log('Selected payment type:', selectedPaymentType);
stripe = Stripe(publishableKey);
// Create elements with specific appearance and payment method options
const appearance = {
theme: 'stripe',
variables: {
colorPrimary: '#3273dc',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Ideal Sans, system-ui, sans-serif',
spacingUnit: '2px',
borderRadius: '4px'
}
};
elements = stripe.elements({
clientSecret: clientSecret,
appearance: appearance
});
// Configure payment element with specific payment method types
const paymentElementOptions = {
layout: 'tabs',
paymentMethodOrder: selectedPaymentType === 'card'
? ['card']
: ['au_becs_debit'],
fields: {
billingDetails: {
name: 'auto',
email: 'auto'
}
}
};
console.log('Creating payment element with options:', paymentElementOptions);
paymentElement = elements.create('payment', paymentElementOptions);
console.log('Mounting payment element to #payment-element');
const mountElement = document.getElementById('payment-element');
if (mountElement) {
console.log('Mount element found:', mountElement);
try {
paymentElement.mount('#payment-element');
console.log('Payment element mounted successfully');
} catch (error) {
console.error('Error mounting payment element:', error);
showError('Error mounting payment form: ' + error.message);
}
} else {
console.error('Mount element #payment-element not found');
showError('Payment form container not found');
}
paymentElement.on('change', function(event) {
console.log('Payment element change event:', event);
const errorElement = document.getElementById('payment-element-errors');
if (event.error) {
errorElement.textContent = event.error.message;
errorElement.classList.remove('is-hidden');
} else {
errorElement.textContent = '';
errorElement.classList.add('is-hidden');
}
});
paymentElement.on('ready', function() {
console.log('Payment element is ready and visible');
});
paymentElement.on('loaderror', function(event) {
console.error('Payment element load error:', event);
showError('Error loading payment form: ' + event.error.message);
});
}
function savePaymentMethod() {
// Validate BECS agreement if needed
if (selectedPaymentType === 'au_becs_debit') {
const becsAgreement = document.getElementById('becsAgreement');
if (!becsAgreement.checked) {
showError('Please agree to the Direct Debit Request Service Agreement');
return;
}
}
const saveBtn = document.getElementById('savePaymentMethodBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Processing...</span>';
document.getElementById('processingPayment').classList.remove('is-hidden');
// Confirm the setup intent
stripe.confirmSetup({
elements,
confirmParams: {
return_url: window.location.origin + '/single-payments/add-payment-method/success',
},
redirect: 'if_required'
}).then(function(result) {
if (result.error) {
// Handle error
showError('Payment method setup failed: ' + result.error.message);
saveBtn.disabled = false;
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>';
document.getElementById('processingPayment').classList.add('is-hidden');
} else {
// Setup succeeded
handleSetupSuccess(result.setupIntent);
}
});
}
function handleSetupSuccess(setupIntent) {
const setAsDefault = document.getElementById('setAsDefault').checked;
// Attach payment method and optionally set as default
fetch('/api/finalize-payment-method', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
setup_intent_id: setupIntent.id,
stripe_customer_id: currentCustomer.stripe_customer_id,
set_as_default: setAsDefault,
splynx_id: currentCustomer.splynx_id
})
})
.then(response => response.json())
.then(data => {
document.getElementById('processingPayment').classList.add('is-hidden');
if (data.success) {
displaySuccess(data);
showStep(4);
} else {
showError('Failed to finalize payment method: ' + data.error);
const saveBtn = document.getElementById('savePaymentMethodBtn');
saveBtn.disabled = false;
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>';
}
})
.catch(error => {
document.getElementById('processingPayment').classList.add('is-hidden');
showError('Error finalizing setup: ' + error.message);
const saveBtn = document.getElementById('savePaymentMethodBtn');
saveBtn.disabled = false;
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>';
});
}
function displaySuccess(data) {
const paymentMethod = data.payment_method;
let methodInfo = '';
if (paymentMethod.type === 'card' && paymentMethod.card) {
methodInfo = `${paymentMethod.card.brand.toUpperCase()} ending in ${paymentMethod.card.last4}`;
} else if (paymentMethod.type === 'au_becs_debit' && paymentMethod.au_becs_debit) {
methodInfo = `Bank account ${paymentMethod.au_becs_debit.bsb_number} ending in ${paymentMethod.au_becs_debit.last4}`;
}
const html = `
<div class="content">
<h4>Payment Method Added</h4>
<p><strong>Type:</strong> ${methodInfo}</p>
<p><strong>Customer:</strong> ${currentCustomer.customer_name} (${currentCustomer.customer_email})</p>
<p><strong>Set as Default:</strong> ${data.is_default ? 'Yes' : 'No'}</p>
<p><strong>Payment Method ID:</strong> <code>${paymentMethod.id}</code></p>
</div>
`;
document.getElementById('successDetails').innerHTML = html;
}
// Utility functions
function showStep(step) {
document.querySelectorAll('.payment-step').forEach(div => div.classList.add('is-hidden'));
document.getElementById('step' + step).classList.remove('is-hidden');
}
function goBackToStep1() {
showStep(1);
selectedPaymentType = null;
}
function goBackToStep2() {
showStep(2);
}
function addAnotherPaymentMethod() {
// Reset form
document.getElementById('lookup_splynx_id').value = '';
currentCustomer = null;
selectedPaymentType = null;
setupIntentClientSecret = null;
showStep(1);
}
function showLoading(show) {
const loading = document.getElementById('loading');
const nextBtn = document.getElementById('nextBtn');
if (show) {
loading.classList.remove('is-hidden');
nextBtn.disabled = true;
} else {
loading.classList.add('is-hidden');
nextBtn.disabled = false;
}
}
function showError(message) {
document.getElementById('errorMessage').textContent = message;
document.getElementById('customerError').classList.remove('is-hidden');
}
function hideError() {
document.getElementById('customerError').classList.add('is-hidden');
}
// Allow Enter key to trigger next button in step 1
document.getElementById('lookup_splynx_id').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
fetchCustomerDetails();
}
});
</script>
<style>
.payment-type-card {
cursor: pointer;
border: 2px solid transparent;
transition: all 0.3s ease;
}
.payment-type-card:hover {
border-color: #dbdbdb;
}
.has-border-primary {
border-color: #3273dc !important;
}
#payment-element {
border: 1px solid #dbdbdb;
border-radius: 4px;
padding: 12px;
background-color: white;
min-height: 60px;
}
.stripe-element {
border: 1px solid #dbdbdb;
border-radius: 4px;
padding: 12px;
background-color: white;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3273dc;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}

3
templates/main/batch_list.html

@ -59,6 +59,9 @@
{% if batch.successful_count %} {% if batch.successful_count %}
<span class="tag is-success is-small">{{ batch.successful_count }} Success</span> <span class="tag is-success is-small">{{ batch.successful_count }} Success</span>
{% endif %} {% endif %}
{% if batch.pending_count %}
<span class="tag is-warning is-small">{{ batch.pending_count }} Pending</span>
{% endif %}
{% if batch.failed_count %} {% if batch.failed_count %}
<span class="tag is-danger is-small">{{ batch.failed_count }} Failed</span> <span class="tag is-danger is-small">{{ batch.failed_count }} Failed</span>
{% endif %} {% endif %}

510
templates/main/logs_list.html

@ -0,0 +1,510 @@
{% extends "base.html" %}
{% block title %}System Logs - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li class="is-active"><a href="#" aria-current="page">System Logs</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">System Logs</h1>
<p class="subtitle">User activity and system audit trail</p>
</div>
</div>
<div class="level-right">
<div class="field is-grouped">
<div class="control">
<button class="button is-info" onclick="exportLogs()">
<span class="icon"><i class="fas fa-download"></i></span>
<span>Export Logs</span>
</button>
</div>
</div>
</div>
</div>
<!-- Filter Controls -->
<div class="box">
<h2 class="title is-5">
<span class="icon"><i class="fas fa-filter"></i></span>
Filters
</h2>
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<label class="label is-small">Search:</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input class="input" type="text" id="searchInput" placeholder="Search logs, actions, details...">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</div>
</div>
</div>
<div class="control">
<label class="label is-small">User:</label>
<div class="select">
<select id="userFilter">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.FullName }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Action:</label>
<div class="select">
<select id="actionFilter">
<option value="">All Actions</option>
{% for action in actions %}
<option value="{{ action }}">{{ action }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Entity Type:</label>
<div class="select">
<select id="entityTypeFilter">
<option value="">All Types</option>
{% for entity_type in entity_types %}
<option value="{{ entity_type }}">{{ entity_type }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Date From:</label>
<input class="input" type="date" id="dateFromFilter">
</div>
<div class="control">
<label class="label is-small">Date To:</label>
<input class="input" type="date" id="dateToFilter">
</div>
<div class="control">
<button class="button is-small is-info" onclick="applyFilters()">
<span class="icon"><i class="fas fa-search"></i></span>
<span>Apply Filters</span>
</button>
</div>
<div class="control">
<button class="button is-small is-light" onclick="clearFilters()">
<span class="icon"><i class="fas fa-times"></i></span>
<span>Clear</span>
</button>
</div>
</div>
</div>
<!-- Results Summary -->
<div class="notification is-info is-light" id="filterResults" style="display: none;">
<span id="resultCount">0</span> of {{ logs|length }} log entries shown
</div>
<!-- Logs Table -->
<div class="box">
<h2 class="title is-5">
<span class="icon"><i class="fas fa-list"></i></span>
Log Entries
</h2>
{% if logs %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" id="logsTable">
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Details</th>
<th>IP Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="logsTableBody">
{% for log in logs %}
<tr>
<td>
<span class="is-size-7">{{ log.Added.strftime('%Y-%m-%d') }}</span><br>
<span class="is-size-7 has-text-grey">{{ log.Added.strftime('%H:%M:%S') }}</span>
</td>
<td>
<div class="media">
<div class="media-content">
<strong>{{ log.user_name or 'System' }}</strong>
{% if log.User_ID %}
<br><small class="has-text-grey">ID: {{ log.User_ID }}</small>
{% endif %}
</div>
</div>
</td>
<td>
{% if log.Action %}
<span class="tag is-info is-light">{{ log.Action }}</span>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td>
{% if log.Entity_Type %}
<div>
<span class="tag is-primary is-light">{{ log.Entity_Type }}</span>
{% if log.Entity_ID %}
<br><small class="has-text-grey">ID: {{ log.Entity_ID }}</small>
{% endif %}
</div>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td>
{% if log.Log_Entry %}
<div class="content is-small">
{{ log.Log_Entry[:100] }}{% if log.Log_Entry|length > 100 %}...{% endif %}
</div>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td>
{% if log.IP_Address %}
<code class="is-small">{{ log.IP_Address }}</code>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td>
<div class="buttons are-small">
<button class="button is-info is-outlined" onclick="showLogDetail({{ log.id }})">
<span class="icon"><i class="fas fa-eye"></i></span>
<span>View</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination %}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if pagination.has_prev %}
<a class="pagination-previous" href="{{ url_for('main.logs_list', page=pagination.prev_num, **request.args) }}">Previous</a>
{% else %}
<a class="pagination-previous" disabled>Previous</a>
{% endif %}
{% if pagination.has_next %}
<a class="pagination-next" href="{{ url_for('main.logs_list', page=pagination.next_num, **request.args) }}">Next page</a>
{% else %}
<a class="pagination-next" disabled>Next page</a>
{% endif %}
<ul class="pagination-list">
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num != pagination.page %}
<li><a class="pagination-link" href="{{ url_for('main.logs_list', page=page_num, **request.args) }}">{{ page_num }}</a></li>
{% else %}
<li><a class="pagination-link is-current" href="#">{{ page_num }}</a></li>
{% endif %}
{% else %}
<li><span class="pagination-ellipsis">&hellip;</span></li>
{% endif %}
{% endfor %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="notification is-info">
<p>No log entries found.</p>
</div>
{% endif %}
</div>
<!-- Log Detail Modal -->
<div class="modal" id="logDetailModal">
<div class="modal-background" onclick="hideModal('logDetailModal')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
<span class="icon"><i class="fas fa-file-alt"></i></span>
Log Entry Details
</p>
<button class="delete" aria-label="close" onclick="hideModal('logDetailModal')"></button>
</header>
<section class="modal-card-body">
<div id="logDetailContent">
<!-- Log details will be populated here -->
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-info" onclick="copyLogDetails()">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy Details</span>
</button>
<button class="button" onclick="hideModal('logDetailModal')">Close</button>
</footer>
</div>
</div>
<script>
let allLogs = [];
let filteredLogs = [];
// Initialize logs and filters when page loads
document.addEventListener('DOMContentLoaded', function() {
initializeLogs();
setupEventListeners();
});
function initializeLogs() {
const tableBody = document.getElementById('logsTableBody');
const rows = tableBody.querySelectorAll('tr');
allLogs = Array.from(rows).map(row => {
const cells = row.querySelectorAll('td');
return {
element: row,
timestamp: cells[0] ? (cells[0].textContent.trim() || '') : '',
user: cells[1] ? (cells[1].textContent.trim() || '') : '',
action: cells[2] ? (cells[2].textContent.trim() || '') : '',
entityType: cells[3] ? (cells[3].textContent.trim() || '') : '',
details: cells[4] ? (cells[4].textContent.trim() || '') : '',
ipAddress: cells[5] ? (cells[5].textContent.trim() || '') : ''
};
});
filteredLogs = [...allLogs];
updateResultCount();
}
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('userFilter').addEventListener('change', applyFilters);
document.getElementById('actionFilter').addEventListener('change', applyFilters);
document.getElementById('entityTypeFilter').addEventListener('change', applyFilters);
document.getElementById('dateFromFilter').addEventListener('change', applyFilters);
document.getElementById('dateToFilter').addEventListener('change', applyFilters);
}
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const userFilter = document.getElementById('userFilter').value;
const actionFilter = document.getElementById('actionFilter').value;
const entityTypeFilter = document.getElementById('entityTypeFilter').value;
const dateFromFilter = document.getElementById('dateFromFilter').value;
const dateToFilter = document.getElementById('dateToFilter').value;
// Filter logs
filteredLogs = allLogs.filter(log => {
// Search filter
const searchMatch = !searchTerm ||
log.user.toLowerCase().includes(searchTerm) ||
log.action.toLowerCase().includes(searchTerm) ||
log.entityType.toLowerCase().includes(searchTerm) ||
log.details.toLowerCase().includes(searchTerm) ||
log.ipAddress.toLowerCase().includes(searchTerm);
// User filter
const userMatch = !userFilter || log.user.includes(`ID: ${userFilter}`);
// Action filter
const actionMatch = !actionFilter || log.action.includes(actionFilter);
// Entity type filter
const entityTypeMatch = !entityTypeFilter || log.entityType.includes(entityTypeFilter);
// Date filters would need server-side implementation for full functionality
// For now, we'll implement basic client-side date filtering on visible text
let dateMatch = true;
if (dateFromFilter || dateToFilter) {
const logDate = log.timestamp.split(' ')[0]; // Get just the date part
if (dateFromFilter && logDate < dateFromFilter) dateMatch = false;
if (dateToFilter && logDate > dateToFilter) dateMatch = false;
}
return searchMatch && userMatch && actionMatch && entityTypeMatch && dateMatch;
});
// Update display
updateTable();
updateResultCount();
}
function updateTable() {
const tableBody = document.getElementById('logsTableBody');
// Hide all rows first
allLogs.forEach(log => {
log.element.style.display = 'none';
});
// Show filtered rows
filteredLogs.forEach(log => {
log.element.style.display = '';
tableBody.appendChild(log.element); // Re-append to maintain order
});
}
function updateResultCount() {
const resultCount = document.getElementById('resultCount');
const filterResults = document.getElementById('filterResults');
resultCount.textContent = filteredLogs.length;
if (filteredLogs.length === allLogs.length) {
filterResults.style.display = 'none';
} else {
filterResults.style.display = 'block';
}
}
function clearFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('userFilter').value = '';
document.getElementById('actionFilter').value = '';
document.getElementById('entityTypeFilter').value = '';
document.getElementById('dateFromFilter').value = '';
document.getElementById('dateToFilter').value = '';
applyFilters();
}
function showLogDetail(logId) {
// Fetch log details via AJAX
fetch(`/logs/detail/${logId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const log = data.log;
const detailHtml = `
<div class="content">
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>ID</strong></td>
<td>#${log.id}</td>
</tr>
<tr>
<td><strong>Timestamp</strong></td>
<td>${log.timestamp}</td>
</tr>
<tr>
<td><strong>User</strong></td>
<td>${log.user_name || 'System'} ${log.User_ID ? `(ID: ${log.User_ID})` : ''}</td>
</tr>
<tr>
<td><strong>Action</strong></td>
<td>${log.Action || '-'}</td>
</tr>
<tr>
<td><strong>Entity Type</strong></td>
<td>${log.Entity_Type || '-'}</td>
</tr>
<tr>
<td><strong>Entity ID</strong></td>
<td>${log.Entity_ID || '-'}</td>
</tr>
<tr>
<td><strong>IP Address</strong></td>
<td>${log.IP_Address || '-'}</td>
</tr>
</tbody>
</table>
${log.Log_Entry ? `
<div class="field">
<label class="label">Full Details:</label>
<div class="box">
<pre class="has-text-dark">${log.Log_Entry}</pre>
</div>
</div>
` : ''}
</div>
`;
document.getElementById('logDetailContent').innerHTML = detailHtml;
document.getElementById('logDetailModal').classList.add('is-active');
} else {
alert('Failed to load log details: ' + data.error);
}
})
.catch(error => {
console.error('Error fetching log details:', error);
alert('Failed to load log details. Please try again.');
});
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('is-active');
}
function copyLogDetails() {
const content = document.getElementById('logDetailContent').innerText;
navigator.clipboard.writeText(content).then(function() {
// Show temporary success message
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy to clipboard');
});
}
function exportLogs() {
// Create form data with current filters
const params = new URLSearchParams();
const searchTerm = document.getElementById('searchInput').value;
const userFilter = document.getElementById('userFilter').value;
const actionFilter = document.getElementById('actionFilter').value;
const entityTypeFilter = document.getElementById('entityTypeFilter').value;
const dateFromFilter = document.getElementById('dateFromFilter').value;
const dateToFilter = document.getElementById('dateToFilter').value;
if (searchTerm) params.append('search', searchTerm);
if (userFilter) params.append('user', userFilter);
if (actionFilter) params.append('action', actionFilter);
if (entityTypeFilter) params.append('entity_type', entityTypeFilter);
if (dateFromFilter) params.append('date_from', dateFromFilter);
if (dateToFilter) params.append('date_to', dateToFilter);
// Open export URL in new window
window.open(`/logs/export?${params.toString()}`, '_blank');
}
// Close modal on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const activeModals = document.querySelectorAll('.modal.is-active');
activeModals.forEach(modal => modal.classList.remove('is-active'));
}
});
</script>
{% endblock %}
Loading…
Cancel
Save