Browse Source

Initial push

master
Alan Woodman 4 months ago
parent
commit
1310f9a6c7
  1. 64
      app.py
  2. 247
      bin/Activate.ps1
  3. 70
      bin/activate
  4. 27
      bin/activate.csh
  5. 69
      bin/activate.fish
  6. 8
      bin/alembic
  7. 8
      bin/flask
  8. 8
      bin/mako-render
  9. 8
      bin/normalizer
  10. 8
      bin/pip
  11. 8
      bin/pip3
  12. 8
      bin/pip3.12
  13. 1
      bin/python
  14. 1
      bin/python3
  15. 1
      bin/python3.12
  16. 8
      bin/wheel
  17. 0
      blueprints/__init__.py
  18. 74
      blueprints/auth.py
  19. 871
      blueprints/main.py
  20. 39
      config.py
  21. 164
      include/site/python3.12/greenlet/greenlet.h
  22. 1
      lib64
  23. 1
      migrations/README
  24. 50
      migrations/alembic.ini
  25. 113
      migrations/env.py
  26. 24
      migrations/script.py.mako
  27. 46
      migrations/versions/1b403a365765_add_new_features.py
  28. 42
      migrations/versions/3252db86eaae_add_new_features.py
  29. 73
      migrations/versions/455cbec206cf_initial_migration_with_users_payments_.py
  30. 32
      migrations/versions/50157fcf55e4_add_new_features.py
  31. 32
      migrations/versions/6a841af4c236_add_new_features.py
  32. 38
      migrations/versions/906059746902_add_new_features.py
  33. 48
      migrations/versions/9d9195d6b9a7_add_new_features.py
  34. 48
      migrations/versions/ed07e785afd5_add_new_features.py
  35. 102
      models.py
  36. 5
      pyvenv.cfg
  37. 532
      query_mysql - Copy.py
  38. 672
      query_mysql.py
  39. 7
      requirements.txt
  40. 229
      services.py
  41. 133
      splynx.py
  42. 332
      static/css/custom.css
  43. BIN
      static/images/plutus3.JPG
  44. 1041
      stripe_payment_processor.py
  45. 76
      templates/auth/add_user.html
  46. 58
      templates/auth/list_users.html
  47. 46
      templates/auth/login.html
  48. 154
      templates/base.html
  49. 609
      templates/main/batch_detail.html
  50. 91
      templates/main/batch_list.html
  51. 33
      templates/main/index.html
  52. 403
      templates/main/payment_plans_detail.html
  53. 551
      templates/main/payment_plans_form.html
  54. 267
      templates/main/payment_plans_list.html
  55. 554
      templates/main/single_payment.html
  56. 410
      templates/main/single_payment_detail.html
  57. 514
      templates/main/single_payments_list.html
  58. 121
      test_logging.py

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

247
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"

70
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

27
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 <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
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

69
bin/activate.fish

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/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

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

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

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

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

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

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

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

1
bin/python

@ -0,0 +1 @@
python3.12

1
bin/python3

@ -0,0 +1 @@
python3.12

1
bin/python3.12

@ -0,0 +1 @@
/usr/bin/python3.12

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

0
blueprints/__init__.py

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

871
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/<int:batch_id>')
@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/<int:payment_id>')
@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/<int:payment_id>')
@login_required
def payment_detail(payment_id):
"""Display detailed view of a specific single payment."""
# Get payment information
payment = db.session.query(
Payments.id,
Payments.Splynx_ID,
Payments.Stripe_Customer_ID,
Payments.Payment_Intent,
Payments.PI_FollowUp,
Payments.PI_Last_Check,
Payments.Payment_Method,
Payments.Fee_Tax,
Payments.Fee_Stripe,
Payments.Fee_Total,
Payments.Payment_Amount,
Payments.PI_JSON,
Payments.PI_FollowUp_JSON,
Payments.Error,
Payments.Success,
Payments.Created)\
.filter(Payments.id == payment_id).first()
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/<int:payment_id>', 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/<int:plan_id>')
@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/<int:plan_id>', 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/<int:plan_id>', 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/<int:plan_id>', 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/<int:plan_id>')
@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/<stripe_customer_id>')
@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/<int:id>')
@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

39
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'

164
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 <Python.h>
#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 */

1
lib64

@ -0,0 +1 @@
lib

1
migrations/README

@ -0,0 +1 @@
Single-database configuration for Flask.

50
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

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

24
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"}

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

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

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

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

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

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

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

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

102
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 '<User %r>' % 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)

5
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

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

672
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")

7
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

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

133
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'}

332
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;
}

BIN
static/images/plutus3.JPG

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

1041
stripe_payment_processor.py

File diff suppressed because it is too large

76
templates/auth/add_user.html

@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Add User - Plutus{% endblock %}
{% block content %}
<div class="columns is-centered">
<div class="column is-6">
<div class="box">
<h1 class="title">Add New User</h1>
<form method="POST">
<div class="field">
<label class="label">Username</label>
<div class="control has-icons-left">
<input class="input" type="text" name="username" placeholder="Username" required>
<span class="icon is-small is-left">
<i class="fas fa-user"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Full Name</label>
<div class="control has-icons-left">
<input class="input" type="text" name="full_name" placeholder="Full Name" required>
<span class="icon is-small is-left">
<i class="fas fa-id-card"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Email</label>
<div class="control has-icons-left">
<input class="input" type="email" name="email" placeholder="Email Address" required>
<span class="icon is-small is-left">
<i class="fas fa-envelope"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control has-icons-left">
<input class="input" type="password" name="password" placeholder="Password" required>
<span class="icon is-small is-left">
<i class="fas fa-lock"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Permissions</label>
<div class="control">
<input class="input" type="text" name="permissions" placeholder="Permissions (optional)">
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>Add User</span>
</button>
</div>
<div class="control">
<a class="button is-light" href="{{ url_for('auth.list_users') }}">Cancel</a>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

