From 60fe478d739e3983055f1f06142794650d465052 Mon Sep 17 00:00:00 2001 From: Alan Woodman Date: Tue, 13 Jan 2026 13:01:06 +0800 Subject: [PATCH] major rewrite --- .gitignore | 22 +- LOGGING.md | 392 ------ .../delete_splynx_payments.py | 0 .../payments_fixup.py | 0 archive/payments_fixup_find_PIs.py | 113 ++ .../payments_fixup_find_customers.py | 0 .../payments_fixup_find_customers_v2.py | 0 archive/pending_fixup.py | 95 ++ .../query_mysql - Copy.py | 0 query_mysql-bak.py => archive/query_mysql.py | 503 +++++++- archive/query_mysql.py.backup | 1095 +++++++++++++++++ archive/set_pending.py | 95 ++ test_logging.py => archive/test_logging.py | 0 cli/__init__.py | 9 + cli/payment_cli.py | 178 +++ config.py | 8 +- models.py | 2 + orchestration/__init__.py | 10 + orchestration/payment_orchestrator.py | 261 ++++ payment_processors/__init__.py | 18 + payment_processors/base_processor.py | 198 +++ payment_processors/batch_processor.py | 306 +++++ payment_processors/followup_processor.py | 310 +++++ payment_processors/notification_handler.py | 137 +++ payment_processors/payment_plan_processor.py | 292 +++++ payment_services/__init__.py | 26 + payment_services/customer_service.py | 141 +++ payment_services/payment_service.py | 452 +++++++ pending_fixup.py | 52 - query_mysql - Copy.py | 532 -------- query_mysql_new.py | 105 ++ repositories/__init__.py | 14 + repositories/payment_repository.py | 482 ++++++++ repositories/splynx_repository.py | 314 +++++ stripe_payment_processor.py | 88 +- test.py | 40 +- 36 files changed, 5221 insertions(+), 1069 deletions(-) delete mode 100644 LOGGING.md rename delete_splynx_payments.py => archive/delete_splynx_payments.py (100%) rename payments_fixup.py => archive/payments_fixup.py (100%) create mode 100644 archive/payments_fixup_find_PIs.py rename payments_fixup_find_customers.py => archive/payments_fixup_find_customers.py (100%) rename payments_fixup_find_customers_v2.py => archive/payments_fixup_find_customers_v2.py (100%) create mode 100644 archive/pending_fixup.py rename query_mysql.py => archive/query_mysql - Copy.py (100%) rename query_mysql-bak.py => archive/query_mysql.py (54%) create mode 100644 archive/query_mysql.py.backup create mode 100644 archive/set_pending.py rename test_logging.py => archive/test_logging.py (100%) create mode 100644 cli/__init__.py create mode 100644 cli/payment_cli.py create mode 100644 orchestration/__init__.py create mode 100644 orchestration/payment_orchestrator.py create mode 100644 payment_processors/__init__.py create mode 100644 payment_processors/base_processor.py create mode 100644 payment_processors/batch_processor.py create mode 100644 payment_processors/followup_processor.py create mode 100644 payment_processors/notification_handler.py create mode 100644 payment_processors/payment_plan_processor.py create mode 100644 payment_services/__init__.py create mode 100644 payment_services/customer_service.py create mode 100644 payment_services/payment_service.py delete mode 100644 pending_fixup.py delete mode 100644 query_mysql - Copy.py create mode 100644 query_mysql_new.py create mode 100644 repositories/__init__.py create mode 100644 repositories/payment_repository.py create mode 100644 repositories/splynx_repository.py diff --git a/.gitignore b/.gitignore index 03f9406..02cac71 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ eggs/ .eggs/ lib/ lib64/ -lib64 parts/ sdist/ var/ @@ -115,12 +114,6 @@ ENV/ env.bak/ venv.bak/ -# Virtual environment directories (if created in project root) -bin/ -include/ -pyvenv.cfg -*.log - # Spyder project settings .spyderproject .spyproject @@ -145,4 +138,17 @@ dmypy.json # Cython debug symbols cython_debug/ -*.csv +venv +__pycache__ +Include +Lib +Scripts +pyvenv.cfg +pyodbc.pyi + +payment_processing.log + +*.md +!README.md + + diff --git a/LOGGING.md b/LOGGING.md deleted file mode 100644 index 7b732a2..0000000 --- a/LOGGING.md +++ /dev/null @@ -1,392 +0,0 @@ -# Plutus Payment System - Logging Best Practices - -## Overview - -This document outlines the enhanced logging system implemented in the Plutus Payment Processing System. The logging infrastructure provides comprehensive monitoring, security event tracking, performance analysis, and automated log management. - -## Logging Architecture - -### Core Components - -1. **Enhanced Logging Configuration** (`logging_config.py`) - - Structured logging with correlation IDs - - Multiple specialized logger types - - Automatic log formatting and rotation - -2. **Middleware System** (`middleware.py`) - - Request/response logging - - Performance monitoring - - Security event detection - - Database query tracking - -3. **Analytics Dashboard** (`blueprints/analytics.py`) - - Real-time system health monitoring - - Performance metrics visualization - - Security event analysis - - Log search and filtering - -4. **Log Retention System** (`log_retention.py`) - - Automated cleanup and archiving - - Configurable retention policies - - Disk space management - -## Logger Types - -### StructuredLogger -General-purpose logger with correlation ID support and structured data. - -```python -from logging_config import get_logger - -logger = get_logger('module_name') -logger.info("Payment processed successfully", - payment_id=12345, - amount=89.95, - customer_id="cus_123") -``` - -### SecurityLogger -Specialized logger for security events and threats. - -```python -from logging_config import security_logger - -security_logger.log_login_attempt("username", success=False, ip_address="192.168.1.1") -security_logger.log_payment_fraud_alert(payment_id=123, customer_id="cus_456", - reason="Unusual amount pattern", amount=5000.0) -``` - -### PerformanceLogger -Dedicated logger for performance monitoring and optimization. - -```python -from logging_config import performance_logger - -performance_logger.log_request_time("POST /payments", "POST", 1250.5, 200, user_id=1) -performance_logger.log_stripe_api_call("create_payment", 850.2, True) -``` - -## Log Files Structure - -### File Organization -``` -logs/ -├── plutus_detailed.log # Comprehensive application logs -├── performance.log # Performance metrics and slow operations -├── security.log # Security events and threats -├── payment_processing.log # Payment-specific operations -├── archive/ # Archived logs by month -│ ├── 202409/ -│ └── 202410/ -└── *.log.gz # Compressed rotated logs -``` - -### Log Formats - -#### Standard Format -``` -2024-09-02 14:30:15,123 - [corr-abc123] - plutus.payments - INFO - Payment processed successfully {"payment_id": 12345, "amount": 89.95} -``` - -#### Security Format -``` -2024-09-02 14:30:15,123 - SECURITY - [corr-abc123] - WARNING - LOGIN_FAILED for user: testuser {"ip_address": "192.168.1.1", "user_agent": "Mozilla/5.0..."} -``` - -#### Performance Format -``` -2024-09-02 14:30:15,123 - PERF - [corr-abc123] - REQUEST: POST /payments - 1250.50ms - 200 {"user_id": 1, "endpoint": "/payments"} -``` - -## Correlation IDs - -### Purpose -Correlation IDs track requests across the entire system, making it easy to trace a single operation through multiple components. - -### Usage -```python -from logging_config import log_context, set_correlation_id - -# Automatic correlation ID -with log_context(): - logger.info("Processing payment") # Will include auto-generated correlation ID - -# Custom correlation ID -with log_context("req-12345"): - logger.info("Processing payment") # Will include "req-12345" - -# Manual setting -correlation_id = set_correlation_id("custom-id") -logger.info("Payment processed") -``` - -## Performance Monitoring - -### Automatic Monitoring -The system automatically tracks: -- HTTP request response times -- Database query performance -- Stripe API call latencies -- Slow operations (>1 second requests, >100ms queries) - -### Manual Performance Logging -```python -from logging_config import log_performance - -@log_performance("payment_processing") -def process_payment(payment_data): - # Function implementation - pass - -# Or manually -start_time = time.time() -result = some_operation() -duration_ms = (time.time() - start_time) * 1000 -performance_logger.log_request_time("operation_name", "GET", duration_ms, 200) -``` - -## Security Event Monitoring - -### Automatic Detection -The middleware automatically detects and logs: -- SQL injection attempts -- Cross-site scripting (XSS) attempts -- Failed authentication attempts -- Suspicious user agents -- Access to admin endpoints -- Brute force attack patterns - -### Manual Security Logging -```python -from logging_config import security_logger - -# Log permission violations -security_logger.log_permission_denied("username", "delete_payment", "payment/123", "192.168.1.1") - -# Log fraud alerts -security_logger.log_payment_fraud_alert(payment_id=123, customer_id="cus_456", - reason="Multiple failed attempts", amount=1000.0) -``` - -## Log Retention and Management - -### Retention Policies -Default retention periods: -- Application logs: 30 days -- Performance logs: 14 days -- Security logs: 90 days -- Payment processing logs: 60 days - -### Automated Cleanup -- Runs daily at 2:00 AM -- Compresses logs older than configured threshold -- Archives important logs before deletion -- Monitors disk space usage - -### Manual Management -```python -from log_retention import retention_manager - -# Get statistics -stats = retention_manager.get_log_statistics() - -# Manual cleanup -cleanup_stats = retention_manager.cleanup_logs() - -# Emergency cleanup (when disk space is low) -emergency_stats = retention_manager.emergency_cleanup(target_size_mb=500) -``` - -## Analytics Dashboard - -### Access -Navigate to `/analytics/dashboard` (requires Finance+ permissions) - -### Features -- **System Health**: Real-time health score and key metrics -- **Performance Monitoring**: Response times, slow requests, database performance -- **Payment Analytics**: Success rates, error analysis, trends -- **Security Events**: Failed logins, suspicious activity, fraud alerts -- **Log Search**: Full-text search with filtering and pagination - -### API Endpoints -- `GET /analytics/api/system-health` - Current system health metrics -- `GET /analytics/api/performance-metrics` - Performance analysis data -- `GET /analytics/api/payment-analytics` - Payment processing statistics -- `GET /analytics/api/security-events` - Security event summary -- `GET /analytics/api/logs/search` - Search system logs - -## Best Practices - -### For Developers - -1. **Use Structured Logging** - ```python - # Good - logger.info("Payment processed", payment_id=123, amount=89.95, status="success") - - # Avoid - logger.info(f"Payment {payment_id} processed for ${amount} - status: {status}") - ``` - -2. **Include Context** - ```python - # Include relevant context in all log messages - logger.info("Payment failed", - payment_id=payment.id, - customer_id=payment.customer_id, - error_code=error.code, - error_message=str(error)) - ``` - -3. **Use Appropriate Log Levels** - - `DEBUG`: Detailed diagnostic information - - `INFO`: General information about system operation - - `WARNING`: Something unexpected happened but system continues - - `ERROR`: Serious problem that prevented function completion - - `CRITICAL`: Very serious error that may abort the program - -4. **Security-Sensitive Data** - ```python - # Never log sensitive data - logger.info("Payment processed", - payment_id=123, - amount=89.95, - card_last4="1234") # OK - only last 4 digits - - # Avoid logging full card numbers, CVV, passwords, etc. - ``` - -### For Operations - -1. **Monitor Key Metrics** - - System health score (target: >90%) - - Payment success rate (target: >95%) - - Error rate (target: <5%) - - Average response time (target: <1000ms) - -2. **Set Up Alerts** - - Health score drops below 75% - - Payment success rate drops below 90% - - Multiple security events in short timeframe - - Disk space usage exceeds 80% - -3. **Regular Review** - - Weekly review of security events - - Monthly analysis of performance trends - - Quarterly review of retention policies - - Annual security audit of logged events - -### For Security - -1. **Monitor for Patterns** - - Multiple failed logins from same IP - - Unusual payment amounts or frequencies - - Access attempts to admin endpoints - - SQL injection or XSS attempts - -2. **Incident Response** - - Use correlation IDs to trace incident across systems - - Export relevant logs for forensic analysis - - Coordinate with development team using structured log data - -## Configuration - -### Environment Variables -```bash -# Optional: Override default log retention -LOG_RETENTION_DAYS=30 -LOG_CLEANUP_TIME=02:00 -LOG_MAX_FILE_SIZE_MB=100 -LOG_ARCHIVE_COMPRESS=true -``` - -### Programmatic Configuration -```python -# Custom retention configuration -custom_config = { - 'retention_policies': { - 'security.log': {'days': 180, 'compress_after_days': 7}, - 'performance.log': {'days': 7, 'compress_after_days': 1}, - 'default': {'days': 30, 'compress_after_days': 7} - }, - 'cleanup_schedule': '03:00', - 'max_file_size_mb': 50 -} - -retention_manager = LogRetentionManager(custom_config) -``` - -## Troubleshooting - -### Common Issues - -1. **Logs Not Appearing** - - Check logs directory permissions - - Verify logger configuration in app initialization - - Check disk space availability - -2. **High Disk Usage** - - Run manual cleanup: `python log_retention.py` - - Reduce retention periods for non-critical logs - - Enable compression for all log types - -3. **Performance Impact** - - Disable DEBUG level logging in production - - Reduce log verbosity for high-frequency operations - - Use async logging for high-throughput scenarios - -4. **Missing Correlation IDs** - - Ensure middleware is properly initialized - - Check that log context is being used in threaded operations - - Verify correlation ID propagation in external API calls - -### Log Analysis Commands - -```bash -# Search for specific payment -grep "payment_id.*12345" logs/plutus_detailed.log - -# Find all errors in last hour -grep "$(date -d '1 hour ago' '+%Y-%m-%d %H')" logs/plutus_detailed.log | grep ERROR - -# Count security events by type -grep "SECURITY" logs/security.log | cut -d'-' -f5 | sort | uniq -c - -# Monitor real-time logs -tail -f logs/plutus_detailed.log - -# Analyze correlation ID flow -grep "corr-abc123" logs/*.log | sort -``` - -## Support and Maintenance - -### Log File Monitoring -Set up monitoring for: -- Log file growth rates -- Error frequency patterns -- Security event trends -- System performance degradation - -### Regular Maintenance -- Weekly: Review disk space and cleanup if needed -- Monthly: Analyze performance trends and optimize slow queries -- Quarterly: Review retention policies and adjust as needed -- Annually: Audit security events and update detection rules - -### Contact Information -For logging system issues or questions: -- Development Team: Review code in `logging_config.py`, `middleware.py` -- Operations Team: Monitor analytics dashboard and system health -- Security Team: Review security logs and event patterns - -## Version History - -- **v1.0** (Phase 8): Initial enhanced logging implementation -- **v1.1** (Phase 9): Analytics dashboard and retention system -- **v1.2**: Correlation ID improvements and performance optimization - ---- - -This logging system provides comprehensive visibility into the Plutus Payment System while maintaining security, performance, and operational efficiency. Regular review and maintenance of the logging infrastructure ensures continued reliability and usefulness for system monitoring and troubleshooting. \ No newline at end of file diff --git a/delete_splynx_payments.py b/archive/delete_splynx_payments.py similarity index 100% rename from delete_splynx_payments.py rename to archive/delete_splynx_payments.py diff --git a/payments_fixup.py b/archive/payments_fixup.py similarity index 100% rename from payments_fixup.py rename to archive/payments_fixup.py diff --git a/archive/payments_fixup_find_PIs.py b/archive/payments_fixup_find_PIs.py new file mode 100644 index 0000000..43d6062 --- /dev/null +++ b/archive/payments_fixup_find_PIs.py @@ -0,0 +1,113 @@ + + +import pymysql +import sys +import json +import random +import threading +import logging +import stripe +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 +) +from notification_service import NotificationService + +# 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'] + +stripe.api_key = api_key + +def get_latest_payment_intent(customer_id): + """Get the most recently created Payment Intent for a customer""" + payment_intents = stripe.PaymentIntent.list( + customer=customer_id, + limit=1 # Only get the most recent one + ) + + if payment_intents.data: + return payment_intents.data[0] + return None + + +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 + ## refund = Check outstanding Refunds and update + + start_time = datetime.now() + + + # Create Flask application context + app = create_app() + api_key = Config.STRIPE_LIVE_API_KEY + + + with app.app_context(): + customers = ( + db.session.query(Payments) + .filter(Payments.PaymentBatch_ID.in_((108,109))) + .all() + ) + + for pay in customers: + #print(pay) + pi = get_latest_payment_intent(pay.Stripe_Customer_ID) + #print(json.dumps(pi,indent=2)) + if str(pay.id) in pi['description']: + print("MATCH") + pm = stripe.Customer.list_payment_methods( + customer=pay.Stripe_Customer_ID + ) + #print(json.dumps(pm,indent=2)) + #print(f"card: {pm['data'][0].get('card').get('brand')}") + pm2 = pm['data'][0] + if hasattr(pm2, 'card'): + print(f"card: {pm['data'][0].get('card').get('brand')}") + pay.Payment_Method = pm['data'][0].get('card').get('brand') + elif hasattr(pm2, 'au_becs_debit'): + print("au_becs_debit") + pay.Payment_Method = "au_becs_debit" + pay.Payment_Intent = pi['id'] + pay.Stripe_Payment_Method = pi['payment_method'] + pay.PI_FollowUp = True + db.session.commit() \ No newline at end of file diff --git a/payments_fixup_find_customers.py b/archive/payments_fixup_find_customers.py similarity index 100% rename from payments_fixup_find_customers.py rename to archive/payments_fixup_find_customers.py diff --git a/payments_fixup_find_customers_v2.py b/archive/payments_fixup_find_customers_v2.py similarity index 100% rename from payments_fixup_find_customers_v2.py rename to archive/payments_fixup_find_customers_v2.py diff --git a/archive/pending_fixup.py b/archive/pending_fixup.py new file mode 100644 index 0000000..17c0b4b --- /dev/null +++ b/archive/pending_fixup.py @@ -0,0 +1,95 @@ +import json +import stripe +from datetime import datetime +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 +from config import Config +from sqlalchemy import and_ + + +splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) + + +api_key = Config.STRIPE_LIVE_API_KEY + +stripe.api_key = api_key + + +def find_pay_splynx_invoices(splynx_id: int, result: dict) -> 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&main_attributes[status]=pending") + + invoice_pay = { + "status": "paid" + } + + for pay in result: + #res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay) + print(f"Invoice: {pay['id']} marked as paid - {pay['status']}") + +def add_payment_splynx(splynx_id: int, pi_id: str, pay_id: int, amount: float, invoice_id: int) -> 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}", + "invoice_id": invoice_id + } + + res = splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay) + if res: + return res['id'] + else: + return False + +if __name__ == "__main__": + app = create_app() + i = 1 + #cust_bill = splynx.get(url=f"/api/2.0/admin/customers/customer-billing/31") + #print(json.dumps(cust_bill,indent=2)) + with app.app_context(): + custs = db.session.query(PaymentBatch,Payments)\ + .join(Payments, Payments.PaymentBatch_ID == PaymentBatch.id)\ + .filter(and_(PaymentBatch.id.in_((109,109)), Payments.Success == True))\ + .all() + print(len(custs)) + for cust in custs: + try: + cust_bill = splynx.get(url=f"/api/2.0/admin/customers/customer-billing/{cust.Payments.Splynx_ID}") + print(f"{i}/{len(custs)}") + print(f"SplynxID: {cust.Payments.Splynx_ID} - ${float(cust_bill['deposit'])} - ${cust.Payments.Payment_Amount} - {cust.Payments.Payment_Intent}") + #print(json.dumps(cust_bill,indent=2)) + if float(cust_bill['deposit']) < 0: + params = { + "main_attributes": { + "customer_id": cust.Payments.Splynx_ID, + "status": ["IN", ["pending", "not_paid"]], + "date_created": ["!=", "2026-01-05"] + } + } + #result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={cust.Payments.Splynx_ID}&main_attributes[status]=pending") + result = splynx.get(url=f"/api/2.0/admin/finance/invoices?{splynx.build_splynx_query_params(params)}") + #print(json.dumps(result,indent=2)) + if len(result) > 0: + print(f"\t{cust.Payments.Splynx_ID} - Has unpaid invoices ({len(result)})") + for res in result: + print(f"\tInvoiceID: {res['id']} - {res['status']}") + #print(json.dumps(res,indent=2)) + res = add_payment_splynx( + splynx_id=cust.Payments.Splynx_ID, + pi_id=cust.Payments.Payment_Intent, + pay_id=cust.Payments.PaymentBatch_ID, + amount=cust.Payments.Payment_Amount, + invoice_id=result[0]['id'] + ) + #if res: + # print("\tPayment added") + # find_pay_splynx_invoices(splynx_id=cust.Payments.Splynx_ID, result=result) + #else: + # print("\tAdding payment failed") + except: + print("Fuck") + i += 1 diff --git a/query_mysql.py b/archive/query_mysql - Copy.py similarity index 100% rename from query_mysql.py rename to archive/query_mysql - Copy.py diff --git a/query_mysql-bak.py b/archive/query_mysql.py similarity index 54% rename from query_mysql-bak.py rename to archive/query_mysql.py index abaaf11..b12cad9 100644 --- a/query_mysql-bak.py +++ b/archive/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 @@ -24,6 +25,7 @@ from services import ( log_script_start, log_script_completion, log_batch_created, log_payment_intent_followup ) +from notification_service import NotificationService # Configure logging logging.basicConfig( @@ -50,11 +52,108 @@ 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'] + #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 create_customer_friendly_message(payment_data: dict, error_details: str) -> str: + """ + Create a customer-friendly ticket message for failed payments. + + Args: + payment_data: Dictionary containing payment information + error_details: Raw error details + + Returns: + str: HTML formatted customer-friendly message + """ + try: + # Import classify_payment_error from main.py + from blueprints.main import classify_payment_error + + # Extract payment details + amount = abs(payment_data.get('amount', 0)) + splynx_id = payment_data.get('splynx_id', 'Unknown') + + # Parse PI_JSON for payment method details if available + pi_json = payment_data.get('pi_json') + payment_method_type = "unknown" + last4 = "****" + + if pi_json: + try: + parsed_json = json.loads(pi_json) + payment_method_type = parsed_json.get('payment_method_type', 'unknown') + + # Get last 4 digits from various possible locations in JSON + if 'payment_method_details' in parsed_json: + pm_details = parsed_json['payment_method_details'] + if payment_method_type == 'card' and 'card' in pm_details: + last4 = pm_details['card'].get('last4', '****') + elif payment_method_type == 'au_becs_debit' and 'au_becs_debit' in pm_details: + last4 = pm_details['au_becs_debit'].get('last4', '****') + elif 'last4' in parsed_json: + last4 = parsed_json.get('last4', '****') + except: + pass + + # Format payment method for display + if payment_method_type == 'au_becs_debit': + payment_method_display = f"Bank Account ending in {last4}" + elif payment_method_type == 'card': + payment_method_display = f"Card ending in {last4}" + else: + payment_method_display = "Payment method" + + # Get current datetime + current_time = datetime.now().strftime("%d/%m/%Y at %I:%M %p") + + # Get customer-friendly error explanation + error_classification = classify_payment_error(error_details, pi_json) + if error_classification: + error_message = error_classification['message'] + else: + error_message = "An error occurred during payment processing" + + # Create customer-friendly HTML message + customer_message = f""" + + + +
Your payment attempt was unsuccessful.
+

+
Payment Details:
+
• Amount: ${amount:.2f} AUD
+
• Date/Time: {current_time}
+
• {payment_method_display}
+

+
Issue: {error_message}
+

+
Please contact us if you need assistance with your payment.
+ + +""" + + return customer_message.strip() + + except Exception as e: + # Fallback message if there's any error creating the friendly message + logger.error(f"Error creating customer-friendly message: {e}") + return f""" + + + +
Your payment attempt was unsuccessful. Please contact us for assistance.
+ + +""" + 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") + result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid&main_attributes[status]=pending") invoice_pay = { "status": "paid" @@ -81,6 +180,56 @@ def find_set_pending_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]: updated_invoices.append(res) return updated_invoices +def find_set_pending_splynx_invoices_to_unpaid(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]=pending") + + invoice_pay = { + "status": "not_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 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, @@ -255,10 +404,8 @@ def addInitialPayments(customers, batch_id): # 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)] + stripe_customer_id = cust['stripe_customer_id'] + add_payer = Payments( PaymentBatch_ID = batch_id, Splynx_ID = cust['customer_id'], @@ -300,45 +447,67 @@ def processPaymentResult(pay_id, result, key): 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) + #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) + + # Send notification and create ticket for failed payments + handle_failed_payment_notification( + payment_record=payment, + error_details=payment.Error, + payment_type=key + ) + elif result.get('failure_details'): + payment.Error = f"Error Type: {result.get('failure_details').get('decline_code')}\nError: {result['failure_reason']}" + payment.Success = result['success'] + payment.PI_JSON = json.dumps(result) + + # Send notification and create ticket for failed payments + handle_failed_payment_notification( + payment_record=payment, + error_details=payment.Error, + payment_type=key + ) + else: + print("Payment successful!") + 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: + 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: - 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) + 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 @@ -564,14 +733,20 @@ def process_payintent_mode(processor): try: intent_result = processor.check_payment_intent(pi.Payment_Intent) logger.debug(f"Intent result: {json.dumps(intent_result, indent=2)}") - + print(f"\n\npayintent result: {json.dumps(intent_result, indent=2)}\n\n") 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 + + # 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') + print("\nProcess payment results coz it succeeded") processPaymentResult(pay_id=pi.id, result=intent_result, key=key) succeeded_count += 1 elif intent_result['status'] == "failed": @@ -605,6 +780,231 @@ 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 + +def handle_failed_payment_notification(payment_record, error_details: str, payment_type: str = "batch"): + """ + Handle notification and ticket creation for failed payments. + + Args: + payment_record: Database payment record (Payments or SinglePayments) + error_details: Error message details + payment_type: Type of payment ("batch" or "single") + """ + try: + # Initialize notification service + notification_service = NotificationService() + + # Get customer information from Splynx + try: + customer_data = splynx.Customer(payment_record.Splynx_ID) + customer_name = customer_data.get('name', 'Unknown Customer') if customer_data != 'unknown' else 'Unknown Customer' + except: + customer_name = 'Unknown Customer' + + # Prepare payment data for notification + payment_data = { + 'payment_id': payment_record.id, + 'splynx_id': payment_record.Splynx_ID, + 'amount': abs(payment_record.Payment_Amount), + 'error': error_details, + 'payment_method': payment_record.Payment_Method or 'Unknown', + 'customer_name': customer_name, + 'payment_type': payment_type, + 'stripe_customer_id': payment_record.Stripe_Customer_ID, + 'payment_intent': payment_record.Payment_Intent + } + + # Revert pending invoices back to "not_paid" (only in live mode) + if PROCESS_LIVE: + updated_invoices = find_set_pending_splynx_invoices_to_unpaid(splynx_id=payment_record.Splynx_ID) + if updated_invoices: + logger.info(f"✅ Payment failure pending invoices reverted back to not_paid Splynx ID: {payment_record.Splynx_ID} - PayID: {payment_record.id}") + else: + logger.error(f"❌ Failed to send payment failure email for payment {payment_record.id}") + + # Send email notification (only in live mode) + if PROCESS_LIVE: + email_sent = notification_service.send_payment_failure_notification(payment_data) + if email_sent: + logger.info(f"✅ Payment failure email sent for payment {payment_record.id}") + else: + logger.error(f"❌ Failed to send payment failure email for payment {payment_record.id}") + + # Create Splynx ticket (only in live mode) + if PROCESS_LIVE: + ticket_subject = f"Payment Failure - Customer {payment_record.Splynx_ID} - ${abs(payment_record.Payment_Amount):.2f}" + internal_message=f""" + + + +
Payment processing has failed for customer {customer_name} (ID: {payment_record.Splynx_ID}).
+

+
Payment Details:
+ +

+
Error Information:
+
{error_details}
+

+
This ticket was automatically created by the Plutus Payment System.
+ + +""" + # Create customer-friendly message + payment_data_for_msg = { + 'amount': payment_data['amount'], + 'splynx_id': payment_data['splynx_id'], + 'pi_json': payment_record.PI_JSON + } + customer_message = create_customer_friendly_message(payment_data_for_msg, error_details) + ticket_result = splynx.create_ticket( + customer_id = payment_record.Splynx_ID, + subject = ticket_subject, + priority = 'medium', + type_id = 1, + group_id = 7, + status_id = 1, + ) + #splynx.create_ticket( + # customer_id=payment_record.Splynx_ID, + # subject=ticket_subject, + # message=internal_message, + # priority="medium" + #) + + if ticket_result.get('success'): + logger.info(f"✅ Splynx ticket created: #{ticket_result['ticket_id']} for payment {payment_record.id}") + + # Optionally store ticket ID in payment record for tracking + # This would require adding a Splynx_Ticket_ID field to the models + + ## Adds internal note + add_message = splynx.add_ticket_message( + ticket_id=ticket_result['ticket_id'], + message=internal_message, + is_admin=False, + hide_for_customer=True, + message_type="note" + ) + + #result['ticket_id'] + add_message = splynx.add_ticket_message( + ticket_id=ticket_result['ticket_id'], + message=customer_message, + is_admin=False, + hide_for_customer=False, + message_type="message" + ) + + else: + logger.error(f"❌ Failed to create Splynx ticket for payment {payment_record.id}: {ticket_result.get('error')}") + + except Exception as e: + logger.error(f"Error handling failed payment notification for payment {payment_record.id}: {e}") + if __name__ == "__main__": ## Payment Method: ## 2 - Direct Debit (Automatic) @@ -615,6 +1015,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 @@ -630,9 +1031,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": @@ -645,6 +1048,10 @@ if __name__ == "__main__": # Create Flask application context app = create_app() + if PROCESS_LIVE: + api_key = Config.STRIPE_LIVE_API_KEY + else: + api_key = Config.STRIPE_TEST_API_KEY print(f"api_key: {api_key}") processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) @@ -663,6 +1070,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/archive/query_mysql.py.backup b/archive/query_mysql.py.backup new file mode 100644 index 0000000..b12cad9 --- /dev/null +++ b/archive/query_mysql.py.backup @@ -0,0 +1,1095 @@ +#!/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 [mode] [live] +Modes: batch, payintent, payplan, refund +""" + +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 +) +from notification_service import NotificationService + +# 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 create_customer_friendly_message(payment_data: dict, error_details: str) -> str: + """ + Create a customer-friendly ticket message for failed payments. + + Args: + payment_data: Dictionary containing payment information + error_details: Raw error details + + Returns: + str: HTML formatted customer-friendly message + """ + try: + # Import classify_payment_error from main.py + from blueprints.main import classify_payment_error + + # Extract payment details + amount = abs(payment_data.get('amount', 0)) + splynx_id = payment_data.get('splynx_id', 'Unknown') + + # Parse PI_JSON for payment method details if available + pi_json = payment_data.get('pi_json') + payment_method_type = "unknown" + last4 = "****" + + if pi_json: + try: + parsed_json = json.loads(pi_json) + payment_method_type = parsed_json.get('payment_method_type', 'unknown') + + # Get last 4 digits from various possible locations in JSON + if 'payment_method_details' in parsed_json: + pm_details = parsed_json['payment_method_details'] + if payment_method_type == 'card' and 'card' in pm_details: + last4 = pm_details['card'].get('last4', '****') + elif payment_method_type == 'au_becs_debit' and 'au_becs_debit' in pm_details: + last4 = pm_details['au_becs_debit'].get('last4', '****') + elif 'last4' in parsed_json: + last4 = parsed_json.get('last4', '****') + except: + pass + + # Format payment method for display + if payment_method_type == 'au_becs_debit': + payment_method_display = f"Bank Account ending in {last4}" + elif payment_method_type == 'card': + payment_method_display = f"Card ending in {last4}" + else: + payment_method_display = "Payment method" + + # Get current datetime + current_time = datetime.now().strftime("%d/%m/%Y at %I:%M %p") + + # Get customer-friendly error explanation + error_classification = classify_payment_error(error_details, pi_json) + if error_classification: + error_message = error_classification['message'] + else: + error_message = "An error occurred during payment processing" + + # Create customer-friendly HTML message + customer_message = f""" + + + +
Your payment attempt was unsuccessful.
+

+
Payment Details:
+
• Amount: ${amount:.2f} AUD
+
• Date/Time: {current_time}
+
• {payment_method_display}
+

+
Issue: {error_message}
+

+
Please contact us if you need assistance with your payment.
+ + +""" + + return customer_message.strip() + + except Exception as e: + # Fallback message if there's any error creating the friendly message + logger.error(f"Error creating customer-friendly message: {e}") + return f""" + + + +
Your payment attempt was unsuccessful. Please contact us for assistance.
+ + +""" + + + +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&main_attributes[status]=pending") + + 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 find_set_pending_splynx_invoices_to_unpaid(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]=pending") + + invoice_pay = { + "status": "not_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 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, + "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: + stripe_customer_id = cust['stripe_customer_id'] + + 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) + + # Send notification and create ticket for failed payments + handle_failed_payment_notification( + payment_record=payment, + error_details=payment.Error, + payment_type=key + ) + elif result.get('failure_details'): + payment.Error = f"Error Type: {result.get('failure_details').get('decline_code')}\nError: {result['failure_reason']}" + payment.Success = result['success'] + payment.PI_JSON = json.dumps(result) + + # Send notification and create ticket for failed payments + handle_failed_payment_notification( + payment_record=payment, + error_details=payment.Error, + payment_type=key + ) + else: + print("Payment successful!") + 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: + 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)}") + print(f"\n\npayintent result: {json.dumps(intent_result, indent=2)}\n\n") + 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 + + # 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') + print("\nProcess payment results coz it succeeded") + 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 + +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 + +def handle_failed_payment_notification(payment_record, error_details: str, payment_type: str = "batch"): + """ + Handle notification and ticket creation for failed payments. + + Args: + payment_record: Database payment record (Payments or SinglePayments) + error_details: Error message details + payment_type: Type of payment ("batch" or "single") + """ + try: + # Initialize notification service + notification_service = NotificationService() + + # Get customer information from Splynx + try: + customer_data = splynx.Customer(payment_record.Splynx_ID) + customer_name = customer_data.get('name', 'Unknown Customer') if customer_data != 'unknown' else 'Unknown Customer' + except: + customer_name = 'Unknown Customer' + + # Prepare payment data for notification + payment_data = { + 'payment_id': payment_record.id, + 'splynx_id': payment_record.Splynx_ID, + 'amount': abs(payment_record.Payment_Amount), + 'error': error_details, + 'payment_method': payment_record.Payment_Method or 'Unknown', + 'customer_name': customer_name, + 'payment_type': payment_type, + 'stripe_customer_id': payment_record.Stripe_Customer_ID, + 'payment_intent': payment_record.Payment_Intent + } + + # Revert pending invoices back to "not_paid" (only in live mode) + if PROCESS_LIVE: + updated_invoices = find_set_pending_splynx_invoices_to_unpaid(splynx_id=payment_record.Splynx_ID) + if updated_invoices: + logger.info(f"✅ Payment failure pending invoices reverted back to not_paid Splynx ID: {payment_record.Splynx_ID} - PayID: {payment_record.id}") + else: + logger.error(f"❌ Failed to send payment failure email for payment {payment_record.id}") + + # Send email notification (only in live mode) + if PROCESS_LIVE: + email_sent = notification_service.send_payment_failure_notification(payment_data) + if email_sent: + logger.info(f"✅ Payment failure email sent for payment {payment_record.id}") + else: + logger.error(f"❌ Failed to send payment failure email for payment {payment_record.id}") + + # Create Splynx ticket (only in live mode) + if PROCESS_LIVE: + ticket_subject = f"Payment Failure - Customer {payment_record.Splynx_ID} - ${abs(payment_record.Payment_Amount):.2f}" + internal_message=f""" + + + +
Payment processing has failed for customer {customer_name} (ID: {payment_record.Splynx_ID}).
+

+
Payment Details:
+ +

+
Error Information:
+
{error_details}
+

