Browse Source

New Features

master
Alan Woodman 4 months ago
parent
commit
a3f9e7744f
  1. 195
      blueprints/main.py
  2. 4
      config.py
  3. 59
      delete_splynx_payments.py
  4. 60
      migrations/versions/1af0e892bd5d_add_new_charge_and_refund_features.py
  5. 10
      models.py
  6. 35
      payments_fixup.py
  7. 76
      payments_fixup_find_customers.py
  8. 214
      payments_fixup_find_customers_v2.py
  9. 26
      query_mysql.py
  10. 35
      splynx.py
  11. 94
      static/css/custom.css
  12. 13
      stripe_payment_processor.py
  13. 89
      templates/main/batch_detail.html
  14. 594
      templates/main/payment_detail.html
  15. 27
      templates/main/payment_plans_detail.html
  16. 60
      templates/main/single_payments_list.html
  17. 26
      wsgi.py

195
blueprints/main.py

@ -325,33 +325,24 @@ def single_payment_detail(payment_id):
@main_bp.route('/payment/detail/<int:payment_id>')
@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/<int:payment_id>', 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/<int:payment_id>', methods=['POST'])
@login_required
def check_batch_payment_intent(payment_id):
"""Check the status of a batch payment intent and update the record."""
from datetime import datetime
try:
# Get the payment record from Payments table (batch payments)
payment = Payments.query.get_or_404(payment_id)
if not payment.Payment_Intent:
return jsonify({'success': False, 'error': 'No payment intent found'}), 400
# Initialize Stripe processor with correct API key
if Config.PROCESS_LIVE:
api_key = Config.STRIPE_LIVE_API_KEY
else:
api_key = Config.STRIPE_TEST_API_KEY
processor = StripePaymentProcessor(api_key=api_key, enable_logging=True)
# Check payment intent status
intent_result = processor.check_payment_intent(payment.Payment_Intent)
if intent_result['status'] == "succeeded":
payment.PI_FollowUp_JSON = json.dumps(intent_result)
payment.PI_FollowUp = False
payment.PI_Last_Check = datetime.now()
payment.Success = True
if intent_result.get('charge_id'):
payment.Stripe_Charge_ID = intent_result.get('charge_id')
processPaymentResult(pay_id=payment.id, result=intent_result, key="pay")
elif intent_result['status'] == "failed":
payment.PI_FollowUp_JSON = json.dumps(intent_result)
payment.PI_FollowUp = False
payment.PI_Last_Check = datetime.now()
payment.Success = False
else:
# Still pending
payment.PI_FollowUp_JSON = json.dumps(intent_result)
payment.PI_Last_Check = datetime.now()
db.session.commit()
# Log the intent check activity
log_activity(
user_id=current_user.id,
action="check_payment_intent",
entity_type="payment",
entity_id=payment_id,
details=f"Checked payment intent {payment.Payment_Intent}, status: {intent_result['status']}"
)
return jsonify({
'success': True,
'status': intent_result['status'],
'payment_succeeded': intent_result['status'] == "succeeded",
'message': f'Payment intent status: {intent_result["status"]}'
})
except Exception as e:
db.session.rollback()
print(f"Check batch payment intent error: {e}")
return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500
@main_bp.route('/payment/refund/<int:payment_id>', methods=['POST'])
@login_required
def process_payment_refund(payment_id):
"""Process a refund for a batch payment."""
from datetime import datetime
import stripe
try:
# Get the payment record from Payments table (batch payments)
payment = Payments.query.get_or_404(payment_id)
# Validate payment can be refunded
if not payment.Success:
return jsonify({'success': False, 'error': 'Cannot refund an unsuccessful payment'}), 400
if payment.Refund:
return jsonify({'success': False, 'error': 'Payment has already been refunded'}), 400
if not payment.Stripe_Charge_ID:
return jsonify({'success': False, 'error': 'No Stripe charge ID found for this payment'}), 400
# Get refund reason from request
data = request.get_json()
reason = data.get('reason', 'requested_by_customer')
# Initialize Stripe with correct API key
if Config.PROCESS_LIVE:
stripe.api_key = Config.STRIPE_LIVE_API_KEY
else:
stripe.api_key = Config.STRIPE_TEST_API_KEY
# Create refund parameters
refund_params = {
'charge': payment.Stripe_Charge_ID,
'reason': reason
}
# Process the refund with Stripe
refund = stripe.Refund.create(**refund_params)
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/<int:id>')
@login_required
def api_splynx_customer(id):

4
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

59
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))

60
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 ###

10
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)

35
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()

76
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)

214
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))

26
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,30 +200,18 @@ 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))
results = cursor.fetchall()
@ -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():

35
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:

94
static/css/custom.css

@ -330,3 +330,97 @@ code {
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;
}

13
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

89
templates/main/batch_detail.html

@ -125,6 +125,7 @@
<option value="failed">Failed Only</option>
<option value="pending">Pending Only</option>
<option value="followup">Follow Up Required</option>
<option value="refund">Refunds Only</option>
<option value="error">Has Errors</option>
</select>
</div>
@ -171,6 +172,7 @@
<table class="table is-fullwidth is-striped is-hoverable" id="paymentsTable">
<thead>
<tr>
<th>Payment ID</th>
<th>Splynx ID</th>
<th>Stripe Customer</th>
<th>Payment Intent</th>
@ -197,6 +199,12 @@
{% endif %}
<tr class="{{ row_class }}">
<td>
<a href="{{ url_for('main.payment_detail', payment_id=payment.id) }}"
class="has-text-weight-semibold has-text-primary">
#{{ payment.id }}
</a>
</td>
<td>
{% if payment.Splynx_ID %}
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ payment.Splynx_ID }}"
@ -280,6 +288,13 @@
</button>
{% endif %}
{% if payment.Refund_JSON %}
<button class="button is-outlined" style="border-color: #9370db; color: #9370db;" onclick="showModal('refund-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>Refund</span>
</button>
{% endif %}
{% if payment.Error %}
<button class="button is-danger is-outlined" onclick="showModal('error-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
@ -289,12 +304,31 @@
</div>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% if payment.Refund == True %}
<span class="status-badge refund">
<i class="fas fa-undo"></i>
Refund
</span>
{% elif payment.Success == True %}
<span class="status-badge success">
<i class="fas fa-check"></i>
Success
</span>
{% elif payment.Success == False and payment.PI_FollowUp %}
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Pending
</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
<span class="status-badge failed">
<i class="fas fa-times"></i>
Failed
</span>
{% else %}
<span class="tag is-warning">Pending</span>
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Pending
</span>
{% endif %}
</td>
</tr>
@ -353,6 +387,27 @@
</div>
{% endif %}
<!-- Refund_JSON Modal -->
{% if payment.Refund_JSON %}
<div class="modal" id="refund-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('refund-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Refund JSON - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('refund-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<pre><code class="language-json">{{ payment.Refund_JSON | format_json }}</code></pre>
<button class="button is-small" style="border-color: #9370db; color: #9370db;" onclick="copyFormattedJSON('refund-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
<div id="refund-content-{{ payment.id }}" style="display: none;">{{ payment.Refund_JSON | format_json }}</div>
</section>
</div>
</div>
{% endif %}
<!-- Error Modal -->
{% if payment.Error %}
<div class="modal" id="error-modal-{{ payment.id }}">
@ -397,20 +452,22 @@ function initializePayments() {
const cells = row.querySelectorAll('td');
return {
element: row,
splynxId: cells[0] ? (cells[0].textContent.trim() || '') : '',
stripeCustomerId: cells[1] ? (cells[1].textContent.trim() || '') : '',
paymentIntent: cells[2] ? (cells[2].textContent.trim() || '') : '',
followUp: cells[3] ? (cells[3].textContent.trim() || '') : '',
lastCheck: cells[4] ? (cells[4].textContent.trim() || '') : '',
paymentMethod: cells[5] ? (cells[5].textContent.trim() || '') : '',
stripeFee: cells[6] ? (cells[6].textContent.trim() || '') : '',
amount: cells[7] ? (cells[7].textContent.trim() || '') : '',
status: cells[9] ? (cells[9].textContent.trim() || '') : '',
paymentId: cells[0] ? (cells[0].textContent.trim() || '') : '',
splynxId: cells[1] ? (cells[1].textContent.trim() || '') : '',
stripeCustomerId: cells[2] ? (cells[2].textContent.trim() || '') : '',
paymentIntent: cells[3] ? (cells[3].textContent.trim() || '') : '',
followUp: cells[4] ? (cells[4].textContent.trim() || '') : '',
lastCheck: cells[5] ? (cells[5].textContent.trim() || '') : '',
paymentMethod: cells[6] ? (cells[6].textContent.trim() || '') : '',
stripeFee: cells[7] ? (cells[7].textContent.trim() || '') : '',
amount: cells[8] ? (cells[8].textContent.trim() || '') : '',
status: cells[10] ? (cells[10].textContent.trim() || '') : '',
success: row.classList.contains('has-background-success-light'),
failed: row.classList.contains('has-background-danger-light'),
pending: row.classList.contains('has-background-info-light'),
followUpRequired: row.classList.contains('has-background-warning-light'),
hasError: cells[8] && cells[8].querySelector('button.is-danger')
refund: cells[10] && cells[10].textContent.includes('Refund'),
hasError: cells[9] && cells[9].querySelector('button.is-danger')
};
});
@ -453,6 +510,7 @@ function applyFilters() {
filteredPayments = allPayments.filter(payment => {
// Search filter
const searchMatch = !searchTerm ||
payment.paymentId.toLowerCase().includes(searchTerm) ||
payment.splynxId.toLowerCase().includes(searchTerm) ||
payment.stripeCustomerId.toLowerCase().includes(searchTerm) ||
payment.paymentIntent.toLowerCase().includes(searchTerm);
@ -472,6 +530,9 @@ function applyFilters() {
case 'followup':
statusMatch = payment.followUpRequired;
break;
case 'refund':
statusMatch = payment.refund;
break;
case 'error':
statusMatch = payment.hasError;
break;

594
templates/main/payment_detail.html

@ -0,0 +1,594 @@
{% extends "base.html" %}
{% block title %}Payment #{{ payment.id }} - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li><a href="{{ url_for('main.batch_list') }}">Payment Batches</a></li>
<li><a href="{{ url_for('main.batch_detail', batch_id=payment.PaymentBatch_ID) }}">Batch #{{ payment.PaymentBatch_ID }}</a></li>
<li class="is-active"><a href="#" aria-current="page">Payment #{{ payment.id }}</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Batch Payment #{{ payment.id }}</h1>
<p class="subtitle">Batch #{{ payment.PaymentBatch_ID }} • Processed: {{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else 'Unknown' }}</p>
</div>
</div>
<div class="level-right">
<div class="field is-grouped">
{% if payment.Refund != True and payment.Success == True %}
<div class="control">
<button class="button is-warning" id="refundBtn" onclick="showRefundModal()">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>Process Refund</span>
</button>
</div>
{% endif %}
{% if payment.PI_FollowUp %}
<div class="control">
<button class="button is-info" id="checkIntentBtn" onclick="checkPaymentIntent()">
<span class="icon"><i class="fas fa-sync-alt"></i></span>
<span>Check Status</span>
</button>
</div>
{% endif %}
<div class="control">
<a class="button is-light" href="{{ url_for('main.batch_detail', batch_id=payment.PaymentBatch_ID) }}">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back to Batch</span>
</a>
</div>
</div>
</div>
</div>
<!-- Payment Status Banner -->
<div class="box">
<div class="level">
<div class="level-left">
<div class="level-item">
{% if payment.Refund == True %}
<span class="icon is-large" style="color: #9370db;">
<i class="fas fa-undo fa-2x"></i>
</span>
{% elif payment.Success == True %}
<span class="icon is-large has-text-success">
<i class="fas fa-check-circle fa-2x"></i>
</span>
{% elif payment.Success == False %}
<span class="icon is-large has-text-danger">
<i class="fas fa-times-circle fa-2x"></i>
</span>
{% else %}
<span class="icon is-large has-text-warning">
<i class="fas fa-clock fa-2x"></i>
</span>
{% endif %}
</div>
<div class="level-item">
<div>
{% if payment.Refund == True %}
<h2 class="title is-4 mb-2" style="color: #9370db;">Payment Refunded</h2>
<p class="has-text-grey">This payment has been refunded to the customer.</p>
{% if payment.Stripe_Refund_Created %}
<p class="has-text-grey is-size-7">Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% endif %}
{% elif payment.Success == True %}
<h2 class="title is-4 has-text-success mb-2">Payment Successful</h2>
<p class="has-text-grey">This payment has been completed successfully.</p>
{% elif payment.Success == False %}
<h2 class="title is-4 has-text-danger mb-2">Payment Failed</h2>
<p class="has-text-grey">This payment could not be completed.</p>
{% else %}
<h2 class="title is-4 has-text-warning mb-2">Payment Pending</h2>
<p class="has-text-grey">This payment is still being processed.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Payment Details -->
<div class="columns">
<div class="column is-half">
<div class="box">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-info-circle"></i></span>
Payment Information
</h3>
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>Payment ID</strong></td>
<td>#{{ payment.id }}</td>
</tr>
<tr>
<td><strong>Batch ID</strong></td>
<td>
<a href="{{ url_for('main.batch_detail', batch_id=payment.PaymentBatch_ID) }}" class="has-text-weight-semibold">
#{{ payment.PaymentBatch_ID }}
</a>
</td>
</tr>
<tr>
<td><strong>Splynx Customer ID</strong></td>
<td>
{% if payment.Splynx_ID %}
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ payment.Splynx_ID }}"
target="_blank">{{ payment.Splynx_ID }}</a>
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<td><strong>Stripe Customer ID</strong></td>
<td><code>{{ payment.Stripe_Customer_ID or '-' }}</code></td>
</tr>
<tr>
<td><strong>Payment Intent</strong></td>
<td><code>{{ payment.Payment_Intent or '-' }}</code></td>
</tr>
{% if payment.Stripe_Charge_ID %}
<tr>
<td><strong>Stripe Charge ID</strong></td>
<td><code>{{ payment.Stripe_Charge_ID }}</code></td>
</tr>
{% endif %}
{% if payment.Stripe_Refund_ID %}
<tr>
<td><strong>Stripe Refund ID</strong></td>
<td><code>{{ payment.Stripe_Refund_ID }}</code></td>
</tr>
{% endif %}
<tr>
<td><strong>Payment Method</strong></td>
<td>
{% if payment.Payment_Method %}
<span class="tag is-info">{{ payment.Payment_Method }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<td><strong>Created</strong></td>
<td>{{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else '-' }}</td>
</tr>
{% if payment.PaymentPlan_ID %}
<tr>
<td><strong>Payment Plan</strong></td>
<td>
<a href="{{ url_for('main.payment_plans_detail', plan_id=payment.PaymentPlan_ID) }}" class="has-text-weight-semibold">
Plan #{{ payment.PaymentPlan_ID }}
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="column is-half">
<div class="box">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-dollar-sign"></i></span>
Financial Details
</h3>
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>Payment Amount</strong></td>
<td><strong class="has-text-success">{{ payment.Payment_Amount | currency }}</strong></td>
</tr>
<tr>
<td><strong>Stripe Fee</strong></td>
<td>{{ payment.Fee_Stripe | currency if payment.Fee_Stripe else '-' }}</td>
</tr>
<tr>
<td><strong>Tax Fee</strong></td>
<td>{{ payment.Fee_Tax | currency if payment.Fee_Tax else '-' }}</td>
</tr>
<tr>
<td><strong>Total Fees</strong></td>
<td>{{ payment.Fee_Total | currency if payment.Fee_Total else '-' }}</td>
</tr>
{% if payment.Fee_Total and payment.Payment_Amount %}
<tr>
<td><strong>Net Amount</strong></td>
<td><strong>{{ (payment.Payment_Amount - payment.Fee_Total) | currency }}</strong></td>
</tr>
{% endif %}
</tbody>
</table>
{% if payment.PI_FollowUp %}
<div class="notification is-warning is-light">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<strong>Follow-up Required:</strong> This payment requires additional processing.
{% if payment.PI_Last_Check %}
<br><small>Last checked: {{ payment.PI_Last_Check.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %}
</div>
{% endif %}
{% if payment.Refund == True %}
<div class="notification is-light" style="background-color: #f8f4ff; border-color: #9370db;">
<span class="icon" style="color: #9370db;"><i class="fas fa-undo"></i></span>
<strong style="color: #9370db;">Refund Processed:</strong> This payment has been refunded.
{% if payment.Stripe_Refund_Created %}
<br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Error Information -->
{% if payment.Error %}
<div class="box">
<h3 class="title is-5 has-text-danger">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
Error Information
</h3>
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
</div>
</div>
{% endif %}
<!-- JSON Data -->
<div class="columns">
{% if payment.PI_JSON %}
<div class="column">
<div class="box">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-code"></i></span>
Payment Intent JSON
</h3>
<div class="field is-grouped">
<div class="control">
<button class="button is-small is-info" onclick="copyFormattedJSON('pi-json-content')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
</div>
</div>
<pre class="is-size-7"><code>{{ payment.PI_JSON | format_json }}</code></pre>
<div id="pi-json-content" style="display: none;">{{ payment.PI_JSON | format_json }}</div>
</div>
</div>
{% endif %}
{% if payment.PI_FollowUp_JSON %}
<div class="column">
<div class="box">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-redo"></i></span>
Follow-up JSON
</h3>
<div class="field is-grouped">
<div class="control">
<button class="button is-small is-primary" onclick="copyFormattedJSON('followup-json-content')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
</div>
</div>
<pre class="is-size-7"><code>{{ payment.PI_FollowUp_JSON | format_json }}</code></pre>
<div id="followup-json-content" style="display: none;">{{ payment.PI_FollowUp_JSON | format_json }}</div>
</div>
</div>
{% endif %}
{% if payment.Refund_JSON %}
<div class="column">
<div class="box">
<h3 class="title is-5">
<span class="icon" style="color: #9370db;"><i class="fas fa-undo"></i></span>
Refund JSON
</h3>
<div class="field is-grouped">
<div class="control">
<button class="button is-small" style="border-color: #9370db; color: #9370db;" onclick="copyFormattedJSON('refund-json-content')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
</div>
</div>
<pre class="is-size-7"><code>{{ payment.Refund_JSON | format_json }}</code></pre>
<div id="refund-json-content" style="display: none;">{{ payment.Refund_JSON | format_json }}</div>
</div>
</div>
{% endif %}
</div>
<!-- Refund Modal -->
<div class="modal" id="refundModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head" style="background-color: #9370db;">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-undo"></i></span>
Process Refund
</p>
<button class="delete" aria-label="close" onclick="hideModal('refundModal')"></button>
</header>
<section class="modal-card-body">
<div class="has-text-centered py-4">
<span class="icon is-large mb-4" style="color: #9370db;">
<i class="fas fa-undo fa-3x"></i>
</span>
<div class="content">
<h4 class="title is-5">Confirm Refund</h4>
<p>Are you sure you want to process a refund for this payment?</p>
<p><strong>Payment Amount:</strong> {{ payment.Payment_Amount | currency }}</p>
<p><strong>Customer:</strong> {{ payment.Splynx_ID }}</p>
<div class="field">
<label class="label">Refund Reason</label>
<div class="control">
<div class="select is-fullwidth">
<select id="refundReason">
<option value="requested_by_customer">Requested by Customer</option>
<option value="duplicate">Duplicate Payment</option>
<option value="fraudulent">Fraudulent</option>
</select>
</div>
</div>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-center">
<button class="button" style="border-color: #9370db; color: #9370db;" onclick="processRefund()" id="processRefundBtn">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>Process Refund</span>
</button>
<button class="button" onclick="hideModal('refundModal')">Cancel</button>
</footer>
</div>
</div>
<!-- Success Modal -->
<div class="modal" id="successModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-success">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-check-circle"></i></span>
Success
</p>
</header>
<section class="modal-card-body">
<div class="has-text-centered py-4">
<span class="icon is-large has-text-success mb-4">
<i class="fas fa-check-circle fa-3x"></i>
</span>
<div id="successMessage" class="content">
<!-- Success details will be populated here -->
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-center">
<button class="button is-success" onclick="closeSuccessModal()">
<span class="icon"><i class="fas fa-check"></i></span>
<span>Refresh Page</span>
</button>
</footer>
</div>
</div>
<!-- Error Modal -->
<div class="modal" id="errorModal">
<div class="modal-background" onclick="hideModal('errorModal')"></div>
<div class="modal-card">
<header class="modal-card-head has-background-danger">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-exclamation-circle"></i></span>
Error
</p>
<button class="delete" aria-label="close" onclick="hideModal('errorModal')"></button>
</header>
<section class="modal-card-body">
<div class="has-text-centered py-4">
<span class="icon is-large has-text-danger mb-4">
<i class="fas fa-exclamation-circle fa-3x"></i>
</span>
<div id="errorDetails" class="content">
<!-- Error details will be populated here -->
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-center">
<button class="button is-danger" onclick="hideModal('errorModal')">
<span class="icon"><i class="fas fa-times"></i></span>
<span>Close</span>
</button>
</footer>
</div>
</div>
<script>
function checkPaymentIntent() {
const btn = document.getElementById('checkIntentBtn');
const originalText = btn.innerHTML;
// Disable button and show loading
btn.disabled = true;
btn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Checking...</span>';
// Make API call to check payment intent
fetch(`/payment/check-intent/{{ payment.id }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.payment_succeeded) {
showSuccessModal(`
<h4 class="title is-5">Payment Completed Successfully!</h4>
<p>The payment has been processed and completed.</p>
<p><strong>Status:</strong> ${data.status}</p>
`);
} else {
showSuccessModal(`
<h4 class="title is-5">Status Updated</h4>
<p>Payment status has been updated.</p>
<p><strong>Current Status:</strong> ${data.status}</p>
`);
}
} else {
showErrorModal(data.error || 'Failed to check payment intent status');
}
})
.catch(error => {
console.error('Error checking payment intent:', error);
showErrorModal('Failed to check payment intent status. Please try again.');
})
.finally(() => {
// Re-enable button
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function showRefundModal() {
document.getElementById('refundModal').classList.add('is-active');
}
function processRefund() {
const btn = document.getElementById('processRefundBtn');
const originalText = btn.innerHTML;
const reason = document.getElementById('refundReason').value;
// Disable button and show loading
btn.disabled = true;
btn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Processing...</span>';
// Make API call to process refund
fetch(`/payment/refund/{{ payment.id }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
reason: reason
})
})
.then(response => response.json())
.then(data => {
hideModal('refundModal');
if (data.success) {
showSuccessModal(`
<h4 class="title is-5">Refund Processed Successfully!</h4>
<p>The refund has been processed and sent to Stripe.</p>
<p><strong>Refund ID:</strong> ${data.refund_id}</p>
<p><strong>Amount:</strong> ${data.amount_refunded}</p>
`);
} else {
showErrorModal(data.error || 'Failed to process refund');
}
})
.catch(error => {
hideModal('refundModal');
console.error('Error processing refund:', error);
showErrorModal('Failed to process refund. 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');
}
function showErrorModal(errorMessage) {
document.getElementById('errorDetails').innerHTML = `<p>${errorMessage}</p>`;
document.getElementById('errorModal').classList.add('is-active');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('is-active');
}
function closeSuccessModal() {
hideModal('successModal');
// Refresh the page to show updated data
window.location.reload();
}
// Copy to clipboard functionality
function copyFormattedJSON(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent || element.innerText;
navigator.clipboard.writeText(text).then(function() {
// Show temporary success message
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
} catch (fallbackErr) {
console.error('Fallback copy failed: ', fallbackErr);
}
document.body.removeChild(textArea);
});
}
// Close modal on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const activeModals = document.querySelectorAll('.modal.is-active');
activeModals.forEach(modal => modal.classList.remove('is-active'));
}
});
</script>
{% endblock %}

27
templates/main/payment_plans_detail.html

@ -218,12 +218,31 @@
<strong>{{ payment.Payment_Amount | currency }}</strong>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% if payment.Refund == True %}
<span class="status-badge refund">
<i class="fas fa-undo"></i>
Refund
</span>
{% elif payment.Success == True %}
<span class="status-badge success">
<i class="fas fa-check"></i>
Success
</span>
{% elif payment.Success == False and payment.PI_FollowUp %}
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Pending
</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
<span class="status-badge failed">
<i class="fas fa-times"></i>
Failed
</span>
{% else %}
<span class="tag is-warning">Pending</span>
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Pending
</span>
{% endif %}
</td>
<td>

60
templates/main/single_payments_list.html

@ -53,6 +53,7 @@
<option value="success">Successful Only</option>
<option value="failed">Failed Only</option>
<option value="pending">Pending Only</option>
<option value="refund">Refunds Only</option>
<option value="error">Has Errors</option>
</select>
</div>
@ -202,6 +203,13 @@
</button>
{% endif %}
{% if payment.Refund_JSON %}
<button class="button is-outlined" style="border-color: #9370db; color: #9370db;" onclick="showModal('refund-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>Refund</span>
</button>
{% endif %}
{% if payment.Error %}
<button class="button is-danger is-outlined" onclick="showModal('error-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
@ -211,12 +219,31 @@
</div>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% if payment.Refund == True %}
<span class="status-badge refund">
<i class="fas fa-undo"></i>
Refund
</span>
{% elif payment.Success == True %}
<span class="status-badge success">
<i class="fas fa-check"></i>
Success
</span>
{% elif payment.Success == False and payment.PI_FollowUp %}
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Pending
</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
<span class="status-badge failed">
<i class="fas fa-times"></i>
Failed
</span>
{% else %}
<span class="tag is-warning">Pending</span>
<span class="status-badge pending">
<i class="fas fa-clock"></i>
Pending
</span>
{% endif %}
</td>
</tr>
@ -254,6 +281,27 @@
</div>
{% endif %}
<!-- Refund_JSON Modal -->
{% if payment.Refund_JSON %}
<div class="modal" id="refund-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('refund-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Refund JSON - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('refund-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<pre><code class="language-json">{{ payment.Refund_JSON | format_json }}</code></pre>
<button class="button is-small" style="border-color: #9370db; color: #9370db;" onclick="copyFormattedJSON('refund-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
<div id="refund-content-{{ payment.id }}" style="display: none;">{{ payment.Refund_JSON | format_json }}</div>
</section>
</div>
</div>
{% endif %}
<!-- Error Modal -->
{% if payment.Error %}
<div class="modal" id="error-modal-{{ payment.id }}">
@ -311,6 +359,7 @@ function initializePayments() {
success: row.classList.contains('has-background-success-light'),
failed: row.classList.contains('has-background-danger-light'),
pending: row.classList.contains('has-background-info-light'),
refund: cells[10] && cells[10].textContent.includes('Refund'),
hasError: cells[9] && cells[9].querySelector('button.is-danger')
};
});
@ -371,6 +420,9 @@ function applyFilters() {
case 'pending':
statusMatch = payment.pending;
break;
case 'refund':
statusMatch = payment.refund;
break;
case 'error':
statusMatch = payment.hasError;
break;

26
wsgi.py

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
WSGI entry point for Plutus Flask application.
This file is used by gunicorn and other WSGI servers to serve the application.
"""
import sys
import os
# Add the application directory to Python path
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, current_dir)
# Import the create_app function from app.py
from app import create_app
# Create the application instance
application = create_app()
# Gunicorn looks for 'application' by default, but we can also provide gunicorn_app
gunicorn_app = application
if __name__ == "__main__":
# For development - run with python wsgi.py
application.run(debug=False, host='0.0.0.0', port=5000)
Loading…
Cancel
Save