58
templates/auth/list_users.html

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Users - Plutus{% endblock %}
{% block content %}
<div class="level">
<div class="level-left">
<h1 class="title">Users</h1>
</div>
<div class="level-right">
<a class="button is-primary" href="{{ url_for('auth.add_user') }}">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>Add User</span>
</a>
</div>
</div>
{% if users %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Status</th>
<th>Created</th>
<th>Permissions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.Username }}</td>
<td>{{ user.FullName }}</td>
<td>{{ user.Email }}</td>
<td>
<span class="tag is-{{ 'success' if user.Enabled else 'danger' }}">
{{ 'Active' if user.Enabled else 'Disabled' }}
</span>
</td>
<td>{{ user.Created.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ user.Permissions or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="notification is-info">
<p>No users found. <a href="{{ url_for('auth.add_user') }}">Add the first user</a>.</p>
</div>
{% endif %}
{% endblock %}

46
templates/auth/login.html

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Login - Plutus{% endblock %}
{% block content %}
<div class="columns is-centered">
<div class="column is-4">
<div class="box">
<h1 class="title has-text-centered">Login to Plutus</h1>
<form method="POST">
<div class="field">
<label class="label">Username</label>
<div class="control has-icons-left">
<input class="input" type="text" name="username" placeholder="Username" required>
<span class="icon is-small is-left">
<i class="fas fa-user"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control has-icons-left">
<input class="input" type="password" name="password" placeholder="Password" required>
<span class="icon is-small is-left">
<i class="fas fa-lock"></i>
</span>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary is-fullwidth" type="submit">
<span class="icon">
<i class="fas fa-sign-in-alt"></i>
</span>
<span>Login</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

154
templates/base.html

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Plutus{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar is-dark" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="{{ url_for('main.index') }}">
<strong>Plutus</strong>
</a>
</div>
<div class="navbar-menu">
<div class="navbar-start">
{% if current_user.is_authenticated %}
<a class="navbar-item" href="{{ url_for('main.index') }}">
Dashboard
</a>
{% if current_user.Permissions == 'Admin' %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Users
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{{ url_for('auth.list_users') }}">
List Users
</a>
<a class="navbar-item" href="{{ url_for('auth.add_user') }}">
Add User
</a>
</div>
</div>
{% endif %}
<a class="navbar-item" href="{{ url_for('main.batch_list') }}">
<span class="icon">
<i class="fas fa-file-invoice-dollar"></i>
</span>
<span>Payment Batches</span>
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
<span class="icon">
<i class="fas fa-credit-card"></i>
</span>
<span>Single Payments</span>
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{{ url_for('main.single_payments_list') }}">
<span class="icon">
<i class="fas fa-list"></i>
</span>
<span>View Payments</span>
</a>
<a class="navbar-item" href="{{ url_for('main.single_payment') }}">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>New Payment</span>
</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
<span class="icon">
<i class="fas fa-calendar-alt"></i>
</span>
<span>Payment Plans</span>
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{{ url_for('main.payment_plans_list') }}">
<span class="icon">
<i class="fas fa-list"></i>
</span>
<span>View Plans</span>
</a>
<a class="navbar-item" href="{{ url_for('main.payment_plans_create') }}">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>New Plan</span>
</a>
</div>
</div>
{% endif %}
</div>
<div class="navbar-end">
{% if current_user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
{{ current_user.FullName }}
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{{ url_for('auth.logout') }}">
Logout
</a>
</div>
</div>
{% else %}
<div class="navbar-item">
<a class="button is-primary" href="{{ url_for('auth.login') }}">
Login
</a>
</div>
{% endif %}
</div>
</div>
</nav>
<main class="section">
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="notification is-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' }}">
<button class="delete"></button>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong style="color: var(--plutus-gold);">Plutus</strong> - Payment Processing System
</p>
</div>
</footer>
<script>
// Close notifications
document.addEventListener('DOMContentLoaded', () => {
(document.querySelectorAll('.notification .delete') || []).forEach(($delete) => {
const $notification = $delete.parentNode;
$delete.addEventListener('click', () => {
$notification.parentNode.removeChild($notification);
});
});
});
</script>
</body>
</html>

609
templates/main/batch_detail.html

@ -0,0 +1,609 @@
{% extends "base.html" %}
{% block title %}Batch #{{ batch.id }} - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li><a href="{{ url_for('main.batch_list') }}">Payment Batches</a></li>
<li class="is-active"><a href="#" aria-current="page">Batch #{{ batch.id }}</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Payment Batch #{{ batch.id }}</h1>
<p class="subtitle">Created: {{ batch.Created.strftime('%Y-%m-%d %H:%M:%S') if batch.Created else 'Unknown' }}</p>
</div>
</div>
<div class="level-right">
<a class="button is-light" href="{{ url_for('main.batch_list') }}">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back to Batches</span>
</a>
</div>
</div>
<!-- Summary Statistics -->
<div class="columns">
<div class="column">
<div class="box">
<div class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Total Payments</p>
<p class="title">{{ summary.payment_count or 0 }}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Payment Amount</p>
<p class="title">{{ summary.total_amount | currency }}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Stripe Fees</p>
<p class="title">{{ summary.total_fees | currency }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<div class="box">
<div class="has-text-centered">
<p class="heading">Successful</p>
<p class="title has-text-success">{{ summary.successful_count or 0 }}</p>
</div>
</div>
</div>
<div class="column is-3">
<div class="box">
<div class="has-text-centered">
<p class="heading">Failed</p>
<p class="title has-text-danger">{{ summary.failed_count or 0 }}</p>
</div>
</div>
</div>
<div class="column is-3">
<div class="box">
<div class="has-text-centered">
<p class="heading">Errors</p>
<p class="title" style="color: #ff8c00;">{{ summary.error_count or 0 }}</p>
</div>
</div>
</div>
<div class="column is-3">
<div class="box">
<div class="has-text-centered">
<p class="heading">Success Rate</p>
{% if summary.payment_count and summary.payment_count > 0 %}
{% set success_rate = (summary.successful_count or 0) / summary.payment_count * 100 %}
<p class="title {% if success_rate >= 90 %}has-text-success{% elif success_rate >= 70 %}has-text-warning{% else %}has-text-danger{% endif %}">
{{ "%.1f"|format(success_rate) }}%
</p>
{% else %}
<p class="title">0%</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Payment Details Table -->
<div class="box">
<div class="level">
<div class="level-left">
<h2 class="title is-4">Payment Details</h2>
</div>
<div class="level-right">
<div class="field">
<p class="control has-icons-left">
<input class="input" type="text" id="searchInput" placeholder="Search Splynx ID, Customer ID, Payment Intent...">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
</div>
</div>
<!-- Filter Controls -->
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<label class="label is-small">Filter by Status:</label>
<div class="select is-small">
<select id="statusFilter">
<option value="all">All Payments</option>
<option value="success">Successful Only</option>
<option value="failed">Failed Only</option>
<option value="pending">Pending Only</option>
<option value="followup">Follow Up Required</option>
<option value="error">Has Errors</option>
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Filter by Payment Method:</label>
<div class="select is-small">
<select id="paymentMethodFilter">
<option value="all">All Methods</option>
<!-- Options will be populated by JavaScript -->
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Sort by:</label>
<div class="select is-small">
<select id="sortFilter">
<option value="splynx_asc">Splynx ID (Ascending)</option>
<option value="splynx_desc">Splynx ID (Descending)</option>
<option value="amount_asc">Amount (Low to High)</option>
<option value="amount_desc">Amount (High to Low)</option>
<option value="status">Status</option>
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-info" onclick="clearFilters()">
<span class="icon"><i class="fas fa-times"></i></span>
<span>Clear Filters</span>
</button>
</div>
</div>
<!-- Results Counter -->
<div class="notification is-info is-light" id="filterResults" style="display: none;">
<span id="resultCount">0</span> of {{ payments|length }} payments shown
</div>
{% if payments %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" id="paymentsTable">
<thead>
<tr>
<th>Splynx ID</th>
<th>Stripe Customer</th>
<th>Payment Intent</th>
<th>Follow Up</th>
<th>Last Check</th>
<th>Payment Method</th>
<th>Stripe Fee</th>
<th>Amount</th>
<th>Data</th>
<th>Status</th>
</tr>
</thead>
<tbody id="paymentsTableBody">
{% 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 %}
<tr class="{{ row_class }}">
<td>
{% if payment.Splynx_ID %}
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ payment.Splynx_ID }}"
target="_blank" class="has-text-weight-semibold">
{{ payment.Splynx_ID }}
</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == None %}
<code class="is-small has-background-info has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% else %}
<code class="is-small has-background-grey-light has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% endif %}
</td>
<td>
{% if payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Success == False and payment.PI_FollowUp %}
<code class="is-small has-background-warning has-text-black">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Payment_Intent or '-' }}</code>
{% elif payment.Success == None %}
<code class="is-small has-background-info has-text-white">{{ payment.Payment_Intent or '-' }}</code>
{% else %}
<code class="is-small has-background-grey-light has-text-black">{{ payment.Payment_Intent or '-' }}</code>
{% endif %}
</td>
<td>
{% if payment.PI_FollowUp %}
<span class="tag is-warning">Follow Up</span>
{% else %}
<span class="tag is-light">No</span>
{% endif %}
</td>
<td>
{{ payment.PI_Last_Check.strftime('%Y-%m-%d %H:%M') if payment.PI_Last_Check else '-' }}
</td>
<td>
{% if payment.Payment_Method %}
<span class="tag is-info is-light">{{ payment.Payment_Method }}</span>
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Fee_Stripe %}
{{ payment.Fee_Stripe | currency }}
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Payment_Amount %}
<strong>{{ payment.Payment_Amount | currency }}</strong>
{% else %}
-
{% endif %}
</td>
<td>
<div class="buttons are-small">
{% if payment.PI_JSON %}
<button class="button is-info is-outlined" onclick="showModal('json-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-code"></i></span>
<span>JSON</span>
</button>
{% endif %}
{% if payment.PI_FollowUp_JSON %}
<button class="button is-primary is-outlined" onclick="showModal('followup-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-redo"></i></span>
<span>Follow Up</span>
</button>
{% endif %}
{% if payment.Error %}
<button class="button is-danger is-outlined" onclick="showModal('error-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span>Error</span>
</button>
{% endif %}
</div>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
{% else %}
<span class="tag is-warning">Pending</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="notification is-info">
<p>No payments found in this batch.</p>
</div>
{% endif %}
</div>
<!-- Modals for JSON/Error data -->
{% for payment in payments %}
<!-- PI_JSON Modal -->
{% if payment.PI_JSON %}
<div class="modal" id="json-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('json-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment Intent JSON - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('json-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<pre><code class="language-json">{{ payment.PI_JSON | format_json }}</code></pre>
<button class="button is-small is-info" onclick="copyFormattedJSON('json-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
<div id="json-content-{{ payment.id }}" style="display: none;">{{ payment.PI_JSON | format_json }}</div>
</section>
</div>
</div>
{% endif %}
<!-- PI_FollowUp_JSON Modal -->
{% if payment.PI_FollowUp_JSON %}
<div class="modal" id="followup-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('followup-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Follow Up JSON - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('followup-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<pre><code class="language-json">{{ payment.PI_FollowUp_JSON | format_json }}</code></pre>
<button class="button is-small is-primary" onclick="copyFormattedJSON('followup-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
<div id="followup-content-{{ payment.id }}" style="display: none;">{{ payment.PI_FollowUp_JSON | format_json }}</div>
</section>
</div>
</div>
{% endif %}
<!-- Error Modal -->
{% if payment.Error %}
<div class="modal" id="error-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('error-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment Error - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('error-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
</div>
<button class="button is-small is-danger" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy Error</span>
</button>
<div id="error-content-{{ payment.id }}" style="display: none;">{{ payment.Error }}</div>
</section>
</div>
</div>
{% endif %}
{% endfor %}
<script>
// Payment filtering and sorting functionality
let allPayments = [];
let filteredPayments = [];
// Initialize payment data and filters when page loads
document.addEventListener('DOMContentLoaded', function() {
initializePayments();
populatePaymentMethodFilter();
setupEventListeners();
});
function initializePayments() {
const tableBody = document.getElementById('paymentsTableBody');
const rows = tableBody.querySelectorAll('tr');
allPayments = Array.from(rows).map(row => {
const cells = row.querySelectorAll('td');
return {
element: row,
splynxId: cells[0] ? (cells[0].textContent.trim() || '') : '',
stripeCustomerId: cells[1] ? (cells[1].textContent.trim() || '') : '',
paymentIntent: cells[2] ? (cells[2].textContent.trim() || '') : '',
followUp: cells[3] ? (cells[3].textContent.trim() || '') : '',
lastCheck: cells[4] ? (cells[4].textContent.trim() || '') : '',
paymentMethod: cells[5] ? (cells[5].textContent.trim() || '') : '',
stripeFee: cells[6] ? (cells[6].textContent.trim() || '') : '',
amount: cells[7] ? (cells[7].textContent.trim() || '') : '',
status: cells[9] ? (cells[9].textContent.trim() || '') : '',
success: row.classList.contains('has-background-success-light'),
failed: row.classList.contains('has-background-danger-light'),
pending: row.classList.contains('has-background-info-light'),
followUpRequired: row.classList.contains('has-background-warning-light'),
hasError: cells[8] && cells[8].querySelector('button.is-danger')
};
});
filteredPayments = [...allPayments];
updateResultCount();
}
function populatePaymentMethodFilter() {
const select = document.getElementById('paymentMethodFilter');
const methods = [...new Set(allPayments
.map(p => p.paymentMethod)
.filter(method => method && method !== '-')
)].sort();
// Clear existing options except "All Methods"
select.innerHTML = '<option value="all">All Methods</option>';
methods.forEach(method => {
const option = document.createElement('option');
option.value = method;
option.textContent = method;
select.appendChild(option);
});
}
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('statusFilter').addEventListener('change', applyFilters);
document.getElementById('paymentMethodFilter').addEventListener('change', applyFilters);
document.getElementById('sortFilter').addEventListener('change', applyFilters);
}
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('statusFilter').value;
const paymentMethodFilter = document.getElementById('paymentMethodFilter').value;
const sortFilter = document.getElementById('sortFilter').value;
// Filter payments
filteredPayments = allPayments.filter(payment => {
// Search filter
const searchMatch = !searchTerm ||
payment.splynxId.toLowerCase().includes(searchTerm) ||
payment.stripeCustomerId.toLowerCase().includes(searchTerm) ||
payment.paymentIntent.toLowerCase().includes(searchTerm);
// Status filter
let statusMatch = true;
switch(statusFilter) {
case 'success':
statusMatch = payment.success;
break;
case 'failed':
statusMatch = payment.failed;
break;
case 'pending':
statusMatch = payment.pending;
break;
case 'followup':
statusMatch = payment.followUpRequired;
break;
case 'error':
statusMatch = payment.hasError;
break;
}
// Payment method filter
const methodMatch = paymentMethodFilter === 'all' ||
payment.paymentMethod === paymentMethodFilter;
return searchMatch && statusMatch && methodMatch;
});
// Sort payments
sortPayments(sortFilter);
// Update display
updateTable();
updateResultCount();
}
function sortPayments(sortBy) {
switch(sortBy) {
case 'splynx_asc':
filteredPayments.sort((a, b) => parseInt(a.splynxId) - parseInt(b.splynxId));
break;
case 'splynx_desc':
filteredPayments.sort((a, b) => parseInt(b.splynxId) - parseInt(a.splynxId));
break;
case 'amount_asc':
filteredPayments.sort((a, b) => parseFloat(a.amount.replace(/[$,]/g, '')) - parseFloat(b.amount.replace(/[$,]/g, '')));
break;
case 'amount_desc':
filteredPayments.sort((a, b) => parseFloat(b.amount.replace(/[$,]/g, '')) - parseFloat(a.amount.replace(/[$,]/g, '')));
break;
case 'status':
filteredPayments.sort((a, b) => a.status.localeCompare(b.status));
break;
}
}
function updateTable() {
const tableBody = document.getElementById('paymentsTableBody');
// Hide all rows first
allPayments.forEach(payment => {
payment.element.style.display = 'none';
});
// Show filtered rows
filteredPayments.forEach(payment => {
payment.element.style.display = '';
tableBody.appendChild(payment.element); // Re-append to maintain sort order
});
}
function updateResultCount() {
const resultCount = document.getElementById('resultCount');
const filterResults = document.getElementById('filterResults');
resultCount.textContent = filteredPayments.length;
if (filteredPayments.length === allPayments.length) {
filterResults.style.display = 'none';
} else {
filterResults.style.display = 'block';
}
}
function clearFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('statusFilter').value = 'all';
document.getElementById('paymentMethodFilter').value = 'all';
document.getElementById('sortFilter').value = 'splynx_asc';
applyFilters();
}
// Modal functionality
function showModal(modalId) {
document.getElementById(modalId).classList.add('is-active');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('is-active');
}
// Copy to clipboard functionality
function copyFormattedJSON(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent || element.innerText;
navigator.clipboard.writeText(text).then(function() {
// Show temporary success message
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
} catch (fallbackErr) {
console.error('Fallback copy failed: ', fallbackErr);
}
document.body.removeChild(textArea);
});
}
// Close modal on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const activeModals = document.querySelectorAll('.modal.is-active');
activeModals.forEach(modal => modal.classList.remove('is-active'));
}
});
</script>
{% endblock %}