+
This ticket was automatically created by the Plutus Payment System.
+ + +""" + # Create customer-friendly message + payment_data_for_msg = { + 'amount': payment_data['amount'], + 'splynx_id': payment_data['splynx_id'], + 'pi_json': payment_record.PI_JSON + } + customer_message = create_customer_friendly_message(payment_data_for_msg, error_details) + ticket_result = splynx.create_ticket( + customer_id = payment_record.Splynx_ID, + subject = ticket_subject, + priority = 'medium', + type_id = 1, + group_id = 7, + status_id = 1, + ) + #splynx.create_ticket( + # customer_id=payment_record.Splynx_ID, + # subject=ticket_subject, + # message=internal_message, + # priority="medium" + #) + + if ticket_result.get('success'): + logger.info(f"✅ Splynx ticket created: #{ticket_result['ticket_id']} for payment {payment_record.id}") + + # Optionally store ticket ID in payment record for tracking + # This would require adding a Splynx_Ticket_ID field to the models + + ## Adds internal note + add_message = splynx.add_ticket_message( + ticket_id=ticket_result['ticket_id'], + message=internal_message, + is_admin=False, + hide_for_customer=True, + message_type="note" + ) + + #result['ticket_id'] + add_message = splynx.add_ticket_message( + ticket_id=ticket_result['ticket_id'], + message=customer_message, + is_admin=False, + hide_for_customer=False, + message_type="message" + ) + + else: + logger.error(f"❌ Failed to create Splynx ticket for payment {payment_record.id}: {ticket_result.get('error')}") + + except Exception as e: + logger.error(f"Error handling failed payment notification for payment {payment_record.id}: {e}") + +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 + ## refund = Check outstanding Refunds and update + + 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" + 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, refund") + 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() + if PROCESS_LIVE: + api_key = Config.STRIPE_LIVE_API_KEY + else: + api_key = Config.STRIPE_TEST_API_KEY + 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) + 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)) + 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/archive/set_pending.py b/archive/set_pending.py new file mode 100644 index 0000000..5a6535d --- /dev/null +++ b/archive/set_pending.py @@ -0,0 +1,95 @@ + + +import pymysql +import sys +import json +import random +import threading +import logging +import stripe +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 sqlalchemy import and_ +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 +) +from notification_service import NotificationService + +# 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) + +def find_set_pending_splynx_invoices(splynx, splynx_id: int) -> List[Dict[str, Any]]: + """ + Mark Splynx invoices as pending for a given customer. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + 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 + + +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 + ## refund = Check outstanding Refunds and update + + start_time = datetime.now() + + + # Create Flask application context + app = create_app() + + + + with app.app_context(): + customers = ( + db.session.query(Payments) + .filter(and_(Payments.PaymentBatch_ID.in_((108,109)), Payments.PI_FollowUp == True)) + .all() + ) + + for pay in customers: + print(pay.Splynx_ID) + find_set_pending_splynx_invoices(splynx, pay.Splynx_ID) + \ No newline at end of file diff --git a/test_logging.py b/archive/test_logging.py similarity index 100% rename from test_logging.py rename to archive/test_logging.py diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..31aaa60 --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,9 @@ +""" +CLI Module + +This module contains command-line interface components for payment processing. +""" + +from .payment_cli import main + +__all__ = ['main'] diff --git a/cli/payment_cli.py b/cli/payment_cli.py new file mode 100644 index 0000000..9e54d83 --- /dev/null +++ b/cli/payment_cli.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Payment Processing CLI + +Command-line interface for running payment processing in different modes. + +Usage: python payment_cli.py [mode] [live] +Modes: batch, payintent, payplan, refund +""" + +import sys +import logging +import argparse +from config import Config +from app import create_app +from orchestration import PaymentOrchestrator + +# 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__) + + +def parse_arguments(): + """ + Parse command-line arguments. + + Returns: + Namespace with parsed arguments + """ + parser = argparse.ArgumentParser( + description='Payment Processing System - Process payments, payment plans, and follow-ups', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python payment_cli.py batch test # Process batch payments in test mode + python payment_cli.py batch live # Process batch payments in live mode + python payment_cli.py payintent # Check payment intents (test mode) + python payment_cli.py payplan live # Process payment plans in live mode + python payment_cli.py refund # Check refund statuses (test mode) + +Modes: + batch - Process Direct Debit and Card payments in batches + payplan - Process recurring payment plans due today + payintent - Follow up on pending payment intents + refund - Follow up on pending refunds + +Environment: + test - Use test Stripe API keys (default) + live - Use live Stripe API keys and update Splynx + """ + ) + + parser.add_argument( + 'mode', + choices=['batch', 'payintent', 'payplan', 'refund'], + help='Processing mode to run' + ) + + parser.add_argument( + 'environment', + nargs='?', + choices=['test', 'live'], + default='test', + help='Environment to run in (default: test)' + ) + + parser.add_argument( + '-v', '--verbose', + action='store_true', + help='Enable verbose logging' + ) + + parser.add_argument( + '--dry-run', + action='store_true', + help='Run in dry-run mode (no changes to Splynx)' + ) + + return parser.parse_args() + + +def main(): + """ + Main entry point for the payment processing CLI. + """ + args = parse_arguments() + + # Set logging level + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + logger.debug("Verbose logging enabled") + + # Determine if running in live mode + process_live = (args.environment == 'live') and not args.dry_run + + if args.dry_run: + logger.info("🔍 DRY-RUN MODE: No changes will be made to Splynx") + + # Log execution details + env_display = "LIVE" if process_live else "TEST" + logger.info("=" * 80) + logger.info(f"Payment Processing System - Mode: {args.mode.upper()} - Environment: {env_display}") + logger.info("=" * 80) + + try: + # Create Flask application + app = create_app() + + # Create orchestrator + orchestrator = PaymentOrchestrator( + app=app, + config=Config, + process_live=process_live + ) + + # Run processing + result = orchestrator.run(args.mode) + + # Display results + if result.get('success'): + logger.info("=" * 80) + logger.info("PROCESSING COMPLETED SUCCESSFULLY") + logger.info("=" * 80) + + # Display mode-specific results + if args.mode == "batch": + logger.info(f"Batch IDs: {result.get('batch_ids', [])}") + logger.info(f"Successful: {result.get('success_count', 0)}") + logger.info(f"Failed: {result.get('failed_count', 0)}") + elif args.mode == "payplan": + logger.info(f"Batch ID: {result.get('batch_id')}") + logger.info(f"Successful: {result.get('success_count', 0)}") + logger.info(f"Failed: {result.get('failed_count', 0)}") + logger.info(f"Total Amount: ${result.get('total_amount', 0):.2f}") + elif args.mode == "payintent": + logger.info(f"Total Checked: {result.get('total_pending', 0)}") + logger.info(f"Succeeded: {result.get('succeeded', 0)}") + logger.info(f"Failed: {result.get('failed', 0)}") + logger.info(f"Still Pending: {result.get('still_pending', 0)}") + elif args.mode == "refund": + logger.info(f"Total Checked: {result.get('total_pending', 0)}") + logger.info(f"Completed: {result.get('completed', 0)}") + logger.info(f"Failed: {result.get('failed', 0)}") + logger.info(f"Still Pending: {result.get('still_pending', 0)}") + + logger.info(f"Duration: {result.get('duration', 0):.1f}s") + logger.info("=" * 80) + + sys.exit(0) + else: + logger.error("=" * 80) + logger.error("PROCESSING FAILED") + logger.error("=" * 80) + logger.error(f"Error: {result.get('error', 'Unknown error')}") + logger.error("=" * 80) + sys.exit(1) + + except KeyboardInterrupt: + logger.warning("\n\nProcessing interrupted by user") + sys.exit(130) + except Exception as e: + logger.error("=" * 80) + logger.error("FATAL ERROR") + logger.error("=" * 80) + logger.error(f"Error: {e}", exc_info=True) + logger.error("=" * 80) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/config.py b/config.py index edecd8e..9876f7a 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,9 @@ class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'plutus-dev-secret-key-change-in-production' # PostgreSQL database configuration (Flask-SQLAlchemy) - SQLALCHEMY_DATABASE_URI = 'postgresql://flask:FR0u9312rad$swib13125@192.168.20.53/plutus' + #SQLALCHEMY_DATABASE_URI = 'postgresql://flask:FR0u9312rad$swib13125@192.168.20.53/plutus' + SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:strong_password@10.0.1.15/plutus' + SQLALCHEMY_TRACK_MODIFICATIONS = False # MySQL database configuration (read-only) @@ -28,8 +30,10 @@ class Config: # Process live on Sandbox # False = Sandbox - Default - PROCESS_LIVE = True + PROCESS_LIVE = False + # Mode: batch, payintent, payplan, refund + MODE = 'payintent' # Threading configuration MAX_PAYMENT_THREADS = 15 # Number of concurrent payment processing threads diff --git a/models.py b/models.py index 1f3b94d..aeb890f 100644 --- a/models.py +++ b/models.py @@ -60,6 +60,7 @@ class Payments(db.Model): 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) + Invoices_to_Pay = db.Column(db.String()) class SinglePayments(db.Model): __tablename__ = 'SinglePayments' @@ -87,6 +88,7 @@ class SinglePayments(db.Model): 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) + Invoices_to_Pay = db.Column(db.String()) class Logs(db.Model): diff --git a/orchestration/__init__.py b/orchestration/__init__.py new file mode 100644 index 0000000..7fd4539 --- /dev/null +++ b/orchestration/__init__.py @@ -0,0 +1,10 @@ +""" +Orchestration Module + +This module contains the payment orchestrator that coordinates +different payment processing modes. +""" + +from .payment_orchestrator import PaymentOrchestrator + +__all__ = ['PaymentOrchestrator'] diff --git a/orchestration/payment_orchestrator.py b/orchestration/payment_orchestrator.py new file mode 100644 index 0000000..1a97a85 --- /dev/null +++ b/orchestration/payment_orchestrator.py @@ -0,0 +1,261 @@ +""" +Payment Orchestrator Module + +This module coordinates the execution of different payment processing modes. +""" + +import logging +from datetime import datetime +from typing import Dict, Any, Optional +from flask import Flask + +logger = logging.getLogger(__name__) + + +class PaymentOrchestrator: + """ + Orchestrator for payment processing operations. + + This class coordinates the execution of different payment processing modes + (batch, payment plan, payment intent follow-up, refund follow-up). + """ + + VALID_MODES = ["batch", "payplan", "payintent", "refund"] + + def __init__(self, app: Flask, config, process_live: bool = False): + """ + Initialize the payment orchestrator. + + Args: + app: Flask application instance + config: Configuration object + process_live: Whether to process in live mode + """ + self.app = app + self.config = config + self.process_live = process_live + self.logger = logging.getLogger(self.__class__.__name__) + + # Initialize components (deferred to run()) + self.stripe_processor = None + self.splynx_repo = None + self.payment_repo = None + + def run(self, mode: str) -> Dict[str, Any]: + """ + Run payment processing for the specified mode. + + Args: + mode: Processing mode ("batch", "payplan", "payintent", "refund") + + Returns: + Dictionary with processing results + """ + if mode not in self.VALID_MODES: + error_msg = f"Invalid mode '{mode}'. Valid modes: {', '.join(self.VALID_MODES)}" + self.logger.error(error_msg) + return { + 'success': False, + 'error': error_msg, + 'mode': mode + } + + start_time = datetime.now() + + # Log script start + try: + from services import log_script_start + environment = "live" if self.process_live else "sandbox" + log_script_start("query_mysql_new.py", mode, environment) + except Exception as e: + self.logger.warning(f"Failed to log script start: {e}") + + # Initialize components within Flask app context + with self.app.app_context(): + self._initialize_components() + + try: + # Execute the appropriate processing mode + if mode == "batch": + result = self._process_batch() + elif mode == "payplan": + result = self._process_payment_plans() + elif mode == "payintent": + result = self._process_payment_intents() + elif mode == "refund": + result = self._process_refunds() + + # Calculate duration + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + result['duration'] = duration + + # Log script completion + self._log_completion(mode, result, duration) + + return result + + except Exception as e: + self.logger.error(f"Error during {mode} processing: {e}", exc_info=True) + duration = (datetime.now() - start_time).total_seconds() + + return { + 'success': False, + 'error': str(e), + 'mode': mode, + 'duration': duration + } + + def _initialize_components(self): + """Initialize all required components within Flask app context.""" + from app import db + from stripe_payment_processor import StripePaymentProcessor + from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET + from repositories import PaymentRepository, SplynxRepository + + # Initialize Stripe processor + api_key = self.config.STRIPE_LIVE_API_KEY if self.process_live else self.config.STRIPE_TEST_API_KEY + self.stripe_processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) + + # Initialize Splynx + splynx_client = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) + self.splynx_repo = SplynxRepository(splynx_client) + + # Initialize payment repository + self.payment_repo = PaymentRepository(db.session) + + self.logger.info("Components initialized successfully") + + def _process_batch(self) -> Dict[str, Any]: + """ + Process batch payments (Direct Debit and Card). + + Returns: + Dictionary with processing results + """ + from app import db + from payment_processors import BatchPaymentProcessor + + self.config.MODE = 'batch' + + processor = BatchPaymentProcessor( + db_session_factory=db.session, + stripe_processor=self.stripe_processor, + splynx_repository=self.splynx_repo, + payment_repository=self.payment_repo, + config=self.config, + process_live=self.process_live + ) + + result = processor.process() + result['mode'] = self.config.MODE + return result + + def _process_payment_plans(self) -> Dict[str, Any]: + """ + Process payment plans. + + Returns: + Dictionary with processing results + """ + from app import db + from payment_processors import PaymentPlanProcessor + + self.config.MODE = 'payplan' + + processor = PaymentPlanProcessor( + db_session_factory=db.session, + stripe_processor=self.stripe_processor, + splynx_repository=self.splynx_repo, + payment_repository=self.payment_repo, + config=self.config, + process_live=self.process_live + ) + + result = processor.process() + result['mode'] = self.config.MODE + return result + + def _process_payment_intents(self) -> Dict[str, Any]: + """ + Process payment intent follow-ups. + + Returns: + Dictionary with processing results + """ + from app import db + from payment_processors import FollowUpProcessor + + self.config.MODE = 'payintent' + + processor = FollowUpProcessor( + db_session_factory=db.session, + stripe_processor=self.stripe_processor, + splynx_repository=self.splynx_repo, + payment_repository=self.payment_repo, + config=self.config, + process_live=self.process_live + ) + + result = processor.process_payment_intents() + result['mode'] = self.config.MODE + return result + + def _process_refunds(self) -> Dict[str, Any]: + """ + Process refund follow-ups. + + Returns: + Dictionary with processing results + """ + from app import db + from payment_processors import FollowUpProcessor + + self.config.MODE = 'refund' + + processor = FollowUpProcessor( + db_session_factory=db.session, + stripe_processor=self.stripe_processor, + splynx_repository=self.splynx_repo, + payment_repository=self.payment_repo, + config=self.config, + process_live=self.process_live + ) + + result = processor.process_refunds() + result['mode'] = self.config.MODE + return result + + def _log_completion(self, mode: str, result: Dict[str, Any], duration: float): + """ + Log script completion using services module. + + Args: + mode: Processing mode + result: Processing result dictionary + duration: Execution duration in seconds + """ + try: + from services import log_script_completion + + success_count = result.get('success_count', result.get('succeeded', result.get('completed', 0))) + failed_count = result.get('failed_count', result.get('failed', 0)) + total_amount = result.get('total_amount', 0.0) + batch_ids = result.get('batch_ids') or ([result.get('batch_id')] if result.get('batch_id') else None) + errors = [result.get('error')] if result.get('error') else None + + log_script_completion( + script_name="query_mysql.py", + mode=mode, + success_count=success_count, + failed_count=failed_count, + total_amount=total_amount, + batch_ids=batch_ids, + duration_seconds=duration, + errors=errors + ) + + self.logger.info(f"Script completed in {duration:.1f}s: {success_count} successful, {failed_count} failed") + + except Exception as e: + self.logger.warning(f"Failed to log script completion: {e}") diff --git a/payment_processors/__init__.py b/payment_processors/__init__.py new file mode 100644 index 0000000..b2740ae --- /dev/null +++ b/payment_processors/__init__.py @@ -0,0 +1,18 @@ +""" +Payment Processors Module + +This module contains payment processor classes that handle different +payment processing modes (batch, payment plan, follow-ups). +""" + +from .base_processor import BasePaymentProcessor +from .batch_processor import BatchPaymentProcessor +from .payment_plan_processor import PaymentPlanProcessor +from .followup_processor import FollowUpProcessor + +__all__ = [ + 'BasePaymentProcessor', + 'BatchPaymentProcessor', + 'PaymentPlanProcessor', + 'FollowUpProcessor' +] diff --git a/payment_processors/base_processor.py b/payment_processors/base_processor.py new file mode 100644 index 0000000..1e2ad3a --- /dev/null +++ b/payment_processors/base_processor.py @@ -0,0 +1,198 @@ +""" +Base Payment Processor Module + +This module provides the abstract base class for all payment processors. +It defines the common interface and shared functionality. +""" + +import logging +import json +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional +from sqlalchemy.orm import scoped_session + +logger = logging.getLogger(__name__) + + +class BasePaymentProcessor(ABC): + """ + Abstract base class for payment processors. + + This class defines the common interface and shared functionality + for all payment processing modes. + """ + + def __init__(self, db_session_factory, stripe_processor, splynx_repository, + payment_repository, config, process_live: bool = False): + """ + Initialize the base payment processor. + + Args: + db_session_factory: Factory for creating database sessions (for thread safety) + stripe_processor: StripePaymentProcessor instance + splynx_repository: SplynxRepository instance + payment_repository: PaymentRepository instance + config: Configuration object + process_live: Whether to process in live mode + """ + self.db_session_factory = db_session_factory + self.stripe_processor = stripe_processor + self.splynx_repo = splynx_repository + self.payment_repo = payment_repository + self.config = config + self.process_live = process_live + self.logger = logging.getLogger(self.__class__.__name__) + + def get_db_session(self): + """ + Get a database session for the current thread. + + Returns: + Database session + """ + if isinstance(self.db_session_factory, scoped_session): + return self.db_session_factory() + else: + return self.db_session_factory + + @abstractmethod + def process(self) -> Dict[str, Any]: + """ + Process payments for this processor's mode. + + This method must be implemented by all subclasses. + + Returns: + Dictionary with processing results (success_count, failed_count, etc.) + """ + pass + + def handle_database_operation(self, operation_func, operation_name: str) -> Any: + """ + Execute a database operation with consistent error handling. + + Args: + operation_func: Function that performs the database operation + operation_name: Description of the operation for logging + + Returns: + Result of operation_func or None if failed + """ + try: + result = operation_func() + self.payment_repo.commit() + self.logger.debug(f"{operation_name} completed successfully") + return result + except Exception as e: + self.payment_repo.rollback() + self.logger.error(f"{operation_name} failed: {e}") + return None + + def log_processing_start(self, mode: str, details: str = ""): + """ + Log the start of payment processing. + + Args: + mode: Processing mode name + details: Additional details to log + """ + env = "LIVE" if self.process_live else "SANDBOX" + self.logger.info(f"Starting {mode} processing ({env})") + if details: + self.logger.info(details) + + def log_processing_complete(self, mode: str, success_count: int, failed_count: int, + duration: float, additional_info: Dict[str, Any] = None): + """ + Log the completion of payment processing. + + Args: + mode: Processing mode name + success_count: Number of successful operations + failed_count: Number of failed operations + duration: Processing duration in seconds + additional_info: Additional information to log + """ + self.logger.info(f"{mode} processing completed in {duration:.1f}s") + self.logger.info(f"Results: {success_count} successful, {failed_count} failed") + + if additional_info: + for key, value in additional_info.items(): + self.logger.info(f"{key}: {value}") + + def create_payment_data(self, payment_record, payment_id: int, description: str = None) -> Dict[str, Any]: + """ + Create standardized payment data dictionary for Stripe processing. + + Args: + payment_record: Database payment record + payment_id: Payment ID + description: Payment description override + + Returns: + Dictionary with payment data for Stripe API + """ + if not description: + description = f"Payment ID: {payment_id} - Splynx ID: {payment_record.Splynx_ID}" + + return { + 'payment_id': payment_id, + 'customer_id': payment_record.Stripe_Customer_ID, + 'amount': payment_record.Payment_Amount, + 'currency': "aud", + 'description': description, + 'stripe_pm': payment_record.Stripe_Payment_Method + } + + def handle_payment_result(self, payment_id: int, result: Dict[str, Any], + payment_type: str = "pay") -> bool: + """ + Handle payment processing result and update database. + + Args: + payment_id: Payment record ID + result: Payment processing result from Stripe + payment_type: "pay" or "singlepay" + + Returns: + True if successful, False otherwise + """ + from payment_services.payment_service import processPaymentResult + + #print(f"\n\nhandle_payment_result - result: {json.dumps(result,indent=2)}\n\n") + + cust_stripe_details = self.stripe_processor.get_customer_info(customer_id=result.get('customer_id')) + + #print(f"\ncust_stripe_details ({result.get('customer_id')}) - result: {json.dumps(cust_stripe_details,indent=2)}\n\n") + + try: + # Get notification handler + notification_handler = self._get_notification_handler() + + # Process the result + processPaymentResult( + db=self.payment_repo.db, + splynx=self.splynx_repo.splynx, + notification_handler=notification_handler, + pay_id=payment_id, + result=result, + key=payment_type, + process_live=self.process_live, + cust_stripe_details=cust_stripe_details, + mode=self.config.MODE + ) + return True + except Exception as e: + self.logger.error(f"Error handling payment result for payment {payment_id}: {e}") + return False + + def _get_notification_handler(self): + """ + Get the notification handler function for failed payments. + + Returns: + Notification handler function or None + """ + from .notification_handler import handle_failed_payment_notification + #return handle_failed_payment_notification if self.process_live else None + return handle_failed_payment_notification diff --git a/payment_processors/batch_processor.py b/payment_processors/batch_processor.py new file mode 100644 index 0000000..d4c39b4 --- /dev/null +++ b/payment_processors/batch_processor.py @@ -0,0 +1,306 @@ +""" +Batch Payment Processor Module + +This module handles batch processing of Direct Debit and Card payments. +""" +import sys +import json +import logging +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Dict, Any, List +from .base_processor import BasePaymentProcessor + +logger = logging.getLogger(__name__) + + +class BatchPaymentProcessor(BasePaymentProcessor): + """ + Processor for batch payment mode. + + This class handles the batch processing of Direct Debit and Card payments, + including multi-threaded payment execution. + """ + + def __init__(self, *args, **kwargs): + """Initialize the batch payment processor.""" + super().__init__(*args, **kwargs) + self.db_lock = threading.Lock() + + def process(self) -> Dict[str, Any]: + """ + Process batch payments for Direct Debit and Card payment methods. + + Returns: + Dictionary with processing results + """ + start_time = datetime.now() + self.log_processing_start("Batch") + + print(f"Batch Processor Mode: {self.process_live}") + + # Create batches for both payment methods + if self.process_live: + print("LIVE MODE") + batch_ids = self._create_payment_batches() + else: + print("The Sandbox") + batch_ids = self._create_payment_batches() + #sys.exit() + + if not batch_ids: + self.logger.warning("No batches created for processing") + return { + 'success': False, + 'batch_ids': [], + 'success_count': 0, + 'failed_count': 0, + 'duration': (datetime.now() - start_time).total_seconds() + } + + # Execute payments for all batches + success_count, failed_count = self._execute_payment_batches(batch_ids) + + # Calculate duration and log completion + duration = (datetime.now() - start_time).total_seconds() + self.log_processing_complete( + "Batch", + success_count, + failed_count, + duration, + {'batch_ids': batch_ids} + ) + + return { + 'success': True, + 'batch_ids': batch_ids, + 'success_count': success_count, + 'failed_count': failed_count, + 'duration': duration + } + + def _create_payment_batches(self) -> List[int]: + """ + Create payment batches for Direct Debit and Card payments. + + Returns: + List of batch IDs + """ + batch_ids = [] + payment_methods = [ + self.config.PAYMENT_METHOD_CARD, + self.config.PAYMENT_METHOD_DIRECT_DEBIT + ] + + payment_method_names = { + self.config.PAYMENT_METHOD_CARD: "Card Payment", + self.config.PAYMENT_METHOD_DIRECT_DEBIT: "Direct Debit" + } + + #for pm in payment_methods: + # Create batch + batch_id = self.payment_repo.create_payment_batch() + if batch_id is None: + self.logger.error(f"Failed to create batch for payment method {payment_methods}") + #continue + + # Query customers for this payment method + if self.process_live: + customers = self.payment_repo.query_mysql_customers( + mysql_config=self.config.MYSQL_CONFIG, + payment_methods=payment_methods, + deposit_threshold=self.config.DEPOSIT_THRESHOLD, + limit=self.config.DEFAULT_QUERY_LIMIT + ) + invoices = self.payment_repo.query_mysql_customer_invoices( + mysql_config=self.config.MYSQL_CONFIG, + limit=self.config.DEFAULT_QUERY_LIMIT + ) + else: + print("Collecting test customer data") + #customers = self.payment_repo.get_test_customer_data(payment_method=payment_methods) + customers = self.payment_repo.query_mysql_customers( + mysql_config=self.config.MYSQL_CONFIG, + payment_methods=payment_methods, + deposit_threshold=self.config.DEPOSIT_THRESHOLD, + limit=self.config.DEFAULT_QUERY_LIMIT + ) + invoices = self.payment_repo.query_mysql_customer_invoices( + mysql_config=self.config.MYSQL_CONFIG, + limit=self.config.DEFAULT_QUERY_LIMIT + ) + + if customers: + customer_count = len(customers) + result = self.payment_repo.add_payments_to_batch(customers, batch_id, invoices) + + if result['added'] > 0: + batch_ids.append(batch_id) + #self.logger.info(f"Created batch {batch_id} for {payment_method_names[pm]} with {customer_count} customers") + self.logger.info(f"Created batch {batch_id} with {customer_count} customers") + + # Log batch creation using services + try: + from services import log_batch_created + #log_batch_created(batch_id, payment_method_names[pm], customer_count) + log_batch_created(batch_id, str(payment_methods), customer_count) + except Exception as e: + self.logger.warning(f"Failed to log batch creation: {e}") + else: + self.logger.warning(f"No payments added to batch {batch_id}") + else: + #self.logger.info(f"No customers found for {payment_method_names[pm]}") + self.logger.info(f"No customers found for {str(payment_methods)}") + sys.exit() + return batch_ids + + def _execute_payment_batches(self, batch_ids: List[int]) -> tuple: + """ + Execute payments for all provided batch IDs using thread pool. + + Args: + batch_ids: List of batch IDs to process + + Returns: + Tuple of (success_count, failed_count) + """ + if not batch_ids: + self.logger.warning("No valid batches to process") + return (0, 0) + + max_threads = self.config.MAX_PAYMENT_THREADS + total_success = 0 + total_failed = 0 + + for batch_id in batch_ids: + self.logger.info(f"Processing batch {batch_id}") + + # Get payments for this batch + payments = self.payment_repo.get_payments_by_batch(batch_id) + if not payments: + self.logger.info(f"No payments found for batch {batch_id}") + continue + + self.logger.info(f"Processing {len(payments)} payments using {max_threads} threads") + + success_count, failed_count = self._process_payments_threaded( + payments, + max_threads + ) + + total_success += success_count + total_failed += failed_count + + self.logger.info(f"Batch {batch_id} completed: {success_count}/{len(payments)} successful") + + return (total_success, total_failed) + + def _process_payments_threaded(self, payments: List[Any], max_threads: int) -> tuple: + """ + Process payments using thread pool with immediate commits. + + Args: + payments: List of payment records + max_threads: Maximum number of concurrent threads + + Returns: + Tuple of (success_count, failed_count) + """ + processed_count = 0 + failed_count = 0 + + # Process payments in chunks to avoid timeout issues + chunk_size = max_threads * 2 + for i in range(0, len(payments), chunk_size): + chunk = payments[i:i + chunk_size] + self.logger.info(f"Processing chunk {i//chunk_size + 1}: payments {i+1}-{min(i+chunk_size, len(payments))}") + + # Prepare payment tasks for this chunk + payment_tasks = [] + for pay in chunk: + payment_data = self.create_payment_data(pay, pay.id) + payment_tasks.append(payment_data) + + # Process chunk with ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=max_threads) as executor: + future_to_payment = { + executor.submit(self._process_single_payment, task): task + for task in payment_tasks + } + + # Process results as they complete + for future in as_completed(future_to_payment): + try: + result = future.result(timeout=60) + + if result['success'] and result['result']: + # Immediately commit each successful payment + self._update_payment_result(result['payment_id'], result['result']) + processed_count += 1 + self.logger.info(f"Payment {result['payment_id']} processed ({processed_count}/{len(payments)})") + else: + failed_count += 1 + self.logger.warning(f"Payment {result['payment_id']} failed ({failed_count} failures)") + + except Exception as e: + payment_data = future_to_payment[future] + failed_count += 1 + self.logger.error(f"Thread exception for payment {payment_data['payment_id']}: {e}") + + self.logger.info(f"Chunk completed: {processed_count} processed, {failed_count} failed") + + return (processed_count, failed_count) + + def _process_single_payment(self, payment_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process a single payment (thread-safe). + + Args: + payment_data: Dictionary containing payment information + + Returns: + Dictionary with payment result and metadata + """ + try: + # Process payment with Stripe + print(f"\n\nBatch Processor - processPaymentResult - Mode: {self.config.MODE}") + result = self.stripe_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 { + 'payment_id': payment_data['payment_id'], + 'result': result, + 'success': True + } + except Exception as e: + self.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_payment_result(self, payment_id: int, result: Dict[str, Any]): + """ + Thread-safe update of payment result to database. + + Args: + payment_id: Payment ID + result: Payment processing result + """ + with self.db_lock: + try: + if result: + self.handle_payment_result(payment_id, result, payment_type="pay") + self.logger.debug(f"Payment {payment_id} result committed to database") + else: + self.logger.warning(f"No result to commit for payment {payment_id}") + except Exception as e: + self.logger.error(f"Failed to update payment {payment_id}: {e}") diff --git a/payment_processors/followup_processor.py b/payment_processors/followup_processor.py new file mode 100644 index 0000000..1944682 --- /dev/null +++ b/payment_processors/followup_processor.py @@ -0,0 +1,310 @@ +""" +Follow-Up Processor Module + +This module handles follow-up processing for payment intents and refunds. +""" + +import json +import logging +from datetime import datetime +from typing import Dict, Any +from .base_processor import BasePaymentProcessor + +logger = logging.getLogger(__name__) + + +class FollowUpProcessor(BasePaymentProcessor): + """ + Processor for payment intent and refund follow-up modes. + + This class handles checking the status of pending payment intents + and refunds, updating the database accordingly. + """ + + def process(self) -> Dict[str, Any]: + """ + Process follow-up (payment intents by default). + + This method is required by BasePaymentProcessor abstract class. + Use process_payment_intents() or process_refunds() for specific modes. + + Returns: + Dictionary with processing results + """ + return self.process_payment_intents() + + def process_payment_intents(self) -> Dict[str, Any]: + """ + Process follow-up for pending payment intents. + + Returns: + Dictionary with processing results + """ + start_time = datetime.now() + self.log_processing_start("Payment Intent Follow-up") + + # Get pending payment intents + pending_intents = self.payment_repo.get_pending_payment_intents(payment_type="both") + + total_pending = sum(len(v) for v in pending_intents.values()) + if total_pending == 0: + self.logger.info("No payment intents requiring follow-up") + return { + 'success': True, + 'total_pending': 0, + 'succeeded': 0, + 'failed': 0, + 'still_pending': 0, + 'duration': (datetime.now() - start_time).total_seconds() + } + + # Process payment intents + succeeded_count = 0 + failed_count = 0 + still_pending = 0 + + for payment_type, payments in pending_intents.items(): + self.logger.info(f"Processing {len(payments)} {payment_type} payment intents") + + for payment in payments: + result = self._check_payment_intent(payment, payment_type) + if result == "succeeded": + succeeded_count += 1 + elif result == "failed": + failed_count += 1 + elif result == "pending": + still_pending += 1 + + # Calculate duration and log completion + duration = (datetime.now() - start_time).total_seconds() + self.log_processing_complete( + "Payment Intent Follow-up", + succeeded_count, + failed_count, + duration, + {'still_pending': still_pending, 'total_checked': total_pending} + ) + + # Log using services + try: + from services import log_payment_intent_followup + log_payment_intent_followup(total_pending, succeeded_count, failed_count, still_pending) + except Exception as e: + self.logger.warning(f"Failed to log payment intent follow-up: {e}") + + return { + 'success': True, + 'total_pending': total_pending, + 'succeeded': succeeded_count, + 'failed': failed_count, + 'still_pending': still_pending, + 'duration': duration + } + + def process_refunds(self) -> Dict[str, Any]: + """ + Process follow-up for pending refunds. + + Returns: + Dictionary with processing results + """ + start_time = datetime.now() + self.log_processing_start("Refund Follow-up") + + # Get pending refunds + pending_refunds = self.payment_repo.get_pending_refunds(payment_type="both") + + total_pending = sum(len(v) for v in pending_refunds.values()) + if total_pending == 0: + self.logger.info("No refunds requiring follow-up") + return { + 'success': True, + 'total_pending': 0, + 'completed': 0, + 'failed': 0, + 'still_pending': 0, + 'duration': (datetime.now() - start_time).total_seconds() + } + + # Process refunds + completed_count = 0 + failed_count = 0 + still_pending = 0 + + for payment_type, refunds in pending_refunds.items(): + self.logger.info(f"Processing {len(refunds)} {payment_type} refunds") + + for refund in refunds: + result = self._check_refund(refund, payment_type) + if result == "succeeded": + completed_count += 1 + elif result == "failed": + failed_count += 1 + elif result == "pending": + still_pending += 1 + + # Calculate duration and log completion + duration = (datetime.now() - start_time).total_seconds() + self.log_processing_complete( + "Refund Follow-up", + completed_count, + failed_count, + duration, + {'still_pending': still_pending, 'total_checked': total_pending} + ) + + # Log using services + try: + from services import log_activity + 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 e: + self.logger.warning(f"Failed to log refund follow-up activity: {e}") + + return { + 'success': True, + 'total_pending': total_pending, + 'completed': completed_count, + 'failed': failed_count, + 'still_pending': still_pending, + 'duration': duration + } + + def _check_payment_intent(self, payment, payment_type: str) -> str: + """ + Check the status of a payment intent and update the database. + + Args: + payment: Payment record + payment_type: "pay" or "singlepay" + + Returns: + Status string: "succeeded", "failed", or "pending" + """ + try: + intent_result = self.stripe_processor.check_payment_intent(payment.Payment_Intent) + self.logger.debug(f"Payment intent {payment.Payment_Intent}: {intent_result['status']}") + + 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 + + # Process payment result to update Splynx + self.handle_payment_result(payment.id, intent_result, payment_type) + + self.payment_repo.commit() + self.logger.info(f"SUCCESS: Payment intent {payment.Payment_Intent} succeeded") + return "succeeded" + + elif intent_result['status'] == "failed": + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_FollowUp = False + payment.PI_Last_Check = datetime.now() + + # Process payment result to update Splynx + self.handle_payment_result(payment.id, intent_result, payment_type) + + self.payment_repo.commit() + self.logger.warning(f"ERROR: Payment intent {payment.Payment_Intent} failed") + return "failed" + + else: + # Still pending or requires action + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_Last_Check = datetime.now() + + if intent_result.get('failure_reason'): + # Has a failure reason, mark as failed + self.handle_payment_result(payment.id, intent_result, payment_type) + payment.PI_FollowUp = False + payment.Error = json.dumps(intent_result) + self.payment_repo.commit() + self.logger.warning(f"ERROR: Payment intent {payment.Payment_Intent} failed with reason") + return "failed" + else: + # Still pending + self.payment_repo.commit() + self.logger.info(f"PENDING: Payment intent {payment.Payment_Intent} still pending") + return "pending" + + except Exception as e: + self.logger.error(f"Error checking payment intent {payment.Payment_Intent}: {e}") + return "failed" + + def _check_refund(self, refund_record, payment_type: str) -> str: + """ + Check the status of a refund and update the database. + + Args: + refund_record: Payment record with refund + payment_type: "pay" or "singlepay" + + Returns: + Status string: "succeeded", "failed", or "pending" + """ + try: + if not refund_record.Stripe_Refund_ID: + self.logger.error(f"No Stripe refund ID found for {payment_type} record {refund_record.id}") + return "failed" + + refund_result = self.stripe_processor.check_refund_status(refund_record.Stripe_Refund_ID) + self.logger.debug(f"Refund {refund_record.Stripe_Refund_ID}: {refund_result.get('status')}") + + # Check if the API call was successful + if not refund_result.get('success', False): + self.logger.error(f"Failed to check refund status: {refund_result.get('error', 'Unknown error')}") + return "failed" + + 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 self.process_live and refund_record.Payment_Intent: + delete_result = self.splynx_repo.delete_payment( + customer_id=refund_record.Splynx_ID, + payment_intent_id=refund_record.Payment_Intent + ) + if delete_result.get('success'): + self.logger.info(f"Deleted Splynx payment for refund completion: customer {refund_record.Splynx_ID}") + else: + self.logger.warning(f"Failed to delete Splynx payment: {delete_result.get('error')}") + + self.payment_repo.commit() + self.logger.info(f"SUCCESS: Refund completed: {refund_record.Stripe_Refund_ID}") + return "succeeded" + + elif refund_result['status'] in ["failed", "canceled"]: + # Refund failed + refund_record.Refund_FollowUp = False + refund_record.Refund_JSON = json.dumps(refund_result) + self.payment_repo.commit() + self.logger.warning(f"ERROR: Refund failed: {refund_record.Stripe_Refund_ID} - {refund_result['status']}") + return "failed" + + elif refund_result['status'] == "pending": + # Still pending + refund_record.Refund_JSON = json.dumps(refund_result) + self.payment_repo.commit() + self.logger.info(f"PENDING: Refund still pending: {refund_record.Stripe_Refund_ID}") + return "pending" + + else: + # Unknown status + refund_record.Refund_JSON = json.dumps(refund_result) + self.payment_repo.commit() + self.logger.warning(f"WARNING: Unknown refund status: {refund_record.Stripe_Refund_ID} - {refund_result['status']}") + return "pending" + + except Exception as e: + self.logger.error(f"Error processing refund {refund_record.Stripe_Refund_ID}: {e}") + return "failed" diff --git a/payment_processors/notification_handler.py b/payment_processors/notification_handler.py new file mode 100644 index 0000000..53c54d5 --- /dev/null +++ b/payment_processors/notification_handler.py @@ -0,0 +1,137 @@ +""" +Notification Handler Module + +This module handles failed payment notifications and ticket creation. +""" + +import logging +from payment_services.payment_service import create_customer_friendly_message +from notification_service import NotificationService + +logger = logging.getLogger(__name__) + + +def handle_failed_payment_notification(payment_record, error_details: str, cust_stripe_details: dict, payment_type: str = "batch"): + """ + Handle notification and ticket creation for failed payments. + + Args: + payment_record: Database payment record (Payments or SinglePayments) + error_details: Error message details + payment_type: Type of payment ("batch" or "single") + """ + # Import here to avoid circular dependencies + from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET + from config import Config + + try: + # Initialize services + splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) + notification_service = NotificationService() + + # Get customer information from Splynx + try: + customer_data = splynx.Customer(payment_record.Splynx_ID) + customer_name = customer_data.get('name', 'Unknown Customer') if customer_data != 'unknown' else 'Unknown Customer' + except: + customer_name = 'Unknown Customer' + + # Prepare payment data for notification + payment_data = { + 'payment_id': payment_record.id, + 'splynx_id': payment_record.Splynx_ID, + 'amount': abs(payment_record.Payment_Amount), + 'error': error_details, + 'payment_method': payment_record.Payment_Method or 'Unknown', + 'customer_name': customer_name, + 'payment_type': payment_type, + 'stripe_customer_id': payment_record.Stripe_Customer_ID, + 'payment_intent': payment_record.Payment_Intent + } + + # Revert pending invoices back to "not_paid" (only in live mode) + from payment_services.payment_service import find_set_pending_splynx_invoices_to_unpaid + updated_invoices = find_set_pending_splynx_invoices_to_unpaid(splynx, payment_record.Splynx_ID) + if updated_invoices: + logger.info(f"Payment failure: pending invoices reverted to not_paid for Splynx ID {payment_record.Splynx_ID}") + else: + logger.warning(f"No pending invoices to revert for Splynx ID {payment_record.Splynx_ID}") + + # Send email notification + email_sent = notification_service.send_payment_failure_notification(payment_data) + if email_sent: + logger.info(f"Payment failure email sent for payment {payment_record.id}") + else: + logger.error(f"Failed to send payment failure email for payment {payment_record.id}") + + # Create Splynx ticket + ticket_subject = f"Payment Failure - Customer {payment_record.Splynx_ID} - ${abs(payment_record.Payment_Amount):.2f}" + + internal_message = f""" + + + +
Payment processing has failed for customer {customer_name} (ID: {payment_record.Splynx_ID}).
+

