Browse Source

more more more

master
Alan Woodman 4 months ago
parent
commit
06c68e3f74
  1. 264
      blueprints/main.py
  2. 38
      migrations/versions/8929cc43ea50_more_refund_features.py
  3. 2
      models.py
  4. 18
      payments_fixup_find_customers_v2.py
  5. 686
      query_mysql-bak.py
  6. 168
      query_mysql.py
  7. 140
      stripe_payment_processor.py
  8. 23
      templates/main/batch_detail.html
  9. 116
      templates/main/payment_detail.html
  10. 121
      templates/main/single_payment_detail.html
  11. 29
      templates/main/single_payments_list.html
  12. 42
      test.py

264
blueprints/main.py

@ -10,6 +10,7 @@ from stripe_payment_processor import StripePaymentProcessor
from config import Config
from services import log_activity
import re
import time
splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET)
@ -216,7 +217,16 @@ def processPaymentResult(pay_id, result, key):
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")
#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"
@ -226,6 +236,28 @@ def find_pay_splynx_invoices(splynx_id):
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
@ -423,6 +455,9 @@ def single_payments_list():
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()
@ -633,6 +668,12 @@ def process_single_payment():
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')
@ -1122,8 +1163,7 @@ def process_single_payment_refund(payment_id):
}
)
# Update payment record
payment.Refund = True
# Update payment record based on refund status
payment.Refund_JSON = json.dumps(refund, default=str)
payment.Stripe_Refund_ID = refund.id
@ -1132,6 +1172,17 @@ def process_single_payment_refund(payment_id):
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
@ -1140,12 +1191,14 @@ def process_single_payment_refund(payment_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}"
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
})
@ -1156,6 +1209,135 @@ def process_single_payment_refund(payment_id):
print(f"Error processing single payment refund: {e}")
return jsonify({'success': False, 'error': 'Internal server error'}), 500
@main_bp.route('/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')
@login_required
def logs_list():
@ -1441,8 +1623,8 @@ def process_payment_refund(payment_id):
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
#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()
@ -1455,20 +1637,50 @@ def process_payment_refund(payment_id):
stripe.api_key = Config.STRIPE_TEST_API_KEY
# Create refund parameters
refund_params = {
'charge': payment.Stripe_Charge_ID,
'reason': reason
}
#refund_params = {
# 'charge': payment.Stripe_Charge_ID,
# 'reason': reason
#}
# Process the refund with Stripe
refund = stripe.Refund.create(**refund_params)
#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'] == "succeeded":
if refund['status'] in ["succeeded", "pending"]:
# Update payment record with refund information
payment.Refund = True
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()
@ -1480,14 +1692,24 @@ def process_payment_refund(payment_id):
entity_id=payment_id,
details=f"Processed refund {refund.id} for payment {payment_id}, amount: ${refund.amount/100:.2f}"
)
return jsonify({
'success': True,
'refund_id': refund.id,
'amount_refunded': f"${refund.amount/100:.2f}",
'status': refund.status,
'message': 'Refund processed successfully'
})
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

38
migrations/versions/8929cc43ea50_more_refund_features.py