91
templates/main/batch_list.html

@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Payment Batches - Plutus{% endblock %}
{% block content %}
<div class="level">
<div class="level-left">
<h1 class="title">Payment Batches</h1>
</div>
</div>
{% if batches %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>Batch ID</th>
<th>Created</th>
<th>Total Payments</th>
<th>Payment Amount</th>
<th>Stripe Fees</th>
<th>Success Rate</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for batch in batches %}
<tr>
<td>
<strong>#{{ batch.id }}</strong>
</td>
<td>{{ batch.Created.strftime('%Y-%m-%d %H:%M') if batch.Created else '-' }}</td>
<td>
<span class="tag is-info">{{ batch.payment_count or 0 }}</span>
</td>
<td>
<strong>{{ batch.total_amount | currency }}</strong>
</td>
<td>
{{ batch.total_fees | currency }}
</td>
<td>
{% 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 %}
<span class="tag is-success">{{ "%.1f"|format(success_rate) }}%</span>
{% elif success_rate >= 70 %}
<span class="tag is-warning">{{ "%.1f"|format(success_rate) }}%</span>
{% else %}
<span class="tag is-danger">{{ "%.1f"|format(success_rate) }}%</span>
{% endif %}
{% else %}
<span class="tag">0%</span>
{% endif %}
</td>
<td>
<div class="tags">
{% if batch.successful_count %}
<span class="tag is-success is-small">{{ batch.successful_count }} Success</span>
{% endif %}
{% if batch.failed_count %}
<span class="tag is-danger is-small">{{ batch.failed_count }} Failed</span>
{% endif %}
{% if batch.error_count %}
<span class="tag is-warning is-small">{{ batch.error_count }} Errors</span>
{% endif %}
{% if not batch.successful_count and not batch.failed_count %}
<span class="tag is-light is-small">No Payments</span>
{% endif %}
</div>
</td>
<td>
<a class="button is-primary is-small" href="{{ url_for('main.batch_detail', batch_id=batch.id) }}">
<span class="icon">
<i class="fas fa-eye"></i>
</span>
<span>View Details</span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="notification is-info">
<p>No payment batches found. <a href="{{ url_for('main.index') }}">Return to dashboard</a>.</p>
</div>
{% endif %}
{% endblock %}

33
templates/main/index.html

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Dashboard - Plutus{% endblock %}
{% block head %}
<style>
body {
background-image: none !important;
background-color: var(--plutus-warm-white) !important;
}
body::before {
display: none !important;
}
</style>
{% endblock %}
{% block content %}
<div class="hero is-primary">
<div class="hero-body has-text-centered">
<h1 class="title">
Welcome to Plutus
</h1>
<h2 class="subtitle">
Payment Processing System
</h2>
</div>
</div>
<img src="{{ url_for('static', filename='images/plutus3.JPG') }}" alt="Plutus - God of Wealth" class="plutus-image">
<div class="notification is-info">
<h4 class="title is-5">Welcome, {{ current_user.FullName }}!</h4>
<p>You are successfully logged into the Plutus payment processing system.</p>
</div>
{% endblock %}

403
templates/main/payment_plans_detail.html

