Browse Source

more more more

master
Alan Woodman 4 months ago
parent
commit
06c68e3f74
  1. 250
      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. 106
      templates/main/payment_detail.html
  10. 111
      templates/main/single_payment_detail.html
  11. 29
      templates/main/single_payments_list.html
  12. 42
      test.py

250
blueprints/main.py

@ -10,6 +10,7 @@ from stripe_payment_processor import StripePaymentProcessor
from config import Config from config import Config
from services import log_activity from services import log_activity
import re import re
import time
splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) 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): def find_pay_splynx_invoices(splynx_id):
"""Mark Splynx invoices as paid for the given customer 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 = { invoice_pay = {
"status": "paid" "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) res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay)
return res 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): def add_payment_splynx(splynx_id, pi_id, pay_id, amount):
"""Add a payment record to Splynx.""" """Add a payment record to Splynx."""
from datetime import datetime from datetime import datetime
@ -423,6 +455,9 @@ def single_payments_list():
SinglePayments.Error, SinglePayments.Error,
SinglePayments.PI_JSON, SinglePayments.PI_JSON,
SinglePayments.Created, SinglePayments.Created,
SinglePayments.PI_FollowUp,
SinglePayments.Refund,
SinglePayments.Refund_FollowUp,
Users.FullName.label('processed_by') Users.FullName.label('processed_by')
).outerjoin(Users, SinglePayments.Who == Users.id)\ ).outerjoin(Users, SinglePayments.Who == Users.id)\
.order_by(SinglePayments.Created.desc()).all() .order_by(SinglePayments.Created.desc()).all()
@ -633,6 +668,12 @@ def process_single_payment():
if result.get('needs_fee_update'): if result.get('needs_fee_update'):
payment_record.PI_FollowUp = True 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": if result.get('payment_method_type') == "card":
payment_record.Payment_Method = result.get('estimated_fee_details', {}).get('card_display_brand', '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 # Update payment record based on refund status
payment.Refund = True
payment.Refund_JSON = json.dumps(refund, default=str) payment.Refund_JSON = json.dumps(refund, default=str)
payment.Stripe_Refund_ID = refund.id payment.Stripe_Refund_ID = refund.id
@ -1132,6 +1172,17 @@ def process_single_payment_refund(payment_id):
from datetime import datetime from datetime import datetime
payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created) 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() db.session.commit()
# Log the refund activity # Log the refund activity
@ -1140,12 +1191,14 @@ def process_single_payment_refund(payment_id):
action="process_refund", action="process_refund",
entity_type="single_payment", entity_type="single_payment",
entity_id=payment_id, 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({ return jsonify({
'success': True, 'success': True,
'pending': refund.status == "pending",
'refund_id': refund.id, 'refund_id': refund.id,
'refund_status': refund.status,
'amount_refunded': f"${payment.Payment_Amount:.2f}", 'amount_refunded': f"${payment.Payment_Amount:.2f}",
'reason': reason 'reason': reason
}) })
@ -1156,6 +1209,135 @@ def process_single_payment_refund(payment_id):
print(f"Error processing single payment refund: {e}") print(f"Error processing single payment refund: {e}")
return jsonify({'success': False, 'error': 'Internal server error'}), 500 return jsonify({'success': False, 'error': 'Internal server error'}), 500
@main_bp.route('/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') @main_bp.route('/logs')
@login_required @login_required
def logs_list(): def logs_list():
@ -1441,8 +1623,8 @@ def process_payment_refund(payment_id):
if payment.Refund: if payment.Refund:
return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400 return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400
if not payment.Stripe_Charge_ID: #if not payment.Stripe_Charge_ID:
return jsonify({'success': False, 'error': 'No Stripe charge ID found for this payment'}), 400 # return jsonify({'success': False, 'error': 'No Stripe charge ID found for this payment'}), 400
# Get refund reason from request # Get refund reason from request
data = request.get_json() data = request.get_json()
@ -1455,20 +1637,50 @@ def process_payment_refund(payment_id):
stripe.api_key = Config.STRIPE_TEST_API_KEY stripe.api_key = Config.STRIPE_TEST_API_KEY
# Create refund parameters # Create refund parameters
refund_params = { #refund_params = {
'charge': payment.Stripe_Charge_ID, # 'charge': payment.Stripe_Charge_ID,
'reason': reason # 'reason': reason
} #}
# Process the refund with Stripe # 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 # Update payment record with refund information
payment.Refund = True
payment.Stripe_Refund_ID = refund.id payment.Stripe_Refund_ID = refund.id
payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created) payment.Stripe_Refund_Created = datetime.fromtimestamp(refund.created)
payment.Refund_JSON = json.dumps(refund) payment.Refund_JSON = json.dumps(refund)
if refund['status'] == "succeeded":
payment.Refund = True
elif refund['status'] == "pending":
payment.Refund_FollowUp = True
db.session.commit() db.session.commit()
@ -1480,14 +1692,24 @@ def process_payment_refund(payment_id):
entity_id=payment_id, entity_id=payment_id,
details=f"Processed refund {refund.id} for payment {payment_id}, amount: ${refund.amount/100:.2f}" details=f"Processed refund {refund.id} for payment {payment_id}, amount: ${refund.amount/100:.2f}"
) )
if refund['status'] == "succeeded":
return jsonify({ return jsonify({
'success': True, 'success': True,
'pending': False,
'refund_id': refund.id, 'refund_id': refund.id,
'amount_refunded': f"${refund.amount/100:.2f}", 'amount_refunded': f"${refund.amount/100:.2f}",
'status': refund.status, 'status': refund.status,
'message': 'Refund processed successfully' '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: else:
# Refund failed # Refund failed
payment.Refund = False 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()) Error = db.Column(db.Text())
Success = db.Column(db.Boolean, nullable=True, default=None) Success = db.Column(db.Boolean, nullable=True, default=None)
Refund = 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()) Refund_JSON = db.Column(db.Text())
Stripe_Refund_ID = db.Column(db.String()) Stripe_Refund_ID = db.Column(db.String())
Stripe_Refund_Created = db.Column(db.DateTime, nullable=True) Stripe_Refund_Created = db.Column(db.DateTime, nullable=True)
@ -80,6 +81,7 @@ class SinglePayments(db.Model):
Error = db.Column(db.Text()) Error = db.Column(db.Text())
Success = db.Column(db.Boolean, nullable=True, default=None) Success = db.Column(db.Boolean, nullable=True, default=None)
Refund = 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()) Refund_JSON = db.Column(db.Text())
Stripe_Refund_ID = db.Column(db.String()) Stripe_Refund_ID = db.Column(db.String())
Stripe_Refund_Created = db.Column(db.DateTime, nullable=True) 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: for payment in first_duplicates:
if payment.Stripe_Charge_ID: if payment.Stripe_Charge_ID:
print(f"Safe to refund - Payment ID: {payment.id}, Customer: {payment.Stripe_Customer_ID}, Charge: {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( #result = issue_refund_for_payment(
payment, # payment,
reason='duplicate' # reason='duplicate'
) #)
results.append(result) #results.append(result)
if result['success']: #if result['success']:
print(f"✓ Refund successful: {result['refund_id']}") # print(f"✓ Refund successful: {result['refund_id']}")
else: #else:
print(f"✗ Refund failed: {result['error']}") # print(f"✗ Refund failed: {result['error']}")
else: else:
print(f"Skipping Payment ID {payment.id} - No Stripe Charge ID") 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. External script to query MySQL database (Splynx) for customer billing data.
This script runs independently of the Flask application. 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 import pymysql
@ -67,6 +68,56 @@ def find_pay_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]:
updated_invoices.append(res) updated_invoices.append(res)
return updated_invoices 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]: def add_payment_splynx(splynx_id: int, pi_id: str, pay_id: int, amount: float) -> Union[int, bool]:
stripe_pay = { stripe_pay = {
"customer_id": splynx_id, "customer_id": splynx_id,
@ -294,6 +345,9 @@ def processPaymentResult(pay_id, result, key):
else: else:
if result.get('needs_fee_update'): if result.get('needs_fee_update'):
payment.PI_FollowUp = True 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.Payment_Intent = result['payment_intent_id']
payment.Success = result['success'] payment.Success = result['success']
if result['success'] and PROCESS_LIVE: if result['success'] and PROCESS_LIVE:
@ -323,6 +377,8 @@ def processPaymentResult(pay_id, result, key):
except Exception as e: except Exception as e:
logger.error(f"processPaymentResult: {e}\nResult: {json.dumps(result)}") logger.error(f"processPaymentResult: {e}\nResult: {json.dumps(result)}")
payment.PI_FollowUp = True payment.PI_FollowUp = True
if PROCESS_LIVE:
find_set_pending_splynx_invoices(payment.Splynx_ID)
def _update_payment(): def _update_payment():
return True # Just need to trigger commit, payment is already modified 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_FollowUp = False
pi.PI_Last_Check = datetime.now() pi.PI_Last_Check = datetime.now()
pi.Success = True 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_'): #if intent_result.get('charge_id').startswith('ch_'):
# pi.Stripe_Charge_ID = intent_result.get('charge_id') # pi.Stripe_Charge_ID = intent_result.get('charge_id')
processPaymentResult(pay_id=pi.id, result=intent_result, key=key) processPaymentResult(pay_id=pi.id, result=intent_result, key=key)
@ -589,6 +650,104 @@ def process_payintent_mode(processor):
return succeeded_count, failed_count 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__": if __name__ == "__main__":
## Payment Method: ## Payment Method:
## 2 - Direct Debit (Automatic) ## 2 - Direct Debit (Automatic)
@ -599,6 +758,7 @@ if __name__ == "__main__":
## batch = Monthly Direct Debit/Credit Cards ## batch = Monthly Direct Debit/Credit Cards
## payintent = Check outstanding Payment Intents and update ## payintent = Check outstanding Payment Intents and update
## payplan = Check for Payment Plans to run ## payplan = Check for Payment Plans to run
## refund = Check outstanding Refunds and update
start_time = datetime.now() start_time = datetime.now()
success_count = 0 success_count = 0
@ -614,9 +774,11 @@ if __name__ == "__main__":
running_mode = "payintent" running_mode = "payintent"
elif sys.argv[1] == "payplan": elif sys.argv[1] == "payplan":
running_mode = "payplan" running_mode = "payplan"
elif sys.argv[1] == "refund":
running_mode = "refund"
else: else:
logger.error(f"Invalid running mode: {sys.argv[1]}") 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) sys.exit(1)
try: try:
if sys.argv[2] == "live": if sys.argv[2] == "live":
@ -647,6 +809,8 @@ if __name__ == "__main__":
execute_payment_batches(processor, batch_ids) execute_payment_batches(processor, batch_ids)
elif running_mode == "payintent": elif running_mode == "payintent":
success_count, failed_count = process_payintent_mode(processor) 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: except Exception as e:
logger.error(f"Script execution failed: {e}") logger.error(f"Script execution failed: {e}")
errors.append(str(e)) errors.append(str(e))

140
stripe_payment_processor.py

@ -804,6 +804,146 @@ class StripePaymentProcessor:
'timestamp': datetime.now().isoformat() '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, 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]: 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"> <tbody id="paymentsTableBody">
{% for payment in payments %} {% for payment in payments %}
{% set row_class = '' %} {% 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' %} {% set row_class = 'has-background-success-light' %}
{% elif payment.Success == False and payment.PI_FollowUp %} {% elif payment.Success == False and payment.PI_FollowUp %}
{% set row_class = 'has-background-warning-light' %} {% set row_class = 'has-background-warning-light' %}
@ -216,7 +220,11 @@
{% endif %} {% endif %}
</td> </td>
<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> <code class="is-small has-background-success has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %} {% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code> <code class="is-small has-background-warning has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code>
@ -229,7 +237,11 @@
{% endif %} {% endif %}
</td> </td>
<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> <code class="is-small has-background-success has-text-white">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %} {% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-black">{{ payment.Payment_Intent or '-' }}</code> <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> <i class="fas fa-undo"></i>
Refund Refund
</span> </span>
{% elif payment.Refund_FollowUp == True %}
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Refund Processing
</span>
{% elif payment.Success == True %} {% elif payment.Success == True %}
<span class="status-badge success"> <span class="status-badge success">
<i class="fas fa-check"></i> <i class="fas fa-check"></i>

106
templates/main/payment_detail.html

@ -21,13 +21,20 @@
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="field is-grouped"> <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"> <div class="control">
<button class="button is-warning" id="refundBtn" onclick="showRefundModal()"> <button class="button is-warning" id="refundBtn" onclick="showRefundModal()">
<span class="icon"><i class="fas fa-undo"></i></span> <span class="icon"><i class="fas fa-undo"></i></span>
<span>Process Refund</span> <span>Process Refund</span>
</button> </button>
</div> </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 %} {% endif %}
{% if payment.PI_FollowUp %} {% if payment.PI_FollowUp %}
<div class="control"> <div class="control">
@ -56,6 +63,10 @@
<span class="icon is-large" style="color: #9370db;"> <span class="icon is-large" style="color: #9370db;">
<i class="fas fa-undo fa-2x"></i> <i class="fas fa-undo fa-2x"></i>
</span> </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 %} {% elif payment.Success == True %}
<span class="icon is-large has-text-success"> <span class="icon is-large has-text-success">
<i class="fas fa-check-circle fa-2x"></i> <i class="fas fa-check-circle fa-2x"></i>
@ -78,6 +89,13 @@
{% if payment.Stripe_Refund_Created %} {% 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> <p class="has-text-grey is-size-7">Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %} {% 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 %} {% elif payment.Success == True %}
<h2 class="title is-4 has-text-success mb-2">Payment Successful</h2> <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> <p class="has-text-grey">This payment has been completed successfully.</p>
@ -224,10 +242,25 @@
{% if payment.Refund == True %} {% if payment.Refund == True %}
<div class="notification is-light" style="background-color: #f8f4ff; border-color: #9370db;"> <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> <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 %} {% if payment.Stripe_Refund_Created %}
<br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small> <br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %} {% 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> </div>
{% endif %} {% endif %}
</div> </div>
@ -501,6 +534,55 @@ function showRefundModal() {
document.getElementById('refundModal').classList.add('is-active'); 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() { function processRefund() {
const btn = document.getElementById('processRefundBtn'); const btn = document.getElementById('processRefundBtn');
const originalText = btn.innerHTML; const originalText = btn.innerHTML;
@ -524,12 +606,30 @@ function processRefund() {
.then(data => { .then(data => {
hideModal('refundModal'); hideModal('refundModal');
if (data.success) { if (data.success) {
// 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(` showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4> <h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been processed and sent to Stripe.</p> <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>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p> <p><strong>Amount:</strong> ${data.amount_refunded}</p>
`); `);
}
} else { } else {
showErrorModal(data.error || 'Failed to process refund'); showErrorModal(data.error || 'Failed to process refund');
} }

111
templates/main/single_payment_detail.html

@ -35,6 +35,14 @@
<span class="icon is-large" style="color: #9370db;"> <span class="icon is-large" style="color: #9370db;">
<i class="fas fa-undo fa-2x"></i> <i class="fas fa-undo fa-2x"></i>
</span> </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 %} {% elif payment.Success == True %}
<span class="icon is-large has-text-success"> <span class="icon is-large has-text-success">
<i class="fas fa-check-circle fa-2x"></i> <i class="fas fa-check-circle fa-2x"></i>
@ -57,6 +65,17 @@
{% if payment.Stripe_Refund_Created %} {% 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> <p class="has-text-grey is-size-7">Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %} {% 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 %} {% elif payment.Success == True %}
<h2 class="title is-4 has-text-success mb-2">Payment Successful</h2> <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> <p class="has-text-grey">This payment has been completed successfully.</p>
@ -72,13 +91,20 @@
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="field is-grouped"> <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"> <div class="control">
<button class="button" style="border-color: #9370db; color: #9370db;" id="refundBtn" onclick="showRefundModal()"> <button class="button" style="border-color: #9370db; color: #9370db;" id="refundBtn" onclick="showRefundModal()">
<span class="icon"><i class="fas fa-undo"></i></span> <span class="icon"><i class="fas fa-undo"></i></span>
<span>Process Refund</span> <span>Process Refund</span>
</button> </button>
</div> </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 %} {% endif %}
{% if payment.PI_FollowUp %} {% if payment.PI_FollowUp %}
<div class="control"> <div class="control">
@ -191,7 +217,7 @@
{% if payment.Refund == True %} {% if payment.Refund == True %}
<div class="notification is-light" style="background-color: #f8f4ff; border-color: #9370db;"> <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> <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 %} {% if payment.Stripe_Refund_Created %}
<br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small> <br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %} {% endif %}
@ -199,6 +225,18 @@
<br><small>Refund ID: <code>{{ payment.Stripe_Refund_ID }}</code></small> <br><small>Refund ID: <code>{{ payment.Stripe_Refund_ID }}</code></small>
{% endif %} {% endif %}
</div> </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 %} {% endif %}
</div> </div>
</div> </div>
@ -448,12 +486,30 @@ function processRefund() {
.then(data => { .then(data => {
hideModal('refundModal'); hideModal('refundModal');
if (data.success) { if (data.success) {
// 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(` showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4> <h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been processed and sent to Stripe.</p> <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>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p> <p><strong>Amount:</strong> ${data.amount_refunded}</p>
`); `);
}
} else { } else {
showErrorModal(data.error || 'Failed to process refund'); 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) { function showSuccessModal(message) {
document.getElementById('successMessage').innerHTML = message; document.getElementById('successMessage').innerHTML = message;
document.getElementById('successModal').classList.add('is-active'); document.getElementById('successModal').classList.add('is-active');

29
templates/main/single_payments_list.html

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