diff --git a/blueprints/main.py b/blueprints/main.py index 322a2f5..336074d 100644 --- a/blueprints/main.py +++ b/blueprints/main.py @@ -325,33 +325,24 @@ def single_payment_detail(payment_id): @main_bp.route('/payment/detail/') @login_required def payment_detail(payment_id): - """Display detailed view of a specific single payment.""" - # Get payment information - - payment = db.session.query( - Payments.id, - Payments.Splynx_ID, - Payments.Stripe_Customer_ID, - Payments.Payment_Intent, - Payments.PI_FollowUp, - Payments.PI_Last_Check, - Payments.Payment_Method, - Payments.Fee_Tax, - Payments.Fee_Stripe, - Payments.Fee_Total, - Payments.Payment_Amount, - Payments.PI_JSON, - Payments.PI_FollowUp_JSON, - Payments.Error, - Payments.Success, - Payments.Created)\ - .filter(Payments.id == payment_id).first() + """Display detailed view of a specific batch payment.""" + # Get payment information with all fields needed for the detail view + payment = db.session.query(Payments).filter(Payments.id == payment_id).first() if not payment: flash('Payment not found.', 'error') - return redirect(url_for('main.single_payments_list')) + return redirect(url_for('main.batch_list')) - return render_template('main/single_payment_detail.html', payment=payment) + # Log the payment detail view access + log_activity( + user_id=current_user.id, + action="view_payment_detail", + entity_type="payment", + entity_id=payment_id, + details=f"Viewed batch payment detail for payment ID {payment_id}" + ) + + return render_template('main/payment_detail.html', payment=payment) @main_bp.route('/single-payment/check-intent/', methods=['POST']) @login_required @@ -841,6 +832,164 @@ def api_stripe_payment_methods(stripe_customer_id): print(f"Error fetching payment methods: {e}") return jsonify({'success': False, 'error': 'Failed to fetch payment methods'}), 500 +@main_bp.route('/payment/check-intent/', methods=['POST']) +@login_required +def check_batch_payment_intent(payment_id): + """Check the status of a batch payment intent and update the record.""" + from datetime import datetime + + try: + # Get the payment record from Payments table (batch payments) + payment = Payments.query.get_or_404(payment_id) + + if not payment.Payment_Intent: + return jsonify({'success': False, 'error': 'No payment intent found'}), 400 + + # Initialize Stripe processor with correct API key + if Config.PROCESS_LIVE: + api_key = Config.STRIPE_LIVE_API_KEY + else: + api_key = Config.STRIPE_TEST_API_KEY + + processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) + + # Check payment intent status + intent_result = processor.check_payment_intent(payment.Payment_Intent) + + if intent_result['status'] == "succeeded": + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_FollowUp = False + payment.PI_Last_Check = datetime.now() + payment.Success = True + if intent_result.get('charge_id'): + payment.Stripe_Charge_ID = intent_result.get('charge_id') + processPaymentResult(pay_id=payment.id, result=intent_result, key="pay") + elif intent_result['status'] == "failed": + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_FollowUp = False + payment.PI_Last_Check = datetime.now() + payment.Success = False + else: + # Still pending + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_Last_Check = datetime.now() + + db.session.commit() + + # Log the intent check activity + log_activity( + user_id=current_user.id, + action="check_payment_intent", + entity_type="payment", + entity_id=payment_id, + details=f"Checked payment intent {payment.Payment_Intent}, status: {intent_result['status']}" + ) + + return jsonify({ + 'success': True, + 'status': intent_result['status'], + 'payment_succeeded': intent_result['status'] == "succeeded", + 'message': f'Payment intent status: {intent_result["status"]}' + }) + + except Exception as e: + db.session.rollback() + print(f"Check batch payment intent error: {e}") + return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500 + +@main_bp.route('/payment/refund/', methods=['POST']) +@login_required +def process_payment_refund(payment_id): + """Process a refund for a batch payment.""" + from datetime import datetime + import stripe + + try: + # Get the payment record from Payments table (batch payments) + payment = Payments.query.get_or_404(payment_id) + + # Validate payment can be refunded + if not payment.Success: + return jsonify({'success': False, 'error': 'Cannot refund an unsuccessful payment'}), 400 + + if payment.Refund: + return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400 + + if not payment.Stripe_Charge_ID: + return jsonify({'success': False, 'error': 'No Stripe charge ID found for this payment'}), 400 + + # Get refund reason from request + data = request.get_json() + reason = data.get('reason', 'requested_by_customer') + + # Initialize Stripe with correct API key + if Config.PROCESS_LIVE: + stripe.api_key = Config.STRIPE_LIVE_API_KEY + else: + stripe.api_key = Config.STRIPE_TEST_API_KEY + + # Create refund parameters + refund_params = { + 'charge': payment.Stripe_Charge_ID, + 'reason': reason + } + + # Process the refund with Stripe + refund = stripe.Refund.create(**refund_params) + + if refund['status'] == "succeeded": + # 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) + + db.session.commit() + + # Log the refund activity + log_activity( + user_id=current_user.id, + action="process_refund", + entity_type="payment", + entity_id=payment_id, + details=f"Processed refund {refund.id} for payment {payment_id}, amount: ${refund.amount/100:.2f}" + ) + + return jsonify({ + 'success': True, + 'refund_id': refund.id, + 'amount_refunded': f"${refund.amount/100:.2f}", + 'status': refund.status, + 'message': 'Refund processed successfully' + }) + else: + # Refund failed + payment.Refund = False + payment.Refund_JSON = json.dumps(refund) + db.session.commit() + + return jsonify({ + 'success': False, + 'error': f'Refund failed with status: {refund.status}' + }), 400 + + except stripe.error.InvalidRequestError as e: + # Handle Stripe-specific errors + error_msg = str(e) + if "has already been refunded" in error_msg: + # Mark as refunded in our database even if Stripe says it's already refunded + payment.Refund = True + payment.Stripe_Refund_Created = datetime.now() + db.session.commit() + return jsonify({'success': False, 'error': 'Payment has already been refunded in Stripe'}), 400 + else: + return jsonify({'success': False, 'error': f'Stripe error: {error_msg}'}), 400 + + except Exception as e: + db.session.rollback() + print(f"Process payment refund error: {e}") + return jsonify({'success': False, 'error': 'Failed to process refund'}), 500 + @main_bp.route('/api/splynx/') @login_required def api_splynx_customer(id): diff --git a/config.py b/config.py index 7120b15..2fbf4b7 100644 --- a/config.py +++ b/config.py @@ -18,7 +18,7 @@ class Config: } # Query configuration - DEFAULT_QUERY_LIMIT = 3 + DEFAULT_QUERY_LIMIT = 10000 DEPOSIT_THRESHOLD = -5 # Payment Method Constants @@ -28,7 +28,7 @@ class Config: # Process live on Sandbox # False = Sandbox - Default - PROCESS_LIVE = False + PROCESS_LIVE = True # Threading configuration MAX_PAYMENT_THREADS = 5 # Number of concurrent payment processing threads diff --git a/delete_splynx_payments.py b/delete_splynx_payments.py new file mode 100644 index 0000000..6d64b86 --- /dev/null +++ b/delete_splynx_payments.py @@ -0,0 +1,59 @@ +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 delete_splynx_invoices(splynx_id: int, payintent: str) -> 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, + 'field_1': payintent + }, + } + query_string = splynx.build_splynx_query_params(params) + result = splynx.get(url=f"/api/2.0/admin/finance/payments?{query_string}") + + print(f"Count: {len(result)} - {json.dumps(result,indent=2)}") + + delete = splynx.delete(url=f"/api/2.0/admin/finance/payments/{result[0]['id']}") + if delete: + results['deleted'] += 1 + details = f"Deleted Splynx Payment ID: {result[0]['id']} for Splynx Customer: {splynx_id}" + else: + results['error'] += 1 + details = f"Error deleting Splynx Payment ID: {result[0]['id']} for Splynx Customer: {splynx_id}" + log_activity( + user_id=1, + action="DELETE_SPLYNX_PAYMENT", + entity_type="Script", + details=details + ) + + + + +if __name__ == "__main__": + app = create_app() + + + with app.app_context(): + payments = db.session.query(Payments).filter(Payments.Refund == True).all() + + for pay in payments: + delete_splynx_invoices(pay.Splynx_ID, pay.Payment_Intent) + + print(json.dumps(results,indent=2)) + diff --git a/migrations/versions/1af0e892bd5d_add_new_charge_and_refund_features.py b/migrations/versions/1af0e892bd5d_add_new_charge_and_refund_features.py new file mode 100644 index 0000000..11397a2 --- /dev/null +++ b/migrations/versions/1af0e892bd5d_add_new_charge_and_refund_features.py @@ -0,0 +1,60 @@ +"""Add new Charge and Refund features + +Revision ID: 1af0e892bd5d +Revises: 3252db86eaae +Create Date: 2025-08-21 14:19:38.943110 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1af0e892bd5d' +down_revision = '3252db86eaae' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: + batch_op.drop_column('Day') + + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.add_column(sa.Column('Stripe_Charge_ID', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('Refund', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('Refund_JSON', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('Stripe_Refund_ID', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('Stripe_Refund_Created', sa.DateTime(), nullable=True)) + + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.add_column(sa.Column('Stripe_Charge_ID', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('Refund', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('Refund_JSON', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('Stripe_Refund_ID', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('Stripe_Refund_Created', sa.DateTime(), 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('Stripe_Refund_Created') + batch_op.drop_column('Stripe_Refund_ID') + batch_op.drop_column('Refund_JSON') + batch_op.drop_column('Refund') + batch_op.drop_column('Stripe_Charge_ID') + + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.drop_column('Stripe_Refund_Created') + batch_op.drop_column('Stripe_Refund_ID') + batch_op.drop_column('Refund_JSON') + batch_op.drop_column('Refund') + batch_op.drop_column('Stripe_Charge_ID') + + with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: + batch_op.add_column(sa.Column('Day', sa.VARCHAR(length=50), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### diff --git a/models.py b/models.py index ce63b4e..412f700 100644 --- a/models.py +++ b/models.py @@ -43,6 +43,7 @@ class Payments(db.Model): PI_FollowUp = db.Column(db.Boolean, nullable=False, default=0) PI_Last_Check = db.Column(db.DateTime, nullable=True) Payment_Method = db.Column(db.String()) + Stripe_Charge_ID = db.Column(db.String()) Stripe_Payment_Method = db.Column(db.String()) Fee_Tax = db.Column(db.Float()) Fee_Stripe = db.Column(db.Float()) @@ -52,6 +53,10 @@ class Payments(db.Model): PI_FollowUp_JSON = db.Column(db.Text()) Error = db.Column(db.Text()) Success = db.Column(db.Boolean, nullable=True, default=None) + Refund = 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) Created = db.Column(db.DateTime, nullable=False, default=datetime.now()) PaymentPlan_ID = db.Column(db.Integer, db.ForeignKey('PaymentPlans.id'), nullable=True) @@ -64,6 +69,7 @@ class SinglePayments(db.Model): PI_FollowUp = db.Column(db.Boolean, nullable=False, default=0) PI_Last_Check = db.Column(db.DateTime, nullable=True) Payment_Method = db.Column(db.String()) + Stripe_Charge_ID = db.Column(db.String()) Stripe_Payment_Method = db.Column(db.String()) Fee_Tax = db.Column(db.Float()) Fee_Stripe = db.Column(db.Float()) @@ -73,6 +79,10 @@ class SinglePayments(db.Model): PI_FollowUp_JSON = db.Column(db.Text()) Error = db.Column(db.Text()) Success = db.Column(db.Boolean, nullable=True, default=None) + Refund = 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) Created = db.Column(db.DateTime, nullable=False, default=datetime.now()) Who = db.Column(db.Integer, db.ForeignKey('Users.id'), nullable=False) diff --git a/payments_fixup.py b/payments_fixup.py new file mode 100644 index 0000000..33579c8 --- /dev/null +++ b/payments_fixup.py @@ -0,0 +1,35 @@ +import stripe +import json +from app import create_app, db +from models import * +from sqlalchemy import and_ +api_key = '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' +stripe.api_key = api_key + + + + + +if __name__ == "__main__": + app = create_app() + print(f"api_key: {api_key}") + + + with app.app_context(): + to_check = { + "pay": db.session.query(Payments).filter(and_(Payments.Stripe_Charge_ID == None, Payments.Payment_Intent != None)).all(), + "singlepay": db.session.query(SinglePayments).filter(and_(SinglePayments.Stripe_Charge_ID == None, SinglePayments.Payment_Intent != None)).all(), + } + + for key, value in to_check.items(): + + + for pi in value: + print(f"PI: {pi.Payment_Intent}") + res = stripe.PaymentIntent.retrieve( + pi.Payment_Intent, + expand=['latest_charge.balance_transaction'] + ) + if res.get('latest_charge') and res.get('latest_charge').get('id').startswith('ch_'): + pi.Stripe_Charge_ID = res.get('latest_charge').get('id') + db.session.commit() \ No newline at end of file diff --git a/payments_fixup_find_customers.py b/payments_fixup_find_customers.py new file mode 100644 index 0000000..951e581 --- /dev/null +++ b/payments_fixup_find_customers.py @@ -0,0 +1,76 @@ + +import pymysql +import sys +import json +import random +import threading +import logging +from sqlalchemy import func +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 +) + + + +if __name__ == "__main__": + app = create_app() + + with app.app_context(): + # Find customer_ids that appear more than once + duplicate_customer_ids = db.session.query( + Payments.Stripe_Customer_ID + ).group_by( + Payments.Stripe_Customer_ID + ).having( + func.count(Payments.Stripe_Customer_ID) > 1 + ).all() + + # Get the actual duplicate records + duplicate_records = db.session.query(Payments).filter( + Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]) + ).order_by(Payments.Stripe_Customer_ID).all() + i = 0 + has_charge = 0 + for a in duplicate_records: + i += 1 + #print(a.Stripe_Customer_ID) + if a.Stripe_Charge_ID != None: + has_charge += 1 + + print(i, i/2, has_charge, has_charge/2) + + + ranked_duplicates = db.session.query( + Payments.id, + func.row_number().over( + partition_by=Payments.Stripe_Customer_ID, + order_by=Payments.id + ).label('rn') + ).filter( + Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]) + ).subquery() + + # Get only the first record (rn = 1) for each customer_id + first_duplicates = db.session.query(Payments).join( + ranked_duplicates, Payments.id == ranked_duplicates.c.id + ).filter(ranked_duplicates.c.rn == 1).order_by(Payments.Stripe_Customer_ID).all() + + i = 0 + has_charge = 0 + for a in first_duplicates: + i += 1 + #print(a.id, a.Splynx_ID) + print(a.Stripe_Charge_ID) + if a.Stripe_Charge_ID != None: + has_charge += 1 + + print(i, has_charge) \ No newline at end of file diff --git a/payments_fixup_find_customers_v2.py b/payments_fixup_find_customers_v2.py new file mode 100644 index 0000000..f50cceb --- /dev/null +++ b/payments_fixup_find_customers_v2.py @@ -0,0 +1,214 @@ + +import pymysql +import sys +import json +import random +import threading +import logging +import stripe +from sqlalchemy import func +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 + + +api_key = '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' +stripe.api_key = api_key + + + +def issue_refund_for_payment(payment_record, amount=None, reason=None): + """ + Issue a refund for a payment record using its Stripe_Charge_ID + + Args: + payment_record: The Payments model instance + amount: Optional - amount to refund in cents. If None, refunds full amount + reason: Optional - reason for refund ('duplicate', 'fraudulent', 'requested_by_customer') + + Returns: + dict: Success status and refund details + """ + try: + # Check if payment has a charge ID + if not payment_record.Stripe_Charge_ID: + return { + 'success': False, + 'error': 'No Stripe Charge ID found for this payment', + 'payment_id': payment_record.id + } + + # Check if already refunded + if payment_record.Refund: + return { + 'success': False, + 'error': 'Payment already marked as refunded', + 'payment_id': payment_record.id + } + + # Create refund parameters + refund_params = { + 'charge': payment_record.Stripe_Charge_ID + } + + if amount: + refund_params['amount'] = amount + + if reason: + refund_params['reason'] = reason + + # Issue the refund + refund = stripe.Refund.create(**refund_params) + + if refund['status'] == "succeeded": + + # Update the payment record + payment_record.Refund = True + payment_record.Stripe_Refund_ID = refund.id + payment_record.Stripe_Refund_Created = datetime.fromtimestamp(refund.created) + payment_record.Refund_JSON = json.dumps(refund) + + db.session.commit() + + return { + 'success': True, + 'refund_id': refund.id, + 'amount_refunded': refund.amount, + 'payment_id': payment_record.id, + 'refund': refund + } + else: + payment_record.Refund = False + + # Commit the changes + db.session.commit() + return { + 'success': False, + 'payment_id': payment_record.id, + 'refund': refund + } + + except stripe.error.InvalidRequestError as e: + if "has already been refunded" in str(e) and payment_record.Refund != True: + payment_record.Refund = True + payment_record.Stripe_Refund_Created = datetime.now() + db.session.commit() + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'payment_id': payment_record.id + } + except Exception as e: + db.session.rollback() + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'payment_id': payment_record.id + } + +if __name__ == "__main__": + app = create_app() + + with app.app_context(): + # Find customer_ids that appear more than once + duplicate_customer_ids = db.session.query( + Payments.Stripe_Customer_ID + ).group_by( + Payments.Stripe_Customer_ID + ).having( + func.count(Payments.Stripe_Customer_ID) > 1 + ).all() + + # Get the actual duplicate records + duplicate_records = db.session.query(Payments).filter( + Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]) + ).order_by(Payments.Stripe_Customer_ID).all() + i = 0 + has_charge = 0 + for a in duplicate_records: + i += 1 + #print(a.Stripe_Customer_ID) + if a.Stripe_Charge_ID != None: + has_charge += 1 + + print(i, i/2, has_charge, has_charge/2) + + + from sqlalchemy import func, and_ + + # Step 1: Find customer_ids that appear more than once + duplicate_customer_ids = db.session.query( + Payments.Stripe_Customer_ID + ).group_by( + Payments.Stripe_Customer_ID + ).having( + func.count(Payments.Stripe_Customer_ID) > 1 + ).all() + + # Step 2: Find customer_ids that already have any refunded payments + customers_with_refunds = db.session.query( + Payments.Stripe_Customer_ID + ).filter( + and_( + Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]), + Payments.Refund == True + ) + ).distinct().all() + + # Step 3: Get customer_ids to process (duplicates minus those with existing refunds) + customers_to_refund = [ + customer_id for customer_id in [row[0] for row in duplicate_customer_ids] + if customer_id not in [row[0] for row in customers_with_refunds] + ] + + print(f"Total duplicate customers: {len(duplicate_customer_ids)}") + print(f"Customers already with refunds: {len(customers_with_refunds)}") + print(f"Customers eligible for refund: {len(customers_to_refund)}") + + # Step 4: Get the first record for each eligible customer + if customers_to_refund: + ranked_duplicates = db.session.query( + Payments.id, + func.row_number().over( + partition_by=Payments.Stripe_Customer_ID, + order_by=Payments.id + ).label('rn') + ).filter( + Payments.Stripe_Customer_ID.in_(customers_to_refund) + ).subquery() + + # Get only the first record (rn = 1) for each customer_id + first_duplicates = db.session.query(Payments).join( + ranked_duplicates, Payments.id == ranked_duplicates.c.id + ).filter(ranked_duplicates.c.rn == 1).order_by(Payments.Stripe_Customer_ID).all() + else: + first_duplicates = [] + + # Now process refunds safely + results = [] + 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' + ) + + results.append(result) + + 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") + + print("\n\n\n") + print("="*60) + print("\n RESULTS\n\n") + print(json.dumps(results,indent=2)) \ No newline at end of file diff --git a/query_mysql.py b/query_mysql.py index 022202e..b4fdfd7 100644 --- a/query_mysql.py +++ b/query_mysql.py @@ -189,9 +189,9 @@ def query_splynx_customers(pm: int) -> Union[List[Dict[str, Any]], bool]: ## 3 - Card Payment (Automatic) ## 9 - Payment Plan - # Execute the query + # Execute the query with DISTINCT to prevent duplicate customers query = """ - SELECT + SELECT DISTINCT cb.customer_id, cb.deposit, cb.payment_method, @@ -200,29 +200,17 @@ def query_splynx_customers(pm: int) -> Union[List[Dict[str, Any]], bool]: 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' ) - ORDER BY cb.payment_method ASC + 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 """ - - #query = """ - #SELECT - # cb.customer_id, - # cb.deposit, - # cb.payment_method, - # pad.field_1 AS stripe_customer_id - #FROM customer_billing cb - #LEFT OUTER JOIN payment_account_data pad ON cb.customer_id = pad.customer_id - #WHERE cb.payment_method = %s - #AND cb.deposit < %s - #ORDER BY cb.payment_method ASC - #LIMIT %s - #""" with connection.cursor() as cursor: cursor.execute(query, (pm, Config.DEPOSIT_THRESHOLD, Config.DEFAULT_QUERY_LIMIT)) @@ -565,6 +553,9 @@ def process_payintent_mode(processor): 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": @@ -632,6 +623,7 @@ if __name__ == "__main__": # 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(): diff --git a/splynx.py b/splynx.py index 6b503e4..e829951 100644 --- a/splynx.py +++ b/splynx.py @@ -3,6 +3,7 @@ import json import requests # type: ignore import hmac import hashlib +from urllib.parse import urlencode SPLYNX_URL = 'https://billing.interphone.com.au' #SPLYNX_KEY = 'c189c78b155ee8e4d389bbcb34bebc05' @@ -76,6 +77,40 @@ class Splynx(): return True return False + # Helper function to build query parameters for Splynx API + def build_splynx_query_params(self, params): + """ + Convert nested dictionary to flattened query parameters that Splynx expects + + Args: + params (dict): Dictionary containing search parameters + + Returns: + str: Query string for the API URL + """ + def flatten_params(data, parent_key=''): + items = [] + for k, v in data.items(): + new_key = f"{parent_key}[{k}]" if parent_key else k + + if isinstance(v, dict): + items.extend(flatten_params(v, new_key).items()) + elif isinstance(v, list): + for i, item in enumerate(v): + if isinstance(item, list): + # Handle nested lists like ['male', 'female'] within ['IN', ['male', 'female']] + for j, sub_item in enumerate(item): + items.append((f"{new_key}[{i}][{j}]", sub_item)) + else: + items.append((f"{new_key}[{i}]", item)) + else: + items.append((new_key, v)) + + return dict(items) + + flattened_params = flatten_params(params) + return urlencode(flattened_params, doseq=True) + def ServiceStatus(self, service_login): try: diff --git a/static/css/custom.css b/static/css/custom.css index e8174cc..b72060a 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -329,4 +329,98 @@ code { border: 3px solid var(--plutus-gold); margin: 2rem auto; display: block; +} + +/* Payment Status Colors */ +:root { + --status-success: #90ee90; /* Light green for Success */ + --status-pending: #f5deb3; /* Light mustard for Pending */ + --status-refund: #dda0dd; /* Light purple for Refund */ + --status-failed: #ffcccb; /* Light red for Failed */ +} + +/* Payment Status Badges */ +.status-badge { + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + text-align: center; + display: inline-flex; + align-items: center; + gap: 0.375rem; + border: 1px solid transparent; +} + +.status-badge.success { + background-color: var(--status-success); + color: #2d5016; + border-color: #7ab317; +} + +.status-badge.pending { + background-color: var(--status-pending); + color: #8b4513; + border-color: #daa520; +} + +.status-badge.refund { + background-color: var(--status-refund); + color: #4b0082; + border-color: #9370db; +} + +.status-badge.failed { + background-color: var(--status-failed); + color: #8b0000; + border-color: #dc143c; +} + +/* Status Icons */ +.status-badge i.fas { + font-size: 0.875rem; +} + +/* Hover effects for clickable status badges */ +.status-badge.clickable { + cursor: pointer; + transition: all 0.2s ease; +} + +.status-badge.clickable:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +/* Error Alert Styling */ +.error-alert { + margin-bottom: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.875rem; + border: 1px solid; +} + +.error-alert.insufficient-funds { + background-color: #ffe4e1; + color: #8b0000; + border-color: #dc143c; +} + +.error-alert.incorrect-card { + background-color: #fff8dc; + color: #8b4513; + border-color: #daa520; +} + +.error-alert.general-decline { + background-color: #ffebcd; + color: #a0522d; + border-color: #cd853f; +} + +.error-alert.bank-contact { + background-color: #e6e6fa; + color: #4b0082; + border-color: #9370db; } \ No newline at end of file diff --git a/stripe_payment_processor.py b/stripe_payment_processor.py index 8415499..16ac65b 100644 --- a/stripe_payment_processor.py +++ b/stripe_payment_processor.py @@ -32,17 +32,18 @@ class StripePaymentProcessor: log_level (int): Logging level if logging is enabled """ # Set up Stripe API key + #print(f"processor api_key: {api_key}") if api_key: stripe.api_key = api_key else: stripe.api_key = os.getenv('STRIPE_SECRET_KEY') - + #print(f"processor api_key: {stripe.api_key}") if not stripe.api_key: raise ValueError("Stripe API key is required. Provide via api_key parameter or STRIPE_SECRET_KEY environment variable.") # Validate API key format - if not (stripe.api_key.startswith('sk_test_') or stripe.api_key.startswith('sk_live_')): - raise ValueError("Invalid Stripe API key format. Key should start with 'sk_test_' or 'sk_live_'") + if not (stripe.api_key.startswith('sk_test_') or stripe.api_key.startswith('rk_live_')): + raise ValueError("Invalid Stripe API key format. Key should start with 'sk_test_' or 'rk_live_'") self.is_test_mode = stripe.api_key.startswith('sk_test_') @@ -626,6 +627,9 @@ class StripePaymentProcessor: self._log('info', f"Retrieved payment intent with expanded data") + Stripe_Charge_ID = None + if payment_intent.get('latest_charge') and payment_intent.get('latest_charge').get('id').startswith('ch_'): + Stripe_Charge_ID = payment_intent.get('latest_charge').get('id') # Base response response = { 'success': True, @@ -638,7 +642,8 @@ class StripePaymentProcessor: 'customer_id': payment_intent.customer, 'payment_method_id': payment_intent.payment_method, 'test_mode': self.is_test_mode, - 'timestamp': datetime.now().isoformat() + 'timestamp': datetime.now().isoformat(), + 'charge_id': Stripe_Charge_ID } # Add status-specific information diff --git a/templates/main/batch_detail.html b/templates/main/batch_detail.html index 249d2d0..8dce5a5 100644 --- a/templates/main/batch_detail.html +++ b/templates/main/batch_detail.html @@ -125,6 +125,7 @@ + @@ -171,6 +172,7 @@ + @@ -197,6 +199,12 @@ {% endif %} + @@ -353,6 +387,27 @@ {% endif %} + + {% if payment.Refund_JSON %} + + {% endif %} + {% if payment.Error %}
Payment ID Splynx ID Stripe Customer Payment Intent
+ + #{{ payment.id }} + + {% if payment.Splynx_ID %} {% endif %} + {% if payment.Refund_JSON %} + + {% endif %} + {% if payment.Error %} - {% if payment.Success == True %} - Success + {% if payment.Refund == True %} + + + Refund + + {% elif payment.Success == True %} + + + Success + + {% elif payment.Success == False and payment.PI_FollowUp %} + + + Pending + {% elif payment.Success == False %} - Failed + + + Failed + {% else %} - Pending + + + Pending + {% endif %}
+ + + + + + + + + + + + + + + + + + + + + + {% if payment.Stripe_Charge_ID %} + + + + + {% endif %} + {% if payment.Stripe_Refund_ID %} + + + + + {% endif %} + + + + + + + + + {% if payment.PaymentPlan_ID %} + + + + + {% endif %} + +
Payment ID#{{ payment.id }}
Batch ID + + #{{ payment.PaymentBatch_ID }} + +
Splynx Customer ID + {% if payment.Splynx_ID %} + {{ payment.Splynx_ID }} + {% else %} + - + {% endif %} +
Stripe Customer ID{{ payment.Stripe_Customer_ID or '-' }}
Payment Intent{{ payment.Payment_Intent or '-' }}
Stripe Charge ID{{ payment.Stripe_Charge_ID }}
Stripe Refund ID{{ payment.Stripe_Refund_ID }}
Payment Method + {% if payment.Payment_Method %} + {{ payment.Payment_Method }} + {% else %} + - + {% endif %} +
Created{{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else '-' }}
Payment Plan + + Plan #{{ payment.PaymentPlan_ID }} + +
+ + + +
+
+