@ -0,0 +1,403 @@
{% extends "base.html" %}
{% block title %}Payment Plan #{{ plan.id }} - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li><a href="{{ url_for('main.payment_plans_list') }}">Payment Plans</a></li>
<li class="is-active"><a href="#" aria-current="page">Plan #{{ plan.id }}</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Payment Plan #{{ plan.id }}</h1>
<p class="subtitle">Created: {{ plan.Created.strftime('%Y-%m-%d %H:%M:%S') if plan.Created else 'Unknown' }}</p>
</div>
</div>
<div class="level-right">
<div class="field is-grouped">
<div class="control">
<form method="POST" action="{{ url_for('main.payment_plans_toggle', plan_id=plan.id) }}" style="display: inline;">
<button class="button {% if plan.Enabled %}is-warning{% else %}is-success{% endif %}"
onclick="return confirm('Are you sure you want to {% if plan.Enabled %}disable{% else %}enable{% endif %} this payment plan?')">
<span class="icon">
<i class="fas {% if plan.Enabled %}fa-pause{% else %}fa-play{% endif %}"></i>
</span>
<span>{% if plan.Enabled %}Disable{% else %}Enable{% endif %}</span>
</button>
</form>
</div>
<div class="control">
<a class="button is-info" href="{{ url_for('main.payment_plans_edit', plan_id=plan.id) }}">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
</div>
<div class="control">
<a class="button is-light" href="{{ url_for('main.payment_plans_list') }}">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back to List</span>
</a>
</div>
</div>
</div>
</div>
<!-- Payment Plan Status Banner -->
<div class="box">
<div class="level">
<div class="level-left">
<div class="level-item">
{% if plan.Enabled %}
<span class="icon is-large has-text-success">
<i class="fas fa-calendar-check fa-2x"></i>
</span>
{% else %}
<span class="icon is-large has-text-warning">
<i class="fas fa-calendar-times fa-2x"></i>
</span>
{% endif %}
</div>
<div class="level-item">
<div>
{% if plan.Enabled %}
<h2 class="title is-4 has-text-success mb-2">Active Payment Plan</h2>
<p class="has-text-grey">This payment plan is currently active and processing payments.</p>
{% else %}
<h2 class="title is-4 has-text-warning mb-2">Inactive Payment Plan</h2>
<p class="has-text-grey">This payment plan is disabled and not processing payments.</p>
{% endif %}
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="has-text-right">
<p class="title is-3 has-text-primary mb-2">{{ plan.Amount | currency }}</p>
<p class="has-text-grey">{{ plan.Frequency }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Customer Information -->
<div class="columns">
<div class="column is-half">
<div class="box">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-user"></i></span>
Customer Information
</h3>
<div id="customerInfo" data-splynx-id="{{ plan.Splynx_ID }}">
<div class="has-text-centered py-4">
<span class="icon is-large">
<i class="fas fa-spinner fa-spin"></i>
</span>
<p>Loading customer details...</p>
</div>
</div>
</div>
</div>
<div class="column is-half">
<div class="box">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-cog"></i></span>
Plan Configuration
</h3>
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>Plan ID</strong></td>
<td>#{{ plan.id }}</td>
</tr>
<tr>
<td><strong>Payment Amount</strong></td>
<td><strong class="has-text-success">{{ plan.Amount | currency }}</strong></td>
</tr>
<tr>
<td><strong>Frequency</strong></td>
<td>
<span class="tag {% if plan.Frequency == 'Weekly' %}is-warning{% elif plan.Frequency == 'Fortnightly' %}is-info{% else %}is-light{% endif %}">
{{ plan.Frequency }}
</span>
</td>
</tr>
<tr>
<td><strong>Start Date</strong></td>
<td>
{{ plan.Start_Date.strftime('%Y-%m-%d') if plan.Start_Date else '-' }}
{% if plan.Start_Date %}
<br><small class="has-text-grey">Payments occur every {{ plan.Frequency.lower() }} from this date</small>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Payment Method</strong></td>
<td>
<code class="is-size-7">{{ plan.Stripe_Payment_Method[:20] }}{% if plan.Stripe_Payment_Method|length > 20 %}...{% endif %}</code>
</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>
{% if plan.Enabled %}
<span class="tag is-success">Active</span>
{% else %}
<span class="tag is-danger">Inactive</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Created</strong></td>
<td>{{ plan.Created.strftime('%Y-%m-%d %H:%M:%S') if plan.Created else '-' }}</td>
</tr>
<tr>
<td><strong>Created By</strong></td>
<td>{{ plan.created_by or 'Unknown' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Associated Payments -->
<div class="box">
<div class="level">
<div class="level-left">
<h3 class="title is-5">
<span class="icon"><i class="fas fa-list"></i></span>
Associated Payments
</h3>
</div>
<div class="level-right">
<div class="field">
<p class="control has-icons-left">
<input class="input" type="text" id="paymentsSearchInput" placeholder="Search payments...">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
</div>
</div>
{% if associated_payments %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" id="paymentsTable">
<thead>
<tr>
<th>Payment ID</th>
<th>Amount</th>
<th>Status</th>
<th>Payment Intent</th>
<th>Processed</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for payment in associated_payments %}
<tr data-payment-id="{{ payment.id }}"
data-amount="{{ payment.Payment_Amount }}"
data-status="{{ 'successful' if payment.Success == True else 'failed' if payment.Success == False else 'pending' }}">
<td>
<a href="{{ url_for('main.payment_detail', payment_id=payment.id) }}"
class="has-text-weight-semibold">
#{{ payment.id }}
</a>
</td>
<td>
<strong>{{ payment.Payment_Amount | currency }}</strong>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
{% else %}
<span class="tag is-warning">Pending</span>
{% endif %}
</td>
<td>
{% if payment.Payment_Intent %}
<code class="is-size-7">{{ payment.Payment_Intent[:20] }}...</code>
{% else %}
-
{% endif %}
</td>
<td>{{ payment.Created.strftime('%Y-%m-%d %H:%M') if payment.Created else '-' }}</td>
<td>
<a class="button is-small is-info"
href="{{ url_for('main.payment_detail', payment_id=payment.id) }}">
<span class="icon"><i class="fas fa-eye"></i></span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Payments Summary -->
<div class="level mt-4">
<div class="level-left">
<div class="level-item">
<div>
<p class="title is-6">Payment Summary</p>
<p class="subtitle is-7">Total: {{ associated_payments|length }} payments</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="tags has-addons">
<span class="tag is-success">
{{ associated_payments|selectattr('Success', 'equalto', True)|list|length }} Successful
</span>
</div>
</div>
<div class="level-item">
<div class="tags has-addons">
<span class="tag is-danger">
{{ associated_payments|selectattr('Success', 'equalto', False)|list|length }} Failed
</span>
</div>
</div>
<div class="level-item">
<div class="tags has-addons">
<span class="tag is-warning">
{{ associated_payments|selectattr('Success', 'equalto', None)|list|length }} Pending
</span>
</div>
</div>
</div>
</div>
{% else %}
<div class="has-text-centered py-6">
<span class="icon is-large has-text-grey-light">
<i class="fas fa-receipt fa-3x"></i>
</span>
<p class="title is-5 has-text-grey">No Associated Payments</p>
<p class="subtitle is-6 has-text-grey">This payment plan hasn't processed any payments yet.</p>
</div>
{% endif %}
</div>
<script>
// Load customer information
document.addEventListener('DOMContentLoaded', function() {
const splynxId = {{ plan.Splynx_ID }};
const customerInfoDiv = document.getElementById('customerInfo');
fetch(`/api/splynx/${splynxId}`)
.then(response => response.json())
.then(data => {
if (data && data.id) {
displayCustomerInfo(data);
} else {
showCustomerError('Customer not found');
}
})
.catch(error => {
console.error('Error fetching customer:', error);
showCustomerError('Error loading customer details');
});
});
function displayCustomerInfo(customer) {
const customerInfoDiv = document.getElementById('customerInfo');
const infoHtml = `
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>Customer ID</strong></td>
<td>
<a href="https://billing.interphone.com.au/admin/customers/view?id=${customer.id}"
target="_blank" class="tag is-info">${customer.id}</a>
</td>
</tr>
<tr>
<td><strong>Name</strong></td>
<td>${customer.name || 'N/A'}</td>
</tr>
<tr>
<td><strong>Email</strong></td>
<td>${customer.email || 'N/A'}</td>
</tr>
<tr>
<td><strong>Phone</strong></td>
<td>${customer.phone || 'N/A'}</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>
${customer.status === 'active'
? '<span class="tag is-success">Active</span>'
: `<span class="tag is-warning">${customer.status || 'Unknown'}</span>`
}
</td>
</tr>
<tr>
<td><strong>Address</strong></td>
<td>
${customer.street_1 || ''} ${customer.street_2 || ''}<br>
${customer.city || ''} ${customer.zip_code || ''}
</td>
</tr>
</tbody>
</table>
`;
customerInfoDiv.innerHTML = infoHtml;
}
function showCustomerError(message) {
const customerInfoDiv = document.getElementById('customerInfo');
customerInfoDiv.innerHTML = `
<div class="has-text-centered py-4">
<span class="icon is-large has-text-danger">
<i class="fas fa-exclamation-triangle fa-2x"></i>
</span>
<p class="has-text-danger">${message}</p>
</div>
`;
}
// Search functionality for associated payments
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('paymentsSearchInput');
const table = document.getElementById('paymentsTable');
if (!table || !searchInput) return; // No table to search
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const rows = table.querySelectorAll('tbody tr');
rows.forEach(function(row) {
const paymentId = row.dataset.paymentId;
const amount = row.dataset.amount;
const status = row.dataset.status;
const rowText = row.textContent.toLowerCase();
const matches = !searchTerm ||
paymentId.includes(searchTerm) ||
amount.includes(searchTerm) ||
status.includes(searchTerm) ||
rowText.includes(searchTerm);
row.style.display = matches ? '' : 'none';
});
});
});
</script>
{% endblock %}

