Code cleanup and fixes

parent bf4e59fe
......@@ -50,6 +50,40 @@ def get_db_executor():
return _db_executor
class _MySQLConnectionWrapper:
"""Wrapper that gives mysql.connector connections a reliable context manager protocol.
mysql-connector-python's C extension (__enter__/__exit__) has version-dependent
behaviour (some versions return a cursor from __enter__, others close the connection
in __exit__ unexpectedly). This wrapper always yields the raw connection and
handles commit/rollback/close explicitly.
"""
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self._conn
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self._conn.commit()
else:
try:
self._conn.rollback()
except Exception:
pass
# Connection is intentionally left open: cursor and conn variables in the
# calling function remain valid after the with-block exits (matching SQLite's
# context-manager behaviour). The connection is closed by GC when the caller
# function returns and conn goes out of scope.
return False
# Forward attribute access so the wrapper can be used directly as well
def __getattr__(self, name):
return getattr(self._conn, name)
class DatabaseManager:
"""
Manages database for persistent tracking of context dimensions and rate limiting.
......@@ -103,9 +137,9 @@ class DatabaseManager:
port=self.db_config['mysql_port'],
user=self.db_config['mysql_user'],
password=self.db_config['mysql_password'],
database=self.db_config['mysql_database']
database=self.db_config['mysql_database'],
)
return conn
return _MySQLConnectionWrapper(conn)
except Exception as e:
logger.error(f"MySQL connection failed: {e}")
raise
......@@ -119,27 +153,20 @@ class DatabaseManager:
async def execute(self, sql: str, params: dict = None):
"""Execute SQL query and return result with mappings (compatible with AsyncSession interface)"""
_params = params or {}
def _sync_execute():
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.row_factory = sqlite3.Row
params = params or {}
# Safe parameter handling - use native database parameter binding
if self.db_type == 'sqlite':
# SQLite natively supports :named parameters directly
cursor.execute(sql, params)
cursor = conn.cursor()
cursor.row_factory = sqlite3.Row
cursor.execute(sql, _params)
else:
# For MySQL, safely convert named parameters to %s placeholders
param_names = []
def replace_param(match):
param_names.append(match.group(1))
return '%s'
cursor = conn.cursor(dictionary=True)
import re
processed_sql = re.sub(r':(\w+)', replace_param, sql)
params_list = [params[name] for name in param_names]
cursor.execute(processed_sql, params_list)
param_names = []
processed_sql = re.sub(r':(\w+)', lambda m: (param_names.append(m.group(1)), '%s')[1], sql)
cursor.execute(processed_sql, [_params[n] for n in param_names])
if cursor.description:
rows = [dict(row) for row in cursor.fetchall()]
# Simulate SQLAlchemy Result object with mappings() method
......@@ -3328,6 +3355,7 @@ def DatabaseManager__init__(self, db_config: Optional[Dict[str, Any]] = None, da
self.db_config = db_config
self.db_type = self.db_config.get('type', 'sqlite').lower()
self.executor = get_db_executor()
if self.db_type == 'mysql':
# Import the module-level MYSQL_AVAILABLE flag
......@@ -3542,13 +3570,18 @@ def DatabaseManager__initialize_database(self):
# ''')
#
# try:
# cursor.execute('''
# CREATE INDEX IF NOT EXISTS idx_model_embeddings_provider_model
# ON model_embeddings(provider_id, model_name)
# ''')
# except:
# pass
# # Index creation moved to separate migration
#
# Create admin settings table for system configuration
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS admin_settings (
id INTEGER PRIMARY KEY {auto_increment},
setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT,
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
''')
# Create users table for multi-user management
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users (
......@@ -3997,9 +4030,8 @@ def DatabaseManager__initialize_database(self):
# Run configuration database migrations if this is a CONFIG database
if self.database_type == DatabaseRegistry.TYPE_CONFIG:
self._run_config_migrations(cursor, auto_increment, timestamp_default, boolean_type)
conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database")
conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database")
def DatabaseManager__create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
......@@ -4176,6 +4208,7 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1,
is_visible {boolean_type} DEFAULT 1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
......@@ -4204,6 +4237,7 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1,
is_visible {boolean_type} DEFAULT 1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
......@@ -4227,7 +4261,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
('max_rotation_models', 'INTEGER DEFAULT -1'),
('max_autoselection_models', 'INTEGER DEFAULT -1'),
('is_default', f'{boolean_type} DEFAULT 0'),
('is_active', f'{boolean_type} DEFAULT 1')
('is_active', f'{boolean_type} DEFAULT 1'),
('is_visible', f'{boolean_type} DEFAULT 1')
]
col_count = 0
for col_name, col_def in tier_columns:
......
......@@ -494,6 +494,8 @@ app = FastAPI(
# Initialize Jinja2 templates with custom globals for proxy-aware URLs
templates = Jinja2Templates(directory="templates")
# Add root templates directory to search path for parent template resolution
templates.env.loader.searchpath.insert(0, "templates")
# Monkey patch TemplateResponse to automatically add dashboard context variables
original_template_response = templates.TemplateResponse
......@@ -8141,9 +8143,13 @@ async def dashboard_wallet(request: Request):
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
all_gateways = db.get_payment_gateway_settings()
enabled_gateways = {k: v for k, v in all_gateways.items() if v.get('enabled', False)}
return templates.TemplateResponse("dashboard/wallet.html", {
"request": request,
"wallet": wallet
"wallet": wallet,
"enabled_gateways": enabled_gateways,
})
except ImportError:
return HTMLResponse("Wallet functionality not available", status_code=503)
......@@ -8154,33 +8160,165 @@ async def dashboard_wallet(request: Request):
"error": "Failed to load wallet. Please try again later."
}, status_code=500)
@app.post("/dashboard/wallet/topup")
async def dashboard_wallet_topup(request: Request):
"""Session-authenticated wallet top-up — supports all admin-enabled gateways."""
from fastapi.responses import JSONResponse
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid request body"}, status_code=400)
method = (body.get('payment_method') or '').lower()
amount = body.get('amount')
try:
amount = float(amount)
except (TypeError, ValueError):
return JSONResponse({"error": "Invalid amount"}, status_code=400)
if amount < 5 or amount > 500:
return JSONResponse({"error": "Amount must be between $5 and $500"}, status_code=400)
db = DatabaseRegistry.get_config_database()
gateways = db.get_payment_gateway_settings()
gw = gateways.get(method, {})
if not gw.get('enabled', False):
return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400)
# Crypto: return deposit address (manual transfer)
crypto_methods = {'bitcoin', 'ethereum', 'usdt', 'usdc'}
if method in crypto_methods:
address = gw.get('address', '')
if not address:
return JSONResponse({"error": "Crypto address not configured"}, status_code=503)
return JSONResponse({
"type": "crypto",
"method": method,
"address": address,
"amount": amount,
"network": gw.get('network', ''),
"confirmations": gw.get('confirmations', 3),
})
# Stripe: create checkout session
if method == 'stripe':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'stripe_handler'):
from decimal import Decimal
intent = await payment_service.stripe_handler.create_payment_intent(
user_id, Decimal(str(amount)), metadata={"type": "wallet_topup"}
)
return JSONResponse({"type": "stripe", "client_secret": intent.client_secret})
# Fallback: redirect to Stripe-hosted checkout via publishable key
import stripe
stripe.api_key = gw.get('secret_key', '')
session = stripe.checkout.Session.create(
payment_method_types=['card'],
line_items=[{
'price_data': {
'currency': 'usd',
'product_data': {'name': 'Wallet Top-Up'},
'unit_amount': int(amount * 100),
},
'quantity': 1,
}],
mode='payment',
success_url=str(request.base_url) + 'dashboard/wallet?topup=success',
cancel_url=str(request.base_url) + 'dashboard/wallet?topup=cancelled',
metadata={'type': 'wallet_topup', 'user_id': str(user_id)},
)
return JSONResponse({"type": "stripe", "checkout_url": session.url})
except Exception as e:
logger.error(f"Stripe top-up error: {e}")
return JSONResponse({"error": "Stripe checkout failed. Please try again."}, status_code=502)
# PayPal: create order
if method == 'paypal':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'paypal_handler'):
from decimal import Decimal
order = await payment_service.paypal_handler.create_order(
user_id, Decimal(str(amount)), metadata={"type": "wallet_topup"}
)
return JSONResponse({"type": "paypal", "order_id": order.id})
# Fallback: direct PayPal redirect
client_id = gw.get('client_id', '')
sandbox = gw.get('sandbox', True)
paypal_base = "https://www.sandbox.paypal.com" if sandbox else "https://www.paypal.com"
return JSONResponse({
"type": "paypal",
"paypal_base": paypal_base,
"client_id": client_id,
"amount": amount,
})
except Exception as e:
logger.error(f"PayPal top-up error: {e}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
return JSONResponse({"error": f"Unsupported payment method: {method}"}, status_code=400)
@app.get("/dashboard/wallet/transactions")
async def dashboard_wallet_transactions(request: Request, limit: int = 50, offset: int = 0):
"""Session-authenticated wallet transaction history (used by the wallet dashboard page)."""
auth_check = require_dashboard_auth(request)
if auth_check:
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
try:
from aisbf.payments.wallet.manager import WalletManager
db = DatabaseRegistry.get_config_database()
wallet_manager = WalletManager(db)
transactions = await wallet_manager.get_transactions(user_id, limit=limit, offset=offset)
return transactions
except Exception as e:
logger.error(f"Failed to load wallet transactions: {e}")
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Failed to load transactions"}, status_code=500)
@app.get("/dashboard/billing")
async def dashboard_billing(request: Request):
"""User payment transaction history page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user payment methods
payment_methods = db.get_user_payment_methods(user_id)
# Get payment transactions
transactions = db.get_user_payment_transactions(user_id)
# Get enabled payment gateways
enabled_gateways = []
gateways = db.get_payment_gateway_settings()
for gateway, settings in gateways.items():
if settings.get('enabled', False):
enabled_gateways.append(gateway)
# Get user wallet
currency_settings = db.get_currency_settings()
currency_code = currency_settings.get('currency_code', 'EUR')
wallet = db.get_user_wallet(user_id) or {'balance': '0.00', 'currency_code': currency_code, 'auto_topup_enabled': False}
try:
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
except Exception:
wallet = {'balance': '0.00', 'currency_code': currency_code, 'auto_topup_enabled': False}
return templates.TemplateResponse(
request=request,
......
......@@ -230,6 +230,8 @@ setup(
'templates/dashboard/add_payment_method.html',
'templates/dashboard/paypal_connect.html',
'templates/dashboard/cache_settings.html',
'templates/dashboard/wallet.html',
'templates/dashboard/error.html',
]),
# Install static files (extension and favicon)
('share/aisbf/static', [
......
{% extends "base.html" %}
{% block title %}Error{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">Error</h5>
</div>
<div class="card-body">
<div class="alert alert-danger">
{{ error }}
</div>
<div class="d-flex justify-content-between">
<a href="{{ request.url_for('dashboard_index') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Go to Dashboard
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Go Back
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% extends "dashboard/base.html" %}
{% extends "base.html" %}
{% block title %}Wallet{% endblock %}
{% block content %}
<div class="container mt-4">
<h1 class="mb-4">Wallet</h1>
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Balance</h5>
</div>
<div class="card-body">
<h2 class="display-4">${{ wallet.balance }}</h2>
<p class="text-muted">Currency: {{ wallet.currency_code }}</p>
<hr>
<div class="mb-3">
<label class="form-label">Top Up Amount</label>
<div class="btn-group w-100 mb-2" role="group">
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="10">$10</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="15">$15</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="20">$20</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="50">$50</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="100">$100</button>
</div>
<input type="number" id="custom-amount" class="form-control" placeholder="Custom amount (5-500)" step="0.01" min="5" max="500">
</div>
<div class="d-grid gap-2">
<button id="topup-stripe" class="btn btn-primary">Top Up with Stripe</button>
<button id="topup-paypal" class="btn btn-outline-primary">Top Up with PayPal</button>
</div>
</div>
<h2 style="margin-bottom: 20px;"><i class="fas fa-wallet me-2"></i>Wallet</h2>
<!-- Balance Banner -->
<div style="background: linear-gradient(135deg, #1a4a2e, #0f3460); border: 2px solid #28a745; border-radius: 8px; padding: 24px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<div>
<div style="color: #a0a0a0; font-size: 14px; margin-bottom: 6px;">Available Balance</div>
<div style="font-size: 42px; font-weight: bold; color: #4ade80;">${{ "%.2f"|format(wallet.balance|float) }}</div>
<div style="color: #a0a0a0; font-size: 13px; margin-top: 6px;">
Currency: <span style="color: #e0e0e0;">{{ wallet.currency_code }}</span>
&nbsp;·&nbsp; Wallet ID: <span style="color: #e0e0e0; font-family: monospace;">{{ wallet.id }}</span>
</div>
<div class="card">
<div class="card-header">
<h5>Auto Top Up Settings</h5>
</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="auto-topup-enabled" {% if wallet.auto_topup_enabled %}checked{% endif %}>
<label class="form-check-label" for="auto-topup-enabled">Enable Auto Top Up</label>
</div>
<div id="auto-topup-settings" {% if not wallet.auto_topup_enabled %}style="display:none;"{% endif %}>
<div class="mb-3">
<label class="form-label">Auto Top Up Amount</label>
<input type="number" id="auto-topup-amount" class="form-control" value="{{ wallet.auto_topup_amount or '' }}" step="0.01" min="10">
</div>
<div class="mb-3">
<label class="form-label">Top Up When Balance Below</label>
<input type="number" id="auto-topup-threshold" class="form-control" value="{{ wallet.auto_topup_threshold or '' }}" step="0.01" min="1">
</div>
<div class="mb-3">
<label class="form-label">Payment Method</label>
<select id="auto-topup-payment-method" class="form-select">
<!-- Options populated via JS -->
</select>
</div>
<button id="save-auto-topup" class="btn btn-primary">Save Settings</button>
</div>
</div>
</div>
<div style="text-align: right;">
{% if wallet.auto_topup_enabled %}
<span style="background: #28a745; color: white; padding: 4px 12px; border-radius: 10px; font-size: 13px;">
<i class="fas fa-sync-alt me-1"></i>Auto Top-Up Active
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 4px 12px; border-radius: 10px; font-size: 13px;">
<i class="fas fa-sync-alt me-1"></i>Auto Top-Up Off
</span>
{% endif %}
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Top Up -->
<div style="background: #16213e; border: 2px solid #4a9eff; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 18px 0; color: #4a9eff;">
<i class="fas fa-plus-circle me-2"></i>Top Up Wallet
</h3>
{% set fiat_gateways = enabled_gateways.keys() | select('in', ['stripe', 'paypal']) | list %}
{% set crypto_gateways = enabled_gateways.keys() | select('in', ['bitcoin', 'ethereum', 'usdt', 'usdc']) | list %}
{% if not enabled_gateways %}
<div style="color: #a0a0a0; text-align: center; padding: 20px 10px;">
<i class="fas fa-info-circle fa-2x" style="margin-bottom: 12px; display: block; color: #4a9eff;"></i>
No payment methods are currently enabled.<br>
<span style="font-size: 13px;">Please contact the administrator to enable a payment gateway.</span>
</div>
{% else %}
{% if fiat_gateways %}
<div style="margin-bottom: 16px;">
<div style="color: #a0a0a0; font-size: 13px; margin-bottom: 8px;">Quick amounts</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
{% for amt in [10, 15, 20, 50, 100] %}
<button type="button" class="amount-btn" data-amount="{{ amt }}"
style="background: #1a1a2e; border: 1px solid #4a9eff; color: #4a9eff; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;">
${{ amt }}
</button>
{% endfor %}
</div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Custom amount</label>
<input type="number" id="custom-amount"
style="width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;"
placeholder="Enter amount ($5 – $500)" step="0.01" min="5" max="500">
</div>
<div style="display: flex; flex-direction: column; gap: 10px; {% if crypto_gateways %}margin-bottom: 16px;{% endif %}">
{% for name in fiat_gateways %}
{% if name == 'stripe' %}
<button class="topup-fiat-btn" data-method="stripe"
style="background: #635bff; border: none; color: white; padding: 11px; border-radius: 6px; cursor: pointer; font-size: 15px; font-weight: 600; transition: background .15s;">
<i class="fab fa-cc-stripe me-2"></i>Top Up with Stripe
</button>
{% elif name == 'paypal' %}
<button class="topup-fiat-btn" data-method="paypal"
style="background: #003087; border: 1px solid #009cde; color: white; padding: 11px; border-radius: 6px; cursor: pointer; font-size: 15px; font-weight: 600; transition: background .15s;">
<i class="fab fa-paypal me-2"></i>Top Up with PayPal
</button>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if crypto_gateways %}
{% if fiat_gateways %}
<hr style="border-color: #0f3460; margin: 4px 0 14px 0;">
{% endif %}
<div style="color: #a0a0a0; font-size: 13px; margin-bottom: 10px;">
<i class="fas fa-coins me-1"></i>Crypto deposit — send to address below
</div>
{% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %}
{% set address = cfg.get('wallet_address') or cfg.get('address') or '' %}
<div style="background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; padding: 12px; margin-bottom: 10px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<span style="color: #e0e0e0; font-weight: 600;">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin me-1" style="color:#f7931a;"></i>
{% elif name == 'ethereum' %}<i class="fab fa-ethereum me-1" style="color:#627eea;"></i>
{% else %}<i class="fas fa-coins me-1" style="color:#26a17b;"></i>
{% endif %}
{{ name | upper }}
</span>
<button onclick="copyAddress('{{ address }}', this)"
style="background: #4a9eff; border: none; color: white; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
<i class="fas fa-copy me-1"></i>Copy
</button>
</div>
<div style="font-family: monospace; font-size: 12px; color: #a0a0a0; word-break: break-all; background: #0f1631; padding: 8px; border-radius: 4px;">
{{ address or 'Address not configured' }}
</div>
<div style="color: #6c757d; font-size: 11px; margin-top: 6px;">
Send {{ name | upper }} to this address. Balance will be credited after on-chain confirmation.
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
</div>
<!-- Auto Top-Up -->
<div style="background: #16213e; border: 2px solid #17a2b8; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 18px 0; color: #17a2b8;">
<i class="fas fa-sync-alt me-2"></i>Auto Top-Up Settings
</h3>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 20px;">
<div style="position: relative; width: 48px; height: 26px; flex-shrink: 0;">
<input type="checkbox" id="auto-topup-enabled"
{% if wallet.auto_topup_enabled %}checked{% endif %}
style="opacity: 0; width: 0; height: 0; position: absolute;">
<label for="auto-topup-enabled" id="toggle-label"
style="position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #6c757d; border-radius: 26px; transition: .3s;">
<span id="toggle-knob" style="position: absolute; height: 20px; width: 20px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: .3s;"></span>
</label>
</div>
<span style="color: #e0e0e0; font-size: 15px;">Enable Auto Top-Up</span>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Transaction History</h5>
<div id="auto-topup-settings" {% if not wallet.auto_topup_enabled %}style="display:none;"{% endif %}>
<div style="margin-bottom: 14px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Top-up amount</label>
<div style="position: relative;">
<span style="position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #4ade80; font-weight: bold;">$</span>
<input type="number" id="auto-topup-amount"
style="width: 100%; padding: 10px 10px 10px 26px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;"
value="{{ wallet.auto_topup_amount or '' }}" step="0.01" min="10">
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody id="transaction-list">
<!-- Populated via JS -->
</tbody>
</table>
</div>
</div>
<div style="margin-bottom: 14px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Trigger when balance falls below</label>
<div style="position: relative;">
<span style="position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #ffc107; font-weight: bold;">$</span>
<input type="number" id="auto-topup-threshold"
style="width: 100%; padding: 10px 10px 10px 26px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;"
value="{{ wallet.auto_topup_threshold or '' }}" step="0.01" min="1">
</div>
</div>
<div style="margin-bottom: 18px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Payment method</label>
<select id="auto-topup-payment-method"
style="width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 14px;">
{% for name in enabled_gateways.keys() | select('in', ['stripe', 'paypal']) %}
<option value="{{ name }}" {% if wallet.auto_topup_payment_method == name %}selected{% endif %}>
{{ name | capitalize }}
</option>
{% endfor %}
</select>
</div>
<button id="save-auto-topup"
style="background: #17a2b8; border: none; color: white; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: background .15s;">
<i class="fas fa-save me-2"></i>Save Settings
</button>
</div>
{% if not wallet.auto_topup_enabled %}
<div style="color: #a0a0a0; font-size: 13px; line-height: 1.6;">
Enable auto top-up to automatically reload your wallet when the balance drops below a threshold. This ensures uninterrupted API access.
</div>
{% endif %}
</div>
</div>
<!-- Transaction History -->
<div style="background: #16213e; border: 2px solid #6f42c1; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 20px 0; color: #a57bff;">
<i class="fas fa-history me-2"></i>Transaction History
</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #0f3460; color: #e0e0e0;">
<th style="padding: 12px 14px; text-align: left; font-weight: 600; border-bottom: 1px solid #1a1a2e;">Date</th>
<th style="padding: 12px 14px; text-align: left; font-weight: 600; border-bottom: 1px solid #1a1a2e;">Type</th>
<th style="padding: 12px 14px; text-align: left; font-weight: 600; border-bottom: 1px solid #1a1a2e;">Description</th>
<th style="padding: 12px 14px; text-align: right; font-weight: 600; border-bottom: 1px solid #1a1a2e;">Amount</th>
<th style="padding: 12px 14px; text-align: center; font-weight: 600; border-bottom: 1px solid #1a1a2e;">Status</th>
</tr>
</thead>
<tbody id="transaction-list">
<tr id="tx-loading">
<td colspan="5" style="padding: 40px; text-align: center; color: #a0a0a0;">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<div style="margin-top: 10px;">Loading transactions…</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<style>
.amount-btn:hover, .amount-btn.active {
background: #4a9eff !important;
color: #fff !important;
border-color: #4a9eff !important;
}
.topup-fiat-btn[data-method="stripe"]:hover:not(:disabled) { background: #4f49d0 !important; }
.topup-fiat-btn[data-method="paypal"]:hover:not(:disabled) { background: #00256b !important; }
#save-auto-topup:hover { background: #138496 !important; }
#toggle-label.on { background: #17a2b8 !important; }
#toggle-label.on #toggle-knob { transform: translateX(22px); }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
// ── Amount buttons ──────────────────────────────────────────
let selectedAmount = null;
document.querySelectorAll('.amount-btn').forEach(btn => {
btn.addEventListener('click', function() {
btn.addEventListener('click', function () {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
selectedAmount = this.dataset.amount;
selectedAmount = parseFloat(this.dataset.amount);
document.getElementById('custom-amount').value = '';
});
});
document.getElementById('custom-amount').addEventListener('input', function() {
document.getElementById('custom-amount').addEventListener('input', function () {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
selectedAmount = this.value;
selectedAmount = parseFloat(this.value) || null;
});
document.getElementById('auto-topup-enabled').addEventListener('change', function() {
document.getElementById('auto-topup-settings').style.display = this.checked ? 'block' : 'none';
function getAmount() {
const custom = parseFloat(document.getElementById('custom-amount').value);
return custom || selectedAmount;
}
// ── Fiat top-up buttons ─────────────────────────────────────
document.querySelectorAll('.topup-fiat-btn').forEach(btn => {
btn.addEventListener('click', function () {
const amount = getAmount();
if (!amount || amount < 5 || amount > 500) {
alert('Please select or enter an amount between $5 and $500.');
return;
}
const method = this.dataset.method;
const orig = this.innerHTML;
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Processing…';
fetch('/dashboard/wallet/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, payment_method: method })
})
.then(r => r.json())
.then(data => {
if (data.type === 'stripe' && data.checkout_url) {
window.location.href = data.checkout_url;
} else if (data.type === 'paypal' && data.approval_url) {
window.location.href = data.approval_url;
} else {
this.disabled = false;
this.innerHTML = orig;
alert(data.error || 'Failed to initiate checkout.');
}
})
.catch(() => {
this.disabled = false;
this.innerHTML = orig;
alert('Network error. Please try again.');
});
});
});
// ── Auto top-up toggle ──────────────────────────────────────
const toggle = document.getElementById('auto-topup-enabled');
const label = document.getElementById('toggle-label');
const settings = document.getElementById('auto-topup-settings');
function syncToggle() {
if (toggle.checked) {
label.classList.add('on');
label.style.background = '#17a2b8';
document.getElementById('toggle-knob').style.transform = 'translateX(22px)';
settings.style.display = 'block';
} else {
label.classList.remove('on');
label.style.background = '#6c757d';
document.getElementById('toggle-knob').style.transform = 'translateX(0)';
settings.style.display = 'none';
}
}
syncToggle();
toggle.addEventListener('change', syncToggle);
// ── Save auto top-up ────────────────────────────────────────
document.getElementById('save-auto-topup').addEventListener('click', function () {
const payload = {
enabled: toggle.checked,
topup_amount: parseFloat(document.getElementById('auto-topup-amount').value) || null,
threshold_amount: parseFloat(document.getElementById('auto-topup-threshold').value) || null,
payment_method: document.getElementById('auto-topup-payment-method').value || null
};
fetch('/api/wallet/auto-topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(data => {
if (data.success) {
this.textContent = '✓ Saved';
setTimeout(() => { this.innerHTML = '<i class="fas fa-save me-2"></i>Save Settings'; }, 2000);
} else {
alert(data.error || 'Failed to save settings.');
}
})
.catch(() => alert('Network error. Please try again.'));
});
// Load transactions
fetch('/api/wallet/transactions')
.then(res => res.json())
// ── Transaction history ─────────────────────────────────────
const typeLabels = {
credit: '<span style="color:#4ade80;">&#9650; Credit</span>',
debit: '<span style="color:#f87171;">&#9660; Debit</span>',
refund: '<span style="color:#60a5fa;">&#8617; Refund</span>',
topup: '<span style="color:#4ade80;">&#43; Top-Up</span>',
payment: '<span style="color:#f87171;">&#8722; Payment</span>',
};
const statusBadge = {
completed: 'background:#28a745;color:white;',
pending: 'background:#ffc107;color:black;',
failed: 'background:#dc3545;color:white;',
refunded: 'background:#6c757d;color:white;',
};
fetch('/dashboard/wallet/transactions')
.then(r => r.json())
.then(transactions => {
const list = document.getElementById('transaction-list');
transactions.forEach(tx => {
document.getElementById('tx-loading').remove();
if (!transactions || transactions.length === 0) {
list.innerHTML = `<tr><td colspan="5" style="padding:40px;text-align:center;color:#a0a0a0;">
<i class="fas fa-receipt fa-3x" style="margin-bottom:12px;display:block;"></i>
No transactions yet.
</td></tr>`;
return;
}
transactions.forEach((tx, i) => {
const date = tx.created_at ? new Date(tx.created_at) : null;
const dateStr = date ? date.toLocaleDateString() : '—';
const timeStr = date ? date.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
const typeKey = (tx.type || '').toLowerCase();
const typeHtml = typeLabels[typeKey] || `<span style="color:#e0e0e0;">${tx.type || '—'}</span>`;
const amt = parseFloat(tx.amount) || 0;
const amtColor = amt >= 0 ? '#4ade80' : '#f87171';
const amtStr = (amt >= 0 ? '+' : '') + '$' + Math.abs(amt).toFixed(2);
const status = (tx.status || 'unknown').toLowerCase();
const badgeStyle = statusBadge[status] || 'background:#6c757d;color:white;';
const rowBg = i % 2 === 0 ? '#1a1a2e' : '#16213e';
const row = document.createElement('tr');
row.style.cssText = `background:${rowBg};border-bottom:1px solid #0f3460;`;
row.innerHTML = `
<td>${new Date(tx.created_at).toLocaleDateString()}</td>
<td>${tx.type}</td>
<td class="${tx.amount >= 0 ? 'text-success' : 'text-danger'}">${tx.amount >= 0 ? '+' : ''}$${Math.abs(tx.amount)}</td>
<td><span class="badge bg-secondary">${tx.status}</span></td>
`;
<td style="padding:12px 14px;color:#e0e0e0;">
<div style="font-weight:600;">${dateStr}</div>
<small style="color:#a0a0a0;">${timeStr}</small>
</td>
<td style="padding:12px 14px;">${typeHtml}</td>
<td style="padding:12px 14px;color:#c0c0c0;font-size:13px;">${tx.description || '—'}</td>
<td style="padding:12px 14px;text-align:right;font-weight:700;font-size:16px;color:${amtColor};">${amtStr}</td>
<td style="padding:12px 14px;text-align:center;">
<span style="${badgeStyle}padding:4px 10px;border-radius:10px;font-size:12px;white-space:nowrap;">${tx.status || 'unknown'}</span>
</td>`;
list.appendChild(row);
});
})
.catch(() => {
document.getElementById('tx-loading').innerHTML =
'<td colspan="5" style="padding:30px;text-align:center;color:#f87171;">Failed to load transactions.</td>';
});
});
function copyAddress(addr, btn) {
navigator.clipboard.writeText(addr).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
btn.style.background = '#28a745';
setTimeout(() => { btn.innerHTML = orig; btn.style.background = ''; }, 2000);
}).catch(() => alert('Copy failed — please copy the address manually.'));
}
</script>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment