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