You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2367 lines
90 KiB
2367 lines
90 KiB
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
|
|
from flask_login import login_required, current_user
|
|
from sqlalchemy import func, case
|
|
import json
|
|
import pymysql
|
|
from app import db
|
|
from models import PaymentBatch, Payments, SinglePayments, PaymentPlans, Logs, Users
|
|
from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET
|
|
from stripe_payment_processor import StripePaymentProcessor
|
|
from config import Config
|
|
from services import log_activity
|
|
from permissions import admin_required, finance_required, helpdesk_required
|
|
from notification_service import NotificationService
|
|
import re
|
|
import time
|
|
|
|
splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET)
|
|
|
|
def create_customer_friendly_message(payment_data: dict, error_details: str) -> str:
|
|
"""
|
|
Create a customer-friendly ticket message for failed payments.
|
|
|
|
Args:
|
|
payment_data: Dictionary containing payment information
|
|
error_details: Raw error details
|
|
|
|
Returns:
|
|
str: HTML formatted customer-friendly message
|
|
"""
|
|
try:
|
|
# Extract payment details
|
|
amount = abs(payment_data.get('amount', 0))
|
|
splynx_id = payment_data.get('splynx_id', 'Unknown')
|
|
|
|
# Parse PI_JSON for payment method details if available
|
|
pi_json = payment_data.get('pi_json')
|
|
payment_method_type = "unknown"
|
|
last4 = "****"
|
|
|
|
if pi_json:
|
|
try:
|
|
import json
|
|
parsed_json = json.loads(pi_json)
|
|
payment_method_type = parsed_json.get('payment_method_type', 'unknown')
|
|
|
|
# Get last 4 digits from various possible locations in JSON
|
|
if 'payment_method_details' in parsed_json:
|
|
pm_details = parsed_json['payment_method_details']
|
|
if payment_method_type == 'card' and 'card' in pm_details:
|
|
last4 = pm_details['card'].get('last4', '****')
|
|
elif payment_method_type == 'au_becs_debit' and 'au_becs_debit' in pm_details:
|
|
last4 = pm_details['au_becs_debit'].get('last4', '****')
|
|
elif 'last4' in parsed_json:
|
|
last4 = parsed_json.get('last4', '****')
|
|
except:
|
|
pass
|
|
|
|
# Format payment method for display
|
|
if payment_method_type == 'au_becs_debit':
|
|
payment_method_display = f"Bank Account ending in {last4}"
|
|
elif payment_method_type == 'card':
|
|
payment_method_display = f"Card ending in {last4}"
|
|
else:
|
|
payment_method_display = "Payment method"
|
|
|
|
# Get current datetime
|
|
from datetime import datetime
|
|
current_time = datetime.now().strftime("%d/%m/%Y at %I:%M %p")
|
|
|
|
# Get customer-friendly error explanation
|
|
error_classification = classify_payment_error(error_details, pi_json)
|
|
if error_classification:
|
|
error_message = error_classification['message']
|
|
else:
|
|
error_message = "An error occurred during payment processing"
|
|
|
|
# Create customer-friendly HTML message
|
|
customer_message = f"""
|
|
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
|
|
<html>
|
|
<body>
|
|
<div>Your payment attempt was unsuccessful.</div>
|
|
<div><br></div>
|
|
<div><strong>Payment Details:</strong></div>
|
|
<div>• Amount: ${amount:.2f} AUD</div>
|
|
<div>• Date/Time: {current_time}</div>
|
|
<div>• {payment_method_display}</div>
|
|
<div><br></div>
|
|
<div><strong>Issue:</strong> {error_message}</div>
|
|
<div><br></div>
|
|
<div>Please contact us if you need assistance with your payment.</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return customer_message.strip()
|
|
|
|
except Exception as e:
|
|
# Fallback message if there's any error creating the friendly message
|
|
return f"""
|
|
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
|
|
<html>
|
|
<body>
|
|
<div>Your payment attempt was unsuccessful. Please contact us for assistance.</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def classify_payment_error(error_text, json_data=None):
|
|
"""
|
|
Classify payment errors into user-friendly categories.
|
|
|
|
Args:
|
|
error_text (str): The error text from the Error field
|
|
json_data (str): Optional JSON data containing additional error details
|
|
|
|
Returns:
|
|
dict: Error classification with type, title, message, suggestion, and icon
|
|
"""
|
|
if not error_text:
|
|
return None
|
|
|
|
# Parse JSON data if provided
|
|
parsed_json = None
|
|
if json_data:
|
|
try:
|
|
parsed_json = json.loads(json_data)
|
|
except:
|
|
pass
|
|
|
|
# Extract decline code from JSON if available
|
|
decline_code = None
|
|
if parsed_json:
|
|
decline_code = parsed_json.get('decline_code')
|
|
if not decline_code and 'error' in parsed_json:
|
|
error_obj = parsed_json['error']
|
|
if isinstance(error_obj, dict):
|
|
decline_code = error_obj.get('decline_code')
|
|
|
|
# Convert to lowercase for easier matching
|
|
error_lower = error_text.lower()
|
|
|
|
# Insufficient Funds
|
|
if (decline_code in ['insufficient_funds', 'card_declined'] and 'insufficient' in error_lower) or \
|
|
'insufficient funds' in error_lower or 'insufficient_funds' in error_lower:
|
|
return {
|
|
'type': 'insufficient-funds',
|
|
'title': 'Insufficient Funds',
|
|
'message': 'Customer does not have sufficient funds in their account',
|
|
'suggestion': 'Customer should check their account balance or try a different payment method',
|
|
'icon': 'fa-credit-card'
|
|
}
|
|
|
|
# Incorrect Card Information
|
|
if decline_code in ['incorrect_number', 'incorrect_cvc', 'incorrect_zip', 'expired_card', 'invalid_expiry_month', 'invalid_expiry_year'] or \
|
|
any(phrase in error_lower for phrase in ['incorrect', 'invalid', 'expired', 'wrong', 'bad']):
|
|
return {
|
|
'type': 'incorrect-card',
|
|
'title': 'Incorrect Card Information',
|
|
'message': 'Card information is incorrect, invalid, or expired',
|
|
'suggestion': 'Customer should verify their card details or use a different card',
|
|
'icon': 'fa-exclamation-triangle'
|
|
}
|
|
|
|
# Bank Account Closed
|
|
if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \
|
|
any(phrase in error_lower for phrase in ['closed']):
|
|
return {
|
|
'type': 'bank-contact',
|
|
'title': 'Bank Account Closed',
|
|
'message': 'The customer bank account has been closed.',
|
|
'suggestion': 'Customer should call the phone number on the back of their card',
|
|
'icon': 'fa-phone'
|
|
}
|
|
|
|
# Bank Account Not Found
|
|
if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \
|
|
any(phrase in error_lower for phrase in ['located']):
|
|
return {
|
|
'type': 'bank-contact',
|
|
'title': 'Account Not Located',
|
|
'message': 'The customer bank account could not be located.',
|
|
'suggestion': 'Customer should call the phone number on the back of their card',
|
|
'icon': 'fa-phone'
|
|
}
|
|
|
|
# Bank Contact Required
|
|
if decline_code in ['call_issuer', 'pickup_card', 'restricted_card', 'security_violation'] or \
|
|
any(phrase in error_lower for phrase in ['call', 'contact', 'bank', 'issuer', 'restricted', 'blocked']):
|
|
return {
|
|
'type': 'bank-contact',
|
|
'title': 'Bank Contact Required',
|
|
'message': 'Customer needs to contact their bank or card issuer',
|
|
'suggestion': 'Customer should call the phone number on the back of their card',
|
|
'icon': 'fa-phone'
|
|
}
|
|
|
|
# Processing Errors
|
|
if decline_code in ['processing_error', 'try_again_later'] or \
|
|
'error_type' in error_lower and any(phrase in error_lower for phrase in ['processing', 'temporary', 'try again', 'timeout']):
|
|
return {
|
|
'type': 'processing-error',
|
|
'title': 'Processing Error',
|
|
'message': 'Temporary payment processing issue occurred',
|
|
'suggestion': 'Please try the payment again in a few minutes',
|
|
'icon': 'fa-sync-alt'
|
|
}
|
|
|
|
# Network Errors
|
|
if 'network' in error_lower or 'connection' in error_lower or 'timeout' in error_lower:
|
|
return {
|
|
'type': 'network-error',
|
|
'title': 'Network Error',
|
|
'message': 'Network connection issue during payment processing',
|
|
'suggestion': 'Please check your connection and try again',
|
|
'icon': 'fa-wifi'
|
|
}
|
|
|
|
# General Decline (catch-all for other card declines)
|
|
if decline_code in ['generic_decline', 'do_not_honor', 'card_not_supported', 'currency_not_supported'] or \
|
|
any(phrase in error_lower for phrase in ['declined', 'decline', 'not supported', 'do not honor']):
|
|
return {
|
|
'type': 'general-decline',
|
|
'title': 'Payment Declined',
|
|
'message': 'Payment was declined by the card issuer',
|
|
'suggestion': 'Customer should try a different payment method or contact their bank',
|
|
'icon': 'fa-ban'
|
|
}
|
|
|
|
# Default for unclassified errors
|
|
return {
|
|
'type': 'general-decline',
|
|
'title': 'Payment Error',
|
|
'message': 'An error occurred during payment processing',
|
|
'suggestion': 'Please try again or contact support if the issue persists',
|
|
'icon': 'fa-exclamation-circle'
|
|
}
|
|
|
|
def get_error_alert_data(payment):
|
|
"""
|
|
Get error alert data for template rendering.
|
|
|
|
Args:
|
|
payment: Payment object with Error and PI_JSON fields
|
|
|
|
Returns:
|
|
dict: Error alert data or None if no error
|
|
"""
|
|
if not payment.Error:
|
|
return None
|
|
|
|
# Use PI_JSON if available, otherwise try PI_FollowUp_JSON
|
|
json_data = payment.PI_JSON or payment.PI_FollowUp_JSON
|
|
|
|
error_classification = classify_payment_error(payment.Error, json_data)
|
|
|
|
if error_classification:
|
|
error_classification['raw_error'] = payment.Error
|
|
|
|
return error_classification
|
|
|
|
def processPaymentResult(pay_id, result, key):
|
|
"""Process payment result and update database record."""
|
|
from datetime import datetime
|
|
|
|
if key == "pay":
|
|
payment = db.session.query(Payments).filter(Payments.id == pay_id).first()
|
|
elif key == "singlepay":
|
|
payment = db.session.query(SinglePayments).filter(SinglePayments.id == pay_id).first()
|
|
|
|
try:
|
|
if result.get('error') and not result.get('needs_fee_update'):
|
|
payment.Error = f"Error Type: {result['error_type']}\nError: {result['error']}"
|
|
payment.Success = result['success']
|
|
payment.PI_JSON = json.dumps(result)
|
|
else:
|
|
if result.get('needs_fee_update'):
|
|
payment.PI_FollowUp = True
|
|
payment.Payment_Intent = result['payment_intent_id']
|
|
payment.Success = result['success']
|
|
if result['success'] and Config.PROCESS_LIVE and key == "singlepay":
|
|
# Only update Splynx for successful single payments in live mode
|
|
find_pay_splynx_invoices(payment.Splynx_ID)
|
|
add_payment_splynx(
|
|
splynx_id=payment.Splynx_ID,
|
|
pi_id=result['payment_intent_id'],
|
|
pay_id=payment.id,
|
|
amount=payment.Payment_Amount
|
|
)
|
|
if result.get('payment_method_type') == "card":
|
|
payment.Payment_Method = result['estimated_fee_details']['card_display_brand']
|
|
elif result.get('payment_method_type') == "au_becs_debit":
|
|
payment.Payment_Method = result['payment_method_type']
|
|
if payment.PI_JSON:
|
|
combined = {**json.loads(payment.PI_JSON), **result}
|
|
payment.PI_JSON = json.dumps(combined)
|
|
else:
|
|
payment.PI_JSON = json.dumps(result)
|
|
if result.get('fee_details'):
|
|
payment.Fee_Total = result['fee_details']['total_fee']
|
|
for fee_type in result['fee_details']['fee_breakdown']:
|
|
if fee_type['type'] == "tax":
|
|
payment.Fee_Tax = fee_type['amount']
|
|
elif fee_type['type'] == "stripe_fee":
|
|
payment.Fee_Stripe = fee_type['amount']
|
|
except Exception as e:
|
|
print(f"processPaymentResult error: {e}\n{json.dumps(result)}")
|
|
payment.PI_FollowUp = True
|
|
|
|
def find_pay_splynx_invoices(splynx_id):
|
|
"""Mark Splynx invoices as paid for the given customer ID."""
|
|
#result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid")
|
|
|
|
params = {
|
|
'main_attributes': {
|
|
'customer_id': splynx_id,
|
|
'status': ['IN', ['not_paid', 'pending']]
|
|
},
|
|
}
|
|
query_string = splynx.build_splynx_query_params(params)
|
|
result = splynx.get(url=f"/api/2.0/admin/finance/invoices?{query_string}")
|
|
|
|
invoice_pay = {
|
|
"status": "paid"
|
|
}
|
|
|
|
for pay in result:
|
|
res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay)
|
|
return res
|
|
|
|
def find_set_pending_splynx_invoices(splynx_id):
|
|
"""Mark Splynx invoices as pending for the given customer ID."""
|
|
params = {
|
|
'main_attributes': {
|
|
'customer_id': splynx_id,
|
|
'status': 'not_paid'
|
|
},
|
|
}
|
|
query_string = splynx.build_splynx_query_params(params)
|
|
result = splynx.get(url=f"/api/2.0/admin/finance/invoices?{query_string}")
|
|
|
|
invoice_pending = {
|
|
"status": "pending"
|
|
}
|
|
|
|
updated_invoices = []
|
|
for invoice in result:
|
|
res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice['id']}", params=invoice_pending)
|
|
if res:
|
|
updated_invoices.append(res)
|
|
return updated_invoices
|
|
|
|
def add_payment_splynx(splynx_id, pi_id, pay_id, amount):
|
|
"""Add a payment record to Splynx."""
|
|
from datetime import datetime
|
|
|
|
stripe_pay = {
|
|
"customer_id": splynx_id,
|
|
"amount": amount,
|
|
"date": str(datetime.now().strftime('%Y-%m-%d')),
|
|
"field_1": pi_id,
|
|
"field_2": f"Single Payment_ID: {pay_id}"
|
|
}
|
|
|
|
res = splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay)
|
|
if res:
|
|
return res['id']
|
|
else:
|
|
return False
|
|
|
|
def get_customer_data_for_notification(splynx_id):
|
|
"""Get customer data from Splynx for notifications."""
|
|
try:
|
|
customer_data = splynx.Customer(splynx_id)
|
|
if customer_data != 'unknown':
|
|
return customer_data
|
|
else:
|
|
return {'name': 'Unknown Customer'}
|
|
except:
|
|
return {'name': 'Unknown Customer'}
|
|
|
|
def search_stripe_customer_by_email(email):
|
|
"""Search for a Stripe customer by email address."""
|
|
try:
|
|
import stripe
|
|
|
|
# Use appropriate API key based on config
|
|
if Config.PROCESS_LIVE:
|
|
stripe.api_key = "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"
|
|
else:
|
|
stripe.api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx"
|
|
|
|
customers = stripe.Customer.search(query=f"email:'{email}'")
|
|
|
|
if customers.get('data') and len(customers['data']) > 0:
|
|
# Return the most recent customer if multiple exist
|
|
return customers['data'][-1]['id']
|
|
else:
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Error searching Stripe customer by email {email}: {e}")
|
|
return None
|
|
|
|
def create_stripe_customer(customer_data, splynx_id):
|
|
"""Create a new Stripe customer with the provided data."""
|
|
try:
|
|
import stripe
|
|
|
|
# Use appropriate API key based on config
|
|
if Config.PROCESS_LIVE:
|
|
stripe.api_key = "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"
|
|
else:
|
|
stripe.api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx"
|
|
|
|
customer = stripe.Customer.create(
|
|
name=customer_data.get('name', ''),
|
|
description=customer_data.get('name', ''),
|
|
email=customer_data.get('billing_email', ''),
|
|
metadata={
|
|
'splynx_id': str(splynx_id)
|
|
}
|
|
)
|
|
|
|
return customer.id
|
|
|
|
except Exception as e:
|
|
print(f"Error creating Stripe customer for Splynx ID {splynx_id}: {e}")
|
|
return None
|
|
|
|
def update_splynx_customer_stripe_id(splynx_id, stripe_customer_id):
|
|
"""Update Splynx customer with Stripe customer ID."""
|
|
try:
|
|
params = {
|
|
'additional_attributes': {
|
|
'stripe_customer_id': stripe_customer_id
|
|
}
|
|
}
|
|
|
|
update_result = splynx.put(url=f"/api/2.0/admin/customers/customer/{splynx_id}", params=params)
|
|
return update_result is not None
|
|
|
|
except Exception as e:
|
|
print(f"Error updating Splynx customer {splynx_id} with Stripe ID {stripe_customer_id}: {e}")
|
|
return False
|
|
|
|
def get_stripe_customer_id(splynx_id):
|
|
"""
|
|
Get Stripe customer ID for a given Splynx customer ID.
|
|
Enhanced logic:
|
|
1. First check MySQL database for existing Stripe customer ID
|
|
2. If not found, check Splynx additional_attributes for stripe_customer_id
|
|
3. If not valid, search Stripe by customer email
|
|
4. If still not found, create new Stripe customer
|
|
5. Store the Stripe customer ID back to Splynx
|
|
"""
|
|
connection = None
|
|
try:
|
|
# Step 1: Check MySQL database first (existing logic)
|
|
connection = pymysql.connect(
|
|
host=Config.MYSQL_CONFIG['host'],
|
|
database=Config.MYSQL_CONFIG['database'],
|
|
user=Config.MYSQL_CONFIG['user'],
|
|
password=Config.MYSQL_CONFIG['password'],
|
|
port=Config.MYSQL_CONFIG['port'],
|
|
autocommit=False,
|
|
cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
|
|
query = """
|
|
SELECT
|
|
cb.customer_id,
|
|
cb.deposit,
|
|
cb.payment_method,
|
|
pad.field_1 AS stripe_customer_id
|
|
FROM customer_billing cb
|
|
LEFT OUTER JOIN payment_account_data pad ON cb.customer_id = pad.customer_id
|
|
WHERE cb.customer_id = %s
|
|
ORDER BY cb.payment_method ASC
|
|
LIMIT 1
|
|
"""
|
|
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(query, (splynx_id,))
|
|
result = cursor.fetchone()
|
|
|
|
if result and result['stripe_customer_id']:
|
|
print(f"Found Stripe customer ID in MySQL: {result['stripe_customer_id']}")
|
|
return result['stripe_customer_id']
|
|
|
|
# Step 2: MySQL lookup failed, get customer from Splynx
|
|
print(f"No Stripe customer ID found in MySQL for Splynx ID {splynx_id}, checking Splynx...")
|
|
cust = splynx.Customer(splynx_id)
|
|
|
|
if not cust or cust == 'unknown':
|
|
print(f"Customer {splynx_id} not found in Splynx")
|
|
return None
|
|
|
|
# Step 3: Check Splynx additional_attributes for stripe_customer_id
|
|
additional_attrs = cust.get('additional_attributes', {})
|
|
existing_stripe_id = additional_attrs.get('stripe_customer_id', '')
|
|
|
|
if existing_stripe_id and existing_stripe_id.startswith('cus_'):
|
|
print(f"Found valid Stripe customer ID in Splynx: {existing_stripe_id}")
|
|
return existing_stripe_id
|
|
|
|
# Step 4: Search Stripe by customer email
|
|
customer_email = cust.get('billing_email', '')
|
|
if customer_email:
|
|
print(f"Searching Stripe for customer with email: {customer_email}")
|
|
stripe_customer_id = search_stripe_customer_by_email(customer_email)
|
|
|
|
if stripe_customer_id:
|
|
print(f"Found existing Stripe customer: {stripe_customer_id}")
|
|
# Store the ID back to Splynx
|
|
if update_splynx_customer_stripe_id(splynx_id, stripe_customer_id):
|
|
print(f"Updated Splynx customer {splynx_id} with Stripe ID {stripe_customer_id}")
|
|
else:
|
|
print(f"Failed to update Splynx customer {splynx_id} with Stripe ID")
|
|
return stripe_customer_id
|
|
|
|
# Step 5: Create new Stripe customer if none found
|
|
print(f"No existing Stripe customer found, creating new customer for Splynx ID {splynx_id}")
|
|
stripe_customer_id = create_stripe_customer(cust, splynx_id)
|
|
|
|
if stripe_customer_id:
|
|
print(f"Created new Stripe customer: {stripe_customer_id}")
|
|
# Store the new ID back to Splynx
|
|
if update_splynx_customer_stripe_id(splynx_id, stripe_customer_id):
|
|
print(f"Updated Splynx customer {splynx_id} with new Stripe ID {stripe_customer_id}")
|
|
else:
|
|
print(f"Failed to update Splynx customer {splynx_id} with new Stripe ID")
|
|
return stripe_customer_id
|
|
else:
|
|
print(f"Failed to create Stripe customer for Splynx ID {splynx_id}")
|
|
return None
|
|
|
|
except pymysql.Error as e:
|
|
print(f"MySQL Error in get_stripe_customer_id: {e}")
|
|
return None
|
|
except Exception as e:
|
|
print(f"Unexpected Error in get_stripe_customer_id: {e}")
|
|
return None
|
|
finally:
|
|
if connection:
|
|
connection.close()
|
|
|
|
def get_stripe_payment_methods(stripe_customer_id):
|
|
"""Get payment methods for a Stripe customer."""
|
|
try:
|
|
# Initialize Stripe processor
|
|
if Config.PROCESS_LIVE:
|
|
api_key = "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"
|
|
else:
|
|
api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx"
|
|
print(api_key)
|
|
processor = StripePaymentProcessor(api_key=api_key, enable_logging=False)
|
|
|
|
# Get payment methods from Stripe
|
|
#stripe_customer_id = "cus_SoMyDihTxRsa7U"
|
|
payment_methods = processor.get_payment_methods(stripe_customer_id)
|
|
return payment_methods
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching payment methods: {e}")
|
|
return []
|
|
|
|
main_bp = Blueprint('main', __name__)
|
|
|
|
@main_bp.app_template_filter('format_json')
|
|
def format_json_filter(json_string):
|
|
"""Format JSON string with proper indentation."""
|
|
if not json_string:
|
|
return ''
|
|
try:
|
|
# Parse the JSON string and format it with indentation
|
|
parsed = json.loads(json_string)
|
|
return json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
except (json.JSONDecodeError, TypeError):
|
|
# If it's not valid JSON, return as-is
|
|
return json_string
|
|
|
|
@main_bp.app_template_filter('currency')
|
|
def currency_filter(value):
|
|
"""Format number as currency with digit grouping."""
|
|
if value is None:
|
|
return '$0.00'
|
|
try:
|
|
# Convert to float if it's not already
|
|
num_value = float(value)
|
|
# Format with comma separators and 2 decimal places
|
|
return f"${num_value:,.2f}"
|
|
except (ValueError, TypeError):
|
|
return '$0.00'
|
|
|
|
@main_bp.app_template_filter('error_alert')
|
|
def error_alert_filter(payment):
|
|
"""Get error alert data for a payment."""
|
|
return get_error_alert_data(payment)
|
|
|
|
@main_bp.route('/')
|
|
@login_required
|
|
def index():
|
|
return render_template('main/index.html')
|
|
|
|
@main_bp.route('/batches')
|
|
@finance_required
|
|
def batch_list():
|
|
"""Display list of all payment batches with summary information."""
|
|
# Query all batches with summary statistics
|
|
batches = db.session.query(
|
|
PaymentBatch.id,
|
|
PaymentBatch.Created,
|
|
func.count(Payments.id).label('payment_count'),
|
|
func.sum(Payments.Payment_Amount).label('total_amount'),
|
|
func.sum(Payments.Fee_Stripe).label('total_fees'),
|
|
func.sum(case((Payments.Success == True, 1), else_=0)).label('successful_count'),
|
|
func.sum(case((Payments.PI_FollowUp == True, 1), else_=0)).label('pending_count'),
|
|
func.sum(case((Payments.Success == False, 1), else_=0)).label('failed_count'),
|
|
func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count')
|
|
).outerjoin(Payments, PaymentBatch.id == Payments.PaymentBatch_ID)\
|
|
.group_by(PaymentBatch.id, PaymentBatch.Created)\
|
|
.order_by(PaymentBatch.Created.desc()).all()
|
|
|
|
return render_template('main/batch_list.html', batches=batches)
|
|
|
|
@main_bp.route('/batch/<int:batch_id>')
|
|
@finance_required
|
|
def batch_detail(batch_id):
|
|
"""Display detailed view of a specific payment batch."""
|
|
# Get batch information
|
|
batch = PaymentBatch.query.get_or_404(batch_id)
|
|
|
|
# Get summary statistics for this batch
|
|
summary = db.session.query(
|
|
func.count(Payments.id).label('payment_count'),
|
|
func.sum(Payments.Payment_Amount).label('total_amount'),
|
|
func.sum(Payments.Fee_Stripe).label('total_fees'),
|
|
func.sum(case((Payments.Success == True, 1), else_=0)).label('successful_count'),
|
|
func.sum(case((Payments.Success == False, 1), else_=0)).label('failed_count'),
|
|
func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count')
|
|
).filter(Payments.PaymentBatch_ID == batch_id).first()
|
|
|
|
# Get all payments for this batch ordered by Splynx_ID
|
|
payments = Payments.query.filter_by(PaymentBatch_ID=batch_id)\
|
|
.order_by(Payments.Splynx_ID.asc()).all()
|
|
|
|
return render_template('main/batch_detail.html',
|
|
batch=batch,
|
|
summary=summary,
|
|
payments=payments)
|
|
|
|
|
|
@main_bp.route('/single-payment')
|
|
@helpdesk_required
|
|
def single_payment():
|
|
"""Display single payment form page."""
|
|
return render_template('main/single_payment.html')
|
|
|
|
@main_bp.route('/single-payments')
|
|
@helpdesk_required
|
|
def single_payments_list():
|
|
"""Display list of all single payments with summary information."""
|
|
# Query all single payments with user information
|
|
from models import Users
|
|
|
|
payments = db.session.query(
|
|
SinglePayments.id,
|
|
SinglePayments.Splynx_ID,
|
|
SinglePayments.Stripe_Customer_ID,
|
|
SinglePayments.Payment_Intent,
|
|
SinglePayments.Payment_Method,
|
|
SinglePayments.Payment_Amount,
|
|
SinglePayments.Fee_Stripe,
|
|
SinglePayments.Fee_Total,
|
|
SinglePayments.Success,
|
|
SinglePayments.Error,
|
|
SinglePayments.PI_JSON,
|
|
SinglePayments.Created,
|
|
SinglePayments.PI_FollowUp,
|
|
SinglePayments.Refund,
|
|
SinglePayments.Refund_FollowUp,
|
|
Users.FullName.label('processed_by')
|
|
).outerjoin(Users, SinglePayments.Who == Users.id)\
|
|
.order_by(SinglePayments.Created.desc()).all()
|
|
|
|
# Calculate summary statistics
|
|
total_payments = len(payments)
|
|
successful_payments = sum(1 for p in payments if p.Success == True)
|
|
failed_payments = sum(1 for p in payments if p.Success == False)
|
|
pending_payments = sum(1 for p in payments if p.Success == None)
|
|
|
|
total_amount = sum(p.Payment_Amount or 0 for p in payments if p.Success == True)
|
|
total_fees = sum(p.Fee_Stripe or 0 for p in payments if p.Success == True)
|
|
|
|
summary = {
|
|
'total_payments': total_payments,
|
|
'successful_payments': successful_payments,
|
|
'failed_payments': failed_payments,
|
|
'pending_payments': pending_payments,
|
|
'total_amount': total_amount,
|
|
'total_fees': total_fees,
|
|
'success_rate': (successful_payments / total_payments * 100) if total_payments > 0 else 0
|
|
}
|
|
|
|
return render_template('main/single_payments_list.html', payments=payments, summary=summary)
|
|
|
|
@main_bp.route('/single-payment/detail/<int:payment_id>')
|
|
@login_required
|
|
def single_payment_detail(payment_id):
|
|
"""Display detailed view of a specific single payment."""
|
|
# Get payment information
|
|
from models import Users
|
|
|
|
payment = db.session.query(
|
|
SinglePayments.id,
|
|
SinglePayments.Splynx_ID,
|
|
SinglePayments.Stripe_Customer_ID,
|
|
SinglePayments.Payment_Intent,
|
|
SinglePayments.PI_FollowUp,
|
|
SinglePayments.PI_Last_Check,
|
|
SinglePayments.Payment_Method,
|
|
SinglePayments.Fee_Tax,
|
|
SinglePayments.Fee_Stripe,
|
|
SinglePayments.Fee_Total,
|
|
SinglePayments.Payment_Amount,
|
|
SinglePayments.PI_JSON,
|
|
SinglePayments.PI_FollowUp_JSON,
|
|
SinglePayments.Refund_JSON,
|
|
SinglePayments.Error,
|
|
SinglePayments.Success,
|
|
SinglePayments.Refund,
|
|
SinglePayments.Stripe_Refund_ID,
|
|
SinglePayments.Stripe_Refund_Created,
|
|
SinglePayments.Created,
|
|
Users.FullName.label('processed_by')
|
|
).outerjoin(Users, SinglePayments.Who == Users.id)\
|
|
.filter(SinglePayments.id == payment_id).first()
|
|
|
|
if not payment:
|
|
flash('Payment not found.', 'error')
|
|
return redirect(url_for('main.single_payments_list'))
|
|
|
|
return render_template('main/single_payment_detail.html', payment=payment)
|
|
|
|
@main_bp.route('/payment/detail/<int:payment_id>')
|
|
@login_required
|
|
def payment_detail(payment_id):
|
|
"""Display detailed view of a specific batch payment."""
|
|
# Get payment information with all fields needed for the detail view
|
|
payment = db.session.query(Payments).filter(Payments.id == payment_id).first()
|
|
|
|
if not payment:
|
|
flash('Payment not found.', 'error')
|
|
return redirect(url_for('main.batch_list'))
|
|
|
|
# Log the payment detail view access
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="view_payment_detail",
|
|
entity_type="payment",
|
|
entity_id=payment_id,
|
|
details=f"Viewed batch payment detail for payment ID {payment_id}"
|
|
)
|
|
|
|
return render_template('main/payment_detail.html', payment=payment)
|
|
|
|
@main_bp.route('/single-payment/check-intent/<int:payment_id>', methods=['POST'])
|
|
@login_required
|
|
def check_payment_intent(payment_id):
|
|
"""Check the status of a payment intent and update the record."""
|
|
from datetime import datetime
|
|
|
|
try:
|
|
# Get the payment record
|
|
payment = SinglePayments.query.get_or_404(payment_id)
|
|
|
|
if not payment.Payment_Intent:
|
|
return jsonify({'success': False, 'error': 'No payment intent found'}), 400
|
|
|
|
# Initialize Stripe processor
|
|
if Config.PROCESS_LIVE:
|
|
api_key = "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"
|
|
else:
|
|
api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx"
|
|
|
|
processor = StripePaymentProcessor(api_key=api_key, enable_logging=True)
|
|
|
|
# Check payment intent status
|
|
intent_result = processor.check_payment_intent(payment.Payment_Intent)
|
|
|
|
print(json.dumps(intent_result, indent=2))
|
|
|
|
if intent_result['status'] == "succeeded":
|
|
payment.PI_FollowUp_JSON = json.dumps(intent_result)
|
|
payment.PI_FollowUp = False
|
|
payment.PI_Last_Check = datetime.now()
|
|
processPaymentResult(pay_id=payment.id, result=intent_result, key="singlepay")
|
|
else:
|
|
payment.PI_FollowUp_JSON = json.dumps(intent_result)
|
|
payment.PI_Last_Check = datetime.now()
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'status': intent_result['status'],
|
|
'payment_succeeded': intent_result['status'] == "succeeded",
|
|
'message': f'Payment intent status: {intent_result["status"]}'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Check payment intent error: {e}")
|
|
return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500
|
|
|
|
@main_bp.route('/single-payment/process', methods=['POST'])
|
|
@helpdesk_required
|
|
def process_single_payment():
|
|
"""Process a single payment using Stripe."""
|
|
try:
|
|
# Get form data
|
|
splynx_id = request.form.get('splynx_id')
|
|
amount = request.form.get('amount')
|
|
payment_method = request.form.get('payment_method')
|
|
|
|
# Validate inputs
|
|
if not splynx_id or not amount or not payment_method:
|
|
return jsonify({'success': False, 'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
splynx_id = int(splynx_id)
|
|
amount = float(amount)
|
|
except (ValueError, TypeError):
|
|
return jsonify({'success': False, 'error': 'Invalid input format'}), 400
|
|
|
|
if amount <= 0:
|
|
return jsonify({'success': False, 'error': 'Amount must be greater than 0'}), 400
|
|
|
|
# Get customer details from Splynx
|
|
customer_data = splynx.Customer(splynx_id)
|
|
if not customer_data:
|
|
return jsonify({'success': False, 'error': 'Customer not found in Splynx'}), 404
|
|
|
|
# Get Stripe customer ID from MySQL
|
|
stripe_customer_id = get_stripe_customer_id(splynx_id)
|
|
if not stripe_customer_id:
|
|
return jsonify({'success': False, 'error': 'Customer does not have a valid Stripe payment method'}), 400
|
|
|
|
# Create payment record in database
|
|
payment_record = SinglePayments(
|
|
Splynx_ID=splynx_id,
|
|
Stripe_Customer_ID=stripe_customer_id,
|
|
Payment_Amount=amount,
|
|
Who=current_user.id
|
|
)
|
|
db.session.add(payment_record)
|
|
db.session.commit() # Commit to get the payment ID
|
|
|
|
# Initialize Stripe processor
|
|
if Config.PROCESS_LIVE:
|
|
print("LIVE Payment")
|
|
api_key = "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"
|
|
else:
|
|
print("SANDBOX Payment")
|
|
api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx"
|
|
# Use test customer for sandbox
|
|
#import random
|
|
#test_customers = ['cus_SoNAgAbkbFo8ZY', 'cus_SoMyDihTxRsa7U', 'cus_SoQedaG3q2ecKG', 'cus_SoMVPWxdYstYbr']
|
|
#stripe_customer_id = random.choice(test_customers)
|
|
|
|
processor = StripePaymentProcessor(api_key=api_key, enable_logging=True)
|
|
print(f"stripe_customer_id: {stripe_customer_id}")
|
|
# Process payment with specified payment method
|
|
result = processor.process_payment(
|
|
customer_id=stripe_customer_id,
|
|
amount=amount,
|
|
currency="aud",
|
|
description=f"Single Payment - Splynx ID: {splynx_id} - Payment ID: {payment_record.id}",
|
|
stripe_pm=payment_method
|
|
)
|
|
|
|
# Update payment record with results
|
|
payment_record.Success = result.get('success', False)
|
|
payment_record.Payment_Intent = result.get('payment_intent_id')
|
|
payment_record.PI_JSON = json.dumps(result)
|
|
|
|
if result.get('error') and not result.get('needs_fee_update'):
|
|
payment_record.Error = f"Error Type: {result.get('error_type', 'Unknown')}\nError: {result['error']}"
|
|
|
|
# Send notification and create ticket for failed single payments
|
|
try:
|
|
# Initialize notification service
|
|
notification_service = NotificationService()
|
|
|
|
# Get customer information
|
|
customer_data = get_customer_data_for_notification(splynx_id)
|
|
|
|
# Prepare payment data for notification
|
|
payment_data = {
|
|
'payment_id': payment_record.id,
|
|
'splynx_id': splynx_id,
|
|
'amount': amount,
|
|
'error': payment_record.Error,
|
|
'payment_method': payment_method,
|
|
'customer_name': customer_data.get('name', 'Unknown Customer'),
|
|
'payment_type': 'single',
|
|
'stripe_customer_id': stripe_customer_id,
|
|
'payment_intent': result.get('payment_intent_id')
|
|
}
|
|
|
|
# Send notification and create ticket (only in live mode)
|
|
#if Config.PROCESS_LIVE:
|
|
# Send email notification
|
|
email_sent = notification_service.send_payment_failure_notification(payment_data)
|
|
|
|
# Create Splynx ticket
|
|
ticket_result = splynx.create_ticket(
|
|
customer_id=splynx_id,
|
|
subject=f"Payment Failure - ${amount:.2f}",
|
|
type_id=1,
|
|
group_up=7,
|
|
status_id=1,
|
|
priority="medium"
|
|
)
|
|
|
|
internal_message=f"""
|
|
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
|
|
<html>
|
|
<body>
|
|
<div>Single payment processing has failed for customer {customer_data.get('name', 'Unknown')} (ID: {splynx_id}).</div>
|
|
<div><br></div>
|
|
<div><strong>Payment Details:</strong></div>
|
|
<ul>
|
|
<li>Payment ID: {payment_record.id} (single payment)</li>
|
|
<li>Amount: ${amount:.2f} AUD</li>
|
|
<li>Payment Method: {payment_method}</li>
|
|
<li>Stripe Customer: {stripe_customer_id}</li>
|
|
<li>Payment Intent: {result.get('payment_intent_id', 'N/A')}</li>
|
|
<li>Processed by: {current_user.FullName}</li>
|
|
</ul>
|
|
<div><br></div>
|
|
<div><strong>Error Information:</strong></div>
|
|
<div>{payment_record.Error}</div>
|
|
<div><br></div>
|
|
<div>This ticket was automatically created by the Plutus Payment System.</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# Create customer-friendly message
|
|
payment_data_for_msg = {
|
|
'amount': amount,
|
|
'splynx_id': splynx_id,
|
|
'pi_json': result.get('pi_json') or json.dumps(result)
|
|
}
|
|
cust_message = create_customer_friendly_message(payment_data_for_msg, result.get('error', 'Unknown error'))
|
|
|
|
# Add Internal Note
|
|
add_internal_note = splynx.add_ticket_message(
|
|
ticket_id=ticket_result['ticket_id'],
|
|
message=internal_message,
|
|
is_admin=False,
|
|
hide_for_customer=True,
|
|
message_type="note"
|
|
)
|
|
|
|
# Customer Message
|
|
add_message = splynx.add_ticket_message(
|
|
ticket_id=ticket_result['ticket_id'],
|
|
message=cust_message,
|
|
is_admin=False,
|
|
hide_for_customer=False,
|
|
message_type="message"
|
|
)
|
|
|
|
print(f"Notification sent: {email_sent}, Ticket created: {ticket_result.get('success', False)}")
|
|
|
|
except Exception as e:
|
|
print(f"Error sending notification for failed single payment: {e}")
|
|
|
|
if result.get('needs_fee_update'):
|
|
payment_record.PI_FollowUp = True
|
|
# Mark invoices as pending when PI_FollowUp is set
|
|
if Config.PROCESS_LIVE:
|
|
try:
|
|
find_set_pending_splynx_invoices(splynx_id)
|
|
except Exception as e:
|
|
print(f"⚠️ Error setting invoices to pending: {e}")
|
|
|
|
if result.get('payment_method_type') == "card":
|
|
payment_record.Payment_Method = result.get('estimated_fee_details', {}).get('card_display_brand', 'card')
|
|
elif result.get('payment_method_type') == "au_becs_debit":
|
|
payment_record.Payment_Method = result['payment_method_type']
|
|
|
|
if result.get('fee_details'):
|
|
payment_record.Fee_Total = result['fee_details']['total_fee']
|
|
for fee_type in result['fee_details']['fee_breakdown']:
|
|
if fee_type['type'] == "tax":
|
|
payment_record.Fee_Tax = fee_type['amount']
|
|
elif fee_type['type'] == "stripe_fee":
|
|
payment_record.Fee_Stripe = fee_type['amount']
|
|
|
|
# Commit the updated payment record
|
|
db.session.commit()
|
|
|
|
# Check if payment was actually successful
|
|
if result.get('success'):
|
|
# Payment succeeded - update Splynx if in live mode
|
|
if Config.PROCESS_LIVE:
|
|
try:
|
|
# Mark invoices as paid in Splynx
|
|
find_pay_splynx_invoices(splynx_id)
|
|
|
|
# Add payment record to Splynx
|
|
splynx_payment_id = add_payment_splynx(
|
|
splynx_id=splynx_id,
|
|
pi_id=result.get('payment_intent_id'),
|
|
pay_id=payment_record.id,
|
|
amount=amount
|
|
)
|
|
|
|
if splynx_payment_id:
|
|
print(f"✅ Splynx payment record created: {splynx_payment_id}")
|
|
else:
|
|
print("⚠️ Failed to create Splynx payment record")
|
|
|
|
except Exception as splynx_error:
|
|
print(f"❌ Error updating Splynx: {splynx_error}")
|
|
# Continue processing even if Splynx update fails
|
|
|
|
# Log successful payment
|
|
log_activity(
|
|
current_user.id,
|
|
"PAYMENT_SUCCESS",
|
|
"SinglePayment",
|
|
payment_record.id,
|
|
details=f"Single payment successful: ${amount:,.2f} for customer {splynx_id} ({customer_data.get('name', 'Unknown')})"
|
|
)
|
|
|
|
# Payment succeeded
|
|
return jsonify({
|
|
'success': True,
|
|
'payment_success': True,
|
|
'payment_id': payment_record.id,
|
|
'payment_intent': result.get('payment_intent_id'),
|
|
'amount': amount,
|
|
'customer_name': customer_data.get('name'),
|
|
'message': f'Payment processed successfully for {customer_data.get("name")}'
|
|
})
|
|
else:
|
|
# Payment failed - log the failure
|
|
log_activity(
|
|
current_user.id,
|
|
"PAYMENT_FAILED",
|
|
"SinglePayment",
|
|
payment_record.id,
|
|
details=f"Single payment failed: ${amount:,.2f} for customer {splynx_id} ({customer_data.get('name', 'Unknown')}) - {result.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
# Payment failed - return the specific error
|
|
if result.get('needs_fee_update'):
|
|
fee_update = True
|
|
else:
|
|
fee_update = False
|
|
return jsonify({
|
|
'success': False,
|
|
'payment_success': False,
|
|
'fee_update': fee_update,
|
|
'payment_id': payment_record.id,
|
|
'error': result.get('error', 'Payment failed'),
|
|
'error_type': result.get('error_type', 'unknown_error'),
|
|
'stripe_error': result.get('error', 'Unknown payment error'),
|
|
'customer_name': customer_data.get('name')
|
|
}), 422 # 422 Unprocessable Entity for business logic failures
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Single payment processing error: {e}")
|
|
return jsonify({'success': False, 'error': 'Payment processing failed. Please try again.'}), 500
|
|
|
|
@main_bp.route('/payment-plans')
|
|
@finance_required
|
|
def payment_plans_list():
|
|
"""Display list of all payment plans with summary information."""
|
|
from models import Users
|
|
|
|
# Query all payment plans with user information
|
|
plans = db.session.query(
|
|
PaymentPlans.id,
|
|
PaymentPlans.Splynx_ID,
|
|
PaymentPlans.Amount,
|
|
PaymentPlans.Frequency,
|
|
PaymentPlans.Start_Date,
|
|
PaymentPlans.Stripe_Payment_Method,
|
|
PaymentPlans.Enabled,
|
|
PaymentPlans.Created,
|
|
Users.FullName.label('created_by')
|
|
).outerjoin(Users, PaymentPlans.Who == Users.id)\
|
|
.order_by(PaymentPlans.Created.desc()).all()
|
|
|
|
# Calculate summary statistics
|
|
total_plans = len(plans)
|
|
active_plans = sum(1 for p in plans if p.Enabled == True)
|
|
inactive_plans = sum(1 for p in plans if p.Enabled == False)
|
|
|
|
total_recurring_amount = sum(p.Amount or 0 for p in plans if p.Enabled == True)
|
|
|
|
summary = {
|
|
'total_plans': total_plans,
|
|
'active_plans': active_plans,
|
|
'inactive_plans': inactive_plans,
|
|
'total_recurring_amount': total_recurring_amount
|
|
}
|
|
|
|
return render_template('main/payment_plans_list.html', plans=plans, summary=summary)
|
|
|
|
@main_bp.route('/payment-plans/create')
|
|
@finance_required
|
|
def payment_plans_create():
|
|
"""Display payment plan creation form."""
|
|
return render_template('main/payment_plans_form.html', edit_mode=False)
|
|
|
|
@main_bp.route('/payment-plans/create', methods=['POST'])
|
|
@finance_required
|
|
def payment_plans_create_post():
|
|
"""Handle payment plan creation."""
|
|
try:
|
|
# Get form data
|
|
splynx_id = request.form.get('splynx_id')
|
|
amount = request.form.get('amount')
|
|
frequency = request.form.get('frequency')
|
|
start_date = request.form.get('start_date')
|
|
stripe_payment_method = request.form.get('stripe_payment_method')
|
|
|
|
# Validate inputs
|
|
if not all([splynx_id, amount, frequency, start_date, stripe_payment_method]):
|
|
flash('All fields are required.', 'error')
|
|
return redirect(url_for('main.payment_plans_create'))
|
|
|
|
try:
|
|
splynx_id = int(splynx_id)
|
|
amount = float(amount)
|
|
from datetime import datetime
|
|
start_date = datetime.strptime(start_date, '%Y-%m-%d')
|
|
except (ValueError, TypeError):
|
|
flash('Invalid input format.', 'error')
|
|
return redirect(url_for('main.payment_plans_create'))
|
|
|
|
if amount <= 0:
|
|
flash('Amount must be greater than 0.', 'error')
|
|
return redirect(url_for('main.payment_plans_create'))
|
|
|
|
# Validate customer exists in Splynx
|
|
customer_data = splynx.Customer(splynx_id)
|
|
if not customer_data:
|
|
flash('Customer not found in Splynx.', 'error')
|
|
return redirect(url_for('main.payment_plans_create'))
|
|
|
|
# Create payment plan record
|
|
payment_plan = PaymentPlans(
|
|
Splynx_ID=splynx_id,
|
|
Amount=amount,
|
|
Frequency=frequency,
|
|
Start_Date=start_date,
|
|
Stripe_Payment_Method=stripe_payment_method,
|
|
Who=current_user.id
|
|
)
|
|
|
|
db.session.add(payment_plan)
|
|
db.session.commit()
|
|
|
|
# Log payment plan creation
|
|
log_activity(
|
|
current_user.id,
|
|
"PAYPLAN_CREATED",
|
|
"PaymentPlan",
|
|
payment_plan.id,
|
|
details=f"Payment plan created: ${amount:,.2f} {frequency} for customer {splynx_id} ({customer_data.get('name', 'Unknown')})"
|
|
)
|
|
|
|
flash(f'Payment plan created successfully for {customer_data.get("name", "customer")}.', 'success')
|
|
return redirect(url_for('main.payment_plans_detail', plan_id=payment_plan.id))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Payment plan creation error: {e}")
|
|
flash('Failed to create payment plan. Please try again.', 'error')
|
|
return redirect(url_for('main.payment_plans_create'))
|
|
|
|
@main_bp.route('/payment-plans/edit/<int:plan_id>')
|
|
@finance_required
|
|
def payment_plans_edit(plan_id):
|
|
"""Display payment plan edit form."""
|
|
plan = PaymentPlans.query.get_or_404(plan_id)
|
|
return render_template('main/payment_plans_form.html', plan=plan, edit_mode=True)
|
|
|
|
@main_bp.route('/payment-plans/edit/<int:plan_id>', methods=['POST'])
|
|
@finance_required
|
|
def payment_plans_edit_post(plan_id):
|
|
"""Handle payment plan updates."""
|
|
try:
|
|
plan = PaymentPlans.query.get_or_404(plan_id)
|
|
|
|
# Get form data
|
|
amount = request.form.get('amount')
|
|
frequency = request.form.get('frequency')
|
|
start_date = request.form.get('start_date')
|
|
stripe_payment_method = request.form.get('stripe_payment_method')
|
|
|
|
# Validate inputs
|
|
if not all([amount, frequency, start_date, stripe_payment_method]):
|
|
flash('All fields are required.', 'error')
|
|
return redirect(url_for('main.payment_plans_edit', plan_id=plan_id))
|
|
|
|
try:
|
|
amount = float(amount)
|
|
from datetime import datetime
|
|
start_date = datetime.strptime(start_date, '%Y-%m-%d')
|
|
except (ValueError, TypeError):
|
|
flash('Invalid input format.', 'error')
|
|
return redirect(url_for('main.payment_plans_edit', plan_id=plan_id))
|
|
|
|
if amount <= 0:
|
|
flash('Amount must be greater than 0.', 'error')
|
|
return redirect(url_for('main.payment_plans_edit', plan_id=plan_id))
|
|
|
|
# Update payment plan
|
|
plan.Amount = amount
|
|
plan.Frequency = frequency
|
|
plan.Start_Date = start_date
|
|
plan.Stripe_Payment_Method = stripe_payment_method
|
|
|
|
db.session.commit()
|
|
|
|
# Log payment plan update
|
|
log_activity(
|
|
current_user.id,
|
|
"PAYPLAN_UPDATED",
|
|
"PaymentPlan",
|
|
plan.id,
|
|
details=f"Payment plan updated: ${amount:,.2f} {frequency} starting {start_date.strftime('%Y-%m-%d')}"
|
|
)
|
|
|
|
flash('Payment plan updated successfully.', 'success')
|
|
return redirect(url_for('main.payment_plans_detail', plan_id=plan.id))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Payment plan update error: {e}")
|
|
flash('Failed to update payment plan. Please try again.', 'error')
|
|
return redirect(url_for('main.payment_plans_edit', plan_id=plan_id))
|
|
|
|
@main_bp.route('/payment-plans/delete/<int:plan_id>', methods=['POST'])
|
|
@finance_required
|
|
def payment_plans_delete(plan_id):
|
|
"""Handle payment plan deletion (soft delete)."""
|
|
try:
|
|
plan = PaymentPlans.query.get_or_404(plan_id)
|
|
|
|
# Soft delete by setting Enabled to False
|
|
plan.Enabled = False
|
|
db.session.commit()
|
|
|
|
flash('Payment plan has been disabled.', 'success')
|
|
return redirect(url_for('main.payment_plans_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Payment plan deletion error: {e}")
|
|
flash('Failed to disable payment plan. Please try again.', 'error')
|
|
return redirect(url_for('main.payment_plans_detail', plan_id=plan_id))
|
|
|
|
@main_bp.route('/payment-plans/toggle/<int:plan_id>', methods=['POST'])
|
|
@login_required
|
|
def payment_plans_toggle(plan_id):
|
|
"""Toggle payment plan enabled status."""
|
|
try:
|
|
plan = PaymentPlans.query.get_or_404(plan_id)
|
|
|
|
# Toggle enabled status
|
|
plan.Enabled = not plan.Enabled
|
|
db.session.commit()
|
|
|
|
# Log payment plan toggle
|
|
action = "PAYPLAN_ENABLED" if plan.Enabled else "PAYPLAN_DISABLED"
|
|
log_activity(
|
|
current_user.id,
|
|
action,
|
|
"PaymentPlan",
|
|
plan.id,
|
|
details=f"Payment plan {'enabled' if plan.Enabled else 'disabled'}: ${plan.Amount:,.2f} {plan.Frequency}"
|
|
)
|
|
|
|
status = "enabled" if plan.Enabled else "disabled"
|
|
flash(f'Payment plan has been {status}.', 'success')
|
|
return redirect(url_for('main.payment_plans_detail', plan_id=plan_id))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Payment plan toggle error: {e}")
|
|
flash('Failed to update payment plan status. Please try again.', 'error')
|
|
return redirect(url_for('main.payment_plans_detail', plan_id=plan_id))
|
|
|
|
@main_bp.route('/payment-plans/detail/<int:plan_id>')
|
|
@login_required
|
|
def payment_plans_detail(plan_id):
|
|
"""Display detailed view of a specific payment plan."""
|
|
from models import Users
|
|
|
|
# Get payment plan with user information
|
|
plan = db.session.query(
|
|
PaymentPlans.id,
|
|
PaymentPlans.Splynx_ID,
|
|
PaymentPlans.Amount,
|
|
PaymentPlans.Frequency,
|
|
PaymentPlans.Start_Date,
|
|
PaymentPlans.Stripe_Payment_Method,
|
|
PaymentPlans.Enabled,
|
|
PaymentPlans.Created,
|
|
Users.FullName.label('created_by')
|
|
).outerjoin(Users, PaymentPlans.Who == Users.id)\
|
|
.filter(PaymentPlans.id == plan_id).first()
|
|
|
|
if not plan:
|
|
flash('Payment plan not found.', 'error')
|
|
return redirect(url_for('main.payment_plans_list'))
|
|
|
|
# Get associated single payments
|
|
associated_payments = db.session.query(
|
|
Payments.id,
|
|
Payments.Payment_Amount,
|
|
Payments.Success,
|
|
Payments.Error,
|
|
Payments.Created,
|
|
Payments.Payment_Intent)\
|
|
.filter(Payments.PaymentPlan_ID == plan_id)\
|
|
.order_by(Payments.Created.desc()).all()
|
|
|
|
return render_template('main/payment_plans_detail.html',
|
|
plan=plan,
|
|
associated_payments=associated_payments)
|
|
|
|
@main_bp.route('/api/stripe-customer-id/<int:splynx_id>')
|
|
@login_required
|
|
def api_stripe_customer_id(splynx_id):
|
|
"""Get Stripe customer ID for a Splynx customer."""
|
|
try:
|
|
stripe_customer_id = get_stripe_customer_id(splynx_id)
|
|
if stripe_customer_id:
|
|
return jsonify({'success': True, 'stripe_customer_id': stripe_customer_id})
|
|
else:
|
|
return jsonify({'success': False, 'error': 'Customer does not have a Stripe customer ID'}), 404
|
|
except Exception as e:
|
|
print(f"Error fetching Stripe customer ID: {e}")
|
|
return jsonify({'success': False, 'error': 'Failed to fetch Stripe customer ID'}), 500
|
|
|
|
@main_bp.route('/api/stripe-payment-methods/<stripe_customer_id>')
|
|
@login_required
|
|
def api_stripe_payment_methods(stripe_customer_id):
|
|
"""Get Stripe payment methods for a customer."""
|
|
try:
|
|
payment_methods = get_stripe_payment_methods(stripe_customer_id)
|
|
return jsonify({'success': True, 'payment_methods': payment_methods})
|
|
except Exception as e:
|
|
print(f"Error fetching payment methods: {e}")
|
|
return jsonify({'success': False, 'error': 'Failed to fetch payment methods'}), 500
|
|
|
|
@main_bp.route('/payment/check-intent/<int:payment_id>', methods=['POST'])
|
|
@login_required
|
|
def check_batch_payment_intent(payment_id):
|
|
"""Check the status of a batch payment intent and update the record."""
|
|
from datetime import datetime
|
|
|
|
try:
|
|
# Get the payment record from Payments table (batch payments)
|
|
payment = Payments.query.get_or_404(payment_id)
|
|
|
|
if not payment.Payment_Intent:
|
|
return jsonify({'success': False, 'error': 'No payment intent found'}), 400
|
|
|
|
# Initialize Stripe processor with correct API key
|
|
if Config.PROCESS_LIVE:
|
|
api_key = Config.STRIPE_LIVE_API_KEY
|
|
else:
|
|
api_key = Config.STRIPE_TEST_API_KEY
|
|
|
|
processor = StripePaymentProcessor(api_key=api_key, enable_logging=True)
|
|
|
|
# Check payment intent status
|
|
intent_result = processor.check_payment_intent(payment.Payment_Intent)
|
|
|
|
if intent_result['status'] == "succeeded":
|
|
payment.PI_FollowUp_JSON = json.dumps(intent_result)
|
|
payment.PI_FollowUp = False
|
|
payment.PI_Last_Check = datetime.now()
|
|
payment.Success = True
|
|
if intent_result.get('charge_id'):
|
|
payment.Stripe_Charge_ID = intent_result.get('charge_id')
|
|
processPaymentResult(pay_id=payment.id, result=intent_result, key="pay")
|
|
elif intent_result['status'] == "failed":
|
|
payment.PI_FollowUp_JSON = json.dumps(intent_result)
|
|
payment.PI_FollowUp = False
|
|
payment.PI_Last_Check = datetime.now()
|
|
payment.Success = False
|
|
else:
|
|
# Still pending
|
|
payment.PI_FollowUp_JSON = json.dumps(intent_result)
|
|
payment.PI_Last_Check = datetime.now()
|
|
|
|
db.session.commit()
|
|
|
|
# Log the intent check activity
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="check_payment_intent",
|
|
entity_type="payment",
|
|
entity_id=payment_id,
|
|
details=f"Checked payment intent {payment.Payment_Intent}, status: {intent_result['status']}"
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'status': intent_result['status'],
|
|
'payment_succeeded': intent_result['status'] == "succeeded",
|
|
'message': f'Payment intent status: {intent_result["status"]}'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Check batch payment intent error: {e}")
|
|
return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500
|
|
|
|
@main_bp.route('/single-payment/refund/<int:payment_id>', methods=['POST'])
|
|
@login_required
|
|
def process_single_payment_refund(payment_id):
|
|
"""Process refund for a single payment."""
|
|
try:
|
|
# Get the payment record
|
|
payment = db.session.query(SinglePayments).filter(SinglePayments.id == payment_id).first()
|
|
|
|
if not payment:
|
|
return jsonify({'success': False, 'error': 'Payment not found'}), 404
|
|
|
|
# Check if payment can be refunded
|
|
if payment.Success != True:
|
|
return jsonify({'success': False, 'error': 'Cannot refund unsuccessful payment'}), 400
|
|
|
|
if payment.Refund == True:
|
|
return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400
|
|
|
|
if not payment.Payment_Intent:
|
|
return jsonify({'success': False, 'error': 'No payment intent found for this payment'}), 400
|
|
|
|
# Get refund reason from request
|
|
data = request.get_json()
|
|
reason = data.get('reason', 'requested_by_customer') if data else 'requested_by_customer'
|
|
|
|
# Initialize Stripe
|
|
if Config.PROCESS_LIVE:
|
|
api_key = "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"
|
|
else:
|
|
api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx"
|
|
|
|
import stripe
|
|
stripe.api_key = api_key
|
|
|
|
# Process refund through Stripe
|
|
refund = stripe.Refund.create(
|
|
payment_intent=payment.Payment_Intent,
|
|
reason=reason,
|
|
metadata={
|
|
'splynx_customer_id': str(payment.Splynx_ID),
|
|
'payment_id': str(payment_id),
|
|
'processed_by': current_user.FullName
|
|
}
|
|
)
|
|
|
|
# Update payment record based on refund status
|
|
payment.Refund_JSON = json.dumps(refund, default=str)
|
|
payment.Stripe_Refund_ID = refund.id
|
|
|
|
# Convert timestamp to datetime
|
|
if hasattr(refund, 'created') and refund.created:
|
|
from datetime import datetime
|
|
payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created)
|
|
|
|
# Handle refund status - check if it's succeeded or pending
|
|
if refund.status == "succeeded":
|
|
payment.Refund = True
|
|
refund_status_message = "succeeded"
|
|
elif refund.status == "pending":
|
|
payment.Refund_FollowUp = True
|
|
refund_status_message = "pending"
|
|
else:
|
|
# For any other status, just store the refund data but don't mark as complete
|
|
refund_status_message = refund.status
|
|
|
|
db.session.commit()
|
|
|
|
# Log the refund activity
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="process_refund",
|
|
entity_type="single_payment",
|
|
entity_id=payment_id,
|
|
details=f"Processed refund for single payment ID {payment_id}, amount ${payment.Payment_Amount}, reason: {reason}, status: {refund_status_message}"
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'pending': refund.status == "pending",
|
|
'refund_id': refund.id,
|
|
'refund_status': refund.status,
|
|
'amount_refunded': f"${payment.Payment_Amount:.2f}",
|
|
'reason': reason
|
|
})
|
|
|
|
except stripe.StripeError as e:
|
|
return jsonify({'success': False, 'error': f'Stripe error: {str(e)}'}), 500
|
|
except Exception as e:
|
|
print(f"Error processing single payment refund: {e}")
|
|
return jsonify({'success': False, 'error': 'Internal server error'}), 500
|
|
|
|
@main_bp.route('/single-payment/check-refund/<int:payment_id>', methods=['POST'])
|
|
@login_required
|
|
def check_single_payment_refund_status(payment_id):
|
|
"""Check the status of a pending refund for a single payment."""
|
|
try:
|
|
# Get the payment record
|
|
payment = db.session.query(SinglePayments).filter(SinglePayments.id == payment_id).first()
|
|
|
|
if not payment:
|
|
return jsonify({'success': False, 'error': 'Payment not found'}), 404
|
|
|
|
if not payment.Stripe_Refund_ID:
|
|
return jsonify({'success': False, 'error': 'No refund ID found for this payment'}), 400
|
|
|
|
# Initialize Stripe
|
|
import stripe
|
|
if Config.PROCESS_LIVE:
|
|
stripe.api_key = Config.STRIPE_LIVE_API_KEY
|
|
else:
|
|
stripe.api_key = Config.STRIPE_TEST_API_KEY
|
|
|
|
# Get refund details from Stripe
|
|
refund = stripe.Refund.retrieve(payment.Stripe_Refund_ID)
|
|
|
|
# Update payment record based on refund status
|
|
if refund.status == "succeeded":
|
|
payment.Refund = True
|
|
payment.Refund_FollowUp = False
|
|
refund_completed = True
|
|
elif refund.status == "pending":
|
|
# Still pending, no change needed
|
|
refund_completed = False
|
|
elif refund.status in ["failed", "canceled"]:
|
|
# Refund failed, update status
|
|
payment.Refund_FollowUp = False
|
|
refund_completed = False
|
|
else:
|
|
refund_completed = False
|
|
|
|
# Update the Refund_JSON with latest data
|
|
payment.Refund_JSON = json.dumps(refund, default=str)
|
|
db.session.commit()
|
|
|
|
# Log the refund status check
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="check_refund_status",
|
|
entity_type="single_payment",
|
|
entity_id=payment_id,
|
|
details=f"Checked refund status for single payment ID {payment_id}, status: {refund.status}"
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'refund_completed': refund_completed,
|
|
'status': refund.status,
|
|
'refund_id': refund.id,
|
|
'amount_refunded': f"${refund.amount/100:.2f}"
|
|
})
|
|
|
|
except stripe.StripeError as e:
|
|
return jsonify({'success': False, 'error': f'Stripe error: {str(e)}'}), 500
|
|
except Exception as e:
|
|
print(f"Error checking refund status: {e}")
|
|
return jsonify({'success': False, 'error': 'Internal server error'}), 500
|
|
|
|
@main_bp.route('/payment/check-refund/<int:payment_id>', methods=['POST'])
|
|
@login_required
|
|
def check_batch_payment_refund_status(payment_id):
|
|
"""Check the status of a pending refund for a batch payment."""
|
|
try:
|
|
# Get the payment record from Payments table (batch payments)
|
|
payment = Payments.query.get_or_404(payment_id)
|
|
|
|
if not payment.Stripe_Refund_ID:
|
|
return jsonify({'success': False, 'error': 'No refund ID found for this payment'}), 400
|
|
|
|
# Initialize Stripe
|
|
import stripe
|
|
if Config.PROCESS_LIVE:
|
|
stripe.api_key = Config.STRIPE_LIVE_API_KEY
|
|
else:
|
|
stripe.api_key = Config.STRIPE_TEST_API_KEY
|
|
|
|
# Get refund details from Stripe
|
|
refund = stripe.Refund.retrieve(payment.Stripe_Refund_ID)
|
|
|
|
# Update payment record based on refund status
|
|
if refund.status == "succeeded":
|
|
payment.Refund = True
|
|
payment.Refund_FollowUp = False
|
|
refund_completed = True
|
|
elif refund.status == "pending":
|
|
# Still pending, no change needed
|
|
refund_completed = False
|
|
elif refund.status in ["failed", "canceled"]:
|
|
# Refund failed, update status
|
|
payment.Refund_FollowUp = False
|
|
refund_completed = False
|
|
else:
|
|
refund_completed = False
|
|
|
|
# Update the Refund_JSON with latest data
|
|
payment.Refund_JSON = json.dumps(refund, default=str)
|
|
db.session.commit()
|
|
|
|
# Log the refund status check
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="check_refund_status",
|
|
entity_type="payment",
|
|
entity_id=payment_id,
|
|
details=f"Checked refund status for batch payment ID {payment_id}, status: {refund.status}"
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'refund_completed': refund_completed,
|
|
'status': refund.status,
|
|
'refund_id': refund.id,
|
|
'amount_refunded': f"${refund.amount/100:.2f}"
|
|
})
|
|
|
|
except stripe.StripeError as e:
|
|
return jsonify({'success': False, 'error': f'Stripe error: {str(e)}'}), 500
|
|
except Exception as e:
|
|
print(f"Error checking batch refund status: {e}")
|
|
return jsonify({'success': False, 'error': 'Internal server error'}), 500
|
|
|
|
@main_bp.route('/logs')
|
|
@finance_required
|
|
def logs_list():
|
|
"""Display system logs with filtering and pagination."""
|
|
# Get filter parameters
|
|
page = request.args.get('page', 1, type=int)
|
|
search = request.args.get('search', '')
|
|
user_filter = request.args.get('user', '', type=int)
|
|
action_filter = request.args.get('action', '')
|
|
entity_type_filter = request.args.get('entity_type', '')
|
|
date_from = request.args.get('date_from', '')
|
|
date_to = request.args.get('date_to', '')
|
|
|
|
# Build query with joins and filters
|
|
from models import Users
|
|
query = db.session.query(
|
|
Logs.id,
|
|
Logs.User_ID,
|
|
Logs.Log_Entry,
|
|
Logs.Added,
|
|
Logs.Action,
|
|
Logs.Entity_Type,
|
|
Logs.Entity_ID,
|
|
Logs.IP_Address,
|
|
Users.FullName.label('user_name')
|
|
).outerjoin(Users, Logs.User_ID == Users.id)
|
|
|
|
# Apply filters
|
|
if search:
|
|
search_filter = f"%{search}%"
|
|
query = query.filter(db.or_(
|
|
Logs.Log_Entry.like(search_filter),
|
|
Logs.Action.like(search_filter),
|
|
Logs.Entity_Type.like(search_filter),
|
|
Users.FullName.like(search_filter),
|
|
Logs.IP_Address.like(search_filter)
|
|
))
|
|
|
|
if user_filter:
|
|
query = query.filter(Logs.User_ID == user_filter)
|
|
|
|
if action_filter:
|
|
query = query.filter(Logs.Action == action_filter)
|
|
|
|
if entity_type_filter:
|
|
query = query.filter(Logs.Entity_Type == entity_type_filter)
|
|
|
|
if date_from:
|
|
try:
|
|
from datetime import datetime
|
|
date_from_obj = datetime.strptime(date_from, '%Y-%m-%d')
|
|
query = query.filter(Logs.Added >= date_from_obj)
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_to:
|
|
try:
|
|
from datetime import datetime
|
|
date_to_obj = datetime.strptime(date_to, '%Y-%m-%d')
|
|
# Add one day to include the entire date_to day
|
|
date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59)
|
|
query = query.filter(Logs.Added <= date_to_obj)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Order by most recent first
|
|
query = query.order_by(Logs.Added.desc())
|
|
|
|
# Paginate results
|
|
per_page = 50 # Show 50 logs per page
|
|
pagination = query.paginate(
|
|
page=page,
|
|
per_page=per_page,
|
|
error_out=False
|
|
)
|
|
logs = pagination.items
|
|
|
|
# Get unique users for filter dropdown
|
|
users = db.session.query(Users.id, Users.FullName).filter(
|
|
Users.id.in_(
|
|
db.session.query(Logs.User_ID).distinct()
|
|
)
|
|
).order_by(Users.FullName).all()
|
|
|
|
# Get unique actions for filter dropdown
|
|
actions = db.session.query(Logs.Action).filter(
|
|
Logs.Action.isnot(None)
|
|
).distinct().order_by(Logs.Action).all()
|
|
actions = [action[0] for action in actions if action[0]]
|
|
|
|
# Get unique entity types for filter dropdown
|
|
entity_types = db.session.query(Logs.Entity_Type).filter(
|
|
Logs.Entity_Type.isnot(None)
|
|
).distinct().order_by(Logs.Entity_Type).all()
|
|
entity_types = [entity_type[0] for entity_type in entity_types if entity_type[0]]
|
|
|
|
# Log this page access
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="view_logs",
|
|
entity_type="logs",
|
|
details=f"Viewed system logs page {page} with filters: search={search}, user={user_filter}, action={action_filter}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
return render_template(
|
|
'main/logs_list.html',
|
|
logs=logs,
|
|
pagination=pagination,
|
|
users=users,
|
|
actions=actions,
|
|
entity_types=entity_types
|
|
)
|
|
|
|
@main_bp.route('/logs/detail/<int:log_id>')
|
|
@finance_required
|
|
def log_detail(log_id):
|
|
"""Get detailed information for a specific log entry."""
|
|
log = db.session.query(Logs).filter(Logs.id == log_id).first()
|
|
|
|
if not log:
|
|
return jsonify({'success': False, 'error': 'Log entry not found'}), 404
|
|
|
|
# Get user name if available
|
|
from models import Users
|
|
user = db.session.query(Users).filter(Users.id == log.User_ID).first() if log.User_ID else None
|
|
|
|
log_data = {
|
|
'id': log.id,
|
|
'User_ID': log.User_ID,
|
|
'user_name': user.FullName if user else None,
|
|
'Log_Entry': log.Log_Entry,
|
|
'timestamp': log.Added.strftime('%Y-%m-%d %H:%M:%S') if log.Added else None,
|
|
'Action': log.Action,
|
|
'Entity_Type': log.Entity_Type,
|
|
'Entity_ID': log.Entity_ID,
|
|
'IP_Address': log.IP_Address
|
|
}
|
|
|
|
# Log this detail view access
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="view_log_detail",
|
|
entity_type="log",
|
|
entity_id=log_id,
|
|
details=f"Viewed details for log entry {log_id}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
return jsonify({'success': True, 'log': log_data})
|
|
|
|
@main_bp.route('/logs/export')
|
|
@finance_required
|
|
def export_logs():
|
|
"""Export logs as CSV file with current filters applied."""
|
|
# Get filter parameters (same as logs_list)
|
|
search = request.args.get('search', '')
|
|
user_filter = request.args.get('user', '', type=int)
|
|
action_filter = request.args.get('action', '')
|
|
entity_type_filter = request.args.get('entity_type', '')
|
|
date_from = request.args.get('date_from', '')
|
|
date_to = request.args.get('date_to', '')
|
|
|
|
# Build query with same filters as logs_list
|
|
from models import Users
|
|
query = db.session.query(
|
|
Logs.id,
|
|
Logs.User_ID,
|
|
Logs.Log_Entry,
|
|
Logs.Added,
|
|
Logs.Action,
|
|
Logs.Entity_Type,
|
|
Logs.Entity_ID,
|
|
Logs.IP_Address,
|
|
Users.FullName.label('user_name')
|
|
).outerjoin(Users, Logs.User_ID == Users.id)
|
|
|
|
# Apply same filters as in logs_list
|
|
if search:
|
|
search_filter = f"%{search}%"
|
|
query = query.filter(db.or_(
|
|
Logs.Log_Entry.like(search_filter),
|
|
Logs.Action.like(search_filter),
|
|
Logs.Entity_Type.like(search_filter),
|
|
Users.FullName.like(search_filter),
|
|
Logs.IP_Address.like(search_filter)
|
|
))
|
|
|
|
if user_filter:
|
|
query = query.filter(Logs.User_ID == user_filter)
|
|
|
|
if action_filter:
|
|
query = query.filter(Logs.Action == action_filter)
|
|
|
|
if entity_type_filter:
|
|
query = query.filter(Logs.Entity_Type == entity_type_filter)
|
|
|
|
if date_from:
|
|
try:
|
|
from datetime import datetime
|
|
date_from_obj = datetime.strptime(date_from, '%Y-%m-%d')
|
|
query = query.filter(Logs.Added >= date_from_obj)
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_to:
|
|
try:
|
|
from datetime import datetime
|
|
date_to_obj = datetime.strptime(date_to, '%Y-%m-%d')
|
|
date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59)
|
|
query = query.filter(Logs.Added <= date_to_obj)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Order by most recent first
|
|
logs = query.order_by(Logs.Added.desc()).limit(10000).all() # Limit to 10k records for export
|
|
|
|
# Generate CSV
|
|
import csv
|
|
from io import StringIO
|
|
from flask import Response
|
|
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Write headers
|
|
writer.writerow([
|
|
'ID', 'Timestamp', 'User ID', 'User Name', 'Action',
|
|
'Entity Type', 'Entity ID', 'Details', 'IP Address'
|
|
])
|
|
|
|
# Write data
|
|
for log in logs:
|
|
writer.writerow([
|
|
log.id,
|
|
log.Added.strftime('%Y-%m-%d %H:%M:%S') if log.Added else '',
|
|
log.User_ID or '',
|
|
log.user_name or '',
|
|
log.Action or '',
|
|
log.Entity_Type or '',
|
|
log.Entity_ID or '',
|
|
(log.Log_Entry or '').replace('\n', ' ').replace('\r', ' '), # Clean newlines for CSV
|
|
log.IP_Address or ''
|
|
])
|
|
|
|
# Log the export activity
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="export_logs",
|
|
entity_type="logs",
|
|
details=f"Exported {len(logs)} log entries with filters applied",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
# Create response
|
|
from datetime import datetime
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f"system_logs_{timestamp}.csv"
|
|
|
|
response = Response(
|
|
output.getvalue(),
|
|
mimetype='text/csv',
|
|
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
|
)
|
|
|
|
return response
|
|
|
|
@main_bp.route('/payment/refund/<int:payment_id>', methods=['POST'])
|
|
@login_required
|
|
def process_payment_refund(payment_id):
|
|
"""Process a refund for a batch payment."""
|
|
from datetime import datetime
|
|
import stripe
|
|
|
|
try:
|
|
# Get the payment record from Payments table (batch payments)
|
|
payment = Payments.query.get_or_404(payment_id)
|
|
|
|
# Validate payment can be refunded
|
|
if not payment.Success:
|
|
return jsonify({'success': False, 'error': 'Cannot refund an unsuccessful payment'}), 400
|
|
|
|
if payment.Refund:
|
|
return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400
|
|
|
|
#if not payment.Stripe_Charge_ID:
|
|
# return jsonify({'success': False, 'error': 'No Stripe charge ID found for this payment'}), 400
|
|
|
|
# Get refund reason from request
|
|
data = request.get_json()
|
|
reason = data.get('reason', 'requested_by_customer')
|
|
|
|
# Initialize Stripe with correct API key
|
|
if Config.PROCESS_LIVE:
|
|
stripe.api_key = Config.STRIPE_LIVE_API_KEY
|
|
else:
|
|
stripe.api_key = Config.STRIPE_TEST_API_KEY
|
|
|
|
# Create refund parameters
|
|
#refund_params = {
|
|
# 'charge': payment.Stripe_Charge_ID,
|
|
# 'reason': reason
|
|
#}
|
|
|
|
# Process the refund with Stripe
|
|
#refund = stripe.Refund.create(**refund_params)
|
|
refund = stripe.Refund.create(
|
|
payment_intent=payment.Payment_Intent,
|
|
reason=reason,
|
|
metadata={
|
|
'splynx_customer_id': str(payment.Splynx_ID),
|
|
'payment_id': str(payment_id),
|
|
'processed_by': current_user.FullName
|
|
}
|
|
)
|
|
print(f"refund: {refund}")
|
|
time.sleep(3)
|
|
refunds = stripe.Refund.list(payment_intent=payment.Payment_Intent)
|
|
#refund = stripe.Refund.retrieve(payment_intent=payment.Payment_Intent)
|
|
|
|
#for refund in refunds.data:
|
|
if refunds.count == 1:
|
|
refund = refunds.data[-1]
|
|
else:
|
|
# Log the refund activity
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="process_refund",
|
|
entity_type="refund_error",
|
|
entity_id=payment_id,
|
|
details=f"Error in refund count ({refunds.count} for Pay Intent: {payment.Payment_Intent})"
|
|
)
|
|
|
|
if refund['status'] in ["succeeded", "pending"]:
|
|
# Update payment record with refund information
|
|
payment.Stripe_Refund_ID = refund.id
|
|
payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created)
|
|
payment.Refund_JSON = json.dumps(refund)
|
|
if refund['status'] == "succeeded":
|
|
payment.Refund = True
|
|
elif refund['status'] == "pending":
|
|
payment.Refund_FollowUp = True
|
|
|
|
|
|
db.session.commit()
|
|
|
|
# Log the refund activity
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="process_refund",
|
|
entity_type="payment",
|
|
entity_id=payment_id,
|
|
details=f"Processed refund {refund.id} for payment {payment_id}, amount: ${refund.amount/100:.2f}"
|
|
)
|
|
if refund['status'] == "succeeded":
|
|
return jsonify({
|
|
'success': True,
|
|
'pending': False,
|
|
'refund_id': refund.id,
|
|
'amount_refunded': f"${refund.amount/100:.2f}",
|
|
'status': refund.status,
|
|
'message': 'Refund processed successfully'
|
|
})
|
|
elif refund['status'] == "pending":
|
|
return jsonify({
|
|
'success': True,
|
|
'pending': True,
|
|
'refund_id': refund.id,
|
|
'amount_refunded': f"${refund.amount/100:.2f}",
|
|
'status': refund.status,
|
|
'message': 'Refund is being processed. Refund should occur within the next few days.'
|
|
})
|
|
else:
|
|
# Refund failed
|
|
payment.Refund = False
|
|
payment.Refund_JSON = json.dumps(refund)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Refund failed with status: {refund.status}'
|
|
}), 400
|
|
|
|
except stripe.error.InvalidRequestError as e:
|
|
# Handle Stripe-specific errors
|
|
error_msg = str(e)
|
|
if "has already been refunded" in error_msg:
|
|
# Mark as refunded in our database even if Stripe says it's already refunded
|
|
payment.Refund = True
|
|
payment.Stripe_Refund_Created = datetime.now()
|
|
db.session.commit()
|
|
return jsonify({'success': False, 'error': 'Payment has already been refunded in Stripe'}), 400
|
|
else:
|
|
return jsonify({'success': False, 'error': f'Stripe error: {error_msg}'}), 400
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Process payment refund error: {e}")
|
|
return jsonify({'success': False, 'error': 'Failed to process refund'}), 500
|
|
|
|
@main_bp.route('/api/splynx/<int:id>')
|
|
@login_required
|
|
def api_splynx_customer(id):
|
|
"""
|
|
Get Splynx customer information by ID
|
|
|
|
Security: Restricted to operational and financial staff who need customer data access
|
|
"""
|
|
try:
|
|
log_activity(current_user.id, "API_ACCESS", "SplynxCustomer", id,
|
|
details=f"Accessed Splynx customer API for customer {id}")
|
|
|
|
print(f"Splynx Customer API: {id}")
|
|
res = splynx.Customer(id)
|
|
|
|
if res:
|
|
log_activity(current_user.id, "API_SUCCESS", "SplynxCustomer", id,
|
|
details=f"Successfully retrieved Splynx customer {id}")
|
|
return res
|
|
else:
|
|
log_activity(current_user.id, "API_NOT_FOUND", "SplynxCustomer", id,
|
|
details=f"Splynx customer {id} not found")
|
|
return {"error": "Customer not found"}, 404
|
|
|
|
except Exception as e:
|
|
log_activity(current_user.id, "API_ERROR", "SplynxCustomer", id,
|
|
details=f"Splynx customer API error: {str(e)}")
|
|
return {"error": "Internal server error"}, 500
|
|
|
|
# ============ Payment Method Management Routes ============
|
|
|
|
@main_bp.route('/single-payments/add-payment-method')
|
|
@login_required
|
|
def add_payment_method():
|
|
"""
|
|
Display the payment method addition form.
|
|
"""
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="view_add_payment_method",
|
|
entity_type="payment_method",
|
|
details="Accessed add payment method page",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
return render_template('main/add_payment_method.html')
|
|
|
|
@main_bp.route('/api/create-setup-intent', methods=['POST'])
|
|
@login_required
|
|
def create_setup_intent():
|
|
"""
|
|
Create a Stripe Setup Intent for collecting payment method details.
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
stripe_customer_id = data.get('stripe_customer_id')
|
|
#stripe_customer_id = 'cus_SoQqMGLmCjiBDZ'
|
|
payment_method_types = data.get('payment_method_types', ['card', 'au_becs_debit'])
|
|
|
|
if not stripe_customer_id:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'stripe_customer_id is required'
|
|
}), 400
|
|
|
|
# Initialize Stripe processor
|
|
config = Config()
|
|
processor = StripePaymentProcessor(api_key=config.STRIPE_SECRET_KEY)
|
|
|
|
# Create setup intent
|
|
result = processor.create_setup_intent(
|
|
customer_id=stripe_customer_id,
|
|
payment_method_types=payment_method_types
|
|
)
|
|
print(f"main result: {result}")
|
|
if result['success']:
|
|
# Add the publishable key for frontend
|
|
result['stripe_publishable_key'] = config.STRIPE_PUBLISHABLE_KEY
|
|
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="create_setup_intent",
|
|
entity_type="setup_intent",
|
|
entity_id=0,
|
|
details=f"Created setup intent for customer {stripe_customer_id} using Setup Intent: {result['setup_intent_id']}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
else:
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="create_setup_intent_failed",
|
|
entity_type="setup_intent",
|
|
details=f"Failed to create setup intent for customer {stripe_customer_id}: {result.get('error')}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
return jsonify(result)
|
|
|
|
except Exception as e:
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="create_setup_intent_error",
|
|
entity_type="setup_intent",
|
|
details=f"Setup intent creation error: {str(e)}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Setup intent creation failed: {str(e)}'
|
|
}), 500
|
|
|
|
@main_bp.route('/api/finalize-payment-method', methods=['POST'])
|
|
@login_required
|
|
def finalize_payment_method():
|
|
"""
|
|
Finalize payment method setup after Stripe confirmation.
|
|
"""
|
|
#try:
|
|
data = request.get_json()
|
|
setup_intent_id = data.get('setup_intent_id')
|
|
stripe_customer_id = data.get('stripe_customer_id')
|
|
#stripe_customer_id = "cus_SoQqMGLmCjiBDZ"
|
|
set_as_default = data.get('set_as_default', False)
|
|
splynx_id = data.get('splynx_id')
|
|
|
|
if not all([setup_intent_id, stripe_customer_id]):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'setup_intent_id and stripe_customer_id are required'
|
|
}), 400
|
|
|
|
# Initialize Stripe processor
|
|
config = Config()
|
|
processor = StripePaymentProcessor(api_key=config.STRIPE_SECRET_KEY)
|
|
|
|
# Check setup intent status
|
|
setup_result = processor.get_setup_intent_status(setup_intent_id)
|
|
print(f"setup_result: {setup_result}")
|
|
if not setup_result['success']:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Setup intent check failed: {setup_result.get("error")}'
|
|
}), 400
|
|
|
|
if setup_result['status'] != 'succeeded':
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Setup intent not succeeded. Status: {setup_result["status"]}'
|
|
}), 400
|
|
|
|
payment_method = setup_result.get('payment_method')
|
|
print(f"payment_method: {payment_method}")
|
|
if not payment_method:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'No payment method found in setup intent'
|
|
}), 400
|
|
|
|
# Attach payment method to customer (if not already attached)
|
|
attach_result = processor.attach_payment_method(
|
|
payment_method['id'],
|
|
stripe_customer_id
|
|
)
|
|
|
|
print(f"attach_result: {attach_result}")
|
|
|
|
if not attach_result['success']:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Failed to attach payment method: {attach_result.get("error")}'
|
|
}), 500
|
|
|
|
# Set as default if requested
|
|
if set_as_default:
|
|
default_result = processor.set_default_payment_method(
|
|
stripe_customer_id,
|
|
payment_method['id']
|
|
)
|
|
|
|
if not default_result['success']:
|
|
# Log warning but don't fail the request
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="set_default_payment_method_failed",
|
|
entity_type="payment_method",
|
|
entity_id=None,
|
|
details=f"Failed to set as default: {default_result.get('error')}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
# Log successful addition
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="add_payment_method",
|
|
entity_type="payment_method",
|
|
entity_id=payment_method['id'],
|
|
details=f"Added {payment_method['type']} payment method for customer {stripe_customer_id} (Splynx ID: {splynx_id}). Set as default: {set_as_default}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'payment_method': payment_method,
|
|
'is_default': set_as_default,
|
|
'setup_intent_id': setup_intent_id,
|
|
'customer_id': stripe_customer_id
|
|
})
|
|
|
|
#except Exception as e:
|
|
# log_activity(
|
|
# user_id=current_user.id,
|
|
# action="finalize_payment_method_error",
|
|
# entity_type="payment_method",
|
|
# details=f"Payment method finalization error: {str(e)}",
|
|
# ip_address=request.remote_addr
|
|
# )
|
|
# return jsonify({
|
|
# 'success': False,
|
|
# 'error': f'Payment method finalization failed: {str(e)}'
|
|
# }), 500
|
|
|
|
@main_bp.route('/api/get-payment-methods', methods=['POST'])
|
|
@login_required
|
|
def get_payment_methods_api():
|
|
"""
|
|
Get payment methods for a Stripe customer.
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
stripe_customer_id = data.get('stripe_customer_id')
|
|
|
|
if not stripe_customer_id:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'stripe_customer_id is required'
|
|
}), 400
|
|
|
|
# Initialize Stripe processor
|
|
config = Config()
|
|
processor = StripePaymentProcessor(api_key=config.STRIPE_SECRET_KEY)
|
|
|
|
# Get payment methods
|
|
payment_methods = processor.get_payment_methods(stripe_customer_id)
|
|
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="get_payment_methods",
|
|
entity_type="payment_method",
|
|
details=f"Retrieved {len(payment_methods)} payment methods for customer {stripe_customer_id}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
|
|
return jsonify(payment_methods)
|
|
|
|
except Exception as e:
|
|
log_activity(
|
|
user_id=current_user.id,
|
|
action="get_payment_methods_error",
|
|
entity_type="payment_method",
|
|
details=f"Get payment methods error: {str(e)}",
|
|
ip_address=request.remote_addr
|
|
)
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Failed to get payment methods: {str(e)}'
|
|
}), 500
|
|
|
|
|
|
|
|
@main_bp.route('/test')
|
|
@login_required
|
|
def test():
|
|
payment_data = {
|
|
'payment_id': 111,
|
|
'splynx_id': 31,
|
|
'amount': 11.11,
|
|
'error': 'payment_record.Error',
|
|
'payment_method': 'payment_method',
|
|
'customer_name': 'Alan',
|
|
'payment_type': 'single',
|
|
'stripe_customer_id': 'cus_31',
|
|
'payment_intent': 'pi_'
|
|
}
|
|
|
|
# Send notification and create ticket (only in live mode)
|
|
#if Config.PROCESS_LIVE:
|
|
# Send email notification
|
|
#email_sent = notification_service.send_payment_failure_notification(payment_data)
|
|
|
|
# Create Splynx ticket
|
|
ticket_result = splynx.create_ticket(
|
|
customer_id=31,
|
|
subject=f"Single Payment Failure - Customer 31 - $11.11",
|
|
message=f"""
|
|
Single payment processing has failed for customer Alan (ID: 31).
|
|
|
|
Payment Details:
|
|
- Payment ID: 12345 (single payment)
|
|
- Amount: $11.11 AUD
|
|
- Payment Method: pm_
|
|
- Stripe Customer: cus_31
|
|
- Payment Intent: pi_
|
|
- Processed by: Me
|
|
|
|
Error Information:
|
|
Some error
|
|
|
|
This ticket was automatically created by the Plutus Payment System.
|
|
""",
|
|
priority="medium"
|
|
)
|
|
|
|
print(f"Ticket created: {ticket_result.get('success', False)}")
|
|
return ticket_result
|