diff --git a/app.py b/app.py new file mode 100644 index 0000000..e52cde4 --- /dev/null +++ b/app.py @@ -0,0 +1,64 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +import pymysql +from config import Config + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() + +def create_app(): + app = Flask(__name__) + app.config.from_object(Config) + + # Initialize extensions + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Please log in to access this page.' + + # MySQL connection (read-only) - initialized when needed + def get_mysql_connection(): + if not hasattr(app, 'mysql_connection') or app.mysql_connection is None: + try: + app.mysql_connection = pymysql.connect( + host=app.config['MYSQL_CONFIG']['host'], + database=app.config['MYSQL_CONFIG']['database'], + user=app.config['MYSQL_CONFIG']['user'], + password=app.config['MYSQL_CONFIG']['password'], + port=app.config['MYSQL_CONFIG']['port'], + autocommit=False # Ensure read-only behavior + ) + except Exception as e: + print(f"MySQL connection failed: {e}") + app.mysql_connection = None + return app.mysql_connection + + # Make connection function available to app context + app.get_mysql_connection = get_mysql_connection + + # Register blueprints + from blueprints.auth import auth_bp + from blueprints.main import main_bp + + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(main_bp) + + # User loader for Flask-Login + from models import Users + + @login_manager.user_loader + def load_user(user_id): + return Users.query.get(int(user_id)) + + # Note: Database tables will be managed by Flask-Migrate + # Use 'flask db init', 'flask db migrate', 'flask db upgrade' commands + + return app + +if __name__ == '__main__': + app = create_app() + app.run(debug=True) \ No newline at end of file diff --git a/bin/Activate.ps1 b/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/bin/activate b/bin/activate new file mode 100644 index 0000000..3479f15 --- /dev/null +++ b/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath /home/alan/python_projects/plutus/plutus) +else + # use the path as-is + export VIRTUAL_ENV=/home/alan/python_projects/plutus/plutus +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/"bin":$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1='(plutus) '"${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT='(plutus) ' + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/bin/activate.csh b/bin/activate.csh new file mode 100644 index 0000000..7ddaacb --- /dev/null +++ b/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV /home/alan/python_projects/plutus/plutus + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/"bin":$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = '(plutus) '"$prompt" + setenv VIRTUAL_ENV_PROMPT '(plutus) ' +endif + +alias pydoc python -m pydoc + +rehash diff --git a/bin/activate.fish b/bin/activate.fish new file mode 100644 index 0000000..2807203 --- /dev/null +++ b/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV /home/alan/python_projects/plutus/plutus + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/"bin $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) '(plutus) ' (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT '(plutus) ' +end diff --git a/bin/alembic b/bin/alembic new file mode 100755 index 0000000..b66fa29 --- /dev/null +++ b/bin/alembic @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from alembic.config import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/bin/flask b/bin/flask new file mode 100755 index 0000000..261e444 --- /dev/null +++ b/bin/flask @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from flask.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/bin/mako-render b/bin/mako-render new file mode 100755 index 0000000..22fb403 --- /dev/null +++ b/bin/mako-render @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from mako.cmd import cmdline +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cmdline()) diff --git a/bin/normalizer b/bin/normalizer new file mode 100755 index 0000000..51e1b7d --- /dev/null +++ b/bin/normalizer @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from charset_normalizer import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli.cli_detect()) diff --git a/bin/pip b/bin/pip new file mode 100755 index 0000000..f9834e8 --- /dev/null +++ b/bin/pip @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/bin/pip3 b/bin/pip3 new file mode 100755 index 0000000..f9834e8 --- /dev/null +++ b/bin/pip3 @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/bin/pip3.12 b/bin/pip3.12 new file mode 100755 index 0000000..f9834e8 --- /dev/null +++ b/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/bin/python b/bin/python new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/bin/python @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/bin/python3 b/bin/python3 new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/bin/python3 @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/bin/python3.12 b/bin/python3.12 new file mode 120000 index 0000000..dc92e12 --- /dev/null +++ b/bin/python3.12 @@ -0,0 +1 @@ +/usr/bin/python3.12 \ No newline at end of file diff --git a/bin/wheel b/bin/wheel new file mode 100755 index 0000000..80fbf2d --- /dev/null +++ b/bin/wheel @@ -0,0 +1,8 @@ +#!/home/alan/python_projects/plutus/plutus/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from wheel.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..a5ab059 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,74 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_user, logout_user, login_required, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from models import Users +from app import db + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + user = Users.query.filter_by(Username=username).first() + + if user and check_password_hash(user.Password, password) and user.Enabled: + login_user(user) + next_page = request.args.get('next') + return redirect(next_page) if next_page else redirect(url_for('main.index')) + else: + flash('Invalid username or password, or account is disabled.', 'error') + + return render_template('auth/login.html') + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.', 'success') + return redirect(url_for('auth.login')) + +@auth_bp.route('/add_user', methods=['GET', 'POST']) +@login_required +def add_user(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + full_name = request.form['full_name'] + email = request.form['email'] + permissions = request.form.get('permissions', '') + + # Check if username already exists + existing_user = Users.query.filter_by(Username=username).first() + if existing_user: + flash('Username already exists.', 'error') + return render_template('auth/add_user.html') + + # Create new user + new_user = Users( + Username=username, + Password=generate_password_hash(password), + FullName=full_name, + Email=email, + Permissions=permissions, + Enabled=True + ) + + try: + db.session.add(new_user) + db.session.commit() + flash('User created successfully.', 'success') + return redirect(url_for('auth.list_users')) + except Exception as e: + db.session.rollback() + flash('Error creating user.', 'error') + + return render_template('auth/add_user.html') + +@auth_bp.route('/list_users') +@login_required +def list_users(): + users = Users.query.all() + return render_template('auth/list_users.html', users=users) \ No newline at end of file diff --git a/blueprints/main.py b/blueprints/main.py new file mode 100644 index 0000000..322a2f5 --- /dev/null +++ b/blueprints/main.py @@ -0,0 +1,871 @@ +from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for +from flask_login import login_required, current_user +from sqlalchemy import func, case +import json +import pymysql +from app import db +from models import PaymentBatch, Payments, SinglePayments, PaymentPlans +from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET +from stripe_payment_processor import StripePaymentProcessor +from config import Config +from services import log_activity + +splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) + +def processPaymentResult(pay_id, result, key): + """Process payment result and update database record.""" + from datetime import datetime + + 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 Config.PROCESS_LIVE and key == "singlepay": + # Only update Splynx for successful single payments in live mode + 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 error: {e}\n{json.dumps(result)}") + payment.PI_FollowUp = True + +def find_pay_splynx_invoices(splynx_id): + """Mark Splynx invoices as paid for the given customer ID.""" + result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid") + + invoice_pay = { + "status": "paid" + } + + for pay in result: + res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay) + return res + +def add_payment_splynx(splynx_id, pi_id, pay_id, amount): + """Add a payment record to Splynx.""" + from datetime import datetime + + stripe_pay = { + "customer_id": splynx_id, + "amount": amount, + "date": str(datetime.now().strftime('%Y-%m-%d')), + "field_1": pi_id, + "field_2": f"Single Payment_ID: {pay_id}" + } + + res = splynx.post(url="/api/2.0/admin/finance/payments", params=stripe_pay) + if res: + return res['id'] + else: + return False + +def get_stripe_customer_id(splynx_id): + """Get Stripe customer ID from MySQL for a given Splynx customer ID.""" + 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 + ) + + 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.customer_id = %s + ORDER BY cb.payment_method ASC + LIMIT 1 + """ + + with connection.cursor() as cursor: + cursor.execute(query, (splynx_id,)) + result = cursor.fetchone() + + if result and result['stripe_customer_id']: + return result['stripe_customer_id'] + else: + return None + + except pymysql.Error as e: + print(f"MySQL Error in get_stripe_customer_id: {e}") + return None + except Exception as e: + print(f"Unexpected Error in get_stripe_customer_id: {e}") + return None + finally: + if connection: + connection.close() + +def get_stripe_payment_methods(stripe_customer_id): + """Get payment methods for a Stripe customer.""" + try: + # Initialize Stripe processor + if Config.PROCESS_LIVE: + api_key = "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" + else: + api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" + + processor = StripePaymentProcessor(api_key=api_key, enable_logging=False) + + # Get payment methods from Stripe + stripe_customer_id = "cus_SoMyDihTxRsa7U" + payment_methods = processor.get_payment_methods(stripe_customer_id) + return payment_methods + + except Exception as e: + print(f"Error fetching payment methods: {e}") + return [] + +main_bp = Blueprint('main', __name__) + +@main_bp.app_template_filter('format_json') +def format_json_filter(json_string): + """Format JSON string with proper indentation.""" + if not json_string: + return '' + try: + # Parse the JSON string and format it with indentation + parsed = json.loads(json_string) + return json.dumps(parsed, indent=2, ensure_ascii=False) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, return as-is + return json_string + +@main_bp.app_template_filter('currency') +def currency_filter(value): + """Format number as currency with digit grouping.""" + if value is None: + return '$0.00' + try: + # Convert to float if it's not already + num_value = float(value) + # Format with comma separators and 2 decimal places + return f"${num_value:,.2f}" + except (ValueError, TypeError): + return '$0.00' + +@main_bp.route('/') +@login_required +def index(): + return render_template('main/index.html') + +@main_bp.route('/batches') +@login_required +def batch_list(): + """Display list of all payment batches with summary information.""" + # Query all batches with summary statistics + batches = db.session.query( + PaymentBatch.id, + PaymentBatch.Created, + func.count(Payments.id).label('payment_count'), + func.sum(Payments.Payment_Amount).label('total_amount'), + func.sum(Payments.Fee_Stripe).label('total_fees'), + func.sum(case((Payments.Success == True, 1), else_=0)).label('successful_count'), + func.sum(case((Payments.Success == False, 1), else_=0)).label('failed_count'), + func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count') + ).outerjoin(Payments, PaymentBatch.id == Payments.PaymentBatch_ID)\ + .group_by(PaymentBatch.id, PaymentBatch.Created)\ + .order_by(PaymentBatch.Created.desc()).all() + + return render_template('main/batch_list.html', batches=batches) + +@main_bp.route('/batch/') +@login_required +def batch_detail(batch_id): + """Display detailed view of a specific payment batch.""" + # Get batch information + batch = PaymentBatch.query.get_or_404(batch_id) + + # Get summary statistics for this batch + summary = db.session.query( + func.count(Payments.id).label('payment_count'), + func.sum(Payments.Payment_Amount).label('total_amount'), + func.sum(Payments.Fee_Stripe).label('total_fees'), + func.sum(case((Payments.Success == True, 1), else_=0)).label('successful_count'), + func.sum(case((Payments.Success == False, 1), else_=0)).label('failed_count'), + func.sum(case((Payments.Error.isnot(None), 1), else_=0)).label('error_count') + ).filter(Payments.PaymentBatch_ID == batch_id).first() + + # Get all payments for this batch ordered by Splynx_ID + payments = Payments.query.filter_by(PaymentBatch_ID=batch_id)\ + .order_by(Payments.Splynx_ID.asc()).all() + + return render_template('main/batch_detail.html', + batch=batch, + summary=summary, + payments=payments) + + +@main_bp.route('/single-payment') +@login_required +def single_payment(): + """Display single payment form page.""" + return render_template('main/single_payment.html') + +@main_bp.route('/single-payments') +@login_required +def single_payments_list(): + """Display list of all single payments with summary information.""" + # Query all single payments with user information + from models import Users + + payments = db.session.query( + SinglePayments.id, + SinglePayments.Splynx_ID, + SinglePayments.Stripe_Customer_ID, + SinglePayments.Payment_Intent, + SinglePayments.Payment_Method, + SinglePayments.Payment_Amount, + SinglePayments.Fee_Stripe, + SinglePayments.Fee_Total, + SinglePayments.Success, + SinglePayments.Error, + SinglePayments.PI_JSON, + SinglePayments.Created, + Users.FullName.label('processed_by') + ).outerjoin(Users, SinglePayments.Who == Users.id)\ + .order_by(SinglePayments.Created.desc()).all() + + # Calculate summary statistics + total_payments = len(payments) + successful_payments = sum(1 for p in payments if p.Success == True) + failed_payments = sum(1 for p in payments if p.Success == False) + pending_payments = sum(1 for p in payments if p.Success == None) + + total_amount = sum(p.Payment_Amount or 0 for p in payments if p.Success == True) + total_fees = sum(p.Fee_Stripe or 0 for p in payments if p.Success == True) + + summary = { + 'total_payments': total_payments, + 'successful_payments': successful_payments, + 'failed_payments': failed_payments, + 'pending_payments': pending_payments, + 'total_amount': total_amount, + 'total_fees': total_fees, + 'success_rate': (successful_payments / total_payments * 100) if total_payments > 0 else 0 + } + + return render_template('main/single_payments_list.html', payments=payments, summary=summary) + +@main_bp.route('/single-payment/detail/') +@login_required +def single_payment_detail(payment_id): + """Display detailed view of a specific single payment.""" + # Get payment information + from models import Users + + payment = db.session.query( + SinglePayments.id, + SinglePayments.Splynx_ID, + SinglePayments.Stripe_Customer_ID, + SinglePayments.Payment_Intent, + SinglePayments.PI_FollowUp, + SinglePayments.PI_Last_Check, + SinglePayments.Payment_Method, + SinglePayments.Fee_Tax, + SinglePayments.Fee_Stripe, + SinglePayments.Fee_Total, + SinglePayments.Payment_Amount, + SinglePayments.PI_JSON, + SinglePayments.PI_FollowUp_JSON, + SinglePayments.Error, + SinglePayments.Success, + SinglePayments.Created, + Users.FullName.label('processed_by') + ).outerjoin(Users, SinglePayments.Who == Users.id)\ + .filter(SinglePayments.id == payment_id).first() + + if not payment: + flash('Payment not found.', 'error') + return redirect(url_for('main.single_payments_list')) + + return render_template('main/single_payment_detail.html', payment=payment) + +@main_bp.route('/payment/detail/') +@login_required +def payment_detail(payment_id): + """Display detailed view of a specific single payment.""" + # Get payment information + + payment = db.session.query( + Payments.id, + Payments.Splynx_ID, + Payments.Stripe_Customer_ID, + Payments.Payment_Intent, + Payments.PI_FollowUp, + Payments.PI_Last_Check, + Payments.Payment_Method, + Payments.Fee_Tax, + Payments.Fee_Stripe, + Payments.Fee_Total, + Payments.Payment_Amount, + Payments.PI_JSON, + Payments.PI_FollowUp_JSON, + Payments.Error, + Payments.Success, + Payments.Created)\ + .filter(Payments.id == payment_id).first() + + if not payment: + flash('Payment not found.', 'error') + return redirect(url_for('main.single_payments_list')) + + return render_template('main/single_payment_detail.html', payment=payment) + +@main_bp.route('/single-payment/check-intent/', methods=['POST']) +@login_required +def check_payment_intent(payment_id): + """Check the status of a payment intent and update the record.""" + from datetime import datetime + + try: + # Get the payment record + payment = SinglePayments.query.get_or_404(payment_id) + + if not payment.Payment_Intent: + return jsonify({'success': False, 'error': 'No payment intent found'}), 400 + + # Initialize Stripe processor + if Config.PROCESS_LIVE: + api_key = "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" + else: + api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" + + processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) + + # Check payment intent status + intent_result = processor.check_payment_intent(payment.Payment_Intent) + + print(json.dumps(intent_result, indent=2)) + + if intent_result['status'] == "succeeded": + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_FollowUp = False + payment.PI_Last_Check = datetime.now() + processPaymentResult(pay_id=payment.id, result=intent_result, key="singlepay") + else: + payment.PI_FollowUp_JSON = json.dumps(intent_result) + payment.PI_Last_Check = datetime.now() + + db.session.commit() + + return jsonify({ + 'success': True, + 'status': intent_result['status'], + 'payment_succeeded': intent_result['status'] == "succeeded", + 'message': f'Payment intent status: {intent_result["status"]}' + }) + + except Exception as e: + db.session.rollback() + print(f"Check payment intent error: {e}") + return jsonify({'success': False, 'error': 'Failed to check payment intent'}), 500 + +@main_bp.route('/single-payment/process', methods=['POST']) +@login_required +def process_single_payment(): + """Process a single payment using Stripe.""" + try: + # Get form data + splynx_id = request.form.get('splynx_id') + amount = request.form.get('amount') + + # Validate inputs + if not splynx_id or not amount: + return jsonify({'success': False, 'error': 'Missing required fields'}), 400 + + try: + splynx_id = int(splynx_id) + amount = float(amount) + except (ValueError, TypeError): + return jsonify({'success': False, 'error': 'Invalid input format'}), 400 + + if amount <= 0: + return jsonify({'success': False, 'error': 'Amount must be greater than 0'}), 400 + + # Get customer details from Splynx + customer_data = splynx.Customer(splynx_id) + if not customer_data: + return jsonify({'success': False, 'error': 'Customer not found in Splynx'}), 404 + + # Get Stripe customer ID from MySQL + stripe_customer_id = get_stripe_customer_id(splynx_id) + if not stripe_customer_id: + return jsonify({'success': False, 'error': 'Customer does not have a valid Stripe payment method'}), 400 + + # Create payment record in database + payment_record = SinglePayments( + Splynx_ID=splynx_id, + Stripe_Customer_ID=stripe_customer_id, + Payment_Amount=amount, + Who=current_user.id + ) + db.session.add(payment_record) + db.session.commit() # Commit to get the payment ID + + # Initialize Stripe processor + if Config.PROCESS_LIVE: + print("LIVE Payment") + api_key = "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" + else: + print("SANDBOX Payment") + api_key = "sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx" + # Use test customer for sandbox + import random + test_customers = ['cus_SoNAgAbkbFo8ZY', 'cus_SoMyDihTxRsa7U', 'cus_SoQedaG3q2ecKG', 'cus_SoMVPWxdYstYbr'] + stripe_customer_id = random.choice(test_customers) + + processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) + print(f"stripe_customer_id: {stripe_customer_id}") + # Process payment + result = processor.process_payment( + customer_id=stripe_customer_id, + amount=amount, + currency="aud", + description=f"Single Payment - Splynx ID: {splynx_id} - Payment ID: {payment_record.id}" + ) + + # Update payment record with results + payment_record.Success = result.get('success', False) + payment_record.Payment_Intent = result.get('payment_intent_id') + payment_record.PI_JSON = json.dumps(result) + + if result.get('error') and not result.get('needs_fee_update'): + payment_record.Error = f"Error Type: {result.get('error_type', 'Unknown')}\nError: {result['error']}" + + if result.get('needs_fee_update'): + payment_record.PI_FollowUp = True + + if result.get('payment_method_type') == "card": + payment_record.Payment_Method = result.get('estimated_fee_details', {}).get('card_display_brand', 'card') + elif result.get('payment_method_type') == "au_becs_debit": + payment_record.Payment_Method = result['payment_method_type'] + + if result.get('fee_details'): + payment_record.Fee_Total = result['fee_details']['total_fee'] + for fee_type in result['fee_details']['fee_breakdown']: + if fee_type['type'] == "tax": + payment_record.Fee_Tax = fee_type['amount'] + elif fee_type['type'] == "stripe_fee": + payment_record.Fee_Stripe = fee_type['amount'] + + # Commit the updated payment record + db.session.commit() + + # Check if payment was actually successful + if result.get('success'): + # Payment succeeded - update Splynx if in live mode + if Config.PROCESS_LIVE: + try: + # Mark invoices as paid in Splynx + find_pay_splynx_invoices(splynx_id) + + # Add payment record to Splynx + splynx_payment_id = add_payment_splynx( + splynx_id=splynx_id, + pi_id=result.get('payment_intent_id'), + pay_id=payment_record.id, + amount=amount + ) + + if splynx_payment_id: + print(f"✅ Splynx payment record created: {splynx_payment_id}") + else: + print("⚠️ Failed to create Splynx payment record") + + except Exception as splynx_error: + print(f"❌ Error updating Splynx: {splynx_error}") + # Continue processing even if Splynx update fails + + # Log successful payment + log_activity( + current_user.id, + "PAYMENT_SUCCESS", + "SinglePayment", + payment_record.id, + details=f"Single payment successful: ${amount:,.2f} for customer {splynx_id} ({customer_data.get('name', 'Unknown')})" + ) + + # Payment succeeded + return jsonify({ + 'success': True, + 'payment_success': True, + 'payment_id': payment_record.id, + 'payment_intent': result.get('payment_intent_id'), + 'amount': amount, + 'customer_name': customer_data.get('name'), + 'message': f'Payment processed successfully for {customer_data.get("name")}' + }) + else: + # Payment failed - log the failure + log_activity( + current_user.id, + "PAYMENT_FAILED", + "SinglePayment", + payment_record.id, + details=f"Single payment failed: ${amount:,.2f} for customer {splynx_id} ({customer_data.get('name', 'Unknown')}) - {result.get('error', 'Unknown error')}" + ) + + # Payment failed - return the specific error + if result.get('needs_fee_update'): + fee_update = True + else: + fee_update = False + return jsonify({ + 'success': False, + 'payment_success': False, + 'fee_update': fee_update, + 'payment_id': payment_record.id, + 'error': result.get('error', 'Payment failed'), + 'error_type': result.get('error_type', 'unknown_error'), + 'stripe_error': result.get('error', 'Unknown payment error'), + 'customer_name': customer_data.get('name') + }), 422 # 422 Unprocessable Entity for business logic failures + + except Exception as e: + db.session.rollback() + print(f"Single payment processing error: {e}") + return jsonify({'success': False, 'error': 'Payment processing failed. Please try again.'}), 500 + +@main_bp.route('/payment-plans') +@login_required +def payment_plans_list(): + """Display list of all payment plans with summary information.""" + from models import Users + + # Query all payment plans with user information + plans = db.session.query( + PaymentPlans.id, + PaymentPlans.Splynx_ID, + PaymentPlans.Amount, + PaymentPlans.Frequency, + PaymentPlans.Start_Date, + PaymentPlans.Stripe_Payment_Method, + PaymentPlans.Enabled, + PaymentPlans.Created, + Users.FullName.label('created_by') + ).outerjoin(Users, PaymentPlans.Who == Users.id)\ + .order_by(PaymentPlans.Created.desc()).all() + + # Calculate summary statistics + total_plans = len(plans) + active_plans = sum(1 for p in plans if p.Enabled == True) + inactive_plans = sum(1 for p in plans if p.Enabled == False) + + total_recurring_amount = sum(p.Amount or 0 for p in plans if p.Enabled == True) + + summary = { + 'total_plans': total_plans, + 'active_plans': active_plans, + 'inactive_plans': inactive_plans, + 'total_recurring_amount': total_recurring_amount + } + + return render_template('main/payment_plans_list.html', plans=plans, summary=summary) + +@main_bp.route('/payment-plans/create') +@login_required +def payment_plans_create(): + """Display payment plan creation form.""" + return render_template('main/payment_plans_form.html', edit_mode=False) + +@main_bp.route('/payment-plans/create', methods=['POST']) +@login_required +def payment_plans_create_post(): + """Handle payment plan creation.""" + try: + # Get form data + splynx_id = request.form.get('splynx_id') + amount = request.form.get('amount') + frequency = request.form.get('frequency') + start_date = request.form.get('start_date') + stripe_payment_method = request.form.get('stripe_payment_method') + + # Validate inputs + if not all([splynx_id, amount, frequency, start_date, stripe_payment_method]): + flash('All fields are required.', 'error') + return redirect(url_for('main.payment_plans_create')) + + try: + splynx_id = int(splynx_id) + amount = float(amount) + from datetime import datetime + start_date = datetime.strptime(start_date, '%Y-%m-%d') + except (ValueError, TypeError): + flash('Invalid input format.', 'error') + return redirect(url_for('main.payment_plans_create')) + + if amount <= 0: + flash('Amount must be greater than 0.', 'error') + return redirect(url_for('main.payment_plans_create')) + + # Validate customer exists in Splynx + customer_data = splynx.Customer(splynx_id) + if not customer_data: + flash('Customer not found in Splynx.', 'error') + return redirect(url_for('main.payment_plans_create')) + + # Create payment plan record + payment_plan = PaymentPlans( + Splynx_ID=splynx_id, + Amount=amount, + Frequency=frequency, + Start_Date=start_date, + Stripe_Payment_Method=stripe_payment_method, + Who=current_user.id + ) + + db.session.add(payment_plan) + db.session.commit() + + # Log payment plan creation + log_activity( + current_user.id, + "PAYPLAN_CREATED", + "PaymentPlan", + payment_plan.id, + details=f"Payment plan created: ${amount:,.2f} {frequency} for customer {splynx_id} ({customer_data.get('name', 'Unknown')})" + ) + + flash(f'Payment plan created successfully for {customer_data.get("name", "customer")}.', 'success') + return redirect(url_for('main.payment_plans_detail', plan_id=payment_plan.id)) + + except Exception as e: + db.session.rollback() + print(f"Payment plan creation error: {e}") + flash('Failed to create payment plan. Please try again.', 'error') + return redirect(url_for('main.payment_plans_create')) + +@main_bp.route('/payment-plans/edit/') +@login_required +def payment_plans_edit(plan_id): + """Display payment plan edit form.""" + plan = PaymentPlans.query.get_or_404(plan_id) + return render_template('main/payment_plans_form.html', plan=plan, edit_mode=True) + +@main_bp.route('/payment-plans/edit/', methods=['POST']) +@login_required +def payment_plans_edit_post(plan_id): + """Handle payment plan updates.""" + try: + plan = PaymentPlans.query.get_or_404(plan_id) + + # Get form data + amount = request.form.get('amount') + frequency = request.form.get('frequency') + start_date = request.form.get('start_date') + stripe_payment_method = request.form.get('stripe_payment_method') + + # Validate inputs + if not all([amount, frequency, start_date, stripe_payment_method]): + flash('All fields are required.', 'error') + return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) + + try: + amount = float(amount) + from datetime import datetime + start_date = datetime.strptime(start_date, '%Y-%m-%d') + except (ValueError, TypeError): + flash('Invalid input format.', 'error') + return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) + + if amount <= 0: + flash('Amount must be greater than 0.', 'error') + return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) + + # Update payment plan + plan.Amount = amount + plan.Frequency = frequency + plan.Start_Date = start_date + plan.Stripe_Payment_Method = stripe_payment_method + + db.session.commit() + + # Log payment plan update + log_activity( + current_user.id, + "PAYPLAN_UPDATED", + "PaymentPlan", + plan.id, + details=f"Payment plan updated: ${amount:,.2f} {frequency} starting {start_date.strftime('%Y-%m-%d')}" + ) + + flash('Payment plan updated successfully.', 'success') + return redirect(url_for('main.payment_plans_detail', plan_id=plan.id)) + + except Exception as e: + db.session.rollback() + print(f"Payment plan update error: {e}") + flash('Failed to update payment plan. Please try again.', 'error') + return redirect(url_for('main.payment_plans_edit', plan_id=plan_id)) + +@main_bp.route('/payment-plans/delete/', methods=['POST']) +@login_required +def payment_plans_delete(plan_id): + """Handle payment plan deletion (soft delete).""" + try: + plan = PaymentPlans.query.get_or_404(plan_id) + + # Soft delete by setting Enabled to False + plan.Enabled = False + db.session.commit() + + flash('Payment plan has been disabled.', 'success') + return redirect(url_for('main.payment_plans_list')) + + except Exception as e: + db.session.rollback() + print(f"Payment plan deletion error: {e}") + flash('Failed to disable payment plan. Please try again.', 'error') + return redirect(url_for('main.payment_plans_detail', plan_id=plan_id)) + +@main_bp.route('/payment-plans/toggle/', methods=['POST']) +@login_required +def payment_plans_toggle(plan_id): + """Toggle payment plan enabled status.""" + try: + plan = PaymentPlans.query.get_or_404(plan_id) + + # Toggle enabled status + plan.Enabled = not plan.Enabled + db.session.commit() + + # Log payment plan toggle + action = "PAYPLAN_ENABLED" if plan.Enabled else "PAYPLAN_DISABLED" + log_activity( + current_user.id, + action, + "PaymentPlan", + plan.id, + details=f"Payment plan {'enabled' if plan.Enabled else 'disabled'}: ${plan.Amount:,.2f} {plan.Frequency}" + ) + + status = "enabled" if plan.Enabled else "disabled" + flash(f'Payment plan has been {status}.', 'success') + return redirect(url_for('main.payment_plans_detail', plan_id=plan_id)) + + except Exception as e: + db.session.rollback() + print(f"Payment plan toggle error: {e}") + flash('Failed to update payment plan status. Please try again.', 'error') + return redirect(url_for('main.payment_plans_detail', plan_id=plan_id)) + +@main_bp.route('/payment-plans/detail/') +@login_required +def payment_plans_detail(plan_id): + """Display detailed view of a specific payment plan.""" + from models import Users + + # Get payment plan with user information + plan = db.session.query( + PaymentPlans.id, + PaymentPlans.Splynx_ID, + PaymentPlans.Amount, + PaymentPlans.Frequency, + PaymentPlans.Start_Date, + PaymentPlans.Stripe_Payment_Method, + PaymentPlans.Enabled, + PaymentPlans.Created, + Users.FullName.label('created_by') + ).outerjoin(Users, PaymentPlans.Who == Users.id)\ + .filter(PaymentPlans.id == plan_id).first() + + if not plan: + flash('Payment plan not found.', 'error') + return redirect(url_for('main.payment_plans_list')) + + # Get associated single payments + associated_payments = db.session.query( + Payments.id, + Payments.Payment_Amount, + Payments.Success, + Payments.Error, + Payments.Created, + Payments.Payment_Intent)\ + .filter(Payments.PaymentPlan_ID == plan_id)\ + .order_by(Payments.Created.desc()).all() + + return render_template('main/payment_plans_detail.html', + plan=plan, + associated_payments=associated_payments) + +@main_bp.route('/api/stripe-payment-methods/') +@login_required +def api_stripe_payment_methods(stripe_customer_id): + """Get Stripe payment methods for a customer.""" + try: + payment_methods = get_stripe_payment_methods(stripe_customer_id) + return jsonify({'success': True, 'payment_methods': payment_methods}) + except Exception as e: + print(f"Error fetching payment methods: {e}") + return jsonify({'success': False, 'error': 'Failed to fetch payment methods'}), 500 + +@main_bp.route('/api/splynx/') +@login_required +def api_splynx_customer(id): + """ + Get Splynx customer information by ID + + Security: Restricted to operational and financial staff who need customer data access + """ + try: + log_activity(current_user.id, "API_ACCESS", "SplynxCustomer", id, + details=f"Accessed Splynx customer API for customer {id}") + + print(f"Splynx Customer API: {id}") + res = splynx.Customer(id) + + if res: + log_activity(current_user.id, "API_SUCCESS", "SplynxCustomer", id, + details=f"Successfully retrieved Splynx customer {id}") + return res + else: + log_activity(current_user.id, "API_NOT_FOUND", "SplynxCustomer", id, + details=f"Splynx customer {id} not found") + return {"error": "Customer not found"}, 404 + + except Exception as e: + log_activity(current_user.id, "API_ERROR", "SplynxCustomer", id, + details=f"Splynx customer API error: {str(e)}") + return {"error": "Internal server error"}, 500 \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..7120b15 --- /dev/null +++ b/config.py @@ -0,0 +1,39 @@ +import os + +class Config: + # Flask configuration + 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_TRACK_MODIFICATIONS = False + + # MySQL database configuration (read-only) + MYSQL_CONFIG = { + 'host': '103.210.154.25', + 'database': 'splynx', + 'user': 'splynximport', + 'password': 'splynxrocksbabyy', + 'port': 3306 + } + + # Query configuration + DEFAULT_QUERY_LIMIT = 3 + DEPOSIT_THRESHOLD = -5 + + # Payment Method Constants + PAYMENT_METHOD_DIRECT_DEBIT = 2 + PAYMENT_METHOD_CARD = 3 + PAYMENT_METHOD_PAYMENT_PLAN = 9 + + # Process live on Sandbox + # False = Sandbox - Default + PROCESS_LIVE = False + + # Threading configuration + MAX_PAYMENT_THREADS = 5 # Number of concurrent payment processing threads + THREAD_TIMEOUT = 60 # Timeout in seconds for payment processing threads + + # Stripe API Keys + STRIPE_LIVE_API_KEY = os.environ.get('STRIPE_LIVE_API_KEY') or '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' + STRIPE_TEST_API_KEY = os.environ.get('STRIPE_TEST_API_KEY') or 'sk_test_51Rsi9gPfYyg6zE1S4ZpaPI1ehpbsHRLsGhysYXKwAWCZ7w6KYgVXy4pV095Nd8tyjUw9AkBhqfxqsIiiWJg5fexI00Dw36vnvx' \ No newline at end of file diff --git a/include/site/python3.12/greenlet/greenlet.h b/include/site/python3.12/greenlet/greenlet.h new file mode 100644 index 0000000..d02a16e --- /dev/null +++ b/include/site/python3.12/greenlet/greenlet.h @@ -0,0 +1,164 @@ +/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ + +/* Greenlet object interface */ + +#ifndef Py_GREENLETOBJECT_H +#define Py_GREENLETOBJECT_H + + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* This is deprecated and undocumented. It does not change. */ +#define GREENLET_VERSION "1.0.0" + +#ifndef GREENLET_MODULE +#define implementation_ptr_t void* +#endif + +typedef struct _greenlet { + PyObject_HEAD + PyObject* weakreflist; + PyObject* dict; + implementation_ptr_t pimpl; +} PyGreenlet; + +#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) + + +/* C API functions */ + +/* Total number of symbols that are exported */ +#define PyGreenlet_API_pointers 12 + +#define PyGreenlet_Type_NUM 0 +#define PyExc_GreenletError_NUM 1 +#define PyExc_GreenletExit_NUM 2 + +#define PyGreenlet_New_NUM 3 +#define PyGreenlet_GetCurrent_NUM 4 +#define PyGreenlet_Throw_NUM 5 +#define PyGreenlet_Switch_NUM 6 +#define PyGreenlet_SetParent_NUM 7 + +#define PyGreenlet_MAIN_NUM 8 +#define PyGreenlet_STARTED_NUM 9 +#define PyGreenlet_ACTIVE_NUM 10 +#define PyGreenlet_GET_PARENT_NUM 11 + +#ifndef GREENLET_MODULE +/* This section is used by modules that uses the greenlet C API */ +static void** _PyGreenlet_API = NULL; + +# define PyGreenlet_Type \ + (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) + +# define PyExc_GreenletError \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) + +# define PyExc_GreenletExit \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) + +/* + * PyGreenlet_New(PyObject *args) + * + * greenlet.greenlet(run, parent=None) + */ +# define PyGreenlet_New \ + (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ + _PyGreenlet_API[PyGreenlet_New_NUM]) + +/* + * PyGreenlet_GetCurrent(void) + * + * greenlet.getcurrent() + */ +# define PyGreenlet_GetCurrent \ + (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) + +/* + * PyGreenlet_Throw( + * PyGreenlet *greenlet, + * PyObject *typ, + * PyObject *val, + * PyObject *tb) + * + * g.throw(...) + */ +# define PyGreenlet_Throw \ + (*(PyObject * (*)(PyGreenlet * self, \ + PyObject * typ, \ + PyObject * val, \ + PyObject * tb)) \ + _PyGreenlet_API[PyGreenlet_Throw_NUM]) + +/* + * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) + * + * g.switch(*args, **kwargs) + */ +# define PyGreenlet_Switch \ + (*(PyObject * \ + (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ + _PyGreenlet_API[PyGreenlet_Switch_NUM]) + +/* + * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) + * + * g.parent = new_parent + */ +# define PyGreenlet_SetParent \ + (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ + _PyGreenlet_API[PyGreenlet_SetParent_NUM]) + +/* + * PyGreenlet_GetParent(PyObject* greenlet) + * + * return greenlet.parent; + * + * This could return NULL even if there is no exception active. + * If it does not return NULL, you are responsible for decrementing the + * reference count. + */ +# define PyGreenlet_GetParent \ + (*(PyGreenlet* (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) + +/* + * deprecated, undocumented alias. + */ +# define PyGreenlet_GET_PARENT PyGreenlet_GetParent + +# define PyGreenlet_MAIN \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_MAIN_NUM]) + +# define PyGreenlet_STARTED \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_STARTED_NUM]) + +# define PyGreenlet_ACTIVE \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) + + + + +/* Macro that imports greenlet and initializes C API */ +/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we + keep the older definition to be sure older code that might have a copy of + the header still works. */ +# define PyGreenlet_Import() \ + { \ + _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ + } + +#endif /* GREENLET_MODULE */ + +#ifdef __cplusplus +} +#endif +#endif /* !Py_GREENLETOBJECT_H */ diff --git a/lib64 b/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/1b403a365765_add_new_features.py b/migrations/versions/1b403a365765_add_new_features.py new file mode 100644 index 0000000..c99d227 --- /dev/null +++ b/migrations/versions/1b403a365765_add_new_features.py @@ -0,0 +1,46 @@ +"""Add new features + +Revision ID: 1b403a365765 +Revises: 455cbec206cf +Create Date: 2025-08-09 17:45:22.066241 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '1b403a365765' +down_revision = '455cbec206cf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('PaymentBatch', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('Created', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.add_column(sa.Column('PaymentBatch_ID', sa.Integer(), nullable=False)) + batch_op.alter_column('PI_Last_Check', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + batch_op.create_foreign_key(None, 'PaymentBatch', ['PaymentBatch_ID'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.alter_column('PI_Last_Check', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + batch_op.drop_column('PaymentBatch_ID') + + op.drop_table('PaymentBatch') + # ### end Alembic commands ### diff --git a/migrations/versions/3252db86eaae_add_new_features.py b/migrations/versions/3252db86eaae_add_new_features.py new file mode 100644 index 0000000..b4a541a --- /dev/null +++ b/migrations/versions/3252db86eaae_add_new_features.py @@ -0,0 +1,42 @@ +"""Add new features + +Revision ID: 3252db86eaae +Revises: 906059746902 +Create Date: 2025-08-14 15:02:47.519589 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3252db86eaae' +down_revision = '906059746902' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.add_column(sa.Column('PaymentPlan_ID', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(None, 'PaymentPlans', ['PaymentPlan_ID'], ['id']) + + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('SinglePayments_PaymentPlan_ID_fkey'), type_='foreignkey') + batch_op.drop_column('PaymentPlan_ID') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.add_column(sa.Column('PaymentPlan_ID', sa.INTEGER(), autoincrement=False, nullable=True)) + batch_op.create_foreign_key(batch_op.f('SinglePayments_PaymentPlan_ID_fkey'), 'PaymentPlans', ['PaymentPlan_ID'], ['id']) + + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('PaymentPlan_ID') + + # ### end Alembic commands ### diff --git a/migrations/versions/455cbec206cf_initial_migration_with_users_payments_.py b/migrations/versions/455cbec206cf_initial_migration_with_users_payments_.py new file mode 100644 index 0000000..539546c --- /dev/null +++ b/migrations/versions/455cbec206cf_initial_migration_with_users_payments_.py @@ -0,0 +1,73 @@ +"""Initial migration with Users, Payments, and Logs tables + +Revision ID: 455cbec206cf +Revises: +Create Date: 2025-08-08 21:11:27.414842 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '455cbec206cf' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('Payments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('Splynx_ID', sa.Integer(), nullable=True), + sa.Column('Stripe_Customer_ID', sa.String(), nullable=True), + sa.Column('Payment_Intent', sa.String(), nullable=True), + sa.Column('PI_FollowUp', sa.Boolean(), nullable=False), + sa.Column('PI_Last_Check', sa.DateTime(), nullable=False), + sa.Column('Payment_Method', sa.String(), nullable=True), + sa.Column('Fee_Tax', sa.Float(), nullable=True), + sa.Column('Fee_Stripe', sa.Float(), nullable=True), + sa.Column('Fee_Total', sa.Float(), nullable=True), + sa.Column('Payment_Amount', sa.Float(), nullable=True), + sa.Column('PI_JSON', sa.Text(), nullable=True), + sa.Column('PI_FollowUp_JSON', sa.Text(), nullable=True), + sa.Column('Success', sa.Boolean(), nullable=False), + sa.Column('Created', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('Users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('Username', sa.String(), nullable=True), + sa.Column('Password', sa.String(), nullable=True), + sa.Column('FullName', sa.String(), nullable=True), + sa.Column('Email', sa.String(), nullable=True), + sa.Column('PassResetCode', sa.String(), nullable=True), + sa.Column('PassResetRequest', sa.DateTime(), nullable=True), + sa.Column('Enabled', sa.Boolean(), nullable=False), + sa.Column('Permissions', sa.String(), nullable=True), + sa.Column('Created', sa.DateTime(), nullable=False), + sa.Column('LoginChangePass', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('Logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('User_ID', sa.Integer(), nullable=False), + sa.Column('Log_Entry', sa.String(length=4000), nullable=True), + sa.Column('Added', sa.DateTime(), nullable=False), + sa.Column('Action', sa.String(length=50), nullable=True), + sa.Column('Entity_Type', sa.String(length=50), nullable=True), + sa.Column('Entity_ID', sa.Integer(), nullable=True), + sa.Column('IP_Address', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['User_ID'], ['Users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('Logs') + op.drop_table('Users') + op.drop_table('Payments') + # ### end Alembic commands ### diff --git a/migrations/versions/50157fcf55e4_add_new_features.py b/migrations/versions/50157fcf55e4_add_new_features.py new file mode 100644 index 0000000..6aef60e --- /dev/null +++ b/migrations/versions/50157fcf55e4_add_new_features.py @@ -0,0 +1,32 @@ +"""Add new features + +Revision ID: 50157fcf55e4 +Revises: ed07e785afd5 +Create Date: 2025-08-13 15:57:43.041740 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '50157fcf55e4' +down_revision = 'ed07e785afd5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: + batch_op.add_column(sa.Column('Stripe_Payment_Method', sa.String(length=50), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: + batch_op.drop_column('Stripe_Payment_Method') + + # ### end Alembic commands ### diff --git a/migrations/versions/6a841af4c236_add_new_features.py b/migrations/versions/6a841af4c236_add_new_features.py new file mode 100644 index 0000000..ba0b329 --- /dev/null +++ b/migrations/versions/6a841af4c236_add_new_features.py @@ -0,0 +1,32 @@ +"""Add new features + +Revision ID: 6a841af4c236 +Revises: 50157fcf55e4 +Create Date: 2025-08-13 20:18:52.912339 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6a841af4c236' +down_revision = '50157fcf55e4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: + batch_op.add_column(sa.Column('Stripe_Customer_ID', sa.String(length=50), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: + batch_op.drop_column('Stripe_Customer_ID') + + # ### end Alembic commands ### diff --git a/migrations/versions/906059746902_add_new_features.py b/migrations/versions/906059746902_add_new_features.py new file mode 100644 index 0000000..87c6f48 --- /dev/null +++ b/migrations/versions/906059746902_add_new_features.py @@ -0,0 +1,38 @@ +"""Add new features + +Revision ID: 906059746902 +Revises: 6a841af4c236 +Create Date: 2025-08-13 20:25:29.561582 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '906059746902' +down_revision = '6a841af4c236' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.add_column(sa.Column('Stripe_Payment_Method', sa.String(), nullable=True)) + + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.add_column(sa.Column('Stripe_Payment_Method', sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.drop_column('Stripe_Payment_Method') + + with op.batch_alter_table('Payments', schema=None) as batch_op: + batch_op.drop_column('Stripe_Payment_Method') + + # ### end Alembic commands ### diff --git a/migrations/versions/9d9195d6b9a7_add_new_features.py b/migrations/versions/9d9195d6b9a7_add_new_features.py new file mode 100644 index 0000000..9921c95 --- /dev/null +++ b/migrations/versions/9d9195d6b9a7_add_new_features.py @@ -0,0 +1,48 @@ +"""Add new features + +Revision ID: 9d9195d6b9a7 +Revises: 1b403a365765 +Create Date: 2025-08-12 16:22:21.329937 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9d9195d6b9a7' +down_revision = '1b403a365765' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('SinglePayments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('Splynx_ID', sa.Integer(), nullable=True), + sa.Column('Stripe_Customer_ID', sa.String(), nullable=True), + sa.Column('Payment_Intent', sa.String(), nullable=True), + sa.Column('PI_FollowUp', sa.Boolean(), nullable=False), + sa.Column('PI_Last_Check', sa.DateTime(), nullable=True), + sa.Column('Payment_Method', sa.String(), nullable=True), + sa.Column('Fee_Tax', sa.Float(), nullable=True), + sa.Column('Fee_Stripe', sa.Float(), nullable=True), + sa.Column('Fee_Total', sa.Float(), nullable=True), + sa.Column('Payment_Amount', sa.Float(), nullable=True), + sa.Column('PI_JSON', sa.Text(), nullable=True), + sa.Column('PI_FollowUp_JSON', sa.Text(), nullable=True), + sa.Column('Error', sa.Text(), nullable=True), + sa.Column('Success', sa.Boolean(), nullable=True), + sa.Column('Created', sa.DateTime(), nullable=False), + sa.Column('Who', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['Who'], ['Users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('SinglePayments') + # ### end Alembic commands ### diff --git a/migrations/versions/ed07e785afd5_add_new_features.py b/migrations/versions/ed07e785afd5_add_new_features.py new file mode 100644 index 0000000..3dfc606 --- /dev/null +++ b/migrations/versions/ed07e785afd5_add_new_features.py @@ -0,0 +1,48 @@ +"""Add new features + +Revision ID: ed07e785afd5 +Revises: 9d9195d6b9a7 +Create Date: 2025-08-13 14:55:02.023809 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ed07e785afd5' +down_revision = '9d9195d6b9a7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('PaymentPlans', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('Splynx_ID', sa.Integer(), nullable=True), + sa.Column('Amount', sa.Float(), nullable=True), + sa.Column('Frequency', sa.String(length=50), nullable=True), + sa.Column('Day', sa.String(length=50), nullable=True), + sa.Column('Start_Date', sa.DateTime(), nullable=True), + sa.Column('Created', sa.DateTime(), nullable=False), + sa.Column('Who', sa.Integer(), nullable=False), + sa.Column('Enabled', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['Who'], ['Users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.add_column(sa.Column('PaymentPlan_ID', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(None, 'PaymentPlans', ['PaymentPlan_ID'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('SinglePayments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('PaymentPlan_ID') + + op.drop_table('PaymentPlans') + # ### end Alembic commands ### diff --git a/models.py b/models.py new file mode 100644 index 0000000..ce63b4e --- /dev/null +++ b/models.py @@ -0,0 +1,102 @@ +from datetime import datetime, timezone +from flask_login import UserMixin, current_user +from flask import redirect, url_for, flash +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import synonym, relationship +from app import db + +# S2qBHlGQxVDMQGYOO5Db + +class Users(UserMixin, db.Model): + __tablename__ = 'Users' + id = db.Column(db.Integer, primary_key=True) + Username = db.Column(db.String()) + Password = db.Column(db.String()) + FullName = db.Column(db.String()) + Email = db.Column(db.String()) + PassResetCode = db.Column(db.String()) + PassResetRequest = db.Column(db.DateTime) + Enabled = db.Column(db.Boolean, nullable=False, default=1) + Permissions = db.Column(db.String()) + Created = db.Column(db.DateTime, nullable=False, default=datetime.now(timezone.utc)) + LoginChangePass = db.Column(db.Boolean, nullable=False, default=0) + + def __repr__(self): + return '' % self.FullName + + def get_id(self): + return str(self.id) + +class PaymentBatch(db.Model): + __tablename__ = 'PaymentBatch' + id = db.Column(db.Integer, primary_key=True) + Created = db.Column(db.DateTime, nullable=False, default=datetime.now()) + + +class Payments(db.Model): + __tablename__ = 'Payments' + id = db.Column(db.Integer, primary_key=True) + Splynx_ID = db.Column(db.Integer) + PaymentBatch_ID = db.Column(db.Integer, db.ForeignKey('PaymentBatch.id'), nullable=False) + Stripe_Customer_ID = db.Column(db.String()) + Payment_Intent = db.Column(db.String()) + PI_FollowUp = db.Column(db.Boolean, nullable=False, default=0) + PI_Last_Check = db.Column(db.DateTime, nullable=True) + Payment_Method = db.Column(db.String()) + Stripe_Payment_Method = db.Column(db.String()) + Fee_Tax = db.Column(db.Float()) + Fee_Stripe = db.Column(db.Float()) + Fee_Total = db.Column(db.Float()) + Payment_Amount = db.Column(db.Float()) + PI_JSON = db.Column(db.Text()) + PI_FollowUp_JSON = db.Column(db.Text()) + Error = db.Column(db.Text()) + Success = db.Column(db.Boolean, nullable=True, default=None) + Created = db.Column(db.DateTime, nullable=False, default=datetime.now()) + PaymentPlan_ID = db.Column(db.Integer, db.ForeignKey('PaymentPlans.id'), nullable=True) + +class SinglePayments(db.Model): + __tablename__ = 'SinglePayments' + id = db.Column(db.Integer, primary_key=True) + Splynx_ID = db.Column(db.Integer) + Stripe_Customer_ID = db.Column(db.String()) + Payment_Intent = db.Column(db.String()) + PI_FollowUp = db.Column(db.Boolean, nullable=False, default=0) + PI_Last_Check = db.Column(db.DateTime, nullable=True) + Payment_Method = db.Column(db.String()) + Stripe_Payment_Method = db.Column(db.String()) + Fee_Tax = db.Column(db.Float()) + Fee_Stripe = db.Column(db.Float()) + Fee_Total = db.Column(db.Float()) + Payment_Amount = db.Column(db.Float()) + PI_JSON = db.Column(db.Text()) + PI_FollowUp_JSON = db.Column(db.Text()) + Error = db.Column(db.Text()) + Success = db.Column(db.Boolean, nullable=True, default=None) + Created = db.Column(db.DateTime, nullable=False, default=datetime.now()) + Who = db.Column(db.Integer, db.ForeignKey('Users.id'), nullable=False) + + +class Logs(db.Model): + __tablename__ = 'Logs' + id = db.Column(db.Integer, primary_key=True) + User_ID = db.Column(db.Integer, db.ForeignKey('Users.id'), nullable=False) + Log_Entry = db.Column(db.String(4000)) + Added = db.Column(db.DateTime, nullable=False, default=datetime.now(timezone.utc)) + Action = db.Column(db.String(50)) + Entity_Type = db.Column(db.String(50)) + Entity_ID = db.Column(db.Integer) + IP_Address = db.Column(db.String(50)) + +class PaymentPlans(db.Model): + __tablename__ = 'PaymentPlans' + id = db.Column(db.Integer, primary_key=True) + Splynx_ID = db.Column(db.Integer) + Stripe_Customer_ID = db.Column(db.String(50)) + Amount = db.Column(db.Float) + Frequency = db.Column(db.String(50)) + Start_Date = db.Column(db.DateTime, nullable=True) + Stripe_Payment_Method = db.Column(db.String(50)) + Created = db.Column(db.DateTime, nullable=False, default=datetime.now()) + Who = db.Column(db.Integer, db.ForeignKey('Users.id'), nullable=False) + Enabled = db.Column(db.Boolean, nullable=True, default=True) \ No newline at end of file diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 0000000..1a517eb --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3.12 -m venv /home/alan/python_projects/plutus/plutus diff --git a/query_mysql - Copy.py b/query_mysql - Copy.py new file mode 100644 index 0000000..ad347a2 --- /dev/null +++ b/query_mysql - Copy.py @@ -0,0 +1,532 @@ +#!/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.py b/query_mysql.py new file mode 100644 index 0000000..022202e --- /dev/null +++ b/query_mysql.py @@ -0,0 +1,672 @@ +#!/usr/bin/env python3 +""" +External script to query MySQL database (Splynx) for customer billing data. +This script runs independently of the Flask application. + +Usage: python query_mysql.py +""" + +import pymysql +import sys +import json +import random +import threading +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import List, Dict, Union, Any +from stripe_payment_processor import StripePaymentProcessor +from config import Config +from app import create_app, db +from models import Payments, PaymentBatch, SinglePayments, PaymentPlans +from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET +from services import ( + log_script_start, log_script_completion, log_batch_created, + log_payment_intent_followup +) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('payment_processing.log'), + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +# Initialize Splynx API +splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) + +# Import constants from config +PAYMENT_METHOD_DIRECT_DEBIT = Config.PAYMENT_METHOD_DIRECT_DEBIT +PAYMENT_METHOD_CARD = Config.PAYMENT_METHOD_CARD +PAYMENT_METHOD_PAYMENT_PLAN = Config.PAYMENT_METHOD_PAYMENT_PLAN +PROCESS_LIVE = Config.PROCESS_LIVE + +# Get Stripe API key from config +if PROCESS_LIVE: + api_key = Config.STRIPE_LIVE_API_KEY +else: + api_key = Config.STRIPE_TEST_API_KEY + test_stripe_customers = ['cus_SoQqMGLmCjiBDZ', 'cus_SoQptxwe8hczGz', 'cus_SoQjeNXkKOdORI', 'cus_SoQiDcSrNRxbPF', 'cus_SoQedaG3q2ecKG', 'cus_SoQeTkzMA7AaLR', 'cus_SoQeijBTETQcGb', 'cus_SoQe259iKMgz7o', 'cus_SoQejTstdXEDTO', 'cus_SoQeQH2ORWBOWX', 'cus_SoQevtyWxqXtpC', 'cus_SoQekOFUHugf26', 'cus_SoPq6Zh0MCUR9W', 'cus_SoPovwUPJmvugz', 'cus_SoPnvGfejhpSR5', 'cus_SoNAgAbkbFo8ZY', 'cus_SoMyDihTxRsa7U', 'cus_SoMVPWxdYstYbr', 'cus_SoMVQ6Xj2dIrCR', 'cus_SoMVmBn1xipFEB', 'cus_SoMVNvZ2Iawb7Y', 'cus_SoMVZupj6wRy5e', 'cus_SoMVqjH7zkc5Qe', 'cus_SoMVkzj0ZUK0Ai', 'cus_SoMVFq3BUD3Njw', 'cus_SoLcrRrvoy9dJ4', 'cus_SoLcqHN1k0WD8j', 'cus_SoLcLtYDZGG32V', 'cus_SoLcG23ilNeMYt', 'cus_SoLcFhtUVzqumj', 'cus_SoLcPgMnuogINl', 'cus_SoLccGTY9mMV7T', 'cus_SoLRxqvJxuKFes', 'cus_SoKs7cjdcvW1oO'] + + +def find_pay_splynx_invoices(splynx_id: int) -> List[Dict[str, Any]]: + result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=not_paid") + + invoice_pay = { + "status": "paid" + } + + updated_invoices = [] + for pay in result: + res = splynx.put(url=f"/api/2.0/admin/finance/invoices/{pay['id']}", params=invoice_pay) + if res: + updated_invoices.append(res) + return updated_invoices + +def 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 + 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 + AND NOT EXISTS ( + SELECT 1 + FROM invoices i + WHERE i.customer_id = cb.customer_id + AND i.status = 'pending' + ) + ORDER BY cb.payment_method ASC + LIMIT %s + """ + + #query = """ + #SELECT + # cb.customer_id, + # cb.deposit, + # cb.payment_method, + # pad.field_1 AS stripe_customer_id + #FROM customer_billing cb + #LEFT OUTER JOIN payment_account_data pad ON cb.customer_id = pad.customer_id + #WHERE cb.payment_method = %s + #AND cb.deposit < %s + #ORDER BY cb.payment_method ASC + #LIMIT %s + #""" + + with connection.cursor() as cursor: + cursor.execute(query, (pm, Config.DEPOSIT_THRESHOLD, Config.DEFAULT_QUERY_LIMIT)) + results = cursor.fetchall() + + if results: + logger.info(f"Found {len(results)} rows") + return results + else: + logger.info("No rows found matching the criteria") + return False + + except pymysql.Error as e: + logger.error(f"MySQL Error: {e}") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected Error: {e}") + sys.exit(1) + finally: + if connection: + connection.close() + logger.info("MySQL connection closed") + + +def addInitialPayments(customers, batch_id): + added = {"added": 0, "failed": 0} + payments_to_add = [] + + # Prepare all payments first + for cust in customers: + if PROCESS_LIVE: + stripe_customer_id = cust['stripe_customer_id'] + else: + stripe_customer_id = test_stripe_customers[random.randint(1, len(test_stripe_customers)-1)] + add_payer = Payments( + PaymentBatch_ID = batch_id, + Splynx_ID = cust['customer_id'], + Stripe_Customer_ID = stripe_customer_id, + Payment_Amount = float(cust['deposit'])*-1, + Stripe_Payment_Method = cust.get('stripe_pm', None), + PaymentPlan_ID = cust.get('paymentplan_id', None) + ) + payments_to_add.append(add_payer) + db.session.add(add_payer) + + # Atomic commit for entire batch + try: + db.session.commit() + added["added"] = len(payments_to_add) + logger.info(f"Successfully added {len(payments_to_add)} payments to batch {batch_id}") + except Exception as e: + db.session.rollback() + added["failed"] = len(payments_to_add) + logger.error(f"addInitialPayments failed for entire batch {batch_id}: {e}") + + logger.info(f"Database operation result: {json.dumps(added,indent=2)}") + +def addPaymentBatch(): + """Create a new payment batch and return its ID.""" + add_batch = PaymentBatch() + + try: + db.session.add(add_batch) + db.session.commit() + return add_batch.id + except Exception as e: + db.session.rollback() + logger.error(f"addPaymentBatch failed: {e}") + return None + +def processPaymentResult(pay_id, result, key): + if key == "pay": + payment = db.session.query(Payments).filter(Payments.id == pay_id).first() + elif key == "singlepay": + payment = db.session.query(SinglePayments).filter(SinglePayments.id == pay_id).first() + try: + if result.get('error') and not result.get('needs_fee_update'): + payment.Error = f"Error Type: {result['error_type']}\nError: {result['error']}" + payment.Success = result['success'] + payment.PI_JSON = json.dumps(result) + else: + if result.get('needs_fee_update'): + payment.PI_FollowUp = True + payment.Payment_Intent = result['payment_intent_id'] + payment.Success = result['success'] + if result['success'] and PROCESS_LIVE: + find_pay_splynx_invoices(payment.Splynx_ID) + add_payment_splynx( + splynx_id=payment.Splynx_ID, + pi_id=result['payment_intent_id'], + pay_id=payment.id, + amount=payment.Payment_Amount + ) + if result.get('payment_method_type') == "card": + payment.Payment_Method = result['estimated_fee_details']['card_display_brand'] + elif result.get('payment_method_type') == "au_becs_debit": + payment.Payment_Method = result['payment_method_type'] + if payment.PI_JSON: + combined = {**json.loads(payment.PI_JSON), **result} + payment.PI_JSON = json.dumps(combined) + else: + payment.PI_JSON = json.dumps(result) + if result.get('fee_details'): + payment.Fee_Total = result['fee_details']['total_fee'] + for fee_type in result['fee_details']['fee_breakdown']: + if fee_type['type'] == "tax": + payment.Fee_Tax = fee_type['amount'] + elif fee_type['type'] == "stripe_fee": + payment.Fee_Stripe = fee_type['amount'] + except Exception as e: + logger.error(f"processPaymentResult: {e}\nResult: {json.dumps(result)}") + payment.PI_FollowUp = True + + def _update_payment(): + return True # Just need to trigger commit, payment is already modified + + handle_database_operation(_update_payment, "processPaymentResult") + +# Thread lock for database operations +db_lock = threading.Lock() + +def process_single_payment(processor, payment_data): + """ + Thread-safe function to process a single payment. + + Args: + processor: StripePaymentProcessor instance + payment_data: Dict containing payment information + + Returns: + Dict with payment result and metadata + """ + try: + # Process payment with Stripe (thread-safe) + result = processor.process_payment( + customer_id=payment_data['customer_id'], + amount=payment_data['amount'], + currency=payment_data['currency'], + description=payment_data['description'], + stripe_pm=payment_data['stripe_pm'] + ) + + # Return result with payment ID for database update + return { + 'payment_id': payment_data['payment_id'], + 'result': result, + 'success': True + } + except Exception as e: + logger.error(f"Payment processing failed for payment ID {payment_data['payment_id']}: {e}") + return { + 'payment_id': payment_data['payment_id'], + 'result': None, + 'success': False, + 'error': str(e) + } + +def update_single_payment_result(payment_id, result): + """ + Thread-safe immediate update of single payment result to database. + Commits immediately to ensure data safety. + + Args: + payment_id: ID of the payment to update + result: Payment processing result + """ + with db_lock: + try: + if result: + processPaymentResult(pay_id=payment_id, result=result, key="pay") + logger.info(f"Payment {payment_id} result committed to database") + else: + logger.warning(f"No result to commit for payment {payment_id}") + except Exception as e: + logger.error(f"Failed to update payment {payment_id}: {e}") + +def process_batch_mode(processor): + """Handle batch processing for Direct Debit and Card payments.""" + to_run_batches = [] + payment_methods = [PAYMENT_METHOD_DIRECT_DEBIT, PAYMENT_METHOD_CARD] + total_customers = 0 + + payment_method_names = { + PAYMENT_METHOD_DIRECT_DEBIT: "Direct Debit", + PAYMENT_METHOD_CARD: "Card Payment" + } + + for pm in payment_methods: + batch_id = addPaymentBatch() + if batch_id is not None: + to_run_batches.append(batch_id) + customers = query_splynx_customers(pm) + if customers: + customer_count = len(customers) + total_customers += customer_count + addInitialPayments(customers=customers, batch_id=batch_id) + + # Log batch creation + log_batch_created(batch_id, payment_method_names[pm], customer_count) + logger.info(f"Created batch {batch_id} for {payment_method_names[pm]} with {customer_count} customers") + else: + logger.info(f"No customers found for {payment_method_names[pm]}") + else: + logger.error(f"Failed to create batch for payment method {pm}") + + return to_run_batches, 0, 0, 0.0 # Success/failed counts will be updated during execution + +def process_payplan_mode(processor): + """Handle payment plan processing.""" + to_run_batches = [] + + # Get count of active payment plans for logging (if needed in future) + + batch_id = addPaymentBatch() + if batch_id is not None: + to_run_batches.append(batch_id) + customers = query_payplan_customers() + due_plans_count = len(customers) if customers else 0 + + if customers: + total_amount = sum(abs(c.get('deposit', 0)) for c in customers) + addInitialPayments(customers=customers, batch_id=batch_id) + + # Log batch creation for payment plans + log_batch_created(batch_id, "Payment Plan", due_plans_count) + logger.info(f"Created payment plan batch {batch_id} with {due_plans_count} due plans (${total_amount:,.2f} total)") + else: + logger.info("No payment plans due for processing today") + total_amount = 0.0 + else: + logger.error("Failed to create batch for payment plan processing") + due_plans_count = 0 + total_amount = 0.0 + + return to_run_batches, 0, 0, total_amount # Success/failed counts will be updated during execution + +def execute_payment_batches(processor, batch_ids): + """Execute payments for all provided batch IDs using safe threading with immediate commits.""" + if not batch_ids: + logger.warning("No valid batches to process") + return + + max_threads = Config.MAX_PAYMENT_THREADS + + for batch in batch_ids: + if batch is None: + logger.warning("Skipping None batch ID") + continue + + cust_pay = db.session.query(Payments).filter(Payments.PaymentBatch_ID == batch).all() + if not cust_pay: + logger.info(f"No payments found for batch {batch}") + continue + + logger.info(f"Processing {len(cust_pay)} payments in batch {batch} using {max_threads} threads") + logger.info("Safety Mode: Each payment will be committed immediately to database") + + # Process payments in smaller chunks to avoid timeout issues + processed_count = 0 + failed_count = 0 + + # Process payments in chunks + chunk_size = max_threads * 2 # Process 2x thread count at a time + for i in range(0, len(cust_pay), chunk_size): + chunk = cust_pay[i:i + chunk_size] + logger.info(f"Processing chunk {i//chunk_size + 1}: payments {i+1}-{min(i+chunk_size, len(cust_pay))}") + + # Prepare payment data for this chunk + payment_tasks = [] + + for pay in chunk: + if PROCESS_LIVE: + customer_id = pay.Stripe_Customer_ID + else: + customer_id = pay.Stripe_Customer_ID + payment_data = { + 'payment_id': pay.id, + 'customer_id': customer_id, + 'amount': pay.Payment_Amount, + 'currency': "aud", + 'description': f"Payment ID: {pay.id} - Splynx ID: {pay.Splynx_ID}", + 'stripe_pm': pay.Stripe_Payment_Method + } + logger.debug(f"payment_data: {json.dumps(payment_data,indent=2)}") + payment_tasks.append(payment_data) + + # Process this chunk with ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=max_threads) as executor: + # Submit tasks for this chunk + future_to_payment = { + executor.submit(process_single_payment, processor, task): task + for task in payment_tasks + } + + # Process results as they complete (NO TIMEOUT on as_completed) + for future in as_completed(future_to_payment): + try: + result = future.result(timeout=60) # Individual payment timeout + + if result['success'] and result['result']: + # IMMEDIATELY commit each successful payment to database + update_single_payment_result(result['payment_id'], result['result']) + processed_count += 1 + logger.info(f"Payment {result['payment_id']} processed and committed ({processed_count}/{len(cust_pay)})") + else: + failed_count += 1 + logger.warning(f"Payment {result['payment_id']} failed ({failed_count} failures total)") + + except Exception as e: + payment_data = future_to_payment[future] + failed_count += 1 + logger.error(f"Thread exception for payment {payment_data['payment_id']}: {e}") + + logger.info(f"Chunk completed: {processed_count} processed, {failed_count} failed") + + logger.info(f"Batch {batch} completed: {processed_count}/{len(cust_pay)} payments processed successfully") + +def process_payintent_mode(processor): + """Handle payment intent follow-up processing.""" + to_check = { + "pay": db.session.query(Payments).filter(Payments.PI_FollowUp == True).all(), + "singlepay": db.session.query(SinglePayments).filter(SinglePayments.PI_FollowUp == True).all(), + } + + total_pending = 0 + succeeded_count = 0 + failed_count = 0 + still_pending = 0 + + for key, value in to_check.items(): + logger.debug(f"Processing payment intent follow-up for {len(value)} {key} items") + total_pending += len(value) + + for pi in value: + try: + intent_result = processor.check_payment_intent(pi.Payment_Intent) + logger.debug(f"Intent result: {json.dumps(intent_result, indent=2)}") + + if intent_result['status'] == "succeeded": + pi.PI_FollowUp_JSON = json.dumps(intent_result) + pi.PI_FollowUp = False + pi.PI_Last_Check = datetime.now() + 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() + still_pending += 1 + + db.session.commit() + except Exception as e: + logger.error(f"Error processing payment intent {pi.Payment_Intent}: {e}") + failed_count += 1 + + # Log payment intent follow-up results + if total_pending > 0: + log_payment_intent_followup(total_pending, succeeded_count, failed_count, still_pending) + logger.info(f"Payment intent follow-up completed: {succeeded_count} succeeded, {failed_count} failed, {still_pending} still pending") + else: + logger.info("No payment intents requiring follow-up") + + return succeeded_count, failed_count + +if __name__ == "__main__": + ## Payment Method: + ## 2 - Direct Debit (Automatic) + ## 3 - Card Payment (Automatic) + ## 9 - Payment Plan + + ### Running Mode + ## batch = Monthly Direct Debit/Credit Cards + ## payintent = Check outstanding Payment Intents and update + ## payplan = Check for Payment Plans to run + + start_time = datetime.now() + success_count = 0 + failed_count = 0 + total_amount = 0.0 + batch_ids = [] + errors = [] + + try: + if sys.argv[1] == "batch": + running_mode = "batch" + elif sys.argv[1] == "payintent": + running_mode = "payintent" + elif sys.argv[1] == "payplan": + running_mode = "payplan" + else: + logger.error(f"Invalid running mode: {sys.argv[1]}") + logger.info("Valid modes: batch, payintent, payplan") + sys.exit(1) + try: + if sys.argv[2] == "live": + PROCESS_LIVE = True + except IndexError: + logger.info("Processing payments against Sandbox") + except IndexError: + logger.info("No running mode specified, defaulting to 'payintent'") + running_mode = "payintent" + + # Create Flask application context + app = create_app() + processor = StripePaymentProcessor(api_key=api_key, enable_logging=True) + + with app.app_context(): + # Log script start + environment = "live" if PROCESS_LIVE else "sandbox" + log_script_start("query_mysql.py", running_mode, environment) + logger.info(f"Starting query_mysql.py in {running_mode} mode ({environment})") + + try: + if running_mode == "batch": + batch_ids, success_count, failed_count, total_amount = process_batch_mode(processor) + execute_payment_batches(processor, batch_ids) + elif running_mode == "payplan": + batch_ids, success_count, failed_count, total_amount = process_payplan_mode(processor) + execute_payment_batches(processor, batch_ids) + elif running_mode == "payintent": + success_count, failed_count = process_payintent_mode(processor) + except Exception as e: + logger.error(f"Script execution failed: {e}") + errors.append(str(e)) + failed_count += 1 + + # Calculate execution time and log completion + end_time = datetime.now() + duration_seconds = (end_time - start_time).total_seconds() + + log_script_completion( + script_name="query_mysql.py", + mode=running_mode, + success_count=success_count, + failed_count=failed_count, + total_amount=total_amount, + batch_ids=batch_ids if batch_ids else None, + duration_seconds=duration_seconds, + errors=errors if errors else None + ) + + logger.info(f"Script completed in {duration_seconds:.1f}s: {success_count} successful, {failed_count} failed") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2859735 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.5 +Flask-Login==0.6.3 +PyMySQL==1.1.0 +psycopg2-binary==2.9.8 +Werkzeug==2.3.7 \ No newline at end of file diff --git a/services.py b/services.py new file mode 100644 index 0000000..1114227 --- /dev/null +++ b/services.py @@ -0,0 +1,229 @@ +""" +Logging and utility services for Plutus payment processing application. + +This module provides database logging functionality for tracking application activities, +particularly automated script executions and payment processing operations. +""" + +from datetime import datetime, timezone +from typing import Optional, Union +from app import db +from models import Logs + +# System user ID for automated processes +SYSTEM_USER_ID = 1 + +def log_activity( + user_id: int, + action: str, + entity_type: str, + entity_id: Optional[int] = None, + details: Optional[str] = None, + ip_address: Optional[str] = None +) -> Optional[int]: + """ + Log an activity to the database. + + Args: + user_id (int): ID of the user performing the action (use SYSTEM_USER_ID for automated processes) + action (str): Type of action performed (e.g., 'BATCH_RUN', 'PAYMENT_PROCESSED', 'API_ACCESS') + entity_type (str): Type of entity involved (e.g., 'Batch', 'Payment', 'PaymentPlan') + entity_id (int, optional): ID of the specific entity involved + details (str, optional): Detailed description of the activity + ip_address (str, optional): IP address of the request (for web requests) + + Returns: + int: ID of the created log entry, or None if failed + """ + try: + log_entry = Logs( + User_ID=user_id, + Action=action, + Entity_Type=entity_type, + Entity_ID=entity_id, + Log_Entry=details, + IP_Address=ip_address, + Added=datetime.now(timezone.utc) + ) + + db.session.add(log_entry) + db.session.commit() + + return log_entry.id + + except Exception as e: + db.session.rollback() + print(f"Failed to log activity: {e}") + return None + +def log_script_start(script_name: str, mode: str, environment: str) -> Optional[int]: + """ + Log the start of a script execution. + + Args: + script_name (str): Name of the script being executed + mode (str): Running mode (batch, payintent, payplan) + environment (str): Environment (live, sandbox) + + Returns: + int: Log entry ID or None if failed + """ + details = f"{script_name} started in {mode} mode ({environment} environment)" + return log_activity( + user_id=SYSTEM_USER_ID, + action="SCRIPT_START", + entity_type="Script", + details=details + ) + +def log_script_completion( + script_name: str, + mode: str, + success_count: int = 0, + failed_count: int = 0, + total_amount: float = 0.0, + batch_ids: Optional[list] = None, + duration_seconds: Optional[float] = None, + errors: Optional[list] = None +) -> Optional[int]: + """ + Log the completion of a script execution with summary statistics. + + Args: + script_name (str): Name of the script that completed + mode (str): Running mode that was executed + success_count (int): Number of successful operations + failed_count (int): Number of failed operations + total_amount (float): Total amount processed + batch_ids (list, optional): List of batch IDs created + duration_seconds (float, optional): Execution time in seconds + errors (list, optional): List of error messages encountered + + Returns: + int: Log entry ID or None if failed + """ + total_operations = success_count + failed_count + success_rate = (success_count / total_operations * 100) if total_operations > 0 else 0 + + details_parts = [ + f"{script_name} completed in {mode} mode", + f"Total operations: {total_operations}", + f"Successful: {success_count}", + f"Failed: {failed_count}", + f"Success rate: {success_rate:.1f}%" + ] + + if total_amount > 0: + details_parts.append(f"Total amount: ${total_amount:,.2f}") + + if batch_ids: + details_parts.append(f"Batch IDs: {', '.join(map(str, batch_ids))}") + + if duration_seconds: + details_parts.append(f"Duration: {duration_seconds:.1f}s") + + if errors: + details_parts.append(f"Errors encountered: {len(errors)}") + if len(errors) <= 3: + details_parts.extend([f"- {error}" for error in errors]) + else: + details_parts.extend([f"- {error}" for error in errors[:3]]) + details_parts.append(f"... and {len(errors) - 3} more errors") + + details = "\\n".join(details_parts) + + action = "SCRIPT_SUCCESS" if failed_count == 0 else "SCRIPT_PARTIAL" if success_count > 0 else "SCRIPT_FAILED" + + return log_activity( + user_id=SYSTEM_USER_ID, + action=action, + entity_type="Script", + details=details + ) + +def log_batch_created(batch_id: int, payment_method: str, customer_count: int) -> Optional[int]: + """ + Log the creation of a payment batch. + + Args: + batch_id (int): ID of the created batch + payment_method (str): Payment method type (Direct Debit, Card, etc.) + customer_count (int): Number of customers in the batch + + Returns: + int: Log entry ID or None if failed + """ + details = f"Payment batch created for {payment_method} with {customer_count} customers" + return log_activity( + user_id=SYSTEM_USER_ID, + action="BATCH_CREATED", + entity_type="PaymentBatch", + entity_id=batch_id, + details=details + ) + +def log_payment_plan_run( + active_plans: int, + due_plans: int, + processed_count: int, + failed_count: int, + total_amount: float +) -> Optional[int]: + """ + Log the results of a payment plan execution. + + Args: + active_plans (int): Total number of active payment plans + due_plans (int): Number of plans due for payment today + processed_count (int): Number of payments successfully processed + failed_count (int): Number of failed payments + total_amount (float): Total amount processed + + Returns: + int: Log entry ID or None if failed + """ + details = ( + f"Payment plan execution: {active_plans} active plans, " + f"{due_plans} due today, {processed_count} successful, " + f"{failed_count} failed, ${total_amount:,.2f} total" + ) + + action = "PAYPLAN_SUCCESS" if failed_count == 0 else "PAYPLAN_PARTIAL" if processed_count > 0 else "PAYPLAN_FAILED" + + return log_activity( + user_id=SYSTEM_USER_ID, + action=action, + entity_type="PaymentPlan", + details=details + ) + +def log_payment_intent_followup( + pending_count: int, + succeeded_count: int, + failed_count: int, + still_pending: int +) -> Optional[int]: + """ + Log the results of payment intent follow-up processing. + + Args: + pending_count (int): Number of payment intents checked + succeeded_count (int): Number that succeeded + failed_count (int): Number that failed + still_pending (int): Number still pending + + Returns: + int: Log entry ID or None if failed + """ + details = ( + f"Payment intent follow-up: {pending_count} intents checked, " + f"{succeeded_count} succeeded, {failed_count} failed, " + f"{still_pending} still pending" + ) + + return log_activity( + user_id=SYSTEM_USER_ID, + action="PAYINTENT_FOLLOWUP", + entity_type="PaymentIntent", + details=details + ) \ No newline at end of file diff --git a/splynx.py b/splynx.py new file mode 100644 index 0000000..6b503e4 --- /dev/null +++ b/splynx.py @@ -0,0 +1,133 @@ +import time +import json +import requests # type: ignore +import hmac +import hashlib + +SPLYNX_URL = 'https://billing.interphone.com.au' +#SPLYNX_KEY = 'c189c78b155ee8e4d389bbcb34bebc05' +#SPLYNX_SECRET = '1454679ddf5c97ea347766709d3ca3bd' +SPLYNX_KEY = 'b4cd90cbea15e7692c940484a9637fc4' +SPLYNX_SECRET = '297ce5c6b7cd5aaf93d8c725fcb49f8f' + +class Splynx(): + def __init__(self, url, key, secret): + self.url = url + self.key = key + self.secret = secret + self.token = None + self.refresh = None + self.refreshtime = None + self.refreshexpire = None + + def _authenticate(self): + nonce = str(round(time.time() * 1000)) + sig = hmac.new(bytes(self.secret, 'UTF-8'),msg=bytes(nonce+self.key, 'UTF-8'), digestmod = hashlib.sha256).hexdigest().upper() + data = { 'auth_type': 'api_key', 'key': self.key, 'nonce': nonce, 'signature': sig} + headers = {'Content-Type': 'application/json'} + ret = requests.post(url=self.url+'/api/2.0/admin/auth/tokens', data=json.dumps(data), headers=headers) + jsonret = json.loads(ret.content) + self.token = jsonret['access_token'] + self.refresh = jsonret['refresh_token'] + self.refreshtime = jsonret['access_token_expiration'] + self.refreshexpire = jsonret['refresh_token_expiration'] + + def _refresh(self): + headers = {'Content-Type': 'application/json', 'Authorization': 'Splynx-EA (access_token={token})'.format(token=self.token)} + ret = requests.get(url=self.url+'/api/2.0/admin/auth/tokens/{refresh}'.format(refresh=self.refresh),headers=headers) + jsonret = json.loads(ret.content) + self.token = jsonret['access_token'] + self.refresh = jsonret['refresh_token'] + self.refreshtime = jsonret['access_token_expiration'] + self.refreshexpire = jsonret['refresh_token_expiration'] + + def getToken(self): + if self.token is None: + self._authenticate() + if self.token is not None and (self.refreshexpire <= int(time.time())): + self._authenticate() + if self.token is not None and (self.refreshtime <= int(time.time())): + self._refresh() + return self.token + + def get(self, url): + headers = {'Content-Type': 'application/json', 'Authorization': 'Splynx-EA (access_token={token})'.format(token=self.getToken())} + ret = requests.get(url=self.url+url, headers=headers) + return json.loads(ret.content) + + def put(self, url, params): + headers = {'Content-Type': 'application/json', 'Authorization': 'Splynx-EA (access_token={token})'.format(token=self.getToken())} + ret = requests.put(url=self.url+url, headers=headers, data=json.dumps(params)) + if ret.status_code == 202: + return True + return False + + def post(self, url, params): + headers = {'Content-Type': 'application/json', 'Authorization': 'Splynx-EA (access_token={token})'.format(token=self.getToken())} + ret = requests.post(url=self.url+url, headers=headers, data=json.dumps(params)) + if ret.status_code == 201: + return json.loads(ret.content) + return False + + def delete(self, url): + headers = {'Content-Type': 'application/json', 'Authorization': 'Splynx-EA (access_token={token})'.format(token=self.getToken())} + ret = requests.delete(url=self.url+url, headers=headers) + if ret.status_code == 204: + return True + return False + + + def ServiceStatus(self, service_login): + try: + s1 = self.get(url=f"/api/2.0/admin/customers/customer/0/internet-services?main_attributes[login]={service_login}") + + if not s1: + return { 'status': 'no service found', 'customer_name': 'none', 'customer_id': 'none' } + + service_status = s1[-1].get('status') + + if service_status == "active": + s2 = self.get(url="/api/2.0/admin/customers/customers-online") + + if s2: + online_services = [d for d in s2 if str(service_login) in d.values()] + #print(f"online_services: {json.dumps(online_services,indent=2)}") + if online_services: + detail = online_services[0] + cust = self.Customer(detail['customer_id']) + detail['status'] = "Online" + detail['customer_name'] = cust['name'] + return detail + else: + cust = self.Customer(s1[-1].get('customer_id')) + return { 'status': 'Offline', 'customer_name': cust['name'], 'customer_id': cust['id'] } + else: + # No online customers data available + return { 'status': 'Offline' } + else: + # Service exists but is not active (could be suspended, terminated, etc.) + cust = self.Customer(s1[-1].get('customer_id')) + return { 'status': service_status.capitalize(), 'customer_name': cust['name'], 'customer_id': cust['id'] } + + except Exception as e: + print(f"Error checking service status for {service_login}: {str(e)}") + return { 'status': 'no service found', 'customer_name': 'none', 'customer_id': 'none' } + + def Customer(self, customer_id): + try: + result = self.get(url=f"/api/2.0/admin/customers/customer/{customer_id}/") + #print(json.dumps(result,indent=2)) + return result + except: + return 'unknown' + + def GetInternetTariffs(self, tariff_id=None): + try: + if tariff_id: + tariffs = self.get(url=f"/api/2.0/admin/tariffs/internet/{tariff_id}") + else: + tariffs = self.get(url=f"/api/2.0/admin/tariffs/internet") + return tariffs + except Exception as e: + print(f"Error getting Internet Tariffs: {str(e)}") + return { 'status': 'no Internet Tariff found'} \ No newline at end of file diff --git a/static/css/custom.css b/static/css/custom.css new file mode 100644 index 0000000..e8174cc --- /dev/null +++ b/static/css/custom.css @@ -0,0 +1,332 @@ +/* Custom CSS for Plutus - God of Wealth Theme */ + +/* Plutus-inspired theme colors extracted from the god image */ +:root { + --plutus-gold: #d4af37; + --plutus-rich-gold: #b8860b; + --plutus-amber: #ffbf00; + --plutus-bronze: #cd7f32; + --plutus-dark-bronze: #8b4513; + --plutus-charcoal: #2c2c2c; + --plutus-deep-navy: #1a1a2e; + --plutus-warm-white: #faf8f0; + --plutus-cream: #f5e6d3; + --plutus-success: #228b22; + --plutus-warning: #ff8c00; + --plutus-danger: #dc143c; +} + +/* Custom navbar styling with Plutus theme */ +.navbar.is-dark { + background: linear-gradient(135deg, var(--plutus-deep-navy) 0%, var(--plutus-charcoal) 100%); + border-bottom: 2px solid var(--plutus-gold); + box-shadow: 0 2px 10px rgba(212, 175, 55, 0.3); +} + +.navbar-brand .navbar-item { + font-weight: 700; + font-size: 1.2rem; + color: var(--plutus-gold) !important; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} + +.navbar-item { + color: var(--plutus-warm-white) !important; + transition: color 0.3s ease, background-color 0.3s ease; +} + +.navbar-item:hover { + color: var(--plutus-amber) !important; + background-color: rgba(212, 175, 55, 0.1) !important; +} + +.navbar-link { + color: var(--plutus-warm-white) !important; +} + +.navbar-link:hover { + color: var(--plutus-amber) !important; + background-color: rgba(212, 175, 55, 0.1) !important; +} + +/* Navbar dropdown styling */ +.navbar-dropdown { + background-color: var(--plutus-deep-navy) !important; + border-color: var(--plutus-gold) !important; + box-shadow: 0 8px 16px rgba(212, 175, 55, 0.2) !important; +} + +.navbar-dropdown .navbar-item { + color: var(--plutus-warm-white) !important; + background-color: transparent !important; +} + +.navbar-dropdown .navbar-item:hover { + color: var(--plutus-amber) !important; + background-color: rgba(212, 175, 55, 0.1) !important; +} + +/* Hero section customization */ +.hero.is-primary { + background: linear-gradient(135deg, var(--plutus-gold) 0%, var(--plutus-amber) 100%); + color: var(--plutus-charcoal); +} + +/* Content boxes with Plutus theme */ +.box { + background-color: rgba(250, 248, 240, 0.95); + border: 1px solid rgba(212, 175, 55, 0.3); + box-shadow: 0 0.5em 1em -0.125em rgba(212, 175, 55, 0.2), 0 0px 0 1px rgba(212, 175, 55, 0.1); + backdrop-filter: blur(5px); +} + +.box:hover { + box-shadow: 0 0.5em 1.5em -0.125em rgba(212, 175, 55, 0.3), 0 0px 0 1px rgba(212, 175, 55, 0.2); + border-color: rgba(212, 175, 55, 0.5); + transition: all 0.3s ease; +} + +/* Button styling with Plutus theme */ +.button.is-primary { + background-color: var(--plutus-gold); + border-color: var(--plutus-rich-gold); + color: var(--plutus-charcoal); + font-weight: 600; +} + +.button.is-primary:hover { + background-color: var(--plutus-amber); + border-color: var(--plutus-gold); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(212, 175, 55, 0.4); +} + +.button:hover { + transform: translateY(-1px); + transition: all 0.2s ease; +} + +/* Table enhancements with Plutus theme */ +.table-container { + overflow-x: auto; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(212, 175, 55, 0.2); + border: 1px solid rgba(212, 175, 55, 0.3); +} + +.table { + background-color: rgba(250, 248, 240, 0.95); + backdrop-filter: blur(5px); +} + +.table th { + background-color: var(--plutus-gold); + color: var(--plutus-charcoal); + font-weight: 700; + border-color: var(--plutus-rich-gold); +} + +.table td { + border-color: rgba(212, 175, 55, 0.2); +} + +.table tr:hover { + background-color: rgba(212, 175, 55, 0.1); +} + +/* Footer styling */ +.footer { + background: linear-gradient(135deg, var(--plutus-deep-navy) 0%, var(--plutus-charcoal) 100%); + color: var(--plutus-warm-white); + padding: 2rem 1.5rem; + margin-top: 2rem; + border-top: 2px solid var(--plutus-gold); +} + +/* Notification improvements with theme */ +.notification { + border-radius: 8px; + backdrop-filter: blur(5px); + border: 1px solid rgba(212, 175, 55, 0.3); +} + +.notification.is-success { + background-color: rgba(34, 139, 34, 0.9); + color: var(--plutus-warm-white); +} + +.notification.is-danger { + background-color: rgba(220, 20, 60, 0.9); + color: var(--plutus-warm-white); +} + +.notification.is-info { + background-color: rgba(212, 175, 55, 0.9); + color: var(--plutus-charcoal); +} + +/* Form styling with Plutus theme */ +.field .control .input:focus, +.field .control .textarea:focus { + border-color: var(--plutus-gold); + box-shadow: 0 0 0 0.125em rgba(212, 175, 55, 0.25); +} + +.field .control .input, +.field .control .textarea, +.field .control .select select { + background-color: rgba(250, 248, 240, 0.95); + border-color: rgba(212, 175, 55, 0.4); +} + +/* Tags with Plutus theme */ +.tag.is-success { + background-color: var(--plutus-success); + color: var(--plutus-warm-white); +} + +.tag.is-warning { + background-color: var(--plutus-warning); + color: var(--plutus-charcoal); +} + +.tag.is-danger { + background-color: var(--plutus-danger); + color: var(--plutus-warm-white); +} + +.tag.is-info { + background-color: var(--plutus-gold); + color: var(--plutus-charcoal); +} + +/* Plutus Background Implementation */ +body { + background-image: url('../images/plutus3.JPG'); + background-size: cover; + background-position: center; + background-attachment: fixed; + background-repeat: no-repeat; + position: relative; +} + +/* Dark overlay for better content readability */ +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 135deg, + rgba(26, 26, 46, 0.85) 0%, + rgba(44, 44, 44, 0.75) 50%, + rgba(26, 26, 46, 0.85) 100% + ); + z-index: -1; + pointer-events: none; +} + +/* Content area styling for better readability over background */ +main.section { + position: relative; + z-index: 1; +} + +.container { + max-width: 1488px !important; /* 20% wider than Bulma's default 1240px */ + position: relative; + z-index: 2; +} + +/* Title styling with Plutus theme */ +.title { + color: var(--plutus-gold); + font-weight: 700; +} + +.subtitle { + color: var(--plutus-charcoal); +} + +/* Breadcrumb styling */ +.breadcrumb { + background-color: rgba(250, 248, 240, 0.9); + border-radius: 6px; + border: 1px solid rgba(212, 175, 55, 0.3); + padding: 0.75rem 1rem; + backdrop-filter: blur(5px); +} + +.breadcrumb a { + color: var(--plutus-bronze); +} + +.breadcrumb a:hover { + color: var(--plutus-gold); +} + +.breadcrumb .is-active a { + color: var(--plutus-charcoal); +} + +/* Level component styling */ +.level { + background-color: rgba(250, 248, 240, 0.85); + border-radius: 6px; + padding: 1rem; + margin-bottom: 1.5rem; + border: 1px solid rgba(212, 175, 55, 0.3); + backdrop-filter: blur(5px); +} + +/* Modal styling with Plutus theme */ +.modal-card-head { + background-color: var(--plutus-gold); + color: var(--plutus-charcoal); +} + +.modal-card-body { + background-color: var(--plutus-warm-white); +} + +.modal-card-foot { + background-color: var(--plutus-cream); +} + +/* Code blocks styling */ +pre { + background-color: var(--plutus-charcoal) !important; + color: var(--plutus-cream) !important; + border: 1px solid var(--plutus-gold); +} + +code { + background-color: rgba(44, 44, 44, 0.9) !important; + color: var(--plutus-amber) !important; + padding: 0.2em 0.4em; + border-radius: 3px; +} + +/* Dashboard-specific styling - remove background image */ +.dashboard-page body { + background-image: none !important; + background-color: var(--plutus-warm-white); +} + +.dashboard-page body::before { + display: none !important; +} + +/* Plutus image styling for dashboard */ +.plutus-image { + width: 100%; + height: auto; + max-width: 1000px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(212, 175, 55, 0.4); + border: 3px solid var(--plutus-gold); + margin: 2rem auto; + display: block; +} \ No newline at end of file diff --git a/static/images/plutus3.JPG b/static/images/plutus3.JPG new file mode 100644 index 0000000..a7b5a7a Binary files /dev/null and b/static/images/plutus3.JPG differ diff --git a/stripe_payment_processor.py b/stripe_payment_processor.py new file mode 100644 index 0000000..8415499 --- /dev/null +++ b/stripe_payment_processor.py @@ -0,0 +1,1041 @@ +#!/usr/bin/env python3 +""" +Stripe Payment Processor Class +A clean, reusable class for processing single Stripe payments with comprehensive error handling and fee calculations. +""" + +import stripe +from stripe import StripeError +import os +import logging +import time +import json +from datetime import datetime +from decimal import Decimal +from typing import Dict, Any, Optional + + +class StripePaymentProcessor: + """ + A clean, focused class for processing individual Stripe payments. + Returns comprehensive JSON results for each payment attempt. + """ + + def __init__(self, api_key: Optional[str] = None, enable_logging: bool = False, + log_level: int = logging.INFO): + """ + Initialize the Stripe payment processor. + + Args: + api_key (str, optional): Stripe API key. If None, will use STRIPE_SECRET_KEY env var + enable_logging (bool): Whether to enable console logging. Defaults to False + log_level (int): Logging level if logging is enabled + """ + # Set up Stripe API key + if api_key: + stripe.api_key = api_key + else: + stripe.api_key = os.getenv('STRIPE_SECRET_KEY') + + if not stripe.api_key: + raise ValueError("Stripe API key is required. Provide via api_key parameter or STRIPE_SECRET_KEY environment variable.") + + # Validate API key format + if not (stripe.api_key.startswith('sk_test_') or stripe.api_key.startswith('sk_live_')): + raise ValueError("Invalid Stripe API key format. Key should start with 'sk_test_' or 'sk_live_'") + + self.is_test_mode = stripe.api_key.startswith('sk_test_') + + # Set up optional logging + self.enable_logging = enable_logging + if enable_logging: + logging.basicConfig(level=log_level, format='%(asctime)s - %(levelname)s - %(message)s') + self.logger = logging.getLogger(__name__) + self.logger.info(f"StripePaymentProcessor initialized ({'TEST' if self.is_test_mode else 'LIVE'} mode)") + else: + self.logger = None + + def _log(self, level: str, message: str): + """Internal logging method""" + if self.logger: + getattr(self.logger, level.lower())(message) + + def extract_actual_fees(self, balance_transaction: Any) -> Dict[str, Any]: + """ + Extract actual fee details from Stripe's balance transaction. + This provides real fee data instead of estimates. + + Args: + balance_transaction: Stripe balance transaction object + + Returns: + dict: Actual fee details from Stripe + """ + if not balance_transaction or not hasattr(balance_transaction, 'fee_details'): + return {'note': 'No fee details available'} + + fee_breakdown = [] + total_fee = getattr(balance_transaction, 'fee', 0) / 100 # Convert from cents + + for fee_detail in balance_transaction.fee_details: + fee_breakdown.append({ + 'type': getattr(fee_detail, 'type', 'unknown'), + 'description': getattr(fee_detail, 'description', 'Unknown fee'), + 'amount': getattr(fee_detail, 'amount', 0) / 100, # Convert from cents + 'currency': getattr(fee_detail, 'currency', 'unknown') + }) + + return { + 'source': 'stripe_actual', + 'total_fee': total_fee, + 'net_amount': getattr(balance_transaction, 'net', 0) / 100, # Convert from cents + 'fee_breakdown': fee_breakdown, + 'available_on': getattr(balance_transaction, 'available_on', None), + 'note': 'Actual fees from Stripe balance transaction' + } + + def calculate_stripe_fees(self, amount: float, payment_method_type: str, + payment_method_details: Optional[Any] = None, + transaction_successful: bool = True) -> Dict[str, Any]: + """ + DEPRECATED: Calculate estimated Stripe fees based on payment method type. + Use extract_actual_fees() with balance transaction for real fee data. + + This method provides fee estimates and is kept for fallback scenarios + where actual fee data is not available from Stripe. + + Args: + amount (float): Transaction amount in dollars + payment_method_type (str): Type of payment method ('card', 'au_becs_debit', etc.) + payment_method_details: Stripe PaymentMethod object with additional details + transaction_successful (bool): Whether transaction succeeded (affects BECS caps) + + Returns: + dict: Estimated fee calculation information + """ + fee_info = { + 'payment_method_type': payment_method_type, + 'percentage_fee': 0.0, + 'fixed_fee': 0.0, + 'total_fee': 0.0, + 'fee_description': 'Unknown payment method', + 'capped': False, + 'cap_amount': None, + 'international': False + } + + if payment_method_type == 'card': + # Default to domestic card rates + fee_info['percentage_fee'] = 1.7 + fee_info['fixed_fee'] = 0.30 + fee_info['fee_description'] = 'Domestic credit/debit card' + + # Check if it's an international card + if payment_method_details and hasattr(payment_method_details, 'card') and payment_method_details.card: + card_country = payment_method_details.card.country + self._log('info', f"Card country detected: {card_country}") + fee_info['card_brand'] = payment_method_details.get('card').get('brand') + fee_info['card_display_brand'] = payment_method_details.get('card').get('display_brand') + + if card_country and card_country != 'AU': + fee_info['percentage_fee'] = 3.5 + fee_info['fixed_fee'] = 0.30 + fee_info['fee_description'] = f'International credit/debit card ({card_country})' + fee_info['international'] = True + else: + self._log('info', f"Domestic card confirmed (country: {card_country})") + else: + # If we can't determine country, assume domestic for AU-based business + self._log('info', "Card country not available - assuming domestic") + + elif payment_method_type == 'au_becs_debit': + fee_info['percentage_fee'] = 1.0 + fee_info['fixed_fee'] = 0.30 + fee_info['fee_description'] = 'Australia BECS Direct Debit' + + # Apply BECS caps based on transaction outcome + if transaction_successful: + fee_info['cap_amount'] = 3.50 + fee_info['fee_description'] += ' (capped at $3.50)' + else: + fee_info['cap_amount'] = 2.50 + fee_info['fee_description'] += ' (failure/dispute - capped at $2.50)' + + # Calculate total fee + percentage_amount = amount * (fee_info['percentage_fee'] / 100) + calculated_fee = percentage_amount + fee_info['fixed_fee'] + + # Apply cap if applicable + if fee_info['cap_amount'] and calculated_fee > fee_info['cap_amount']: + fee_info['total_fee'] = fee_info['cap_amount'] + fee_info['capped'] = True + else: + fee_info['total_fee'] = round(calculated_fee, 2) + + return fee_info + + def process_payment(self, customer_id: str, amount: float, currency: str = 'aud', + description: Optional[str] = None, wait_for_completion: bool = True, + stripe_pm: Optional[str] = None) -> Dict[str, Any]: + """ + Process a single payment for a customer using their default payment method. + + Args: + customer_id (str): Stripe customer ID + amount (float): Amount in dollars (will be converted to cents internally) + currency (str): Currency code (default: 'aud') + description (str, optional): Payment description + wait_for_completion (bool): If True, will poll for 'processing' payments to complete (default: True) + + Returns: + dict: Comprehensive payment result with success status and details + """ + transaction_start = datetime.now() + + # Base response structure + response = { + 'success': False, + 'timestamp': transaction_start.isoformat(), + 'customer_id': customer_id, + 'amount': amount, + 'currency': currency.lower(), + 'description': description, + 'processing_time_seconds': 0.0, + 'test_mode': self.is_test_mode + } + + try: + # Validate inputs + if not customer_id or not isinstance(customer_id, str): + response['error'] = 'Invalid customer_id provided' + response['error_type'] = 'validation_error' + return response + + if amount <= 0: + response['error'] = 'Amount must be greater than 0' + response['error_type'] = 'validation_error' + return response + + # Convert dollars to cents + amount_cents = int(Decimal(str(amount)) * 100) + + self._log('info', f"Processing payment: {customer_id}, ${amount} {currency.upper()}") + + # Retrieve customer + customer = stripe.Customer.retrieve(customer_id) + print(f"customer: {json.dumps(customer,indent=2)}") + + if not customer: + response['error'] = f'Customer {customer_id} not found' + response['error_type'] = 'customer_not_found' + return response + + # Add customer details to response + response.update({ + 'customer_email': customer.email, + 'customer_name': customer.description or customer.name + }) + + if stripe_pm: + default_payment_method = stripe_pm + else: + # Get default payment method + default_payment_method = customer.invoice_settings.default_payment_method + + if not default_payment_method: + response['error'] = 'Customer has no default payment method set' + response['error_type'] = 'no_payment_method' + return response + + # Retrieve payment method details + payment_method = stripe.PaymentMethod.retrieve(default_payment_method) + payment_method_type = payment_method.type + + print(f"payment_method: {json.dumps(payment_method,indent=2)}") + + + response.update({ + 'payment_method_id': default_payment_method, + 'payment_method_type': payment_method_type + }) + + # Calculate estimated fees before payment + estimated_fee_details = self.calculate_stripe_fees( + amount, + payment_method_type, + payment_method, + transaction_successful=True # Will be updated if payment fails + ) + estimated_fee_details['source'] = 'estimated' + estimated_fee_details['note'] = 'Pre-payment estimate' + + response['estimated_fee_details'] = estimated_fee_details + + self._log('info', f"Payment method: {payment_method_type} - {estimated_fee_details['fee_description']}") + + # Prepare Payment Intent parameters + payment_intent_params = { + 'amount': amount_cents, + 'currency': currency, + 'customer': customer_id, + 'payment_method': default_payment_method, + 'description': description or f"Payment for {customer.description or customer.email}", + 'confirm': True, + 'return_url': 'https://your-website.com/payment-success', + 'off_session': True + } + + # Add mandate data for BECS Direct Debit + if payment_method_type == 'au_becs_debit': + payment_intent_params['mandate_data'] = { + 'customer_acceptance': { + 'type': 'offline' + } + } + self._log('info', "Added BECS mandate data for offline acceptance") + + # Create and confirm Payment Intent + payment_intent = stripe.PaymentIntent.create(**payment_intent_params) + + # Add payment intent details + response.update({ + 'payment_intent_id': payment_intent.id, + 'status': payment_intent.status + }) + + if payment_intent.status == 'succeeded': + response['success'] = True + self._log('info', f"✅ Payment successful: {payment_intent.id}") + + # Get actual fee details for successful payments + try: + # Re-retrieve with expanded balance transaction to get actual fees + time.sleep(3) + payment_intent_expanded = stripe.PaymentIntent.retrieve( + payment_intent.id, + expand=['latest_charge.balance_transaction'] + ) + + if (hasattr(payment_intent_expanded, 'latest_charge') and + payment_intent_expanded.latest_charge and + hasattr(payment_intent_expanded.latest_charge, 'balance_transaction') and + payment_intent_expanded.latest_charge.balance_transaction): + + balance_transaction = payment_intent_expanded.latest_charge.balance_transaction + actual_fees = self.extract_actual_fees(balance_transaction) + response['fee_details'] = actual_fees + self._log('info', f"Retrieved actual fees: ${actual_fees['total_fee']:.2f}") + else: + # Keep estimated fees if balance transaction not available + response['fee_details'] = estimated_fee_details + response['fee_details']['note'] = 'Balance transaction not yet available, showing estimate' + # Record this payment for later fee update + response['needs_fee_update'] = [customer_id, payment_intent.id] + self._log('info', f"Balance transaction not available, using estimates - marked for later update") + + except Exception as e: + # If we can't get actual fees, keep the estimates and mark for later + response['fee_details'] = estimated_fee_details + response['fee_details']['note'] = f'Could not retrieve actual fees: {str(e)}' + response['needs_fee_update'] = [customer_id, payment_intent.id] + 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...") + + # 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) + + if polling_result['success']: + # Update our response with the final polling result + response.update(polling_result) + # The polling result already has all the details we need + + if polling_result['status'] == 'succeeded': + response['success'] = True + self._log('info', f"✅ Payment completed successfully after polling") + else: + response['success'] = False + response['error'] = f'Payment completed with status: {polling_result["status"]}' + response['error_type'] = 'payment_not_succeeded' + else: + # Polling failed - update with polling error info + response.update(polling_result) + response['success'] = False + if 'error' not in response: + response['error'] = 'Payment polling failed' + response['error_type'] = 'polling_failed' + else: + # For failed payments or processing without polling + if payment_method_type == 'au_becs_debit': + # Recalculate BECS fees with failure cap for failed payments + failed_fee_details = self.calculate_stripe_fees( + amount, + payment_method_type, + payment_method, + transaction_successful=False + ) + failed_fee_details['source'] = 'estimated' + failed_fee_details['note'] = 'Estimated fees for failed BECS payment' + response['fee_details'] = failed_fee_details + else: + # Use estimated fees for other payment types + response['fee_details'] = estimated_fee_details + + if payment_intent.status == 'processing': + response['error'] = f'Payment is processing (polling disabled). Check status later.' + response['error_type'] = 'payment_processing' + response['next_action'] = f'Use check_payment_intent("{payment_intent.id}") or wait_for_payment_completion("{payment_intent.id}") to check status' + 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}") + + # Calculate processing time + processing_time = (datetime.now() - transaction_start).total_seconds() + response['processing_time_seconds'] = round(processing_time, 2) + response['pi_status'] = payment_intent.status + return response + + 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)) + response.update({ + '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}") + return response + + except stripe.InvalidRequestError as e: + # Invalid parameters + processing_time = (datetime.now() - transaction_start).total_seconds() + response.update({ + 'error': f'Invalid request: {str(e)}', + 'error_type': 'invalid_request', + 'processing_time_seconds': round(processing_time, 2) + }) + self._log('error', f"❌ Invalid request for {customer_id}: {str(e)}") + return response + + except stripe.AuthenticationError as e: + # Authentication with Stripe failed + processing_time = (datetime.now() - transaction_start).total_seconds() + response.update({ + 'error': f'Authentication failed: {str(e)}', + 'error_type': 'authentication_error', + 'processing_time_seconds': round(processing_time, 2) + }) + self._log('error', f"❌ Authentication failed: {str(e)}") + return response + + except stripe.APIConnectionError as e: + # Network communication with Stripe failed + processing_time = (datetime.now() - transaction_start).total_seconds() + response.update({ + 'error': f'Network error: {str(e)}', + 'error_type': 'network_error', + 'processing_time_seconds': round(processing_time, 2) + }) + self._log('error', f"❌ Network error: {str(e)}") + return response + + except stripe.StripeError as e: + # Other Stripe-specific errors + processing_time = (datetime.now() - transaction_start).total_seconds() + response.update({ + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'processing_time_seconds': round(processing_time, 2) + }) + self._log('error', f"❌ Stripe error: {str(e)}") + return response + + except Exception as e: + # Unexpected errors + processing_time = (datetime.now() - transaction_start).total_seconds() + response.update({ + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'processing_time_seconds': round(processing_time, 2) + }) + self._log('error', f"❌ Unexpected error for {customer_id}: {str(e)}") + return response + + def get_customer_info(self, customer_id: str) -> Dict[str, Any]: + """ + Retrieve customer information including payment methods. + + Args: + customer_id (str): Stripe customer ID + + Returns: + dict: Customer information and payment method details + """ + try: + customer = stripe.Customer.retrieve(customer_id) + + customer_info = { + 'success': True, + 'customer_id': customer.id, + 'email': customer.email, + 'name': customer.description or customer.name, + 'created': customer.created, + 'default_payment_method': customer.invoice_settings.default_payment_method if customer.invoice_settings else None, + 'payment_methods': [] + } + + # Get payment methods + payment_methods = stripe.PaymentMethod.list( + customer=customer_id, + limit=10 + ) + + for pm in payment_methods.data: + pm_info = { + 'id': pm.id, + 'type': pm.type, + 'created': pm.created + } + + if pm.card: + pm_info['card'] = { + 'brand': pm.card.brand, + 'last4': pm.card.last4, + 'country': pm.card.country, + 'exp_month': pm.card.exp_month, + 'exp_year': pm.card.exp_year + } + elif pm.au_becs_debit: + pm_info['au_becs_debit'] = { + 'bsb_number': pm.au_becs_debit.bsb_number, + 'last4': pm.au_becs_debit.last4 + } + + customer_info['payment_methods'].append(pm_info) + + return customer_info + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error' + } + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error' + } + + def get_payment_methods(self, customer_id: str) -> list: + """ + Get all payment methods for a Stripe customer. + + Args: + customer_id (str): Stripe customer ID + + Returns: + list: List of payment methods with details + """ + try: + self._log('info', f"Retrieving payment methods for customer: {customer_id}") + + # Get payment methods for the customer + payment_methods = stripe.PaymentMethod.list( + customer=customer_id, + limit=10 + ) + + methods_list = [] + + for pm in payment_methods.data: + pm_info = { + 'id': pm.id, + 'type': pm.type, + 'created': pm.created + } + + if pm.card: + pm_info['card'] = { + 'brand': pm.card.brand, + 'last4': pm.card.last4, + 'country': pm.card.country, + 'exp_month': pm.card.exp_month, + 'exp_year': pm.card.exp_year + } + elif pm.au_becs_debit: + pm_info['au_becs_debit'] = { + 'bsb_number': pm.au_becs_debit.bsb_number, + 'last4': pm.au_becs_debit.last4 + } + + methods_list.append(pm_info) + + self._log('info', f"Found {len(methods_list)} payment methods") + return methods_list + + except stripe.StripeError as e: + self._log('error', f"Stripe error retrieving payment methods: {str(e)}") + return [] + except Exception as e: + self._log('error', f"Unexpected error retrieving payment methods: {str(e)}") + return [] + + def check_payment_intent(self, payment_intent_id: str) -> Dict[str, Any]: + """ + Check the status and details of a specific payment intent. + + Args: + payment_intent_id (str): Stripe Payment Intent ID (e.g., 'pi_1234567890') + + Returns: + dict: Payment intent status and comprehensive details + """ + try: + # Validate input + if not payment_intent_id or not isinstance(payment_intent_id, str): + return { + 'success': False, + 'error': 'Invalid payment_intent_id provided', + 'error_type': 'validation_error', + 'timestamp': datetime.now().isoformat() + } + + if not payment_intent_id.startswith('pi_'): + return { + 'success': False, + 'error': 'Payment Intent ID must start with "pi_"', + 'error_type': 'validation_error', + 'timestamp': datetime.now().isoformat() + } + + self._log('info', f"Checking payment intent: {payment_intent_id}") + + # Retrieve the payment intent with expanded balance transaction for fee details + payment_intent = stripe.PaymentIntent.retrieve( + payment_intent_id, + expand=['latest_charge.balance_transaction'] + ) + + self._log('info', f"Retrieved payment intent with expanded data") + + # Base response + response = { + 'success': True, + 'payment_intent_id': payment_intent.id, + 'status': payment_intent.status, + 'amount': payment_intent.amount / 100, # Convert from cents to dollars + 'currency': payment_intent.currency, + 'created': datetime.fromtimestamp(payment_intent.created).isoformat(), + 'description': payment_intent.description, + 'customer_id': payment_intent.customer, + 'payment_method_id': payment_intent.payment_method, + 'test_mode': self.is_test_mode, + 'timestamp': datetime.now().isoformat() + } + + # Add status-specific information + if payment_intent.status == 'succeeded': + response.update({ + 'success_date': datetime.fromtimestamp(payment_intent.created).isoformat() + }) + + # Add receipt URL if available + if hasattr(payment_intent, 'charges') and payment_intent.charges and payment_intent.charges.data: + first_charge = payment_intent.charges.data[0] + response['receipt_url'] = getattr(first_charge, 'receipt_url', None) + + # Get actual fee details from balance transaction if available + if (hasattr(payment_intent, 'latest_charge') and + payment_intent.latest_charge and + hasattr(payment_intent.latest_charge, 'balance_transaction') and + payment_intent.latest_charge.balance_transaction): + + # Use actual fee data from Stripe + balance_transaction = payment_intent.latest_charge.balance_transaction + actual_fees = self.extract_actual_fees(balance_transaction) + response['fee_details'] = actual_fees + self._log('info', f"Using actual fee data: ${actual_fees['total_fee']:.2f}") + + elif payment_intent.payment_method: + # Fallback to calculated fees if balance transaction not available + try: + payment_method = stripe.PaymentMethod.retrieve(payment_intent.payment_method) + estimated_fees = self.calculate_stripe_fees( + response['amount'], + payment_method.type, + payment_method, + transaction_successful=True + ) + estimated_fees['source'] = 'estimated' + estimated_fees['note'] = 'Estimated fees - actual fees not yet available' + response['fee_details'] = estimated_fees + self._log('info', f"Using estimated fee data: ${estimated_fees['total_fee']:.2f}") + except Exception as e: + # If we can't get payment method details, just note it + response['fee_details'] = { + 'note': 'Fee details unavailable - payment method not accessible', + 'error': str(e) + } + self._log('warning', f"Could not retrieve fee details: {str(e)}") + else: + response['fee_details'] = {'note': 'No payment method associated with this payment intent'} + + elif payment_intent.status == 'requires_payment_method': + response['next_action'] = 'Payment method required' + + elif payment_intent.status == 'requires_confirmation': + response['next_action'] = 'Payment requires confirmation' + + elif payment_intent.status == 'requires_action': + response['next_action'] = 'Additional action required (e.g., 3D Secure)' + if payment_intent.next_action: + response['next_action_details'] = { + 'type': payment_intent.next_action.type if hasattr(payment_intent.next_action, 'type') else 'unknown' + } + + elif payment_intent.status == 'processing': + response['next_action'] = 'Payment is being processed' + + if payment_intent.status in ['canceled', 'failed', 'requires_payment_method']: + response['success'] = False + response['failure_reason'] = 'Payment was canceled or failed' + + # Get failure details if available + if payment_intent.last_payment_error: + error = payment_intent.last_payment_error + response['failure_details'] = { + 'code': error.code, + 'message': error.message, + 'type': error.type, + 'decline_code': getattr(error, 'decline_code', None) + } + + # Add charges information if available + if hasattr(payment_intent, 'charges') and payment_intent.charges and payment_intent.charges.data: + charge = payment_intent.charges.data[0] + response['charge_details'] = { + 'charge_id': charge.id, + 'paid': getattr(charge, 'paid', False), + 'refunded': getattr(charge, 'refunded', False), + 'amount_refunded': getattr(charge, 'amount_refunded', 0) / 100, # Convert to dollars + 'failure_code': getattr(charge, 'failure_code', None), + 'failure_message': getattr(charge, 'failure_message', None) + } + + # Add outcome information if available + if hasattr(charge, 'outcome') and charge.outcome: + response['charge_details']['outcome'] = { + 'network_status': getattr(charge.outcome, 'network_status', None), + 'reason': getattr(charge.outcome, 'reason', None), + 'seller_message': getattr(charge.outcome, 'seller_message', None), + 'type': getattr(charge.outcome, 'type', None) + } + + # Add payment method details from charge if available + if hasattr(charge, 'payment_method_details') and charge.payment_method_details: + pm_details = charge.payment_method_details + response['payment_method_details'] = { + 'type': getattr(pm_details, 'type', 'unknown') + } + + if hasattr(pm_details, 'card') and pm_details.card: + response['payment_method_details']['card'] = { + 'brand': getattr(pm_details.card, 'brand', None), + 'country': getattr(pm_details.card, 'country', None), + 'last4': getattr(pm_details.card, 'last4', None), + 'funding': getattr(pm_details.card, 'funding', None) + } + elif hasattr(pm_details, 'au_becs_debit') and pm_details.au_becs_debit: + response['payment_method_details']['au_becs_debit'] = { + 'bsb_number': getattr(pm_details.au_becs_debit, 'bsb_number', None), + 'last4': getattr(pm_details.au_becs_debit, 'last4', None) + } + + self._log('info', f"Payment intent {payment_intent_id} status: {payment_intent.status}") + return response + + except stripe.InvalidRequestError as e: + return { + 'success': False, + 'error': f'Invalid request: {str(e)}', + 'error_type': 'invalid_request', + 'payment_intent_id': payment_intent_id, + 'timestamp': datetime.now().isoformat() + } + + except stripe.PermissionError as e: + return { + 'success': False, + 'error': f'Permission denied: {str(e)}', + 'error_type': 'permission_error', + 'payment_intent_id': payment_intent_id, + 'timestamp': datetime.now().isoformat() + } + + except stripe.StripeError as e: + return { + 'success': False, + 'error': f'Stripe error: {str(e)}', + 'error_type': 'stripe_error', + 'payment_intent_id': payment_intent_id, + 'timestamp': datetime.now().isoformat() + } + + except Exception as e: + return { + 'success': False, + 'error': f'Unexpected error: {str(e)}', + 'error_type': 'unexpected_error', + 'payment_intent_id': payment_intent_id, + 'timestamp': datetime.now().isoformat() + } + + def wait_for_payment_completion(self, payment_intent_id: str, max_wait_seconds: int = 60, + check_interval: int = 5, customer_id: Optional[str] = None) -> Dict[str, Any]: + """ + Poll a payment intent until it completes or times out. + Useful for payments that start with 'processing' status. + + Args: + payment_intent_id (str): Stripe Payment Intent ID + max_wait_seconds (int): Maximum time to wait in seconds (default: 60) + check_interval (int): How often to check in seconds (default: 5) + customer_id (str, optional): Customer ID to include in needs_fee_update tracking + + Returns: + dict: Final payment intent status with polling metadata + """ + start_time = datetime.now() + attempts = 0 + max_attempts = max_wait_seconds // check_interval + + self._log('info', f"Starting payment polling for {payment_intent_id} (max {max_wait_seconds}s, every {check_interval}s)") + + # Check initial status + result = self.check_payment_intent(payment_intent_id) + + if not result['success']: + # If we can't even check the payment, return the error + return result + + initial_status = result['status'] + self._log('info', f"Initial payment status: {initial_status}") + + # If payment is already in a final state, return immediately + final_statuses = ['succeeded', 'failed', 'canceled'] + if initial_status in final_statuses: + result['polling_info'] = { + 'polling_needed': False, + 'initial_status': initial_status, + 'final_status': initial_status, + 'total_wait_time_seconds': 0, + 'attempts': 1 + } + return result + + # Start polling for non-final statuses + polling_statuses = ['processing', 'requires_action', 'requires_confirmation'] + + if initial_status not in polling_statuses: + # Status doesn't require polling + result['polling_info'] = { + 'polling_needed': False, + 'initial_status': initial_status, + 'final_status': initial_status, + 'total_wait_time_seconds': 0, + 'attempts': 1, + 'note': f'Status "{initial_status}" does not require polling' + } + return result + + # Polling loop + while attempts < max_attempts: + attempts += 1 + + # Wait before checking (except for first attempt which we already did) + if attempts > 1: + self._log('info', f"Waiting {check_interval} seconds before attempt {attempts}...") + time.sleep(check_interval) + + # Check current status + current_result = self.check_payment_intent(payment_intent_id) + + if not current_result['success']: + # Error occurred during polling + elapsed_time = (datetime.now() - start_time).total_seconds() + current_result['polling_info'] = { + 'polling_needed': True, + 'initial_status': initial_status, + 'final_status': 'error', + 'total_wait_time_seconds': round(elapsed_time, 2), + 'attempts': attempts, + 'polling_error': 'Failed to check payment status during polling' + } + return current_result + + current_status = current_result['status'] + elapsed_time = (datetime.now() - start_time).total_seconds() + + self._log('info', f"Attempt {attempts}: Status = {current_status} (elapsed: {elapsed_time:.1f}s)") + + # Check if we've reached a final status + if current_status in final_statuses: + # Payment completed (success or failure) + current_result['polling_info'] = { + 'polling_needed': True, + 'initial_status': initial_status, + 'final_status': current_status, + 'total_wait_time_seconds': round(elapsed_time, 2), + 'attempts': attempts, + 'completed': True + } + + if current_status == 'succeeded': + self._log('info', f"✅ 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") + current_result['pi_status'] = current_status + return current_result + + # Check if status changed to something that doesn't need polling + if current_status not in polling_statuses: + current_result['polling_info'] = { + 'polling_needed': True, + 'initial_status': initial_status, + 'final_status': current_status, + 'total_wait_time_seconds': round(elapsed_time, 2), + 'attempts': attempts, + 'completed': False, + 'note': f'Status changed to "{current_status}" which does not require further polling' + } + self._log('info', f"Status changed to '{current_status}', stopping polling") + current_result['pi_status'] = current_status + return current_result + + # Timeout reached + elapsed_time = (datetime.now() - start_time).total_seconds() + final_result = self.check_payment_intent(payment_intent_id) + + if final_result['success']: + final_status = final_result['status'] + final_result['polling_info'] = { + 'polling_needed': True, + 'initial_status': initial_status, + 'final_status': final_status, + 'total_wait_time_seconds': round(elapsed_time, 2), + 'attempts': attempts, + 'completed': False, + 'timed_out': True, + 'timeout_reason': f'Reached maximum wait time of {max_wait_seconds} seconds' + } + + # 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}") + else: + # Error on final check + final_result['polling_info'] = { + 'polling_needed': True, + 'initial_status': initial_status, + 'final_status': 'error', + 'total_wait_time_seconds': round(elapsed_time, 2), + 'attempts': attempts, + 'completed': False, + 'timed_out': True, + 'timeout_reason': 'Timeout reached and final status check failed' + } + + # 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") + current_result['pi_status'] = final_status + return final_result + + def update_payment_fees(self, needs_fee_update) -> Dict[str, Any]: + """ + Update fees for a payment that was previously marked as needing a fee update. + + Args: + needs_fee_update (list): List containing [customer_id, payment_intent_id] + + Returns: + dict: Updated payment information with actual fees if available + """ + try: + # Parse the identifier + if not isinstance(needs_fee_update, (list, tuple)) or len(needs_fee_update) != 2: + return { + 'success': False, + 'error': 'Invalid needs_fee_update format. Expected [customer_id, payment_intent_id]', + 'error_type': 'validation_error' + } + + customer_id, payment_intent_id = needs_fee_update + + self._log('info', f"Updating fees for {payment_intent_id} (customer: {customer_id})") + + # Get the current payment intent status with expanded data + current_result = self.check_payment_intent(payment_intent_id) + + if not current_result['success']: + return current_result + + # Check if we now have actual fees or if payment is now complete + has_actual_fees = (current_result.get('fee_details', {}).get('source') == 'stripe_actual') + is_complete = current_result['status'] in ['succeeded', 'failed', 'canceled'] + + update_result = { + 'success': True, + 'payment_intent_id': payment_intent_id, + 'customer_id': customer_id, + 'status': current_result['status'], + 'amount': current_result['amount'], + 'currency': current_result['currency'], + 'has_actual_fees': has_actual_fees, + 'is_complete': is_complete, + 'fee_details': current_result.get('fee_details', {}), + 'timestamp': datetime.now().isoformat() + } + + # Determine if this payment still needs future updates + 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") + 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") + 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") + 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") + + return update_result + + except Exception as e: + return { + 'success': False, + 'error': f'Failed to update payment fees: {str(e)}', + 'error_type': 'update_error', + 'needs_fee_update': needs_fee_update, + 'timestamp': datetime.now().isoformat() + } \ No newline at end of file diff --git a/templates/auth/add_user.html b/templates/auth/add_user.html new file mode 100644 index 0000000..1aefacd --- /dev/null +++ b/templates/auth/add_user.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}Add User - Plutus{% endblock %} + +{% block content %} +
+
+
+

