diff --git a/blueprints/main.py b/blueprints/main.py index 4247687..b3a50bf 100644 --- a/blueprints/main.py +++ b/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/', 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/', 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 diff --git a/migrations/versions/8929cc43ea50_more_refund_features.py b/migrations/versions/8929cc43ea50_more_refund_features.py new file mode 100644 index 0000000..5f73c27 --- /dev/null +++ b/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 ### diff --git a/models.py b/models.py index 412f700..1f3b94d 100644 --- a/models.py +++ b/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) diff --git a/payments_fixup_find_customers_v2.py b/payments_fixup_find_customers_v2.py index f50cceb..ae6520b 100644 --- a/payments_fixup_find_customers_v2.py +++ b/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") diff --git a/query_mysql-bak.py b/query_mysql-bak.py new file mode 100644 index 0000000..abaaf11 --- /dev/null +++ b/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") diff --git a/query_mysql.py b/query_mysql.py index 7994eee..7a59496 100644 --- a/query_mysql.py +++ b/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)) diff --git a/stripe_payment_processor.py b/stripe_payment_processor.py index 6b55710..e124ff7 100644 --- a/stripe_payment_processor.py +++ b/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]: """ diff --git a/templates/main/batch_detail.html b/templates/main/batch_detail.html index b9ece7c..cd21f9d 100644 --- a/templates/main/batch_detail.html +++ b/templates/main/batch_detail.html @@ -188,7 +188,11 @@ {% 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 %} - {% if payment.Success == True %} + {% if payment.Refund == True %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Refund_FollowUp == True %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == True %} {{ payment.Stripe_Customer_ID or '-' }} {% elif payment.Success == False and payment.PI_FollowUp %} {{ payment.Stripe_Customer_ID or '-' }} @@ -229,7 +237,11 @@ {% endif %} - {% if payment.Success == True %} + {% if payment.Refund == True %} + {{ payment.Payment_Intent or '-' }} + {% elif payment.Refund_FollowUp == True %} + {{ payment.Payment_Intent or '-' }} + {% elif payment.Success == True %} {{ payment.Payment_Intent or '-' }} {% elif payment.Success == False and payment.PI_FollowUp %} {{ payment.Payment_Intent or '-' }} @@ -319,6 +331,11 @@ Refund + {% elif payment.Refund_FollowUp == True %} + + + Refund Processing + {% elif payment.Success == True %} diff --git a/templates/main/payment_detail.html b/templates/main/payment_detail.html index c489684..d5b8794 100644 --- a/templates/main/payment_detail.html +++ b/templates/main/payment_detail.html @@ -21,13 +21,20 @@
- {% if payment.Refund != True and payment.Success == True %} + {% if payment.Refund != True and payment.Refund_FollowUp != True and payment.Success == True %}
+ {% elif payment.Refund_FollowUp == True %} +
+ +
{% endif %} {% if payment.PI_FollowUp %}
@@ -56,6 +63,10 @@ + {% elif payment.Refund_FollowUp == True %} + + + {% elif payment.Success == True %} @@ -78,6 +89,13 @@ {% if payment.Stripe_Refund_Created %}

Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}

{% endif %} + {% elif payment.Refund_FollowUp == True %} +

Refund Processing

+

A refund is being processed for this payment.

+

BECS Direct Debit refunds can take several business days to complete.

+ {% if payment.Stripe_Refund_Created %} +

Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}

+ {% endif %} {% elif payment.Success == True %}

Payment Successful

This payment has been completed successfully.

@@ -224,10 +242,25 @@ {% if payment.Refund == True %}
- Refund Processed: This payment has been refunded. + Refund Completed: This payment has been successfully refunded. {% if payment.Stripe_Refund_Created %}
Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }} {% endif %} + {% if payment.Stripe_Refund_ID %} +
Refund ID: {{ payment.Stripe_Refund_ID }} + {% endif %} +
+ {% elif payment.Refund_FollowUp == True %} +
+ + Refund Processing: A refund for this payment is currently being processed by the bank. + {% if payment.Stripe_Refund_Created %} +
Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }} + {% endif %} + {% if payment.Stripe_Refund_ID %} +
Refund ID: {{ payment.Stripe_Refund_ID }} + {% endif %} +
BECS Direct Debit refunds typically take 3-5 business days to complete.
{% endif %}
@@ -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 = 'Checking...'; + + // 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(` +

Refund Completed Successfully!

+

The refund has been processed and completed by the bank.

+

Status: ${data.status}

+

Refund ID: ${data.refund_id}

+ `); + } else { + showSuccessModal(` +

Refund Status Updated

+

Refund status has been checked and updated.

+

Current Status: ${data.status}

+

Refund ID: ${data.refund_id}

+

The refund is still being processed. Please check again later.

+ `); + } + } 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(` -

Refund Processed Successfully!

-

The refund has been processed and sent to Stripe.

-

Refund ID: ${data.refund_id}

-

Amount: ${data.amount_refunded}

- `); + // Handle both successful and pending refunds + if (data.pending) { + // BECS Direct Debit refunds are pending and need follow-up + showSuccessModal(` +

Refund Processing!

+

The refund has been initiated and is currently being processed by the bank.

+

Status: Pending

+

Refund ID: ${data.refund_id}

+

Amount: ${data.amount_refunded}

+
+ + BECS Direct Debit refunds can take several business days to complete. The refund will be automatically updated once processed. +
+ `); + } else { + // Standard card refunds are processed immediately + showSuccessModal(` +