@ -0,0 +1,38 @@
"""More Refund features
Revision ID: 8929cc43ea50
Revises: 1af0e892bd5d
Create Date: 2025-08-27 18:50:27.562565
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8929cc43ea50'
down_revision = '1af0e892bd5d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('Payments', schema=None) as batch_op:
batch_op.add_column(sa.Column('Refund_FollowUp', sa.Boolean(), nullable=True))
with op.batch_alter_table('SinglePayments', schema=None) as batch_op:
batch_op.add_column(sa.Column('Refund_FollowUp', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('SinglePayments', schema=None) as batch_op:
batch_op.drop_column('Refund_FollowUp')
with op.batch_alter_table('Payments', schema=None) as batch_op:
batch_op.drop_column('Refund_FollowUp')
# ### end Alembic commands ###

2
models.py

@ -54,6 +54,7 @@ class Payments(db.Model):
Error = db.Column(db.Text())
Success = db.Column(db.Boolean, nullable=True, default=None)
Refund = db.Column(db.Boolean, nullable=True, default=None)
Refund_FollowUp = db.Column(db.Boolean, nullable=True, default=None)
Refund_JSON = db.Column(db.Text())
Stripe_Refund_ID = db.Column(db.String())
Stripe_Refund_Created = db.Column(db.DateTime, nullable=True)
@ -80,6 +81,7 @@ class SinglePayments(db.Model):
Error = db.Column(db.Text())
Success = db.Column(db.Boolean, nullable=True, default=None)
Refund = db.Column(db.Boolean, nullable=True, default=None)
Refund_FollowUp = db.Column(db.Boolean, nullable=True, default=None)
Refund_JSON = db.Column(db.Text())
Stripe_Refund_ID = db.Column(db.String())
Stripe_Refund_Created = db.Column(db.DateTime, nullable=True)

18
payments_fixup_find_customers_v2.py

@ -194,17 +194,17 @@ if __name__ == "__main__":
for payment in first_duplicates:
if payment.Stripe_Charge_ID:
print(f"Safe to refund - Payment ID: {payment.id}, Customer: {payment.Stripe_Customer_ID}, Charge: {payment.Stripe_Charge_ID}")
result = issue_refund_for_payment(
payment,
reason='duplicate'
)
#result = issue_refund_for_payment(
# payment,
# reason='duplicate'
#)
results.append(result)
#results.append(result)
if result['success']:
print(f"✓ Refund successful: {result['refund_id']}")
else:
print(f"✗ Refund failed: {result['error']}")
#if result['success']:
# print(f"✓ Refund successful: {result['refund_id']}")
#else:
# print(f"✗ Refund failed: {result['error']}")
else:
print(f"Skipping Payment ID {payment.id} - No Stripe Charge ID")

686
query_mysql-bak.py

@ -0,0 +1,686 @@
#!/usr/bin/env python3
"""
External script to query MySQL database (Splynx) for customer billing data.
This script runs independently of the Flask application.
Usage: python query_mysql.py
"""
import pymysql
import sys
import json
import random
import threading
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import List, Dict, Union, Any
from stripe_payment_processor import StripePaymentProcessor
from config import Config
from app import create_app, db
from models import Payments, PaymentBatch, SinglePayments, PaymentPlans
from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET
from services import (
log_script_start, log_script_completion, log_batch_created,
log_payment_intent_followup
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('payment_processing.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# Initialize Splynx API
splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET)
# Import constants from config
PAYMENT_METHOD_DIRECT_DEBIT = Config.PAYMENT_METHOD_DIRECT_DEBIT
PAYMENT_METHOD_CARD = Config.PAYMENT_METHOD_CARD
PAYMENT_METHOD_PAYMENT_PLAN = Config.PAYMENT_METHOD_PAYMENT_PLAN
PROCESS_LIVE = Config.PROCESS_LIVE
# Get Stripe API key from config
if PROCESS_LIVE:
api_key = Config.STRIPE_LIVE_API_KEY
else:
api_key = Config.STRIPE_TEST_API_KEY
test_stripe_customers = ['cus_SoQqMGLmCjiBDZ', 'cus_SoQptxwe8hczGz', 'cus_SoQjeNXkKOdORI', 'cus_SoQiDcSrNRxbPF', 'cus_SoQedaG3q2ecKG', 'cus_SoQeTkzMA7AaLR', 'cus_SoQeijBTETQcGb', 'cus_SoQe259iKMgz7o', 'cus_SoQejTstdXEDTO', 'cus_SoQeQH2ORWBOWX', 'cus_SoQevtyWxqXtpC', 'cus_SoQekOFUHugf26', 'cus_SoPq6Zh0MCUR9W', 'cus_SoPovwUPJmvugz', 'cus_SoPnvGfejhpSR5', 'cus_SoNAgAbkbFo8ZY', 'cus_SoMyDihTxRsa7U', 'cus_SoMVPWxdYstYbr', 'cus_SoMVQ6Xj2dIrCR', 'cus_SoMVmBn1xipFEB', 'cus_SoMVNvZ2Iawb7Y', 'cus_SoMVZupj6wRy5e', 'cus_SoMVqjH7zkc5Qe', 'cus_SoMVkzj0ZUK0Ai', 'cus_SoMVFq3BUD3Njw', 'cus_SoLcrRrvoy9dJ4', 'cus_SoLcqHN1k0WD8j', 'cus_SoLcLtYDZGG32V', 'cus_SoLcG23ilNeMYt', 'cus_SoLcFhtUVzqumj', 'cus_SoLcPgMnuogINl', 'cus_SoLccGTY9mMV7T', 'cus_SoLRxqvJxuKFes', 'cus_SoKs7cjdcvW1oO']
def find_pay_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]:
result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid")
invoice_pay = {
"status": "paid"
}
updated_invoices = []
for pay in result:
res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay)
if res:
updated_invoices.append(res)
return updated_invoices
def find_set_pending_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]:
result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid")
invoice_pay = {
"status": "pending"
}
updated_invoices = []
for pay in result:
res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay)
if res:
updated_invoices.append(res)
return updated_invoices
def add_payment_splynx(splynx_id: int, pi_id: str, pay_id: int, amount: float) -> Union[int, bool]:
stripe_pay = {
"customer_id": splynx_id,
"amount": amount,
"date": str(datetime.now().strftime('%Y-%m-%d')),
"field_1": pi_id,
"field_2": f"Payment_ID (Batch): {pay_id}"
}
res = splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay)
if res:
return res['id']
else:
return False
def handle_database_operation(operation_func: callable, operation_name: str) -> Any:
"""
Reusable function to handle database operations with consistent error handling.
Args:
operation_func: Function that performs the database operation
operation_name: String description of the operation for error messages
Returns:
Result of operation_func or None if failed
"""
try:
result = operation_func()
db.session.commit()
return result
except Exception as e:
db.session.rollback()
logger.error(f"{operation_name} failed: {e}")
return None
def is_payment_day(start_date_string: str, payplan_schedule: str, date_format: str = "%Y-%m-%d") -> bool:
"""
Check if today is a payment day based on a start date and frequency.
Args:
start_date_string (str): The first payment date
payplan_schedule (str): Payment frequency ("Weekly" or "Fortnightly")
date_format (str): Format of the date string
Returns:
bool: True if today is a payment day, False otherwise
"""
try:
if not start_date_string or not payplan_schedule:
logger.error("Missing required parameters for payment day calculation")
return False
if payplan_schedule == "Weekly":
num_days = 7
elif payplan_schedule == "Fortnightly":
num_days = 14
else:
logger.error(f"Unsupported payment schedule '{payplan_schedule}'")
return False
start_date = datetime.strptime(start_date_string, date_format)
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
# Calculate days since start date
days_since_start = (today - start_date).days
# Check if it's a multiple of the payment frequency
return days_since_start >= 0 and days_since_start % num_days == 0
except ValueError as e:
logger.error(f"Error parsing date '{start_date_string}' with format '{date_format}': {e}")
return False
except Exception as e:
logger.error(f"Unexpected error in is_payment_day: {e}")
return False
def query_payplan_customers() -> List[Dict[str, Any]]:
"""Query customer billing data from MySQL database and find Payment Plan customers."""
to_return = []
customers = db.session.query(PaymentPlans).filter(PaymentPlans.Enabled == True).all()
for cust in customers:
if cust.Start_Date and is_payment_day(start_date_string=str(cust.Start_Date.strftime('%Y-%m-%d')), payplan_schedule=cust.Frequency):
payment_data = {
"customer_id": cust.Splynx_ID,
"stripe_customer_id": cust.Stripe_Customer_ID,
"deposit": cust.Amount*-1,
"stripe_pm": cust.Stripe_Payment_Method,
"paymentplan_id": cust.id
}
to_return.append(payment_data)
return to_return
def query_splynx_customers(pm: int) -> Union[List[Dict[str, Any]], bool]:
"""Query customer billing data from MySQL database."""
connection = None
try:
# Connect to MySQL database
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 # Return results as dictionaries
)
logger.info("Connected to MySQL database successfully")
logger.info(f"Database: {Config.MYSQL_CONFIG['database']} on {Config.MYSQL_CONFIG['host']}")
logger.info("-" * 80)
## Payment Method:
## 2 - Direct Debit (Automatic)
## 3 - Card Payment (Automatic)
## 9 - Payment Plan
# Execute the query with DISTINCT to prevent duplicate customers
query = """
SELECT DISTINCT
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.payment_method = %s
AND cb.deposit < %s
AND pad.field_1 IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM invoices i
WHERE i.customer_id = cb.customer_id
AND i.status = 'pending'
)
GROUP BY cb.customer_id, cb.deposit, cb.payment_method, pad.field_1
ORDER BY cb.payment_method ASC, cb.customer_id ASC
LIMIT %s
"""
with connection.cursor() as cursor:
cursor.execute(query, (pm, Config.DEPOSIT_THRESHOLD, Config.DEFAULT_QUERY_LIMIT))
results = cursor.fetchall()
if results:
logger.info(f"Found {len(results)} rows")
return results
else:
logger.info("No rows found matching the criteria")
return False
except pymysql.Error as e:
logger.error(f"MySQL Error: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"Unexpected Error: {e}")
sys.exit(1)
finally:
if connection:
connection.close()
logger.info("MySQL connection closed")
def addInitialPayments(customers, batch_id):
added = {"added": 0, "failed": 0}
payments_to_add = []
# Prepare all payments first
for cust in customers:
if PROCESS_LIVE:
stripe_customer_id = cust['stripe_customer_id']
else:
stripe_customer_id = test_stripe_customers[random.randint(1, len(test_stripe_customers)-1)]
add_payer = Payments(
PaymentBatch_ID = batch_id,
Splynx_ID = cust['customer_id'],
Stripe_Customer_ID = stripe_customer_id,
Payment_Amount = float(cust['deposit'])*-1,
Stripe_Payment_Method = cust.get('stripe_pm', None),
PaymentPlan_ID = cust.get('paymentplan_id', None)
)
payments_to_add.append(add_payer)
db.session.add(add_payer)
# Atomic commit for entire batch
try:
db.session.commit()
added["added"] = len(payments_to_add)
logger.info(f"Successfully added {len(payments_to_add)} payments to batch {batch_id}")
except Exception as e:
db.session.rollback()
added["failed"] = len(payments_to_add)
logger.error(f"addInitialPayments failed for entire batch {batch_id}: {e}")
logger.info(f"Database operation result: {json.dumps(added,indent=2)}")
def addPaymentBatch():
"""Create a new payment batch and return its ID."""
add_batch = PaymentBatch()
try:
db.session.add(add_batch)
db.session.commit()
return add_batch.id
except Exception as e:
db.session.rollback()
logger.error(f"addPaymentBatch failed: {e}")
return None
def processPaymentResult(pay_id, result, key):
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 PROCESS_LIVE:
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:
logger.error(f"processPaymentResult: {e}\nResult: {json.dumps(result)}")
payment.PI_FollowUp = True
if PROCESS_LIVE:
find_set_pending_splynx_invoices(payment.Splynx_ID)
def _update_payment():
return True # Just need to trigger commit, payment is already modified
handle_database_operation(_update_payment, "processPaymentResult")
# Thread lock for database operations
db_lock = threading.Lock()
def process_single_payment(processor, payment_data):
"""
Thread-safe function to process a single payment.
Args:
processor: StripePaymentProcessor instance
payment_data: Dict containing payment information
Returns:
Dict with payment result and metadata
"""
try:
# Process payment with Stripe (thread-safe)
result = processor.process_payment(
customer_id=payment_data['customer_id'],
amount=payment_data['amount'],
currency=payment_data['currency'],
description=payment_data['description'],
stripe_pm=payment_data['stripe_pm']
)
# Return result with payment ID for database update
return {
'payment_id': payment_data['payment_id'],
'result': result,
'success': True
}
except Exception as e:
logger.error(f"Payment processing failed for payment ID {payment_data['payment_id']}: {e}")
return {
'payment_id': payment_data['payment_id'],
'result': None,
'success': False,
'error': str(e)
}
def update_single_payment_result(payment_id, result):
"""
Thread-safe immediate update of single payment result to database.
Commits immediately to ensure data safety.
Args:
payment_id: ID of the payment to update
result: Payment processing result
"""
with db_lock:
try:
if result:
processPaymentResult(pay_id=payment_id, result=result, key="pay")
logger.info(f"Payment {payment_id} result committed to database")
else:
logger.warning(f"No result to commit for payment {payment_id}")
except Exception as e:
logger.error(f"Failed to update payment {payment_id}: {e}")
def process_batch_mode(processor):
"""Handle batch processing for Direct Debit and Card payments."""
to_run_batches = []
payment_methods = [PAYMENT_METHOD_DIRECT_DEBIT, PAYMENT_METHOD_CARD]
total_customers = 0
payment_method_names = {
PAYMENT_METHOD_DIRECT_DEBIT: "Direct Debit",
PAYMENT_METHOD_CARD: "Card Payment"
}
for pm in payment_methods:
batch_id = addPaymentBatch()
if batch_id is not None:
to_run_batches.append(batch_id)
customers = query_splynx_customers(pm)
if customers:
customer_count = len(customers)
total_customers += customer_count
addInitialPayments(customers=customers, batch_id=batch_id)
# Log batch creation
log_batch_created(batch_id, payment_method_names[pm], customer_count)
logger.info(f"Created batch {batch_id} for {payment_method_names[pm]} with {customer_count} customers")
else:
logger.info(f"No customers found for {payment_method_names[pm]}")
else:
logger.error(f"Failed to create batch for payment method {pm}")
return to_run_batches, 0, 0, 0.0 # Success/failed counts will be updated during execution
def process_payplan_mode(processor):
"""Handle payment plan processing."""
to_run_batches = []
# Get count of active payment plans for logging (if needed in future)
batch_id = addPaymentBatch()
if batch_id is not None:
to_run_batches.append(batch_id)
customers = query_payplan_customers()
due_plans_count = len(customers) if customers else 0
if customers:
total_amount = sum(abs(c.get('deposit', 0)) for c in customers)
addInitialPayments(customers=customers, batch_id=batch_id)
# Log batch creation for payment plans
log_batch_created(batch_id, "Payment Plan", due_plans_count)
logger.info(f"Created payment plan batch {batch_id} with {due_plans_count} due plans (${total_amount:,.2f} total)")
else:
logger.info("No payment plans due for processing today")
total_amount = 0.0
else:
logger.error("Failed to create batch for payment plan processing")
due_plans_count = 0
total_amount = 0.0
return to_run_batches, 0, 0, total_amount # Success/failed counts will be updated during execution
def execute_payment_batches(processor, batch_ids):
"""Execute payments for all provided batch IDs using safe threading with immediate commits."""
if not batch_ids:
logger.warning("No valid batches to process")
return
max_threads = Config.MAX_PAYMENT_THREADS
for batch in batch_ids:
if batch is None:
logger.warning("Skipping None batch ID")
continue
cust_pay = db.session.query(Payments).filter(Payments.PaymentBatch_ID == batch).all()
if not cust_pay:
logger.info(f"No payments found for batch {batch}")
continue
logger.info(f"Processing {len(cust_pay)} payments in batch {batch} using {max_threads} threads")
logger.info("Safety Mode: Each payment will be committed immediately to database")
# Process payments in smaller chunks to avoid timeout issues
processed_count = 0
failed_count = 0
# Process payments in chunks
chunk_size = max_threads * 2 # Process 2x thread count at a time
for i in range(0, len(cust_pay), chunk_size):
chunk = cust_pay[i:i + chunk_size]
logger.info(f"Processing chunk {i//chunk_size + 1}: payments {i+1}-{min(i+chunk_size, len(cust_pay))}")
# Prepare payment data for this chunk
payment_tasks = []
for pay in chunk:
if PROCESS_LIVE:
customer_id = pay.Stripe_Customer_ID
else:
customer_id = pay.Stripe_Customer_ID
payment_data = {
'payment_id': pay.id,
'customer_id': customer_id,
'amount': pay.Payment_Amount,
'currency': "aud",
'description': f"Payment ID: {pay.id} - Splynx ID: {pay.Splynx_ID}",
'stripe_pm': pay.Stripe_Payment_Method
}
logger.debug(f"payment_data: {json.dumps(payment_data,indent=2)}")
payment_tasks.append(payment_data)
# Process this chunk with ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=max_threads) as executor:
# Submit tasks for this chunk
future_to_payment = {
executor.submit(process_single_payment, processor, task): task
for task in payment_tasks
}
# Process results as they complete (NO TIMEOUT on as_completed)
for future in as_completed(future_to_payment):
try:
result = future.result(timeout=60) # Individual payment timeout
if result['success'] and result['result']:
# IMMEDIATELY commit each successful payment to database
update_single_payment_result(result['payment_id'], result['result'])
processed_count += 1
logger.info(f"Payment {result['payment_id']} processed and committed ({processed_count}/{len(cust_pay)})")
else:
failed_count += 1
logger.warning(f"Payment {result['payment_id']} failed ({failed_count} failures total)")
except Exception as e:
payment_data = future_to_payment[future]
failed_count += 1
logger.error(f"Thread exception for payment {payment_data['payment_id']}: {e}")
logger.info(f"Chunk completed: {processed_count} processed, {failed_count} failed")
logger.info(f"Batch {batch} completed: {processed_count}/{len(cust_pay)} payments processed successfully")
def process_payintent_mode(processor):
"""Handle payment intent follow-up processing."""
to_check = {
"pay": db.session.query(Payments).filter(Payments.PI_FollowUp == True).all(),
"singlepay": db.session.query(SinglePayments).filter(SinglePayments.PI_FollowUp == True).all(),
}
total_pending = 0
succeeded_count = 0
failed_count = 0
still_pending = 0
for key, value in to_check.items():
logger.debug(f"Processing payment intent follow-up for {len(value)} {key} items")
total_pending += len(value)
for pi in value:
try:
intent_result = processor.check_payment_intent(pi.Payment_Intent)
logger.debug(f"Intent result: {json.dumps(intent_result, indent=2)}")
if intent_result['status'] == "succeeded":
pi.PI_FollowUp_JSON = json.dumps(intent_result)
pi.PI_FollowUp = False
pi.PI_Last_Check = datetime.now()
pi.Success = True
#if intent_result.get('charge_id').startswith('ch_'):
# pi.Stripe_Charge_ID = intent_result.get('charge_id')
processPaymentResult(pay_id=pi.id, result=intent_result, key=key)
succeeded_count += 1
elif intent_result['status'] == "failed":
pi.PI_FollowUp_JSON = json.dumps(intent_result)
pi.PI_FollowUp = False
pi.PI_Last_Check = datetime.now()
failed_count += 1
else:
# Still pending
pi.PI_FollowUp_JSON = json.dumps(intent_result)
pi.PI_Last_Check = datetime.now()
if intent_result.get('failure_reason'):
processPaymentResult(pay_id=pi.id, result=intent_result, key=key)
pi.PI_FollowUp = False
pi.Error = json.dumps(intent_result)
failed_count += 1
else:
still_pending += 1
db.session.commit()
except Exception as e:
logger.error(f"Error processing payment intent {pi.Payment_Intent}: {e}")
failed_count += 1
# Log payment intent follow-up results
if total_pending > 0:
log_payment_intent_followup(total_pending, succeeded_count, failed_count, still_pending)
logger.info(f"Payment intent follow-up completed: {succeeded_count} succeeded, {failed_count} failed, {still_pending} still pending")
else:
logger.info("No payment intents requiring follow-up")
return succeeded_count, failed_count
if __name__ == "__main__":
## Payment Method:
## 2 - Direct Debit (Automatic)
## 3 - Card Payment (Automatic)
## 9 - Payment Plan
### Running Mode
## batch = Monthly Direct Debit/Credit Cards
## payintent = Check outstanding Payment Intents and update
## payplan = Check for Payment Plans to run
start_time = datetime.now()
success_count = 0
failed_count = 0
total_amount = 0.0
batch_ids = []
errors = []
try:
if sys.argv[1] == "batch":
running_mode = "batch"
elif sys.argv[1] == "payintent":
running_mode = "payintent"
elif sys.argv[1] == "payplan":
running_mode = "payplan"
else:
logger.error(f"Invalid running mode: {sys.argv[1]}")
logger.info("Valid modes: batch, payintent, payplan")
sys.exit(1)
try:
if sys.argv[2] == "live":
PROCESS_LIVE = True
except IndexError:
logger.info("Processing payments against Sandbox")
except IndexError:
logger.info("No running mode specified, defaulting to 'payintent'")
running_mode = "payintent"
# Create Flask application context
app = create_app()
print(f"api_key: {api_key}")
processor = StripePaymentProcessor(api_key=api_key, enable_logging=True)
with app.app_context():
# Log script start
environment = "live" if PROCESS_LIVE else "sandbox"
log_script_start("query_mysql.py", running_mode, environment)
logger.info(f"Starting query_mysql.py in {running_mode} mode ({environment})")
try:
if running_mode == "batch":
batch_ids, success_count, failed_count, total_amount = process_batch_mode(processor)
execute_payment_batches(processor, batch_ids)
elif running_mode == "payplan":
batch_ids, success_count, failed_count, total_amount = process_payplan_mode(processor)
execute_payment_batches(processor, batch_ids)
elif running_mode == "payintent":
success_count, failed_count = process_payintent_mode(processor)
except Exception as e:
logger.error(f"Script execution failed: {e}")
errors.append(str(e))
failed_count += 1
# Calculate execution time and log completion
end_time = datetime.now()
duration_seconds = (end_time - start_time).total_seconds()
log_script_completion(
script_name="query_mysql.py",
mode=running_mode,
success_count=success_count,
failed_count=failed_count,
total_amount=total_amount,
batch_ids=batch_ids if batch_ids else None,
duration_seconds=duration_seconds,
errors=errors if errors else None
)
logger.info(f"Script completed in {duration_seconds:.1f}s: {success_count} successful, {failed_count} failed")

168
query_mysql.py

@ -3,7 +3,8 @@
External script to query MySQL database (Splynx) for customer billing data.
This script runs independently of the Flask application.
Usage: python query_mysql.py
Usage: python query_mysql.py [mode] [live]
Modes: batch, payintent, payplan, refund
"""
import pymysql
@ -67,6 +68,56 @@ def find_pay_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]:
updated_invoices.append(res)
return updated_invoices
def find_set_pending_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]:
result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid")
invoice_pay = {
"status": "pending"
}
updated_invoices = []
for pay in result:
res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay)
if res:
updated_invoices.append(res)
return updated_invoices
def delete_splynx_invoices(splynx_id: int, payintent: str) -> Dict[str, Any]:
"""Delete Splynx payment records for a given customer and payment intent."""
try:
params = {
'main_attributes': {
'customer_id': splynx_id,
'field_1': payintent
},
}
query_string = splynx.build_splynx_query_params(params)
result = splynx.get(url=f"/api/2.0/admin/finance/payments?{query_string}")
if not result:
logger.warning(f"No Splynx payment found for customer {splynx_id}, payment intent {payintent}")
return {'success': False, 'error': 'No payment found to delete'}
logger.info(f"Found {len(result)} Splynx payment(s) to delete for customer {splynx_id}")
delete_success = splynx.delete(url=f"/api/2.0/admin/finance/payments/{result[0]['id']}")
if delete_success:
logger.info(f"Successfully deleted Splynx Payment ID: {result[0]['id']} for customer: {splynx_id}")
return {
'success': True,
'deleted_payment_id': result[0]['id'],
'customer_id': splynx_id,
'payment_intent': payintent
}
else:
logger.error(f"Failed to delete Splynx Payment ID: {result[0]['id']} for customer: {splynx_id}")
return {'success': False, 'error': 'Delete operation failed'}
except Exception as e:
logger.error(f"Error deleting Splynx payment for customer {splynx_id}: {e}")
return {'success': False, 'error': str(e)}
def add_payment_splynx(splynx_id: int, pi_id: str, pay_id: int, amount: float) -> Union[int, bool]:
stripe_pay = {
"customer_id": splynx_id,
@ -294,6 +345,9 @@ def processPaymentResult(pay_id, result, key):
else:
if result.get('needs_fee_update'):
payment.PI_FollowUp = True
# Mark invoices as pending when PI_FollowUp is set
if PROCESS_LIVE:
find_set_pending_splynx_invoices(payment.Splynx_ID)
payment.Payment_Intent = result['payment_intent_id']
payment.Success = result['success']
if result['success'] and PROCESS_LIVE:
@ -323,6 +377,8 @@ def processPaymentResult(pay_id, result, key):
except Exception as e:
logger.error(f"processPaymentResult: {e}\nResult: {json.dumps(result)}")
payment.PI_FollowUp = True
if PROCESS_LIVE:
find_set_pending_splynx_invoices(payment.Splynx_ID)
def _update_payment():
return True # Just need to trigger commit, payment is already modified
@ -554,6 +610,11 @@ def process_payintent_mode(processor):
pi.PI_FollowUp = False
pi.PI_Last_Check = datetime.now()
pi.Success = True
# Mark invoices as paid when payment intent succeeds
if PROCESS_LIVE:
find_pay_splynx_invoices(pi.Splynx_ID)
#if intent_result.get('charge_id').startswith('ch_'):
# pi.Stripe_Charge_ID = intent_result.get('charge_id')
processPaymentResult(pay_id=pi.id, result=intent_result, key=key)
@ -589,6 +650,104 @@ def process_payintent_mode(processor):
return succeeded_count, failed_count
def process_refund_followup_mode(processor):
"""Handle refund follow-up processing for pending refunds."""
to_check = {
"pay": db.session.query(Payments).filter(Payments.Refund_FollowUp == True).all(),
"singlepay": db.session.query(SinglePayments).filter(SinglePayments.Refund_FollowUp == True).all(),
}
total_pending = 0
completed_count = 0
failed_count = 0
still_pending = 0
for key, value in to_check.items():
logger.debug(f"Processing refund follow-up for {len(value)} {key} items")
total_pending += len(value)
for refund_record in value:
try:
if not refund_record.Stripe_Refund_ID:
logger.error(f"No Stripe refund ID found for {key} record {refund_record.id}")
failed_count += 1
continue
print(f"refund_record.Stripe_Refund_ID: {refund_record.Stripe_Refund_ID}")
refund_result = processor.check_refund_status(refund_record.Stripe_Refund_ID)
logger.debug(f"Refund result: {json.dumps(refund_result, indent=2)}")
# Check if the API call was successful
if not refund_result.get('success', False):
logger.error(f"Failed to check refund status: {refund_result.get('error', 'Unknown error')}")
failed_count += 1
continue
if refund_result['status'] == "succeeded":
# Refund completed successfully
refund_record.Refund = True
refund_record.Refund_FollowUp = False
refund_record.Refund_JSON = json.dumps(refund_result)
# Delete associated Splynx payment record if in live mode
if PROCESS_LIVE and refund_record.Payment_Intent:
delete_result = delete_splynx_invoices(
splynx_id=refund_record.Splynx_ID,
payintent=refund_record.Payment_Intent
)
if delete_result.get('success'):
logger.info(f"Deleted Splynx payment for refund completion: customer {refund_record.Splynx_ID}")
else:
logger.warning(f"Failed to delete Splynx payment: {delete_result.get('error')}")
completed_count += 1
logger.info(f"✅ Refund completed: {refund_record.Stripe_Refund_ID}")
elif refund_result['status'] in ["failed", "canceled"]:
# Refund failed
refund_record.Refund_FollowUp = False
refund_record.Refund_JSON = json.dumps(refund_result)
failed_count += 1
logger.warning(f"❌ Refund failed: {refund_record.Stripe_Refund_ID} - {refund_result['status']}")
elif refund_result['status'] == "pending":
# Still pending - update JSON but keep follow-up flag
refund_record.Refund_JSON = json.dumps(refund_result)
still_pending += 1
logger.info(f"⏳ Refund still pending: {refund_record.Stripe_Refund_ID}")
else:
# Unknown status
refund_record.Refund_JSON = json.dumps(refund_result)
still_pending += 1
logger.warning(f"⚠️ Unknown refund status: {refund_record.Stripe_Refund_ID} - {refund_result['status']}")
db.session.commit()
except Exception as e:
logger.error(f"Error processing refund {refund_record.Stripe_Refund_ID}: {e}")
failed_count += 1
# Log refund follow-up results
if total_pending > 0:
logger.info(f"Refund follow-up completed: {completed_count} completed, {failed_count} failed, {still_pending} still pending")
# Log the activity for tracking
from services import log_activity
try:
log_activity(
user_id=1, # System user
action="refund_followup",
entity_type="script",
entity_id=None,
details=f"Processed {total_pending} pending refunds: {completed_count} completed, {failed_count} failed, {still_pending} still pending"
)
except Exception as log_error:
logger.error(f"Failed to log refund follow-up activity: {log_error}")
else:
logger.info("No refunds requiring follow-up")
return completed_count, failed_count
if __name__ == "__main__":
## Payment Method:
## 2 - Direct Debit (Automatic)
@ -599,6 +758,7 @@ if __name__ == "__main__":
## batch = Monthly Direct Debit/Credit Cards
## payintent = Check outstanding Payment Intents and update
## payplan = Check for Payment Plans to run
## refund = Check outstanding Refunds and update
start_time = datetime.now()
success_count = 0
@ -614,9 +774,11 @@ if __name__ == "__main__":
running_mode = "payintent"
elif sys.argv[1] == "payplan":
running_mode = "payplan"
elif sys.argv[1] == "refund":
running_mode = "refund"
else:
logger.error(f"Invalid running mode: {sys.argv[1]}")
logger.info("Valid modes: batch, payintent, payplan")
logger.info("Valid modes: batch, payintent, payplan, refund")
sys.exit(1)
try:
if sys.argv[2] == "live":
@ -647,6 +809,8 @@ if __name__ == "__main__":
execute_payment_batches(processor, batch_ids)
elif running_mode == "payintent":
success_count, failed_count = process_payintent_mode(processor)
elif running_mode == "refund":
success_count, failed_count = process_refund_followup_mode(processor)
except Exception as e:
logger.error(f"Script execution failed: {e}")
errors.append(str(e))

140
stripe_payment_processor.py

@ -804,6 +804,146 @@ class StripePaymentProcessor:
'timestamp': datetime.now().isoformat()
}
def check_refund_status(self, refund_id: str) -> Dict[str, Any]:
"""
Check the status and details of a specific refund.
Args:
refund_id (str): Stripe Refund ID (e.g., 'pyr_1234567890')
Returns:
dict: Refund status and comprehensive details
"""
try:
# Validate input
if not refund_id or not isinstance(refund_id, str):
return {
'success': False,
'error': 'Invalid refund_id provided',
'error_type': 'validation_error',
'timestamp': datetime.now().isoformat()
}
if not refund_id.startswith('pyr_') and not refund_id.startswith('re_'):
return {
'success': False,
'error': 'Refund ID must start with "pyr_" or "re_"',
'error_type': 'validation_error',
'timestamp': datetime.now().isoformat()
}
self._log('info', f"Checking refund status: {refund_id}")
# Retrieve the refund with expanded balance transaction for fee details
refund = stripe.Refund.retrieve(
refund_id,
expand=['balance_transaction']
)
print(f"refund: {refund}")
self._log('info', f"Retrieved refund with expanded data")
# Base response
response = {
'success': True,
'refund_id': refund.id,
'status': refund.status,
'amount': refund.amount / 100, # Convert from cents to dollars
'currency': refund.currency.upper(),
'reason': refund.reason,
'failure_reason': getattr(refund, 'failure_reason', None),
'charge_id': refund.charge,
'payment_intent_id': refund.payment_intent,
'created': datetime.fromtimestamp(refund.created).isoformat(),
'timestamp': datetime.now().isoformat()
}
# Add metadata if present
if refund.metadata:
response['metadata'] = dict(refund.metadata)
# Add balance transaction details if available
if hasattr(refund, 'balance_transaction') and refund.balance_transaction:
balance_txn = refund.balance_transaction
response['balance_transaction'] = {
'id': balance_txn.id,
'net': balance_txn.net / 100, # Convert from cents
'fee': balance_txn.fee / 100, # Convert from cents
'available_on': datetime.fromtimestamp(balance_txn.available_on).isoformat() if balance_txn.available_on else None
}
# Add fee details if available
if hasattr(balance_txn, 'fee_details') and balance_txn.fee_details:
response['fee_details'] = []
for fee_detail in balance_txn.fee_details:
response['fee_details'].append({
'type': fee_detail.type,
'amount': fee_detail.amount / 100, # Convert from cents
'currency': fee_detail.currency.upper(),
'description': fee_detail.description
})
# Add receipt details if available
if hasattr(refund, 'receipt_number') and refund.receipt_number:
response['receipt_number'] = refund.receipt_number
# Determine if refund is complete
response['is_complete'] = refund.status == 'succeeded'
response['is_failed'] = refund.status in ['failed', 'canceled']
response['is_pending'] = refund.status == 'pending'
# Add pending reason if applicable
if hasattr(refund, 'pending_reason') and refund.pending_reason:
response['pending_reason'] = refund.pending_reason
self._log('info', f"✅ Refund status check successful: {refund_id} - {refund.status}")
return response
except stripe.InvalidRequestError as e:
return {
'success': False,
'error': f'Invalid request: {str(e)}',
'error_type': 'invalid_request_error',
'refund_id': refund_id,
'timestamp': datetime.now().isoformat()
}
except stripe.AuthenticationError as e:
return {
'success': False,
'error': f'Authentication failed: {str(e)}',
'error_type': 'authentication_error',
'refund_id': refund_id,
'timestamp': datetime.now().isoformat()
}
except stripe.PermissionError as e:
return {
'success': False,
'error': f'Permission denied: {str(e)}',
'error_type': 'permission_error',
'refund_id': refund_id,
'timestamp': datetime.now().isoformat()
}
except stripe.StripeError as e:
return {
'success': False,
'error': f'Stripe error: {str(e)}',
'error_type': 'stripe_error',
'refund_id': refund_id,
'timestamp': datetime.now().isoformat()
}
except Exception as e:
return {
'success': False,
'error': f'Unexpected error: {str(e)}',
'error_type': 'unexpected_error',
'refund_id': refund_id,
'timestamp': datetime.now().isoformat()
}
def wait_for_payment_completion(self, payment_intent_id: str, max_wait_seconds: int = 60,
check_interval: int = 5, customer_id: Optional[str] = None) -> Dict[str, Any]:
"""

23
templates/main/batch_detail.html

@ -188,7 +188,11 @@
<tbody id="paymentsTableBody">
{% for payment in payments %}
{% set row_class = '' %}
{% if payment.Success == True %}
{% if payment.Refund == True %}
{% set row_class = 'has-background-light' %}
{% elif payment.Refund_FollowUp == True %}
{% set row_class = 'has-background-warning-light' %}
{% elif payment.Success == True %}
{% set row_class = 'has-background-success-light' %}
{% elif payment.Success == False and payment.PI_FollowUp %}
{% set row_class = 'has-background-warning-light' %}
@ -216,7 +220,11 @@
{% endif %}
</td>
<td>
{% if payment.Success == True %}
{% if payment.Refund == True %}
<code class="is-small" style="background-color: #9370db; color: white;">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Refund_FollowUp == True %}
<code class="is-small has-background-warning has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code>
@ -229,7 +237,11 @@
{% endif %}
</td>
<td>
{% if payment.Success == True %}
{% if payment.Refund == True %}
<code class="is-small" style="background-color: #9370db; color: white;">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Refund_FollowUp == True %}
<code class="is-small has-background-warning has-text-white">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-black">{{ payment.Payment_Intent or '-' }}</code>
@ -319,6 +331,11 @@
<i class="fas fa-undo"></i>
Refund
</span>
{% elif payment.Refund_FollowUp == True %}
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Refund Processing
</span>
{% elif payment.Success == True %}
<span class="status-badge success">
<i class="fas fa-check"></i>

116
templates/main/payment_detail.html

@ -21,13 +21,20 @@
</div>
<div class="level-right">
<div class="field is-grouped">
{% if payment.Refund != True and payment.Success == True %}
{% if payment.Refund != True and payment.Refund_FollowUp != True and payment.Success == True %}
<div class="control">
<button class="button is-warning" id="refundBtn" onclick="showRefundModal()">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>Process Refund</span>
</button>
</div>
{% elif payment.Refund_FollowUp == True %}
<div class="control">
<button class="button is-warning" id="checkRefundBtn" onclick="checkRefundStatus()">
<span class="icon"><i class="fas fa-sync"></i></span>
<span>Check Refund Status</span>
</button>
</div>
{% endif %}
{% if payment.PI_FollowUp %}
<div class="control">
@ -56,6 +63,10 @@
<span class="icon is-large" style="color: #9370db;">
<i class="fas fa-undo fa-2x"></i>
</span>
{% elif payment.Refund_FollowUp == True %}
<span class="icon is-large" style="color: #ff8c00;">
<i class="fas fa-clock fa-2x"></i>
</span>
{% elif payment.Success == True %}
<span class="icon is-large has-text-success">
<i class="fas fa-check-circle fa-2x"></i>
@ -78,6 +89,13 @@
{% if payment.Stripe_Refund_Created %}
<p class="has-text-grey is-size-7">Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %}
{% elif payment.Refund_FollowUp == True %}
<h2 class="title is-4 mb-2" style="color: #ff8c00;">Refund Processing</h2>
<p class="has-text-grey">A refund is being processed for this payment.</p>
<p class="has-text-grey is-size-7">BECS Direct Debit refunds can take several business days to complete.</p>
{% if payment.Stripe_Refund_Created %}
<p class="has-text-grey is-size-7">Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %}
{% elif payment.Success == True %}
<h2 class="title is-4 has-text-success mb-2">Payment Successful</h2>
<p class="has-text-grey">This payment has been completed successfully.</p>
@ -224,10 +242,25 @@
{% if payment.Refund == True %}
<div class="notification is-light" style="background-color: #f8f4ff; border-color: #9370db;">
<span class="icon" style="color: #9370db;"><i class="fas fa-undo"></i></span>
<strong style="color: #9370db;">Refund Processed:</strong> This payment has been refunded.
<strong style="color: #9370db;">Refund Completed:</strong> This payment has been successfully refunded.
{% if payment.Stripe_Refund_Created %}
<br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %}
{% if payment.Stripe_Refund_ID %}
<br><small>Refund ID: <code>{{ payment.Stripe_Refund_ID }}</code></small>
{% endif %}
</div>
{% elif payment.Refund_FollowUp == True %}
<div class="notification is-warning is-light">
<span class="icon" style="color: #ff8c00;"><i class="fas fa-clock"></i></span>
<strong style="color: #ff8c00;">Refund Processing:</strong> A refund for this payment is currently being processed by the bank.
{% if payment.Stripe_Refund_Created %}
<br><small>Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %}
{% if payment.Stripe_Refund_ID %}
<br><small>Refund ID: <code>{{ payment.Stripe_Refund_ID }}</code></small>
{% endif %}
<br><small><em>BECS Direct Debit refunds typically take 3-5 business days to complete.</em></small>
</div>
{% endif %}
</div>
@ -501,6 +534,55 @@ function showRefundModal() {
document.getElementById('refundModal').classList.add('is-active');
}
function checkRefundStatus() {
const btn = document.getElementById('checkRefundBtn');
const originalText = btn.innerHTML;
// Disable button and show loading
btn.disabled = true;
btn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Checking...</span>';
// Make API call to check refund status
fetch(`/payment/check-refund/{{ payment.id }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.refund_completed) {
showSuccessModal(`
<h4 class="title is-5">Refund Completed Successfully!</h4>
<p>The refund has been processed and completed by the bank.</p>
<p><strong>Status:</strong> <span class="tag is-success">${data.status}</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
`);
} else {
showSuccessModal(`
<h4 class="title is-5">Refund Status Updated</h4>
<p>Refund status has been checked and updated.</p>
<p><strong>Current Status:</strong> <span class="tag is-warning">${data.status}</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><em>The refund is still being processed. Please check again later.</em></p>
`);
}
} else {
showErrorModal(data.error || 'Failed to check refund status');
}
})
.catch(error => {
console.error('Error checking refund status:', error);
showErrorModal('Failed to check refund status. Please try again.');
})
.finally(() => {
// Re-enable button
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function processRefund() {
const btn = document.getElementById('processRefundBtn');
const originalText = btn.innerHTML;
@ -524,12 +606,30 @@ function processRefund() {
.then(data => {
hideModal('refundModal');
if (data.success) {
showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been processed and sent to Stripe.</p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
`);
// Handle both successful and pending refunds
if (data.pending) {
// BECS Direct Debit refunds are pending and need follow-up
showSuccessModal(`
<h4 class="title is-5">Refund Processing!</h4>
<p>The refund has been initiated and is currently being processed by the bank.</p>
<p><strong>Status:</strong> <span class="tag is-warning">Pending</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
<div class="notification is-info is-light mt-3">
<span class="icon"><i class="fas fa-info-circle"></i></span>
BECS Direct Debit refunds can take several business days to complete. The refund will be automatically updated once processed.
</div>
`);
} else {
// Standard card refunds are processed immediately
showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been completed and processed by Stripe.</p>
<p><strong>Status:</strong> <span class="tag is-success">Completed</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
`);
}
} else {
showErrorModal(data.error || 'Failed to process refund');
}

121
templates/main/single_payment_detail.html

@ -35,6 +35,14 @@
<span class="icon is-large" style="color: #9370db;">
<i class="fas fa-undo fa-2x"></i>
</span>
{% elif payment.Refund_FollowUp == True %}
<span class="icon is-large" style="color: #ff8c00;">
<i class="fas fa-clock fa-2x"></i>
</span>
{% elif payment.PI_FollowUp == True %}
<span class="icon is-large has-text-warning">
<i class="fas fa-clock fa-2x"></i>
</span>
{% elif payment.Success == True %}
<span class="icon is-large has-text-success">
<i class="fas fa-check-circle fa-2x"></i>
@ -57,6 +65,17 @@
{% if payment.Stripe_Refund_Created %}
<p class="has-text-grey is-size-7">Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %}
{% elif payment.Refund_FollowUp == True %}
<h2 class="title is-4 mb-2" style="color: #ff8c00;">Refund Processing</h2>
<p class="has-text-grey">A refund is being processed for this payment.</p>
<p class="has-text-grey is-size-7">BECS Direct Debit refunds can take several business days to complete.</p>
{% if payment.Stripe_Refund_Created %}
<p class="has-text-grey is-size-7">Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %}
{% elif payment.PI_FollowUp == True %}
<h2 class="title is-4 has-text-warning mb-2">Payment Pending</h2>
<p class="has-text-grey">This payment is still being processed by the bank.</p>
<p class="has-text-grey is-size-7">BECS Direct Debit payments can take several business days to complete.</p>
{% elif payment.Success == True %}
<h2 class="title is-4 has-text-success mb-2">Payment Successful</h2>
<p class="has-text-grey">This payment has been completed successfully.</p>
@ -72,13 +91,20 @@
</div>
<div class="level-right">
<div class="field is-grouped">
{% if payment.Refund != True and payment.Success == True %}
{% if payment.Refund != True and payment.Refund_FollowUp != True and payment.Success == True %}
<div class="control">
<button class="button" style="border-color: #9370db; color: #9370db;" id="refundBtn" onclick="showRefundModal()">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>Process Refund</span>
</button>
</div>
{% elif payment.Refund_FollowUp == True %}
<div class="control">
<button class="button is-warning" id="checkRefundBtn" onclick="checkRefundStatus()">
<span class="icon"><i class="fas fa-sync"></i></span>
<span>Check Refund Status</span>
</button>
</div>
{% endif %}
{% if payment.PI_FollowUp %}
<div class="control">
@ -191,7 +217,7 @@
{% if payment.Refund == True %}
<div class="notification is-light" style="background-color: #f8f4ff; border-color: #9370db;">
<span class="icon" style="color: #9370db;"><i class="fas fa-undo"></i></span>
<strong style="color: #9370db;">Refund Processed:</strong> This payment has been refunded.
<strong style="color: #9370db;">Refund Completed:</strong> This payment has been successfully refunded.
{% if payment.Stripe_Refund_Created %}
<br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %}
@ -199,6 +225,18 @@
<br><small>Refund ID: <code>{{ payment.Stripe_Refund_ID }}</code></small>
{% endif %}
</div>
{% elif payment.Refund_FollowUp == True %}
<div class="notification is-warning is-light">
<span class="icon" style="color: #ff8c00;"><i class="fas fa-clock"></i></span>
<strong style="color: #ff8c00;">Refund Processing:</strong> A refund for this payment is currently being processed by the bank.
{% if payment.Stripe_Refund_Created %}
<br><small>Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %}
{% if payment.Stripe_Refund_ID %}
<br><small>Refund ID: <code>{{ payment.Stripe_Refund_ID }}</code></small>
{% endif %}
<br><small><em>BECS Direct Debit refunds typically take 3-5 business days to complete.</em></small>
</div>
{% endif %}
</div>
</div>
@ -448,12 +486,30 @@ function processRefund() {
.then(data => {
hideModal('refundModal');
if (data.success) {
showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been processed and sent to Stripe.</p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
`);
// Handle both successful and pending refunds
if (data.pending) {
// BECS Direct Debit refunds are pending and need follow-up
showSuccessModal(`
<h4 class="title is-5">Refund Processing!</h4>
<p>The refund has been initiated and is currently being processed by the bank.</p>
<p><strong>Status:</strong> <span class="tag is-warning">Pending</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
<div class="notification is-info is-light mt-3">
<span class="icon"><i class="fas fa-info-circle"></i></span>
BECS Direct Debit refunds can take several business days to complete. The refund will be automatically updated once processed.
</div>
`);
} else {
// Standard card refunds are processed immediately
showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been completed and processed by Stripe.</p>
<p><strong>Status:</strong> <span class="tag is-success">Completed</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
`);
}
} else {
showErrorModal(data.error || 'Failed to process refund');
}
@ -516,6 +572,55 @@ function checkPaymentIntent() {
});
}
function checkRefundStatus() {
const btn = document.getElementById('checkRefundBtn');
const originalText = btn.innerHTML;
// Disable button and show loading
btn.disabled = true;
btn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Checking...</span>';
// Make API call to check refund status
fetch(`/single-payment/check-refund/{{ payment.id }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.refund_completed) {
showSuccessModal(`
<h4 class="title is-5">Refund Completed Successfully!</h4>
<p>The refund has been processed and completed by the bank.</p>
<p><strong>Status:</strong> <span class="tag is-success">${data.status}</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
`);
} else {
showSuccessModal(`
<h4 class="title is-5">Refund Status Updated</h4>
<p>Refund status has been checked and updated.</p>
<p><strong>Current Status:</strong> <span class="tag is-warning">${data.status}</span></p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><em>The refund is still being processed. Please check again later.</em></p>
`);
}
} else {
showErrorModal(data.error || 'Failed to check refund status');
}
})
.catch(error => {
console.error('Error checking refund status:', error);
showErrorModal('Failed to check refund status. Please try again.');
})
.finally(() => {
// Re-enable button
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function showSuccessModal(message) {
document.getElementById('successMessage').innerHTML = message;
document.getElementById('successModal').classList.add('is-active');

29
templates/main/single_payments_list.html

@ -118,8 +118,14 @@
<tbody id="paymentsTableBody">
{% for payment in payments %}
{% set row_class = '' %}
{% if payment.Success == True %}
{% if payment.Refund == True %}
{% set row_class = 'has-background-light' %}
{% elif payment.Refund_FollowUp == True %}
{% set row_class = 'has-background-warning-light' %}
{% elif payment.Success == True %}
{% set row_class = 'has-background-success-light' %}
{% elif payment.Success == False and payment.PI_FollowUp %}
{% set row_class = 'has-background-warning-light' %}
{% elif payment.Success == False and payment.Error %}
{% set row_class = 'has-background-danger-light' %}
{% elif payment.Success == None %}
@ -148,8 +154,14 @@
{% endif %}
</td>
<td>
{% if payment.Success == True %}
{% if payment.Refund == True %}
<code class="is-small" style="background-color: #9370db; color: white;">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Refund_FollowUp == True %}
<code class="is-small has-background-warning has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == None %}
@ -160,8 +172,14 @@
</td>
<td>
{% if payment.Payment_Intent %}
{% if payment.Success == True %}
{% if payment.Refund == True %}
<code class="is-small" style="background-color: #9370db; color: white;">{{ payment.Payment_Intent }}</code>
{% elif payment.Refund_FollowUp == True %}
<code class="is-small has-background-warning has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == None %}
@ -237,6 +255,11 @@
<i class="fas fa-undo"></i>
Refund
</span>
{% elif payment.Refund_FollowUp == True %}
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Refund Processing
</span>
{% elif payment.Success == True %}
<span class="status-badge success">
<i class="fas fa-check"></i>

42
test.py

@ -0,0 +1,42 @@
import json
from typing import List, Dict, Union, Any
from app import create_app, db
from models import Payments, PaymentBatch, SinglePayments, PaymentPlans
from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET
from services import log_activity
splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET)
results = {
"deleted": 0,
"error": 0
}
def splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]:
#result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=paid&main_attributes[date]=2025-08-21")
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}")
print(f"Count: {len(result)} - {json.dumps(result,indent=2)}")
if __name__ == "__main__":
app = create_app()
customer_id = '1219464'
results = splynx_invoices(customer_id)
print(json.dumps(results,indent=2))
Loading…
Cancel
Save