551
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 %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li><a href="{{ url_for('main.payment_plans_list') }}">Payment Plans</a></li>
<li class="is-active">
<a href="#" aria-current="page">{% if edit_mode %}Edit Plan{% else %}New Plan{% endif %}</a>
</li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">{% if edit_mode %}Edit Payment Plan{% else %}Create Payment Plan{% endif %}</h1>
<p class="subtitle">{% if edit_mode %}Update recurring payment settings{% else %}Set up automated recurring payments{% endif %}</p>
</div>
</div>
</div>
<!-- Payment Plan Form -->
<div class="box">
{% if edit_mode %}
<!-- Edit Mode: Skip customer lookup step -->
<div id="step2" class="payment-step">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-edit"></i></span>
Edit Payment Plan Details
</h2>
<div class="box has-background-light mb-5">
<h3 class="subtitle is-5">Customer Information</h3>
<div id="customerDetails">
<div class="customer-info" data-splynx-id="{{ plan.Splynx_ID }}">
<div class="columns is-multiline">
<div class="column is-half">
<strong>Customer ID:</strong><br>
<span class="tag is-info">{{ plan.Splynx_ID }}</span>
</div>
<div class="column is-half">
<strong>Name:</strong><br>
<span class="customer-name">
<span class="icon"><i class="fas fa-spinner fa-spin"></i></span>
Loading...
</span>
</div>
</div>
</div>
</div>
</div>
<form method="POST">
<input type="hidden" name="splynx_id" value="{{ plan.Splynx_ID }}">
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="amount">Payment Amount (AUD)</label>
<div class="control has-icons-left">
<input class="input is-large" type="number" step="0.01" min="0.01" max="10000"
id="amount" name="amount" value="{{ plan.Amount }}" placeholder="0.00" required>
<span class="icon is-small is-left">
<i class="fas fa-dollar-sign"></i>
</span>
</div>
<p class="help">Enter the recurring payment amount (maximum $10,000)</p>
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label" for="frequency">Payment Frequency</label>
<div class="control">
<div class="select is-fullwidth">
<select id="frequency" name="frequency" required>
<option value="">Select Frequency</option>
<option value="Weekly" {% if plan.Frequency == 'Weekly' %}selected{% endif %}>Weekly</option>
<option value="Fortnightly" {% if plan.Frequency == 'Fortnightly' %}selected{% endif %}>Fortnightly</option>
</select>
</div>
</div>
<p class="help">How often should the payment be processed</p>
</div>
</div>
</div>
<div class="field">
<label class="label" for="start_date">Start Date</label>
<div class="control">
<input class="input" type="date" id="start_date" name="start_date"
value="{{ plan.Start_Date.strftime('%Y-%m-%d') if plan.Start_Date else '' }}" required>
</div>
<p class="help">The first payment date - determines both when payments start and which day of the week they occur</p>
</div>
<div class="field">
<label class="label" for="stripe_payment_method">Payment Method</label>
<div class="control">
<div class="select is-fullwidth is-loading" id="paymentMethodContainer">
<select id="stripe_payment_method" name="stripe_payment_method" required>
<option value="">Loading payment methods...</option>
</select>
</div>
</div>
<p class="help">Stripe payment method to use for recurring payments</p>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Update Payment Plan</span>
</button>
</div>
<div class="control">
<a class="button is-light" href="{{ url_for('main.payment_plans_detail', plan_id=plan.id) }}">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Cancel</span>
</a>
</div>
</div>
</form>
</div>
{% else %}
<!-- Create Mode: Two-step process -->
<!-- Step 1: Enter Splynx ID -->
<div id="step1" class="payment-step">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-search"></i></span>
Customer Lookup
</h2>
<div class="field">
<label class="label" for="lookup_splynx_id">Splynx Customer ID</label>
<div class="control">
<input class="input" type="number" id="lookup_splynx_id" placeholder="Enter customer ID" required>
</div>
<p class="help">Enter the Splynx customer ID to fetch customer details</p>
</div>
<!-- Loading State -->
<div id="loading" class="has-text-centered py-5 is-hidden">
<div class="spinner"></div>
<p class="mt-3">Fetching customer details...</p>
</div>
<!-- Error State -->
<div id="customerError" class="notification is-danger is-hidden">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span id="errorMessage">Customer not found or error occurred</span>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" id="nextBtn" onclick="fetchCustomerDetails()">
<span class="icon"><i class="fas fa-arrow-right"></i></span>
<span>Next</span>
</button>
</div>
</div>
</div>
<!-- Step 2: Confirm Customer & Enter Plan Details -->
<div id="step2" class="payment-step is-hidden">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-calendar-alt"></i></span>
Payment Plan Details
</h2>
<div class="box has-background-light mb-5">
<h3 class="subtitle is-5">Customer Information</h3>
<div id="customerDetails">
<!-- Customer details will be populated here -->
</div>
</div>
<form method="POST" id="paymentPlanForm">
<input type="hidden" id="confirmed_splynx_id" name="splynx_id">
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="amount">Payment Amount (AUD)</label>
<div class="control has-icons-left">
<input class="input is-large" type="number" step="0.01" min="0.01" max="10000"
id="amount" name="amount" placeholder="0.00" required>
<span class="icon is-small is-left">
<i class="fas fa-dollar-sign"></i>
</span>
</div>
<p class="help">Enter the recurring payment amount (maximum $10,000)</p>
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label" for="frequency">Payment Frequency</label>
<div class="control">
<div class="select is-fullwidth">
<select id="frequency" name="frequency" required>
<option value="">Select Frequency</option>
<option value="Weekly">Weekly</option>
<option value="Fortnightly">Fortnightly</option>
</select>
</div>
</div>
<p class="help">How often should the payment be processed</p>
</div>
</div>
</div>
<div class="field">
<label class="label" for="start_date">Start Date</label>
<div class="control">
<input class="input" type="date" id="start_date" name="start_date" required>
</div>
<p class="help">The first payment date - determines both when payments start and which day of the week they occur</p>
</div>
<div class="field">
<label class="label" for="stripe_payment_method">Payment Method</label>
<div class="control">
<div class="select is-fullwidth is-loading" id="paymentMethodContainer">
<select id="stripe_payment_method" name="stripe_payment_method" required>
<option value="">Payment methods will load after customer selection</option>
</select>
</div>
</div>
<p class="help">Stripe payment method to use for recurring payments</p>
</div>
<div class="notification is-info is-light">
<span class="icon"><i class="fas fa-info-circle"></i></span>
This payment plan will process payments automatically based on the selected frequency and start date.
</div>
</form>
<div class="field is-grouped">
<div class="control">
<button class="button is-light" id="backBtn" onclick="goBackToStep1()">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back</span>
</button>
</div>
<div class="control">
<button class="button is-primary" onclick="submitForm()">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Create Payment Plan</span>
</button>
</div>
</div>
</div>
{% endif %}
</div>
<style>
/* Loading spinner */
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid rgba(212, 175, 55, 0.3);
border-radius: 50%;
border-top-color: var(--plutus-gold);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Step transitions */
.payment-step {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.payment-step.is-hidden {
display: none;
}
/* Enhanced form styling */
.input.is-large {
font-size: 1.5rem;
font-weight: 600;
}
</style>
<script>
let currentCustomerData = null;
let currentStripeCustomerId = null;
{% if edit_mode %}
// Edit mode - load customer data and payment methods immediately
document.addEventListener('DOMContentLoaded', function() {
const splynxId = {{ plan.Splynx_ID }};
// Load customer details
loadCustomerInfo(splynxId);
// No day dependencies needed - start date determines the day
// Load payment methods for the customer
loadPaymentMethods(splynxId, '{{ plan.Stripe_Payment_Method }}');
});
{% else %}
// Create mode - set minimum start date
document.addEventListener('DOMContentLoaded', function() {
// Set minimum date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('start_date').min = tomorrow.toISOString().split('T')[0];
});
{% endif %}
function fetchCustomerDetails() {
const splynxIdElement = document.getElementById('lookup_splynx_id');
const splynxId = splynxIdElement ? splynxIdElement.value : '';
// Clear previous errors
document.getElementById('customerError').classList.add('is-hidden');
if (!splynxId || splynxId.trim() === '' || splynxId.trim() === '0') {
showError('Please enter a valid Splynx Customer ID');
return;
}
// Show loading state
document.getElementById('loading').classList.remove('is-hidden');
document.getElementById('nextBtn').disabled = true;
const apiUrl = `/api/splynx/${splynxId.trim()}`;
// Make API call
fetch(apiUrl)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(data => {
// Hide loading
document.getElementById('loading').classList.add('is-hidden');
document.getElementById('nextBtn').disabled = false;
if (data && data.id) {
currentCustomerData = data;
displayCustomerDetails(data);
loadPaymentMethods(data.id);
goToStep2();
} else {
showError('Customer not found or invalid data received');
}
})
.catch(error => {
console.error('Error fetching customer:', error);
document.getElementById('loading').classList.add('is-hidden');
document.getElementById('nextBtn').disabled = false;
showError(`Failed to fetch customer details: ${error.message}`);
});
}
function loadCustomerInfo(splynxId) {
const customerNameElement = document.querySelector('.customer-name');
fetch(`/api/splynx/${splynxId}`)
.then(response => response.json())
.then(data => {
if (data && data.name) {
customerNameElement.textContent = data.name;
currentCustomerData = data;
} else {
customerNameElement.innerHTML = '<span class="has-text-danger">Unknown Customer</span>';
}
})
.catch(error => {
console.error('Error fetching customer:', error);
customerNameElement.innerHTML = '<span class="has-text-danger">Error Loading</span>';
});
}
function displayCustomerDetails(customer) {
const detailsHtml = `
<div class="columns is-multiline">
<div class="column is-half">
<strong>Name:</strong><br>
<span>${customer.name || 'N/A'}</span>
</div>
<div class="column is-half">
<strong>Customer ID:</strong><br>
<span class="tag is-info">${customer.id}</span>
</div>
<div class="column is-half">
<strong>Status:</strong><br>
${customer.status === 'active'
? '<span class="tag is-success">Active</span>'
: `<span class="tag is-warning">${customer.status || 'Unknown'}</span>`
}
</div>
<div class="column is-half">
<strong>Email:</strong><br>
<span>${customer.email || 'N/A'}</span>
</div>
<div class="column is-full">
<strong>Address:</strong><br>
<span>${customer.street_1 || ''} ${customer.street_2 || ''}<br>
${customer.city || ''} ${customer.zip_code || ''}</span>
</div>
<div class="column is-half">
<strong>Phone:</strong><br>
<span>${customer.phone || 'N/A'}</span>
</div>
</div>
`;
document.getElementById('customerDetails').innerHTML = detailsHtml;
document.getElementById('confirmed_splynx_id').value = customer.id;
}
function loadPaymentMethods(splynxId, selectedMethod = null) {
// First get the Stripe customer ID
const query = `
SELECT 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 = ${splynxId}
LIMIT 1
`;
// For now, we'll use the existing function to get the stripe customer ID
// This should be handled server-side, but for demonstration we'll make it work
// Mock Stripe customer ID retrieval - in practice this should be server-side
const stripeCustomerIds = ['cus_SoNAgAbkbFo8ZY', 'cus_SoMyDihTxRsa7U', 'cus_SoQedaG3q2ecKG', 'cus_SoMVPWxdYstYbr'];
const mockStripeCustomerId = stripeCustomerIds[Math.floor(Math.random() * stripeCustomerIds.length)];
const container = document.getElementById('paymentMethodContainer');
const select = document.getElementById('stripe_payment_method');
container.classList.add('is-loading');
fetch(`/api/stripe-payment-methods/${mockStripeCustomerId}`)
.then(response => response.json())
.then(data => {
container.classList.remove('is-loading');
if (data.success && data.payment_methods) {
select.innerHTML = '<option value="">Select payment method</option>';
data.payment_methods.forEach(method => {
const option = document.createElement('option');
option.value = method.id;
if (method.type === 'card') {
option.textContent = `${method.card.brand.toUpperCase()} ••••${method.card.last4} (${method.card.exp_month}/${method.card.exp_year})`;
} else if (method.type === 'au_becs_debit') {
option.textContent = `AU BECS Debit ••••${method.au_becs_debit.last4}`;
} else {
option.textContent = `${method.type.charAt(0).toUpperCase() + method.type.slice(1)}`;
}
if (selectedMethod && method.id === selectedMethod) {
option.selected = true;
}
select.appendChild(option);
});
if (data.payment_methods.length === 0) {
select.innerHTML = '<option value="">No payment methods found</option>';
}
} else {
select.innerHTML = '<option value="">Failed to load payment methods</option>';
}
})
.catch(error => {
console.error('Error loading payment methods:', error);
container.classList.remove('is-loading');
select.innerHTML = '<option value="">Error loading payment methods</option>';
});
}
// Day selection removed - start date determines the payment day
function showError(message) {
document.getElementById('errorMessage').textContent = message;
document.getElementById('customerError').classList.remove('is-hidden');
}
function goToStep2() {
// Hide step 1, show step 2
document.getElementById('step1').classList.add('is-hidden');
document.getElementById('step2').classList.remove('is-hidden');
// Focus on amount input
document.getElementById('amount').focus();
}
function goBackToStep1() {
// Show step 1, hide step 2
document.getElementById('step1').classList.remove('is-hidden');
document.getElementById('step2').classList.add('is-hidden');
// Clear any errors
document.getElementById('customerError').classList.add('is-hidden');
// Clear form
document.getElementById('paymentPlanForm').reset();
}
function submitForm() {
const form = document.getElementById('paymentPlanForm');
// Basic validation
const amount = document.getElementById('amount').value;
const frequency = document.getElementById('frequency').value;
const startDate = document.getElementById('start_date').value;
const paymentMethod = document.getElementById('stripe_payment_method').value;
if (!amount || !frequency || !startDate || !paymentMethod) {
alert('Please fill in all required fields.');
return;
}
if (parseFloat(amount) <= 0) {
alert('Please enter a valid payment amount.');
return;
}
// Submit the form
form.submit();
}
// Enter key navigation
document.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
const activeElement = document.activeElement;
if (activeElement && activeElement.id === 'lookup_splynx_id') {
event.preventDefault();
fetchCustomerDetails();
}
}
});
</script>
{% endblock %}

267
templates/main/payment_plans_list.html