Refund Processed Successfully!

+

The refund has been completed and processed by Stripe.

+

Status: Completed

+

Refund ID: ${data.refund_id}

+

Amount: ${data.amount_refunded}

+ `); + } } else { showErrorModal(data.error || 'Failed to process refund'); } diff --git a/templates/main/single_payment_detail.html b/templates/main/single_payment_detail.html index eeae853..5917ff6 100644 --- a/templates/main/single_payment_detail.html +++ b/templates/main/single_payment_detail.html @@ -35,6 +35,14 @@ + {% elif payment.Refund_FollowUp == True %} + + + + {% elif payment.PI_FollowUp == True %} + + + {% elif payment.Success == True %} @@ -57,6 +65,17 @@ {% if payment.Stripe_Refund_Created %}

Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}

{% endif %} + {% elif payment.Refund_FollowUp == True %} +

Refund Processing

+

A refund is being processed for this payment.

+

BECS Direct Debit refunds can take several business days to complete.

+ {% if payment.Stripe_Refund_Created %} +

Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}

+ {% endif %} + {% elif payment.PI_FollowUp == True %} +

Payment Pending

+

This payment is still being processed by the bank.

+

BECS Direct Debit payments can take several business days to complete.

{% elif payment.Success == True %}

Payment Successful

This payment has been completed successfully.

@@ -72,13 +91,20 @@
- {% if payment.Refund != True and payment.Success == True %} + {% if payment.Refund != True and payment.Refund_FollowUp != True and payment.Success == True %}
+ {% elif payment.Refund_FollowUp == True %} +
+ +
{% endif %} {% if payment.PI_FollowUp %}
@@ -191,7 +217,7 @@ {% if payment.Refund == True %}
- Refund Processed: This payment has been refunded. + Refund Completed: This payment has been successfully refunded. {% if payment.Stripe_Refund_Created %}
Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }} {% endif %} @@ -199,6 +225,18 @@
Refund ID: {{ payment.Stripe_Refund_ID }} {% endif %}
+ {% elif payment.Refund_FollowUp == True %} +
+ + Refund Processing: A refund for this payment is currently being processed by the bank. + {% if payment.Stripe_Refund_Created %} +
Initiated: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }} + {% endif %} + {% if payment.Stripe_Refund_ID %} +
Refund ID: {{ payment.Stripe_Refund_ID }} + {% endif %} +
BECS Direct Debit refunds typically take 3-5 business days to complete. +
{% endif %}
@@ -448,12 +486,30 @@ function processRefund() { .then(data => { hideModal('refundModal'); if (data.success) { - showSuccessModal(` -

Refund Processed Successfully!

-

The refund has been processed and sent to Stripe.

-

Refund ID: ${data.refund_id}

-

Amount: ${data.amount_refunded}

- `); + // Handle both successful and pending refunds + if (data.pending) { + // BECS Direct Debit refunds are pending and need follow-up + showSuccessModal(` +

Refund Processing!

+

The refund has been initiated and is currently being processed by the bank.

+

Status: Pending

+

Refund ID: ${data.refund_id}

+

Amount: ${data.amount_refunded}

+
+ + BECS Direct Debit refunds can take several business days to complete. The refund will be automatically updated once processed. +
+ `); + } else { + // Standard card refunds are processed immediately + showSuccessModal(` +

Refund Processed Successfully!

+

The refund has been completed and processed by Stripe.

+

Status: Completed

+

Refund ID: ${data.refund_id}

+

Amount: ${data.amount_refunded}

+ `); + } } 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 = 'Checking...'; + + // 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(` +

Refund Completed Successfully!

+

The refund has been processed and completed by the bank.

+

Status: ${data.status}

+

Refund ID: ${data.refund_id}

+ `); + } else { + showSuccessModal(` +

Refund Status Updated

+

Refund status has been checked and updated.

+

Current Status: ${data.status}

+

Refund ID: ${data.refund_id}

+

The refund is still being processed. Please check again later.

+ `); + } + } 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'); diff --git a/templates/main/single_payments_list.html b/templates/main/single_payments_list.html index 69ffa97..873e457 100644 --- a/templates/main/single_payments_list.html +++ b/templates/main/single_payments_list.html @@ -118,8 +118,14 @@ {% 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 %} - {% if payment.Success == True %} + {% if payment.Refund == True %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Refund_FollowUp == True %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == True %} {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == False and payment.PI_FollowUp %} + {{ payment.Stripe_Customer_ID or '-' }} {% elif payment.Success == False and payment.Error %} {{ payment.Stripe_Customer_ID or '-' }} {% elif payment.Success == None %} @@ -160,8 +172,14 @@ {% if payment.Payment_Intent %} - {% if payment.Success == True %} + {% if payment.Refund == True %} + {{ payment.Payment_Intent }} + {% elif payment.Refund_FollowUp == True %} + {{ payment.Payment_Intent }} + {% elif payment.Success == True %} {{ payment.Payment_Intent }} + {% elif payment.Success == False and payment.PI_FollowUp %} + {{ payment.Payment_Intent }} {% elif payment.Success == False and payment.Error %} {{ payment.Payment_Intent }} {% elif payment.Success == None %} @@ -237,6 +255,11 @@ Refund + {% elif payment.Refund_FollowUp == True %} + + + Refund Processing + {% elif payment.Success == True %} diff --git a/test.py b/test.py new file mode 100644 index 0000000..997f8fb --- /dev/null +++ b/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)) +