58 changed files with 9180 additions and 0 deletions
@ -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) |
|||
@ -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" |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -0,0 +1 @@ |
|||
python3.12 |
|||
@ -0,0 +1 @@ |
|||
python3.12 |
|||
@ -0,0 +1 @@ |
|||
/usr/bin/python3.12 |
|||
@ -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,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) |
|||
@ -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 |
|||
@ -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' |
|||
@ -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 */ |
|||
@ -0,0 +1 @@ |
|||
lib |
|||
@ -0,0 +1 @@ |
|||
Single-database configuration for Flask. |
|||
@ -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 |
|||
@ -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() |
|||
@ -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"} |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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 ### |
|||
@ -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) |
|||
@ -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 |
|||
@ -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) |
|||
@ -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") |
|||
@ -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 |
|||
@ -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 |
|||
) |
|||
@ -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'} |
|||
@ -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; |
|||
} |
|||
|
After Width: | Height: | Size: 206 KiB |
File diff suppressed because it is too large
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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> |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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 %} |
|||
@ -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…
Reference in new issue