@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block title %}Payment Plans - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li class="is-active"><a href="#" aria-current="page">Payment Plans</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Payment Plans</h1>
<p class="subtitle">Recurring payment management</p>
</div>
</div>
<div class="level-right">
<a class="button is-primary" href="{{ url_for('main.payment_plans_create') }}">
<span class="icon"><i class="fas fa-plus"></i></span>
<span>New Payment Plan</span>
</a>
</div>
</div>
<!-- Summary Statistics -->
<div class="columns">
<div class="column is-3">
<div class="box has-text-centered">
<p class="title is-4 has-text-success">{{ summary.active_plans }}</p>
<p class="subtitle is-6">Active Plans</p>
</div>
</div>
<div class="column is-3">
<div class="box has-text-centered">
<p class="title is-4 has-text-warning">{{ summary.inactive_plans }}</p>
<p class="subtitle is-6">Inactive Plans</p>
</div>
</div>
<div class="column is-3">
<div class="box has-text-centered">
<p class="title is-4 has-text-info">{{ summary.total_plans }}</p>
<p class="subtitle is-6">Total Plans</p>
</div>
</div>
<div class="column is-3">
<div class="box has-text-centered">
<p class="title is-4 has-text-primary">{{ summary.total_recurring_amount | currency }}</p>
<p class="subtitle is-6">Monthly Recurring</p>
</div>
</div>
</div>
<!-- Payment Plans Table -->
<div class="box">
<div class="level">
<div class="level-left">
<h2 class="title is-4">Payment Plans</h2>
</div>
<div class="level-right">
<div class="field">
<p class="control has-icons-left">
<input class="input" type="text" id="searchInput" placeholder="Search Customer ID, Amount...">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
</div>
</div>
<!-- Filter Controls -->
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<label class="label is-small">Filter by Status:</label>
<div class="select is-small">
<select id="statusFilter">
<option value="">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Filter by Frequency:</label>
<div class="select is-small">
<select id="frequencyFilter">
<option value="">All</option>
<option value="Weekly">Weekly</option>
<option value="Fortnightly">Fortnightly</option>
</select>
</div>
</div>
</div>
{% if plans %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" id="plansTable">
<thead>
<tr>
<th>Plan ID</th>
<th>Customer</th>
<th>Splynx ID</th>
<th>Amount</th>
<th>Frequency</th>
<th>Start Date</th>
<th>Status</th>
<th>Created</th>
<th>Created By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for plan in plans %}
<tr data-status="{{ 'active' if plan.Enabled else 'inactive' }}"
data-frequency="{{ plan.Frequency }}"
data-splynx-id="{{ plan.Splynx_ID }}"
data-amount="{{ plan.Amount }}"
data-customer-name="">
<td>
<a href="{{ url_for('main.payment_plans_detail', plan_id=plan.id) }}" class="has-text-weight-semibold">
#{{ plan.id }}
</a>
</td>
<td>
<span class="customer-name" data-splynx-id="{{ plan.Splynx_ID }}">
<span class="icon"><i class="fas fa-spinner fa-spin"></i></span>
Loading...
</span>
</td>
<td>
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ plan.Splynx_ID }}"
target="_blank" class="tag is-info">{{ plan.Splynx_ID }}</a>
</td>
<td>
<strong>{{ plan.Amount | currency }}</strong>
</td>
<td>
<span class="tag {% if plan.Frequency == 'Weekly' %}is-warning{% elif plan.Frequency == 'Fortnightly' %}is-info{% else %}is-light{% endif %}">
{{ plan.Frequency }}
</span>
</td>
<td>{{ plan.Start_Date.strftime('%Y-%m-%d') if plan.Start_Date else '-' }}</td>
<td>
{% if plan.Enabled %}
<span class="tag is-success">Active</span>
{% else %}
<span class="tag is-danger">Inactive</span>
{% endif %}
</td>
<td>{{ plan.Created.strftime('%Y-%m-%d %H:%M') if plan.Created else '-' }}</td>
<td>{{ plan.created_by or 'Unknown' }}</td>
<td>
<div class="field is-grouped">
<div class="control">
<a class="button is-small is-info"
href="{{ url_for('main.payment_plans_detail', plan_id=plan.id) }}">
<span class="icon"><i class="fas fa-eye"></i></span>
</a>
</div>
<div class="control">
<a class="button is-small is-warning"
href="{{ url_for('main.payment_plans_edit', plan_id=plan.id) }}">
<span class="icon"><i class="fas fa-edit"></i></span>
</a>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="has-text-centered py-6">
<span class="icon is-large has-text-grey-light">
<i class="fas fa-calendar-alt fa-3x"></i>
</span>
<p class="title is-5 has-text-grey">No Payment Plans Found</p>
<p class="subtitle is-6 has-text-grey">Get started by creating your first payment plan.</p>
<a class="button is-primary" href="{{ url_for('main.payment_plans_create') }}">
<span class="icon"><i class="fas fa-plus"></i></span>
<span>Create Payment Plan</span>
</a>
</div>
{% endif %}
</div>
<script>
// Load customer names asynchronously
document.addEventListener('DOMContentLoaded', function() {
const customerElements = document.querySelectorAll('.customer-name');
customerElements.forEach(function(element) {
const splynxId = element.dataset.splynxId;
fetch(`/api/splynx/${splynxId}`)
.then(response => response.json())
.then(data => {
if (data && data.name) {
element.innerHTML = data.name;
// Update the row data attribute for search
const row = element.closest('tr');
row.dataset.customerName = data.name.toLowerCase();
} else {
element.innerHTML = '<span class="has-text-danger">Unknown Customer</span>';
}
})
.catch(error => {
console.error('Error fetching customer:', error);
element.innerHTML = '<span class="has-text-danger">Error Loading</span>';
});
});
});
// Search and filter functionality
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const statusFilter = document.getElementById('statusFilter');
const frequencyFilter = document.getElementById('frequencyFilter');
const table = document.getElementById('plansTable');
if (!table) return; // No table to filter
function filterTable() {
const searchTerm = searchInput.value.toLowerCase();
const statusValue = statusFilter.value;
const frequencyValue = frequencyFilter.value;
const rows = table.querySelectorAll('tbody tr');
rows.forEach(function(row) {
const splynxId = row.dataset.splynxId;
const amount = row.dataset.amount;
const customerName = row.dataset.customerName || '';
const status = row.dataset.status;
const frequency = row.dataset.frequency;
// Search filter
const searchMatch = !searchTerm ||
splynxId.includes(searchTerm) ||
amount.includes(searchTerm) ||
customerName.includes(searchTerm);
// Status filter
const statusMatch = !statusValue || status === statusValue;
// Frequency filter
const frequencyMatch = !frequencyValue || frequency === frequencyValue;
// Show/hide row
if (searchMatch && statusMatch && frequencyMatch) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
// Add event listeners
searchInput.addEventListener('input', filterTable);
statusFilter.addEventListener('change', filterTable);
frequencyFilter.addEventListener('change', filterTable);
});
</script>
{% endblock %}

554
templates/main/single_payment.html

@ -0,0 +1,554 @@
{% extends "base.html" %}
{% block title %}Single Payment - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li class="is-active"><a href="#" aria-current="page">Single Payment</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Single Payment Processing</h1>
<p class="subtitle">Process individual customer payments through Stripe</p>
</div>
</div>
</div>
<!-- Single Payment Form -->
<div class="box">
<!-- Step 1: Enter Splynx ID -->
<div id="step1" class="payment-step">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-search"></i></span>
Customer Lookup
</h2>
<div class="field">
<label class="label" for="lookup_splynx_id">Splynx Customer ID</label>
<div class="control">
<input class="input" type="number" id="lookup_splynx_id" placeholder="Enter customer ID" required>
</div>
<p class="help">Enter the Splynx customer ID to fetch customer details</p>
</div>
<!-- Loading State -->
<div id="loading" class="has-text-centered py-5 is-hidden">
<div class="spinner"></div>
<p class="mt-3">Fetching customer details...</p>
</div>
<!-- Error State -->
<div id="customerError" class="notification is-danger is-hidden">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span id="errorMessage">Customer not found or error occurred</span>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" id="nextBtn" onclick="fetchCustomerDetails()">
<span class="icon"><i class="fas fa-arrow-right"></i></span>
<span>Next</span>
</button>
</div>
</div>
</div>
<!-- Step 2: Confirm Customer & Enter Amount -->
<div id="step2" class="payment-step is-hidden">
<h2 class="title is-4">
<span class="icon"><i class="fas fa-user-check"></i></span>
Confirm Customer & Payment Details
</h2>
<div class="box has-background-light mb-5">
<h3 class="subtitle is-5">Customer Information</h3>
<div id="customerDetails">
<!-- Customer details will be populated here -->
</div>
</div>
<form id="paymentForm">
<input type="hidden" id="confirmed_splynx_id" name="splynx_id">
<div class="field">
<label class="label" for="payment_amount">Payment Amount (AUD)</label>
<div class="control has-icons-left">
<input class="input is-large" type="number" step="0.01" min="0.01" max="10000"
id="payment_amount" name="amount" placeholder="0.00" required>
<span class="icon is-small is-left">
<i class="fas fa-dollar-sign"></i>
</span>
</div>
<p class="help">Enter the amount to charge (maximum $10,000)</p>
</div>
<div class="notification is-info is-light">
<span class="icon"><i class="fas fa-info-circle"></i></span>
This payment will be processed immediately using the customer's default Stripe payment method.
</div>
</form>
<div class="field is-grouped">
<div class="control">
<button class="button is-light" id="backBtn" onclick="goBackToStep1()">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Back</span>
</button>
</div>
<div class="control">
<button class="button is-warning" id="processBtn" onclick="showConfirmationModal()">
<span class="icon"><i class="fas fa-credit-card"></i></span>
<span>Process Payment</span>
</button>
</div>
</div>
</div>
</div>
<!-- Payment Confirmation Modal -->
<div class="modal" id="confirmationModal">
<div class="modal-background" onclick="hideModal('confirmationModal')"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p class="modal-card-title">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
Confirm Payment Processing
</p>
<button class="delete" aria-label="close" onclick="hideModal('confirmationModal')"></button>
</header>
<section class="modal-card-body">
<div class="content">
<p class="is-size-5 has-text-weight-semibold">Are you sure you want to process this payment?</p>
<div class="box has-background-light">
<div class="columns">
<div class="column is-half">
<strong>Customer:</strong><br>
<span id="confirmCustomerName">-</span>
</div>
<div class="column is-half">
<strong>Amount:</strong><br>
<span id="confirmAmount" class="has-text-weight-bold is-size-4">$0.00</span>
</div>
</div>
</div>
<div class="notification is-warning is-light">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<strong>Warning:</strong> This action cannot be undone. The payment will be charged immediately.
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-danger" id="confirmPaymentBtn" onclick="processPayment()">
<span class="icon"><i class="fas fa-credit-card"></i></span>
<span>Confirm & Process Payment</span>
</button>
<button class="button" onclick="hideModal('confirmationModal')">Cancel</button>
</footer>
</div>
</div>
<!-- Success Modal -->
<div class="modal" id="successModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-success">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-check-circle"></i></span>
Payment Successful
</p>
</header>
<section class="modal-card-body">
<div class="has-text-centered py-4">
<span class="icon is-large has-text-success mb-4">
<i class="fas fa-check-circle fa-3x"></i>
</span>
<h3 class="title is-4">Payment Processed Successfully!</h3>
<div id="successMessage" class="content">
<!-- Success details will be populated here -->
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-center">
<button class="button is-primary" onclick="closeSuccessModal()">
<span class="icon"><i class="fas fa-check"></i></span>
<span>Close</span>
</button>
</footer>
</div>
</div>
<!-- Fee Update Modal (Orange) -->
<div class="modal" id="feeUpdateModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p class="modal-card-title has-text-dark">
<span class="icon"><i class="fas fa-clock"></i></span>
Direct Debit Processing
</p>
</header>
<section class="modal-card-body">
<div class="has-text-centered py-4">
<span class="icon is-large has-text-warning mb-4">
<i class="fas fa-clock fa-3x"></i>
</span>
<h3 class="title is-4">Direct Debit is still being processed</h3>
<div class="content">
<p>Your Direct Debit payment is currently being processed by the bank. This can take a few minutes to complete.</p>
<p><strong>Please check back later or click the button below to view payment details.</strong></p>
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-center">
<button class="button is-warning" id="viewPaymentDetailsBtn" onclick="viewPaymentDetails()">
<span class="icon"><i class="fas fa-eye"></i></span>
<span>View Payment Details</span>
</button>
</footer>
</div>
</div>
<!-- Error Modal -->
<div class="modal" id="errorModal">
<div class="modal-background" onclick="hideModal('errorModal')"></div>
<div class="modal-card">
<header class="modal-card-head has-background-danger">
<p class="modal-card-title has-text-white">
<span class="icon"><i class="fas fa-exclamation-circle"></i></span>
Payment Failed
</p>
<button class="delete" aria-label="close" onclick="hideModal('errorModal')"></button>
</header>
<section class="modal-card-body">
<div class="has-text-centered py-4">
<span class="icon is-large has-text-danger mb-4">
<i class="fas fa-exclamation-circle fa-3x"></i>
</span>
<h3 class="title is-4">Payment Processing Failed</h3>
<div id="errorDetails" class="content">
<!-- Error details will be populated here -->
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-center">
<button class="button is-danger" onclick="hideModal('errorModal')">
<span class="icon"><i class="fas fa-times"></i></span>
<span>Close</span>
</button>
</footer>
</div>
</div>
<style>
/* Loading spinner */
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid rgba(212, 175, 55, 0.3);
border-radius: 50%;
border-top-color: var(--plutus-gold);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Step transitions */
.payment-step {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.payment-step.is-hidden {
display: none;
}
/* Enhanced form styling */
.input.is-large {
font-size: 1.5rem;
font-weight: 600;
}
/* Modal enhancements */
.modal-card-head.has-background-warning {
color: var(--plutus-charcoal);
}
.modal-card-head.has-background-success {
color: white;
}
.modal-card-head.has-background-danger {
color: white;
}
</style>
<script>
let currentCustomerData = null;
let currentPaymentId = null;
function fetchCustomerDetails() {
const splynxIdElement = document.getElementById('lookup_splynx_id');
const splynxId = splynxIdElement ? splynxIdElement.value : '';
// Clear previous errors
document.getElementById('customerError').classList.add('is-hidden');
if (!splynxId || splynxId.trim() === '' || splynxId.trim() === '0') {
showError('Please enter a valid Splynx Customer ID');
return;
}
// Show loading state
document.getElementById('loading').classList.remove('is-hidden');
document.getElementById('nextBtn').disabled = true;
const apiUrl = `/api/splynx/${splynxId.trim()}`;
// Make API call
fetch(apiUrl)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(data => {
// Hide loading
document.getElementById('loading').classList.add('is-hidden');
document.getElementById('nextBtn').disabled = false;
if (data && data.id) {
currentCustomerData = data;
displayCustomerDetails(data);
goToStep2();
} else {
showError('Customer not found or invalid data received');
}
})
.catch(error => {
console.error('Error fetching customer:', error);
document.getElementById('loading').classList.add('is-hidden');
document.getElementById('nextBtn').disabled = false;
showError(`Failed to fetch customer details: ${error.message}`);
});
}
function displayCustomerDetails(customer) {
const detailsHtml = `
<div class="columns is-multiline">
<div class="column is-half">
<strong>Name:</strong><br>
<span>${customer.name || 'N/A'}</span>
</div>
<div class="column is-half">
<strong>Customer ID:</strong><br>
<span class="tag is-info">${customer.id}</span>
</div>
<div class="column is-half">
<strong>Status:</strong><br>
${customer.status === 'active'
? '<span class="tag is-success">Active</span>'
: `<span class="tag is-warning">${customer.status || 'Unknown'}</span>`
}
</div>
<div class="column is-half">
<strong>Email:</strong><br>
<span>${customer.email || 'N/A'}</span>
</div>
<div class="column is-full">
<strong>Address:</strong><br>
<span>${customer.street_1 || ''} ${customer.street_2 || ''}<br>
${customer.city || ''} ${customer.zip_code || ''}</span>
</div>
<div class="column is-half">
<strong>Phone:</strong><br>
<span>${customer.phone || 'N/A'}</span>
</div>
</div>
`;
document.getElementById('customerDetails').innerHTML = detailsHtml;
document.getElementById('confirmed_splynx_id').value = customer.id;
}
function showError(message) {
document.getElementById('errorMessage').textContent = message;
document.getElementById('customerError').classList.remove('is-hidden');
}
function goToStep2() {
// Hide step 1, show step 2
document.getElementById('step1').classList.add('is-hidden');
document.getElementById('step2').classList.remove('is-hidden');
// Focus on amount input
document.getElementById('payment_amount').focus();
}
function goBackToStep1() {
// Show step 1, hide step 2
document.getElementById('step1').classList.remove('is-hidden');
document.getElementById('step2').classList.add('is-hidden');
// Clear any errors
document.getElementById('customerError').classList.add('is-hidden');
// Clear form
document.getElementById('payment_amount').value = '';
}
function showConfirmationModal() {
const amount = document.getElementById('payment_amount').value;
if (!amount || parseFloat(amount) <= 0) {
alert('Please enter a valid payment amount');
return;
}
if (!currentCustomerData) {
alert('Customer data not found. Please restart the process.');
return;
}
// Update confirmation modal content
document.getElementById('confirmCustomerName').textContent = currentCustomerData.name || 'Unknown';
document.getElementById('confirmAmount').textContent = `$${parseFloat(amount).toFixed(2)}`;
// Show modal
document.getElementById('confirmationModal').classList.add('is-active');
}
function processPayment() {
const form = document.getElementById('paymentForm');
const formData = new FormData(form);
// Disable confirm button and show loading
const confirmBtn = document.getElementById('confirmPaymentBtn');
const originalText = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Processing...</span>';
// Submit the payment
fetch('/single-payment/process', {
method: 'POST',
body: formData
})
.then(response => {
return response.json().then(data => {
return { status: response.status, data: data };
});
})
.then(result => {
// Hide confirmation modal
hideModal('confirmationModal');
const { status, data } = result;
// Check if payment was successful
if (status === 200 && data.success && data.payment_success) {
showSuccessModal(data);
} else if (status === 422 && data.fee_update) {
// Direct Debit needs fee update - show orange modal
showFeeUpdateModal(data);
} else {
// Payment failed or had an error - show the specific error
let errorMessage;
if (status === 422) {
// Payment processing failed (business logic error)
errorMessage = `Payment Failed: ${data.stripe_error || data.error || 'Unknown error'}`;
} else if (status >= 400) {
// Other HTTP errors
errorMessage = data.error || 'Payment processing failed';
} else {
// Unexpected status
errorMessage = 'Payment processing failed. Please try again.';
}
showErrorModal(errorMessage);
}
})
.catch(error => {
console.error('Error processing payment:', error);
hideModal('confirmationModal');
showErrorModal('Payment processing failed. Please try again.');
})
.finally(() => {
// Re-enable button
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalText;
});
}
function showSuccessModal(data) {
const successHtml = `
<p><strong>Payment ID:</strong> ${data.payment_id}</p>
<p><strong>Payment Intent:</strong> ${data.payment_intent || 'N/A'}</p>
<p><strong>Amount:</strong> $${parseFloat(data.amount).toFixed(2)}</p>
<p><strong>Customer:</strong> ${data.customer_name}</p>
`;
document.getElementById('successMessage').innerHTML = successHtml;
document.getElementById('successModal').classList.add('is-active');
}
function showErrorModal(errorMessage) {
document.getElementById('errorDetails').innerHTML = `<p>${errorMessage}</p>`;
document.getElementById('errorModal').classList.add('is-active');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('is-active');
}
function showFeeUpdateModal(data) {
currentPaymentId = data.payment_id;
document.getElementById('feeUpdateModal').classList.add('is-active');
}
function viewPaymentDetails() {
if (currentPaymentId) {
// Redirect to the single payment detail page
window.location.href = `/single-payment/detail/${currentPaymentId}`;
}
}
function closeSuccessModal() {
hideModal('successModal');
// Reset form to step 1
goBackToStep1();
document.getElementById('lookup_splynx_id').value = '';
currentCustomerData = null;
}
// Close modals on escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const activeModals = document.querySelectorAll('.modal.is-active');
activeModals.forEach(modal => modal.classList.remove('is-active'));
}
});
// Enter key navigation
document.getElementById('lookup_splynx_id').addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
fetchCustomerDetails();
}
});
document.getElementById('payment_amount').addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
showConfirmationModal();
}
});
</script>
{% endblock %}