+
Payment Details:
+ +

+
Error Information:
+
{error_details}
+

+
This ticket was automatically created by the Plutus Payment System.
+ + +""" + + # Create customer-friendly message + payment_data_for_msg = { + 'amount': payment_data['amount'], + 'splynx_id': payment_data['splynx_id'], + 'pi_json': payment_record.PI_JSON, + 'stripe_customer_id': payment_record.Stripe_Customer_ID, + 'cust_stripe_details': cust_stripe_details + } + customer_message = create_customer_friendly_message(payment_data_for_msg, error_details) + + ticket_result = splynx.create_ticket( + customer_id=payment_record.Splynx_ID, + subject=ticket_subject, + priority='medium', + type_id=1, + group_id=7, + status_id=1, + ) + + if ticket_result.get('success'): + logger.info(f"Splynx ticket created: #{ticket_result['ticket_id']} for payment {payment_record.id}") + + # Add internal note + splynx.add_ticket_message( + ticket_id=ticket_result['ticket_id'], + message=internal_message, + is_admin=False, + hide_for_customer=True, + message_type="note" + ) + + # Add customer-visible message + splynx.add_ticket_message( + ticket_id=ticket_result['ticket_id'], + message=customer_message, + is_admin=False, + hide_for_customer=False, + message_type="message" + ) + + else: + logger.error(f"Failed to create Splynx ticket for payment {payment_record.id}: {ticket_result.get('error')}") + + except Exception as e: + logger.error(f"Error handling failed payment notification for payment {payment_record.id}: {e}") diff --git a/payment_processors/payment_plan_processor.py b/payment_processors/payment_plan_processor.py new file mode 100644 index 0000000..4d2fba7 --- /dev/null +++ b/payment_processors/payment_plan_processor.py @@ -0,0 +1,292 @@ +""" +Payment Plan Processor Module + +This module handles processing of recurring payment plans. +""" + +import logging +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Dict, Any, List +from .base_processor import BasePaymentProcessor + +logger = logging.getLogger(__name__) + + +class PaymentPlanProcessor(BasePaymentProcessor): + """ + Processor for payment plan mode. + + This class handles processing of recurring payment plans that are due + based on their schedule (weekly/fortnightly). + """ + + def __init__(self, *args, **kwargs): + """Initialize the payment plan processor.""" + super().__init__(*args, **kwargs) + self.db_lock = threading.Lock() + + def process(self) -> Dict[str, Any]: + """ + Process payment plans that are due today. + + Returns: + Dictionary with processing results + """ + start_time = datetime.now() + self.log_processing_start("Payment Plan") + + # Query for due payment plans + due_plans = self._get_due_payment_plans() + + if not due_plans: + self.logger.info("No payment plans due for processing today") + return { + 'success': True, + 'batch_id': None, + 'success_count': 0, + 'failed_count': 0, + 'total_amount': 0.0, + 'duration': (datetime.now() - start_time).total_seconds() + } + + # Create batch for payment plans + batch_id = self.payment_repo.create_payment_batch() + if batch_id is None: + self.logger.error("Failed to create batch for payment plan processing") + return { + 'success': False, + 'batch_id': None, + 'success_count': 0, + 'failed_count': 0, + 'total_amount': 0.0, + 'duration': (datetime.now() - start_time).total_seconds() + } + + # Add payments to batch + total_amount = sum(abs(plan.get('deposit', 0)) for plan in due_plans) + result = self.payment_repo.add_payments_to_batch(due_plans, batch_id) + + if result['added'] == 0: + self.logger.error("Failed to add payment plans to batch") + return { + 'success': False, + 'batch_id': batch_id, + 'success_count': 0, + 'failed_count': 0, + 'total_amount': 0.0, + 'duration': (datetime.now() - start_time).total_seconds() + } + + self.logger.info(f"Created payment plan batch {batch_id} with {result['added']} plans (${total_amount:,.2f} total)") + + # Log batch creation + try: + from services import log_batch_created + log_batch_created(batch_id, "Payment Plan", result['added']) + except Exception as e: + self.logger.warning(f"Failed to log batch creation: {e}") + + # Execute payments + payments = self.payment_repo.get_payments_by_batch(batch_id) + success_count, failed_count = self._process_payments_threaded( + payments, + self.config.MAX_PAYMENT_THREADS + ) + + # Calculate duration and log completion + duration = (datetime.now() - start_time).total_seconds() + self.log_processing_complete( + "Payment Plan", + success_count, + failed_count, + duration, + { + 'batch_id': batch_id, + 'total_amount': f"${total_amount:,.2f}" + } + ) + + return { + 'success': True, + 'batch_id': batch_id, + 'success_count': success_count, + 'failed_count': failed_count, + 'total_amount': total_amount, + 'duration': duration + } + + def _get_due_payment_plans(self) -> List[Dict[str, Any]]: + """ + Get payment plans that are due for processing today. + + Returns: + List of payment plan dictionaries + """ + active_plans = self.payment_repo.get_active_payment_plans() + due_plans = [] + + for plan in active_plans: + if plan.Start_Date and self._is_payment_day( + start_date_string=str(plan.Start_Date.strftime('%Y-%m-%d')), + schedule=plan.Frequency + ): + payment_data = { + "customer_id": plan.Splynx_ID, + "stripe_customer_id": plan.Stripe_Customer_ID, + "deposit": plan.Amount * -1, + "stripe_pm": plan.Stripe_Payment_Method, + "paymentplan_id": plan.id + } + due_plans.append(payment_data) + + self.logger.info(f"Found {len(due_plans)} payment plans due today") + return due_plans + + def _is_payment_day(self, start_date_string: str, schedule: str, date_format: str = "%Y-%m-%d") -> bool: + """ + Check if today is a payment day based on start date and frequency. + + Args: + start_date_string: The first payment date + schedule: Payment frequency ("Weekly" or "Fortnightly") + date_format: Format of the date string + + Returns: + True if today is a payment day, False otherwise + """ + try: + if not start_date_string or not schedule: + self.logger.error("Missing required parameters for payment day calculation") + return False + + if schedule == "Weekly": + num_days = 7 + elif schedule == "Fortnightly": + num_days = 14 + else: + self.logger.error(f"Unsupported payment schedule '{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: + self.logger.error(f"Error parsing date '{start_date_string}' with format '{date_format}': {e}") + return False + except Exception as e: + self.logger.error(f"Unexpected error in is_payment_day: {e}") + return False + + def _process_payments_threaded(self, payments: List[Any], max_threads: int) -> tuple: + """ + Process payments using thread pool. + + Args: + payments: List of payment records + max_threads: Maximum number of concurrent threads + + Returns: + Tuple of (success_count, failed_count) + """ + processed_count = 0 + failed_count = 0 + + if not payments: + return (0, 0) + + self.logger.info(f"Processing {len(payments)} payment plan payments using {max_threads} threads") + + # Prepare payment tasks + payment_tasks = [] + for pay in payments: + payment_data = self.create_payment_data(pay, pay.id) + payment_tasks.append(payment_data) + + # Process with ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=max_threads) as executor: + future_to_payment = { + executor.submit(self._process_single_payment, task): task + for task in payment_tasks + } + + # Process results as they complete + for future in as_completed(future_to_payment): + try: + result = future.result(timeout=60) + + if result['success'] and result['result']: + # Immediately commit each successful payment + self._update_payment_result(result['payment_id'], result['result']) + processed_count += 1 + self.logger.info(f"Payment {result['payment_id']} processed ({processed_count}/{len(payments)})") + else: + failed_count += 1 + self.logger.warning(f"Payment {result['payment_id']} failed ({failed_count} failures)") + + except Exception as e: + payment_data = future_to_payment[future] + failed_count += 1 + self.logger.error(f"Thread exception for payment {payment_data['payment_id']}: {e}") + + return (processed_count, failed_count) + + def _process_single_payment(self, payment_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process a single payment (thread-safe). + + Args: + payment_data: Dictionary containing payment information + + Returns: + Dictionary with payment result and metadata + """ + try: + # Process payment with Stripe + result = self.stripe_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 { + 'payment_id': payment_data['payment_id'], + 'result': result, + 'success': True + } + except Exception as e: + self.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_payment_result(self, payment_id: int, result: Dict[str, Any]): + """ + Thread-safe update of payment result to database. + + Args: + payment_id: Payment ID + result: Payment processing result + """ + with self.db_lock: + try: + if result: + self.handle_payment_result(payment_id, result, payment_type="pay") + self.logger.debug(f"Payment {payment_id} result committed to database") + else: + self.logger.warning(f"No result to commit for payment {payment_id}") + except Exception as e: + self.logger.error(f"Failed to update payment {payment_id}: {e}") diff --git a/payment_services/__init__.py b/payment_services/__init__.py new file mode 100644 index 0000000..51a164c --- /dev/null +++ b/payment_services/__init__.py @@ -0,0 +1,26 @@ +""" +Services module for shared business logic. + +This module contains reusable service functions that are shared between +the query_mysql.py script and the Flask application. +""" + +from .payment_service import ( + processPaymentResult, + find_pay_splynx_invoices, + find_set_pending_splynx_invoices, + find_set_pending_splynx_invoices_to_unpaid, + add_payment_splynx, + delete_splynx_invoices, + create_customer_friendly_message +) + +__all__ = [ + 'processPaymentResult', + 'find_pay_splynx_invoices', + 'find_set_pending_splynx_invoices', + 'find_set_pending_splynx_invoices_to_unpaid', + 'add_payment_splynx', + 'delete_splynx_invoices', + 'create_customer_friendly_message' +] diff --git a/payment_services/customer_service.py b/payment_services/customer_service.py new file mode 100644 index 0000000..f13041a --- /dev/null +++ b/payment_services/customer_service.py @@ -0,0 +1,141 @@ +""" +Customer Service Module + +This module contains customer-related functions including: +- Stripe customer ID lookup and creation +- MySQL customer billing queries +- Customer data retrieval +""" + +import logging +import pymysql +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +def get_stripe_customer_id_from_mysql(splynx_id: int, mysql_config: dict) -> Optional[str]: + """ + Query MySQL database to get Stripe customer ID for a Splynx customer. + + Args: + splynx_id: Customer ID in Splynx + mysql_config: MySQL connection configuration dictionary + + Returns: + Stripe customer ID if found, None otherwise + """ + connection = None + try: + connection = pymysql.connect( + host=mysql_config['host'], + database=mysql_config['database'], + user=mysql_config['user'], + password=mysql_config['password'], + port=mysql_config['port'], + autocommit=False, + cursorclass=pymysql.cursors.DictCursor + ) + + query = """ + SELECT field_1 AS stripe_customer_id + FROM payment_account_data + WHERE customer_id = %s + LIMIT 1 + """ + + with connection.cursor() as cursor: + cursor.execute(query, (splynx_id,)) + result = cursor.fetchone() + + if result and result.get('stripe_customer_id'): + logger.info(f"Found Stripe customer ID for Splynx ID {splynx_id}: {result['stripe_customer_id']}") + return result['stripe_customer_id'] + else: + logger.warning(f"No Stripe customer ID found for Splynx ID {splynx_id}") + return None + + except pymysql.Error as e: + logger.error(f"MySQL Error while fetching Stripe customer ID: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error while fetching Stripe customer ID: {e}") + return None + finally: + if connection: + connection.close() + + +def get_customer_from_splynx(splynx, splynx_id: int) -> Optional[Dict[str, Any]]: + """ + Get customer information from Splynx API. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + + Returns: + Customer data dictionary if found, None otherwise + """ + try: + customer_data = splynx.Customer(splynx_id) + if customer_data != 'unknown': + return customer_data + else: + logger.warning(f"Customer {splynx_id} not found in Splynx") + return None + except Exception as e: + logger.error(f"Error fetching customer {splynx_id} from Splynx: {e}") + return None + + +def get_stripe_customer_id_from_splynx(splynx, splynx_id: int) -> Optional[str]: + """ + Get Stripe customer ID from Splynx additional attributes. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + + Returns: + Stripe customer ID if found, None otherwise + """ + try: + customer_data = get_customer_from_splynx(splynx, splynx_id) + if customer_data: + # Check for Stripe customer ID in additional attributes + additional_attrs = customer_data.get('additional_attributes', {}) + stripe_id = additional_attrs.get('stripe_customer_id') + if stripe_id: + logger.info(f"Found Stripe customer ID in Splynx attributes for customer {splynx_id}") + return stripe_id + return None + except Exception as e: + logger.error(f"Error fetching Stripe ID from Splynx for customer {splynx_id}: {e}") + return None + + +def create_stripe_customer(stripe_processor, email: str, name: str, splynx_id: int) -> Optional[str]: + """ + Create a new Stripe customer. + + Args: + stripe_processor: StripePaymentProcessor instance + email: Customer email + name: Customer name + splynx_id: Customer ID in Splynx (for metadata) + + Returns: + Stripe customer ID if created successfully, None otherwise + """ + try: + # This would use the Stripe API via stripe_processor + # For now, just log that this would need implementation + logger.info(f"Creating new Stripe customer for {email} (Splynx ID: {splynx_id})") + # Implementation would go here using stripe_processor + # customer = stripe_processor.create_customer(email=email, name=name, metadata={'splynx_id': splynx_id}) + # return customer.id + return None + except Exception as e: + logger.error(f"Error creating Stripe customer: {e}") + return None diff --git a/payment_services/payment_service.py b/payment_services/payment_service.py new file mode 100644 index 0000000..b63aa08 --- /dev/null +++ b/payment_services/payment_service.py @@ -0,0 +1,452 @@ +""" +Payment Service Module + +This module contains shared payment processing functions used by both +query_mysql.py and the Flask application. These functions handle: +- Payment result processing +- Splynx invoice status updates +- Payment record creation +- Customer-friendly error messages +""" + +import json +import logging +from datetime import datetime +from typing import List, Dict, Any, Union + +logger = logging.getLogger(__name__) + + +def create_customer_friendly_message(payment_data: dict, error_details: str) -> str: + """ + Create a customer-friendly ticket message for failed payments. + + Args: + payment_data: Dictionary containing payment information + error_details: Raw error details + + Returns: + str: HTML formatted customer-friendly message + """ + print("\n\ncreate_customer_friendly_message\n\n") + try: + # Import classify_payment_error from main.py + from blueprints.main import classify_payment_error + + # Extract payment details + amount = abs(payment_data.get('amount', 0)) + splynx_id = payment_data.get('splynx_id', 'Unknown') + + # Parse PI_JSON for payment method details if available + pi_json = payment_data.get('pi_json') + cust_stripe_details = payment_data.get('cust_stripe_details') + payment_method_type = "unknown" + last4 = "****" + + #cust_stripe_details = self.stripe_processor.get_customer_info(customer_id=payment_data.get('stripe_customer_id')) + + print(f"\npayment_data:\n{json.dumps(payment_data,indent=2)}\n\n") + + #if pi_json: + # try: + # parsed_json = json.loads(pi_json) + # payment_method_type = parsed_json.get('payment_method_type', 'unknown') +# + # # Get last 4 digits from various possible locations in JSON + # if 'payment_method_details' in parsed_json: + # pm_details = parsed_json['payment_method_details'] + # if payment_method_type == 'card' and 'card' in pm_details: + # last4 = pm_details['card'].get('last4', '****') + # elif payment_method_type == 'au_becs_debit' and 'au_becs_debit' in pm_details: + # last4 = pm_details['au_becs_debit'].get('last4', '****') + # elif 'last4' in parsed_json: + # last4 = parsed_json.get('last4', '****') + # except: + # pass + + if cust_stripe_details: + try: + if cust_stripe_details['payment_methods'][0]['type'] == "card": + last4 = cust_stripe_details['payment_methods'][0]['card']['last4'] + payment_method_type = "card" + elif cust_stripe_details['payment_methods'][0]['type'] == "au_becs_debit": + last4 = cust_stripe_details['payment_methods'][0]['au_becs_debit']['last4'] + payment_method_type = "au_becs_debit" + except: + pass + + # Format payment method for display + if payment_method_type == 'au_becs_debit': + payment_method_display = f"Bank Account ending in {last4}" + elif payment_method_type == 'card': + payment_method_display = f"Card ending in {last4}" + else: + payment_method_display = "Payment method" + + # Get current datetime + current_time = datetime.now().strftime("%d/%m/%Y at %I:%M %p") + + # Get customer-friendly error explanation + error_classification = classify_payment_error(error_details, pi_json) + if error_classification: + error_message = error_classification['message'] + else: + error_message = "An error occurred during payment processing" + + # Create customer-friendly HTML message + customer_message = f""" + + + +
Your payment attempt was unsuccessful.
+