+ + Financial Details +

+ + + + + + + + + + + + + + + + + + + + {% if payment.Fee_Total and payment.Payment_Amount %} + + + + + {% endif %} + +
Payment Amount{{ payment.Payment_Amount | currency }}
Stripe Fee{{ payment.Fee_Stripe | currency if payment.Fee_Stripe else '-' }}
Tax Fee{{ payment.Fee_Tax | currency if payment.Fee_Tax else '-' }}
Total Fees{{ payment.Fee_Total | currency if payment.Fee_Total else '-' }}
Net Amount{{ (payment.Payment_Amount - payment.Fee_Total) | currency }}
+ + {% if payment.PI_FollowUp %} +
+ + Follow-up Required: This payment requires additional processing. + {% if payment.PI_Last_Check %} +
Last checked: {{ payment.PI_Last_Check.strftime('%Y-%m-%d %H:%M:%S') }} + {% endif %} +
+ {% endif %} + + {% if payment.Refund == True %} +
+ + Refund Processed: This payment has been refunded. + {% if payment.Stripe_Refund_Created %} +
Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }} + {% endif %} +
+ {% endif %} +
+
+ + + +{% if payment.Error %} +
+

+ + Error Information +

+ +
+
{{ payment.Error }}
+
+
+{% endif %} + + +
+ {% if payment.PI_JSON %} +
+
+