410
templates/main/single_payment_detail.html

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

514
templates/main/single_payments_list.html

@ -0,0 +1,514 @@
{% extends "base.html" %}
{% block title %}Single Payments - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li class="is-active"><a href="#" aria-current="page">Single Payments</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Single Payments</h1>
<p class="subtitle">Individual payment processing history</p>
</div>
</div>
<div class="level-right">
<a class="button is-primary" href="{{ url_for('main.single_payment') }}">
<span class="icon"><i class="fas fa-plus"></i></span>
<span>New Payment</span>
</a>
</div>
</div>
<!-- Payment Details Table -->
<div class="box">
<div class="level">
<div class="level-left">
<h2 class="title is-4">Payment History</h2>
</div>
<div class="level-right">
<div class="field">
<p class="control has-icons-left">
<input class="input" type="text" id="searchInput" placeholder="Search Splynx ID, Customer ID, Payment Intent...">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
</div>
</div>
<!-- Filter Controls -->
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<label class="label is-small">Filter by Status:</label>
<div class="select is-small">
<select id="statusFilter">
<option value="all">All Payments</option>
<option value="success">Successful Only</option>
<option value="failed">Failed Only</option>
<option value="pending">Pending Only</option>
<option value="error">Has Errors</option>
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Filter by Payment Method:</label>
<div class="select is-small">
<select id="paymentMethodFilter">
<option value="all">All Methods</option>
<!-- Options will be populated by JavaScript -->
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Sort by:</label>
<div class="select is-small">
<select id="sortFilter">
<option value="date_desc">Date (Newest First)</option>
<option value="date_asc">Date (Oldest First)</option>
<option value="splynx_asc">Splynx ID (Ascending)</option>
<option value="splynx_desc">Splynx ID (Descending)</option>
<option value="amount_desc">Amount (High to Low)</option>
<option value="amount_asc">Amount (Low to High)</option>
<option value="status">Status</option>
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-info" onclick="clearFilters()">
<span class="icon"><i class="fas fa-times"></i></span>
<span>Clear Filters</span>
</button>
</div>
</div>
<!-- Results Counter -->
<div class="notification is-info is-light" id="filterResults" style="display: none;">
<span id="resultCount">0</span> of {{ payments|length }} payments shown
</div>
{% if payments %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" id="paymentsTable">
<thead>
<tr>
<th>Payment ID</th>
<th>Date</th>
<th>Splynx ID</th>
<th>Stripe Customer</th>
<th>Payment Intent</th>
<th>Payment Method</th>
<th>Stripe Fee</th>
<th>Amount</th>
<th>Processed By</th>
<th>Data</th>
<th>Status</th>
</tr>
</thead>
<tbody id="paymentsTableBody">
{% 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 %}
<tr class="{{ row_class }}">
<td>
<strong>#{{ payment.id }}</strong>
</td>
<td>
<span class="is-size-7">{{ payment.Created.strftime('%Y-%m-%d') }}</span><br>
<span class="is-size-7 has-text-grey">{{ payment.Created.strftime('%H:%M:%S') }}</span>
</td>
<td>
{% if payment.Splynx_ID %}
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ payment.Splynx_ID }}"
target="_blank" class="has-text-weight-semibold">
{{ payment.Splynx_ID }}
</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == None %}
<code class="is-small has-background-info has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% else %}
<code class="is-small has-background-grey-light has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% endif %}
</td>
<td>
{% if payment.Payment_Intent %}
{% if payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == None %}
<code class="is-small has-background-info has-text-white">{{ payment.Payment_Intent }}</code>
{% else %}
<code class="is-small has-background-grey-light has-text-black">{{ payment.Payment_Intent }}</code>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Payment_Method %}
<span class="tag is-info is-light">{{ payment.Payment_Method }}</span>
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Fee_Stripe %}
{{ payment.Fee_Stripe | currency }}
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Payment_Amount %}
<strong>{{ payment.Payment_Amount | currency }}</strong>
{% else %}
-
{% endif %}
</td>
<td>
<span class="is-size-7">{{ payment.processed_by or 'Unknown' }}</span>
</td>
<td>
<div class="buttons are-small">
{% if payment.PI_JSON %}
<button class="button is-info is-outlined" onclick="showModal('json-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-code"></i></span>
<span>JSON</span>
</button>
{% endif %}
{% if payment.Error %}
<button class="button is-danger is-outlined" onclick="showModal('error-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span>Error</span>
</button>
{% endif %}
</div>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
{% else %}
<span class="tag is-warning">Pending</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="notification is-info">
<p>No single payments found. <a href="{{ url_for('main.single_payment') }}">Process your first payment</a>.</p>
</div>
{% endif %}
</div>
<!-- Modals for JSON/Error data -->
{% for payment in payments %}
<!-- PI_JSON Modal -->
{% if payment.PI_JSON %}
<div class="modal" id="json-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('json-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment JSON - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('json-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<pre><code class="language-json">{{ payment.PI_JSON | format_json }}</code></pre>
<button class="button is-small is-info" onclick="copyFormattedJSON('json-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
<div id="json-content-{{ payment.id }}" style="display: none;">{{ payment.PI_JSON | format_json }}</div>
</section>
</div>
</div>
{% endif %}
<!-- Error Modal -->
{% if payment.Error %}
<div class="modal" id="error-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('error-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment Error - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('error-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
</div>
<button class="button is-small is-danger" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy Error</span>
</button>
<div id="error-content-{{ payment.id }}" style="display: none;">{{ payment.Error }}</div>
</section>
</div>
</div>
{% endif %}
{% endfor %}
<script>
// Payment filtering and sorting functionality
let allPayments = [];
let filteredPayments = [];
// Initialize payment data and filters when page loads
document.addEventListener('DOMContentLoaded', function() {
initializePayments();
populatePaymentMethodFilter();
setupEventListeners();
});
function initializePayments() {
const tableBody = document.getElementById('paymentsTableBody');
const rows = tableBody.querySelectorAll('tr');
allPayments = Array.from(rows).map(row => {
const cells = row.querySelectorAll('td');
return {
element: row,
paymentId: cells[0] ? (cells[0].textContent.trim() || '') : '',
date: cells[1] ? (cells[1].textContent.trim() || '') : '',
splynxId: cells[2] ? (cells[2].textContent.trim() || '') : '',
stripeCustomerId: cells[3] ? (cells[3].textContent.trim() || '') : '',
paymentIntent: cells[4] ? (cells[4].textContent.trim() || '') : '',
paymentMethod: cells[5] ? (cells[5].textContent.trim() || '') : '',
stripeFee: cells[6] ? (cells[6].textContent.trim() || '') : '',
amount: cells[7] ? (cells[7].textContent.trim() || '') : '',
processedBy: cells[8] ? (cells[8].textContent.trim() || '') : '',
status: cells[10] ? (cells[10].textContent.trim() || '') : '',
success: row.classList.contains('has-background-success-light'),
failed: row.classList.contains('has-background-danger-light'),
pending: row.classList.contains('has-background-info-light'),
hasError: cells[9] && cells[9].querySelector('button.is-danger')
};
});
filteredPayments = [...allPayments];
updateResultCount();
}
function populatePaymentMethodFilter() {
const select = document.getElementById('paymentMethodFilter');
const methods = [...new Set(allPayments
.map(p => p.paymentMethod)
.filter(method => method && method !== '-')
)].sort();
// Clear existing options except "All Methods"
select.innerHTML = '<option value="all">All Methods</option>';
methods.forEach(method => {
const option = document.createElement('option');
option.value = method;
option.textContent = method;
select.appendChild(option);
});
}
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('statusFilter').addEventListener('change', applyFilters);
document.getElementById('paymentMethodFilter').addEventListener('change', applyFilters);
document.getElementById('sortFilter').addEventListener('change', applyFilters);
}
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('statusFilter').value;
const paymentMethodFilter = document.getElementById('paymentMethodFilter').value;
const sortFilter = document.getElementById('sortFilter').value;
// Filter payments
filteredPayments = allPayments.filter(payment => {
// Search filter
const searchMatch = !searchTerm ||
payment.splynxId.toLowerCase().includes(searchTerm) ||
payment.stripeCustomerId.toLowerCase().includes(searchTerm) ||
payment.paymentIntent.toLowerCase().includes(searchTerm) ||
payment.paymentId.toLowerCase().includes(searchTerm);
// Status filter
let statusMatch = true;
switch(statusFilter) {
case 'success':
statusMatch = payment.success;
break;
case 'failed':
statusMatch = payment.failed;
break;
case 'pending':
statusMatch = payment.pending;
break;
case 'error':
statusMatch = payment.hasError;
break;
}
// Payment method filter
const methodMatch = paymentMethodFilter === 'all' ||
payment.paymentMethod === paymentMethodFilter;
return searchMatch && statusMatch && methodMatch;
});
// Sort payments
sortPayments(sortFilter);
// Update display
updateTable();
updateResultCount();
}
function sortPayments(sortBy) {
switch(sortBy) {
case 'date_desc':
// Already sorted by date desc in backend query
break;
case 'date_asc':
filteredPayments.reverse();
break;
case 'splynx_asc':
filteredPayments.sort((a, b) => parseInt(a.splynxId) - parseInt(b.splynxId));
break;
case 'splynx_desc':
filteredPayments.sort((a, b) => parseInt(b.splynxId) - parseInt(a.splynxId));
break;
case 'amount_asc':
filteredPayments.sort((a, b) => parseFloat(a.amount.replace(/[$,]/g, '')) - parseFloat(b.amount.replace(/[$,]/g, '')));
break;
case 'amount_desc':
filteredPayments.sort((a, b) => parseFloat(b.amount.replace(/[$,]/g, '')) - parseFloat(a.amount.replace(/[$,]/g, '')));
break;
case 'status':
filteredPayments.sort((a, b) => a.status.localeCompare(b.status));
break;
}
}
function updateTable() {
const tableBody = document.getElementById('paymentsTableBody');
// Hide all rows first
allPayments.forEach(payment => {
payment.element.style.display = 'none';
});
// Show filtered rows
filteredPayments.forEach(payment => {
payment.element.style.display = '';
tableBody.appendChild(payment.element); // Re-append to maintain sort order
});
}
function updateResultCount() {
const resultCount = document.getElementById('resultCount');
const filterResults = document.getElementById('filterResults');
resultCount.textContent = filteredPayments.length;
if (filteredPayments.length === allPayments.length) {
filterResults.style.display = 'none';
} else {
filterResults.style.display = 'block';
}
}
function clearFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('statusFilter').value = 'all';
document.getElementById('paymentMethodFilter').value = 'all';
document.getElementById('sortFilter').value = 'date_desc';
applyFilters();
}
// Modal functionality
function showModal(modalId) {
document.getElementById(modalId).classList.add('is-active');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('is-active');
}
// Copy to clipboard functionality
function copyFormattedJSON(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent || element.innerText;
navigator.clipboard.writeText(text).then(function() {
// Show temporary success message
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
} catch (fallbackErr) {
console.error('Fallback copy failed: ', fallbackErr);
}
document.body.removeChild(textArea);
});
}
// Close modal on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const activeModals = document.querySelectorAll('.modal.is-active');
activeModals.forEach(modal => modal.classList.remove('is-active'));
}
});
</script>
{% endblock %}

121
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)
Loading…
Cancel
Save