+
Payment Details:
+
• Amount: ${amount:.2f} AUD
+
• Date/Time: {current_time}
+
• {payment_method_display}
+

+
Issue: {error_message}
+

+
Please contact us if you need assistance with your payment.
+ + +""" + + return customer_message.strip() + + except Exception as e: + # Fallback message if there's any error creating the friendly message + logger.error(f"Error creating customer-friendly message: {e}") + return f""" + + + +
Your payment attempt was unsuccessful. Please contact us for assistance.
+ + +""" + + +def find_pay_splynx_invoices(splynx, splynx_id: int, splynx_pay_id: int, invoice_list: List) -> List[Dict[str, Any]]: + """ + Mark Splynx invoices as paid for a given customer. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + remove_first_invoice = invoice_list.pop(0) + + #result = splynx.get( + # url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid&main_attributes[status]=pending" + #) + + invoice_pay = { + "status": "paid", + "payment_id": splynx_pay_id, + "date_payment": datetime.now().strftime("%Y-%m-%d") + } + 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) + for invoice in invoice_list: + res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice}", params=invoice_pay) + if res: + updated_invoices.append(res) + + return updated_invoices + + +def find_set_pending_splynx_invoices(splynx, splynx_id: int, invoice_list: List) -> List[Dict[str, Any]]: + """ + Mark Splynx invoices as pending for a given customer. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + #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) + + for invoice in invoice_list: + res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice}", params=invoice_pay) + if res: + updated_invoices.append(res) + + return updated_invoices + + +def find_set_pending_splynx_invoices_to_unpaid(splynx, splynx_id: int) -> List[Dict[str, Any]]: + """ + Revert pending Splynx invoices back to unpaid status for a given customer. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + result = splynx.get( + url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=pending" + ) + + invoice_pay = {"status": "not_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 delete_splynx_invoices(splynx, splynx_id: int, payintent: str) -> Dict[str, Any]: + """ + Delete Splynx payment records for a given customer and payment intent. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + payintent: Stripe payment intent ID + + Returns: + Dictionary with success status and details + """ + 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, splynx_id: int, pi_id: str, pay_id: int, amount: float, invoice: int) -> Union[int, bool]: + """ + Add a payment record to Splynx. + + Args: + splynx: Splynx API client instance + splynx_id: Customer ID in Splynx + pi_id: Stripe payment intent ID + pay_id: Internal payment ID + amount: Payment amount + + Returns: + Splynx payment ID if successful, False otherwise + """ + 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}", + "invoice_id": invoice + } + + res = splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay) + if res: + return res['id'] + else: + return False + + +def processPaymentResult(db, splynx, notification_handler, pay_id: int, result: dict, key: str, cust_stripe_details: dict, mode: str, process_live: bool = False): + """ + Process payment result and update database record. + + This function is shared between query_mysql.py and the Flask application. + It handles both successful and failed payments, updates the database, + and triggers notifications for failures. + + Args: + db: Database session/connection + splynx: Splynx API client instance + notification_handler: Function to handle failed payment notifications + pay_id: Payment record ID + result: Payment processing result dictionary from StripePaymentProcessor + key: "pay" for Payments table, "singlepay" for SinglePayments table + process_live: Whether to update live Splynx records + + Returns: + None (updates database in place) + """ + from models import Payments, SinglePayments + + # Fetch payment record + print(f"\n\nPayment Service - processPaymentResult - Mode: {mode}") + + if key == "pay": + payment = db.query(Payments).filter(Payments.id == pay_id).first() + elif key == "singlepay": + payment = db.query(SinglePayments).filter(SinglePayments.id == pay_id).first() + else: + logger.error(f"Invalid key '{key}' provided to processPaymentResult") + return + + if not payment: + logger.error(f"Payment record {pay_id} not found for key '{key}'") + return + + try: + # Handle errors + if result.get('error') and not result.get('needs_fee_update'): + print("\n\tPayment Error!\n") + #print(f"result: {json.dumps(result,indent=2)}") + payment.Error = f"Error Type: {result['error_type']}\nError: {result['error']}" + payment.Success = result['success'] + payment.Payment_Intent = result['payment_intent_id'] + payment.PI_JSON = json.dumps(result) + # Update payment method + 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'] + + # Send notification and create ticket for failed payments + #print(f"\n\nNotification Handler: {notification_handler}\n\n") + if notification_handler: + print("\n\tNotification time!\n") + notification_handler( + payment_record=payment, + error_details=payment.Error, + payment_type=key, + cust_stripe_details=cust_stripe_details + ) + + elif result.get('failure_details'): + payment.Error = f"Error Type: {result.get('failure_details').get('decline_code')}\nError: {result['failure_reason']}" + payment.Success = result['success'] + payment.Payment_Intent = result['payment_intent_id'] + payment.PI_JSON = json.dumps(result) + # Update payment method + 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'] + + # Send notification and create ticket for failed payments + if notification_handler: + print("\n\tNotification time!\n") + notification_handler( + payment_record=payment, + error_details=payment.Error, + payment_type=key, + cust_stripe_details=cust_stripe_details + ) + + else: + # Handle successful or pending payments + logger.info("Payment successful!") + + invoice_list = payment.Invoices_to_Pay.split(",") if payment.Invoices_to_Pay else [0] + + if result.get('needs_fee_update'): + payment.PI_FollowUp = True + # Mark invoices as pending when PI_FollowUp is set + #if process_live: + if invoice_list[0] != 0 and mode != 'payintent': + find_set_pending_splynx_invoices(splynx, payment.Splynx_ID, invoice_list) + + payment.Payment_Intent = result['payment_intent_id'] + payment.Success = result['success'] + + if result['success']: + #if result['success'] and process_live: + splynx_pay_id = add_payment_splynx( + splynx=splynx, + splynx_id=payment.Splynx_ID, + pi_id=result['payment_intent_id'], + pay_id=payment.id, + amount=payment.Payment_Amount, + invoice=invoice_list[0] + ) + if len(invoice_list) > 1: + find_pay_splynx_invoices(splynx, payment.Splynx_ID, splynx_pay_id, invoice_list) + + # Update payment method + 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'] + + # Update PI_JSON + if payment.PI_JSON: + combined = {**json.loads(payment.PI_JSON), **result} + payment.PI_JSON = json.dumps(combined) + else: + payment.PI_JSON = json.dumps(result) + + # Update fee details + 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'] + + # Commit changes + db.commit() + logger.info(f"Successfully processed payment result for payment {pay_id}") + + except Exception as e: + db.rollback() + logger.error(f"Error in processPaymentResult for payment {pay_id}: {e}") + + # Set PI_FollowUp flag on error to retry later + try: + payment.PI_FollowUp = True + #if process_live: + #find_set_pending_splynx_invoices(splynx, payment.Splynx_ID) + find_set_pending_splynx_invoices(splynx, payment.Splynx_ID, invoice_list) + db.commit() + except Exception as rollback_error: + logger.error(f"Failed to set PI_FollowUp flag after error: {rollback_error}") + db.rollback() diff --git a/pending_fixup.py b/pending_fixup.py deleted file mode 100644 index e74784f..0000000 --- a/pending_fixup.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -import stripe -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 -from config import Config -from sqlalchemy import and_ - - -splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) - - -api_key = Config.STRIPE_LIVE_API_KEY - -stripe.api_key = api_key - - -def find_pay_splynx_invoices(splynx_id: int, result: dict) -> 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&main_attributes[status]=pending") - - invoice_pay = { - "status": "paid" - } - - for pay in result: - res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay) - - - -if __name__ == "__main__": - app = create_app() - i = 1 - #cust_bill = splynx.get(url=f"/api/2.0/admin/customers/customer-billing/31") - #print(json.dumps(cust_bill,indent=2)) - with app.app_context(): - custs = db.session.query(PaymentBatch,Payments)\ - .join(Payments, Payments.PaymentBatch_ID == PaymentBatch.id)\ - .filter(and_(PaymentBatch.id.in_((102,103)), Payments.Success == True))\ - .all() - print(len(custs)) - for cust in custs: - cust_bill = splynx.get(url=f"/api/2.0/admin/customers/customer-billing/{cust.Payments.Splynx_ID}") - print(f"{i}/{len(custs)}") - if cust_bill['deposit'] == 0: - result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={cust.Payments.Splynx_ID}&main_attributes[status]=pending") - #print(json.dumps(result,indent=2)) - if len(result) > 0: - print(f"\t{cust.Payments.Splynx_ID} - Has unpaid invoices") - find_pay_splynx_invoices(splynx_id=cust.Payments.Splynx_ID, result=result) - i += 1 diff --git a/query_mysql - Copy.py b/query_mysql - Copy.py deleted file mode 100644 index ad347a2..0000000 --- a/query_mysql - Copy.py +++ /dev/null @@ -1,532 +0,0 @@ -#!/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 -from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime, timedelta -from stripe_payment_processor import StripePaymentProcessor -from config import Config -from app import create_app, db -from models import Logs, Payments, PaymentBatch, SinglePayments, PaymentPlans -from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET - -# 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 - -if PROCESS_LIVE: - api_key = "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" -else: - api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" - 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): - 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" - } - - for pay in result: - res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay) - #print(json.dumps(res,indent=2)) - return res - -def add_payment_splynx(splynx_id, pi_id, pay_id, amount): - 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, operation_name): - """ - 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() - print(f"❌ {operation_name} failed: {e}") - return None - -def is_payment_day(start_date_string, payplan_schedule, date_format="%Y-%m-%d"): - """ - Check if today is a fortnightly payment day based on a start date. - - Args: - start_date_string (str): The first payment date - date_format (str): Format of the date string - - Returns: - bool: True if today is a payment day, False otherwise - """ - try: - if payplan_schedule == "Weekly": - num_days = 7 - elif payplan_schedule == "Fortnightly": - num_days = 14 - 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 14 days (fortnight) - return days_since_start >= 0 and days_since_start % num_days == 0 - - except ValueError as e: - print(f"Error parsing date: {e}") - return False - - -def query_payplan_customers(): - """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 is_payment_day(start_date_string=str(cust.Start_Date.strftime('%Y-%m-%d')), payplan_schedule=cust.Frequency): - blah = { - "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(blah) - - return to_return - - -def query_splynx_customers(pm): - """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 - ) - - print("✅ Connected to MySQL database successfully") - print(f"Database: {Config.MYSQL_CONFIG['database']} on {Config.MYSQL_CONFIG['host']}") - print("-" * 80) - - ## Payment Method: - ## 2 - Direct Debit (Automatic) - ## 3 - Card Payment (Automatic) - ## 9 - Payment Plan - - # Execute the query - 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)) - #cursor.execute(query, (Config.DEFAULT_QUERY_LIMIT)) - results = cursor.fetchall() - - if results: - print(f"📊 Found {len(results)} rows:") - return results - else: - print("ℹ️ No rows found matching the criteria") - return False - - except pymysql.Error as e: - print(f"❌ MySQL Error: {e}") - sys.exit(1) - except Exception as e: - print(f"❌ Unexpected Error: {e}") - sys.exit(1) - finally: - if connection: - connection.close() - print("\n🔒 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 = cust['stripe_customer_id'] - 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) - print(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) - print(f"❌ addInitialPayments failed for entire batch {batch_id}: {e}") - - print(f"Plutus DB: {json.dumps(added,indent=2)}\n") - -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() - print(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: - print(f"processPaymentResult: {e}\n{json.dumps(result)}") - payment.PI_FollowUp = True - - 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: - print(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") - print(f"✅ Payment {payment_id} result committed to database") - else: - print(f"⚠️ No result to commit for payment {payment_id}") - except Exception as e: - print(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] - - 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) - addInitialPayments(customers=customers, batch_id=batch_id) - else: - print(f"❌ Failed to create batch for payment method {pm}") - - return to_run_batches - -def process_payplan_mode(processor): - """Handle payment plan processing.""" - to_run_batches = [] - batch_id = addPaymentBatch() - if batch_id is not None: - to_run_batches.append(batch_id) - customers = query_payplan_customers() - addInitialPayments(customers=customers, batch_id=batch_id) - else: - print(f"❌ Failed to create batch for payment plan processing") - - return to_run_batches - -def execute_payment_batches(processor, batch_ids): - """Execute payments for all provided batch IDs using safe threading with immediate commits.""" - if not batch_ids: - print("⚠️ No valid batches to process") - return - - max_threads = Config.MAX_PAYMENT_THREADS - - for batch in batch_ids: - if batch is None: - print("⚠️ Skipping None batch ID") - continue - - cust_pay = db.session.query(Payments).filter(Payments.PaymentBatch_ID == batch).all() - if not cust_pay: - print(f"ℹ️ No payments found for batch {batch}") - continue - - print(f"🔄 Processing {len(cust_pay)} payments in batch {batch} using {max_threads} threads") - print(f"📊 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] - print(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 - #customer_id = test_stripe_customers[random.randint(1, len(test_stripe_customers)-1)] - 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 - } - print(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 - print(f"✅ Payment {result['payment_id']} processed and committed ({processed_count}/{len(cust_pay)})") - else: - failed_count += 1 - print(f"❌ Payment {result['payment_id']} failed ({failed_count} failures total)") - - except Exception as e: - payment_data = future_to_payment[future] - failed_count += 1 - print(f"❌ Thread exception for payment {payment_data['payment_id']}: {e}") - - print(f"📊 Chunk completed: {processed_count} processed, {failed_count} failed") - - print(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(), - } - #pis = db.session.query(Payments).filter(Payments.PI_FollowUp == True).all() - #to_check.append(pis) - #pis = db.session.query(SinglePayments).filter(SinglePayments.PI_FollowUp == True).all() - #to_check.append(pis) - for key, value in to_check.items(): - print(value) - for pi in value: - intent_result = processor.check_payment_intent(pi.Payment_Intent) - print(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() - processPaymentResult(pay_id=pi.id, result=intent_result, key=key) - else: - pi.PI_FollowUp_JSON = json.dumps(intent_result) - pi.PI_Last_Check = datetime.now() - - db.session.commit() - -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 - - 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: - print(f"❌ Invalid running mode: {sys.argv[1]}") - print("Valid modes: batch, payintent, payplan") - sys.exit(1) - try: - if sys.argv[2] == "live": - PROCESS_LIVE = True - except: - print("Processing payments against Sandbox") - except IndexError: - print("ℹ️ No running mode specified, defaulting to 'payintent'") - running_mode = "payintent" - # Create Flask application context - app = create_app() - processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) - - with app.app_context(): - if running_mode == "batch": - batch_ids = process_batch_mode(processor) - execute_payment_batches(processor, batch_ids) - elif running_mode == "payplan": - batch_ids = process_payplan_mode(processor) - execute_payment_batches(processor, batch_ids) - elif running_mode == "payintent": - process_payintent_mode(processor) diff --git a/query_mysql_new.py b/query_mysql_new.py new file mode 100644 index 0000000..f96d88a --- /dev/null +++ b/query_mysql_new.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Payment Processing Script (New Architecture) + +This script provides backward-compatible CLI for the refactored payment processing system. +It maintains the same command-line interface as the original query_mysql.py while +using the new modular architecture under the hood. + +Usage: python query_mysql.py [mode] [live] +Modes: batch, payintent, payplan, refund + +The new architecture provides: +- Separation of concerns with dedicated modules +- Thread-safe database operations +- Improved error handling and logging +- Better testability and maintainability +""" + +import sys +import logging +from config import Config +from app import create_app +from orchestration import PaymentOrchestrator + +# 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__) + + +def main(): + """ + Main entry point - maintains backward compatibility with original CLI. + """ + # Parse command-line arguments (original format) + running_mode = "payintent" # Default mode + process_live = False + + try: + if len(sys.argv) > 1: + mode_arg = sys.argv[1].lower() + if mode_arg in ["batch", "payintent", "payplan", "refund"]: + running_mode = mode_arg + else: + logger.error(f"Invalid running mode: {sys.argv[1]}") + logger.info("Valid modes: batch, payintent, payplan, refund") + sys.exit(1) + + if len(sys.argv) > 2: + if sys.argv[2].lower() == "live": + process_live = True + else: + logger.warning(f"Unknown environment argument: {sys.argv[2]}. Using test mode.") + + except IndexError: + logger.info("No running mode specified, defaulting to 'payintent'") + + # Log execution details + env_display = "LIVE" if process_live else "SANDBOX" + logger.info("=" * 80) + logger.info(f"Payment Processing System (Refactored)") + logger.info(f"Mode: {running_mode.upper()} - Environment: {env_display}") + logger.info("=" * 80) + + try: + # Create Flask application + app = create_app() + + # Create and run orchestrator + orchestrator = PaymentOrchestrator( + app=app, + config=Config, + process_live=process_live + ) + + result = orchestrator.run(running_mode) + + # Log results + if result.get('success'): + logger.info("=" * 80) + logger.info("SUCCESS: Processing completed successfully") + logger.info("=" * 80) + sys.exit(0) + else: + logger.error("=" * 80) + logger.error(f"FAILED: Processing failed: {result.get('error', 'Unknown error')}") + logger.error("=" * 80) + sys.exit(1) + + except KeyboardInterrupt: + logger.warning("\n\nWARNING: Processing interrupted by user") + sys.exit(130) + except Exception as e: + logger.error(f"Fatal error: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/repositories/__init__.py b/repositories/__init__.py new file mode 100644 index 0000000..88f0e03 --- /dev/null +++ b/repositories/__init__.py @@ -0,0 +1,14 @@ +""" +Repositories module for data access layer. + +This module contains repository classes that handle database operations +for payments, customers, and external API interactions. +""" + +from .payment_repository import PaymentRepository +from .splynx_repository import SplynxRepository + +__all__ = [ + 'PaymentRepository', + 'SplynxRepository' +] diff --git a/repositories/payment_repository.py b/repositories/payment_repository.py new file mode 100644 index 0000000..cf3ac06 --- /dev/null +++ b/repositories/payment_repository.py @@ -0,0 +1,482 @@ +""" +Payment Repository Module + +This module provides a repository pattern for payment-related database operations. +It abstracts database access and provides a clean interface for CRUD operations. +""" + +import logging +import pymysql +import json +from datetime import datetime +from typing import List, Dict, Any, Optional, Union +from sqlalchemy.orm import Session +from collections import defaultdict + +logger = logging.getLogger(__name__) + + +class PaymentRepository: + """ + Repository for payment-related database operations. + + This class provides methods for creating, reading, updating, and deleting + payment records in both the Payments and SinglePayments tables. + """ + + def __init__(self, db_session: Session): + """ + Initialize the payment repository. + + Args: + db_session: SQLAlchemy database session + """ + self.db = db_session + + def create_payment_batch(self) -> Optional[int]: + """ + Create a new payment batch record. + + Returns: + Batch ID if successful, None otherwise + """ + from models import PaymentBatch + + try: + batch = PaymentBatch() + self.db.add(batch) + self.db.commit() + logger.info(f"Created payment batch with ID {batch.id}") + return batch.id + except Exception as e: + self.db.rollback() + logger.error(f"Failed to create payment batch: {e}") + return None + + def add_payments_to_batch(self, customers: List[Dict[str, Any]], batch_id: int, invoices: Dict) -> Dict[str, int]: + """ + Add multiple payments to a batch atomically. + + Args: + customers: List of customer payment data dictionaries + batch_id: Batch ID to associate payments with + + Returns: + Dictionary with 'added' and 'failed' counts + """ + from models import Payments + + result = {"added": 0, "failed": 0} + payments_to_add = [] + + try: + for cust in customers: + cust_id_str = str(cust['customer_id']) + cust_invoices = invoices.get(cust_id_str) # Returns None if no invoices + + payment = Payments( + PaymentBatch_ID=batch_id, + Splynx_ID=cust['customer_id'], + Stripe_Customer_ID=cust['stripe_customer_id'], + Payment_Amount=float(cust['deposit']) * -1, + Stripe_Payment_Method=cust.get('stripe_pm', None), + PaymentPlan_ID=cust.get('paymentplan_id', None), + Invoices_to_Pay=cust_invoices + ) + payments_to_add.append(payment) + self.db.add(payment) + + self.db.commit() + result["added"] = len(payments_to_add) + logger.info(f"Successfully added {len(payments_to_add)} payments to batch {batch_id}") + except Exception as e: + self.db.rollback() + result["failed"] = len(payments_to_add) + logger.error(f"Failed to add payments to batch {batch_id}: {e}") + + return result + + def get_payments_by_batch(self, batch_id: int) -> List[Any]: + """ + Get all payments for a given batch. + + Args: + batch_id: Batch ID + + Returns: + List of Payment objects + """ + from models import Payments + + try: + payments = self.db.query(Payments).filter(Payments.PaymentBatch_ID == batch_id).all() + logger.debug(f"Found {len(payments)} payments for batch {batch_id}") + return payments + except Exception as e: + logger.error(f"Error fetching payments for batch {batch_id}: {e}") + return [] + + def get_payment_by_id(self, payment_id: int, payment_type: str = "pay") -> Optional[Any]: + """ + Get a payment record by ID. + + Args: + payment_id: Payment record ID + payment_type: "pay" for Payments, "singlepay" for SinglePayments + + Returns: + Payment object if found, None otherwise + """ + from models import Payments, SinglePayments + + try: + if payment_type == "pay": + return self.db.query(Payments).filter(Payments.id == payment_id).first() + elif payment_type == "singlepay": + return self.db.query(SinglePayments).filter(SinglePayments.id == payment_id).first() + else: + logger.error(f"Invalid payment type: {payment_type}") + return None + except Exception as e: + logger.error(f"Error fetching payment {payment_id}: {e}") + return None + + def get_pending_payment_intents(self, payment_type: str = "both") -> Dict[str, List[Any]]: + """ + Get all payments with pending payment intents requiring follow-up. + + Args: + payment_type: "pay", "singlepay", or "both" + + Returns: + Dictionary with payment lists by type + """ + from models import Payments, SinglePayments + + result = {} + try: + if payment_type in ["pay", "both"]: + result["pay"] = self.db.query(Payments).filter(Payments.PI_FollowUp == True).all() + if payment_type in ["singlepay", "both"]: + result["singlepay"] = self.db.query(SinglePayments).filter(SinglePayments.PI_FollowUp == True).all() + + total = sum(len(v) for v in result.values()) + logger.info(f"Found {total} pending payment intents requiring follow-up") + return result + except Exception as e: + logger.error(f"Error fetching pending payment intents: {e}") + return {} + + def get_pending_refunds(self, payment_type: str = "both") -> Dict[str, List[Any]]: + """ + Get all payments with pending refunds requiring follow-up. + + Args: + payment_type: "pay", "singlepay", or "both" + + Returns: + Dictionary with payment lists by type + """ + from models import Payments, SinglePayments + + result = {} + try: + if payment_type in ["pay", "both"]: + result["pay"] = self.db.query(Payments).filter(Payments.Refund_FollowUp == True).all() + if payment_type in ["singlepay", "both"]: + result["singlepay"] = self.db.query(SinglePayments).filter(SinglePayments.Refund_FollowUp == True).all() + + total = sum(len(v) for v in result.values()) + logger.info(f"Found {total} pending refunds requiring follow-up") + return result + except Exception as e: + logger.error(f"Error fetching pending refunds: {e}") + return {} + + def get_active_payment_plans(self) -> List[Any]: + """ + Get all active payment plans. + + Returns: + List of PaymentPlans objects + """ + from models import PaymentPlans + + try: + plans = self.db.query(PaymentPlans).filter(PaymentPlans.Enabled == True).all() + logger.info(f"Found {len(plans)} active payment plans") + return plans + except Exception as e: + logger.error(f"Error fetching active payment plans: {e}") + return [] + + def get_test_customer_data(self, payment_method: List) -> Union[List[Dict[str, Any]], bool]: + + customers = [ + { + "customer_id": 1222024, + "stripe_customer_id": "cus_SoNAgAbkbFo8ZY", + "deposit": -10, + "stripe_pm": "pm_1RskQIPfYyg6zE1Spbtf4pIa", + "paymentplan_id": None + }, + { + "customer_id": 1222025, + "stripe_customer_id": "cus_SoMVPWxdYstYbr", + "deposit": -10, + "stripe_pm": "pm_1SmTzDPfYyg6zE1SZnZogJAz", + "paymentplan_id": None + }, + { + "customer_id": 1222026, + "stripe_customer_id": "cus_SoMVQ6Xj2dIrCR", + "deposit": -10, + "stripe_pm": "pm_1RsjmdPfYyg6zE1SOp2vBKNT", + "paymentplan_id": None + }, + { + "customer_id": 1222027, + "stripe_customer_id": "cus_SoMVqjH7zkc5Qe", + "deposit": -10, + "stripe_pm": "pm_1RsjmFPfYyg6zE1SS1DmqdvB", + "paymentplan_id": None + }, + { + "customer_id": 1222028, + "stripe_customer_id": "cus_SoQiDcSrNRxbPF", + "deposit": -10, + "stripe_pm": "pm_1SmTblPfYyg6zE1SWkzAFekG", + "paymentplan_id": None + }, + + ] + return customers + + + def query_mysql_customer_invoices(self, mysql_config: dict, limit: int = 10) -> Dict: + """ + Query customer invoice data from external MySQL database. + + Args: + mysql_config: MySQL connection configuration + limit: Maximum number of results + + Returns: + List of customer dictionaries or False if no results/error + """ + invoices_dict = {} + connection = None + try: + connection = pymysql.connect( + host=mysql_config['host'], + database=mysql_config['database'], + user=mysql_config['user'], + password=mysql_config['password'], + port=mysql_config['port'], + autocommit=False, + cursorclass=pymysql.cursors.DictCursor + ) + + logger.info(f"Connected to MySQL database: {mysql_config['database']} on {mysql_config['host']}") + + query = """ + SELECT + i.id, + i.customer_id + FROM invoices i + WHERE i.status = 'not_paid' + ORDER BY i.customer_id ASC + LIMIT %s + """ + + with connection.cursor() as cursor: + cursor.execute(query, (limit)) + results = cursor.fetchall() + + if results: + logger.info(f"Found {len(results)} customer invoices") + # Group invoice IDs by customer_id + invoices_dict = defaultdict(list) + for res in results: + #print(res['id'], res['customer_id']) + invoices_dict[res['customer_id']].append(str(res['id'])) + + #print(f"\nCustomer Invoice Data: {json.dumps(invoices_dict)}\n") + print(f"Cust 31: {invoices_dict[31]}") + # Convert to comma-separated strings + invoices_dict = { + str(cust_id): ",".join(inv_ids) + for cust_id, inv_ids in invoices_dict.items() + } + + return invoices_dict + else: + logger.info(f"No customer invoices found") + return False + + except pymysql.Error as e: + logger.error(f"MySQL Error: {e}") + return False + except Exception as e: + logger.error(f"Unexpected Error querying MySQL: {e}") + return False + finally: + if connection: + connection.close() + logger.debug("MySQL connection closed") + + def query_mysql_customers(self, mysql_config: dict, payment_methods: List, deposit_threshold: float = 0, limit: int = 10) -> Union[List[Dict[str, Any]], bool]: + """ + Query customer billing data from external MySQL database. + + Args: + mysql_config: MySQL connection configuration + payment_method: Payment method ID (2=Direct Debit, 3=Card, 9=Payment Plan) + deposit_threshold: Maximum deposit threshold + limit: Maximum number of results + + Returns: + List of customer dictionaries or False if no results/error + """ + connection = None + try: + connection = pymysql.connect( + host=mysql_config['host'], + database=mysql_config['database'], + user=mysql_config['user'], + password=mysql_config['password'], + port=mysql_config['port'], + autocommit=False, + cursorclass=pymysql.cursors.DictCursor + ) + + logger.info(f"Connected to MySQL database: {mysql_config['database']} on {mysql_config['host']}") + + placeholders = ','.join(['%s'] * len(payment_methods)) + + query = f""" + 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 IN ({placeholders}) + 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 + """ + + ###### Multi-month billing runs + #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' + #) + #AND EXISTS ( + # SELECT 1 + # FROM invoices i + # WHERE i.customer_id = cb.customer_id + # AND i.date_till = '2026-01-20' + #) + #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 + #""" + + ## Need to fix "date_till" + ##################### + + + + with connection.cursor() as cursor: + cursor.execute(query, (*payment_methods, deposit_threshold, limit)) + results = cursor.fetchall() + + if results: + logger.info(f"Found {len(results)} customers for payment method {payment_methods}") + return results + else: + logger.info(f"No customers found for payment method {payment_methods}") + return False + + except pymysql.Error as e: + logger.error(f"MySQL Error: {e}") + return False + except Exception as e: + logger.error(f"Unexpected Error querying MySQL: {e}") + return False + finally: + if connection: + connection.close() + logger.debug("MySQL connection closed") + + def update_payment(self, payment_id: int, updates: Dict[str, Any], payment_type: str = "pay") -> bool: + """ + Update a payment record with given fields. + + Args: + payment_id: Payment ID + updates: Dictionary of field names and values to update + payment_type: "pay" or "singlepay" + + Returns: + True if successful, False otherwise + """ + try: + payment = self.get_payment_by_id(payment_id, payment_type) + if not payment: + logger.error(f"Payment {payment_id} not found") + return False + + for key, value in updates.items(): + if hasattr(payment, key): + setattr(payment, key, value) + else: + logger.warning(f"Payment object has no attribute '{key}'") + + self.db.commit() + logger.debug(f"Updated payment {payment_id} with {len(updates)} fields") + return True + except Exception as e: + self.db.rollback() + logger.error(f"Failed to update payment {payment_id}: {e}") + return False + + def commit(self): + """Commit the current database session.""" + try: + self.db.commit() + except Exception as e: + self.db.rollback() + logger.error(f"Failed to commit database session: {e}") + raise + + def rollback(self): + """Rollback the current database session.""" + try: + self.db.rollback() + except Exception as e: + logger.error(f"Failed to rollback database session: {e}") + raise diff --git a/repositories/splynx_repository.py b/repositories/splynx_repository.py new file mode 100644 index 0000000..acd8936 --- /dev/null +++ b/repositories/splynx_repository.py @@ -0,0 +1,314 @@ +""" +Splynx Repository Module + +This module provides a repository wrapper around the Splynx API client. +It adds additional error handling, retry logic, and commonly used operations. +""" + +import logging +import time +from typing import Dict, Any, Optional, List + +logger = logging.getLogger(__name__) + + +class SplynxRepository: + """ + Repository for Splynx API operations. + + This class wraps the Splynx API client and provides higher-level + operations with consistent error handling and retry logic. + """ + + def __init__(self, splynx_client, max_retries: int = 3, retry_delay: float = 1.0): + """ + Initialize the Splynx repository. + + Args: + splynx_client: Splynx API client instance + max_retries: Maximum number of retry attempts for transient failures + retry_delay: Delay in seconds between retries + """ + self.splynx = splynx_client + self.max_retries = max_retries + self.retry_delay = retry_delay + + def _retry_operation(self, operation_func, operation_name: str): + """ + Execute an operation with retry logic for transient failures. + + Args: + operation_func: Function to execute + operation_name: Description for logging + + Returns: + Result of operation_func or None if all retries failed + """ + last_error = None + for attempt in range(self.max_retries): + try: + return operation_func() + except Exception as e: + last_error = e + if attempt < self.max_retries - 1: + logger.warning(f"{operation_name} failed (attempt {attempt + 1}/{self.max_retries}): {e}") + time.sleep(self.retry_delay) + else: + logger.error(f"{operation_name} failed after {self.max_retries} attempts: {e}") + + return None + + def get_customer(self, customer_id: int) -> Optional[Dict[str, Any]]: + """ + Get customer information from Splynx. + + Args: + customer_id: Customer ID in Splynx + + Returns: + Customer data dictionary or None if not found + """ + def _get(): + customer_data = self.splynx.Customer(customer_id) + return customer_data if customer_data != 'unknown' else None + + return self._retry_operation(_get, f"Get customer {customer_id}") + + def get_customer_name(self, customer_id: int) -> str: + """ + Get customer name from Splynx. + + Args: + customer_id: Customer ID in Splynx + + Returns: + Customer name or "Unknown Customer" if not found + """ + customer_data = self.get_customer(customer_id) + if customer_data: + return customer_data.get('name', 'Unknown Customer') + return 'Unknown Customer' + + def mark_invoices_paid(self, customer_id: int) -> List[Dict[str, Any]]: + """ + Mark all unpaid/pending invoices as paid for a customer. + + Args: + customer_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + def _mark(): + result = self.splynx.get( + url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={customer_id}&main_attributes[status]=not_paid&main_attributes[status]=pending" + ) + + invoice_pay = {"status": "paid"} + updated_invoices = [] + + for invoice in result: + res = self.splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice['id']}", params=invoice_pay) + if res: + updated_invoices.append(res) + + logger.info(f"Marked {len(updated_invoices)} invoices as paid for customer {customer_id}") + return updated_invoices + + return self._retry_operation(_mark, f"Mark invoices paid for customer {customer_id}") or [] + + def mark_invoices_pending(self, customer_id: int) -> List[Dict[str, Any]]: + """ + Mark all unpaid invoices as pending for a customer. + + Args: + customer_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + def _mark(): + result = self.splynx.get( + url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={customer_id}&main_attributes[status]=not_paid" + ) + + invoice_pay = {"status": "pending"} + updated_invoices = [] + + for invoice in result: + res = self.splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice['id']}", params=invoice_pay) + if res: + updated_invoices.append(res) + + logger.info(f"Marked {len(updated_invoices)} invoices as pending for customer {customer_id}") + return updated_invoices + + return self._retry_operation(_mark, f"Mark invoices pending for customer {customer_id}") or [] + + def revert_invoices_to_unpaid(self, customer_id: int) -> List[Dict[str, Any]]: + """ + Revert pending invoices back to unpaid status for a customer. + + Args: + customer_id: Customer ID in Splynx + + Returns: + List of updated invoice dictionaries + """ + def _revert(): + result = self.splynx.get( + url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={customer_id}&main_attributes[status]=pending" + ) + + invoice_pay = {"status": "not_paid"} + updated_invoices = [] + + for invoice in result: + res = self.splynx.put(url=f"/api/2.0/admin/finance/invoices/{invoice['id']}", params=invoice_pay) + if res: + updated_invoices.append(res) + + logger.info(f"Reverted {len(updated_invoices)} invoices to unpaid for customer {customer_id}") + return updated_invoices + + return self._retry_operation(_revert, f"Revert invoices to unpaid for customer {customer_id}") or [] + + def add_payment(self, customer_id: int, amount: float, payment_intent_id: str, payment_id: int) -> Optional[int]: + """ + Add a payment record to Splynx. + + Args: + customer_id: Customer ID in Splynx + amount: Payment amount + payment_intent_id: Stripe payment intent ID + payment_id: Internal payment ID + + Returns: + Splynx payment ID if successful, None otherwise + """ + def _add(): + from datetime import datetime + + stripe_pay = { + "customer_id": customer_id, + "amount": amount, + "date": str(datetime.now().strftime('%Y-%m-%d')), + "field_1": payment_intent_id, + "field_2": f"Payment_ID (Batch): {payment_id}" + } + + res = self.splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay) + if res: + logger.info(f"Added payment to Splynx for customer {customer_id}: payment ID {res['id']}") + return res['id'] + else: + logger.error(f"Failed to add payment to Splynx for customer {customer_id}") + return None + + return self._retry_operation(_add, f"Add payment to Splynx for customer {customer_id}") + + def delete_payment(self, customer_id: int, payment_intent_id: str) -> Dict[str, Any]: + """ + Delete a Splynx payment record by customer ID and payment intent. + + Args: + customer_id: Customer ID in Splynx + payment_intent_id: Stripe payment intent ID + + Returns: + Dictionary with success status and details + """ + try: + params = { + 'main_attributes': { + 'customer_id': customer_id, + 'field_1': payment_intent_id + }, + } + query_string = self.splynx.build_splynx_query_params(params) + result = self.splynx.get(url=f"/api/2.0/admin/finance/payments?{query_string}") + + if not result: + logger.warning(f"No Splynx payment found for customer {customer_id}, payment intent {payment_intent_id}") + return {'success': False, 'error': 'No payment found to delete'} + + logger.info(f"Found {len(result)} Splynx payment(s) to delete for customer {customer_id}") + + delete_success = self.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: {customer_id}") + return { + 'success': True, + 'deleted_payment_id': result[0]['id'], + 'customer_id': customer_id, + 'payment_intent': payment_intent_id + } + else: + logger.error(f"Failed to delete Splynx Payment ID: {result[0]['id']} for customer: {customer_id}") + return {'success': False, 'error': 'Delete operation failed'} + + except Exception as e: + logger.error(f"Error deleting Splynx payment for customer {customer_id}: {e}") + return {'success': False, 'error': str(e)} + + def create_ticket(self, customer_id: int, subject: str, priority: str = "medium", + type_id: int = 1, group_id: int = 7, status_id: int = 1) -> Dict[str, Any]: + """ + Create a support ticket in Splynx. + + Args: + customer_id: Customer ID in Splynx + subject: Ticket subject + priority: Ticket priority (low, medium, high) + type_id: Ticket type ID + group_id: Ticket group ID + status_id: Ticket status ID + + Returns: + Dictionary with success status and ticket ID + """ + def _create(): + return self.splynx.create_ticket( + customer_id=customer_id, + subject=subject, + priority=priority, + type_id=type_id, + group_id=group_id, + status_id=status_id + ) + + result = self._retry_operation(_create, f"Create ticket for customer {customer_id}") + if result and result.get('success'): + logger.info(f"Created ticket #{result['ticket_id']} for customer {customer_id}") + return result or {'success': False, 'error': 'Failed to create ticket'} + + def add_ticket_message(self, ticket_id: int, message: str, is_admin: bool = False, + hide_for_customer: bool = False, message_type: str = "message") -> bool: + """ + Add a message to an existing ticket. + + Args: + ticket_id: Ticket ID + message: Message content (HTML) + is_admin: Whether message is from admin + hide_for_customer: Whether to hide message from customer + message_type: Message type ("message" or "note") + + Returns: + True if successful, False otherwise + """ + def _add(): + return self.splynx.add_ticket_message( + ticket_id=ticket_id, + message=message, + is_admin=is_admin, + hide_for_customer=hide_for_customer, + message_type=message_type + ) + + result = self._retry_operation(_add, f"Add message to ticket {ticket_id}") + if result: + logger.info(f"Added {message_type} to ticket #{ticket_id}") + return True + return False diff --git a/stripe_payment_processor.py b/stripe_payment_processor.py index e124ff7..d0c86fd 100644 --- a/stripe_payment_processor.py +++ b/stripe_payment_processor.py @@ -224,7 +224,7 @@ class StripePaymentProcessor: # Retrieve customer customer = stripe.Customer.retrieve(customer_id) - print(f"customer: {json.dumps(customer,indent=2)}") + print(f"SPP- customer: {json.dumps(customer,indent=2)}") if not customer: response['error'] = f'Customer {customer_id} not found' @@ -252,7 +252,7 @@ class StripePaymentProcessor: payment_method = stripe.PaymentMethod.retrieve(default_payment_method) payment_method_type = payment_method.type - print(f"payment_method: {json.dumps(payment_method,indent=2)}") + #print(f"SPP - payment_method: {json.dumps(payment_method,indent=2)}") response.update({ @@ -296,8 +296,9 @@ class StripePaymentProcessor: self._log('info', "Added BECS mandate data for offline acceptance") # Create and confirm Payment Intent + #print(f"\nSPP - creating PI\n") payment_intent = stripe.PaymentIntent.create(**payment_intent_params) - + #print(f"\nSPP - payment_intent: {json.dumps(payment_intent,indent=2)}\n") # Add payment intent details response.update({ 'payment_intent_id': payment_intent.id, @@ -306,7 +307,7 @@ class StripePaymentProcessor: if payment_intent.status == 'succeeded': response['success'] = True - self._log('info', f"✅ Payment successful: {payment_intent.id}") + self._log('info', f"SUCCESS: Payment successful: {payment_intent.id}") # Get actual fee details for successful payments try: @@ -342,7 +343,7 @@ class StripePaymentProcessor: self._log('warning', f"Failed to get actual fees: {str(e)} - marked for later update") elif payment_intent.status == 'processing' and wait_for_completion: # Payment is processing - wait for completion - self._log('info', f"💭 Payment is processing, waiting for completion...") + self._log('info', f"INFO: Payment is processing, waiting for completion...") # Use the polling method to wait for completion polling_result = self.wait_for_payment_completion(payment_intent.id, customer_id=customer_id, max_wait_seconds=30) @@ -354,7 +355,7 @@ class StripePaymentProcessor: if polling_result['status'] == 'succeeded': response['success'] = True - self._log('info', f"✅ Payment completed successfully after polling") + self._log('info', f"SUCCESS: Payment completed successfully after polling") else: response['success'] = False response['error'] = f'Payment completed with status: {polling_result["status"]}' @@ -390,8 +391,8 @@ class StripePaymentProcessor: else: response['error'] = f'Payment not completed. Status: {payment_intent.status}' response['error_type'] = 'payment_incomplete' - - self._log('warning', f"⚠️ Payment incomplete: {payment_intent.id} - {payment_intent.status}") + + self._log('warning', f"WARNING: Payment incomplete: {payment_intent.id} - {payment_intent.status}") # Calculate processing time processing_time = (datetime.now() - transaction_start).total_seconds() @@ -402,15 +403,27 @@ class StripePaymentProcessor: except stripe.CardError as e: # Card-specific error (declined, etc.) processing_time = (datetime.now() - transaction_start).total_seconds() - #print(f"stripe.CardError: {str(e)}\n{e.user_message}\n{e.request_id}\n{e.code}") - #print(json.dumps(e, indent=2)) + #print(f"SPP - stripe.CardError: {str(e)}\n{e.user_message}\n{e.request_id}\n{e.code}\n\n") + + pi = stripe.PaymentIntent.list(customer=customer_id, limit=1) + + if pi: + payment_intent_error = pi['data'][0]['id'] + payment_intent_details = pi + print(f"\nSPP - payment_intent_error: {payment_intent_error}") + else: + payment_intent_error = "not found" + payment_intent_details = None + response.update({ + 'payment_intent_id': payment_intent_error, + 'payment_intent_details': payment_intent_details, 'error': f'Card declined: {e.user_message}', 'error_type': 'card_declined', 'decline_code': e.code, 'processing_time_seconds': round(processing_time, 2) }) - self._log('error', f"❌ Card declined for {customer_id}: {e.user_message}") + self._log('error', f"ERROR: Card declined for {customer_id}: {e.user_message}") return response except stripe.InvalidRequestError as e: @@ -421,7 +434,7 @@ class StripePaymentProcessor: 'error_type': 'invalid_request', 'processing_time_seconds': round(processing_time, 2) }) - self._log('error', f"❌ Invalid request for {customer_id}: {str(e)}") + self._log('error', f"ERROR: Invalid request for {customer_id}: {str(e)}") return response except stripe.AuthenticationError as e: @@ -432,7 +445,7 @@ class StripePaymentProcessor: 'error_type': 'authentication_error', 'processing_time_seconds': round(processing_time, 2) }) - self._log('error', f"❌ Authentication failed: {str(e)}") + self._log('error', f"ERROR: Authentication failed: {str(e)}") return response except stripe.APIConnectionError as e: @@ -443,7 +456,7 @@ class StripePaymentProcessor: 'error_type': 'network_error', 'processing_time_seconds': round(processing_time, 2) }) - self._log('error', f"❌ Network error: {str(e)}") + self._log('error', f"ERROR: Network error: {str(e)}") return response except stripe.StripeError as e: @@ -454,7 +467,7 @@ class StripePaymentProcessor: 'error_type': 'stripe_error', 'processing_time_seconds': round(processing_time, 2) }) - self._log('error', f"❌ Stripe error: {str(e)}") + self._log('error', f"ERROR: Stripe error: {str(e)}") return response except Exception as e: @@ -465,7 +478,7 @@ class StripePaymentProcessor: 'error_type': 'unexpected_error', 'processing_time_seconds': round(processing_time, 2) }) - self._log('error', f"❌ Unexpected error for {customer_id}: {str(e)}") + self._log('error', f"ERROR: Unexpected error for {customer_id}: {str(e)}") return response def get_customer_info(self, customer_id: str) -> Dict[str, Any]: @@ -496,15 +509,20 @@ class StripePaymentProcessor: customer=customer_id, limit=10 ) + + #print(f"\nSPP - payment_methods: {json.dumps(payment_methods,indent=2)}\n") for pm in payment_methods.data: + #print(f"\nSPP - pm: {json.dumps(pm,indent=2)}\n") + pm_info = { 'id': pm.id, 'type': pm.type, 'created': pm.created } - if pm.card: + if pm_info['type'] == "card": + print("CARD TIME!") pm_info['card'] = { 'brand': pm.card.brand, 'last4': pm.card.last4, @@ -512,11 +530,17 @@ class StripePaymentProcessor: 'exp_month': pm.card.exp_month, 'exp_year': pm.card.exp_year } - elif pm.au_becs_debit: + elif pm_info['type'] == "au_becs_debit": + print("AU BECS DEBIT TIME!") pm_info['au_becs_debit'] = { 'bsb_number': pm.au_becs_debit.bsb_number, 'last4': pm.au_becs_debit.last4 } + + #print(pm.get('au_becs_debit', 'BECS_Nope')) + #print(pm.get('card', 'Card_Nope')) + + #print(f"pm_info: {json.dumps(pm_info,indent=2)}") customer_info['payment_methods'].append(pm_info) @@ -895,7 +919,7 @@ class StripePaymentProcessor: 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}") + self._log('info', f"SUCCESS: Refund status check successful: {refund_id} - {refund.status}") return response @@ -1045,9 +1069,9 @@ class StripePaymentProcessor: } if current_status == 'succeeded': - self._log('info', f"✅ Payment completed successfully after {elapsed_time:.1f}s ({attempts} attempts)") + self._log('info', f"SUCCESS: Payment completed successfully after {elapsed_time:.1f}s ({attempts} attempts)") else: - self._log('warning', f"❌ Payment completed with status '{current_status}' after {elapsed_time:.1f}s") + self._log('warning', f"ERROR: Payment completed with status '{current_status}' after {elapsed_time:.1f}s") current_result['pi_status'] = current_status return current_result @@ -1086,9 +1110,9 @@ class StripePaymentProcessor: # If payment is still processing after timeout and we have customer_id, mark for later review if final_status == 'processing' and customer_id: final_result['needs_fee_update'] = [customer_id, payment_intent_id] - self._log('warning', f"⏰ Payment still processing after timeout - marked for later review") - - self._log('warning', f"⏰ Polling timed out after {max_wait_seconds}s. Final status: {final_status}") + self._log('warning', f"TIMEOUT: Payment still processing after timeout - marked for later review") + + self._log('warning', f"TIMEOUT: Polling timed out after {max_wait_seconds}s. Final status: {final_status}") else: # Error on final check final_result['polling_info'] = { @@ -1105,7 +1129,7 @@ class StripePaymentProcessor: # If we have customer_id and this might be a processing payment, mark for later if customer_id: final_result['needs_fee_update'] = [customer_id, payment_intent_id] - self._log('warning', f"⏰ Final check failed - marked for later review") + self._log('warning', f"TIMEOUT: Final check failed - marked for later review") current_result['pi_status'] = final_status return final_result @@ -1162,7 +1186,7 @@ class StripePaymentProcessor: 'timestamp': datetime.now().isoformat() } - self._log('info', f"✅ Setup intent created: {setup_intent.id}") + self._log('info', f"SUCCESS: Setup intent created: {setup_intent.id}") return response except stripe.StripeError as e: @@ -1240,7 +1264,7 @@ class StripePaymentProcessor: } response['payment_method'] = pm_details - self._log('info', f"✅ Setup intent succeeded with payment method: {payment_method.id}") + self._log('info', f"SUCCESS: Setup intent succeeded with payment method: {payment_method.id}") elif setup_intent.status in ['requires_payment_method', 'requires_confirmation']: response['next_action'] = 'Setup still requires user action' @@ -1311,7 +1335,7 @@ class StripePaymentProcessor: payment_method_id, customer=customer_id ) - self._log('info', f"✅ Payment method attached successfully") + self._log('info', f"SUCCESS: Payment method attached successfully") except stripe.InvalidRequestError as e: if 'already attached' in str(e).lower(): # Already attached, just retrieve it @@ -1498,21 +1522,21 @@ class StripePaymentProcessor: if has_actual_fees and is_complete: update_result['needs_further_updates'] = False update_result['note'] = 'Payment complete with actual fees' - self._log('info', f"✅ Payment {payment_intent_id} now complete with actual fees") + self._log('info', f"SUCCESS: Payment {payment_intent_id} now complete with actual fees") elif is_complete and not has_actual_fees: update_result['needs_further_updates'] = False update_result['note'] = 'Payment complete but actual fees not available' - self._log('info', f"✅ Payment {payment_intent_id} complete but no actual fees") + self._log('info', f"SUCCESS: Payment {payment_intent_id} complete but no actual fees") elif has_actual_fees and not is_complete: update_result['needs_further_updates'] = True update_result['needs_fee_update'] = [customer_id, payment_intent_id] # Keep tracking update_result['note'] = 'Has actual fees but payment still processing' - self._log('info', f"⏳ Payment {payment_intent_id} has fees but still processing") + self._log('info', f"PENDING: Payment {payment_intent_id} has fees but still processing") else: update_result['needs_further_updates'] = True update_result['needs_fee_update'] = [customer_id, payment_intent_id] # Keep tracking update_result['note'] = 'Payment still processing without actual fees' - self._log('info', f"⏳ Payment {payment_intent_id} still needs updates") + self._log('info', f"PENDING: Payment {payment_intent_id} still needs updates") return update_result diff --git a/test.py b/test.py index 342a7cc..587dbbd 100644 --- a/test.py +++ b/test.py @@ -1,6 +1,7 @@ import json import stripe from typing import List, Dict, Union, Any +from datetime import datetime from app import create_app, db from models import Payments, PaymentBatch, SinglePayments, PaymentPlans from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET @@ -11,13 +12,44 @@ from config import Config splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) -api_key = Config.STRIPE_LIVE_API_KEY +#api_key = Config.STRIPE_LIVE_API_KEY +api_key = Config.STRIPE_TEST_API_KEY stripe.api_key = api_key +def attach_becs_payment_to_customer(): + payment_method = stripe.PaymentMethod.create( + type='au_becs_debit', + au_becs_debit={ + 'bsb_number': '000000', # 6-digit BSB + 'account_number': '333333335' # Account number + }, + billing_details={ + 'name': 'AU BECS Decline', + 'email': 'customer@example.com' + } + ) + + # Attach to customer + stripe.PaymentMethod.attach( + payment_method.id, + customer='cus_SoMVPWxdYstYbr' + ) + + print(f"Payment method {payment_method.id} attached to customer") + if __name__ == "__main__": - splynx_id = 1218789 - result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid&main_attributes[status]=pending") + #splynx_id = 1218789 + #result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid&main_attributes[status]=pending") +# + #print(json.dumps(result,indent=2)) + + #pi = stripe.PaymentIntent.list(customer="cus_SoQiDcSrNRxbPF", limit=1) +# + #print(json.dumps(pi,indent=2)) - print(json.dumps(result,indent=2)) \ No newline at end of file + print(datetime.now()) + splynx_date = datetime.now().strftime("%Y-%m-%d") + print(splynx_date) + print(type(splynx_date)) \ No newline at end of file