+ + Payment Intent JSON +

+ +
+
+ +
+
+ +
{{ payment.PI_JSON | format_json }}
+ +
+
+ {% endif %} + + {% if payment.PI_FollowUp_JSON %} +
+
+

+ + Follow-up JSON +

+ +
+
+ +
+
+ +
{{ payment.PI_FollowUp_JSON | format_json }}
+ +
+
+ {% endif %} + + {% if payment.Refund_JSON %} +
+
+

+ + Refund JSON +

+ +
+
+ +
+
+ +
{{ payment.Refund_JSON | format_json }}
+ +
+
+ {% endif %} +
+ + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/main/payment_plans_detail.html b/templates/main/payment_plans_detail.html index bf7e5b0..8d1252d 100644 --- a/templates/main/payment_plans_detail.html +++ b/templates/main/payment_plans_detail.html @@ -218,12 +218,31 @@ {{ payment.Payment_Amount | currency }} - {% if payment.Success == True %} - Success + {% if payment.Refund == True %} + + + Refund + + {% elif payment.Success == True %} + + + Success + + {% elif payment.Success == False and payment.PI_FollowUp %} + + + Pending + {% elif payment.Success == False %} - Failed + + + Failed + {% else %} - Pending + + + Pending + {% endif %} diff --git a/templates/main/single_payments_list.html b/templates/main/single_payments_list.html index 6e3b196..5f14c78 100644 --- a/templates/main/single_payments_list.html +++ b/templates/main/single_payments_list.html @@ -53,6 +53,7 @@ + @@ -202,6 +203,13 @@ {% endif %} + {% if payment.Refund_JSON %} + + {% endif %} + {% if payment.Error %} + + + + + {% endif %} + {% if payment.Error %}