Add New User

+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/auth/list_users.html b/templates/auth/list_users.html new file mode 100644 index 0000000..50105d0 --- /dev/null +++ b/templates/auth/list_users.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Users - Plutus{% endblock %} + +{% block content %} +
+
+

Users

+
+ +
+ +{% if users %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
IDUsernameFull NameEmailStatusCreatedPermissions
{{ user.id }}{{ user.Username }}{{ user.FullName }}{{ user.Email }} + + {{ 'Active' if user.Enabled else 'Disabled' }} + + {{ user.Created.strftime('%Y-%m-%d %H:%M') }}{{ user.Permissions or '-' }}
+
+{% else %} +
+

No users found. Add the first user.

+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..7512a32 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Login - Plutus{% endblock %} + +{% block content %} +
+
+
+

Login to Plutus

+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+
+ +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3b70b0d --- /dev/null +++ b/templates/base.html @@ -0,0 +1,154 @@ + + + + + + {% block title %}Plutus{% endblock %} + + + + {% block head %}{% endblock %} + + + + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ + {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ +
+
+

+ Plutus - Payment Processing System +

+
+
+ + + + \ No newline at end of file diff --git a/templates/main/batch_detail.html b/templates/main/batch_detail.html new file mode 100644 index 0000000..249d2d0 --- /dev/null +++ b/templates/main/batch_detail.html @@ -0,0 +1,609 @@ +{% extends "base.html" %} + +{% block title %}Batch #{{ batch.id }} - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Payment Batch #{{ batch.id }}

+

Created: {{ batch.Created.strftime('%Y-%m-%d %H:%M:%S') if batch.Created else 'Unknown' }}

+
+
+ +
+ + +
+
+
+
+
+
+

Total Payments

+

{{ summary.payment_count or 0 }}

+
+
+
+
+

Payment Amount

+

{{ summary.total_amount | currency }}

+
+
+
+
+

Stripe Fees

+

{{ summary.total_fees | currency }}

+
+
+
+
+
+
+ +
+
+
+
+

Successful

+

{{ summary.successful_count or 0 }}

+
+
+
+
+
+
+

Failed

+

{{ summary.failed_count or 0 }}

+
+
+
+
+
+
+

Errors

+

{{ summary.error_count or 0 }}

+
+
+
+
+
+
+

Success Rate

+ {% if summary.payment_count and summary.payment_count > 0 %} + {% set success_rate = (summary.successful_count or 0) / summary.payment_count * 100 %} +

+ {{ "%.1f"|format(success_rate) }}% +

+ {% else %} +

0%

+ {% endif %} +
+
+
+
+ + +
+
+
+

Payment Details

+
+
+
+

+ + + + +

+
+
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + + + + {% if payments %} +
+ + + + + + + + + + + + + + + + + {% for payment in payments %} + {% set row_class = '' %} + {% if payment.Success == True %} + {% set row_class = 'has-background-success-light' %} + {% elif payment.Success == False and payment.PI_FollowUp %} + {% set row_class = 'has-background-warning-light' %} + {% elif payment.Success == False and payment.Error %} + {% set row_class = 'has-background-danger-light' %} + {% elif payment.Success == None %} + {% set row_class = 'has-background-info-light' %} + {% endif %} + + + + + + + + + + + + + + {% endfor %} + +
Splynx IDStripe CustomerPayment IntentFollow UpLast CheckPayment MethodStripe FeeAmountDataStatus
+ {% if payment.Splynx_ID %} + + {{ payment.Splynx_ID }} + + {% else %} + - + {% endif %} + + {% if payment.Success == True %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == False and payment.PI_FollowUp %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == False and payment.Error %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == None %} + {{ payment.Stripe_Customer_ID or '-' }} + {% else %} + {{ payment.Stripe_Customer_ID or '-' }} + {% endif %} + + {% if payment.Success == True %} + {{ payment.Payment_Intent or '-' }} + {% elif payment.Success == False and payment.PI_FollowUp %} + {{ payment.Payment_Intent or '-' }} + {% elif payment.Success == False and payment.Error %} + {{ payment.Payment_Intent or '-' }} + {% elif payment.Success == None %} + {{ payment.Payment_Intent or '-' }} + {% else %} + {{ payment.Payment_Intent or '-' }} + {% endif %} + + {% if payment.PI_FollowUp %} + Follow Up + {% else %} + No + {% endif %} + + {{ payment.PI_Last_Check.strftime('%Y-%m-%d %H:%M') if payment.PI_Last_Check else '-' }} + + {% if payment.Payment_Method %} + {{ payment.Payment_Method }} + {% else %} + - + {% endif %} + + {% if payment.Fee_Stripe %} + {{ payment.Fee_Stripe | currency }} + {% else %} + - + {% endif %} + + {% if payment.Payment_Amount %} + {{ payment.Payment_Amount | currency }} + {% else %} + - + {% endif %} + +
+ {% if payment.PI_JSON %} + + {% endif %} + + {% if payment.PI_FollowUp_JSON %} + + {% endif %} + + {% if payment.Error %} + + {% endif %} +
+
+ {% if payment.Success == True %} + Success + {% elif payment.Success == False %} + Failed + {% else %} + Pending + {% endif %} +
+
+ {% else %} +
+

No payments found in this batch.

+
+ {% endif %} +
+ + +{% for payment in payments %} + + {% if payment.PI_JSON %} + + {% endif %} + + + {% if payment.PI_FollowUp_JSON %} + + {% endif %} + + + {% if payment.Error %} + + {% endif %} +{% endfor %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/main/batch_list.html b/templates/main/batch_list.html new file mode 100644 index 0000000..2a3b040 --- /dev/null +++ b/templates/main/batch_list.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} + +{% block title %}Payment Batches - Plutus{% endblock %} + +{% block content %} +
+
+

Payment Batches

+
+
+ +{% if batches %} +
+ + + + + + + + + + + + + + + {% for batch in batches %} + + + + + + + + + + + {% endfor %} + +
Batch IDCreatedTotal PaymentsPayment AmountStripe FeesSuccess RateStatusActions
+ #{{ batch.id }} + {{ batch.Created.strftime('%Y-%m-%d %H:%M') if batch.Created else '-' }} + {{ batch.payment_count or 0 }} + + {{ batch.total_amount | currency }} + + {{ batch.total_fees | currency }} + + {% if batch.payment_count and batch.payment_count > 0 %} + {% set success_rate = (batch.successful_count or 0) / batch.payment_count * 100 %} + {% if success_rate >= 90 %} + {{ "%.1f"|format(success_rate) }}% + {% elif success_rate >= 70 %} + {{ "%.1f"|format(success_rate) }}% + {% else %} + {{ "%.1f"|format(success_rate) }}% + {% endif %} + {% else %} + 0% + {% endif %} + +
+ {% if batch.successful_count %} + {{ batch.successful_count }} Success + {% endif %} + {% if batch.failed_count %} + {{ batch.failed_count }} Failed + {% endif %} + {% if batch.error_count %} + {{ batch.error_count }} Errors + {% endif %} + {% if not batch.successful_count and not batch.failed_count %} + No Payments + {% endif %} +
+
+ + + + + View Details + +
+
+{% else %} +
+

No payment batches found. Return to dashboard.

+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/main/index.html b/templates/main/index.html new file mode 100644 index 0000000..f25bfbe --- /dev/null +++ b/templates/main/index.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Plutus{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

+ Welcome to Plutus +

+

+ Payment Processing System +

+
+
+Plutus - God of Wealth +
+

Welcome, {{ current_user.FullName }}!

+

You are successfully logged into the Plutus payment processing system.

+
+{% endblock %} \ No newline at end of file diff --git a/templates/main/payment_plans_detail.html b/templates/main/payment_plans_detail.html new file mode 100644 index 0000000..bf7e5b0 --- /dev/null +++ b/templates/main/payment_plans_detail.html @@ -0,0 +1,403 @@ +{% extends "base.html" %} + +{% block title %}Payment Plan #{{ plan.id }} - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Payment Plan #{{ plan.id }}

+

Created: {{ plan.Created.strftime('%Y-%m-%d %H:%M:%S') if plan.Created else 'Unknown' }}

+
+
+
+
+
+
+ +
+
+ + +
+
+
+ + +
+
+
+
+ {% if plan.Enabled %} + + + + {% else %} + + + + {% endif %} +
+
+
+ {% if plan.Enabled %} +

Active Payment Plan

+

This payment plan is currently active and processing payments.

+ {% else %} +

Inactive Payment Plan

+

This payment plan is disabled and not processing payments.

+ {% endif %} +
+
+
+
+
+
+

{{ plan.Amount | currency }}

+

{{ plan.Frequency }}

+
+
+
+
+
+ + +
+
+
+

+ + Customer Information +

+ +
+
+ + + +

Loading customer details...

+
+
+
+
+ +
+
+

+ + Plan Configuration +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Plan ID#{{ plan.id }}
Payment Amount{{ plan.Amount | currency }}
Frequency + + {{ plan.Frequency }} + +
Start Date + {{ plan.Start_Date.strftime('%Y-%m-%d') if plan.Start_Date else '-' }} + {% if plan.Start_Date %} +
Payments occur every {{ plan.Frequency.lower() }} from this date + {% endif %} +
Payment Method + {{ plan.Stripe_Payment_Method[:20] }}{% if plan.Stripe_Payment_Method|length > 20 %}...{% endif %} +
Status + {% if plan.Enabled %} + Active + {% else %} + Inactive + {% endif %} +
Created{{ plan.Created.strftime('%Y-%m-%d %H:%M:%S') if plan.Created else '-' }}
Created By{{ plan.created_by or 'Unknown' }}
+
+
+
+ + +
+
+
+

+ + Associated Payments +

+
+
+
+

+ + + + +

+
+
+
+ + {% if associated_payments %} +
+ + + + + + + + + + + + + {% for payment in associated_payments %} + + + + + + + + + {% endfor %} + +
Payment IDAmountStatusPayment IntentProcessedActions
+ + #{{ payment.id }} + + + {{ payment.Payment_Amount | currency }} + + {% if payment.Success == True %} + Success + {% elif payment.Success == False %} + Failed + {% else %} + Pending + {% endif %} + + {% if payment.Payment_Intent %} + {{ payment.Payment_Intent[:20] }}... + {% else %} + - + {% endif %} + {{ payment.Created.strftime('%Y-%m-%d %H:%M') if payment.Created else '-' }} + + + +
+
+ + +
+
+
+
+

Payment Summary

+

Total: {{ associated_payments|length }} payments

+
+
+
+
+
+
+ + {{ associated_payments|selectattr('Success', 'equalto', True)|list|length }} Successful + +
+
+
+
+ + {{ associated_payments|selectattr('Success', 'equalto', False)|list|length }} Failed + +
+
+
+
+ + {{ associated_payments|selectattr('Success', 'equalto', None)|list|length }} Pending + +
+
+
+
+ {% else %} +
+ + + +

No Associated Payments

+

This payment plan hasn't processed any payments yet.

+
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/main/payment_plans_form.html b/templates/main/payment_plans_form.html new file mode 100644 index 0000000..d7d2460 --- /dev/null +++ b/templates/main/payment_plans_form.html @@ -0,0 +1,551 @@ +{% extends "base.html" %} + +{% block title %}{% if edit_mode %}Edit Payment Plan{% else %}Create Payment Plan{% endif %} - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

{% if edit_mode %}Edit Payment Plan{% else %}Create Payment Plan{% endif %}

+

{% if edit_mode %}Update recurring payment settings{% else %}Set up automated recurring payments{% endif %}

+
+
+
+ + +
+ {% if edit_mode %} + +
+

+ + Edit Payment Plan Details +

+ +
+

Customer Information

+
+
+
+
+ Customer ID:
+ {{ plan.Splynx_ID }} +
+
+ Name:
+ + + Loading... + +
+
+
+
+
+ +
+ + +
+
+
+ +
+ + + + +
+

Enter the recurring payment amount (maximum $10,000)

+
+
+ +
+
+ +
+
+ +
+
+

How often should the payment be processed

+
+
+
+ +
+ +
+ +
+

The first payment date - determines both when payments start and which day of the week they occur

+
+ +
+ +
+
+ +
+
+

Stripe payment method to use for recurring payments

+
+ +
+
+ +
+ +
+
+
+ {% else %} + + + +
+

+ + Customer Lookup +

+ +
+ +
+ +
+

Enter the Splynx customer ID to fetch customer details

+
+ + + + + + + +
+
+ +
+
+
+ + + + {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/main/payment_plans_list.html b/templates/main/payment_plans_list.html new file mode 100644 index 0000000..d9361d5 --- /dev/null +++ b/templates/main/payment_plans_list.html @@ -0,0 +1,267 @@ +{% extends "base.html" %} + +{% block title %}Payment Plans - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Payment Plans

+

Recurring payment management

+
+
+ +
+ + +
+
+
+

{{ summary.active_plans }}

+

Active Plans

+
+
+
+
+

{{ summary.inactive_plans }}

+

Inactive Plans

+
+
+
+
+

{{ summary.total_plans }}

+

Total Plans

+
+
+
+
+

{{ summary.total_recurring_amount | currency }}

+

Monthly Recurring

+
+
+
+ + +
+
+
+

Payment Plans

+
+
+
+

+ + + + +

+
+
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + {% if plans %} +
+ + + + + + + + + + + + + + + + + {% for plan in plans %} + + + + + + + + + + + + + {% endfor %} + +
Plan IDCustomerSplynx IDAmountFrequencyStart DateStatusCreatedCreated ByActions
+ + #{{ plan.id }} + + + + + Loading... + + + {{ plan.Splynx_ID }} + + {{ plan.Amount | currency }} + + + {{ plan.Frequency }} + + {{ plan.Start_Date.strftime('%Y-%m-%d') if plan.Start_Date else '-' }} + {% if plan.Enabled %} + Active + {% else %} + Inactive + {% endif %} + {{ plan.Created.strftime('%Y-%m-%d %H:%M') if plan.Created else '-' }}{{ plan.created_by or 'Unknown' }} +
+
+ + + +
+
+ + + +
+
+
+
+ {% else %} +
+ + + +

No Payment Plans Found

+

Get started by creating your first payment plan.

+ + + Create Payment Plan + +
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/main/single_payment.html b/templates/main/single_payment.html new file mode 100644 index 0000000..6fdf029 --- /dev/null +++ b/templates/main/single_payment.html @@ -0,0 +1,554 @@ +{% extends "base.html" %} + +{% block title %}Single Payment - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Single Payment Processing

+

Process individual customer payments through Stripe

+
+
+
+ + +
+ +
+

+ + Customer Lookup +

+ +
+ +
+ +
+

Enter the Splynx customer ID to fetch customer details

+
+ + + + + + + +
+
+ +
+
+
+ + + +
+ + + + + + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/main/single_payment_detail.html b/templates/main/single_payment_detail.html new file mode 100644 index 0000000..0f9148b --- /dev/null +++ b/templates/main/single_payment_detail.html @@ -0,0 +1,410 @@ +{% extends "base.html" %} + +{% block title %}Payment #{{ payment.id }} - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Single Payment #{{ payment.id }}

+

Processed: {{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else 'Unknown' }}

+
+
+ +
+ + +
+
+
+
+ {% if payment.Success == True %} + + + + {% elif payment.Success == False %} + + + + {% else %} + + + + {% endif %} +
+
+
+ {% if payment.Success == True %} +

Payment Successful

+

This payment has been completed successfully.

+ {% elif payment.Success == False %} +

Payment Failed

+

This payment could not be completed.

+ {% else %} +

Payment Pending

+

This payment is still being processed.

+ {% endif %} +
+
+
+
+ {% if payment.PI_FollowUp %} + + {% endif %} +
+
+
+ + +
+
+
+

+ + Payment Information +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Payment ID#{{ payment.id }}
Splynx Customer ID + {% if payment.Splynx_ID %} + {{ payment.Splynx_ID }} + {% else %} + - + {% endif %} +
Stripe Customer ID{{ payment.Stripe_Customer_ID or '-' }}
Payment Intent{{ payment.Payment_Intent or '-' }}
Payment Method + {% if payment.Payment_Method %} + {{ payment.Payment_Method }} + {% else %} + - + {% endif %} +
Created{{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else '-' }}
Processed By{{ payment.processed_by or 'Unknown' }}
+
+
+ +
+
+

+ + Financial Details +

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

+ + Error Information +

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

+ + Payment Intent JSON +

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

+ + Follow-up JSON +

+ +
+
+ +
+
+ +
{{ payment.PI_FollowUp_JSON | format_json }}
+ +
+
+ {% endif %} +
+ + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/main/single_payments_list.html b/templates/main/single_payments_list.html new file mode 100644 index 0000000..6e3b196 --- /dev/null +++ b/templates/main/single_payments_list.html @@ -0,0 +1,514 @@ +{% extends "base.html" %} + +{% block title %}Single Payments - Plutus{% endblock %} + +{% block content %} + + +
+
+
+

Single Payments

+

Individual payment processing history

+
+
+ +
+ + +
+
+
+

Payment History

+
+
+
+

+ + + + +

+
+
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + + + + {% if payments %} +
+ + + + + + + + + + + + + + + + + + {% for payment in payments %} + {% set row_class = '' %} + {% if payment.Success == True %} + {% set row_class = 'has-background-success-light' %} + {% elif payment.Success == False and payment.Error %} + {% set row_class = 'has-background-danger-light' %} + {% elif payment.Success == None %} + {% set row_class = 'has-background-info-light' %} + {% endif %} + + + + + + + + + + + + + + + {% endfor %} + +
Payment IDDateSplynx IDStripe CustomerPayment IntentPayment MethodStripe FeeAmountProcessed ByDataStatus
+ #{{ payment.id }} + + {{ payment.Created.strftime('%Y-%m-%d') }}
+ {{ payment.Created.strftime('%H:%M:%S') }} +
+ {% if payment.Splynx_ID %} + + {{ payment.Splynx_ID }} + + {% else %} + - + {% endif %} + + {% if payment.Success == True %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == False and payment.Error %} + {{ payment.Stripe_Customer_ID or '-' }} + {% elif payment.Success == None %} + {{ payment.Stripe_Customer_ID or '-' }} + {% else %} + {{ payment.Stripe_Customer_ID or '-' }} + {% endif %} + + {% if payment.Payment_Intent %} + {% if payment.Success == True %} + {{ payment.Payment_Intent }} + {% elif payment.Success == False and payment.Error %} + {{ payment.Payment_Intent }} + {% elif payment.Success == None %} + {{ payment.Payment_Intent }} + {% else %} + {{ payment.Payment_Intent }} + {% endif %} + {% else %} + - + {% endif %} + + {% if payment.Payment_Method %} + {{ payment.Payment_Method }} + {% else %} + - + {% endif %} + + {% if payment.Fee_Stripe %} + {{ payment.Fee_Stripe | currency }} + {% else %} + - + {% endif %} + + {% if payment.Payment_Amount %} + {{ payment.Payment_Amount | currency }} + {% else %} + - + {% endif %} + + {{ payment.processed_by or 'Unknown' }} + +
+ {% if payment.PI_JSON %} + + {% endif %} + + {% if payment.Error %} + + {% endif %} +
+
+ {% if payment.Success == True %} + Success + {% elif payment.Success == False %} + Failed + {% else %} + Pending + {% endif %} +
+
+ {% else %} +
+

No single payments found. Process your first payment.

+
+ {% endif %} +
+ + +{% for payment in payments %} + + {% if payment.PI_JSON %} + + {% endif %} + + + {% if payment.Error %} + + {% endif %} +{% endfor %} + + +{% endblock %} \ No newline at end of file diff --git a/test_logging.py b/test_logging.py new file mode 100644 index 0000000..3fce854 --- /dev/null +++ b/test_logging.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Test script to verify the logging system is working correctly. +""" + +from app import create_app, db +from services import ( + log_script_start, log_script_completion, log_batch_created, + log_payment_plan_run, log_payment_intent_followup, log_activity +) +from models import Logs +import time + +def test_logging_functions(): + """Test all logging functions.""" + print("Testing logging system...") + + # Test basic log_activity function + log_id = log_activity( + user_id=1, # System user + action="TEST_LOGGING", + entity_type="Test", + entity_id=999, + details="Testing the logging system functionality" + ) + print(f"✓ Basic logging test - Log ID: {log_id}") + + # Test script start logging + start_log_id = log_script_start("test_logging.py", "test", "sandbox") + print(f"✓ Script start logging - Log ID: {start_log_id}") + + # Test batch creation logging + batch_log_id = log_batch_created(123, "Direct Debit", 45) + print(f"✓ Batch creation logging - Log ID: {batch_log_id}") + + # Test payment plan run logging + payplan_log_id = log_payment_plan_run( + active_plans=25, + due_plans=8, + processed_count=7, + failed_count=1, + total_amount=1247.50 + ) + print(f"✓ Payment plan run logging - Log ID: {payplan_log_id}") + + # Test payment intent follow-up logging + intent_log_id = log_payment_intent_followup( + pending_count=15, + succeeded_count=12, + failed_count=2, + still_pending=1 + ) + print(f"✓ Payment intent follow-up logging - Log ID: {intent_log_id}") + + # Test script completion logging + completion_log_id = log_script_completion( + script_name="test_logging.py", + mode="test", + success_count=5, + failed_count=1, + total_amount=1247.50, + batch_ids=[123, 124], + duration_seconds=2.5, + errors=["Test error message"] + ) + print(f"✓ Script completion logging - Log ID: {completion_log_id}") + + return True + +def verify_logs_in_database(): + """Verify that logs were actually written to the database.""" + print("\nVerifying logs in database...") + + # Query recent test logs + recent_logs = db.session.query(Logs).filter( + (Logs.Action.like('TEST_%')) | + (Logs.Entity_Type == 'Test') + ).order_by(Logs.Added.desc()).limit(10).all() + + print(f"Found {len(recent_logs)} test log entries:") + for log in recent_logs: + print(f" - ID: {log.id}, Action: {log.Action}, Entity: {log.Entity_Type}, Details: {log.Log_Entry[:50]}...") + + return len(recent_logs) > 0 + +if __name__ == "__main__": + app = create_app() + + with app.app_context(): + print("=" * 60) + print("PLUTUS LOGGING SYSTEM TEST") + print("=" * 60) + + start_time = time.time() + + # Test logging functions + test_success = test_logging_functions() + + # Verify logs were written to database + db_success = verify_logs_in_database() + + end_time = time.time() + duration = end_time - start_time + + print(f"\nTest completed in {duration:.2f} seconds") + + if test_success and db_success: + print("✅ All logging tests passed!") + + # Log this test completion to demonstrate the system + log_activity( + user_id=1, + action="TEST_COMPLETED", + entity_type="Test", + details=f"Logging system test completed successfully in {duration:.2f}s" + ) + + else: + print("❌ Some logging tests failed!") + + print("=" * 60) \ No newline at end of file