Payment systems...

parent 946dfd79
...@@ -1375,7 +1375,7 @@ class DatabaseManager: ...@@ -1375,7 +1375,7 @@ class DatabaseManager:
''', (new_email, user_id)) ''', (new_email, user_id))
conn.commit() conn.commit()
def update_user_profile(self, user_id: int, username: str, email: str, display_name: str = None): def update_user_profile(self, user_id: int, username: str, email: str, display_name: str = None, profile_pic: str = None):
""" """
Update user profile (username and display_name, email is read-only). Update user profile (username and display_name, email is read-only).
...@@ -1384,23 +1384,25 @@ class DatabaseManager: ...@@ -1384,23 +1384,25 @@ class DatabaseManager:
username: New username username: New username
email: Email (ignored, kept for backward compatibility) email: Email (ignored, kept for backward compatibility)
display_name: New display name (optional) display_name: New display name (optional)
profile_pic: Base64-encoded profile picture data URL (optional)
""" """
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s' placeholder = '?' if self.db_type == 'sqlite' else '%s'
fields = ['username = ' + placeholder]
params = [username]
if display_name is not None: if display_name is not None:
fields.append('display_name = ' + placeholder)
params.append(display_name)
if profile_pic is not None:
fields.append('profile_pic = ' + placeholder)
params.append(profile_pic)
params.append(user_id)
cursor.execute(f''' cursor.execute(f'''
UPDATE users UPDATE users SET {', '.join(fields)} WHERE id = {placeholder}
SET username = {placeholder}, display_name = {placeholder} ''', tuple(params))
WHERE id = {placeholder}
''', (username, display_name, user_id))
else:
cursor.execute(f'''
UPDATE users
SET username = {placeholder}
WHERE id = {placeholder}
''', (username, user_id))
conn.commit() conn.commit()
def sanitize_username(self, input_str: str) -> str: def sanitize_username(self, input_str: str) -> str:
...@@ -4315,7 +4317,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta ...@@ -4315,7 +4317,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
('subscription_expires', 'TIMESTAMP NULL', 'tier_id'), ('subscription_expires', 'TIMESTAMP NULL', 'tier_id'),
('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'), ('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'),
('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'), ('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'),
('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token') ('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token'),
('profile_pic', 'TEXT', 'reset_password_token_expires')
] ]
for col_name, col_def, after_col in required_columns: for col_name, col_def, after_col in required_columns:
......
...@@ -115,73 +115,61 @@ class BlockchainMonitor: ...@@ -115,73 +115,61 @@ class BlockchainMonitor:
logger.error(f"Error checking Bitcoin address {address}: {e}") logger.error(f"Error checking Bitcoin address {address}: {e}")
async def _get_bitcoin_transactions(self, address: str) -> List[Dict]: async def _get_bitcoin_transactions(self, address: str) -> List[Dict]:
""" """Fetch Bitcoin transactions from Blockchain.com API."""
Fetch Bitcoin transactions from Blockchain.com API.
Args:
address: Bitcoin address to check
Returns:
List of transaction dicts with keys: hash, from_address, amount, confirmations
"""
url = f"https://blockchain.info/rawaddr/{address}"
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url) addr_resp = await client.get(f"https://blockchain.info/rawaddr/{address}?limit=25")
response.raise_for_status() addr_resp.raise_for_status()
data = response.json() data = addr_resp.json()
# Fetch latest block height for confirmation calculation
try:
block_resp = await client.get("https://blockchain.info/latestblock")
block_resp.raise_for_status()
data['latest_block'] = block_resp.json()
except Exception:
pass # confirmations will be 0 if unavailable
return self._parse_blockchain_com_btc(data, address) return self._parse_blockchain_com_btc(data, address)
def _parse_blockchain_com_btc(self, data: Dict, target_address: str) -> List[Dict]: def _parse_blockchain_com_btc(self, data: Dict, target_address: str) -> List[Dict]:
""" """Parse Blockchain.com API response for Bitcoin transactions."""
Parse Blockchain.com API response for Bitcoin transactions.
Args:
data: API response data
target_address: The address we're monitoring (to identify incoming txs)
Returns:
List of incoming transaction dicts
"""
transactions = [] transactions = []
latest_block = data.get('latest_block', {}).get('height', 0)
for tx in data.get('txs', []): for tx in data.get('txs', []):
# Check if this transaction sends to our address
for output in tx.get('out', []): for output in tx.get('out', []):
if output.get('addr') == target_address: if output.get('addr') == target_address:
# This is an incoming transaction
amount_satoshi = output.get('value', 0) amount_satoshi = output.get('value', 0)
amount_btc = amount_satoshi / 100000000 # Convert satoshi to BTC amount_btc = amount_satoshi / 100_000_000
# Get sender address (first input address)
from_address = None from_address = None
if tx.get('inputs'): if tx.get('inputs'):
prev_out = tx['inputs'][0].get('prev_out', {}) prev_out = tx['inputs'][0].get('prev_out', {})
from_address = prev_out.get('addr', 'unknown') from_address = prev_out.get('addr', 'unknown')
tx_block = tx.get('block_height')
confirmations = (latest_block - tx_block + 1) if tx_block else 0
transactions.append({ transactions.append({
'hash': tx['hash'], 'hash': tx['hash'],
'from_address': from_address or 'unknown', 'from_address': from_address or 'unknown',
'amount': amount_btc, 'amount': amount_btc,
'confirmations': data.get('n_tx', 0) # Use block height as proxy 'confirmations': confirmations,
}) })
return transactions return transactions
async def check_ethereum_addresses(self): async def check_ethereum_addresses(self):
""" """
Check all Ethereum addresses for transactions. Check all Ethereum addresses (and ERC-20 tokens) for transactions.
Uses Etherscan public API (no key required for basic polling).
Placeholder - would use Etherscan/Infura API in production.
""" """
# Get all Ethereum addresses
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, user_id, address SELECT id, user_id, address, crypto_type
FROM user_crypto_addresses FROM user_crypto_addresses
WHERE crypto_type = 'eth' WHERE crypto_type IN ('eth', 'usdt', 'usdc')
""") """)
addresses = cursor.fetchall() addresses = cursor.fetchall()
...@@ -189,8 +177,104 @@ class BlockchainMonitor: ...@@ -189,8 +177,104 @@ class BlockchainMonitor:
logger.debug("No Ethereum addresses to check") logger.debug("No Ethereum addresses to check")
return return
logger.info(f"Checking {len(addresses)} Ethereum addresses (placeholder)...") logger.info(f"Checking {len(addresses)} Ethereum addresses...")
# TODO: Implement Ethereum checking with Etherscan/Infura API
# ERC-20 contract addresses (mainnet)
ERC20_CONTRACTS = {
'usdt': '0xdac17f958d2ee523a2206206994597c13d831ec7',
'usdc': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
}
for address_id, user_id, address, crypto_type in addresses:
try:
if crypto_type == 'eth':
transactions = await self._get_ethereum_transactions(address)
else:
contract = ERC20_CONTRACTS.get(crypto_type)
if not contract:
continue
transactions = await self._get_erc20_transactions(address, contract, crypto_type)
for tx in transactions:
await self.process_transaction(
user_id=user_id,
crypto_type=crypto_type,
tx_hash=tx['hash'],
from_address=tx['from_address'],
to_address=address,
amount=tx['amount'],
confirmations=tx['confirmations']
)
except Exception as e:
logger.error(f"Error checking Ethereum address {address}: {e}")
async def _get_ethereum_transactions(self, address: str) -> List[Dict]:
"""Fetch ETH transactions from Etherscan public API."""
url = (
f"https://api.etherscan.io/api"
f"?module=account&action=txlist&address={address}"
f"&startblock=0&endblock=99999999&sort=desc&offset=25&page=1"
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
if data.get('status') != '1':
return []
results = []
for tx in data.get('result', []):
# Only incoming transactions
if tx.get('to', '').lower() != address.lower():
continue
amount_wei = int(tx.get('value', 0))
amount_eth = amount_wei / 1e18
if amount_eth <= 0:
continue
confirmations = int(tx.get('confirmations', 0))
results.append({
'hash': tx['hash'],
'from_address': tx.get('from', 'unknown'),
'amount': amount_eth,
'confirmations': confirmations,
})
return results
async def _get_erc20_transactions(self, address: str, contract: str, crypto_type: str) -> List[Dict]:
"""Fetch ERC-20 token transfers from Etherscan public API."""
url = (
f"https://api.etherscan.io/api"
f"?module=account&action=tokentx&contractaddress={contract}"
f"&address={address}&startblock=0&endblock=99999999&sort=desc&offset=25&page=1"
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
if data.get('status') != '1':
return []
decimals_map = {'usdt': 6, 'usdc': 6}
decimals = decimals_map.get(crypto_type, 18)
results = []
for tx in data.get('result', []):
if tx.get('to', '').lower() != address.lower():
continue
amount_raw = int(tx.get('value', 0))
amount = amount_raw / (10 ** decimals)
if amount <= 0:
continue
confirmations = int(tx.get('confirmations', 0))
results.append({
'hash': tx['hash'],
'from_address': tx.get('from', 'unknown'),
'amount': amount,
'confirmations': confirmations,
})
return results
async def process_transaction( async def process_transaction(
self, self,
...@@ -219,30 +303,29 @@ class BlockchainMonitor: ...@@ -219,30 +303,29 @@ class BlockchainMonitor:
""" """
crypto_type = crypto_type.lower() crypto_type = crypto_type.lower()
required_confs = self.required_confirmations.get(crypto_type, 3) required_confs = self.required_confirmations.get(crypto_type, 3)
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Check if transaction already exists # Check if transaction already exists
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"SELECT id, status, confirmations FROM crypto_transactions WHERE tx_hash = ?", f"SELECT id, status, confirmations FROM crypto_transactions WHERE tx_hash = {placeholder}",
(tx_hash,) (tx_hash,)
) )
existing = cursor.fetchone() existing = cursor.fetchone()
if existing: if existing:
# Update existing transaction
tx_id, status, old_confirmations = existing tx_id, status, old_confirmations = existing
# Only update if confirmations increased or status changed
if confirmations > old_confirmations or status == 'pending': if confirmations > old_confirmations or status == 'pending':
new_status = 'confirmed' if confirmations >= required_confs else 'pending' new_status = 'confirmed' if confirmations >= required_confs else 'pending'
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute(f"""
UPDATE crypto_transactions UPDATE crypto_transactions
SET confirmations = ?, status = ?, confirmed_at = ? SET confirmations = {placeholder}, status = {placeholder}, confirmed_at = {placeholder}
WHERE id = ? WHERE id = {placeholder}
""", ( """, (
confirmations, confirmations,
new_status, new_status,
...@@ -253,18 +336,15 @@ class BlockchainMonitor: ...@@ -253,18 +336,15 @@ class BlockchainMonitor:
logger.info(f"Updated transaction {tx_hash}: {confirmations} confirmations, status={new_status}") logger.info(f"Updated transaction {tx_hash}: {confirmations} confirmations, status={new_status}")
# Credit wallet if newly confirmed
if new_status == 'confirmed' and status == 'pending': if new_status == 'confirmed' and status == 'pending':
await self.credit_user_wallet(user_id, crypto_type, amount, tx_id) await self.credit_user_wallet(user_id, crypto_type, amount, tx_id)
else: else:
# Create new transaction
status = 'confirmed' if confirmations >= required_confs else 'pending' status = 'confirmed' if confirmations >= required_confs else 'pending'
# Get address_id
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"SELECT id FROM user_crypto_addresses WHERE user_id = ? AND address = ?", f"SELECT id FROM user_crypto_addresses WHERE user_id = {placeholder} AND address = {placeholder}",
(user_id, to_address) (user_id, to_address)
) )
address_row = cursor.fetchone() address_row = cursor.fetchone()
...@@ -274,31 +354,23 @@ class BlockchainMonitor: ...@@ -274,31 +354,23 @@ class BlockchainMonitor:
logger.error(f"Address {to_address} not found for user {user_id}") logger.error(f"Address {to_address} not found for user {user_id}")
return return
# Convert to fiat
try: try:
amount_fiat = await self.price_service.convert_crypto_to_fiat(crypto_type, amount) amount_fiat = await self.price_service.convert_crypto_to_fiat(crypto_type, amount)
except Exception as e: except Exception as e:
logger.warning(f"Could not convert to fiat: {e}") logger.warning(f"Could not convert to fiat: {e}")
amount_fiat = None amount_fiat = None
# Insert transaction
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute(f"""
INSERT INTO crypto_transactions INSERT INTO crypto_transactions
(user_id, address_id, crypto_type, tx_hash, amount_crypto, amount_fiat, (user_id, address_id, crypto_type, tx_hash, amount_crypto, amount_fiat,
confirmations, required_confirmations, status, detected_at, confirmed_at) confirmations, required_confirmations, status, detected_at, confirmed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES ({placeholder},{placeholder},{placeholder},{placeholder},{placeholder},
{placeholder},{placeholder},{placeholder},{placeholder},{placeholder},{placeholder})
""", ( """, (
user_id, user_id, address_id, crypto_type, tx_hash, amount, amount_fiat,
address_id, confirmations, required_confs, status,
crypto_type,
tx_hash,
amount,
amount_fiat,
confirmations,
required_confs,
status,
datetime.utcnow(), datetime.utcnow(),
datetime.utcnow() if status == 'confirmed' else None datetime.utcnow() if status == 'confirmed' else None
)) ))
...@@ -307,68 +379,87 @@ class BlockchainMonitor: ...@@ -307,68 +379,87 @@ class BlockchainMonitor:
logger.info(f"Created transaction {tx_hash}: {amount} {crypto_type}, status={status}") logger.info(f"Created transaction {tx_hash}: {amount} {crypto_type}, status={status}")
# Credit wallet if confirmed
if status == 'confirmed': if status == 'confirmed':
await self.credit_user_wallet(user_id, crypto_type, amount, tx_id) await self.credit_user_wallet(user_id, crypto_type, amount, tx_id)
async def credit_user_wallet(self, user_id: int, crypto_type: str, amount: float, tx_id: int): async def credit_user_wallet(self, user_id: int, crypto_type: str, amount: float, tx_id: int):
""" """
Credit user's crypto wallet with confirmed transaction amount. Credit user's fiat wallet with the confirmed transaction amount.
Args:
user_id: User ID
crypto_type: Cryptocurrency type
amount: Amount in crypto
tx_id: Transaction ID
""" """
# Convert to fiat
try: try:
amount_fiat = await self.price_service.convert_crypto_to_fiat(crypto_type, amount) amount_fiat = await self.price_service.convert_crypto_to_fiat(crypto_type, amount)
except Exception as e: except Exception as e:
logger.error(f"Could not convert to fiat for crediting: {e}") logger.error(f"Could not convert to fiat for crediting: {e}")
amount_fiat = 0 amount_fiat = 0
now = datetime.utcnow()
# Update crypto wallet balance # Update crypto wallet balance
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
cursor.execute(f"""
UPDATE user_crypto_wallets UPDATE user_crypto_wallets
SET balance_crypto = balance_crypto + ?, SET balance_crypto = balance_crypto + {placeholder},
balance_fiat = balance_fiat + ?, balance_fiat = balance_fiat + {placeholder},
last_updated = ? last_updated = {placeholder}
WHERE user_id = ? AND crypto_type = ? WHERE user_id = {placeholder} AND crypto_type = {placeholder}
""", (amount, amount_fiat, datetime.utcnow(), user_id, crypto_type)) """, (amount, amount_fiat, now, user_id, crypto_type))
# Mark transaction as credited cursor.execute(f"""
cursor.execute(""" UPDATE crypto_transactions SET credited_at = {placeholder} WHERE id = {placeholder}
UPDATE crypto_transactions """, (now, tx_id))
SET credited_at = ?
WHERE id = ?
""", (datetime.utcnow(), tx_id))
conn.commit() conn.commit()
# Also credit fiat wallet # Credit fiat wallet directly via DB
if amount_fiat > 0: if amount_fiat > 0:
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
try: try:
async with AsyncSession(self.db.engine) as session: with self.db._get_connection() as conn:
wallet_manager = WalletManager(session) cursor = conn.cursor()
await wallet_manager.credit_wallet( placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
user_id=user_id,
amount=Decimal(str(amount_fiat)), # Ensure wallet row exists
transaction_details={ if self.db.db_type == 'sqlite':
'payment_gateway': f'crypto_{crypto_type}', cursor.execute(f"""
'gateway_transaction_id': f'crypto_tx_{tx_id}', INSERT OR IGNORE INTO user_wallets (user_id, balance, currency_code)
'description': f'Wallet top up via {crypto_type.upper()} payment', VALUES ({placeholder}, 0.00, 'USD')
'metadata': {'crypto_amount': amount, 'crypto_type': crypto_type, 'tx_id': tx_id} """, (user_id,))
} else:
) cursor.execute(f"""
await session.commit() INSERT IGNORE INTO user_wallets (user_id, balance, currency_code)
VALUES ({placeholder}, 0.00, 'USD')
""", (user_id,))
cursor.execute(f"""
UPDATE user_wallets
SET balance = balance + {placeholder}, updated_at = {placeholder}
WHERE user_id = {placeholder}
""", (amount_fiat, now, user_id))
# Get wallet id
cursor.execute(f"SELECT id FROM user_wallets WHERE user_id = {placeholder}", (user_id,))
wallet_row = cursor.fetchone()
wallet_id = wallet_row[0] if wallet_row else None
if wallet_id:
import json
cursor.execute(f"""
INSERT INTO wallet_transactions
(user_id, wallet_id, amount, type, status,
payment_gateway, gateway_transaction_id, description, metadata)
VALUES ({placeholder},{placeholder},{placeholder},'credit','completed',
{placeholder},{placeholder},{placeholder},{placeholder})
""", (
user_id, wallet_id, amount_fiat,
f'crypto_{crypto_type}',
f'crypto_tx_{tx_id}',
f'Wallet top-up via {crypto_type.upper()} payment',
json.dumps({'crypto_amount': amount, 'crypto_type': crypto_type, 'tx_id': tx_id})
))
conn.commit()
logger.info(f"Fiat wallet credited {amount_fiat:.2f} USD for user {user_id} from crypto payment") logger.info(f"Fiat wallet credited {amount_fiat:.2f} for user {user_id} from {crypto_type} payment")
except Exception as e: except Exception as e:
logger.error(f"Error crediting fiat wallet from crypto payment: {e}") logger.error(f"Error crediting fiat wallet from crypto payment: {e}")
......
...@@ -56,23 +56,19 @@ class PaymentService: ...@@ -56,23 +56,19 @@ class PaymentService:
"""Get or create crypto addresses for user""" """Get or create crypto addresses for user"""
addresses = {} addresses = {}
# Get enabled crypto types # Get enabled crypto gateways from payment gateway settings
with self.db._get_connection() as conn: gateways = self.db.get_payment_gateway_settings()
cursor = conn.cursor() crypto_types = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
cursor.execute(f""" for gateway_name, crypto_type in crypto_types.items():
SELECT crypto_type FROM crypto_consolidation_settings gw = gateways.get(gateway_name, {})
WHERE enabled = {placeholder} if not gw.get('enabled', False):
""", (True,)) continue
enabled_cryptos = cursor.fetchall() try:
address = await self.wallet_manager.get_or_create_user_address(user_id, crypto_type)
for crypto_config in enabled_cryptos: addresses[gateway_name] = address
crypto_type = crypto_config[0] except Exception as e:
address = await self.wallet_manager.get_or_create_user_address( logger.warning(f"Could not get/create {crypto_type} address for user {user_id}: {e}")
user_id,
crypto_type
)
addresses[crypto_type] = address
return addresses return addresses
......
...@@ -59,6 +59,7 @@ from datetime import datetime, timedelta ...@@ -59,6 +59,7 @@ from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
import json import json
import re
import markdown import markdown
from urllib.parse import urljoin, urlencode from urllib.parse import urljoin, urlencode
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
...@@ -3198,7 +3199,7 @@ async def dashboard_profile(request: Request): ...@@ -3198,7 +3199,7 @@ async def dashboard_profile(request: Request):
@app.post("/dashboard/profile") @app.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form("")): async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form(""), profile_pic: UploadFile = File(None)):
"""Save user profile changes (username and display_name)""" """Save user profile changes (username and display_name)"""
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse): if isinstance(auth_check, RedirectResponse):
...@@ -3208,7 +3209,18 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di ...@@ -3208,7 +3209,18 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
try: try:
db.update_user_profile(user_id, username, None, display_name if display_name else None) profile_pic_data = None
if profile_pic and profile_pic.filename:
content_type = profile_pic.content_type or ''
if not content_type.startswith('image/'):
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Invalid file type. Please upload an image."), status_code=303)
data = await profile_pic.read(1024 * 1024 + 1) # read up to 1MB+1 to detect oversized
if len(data) > 1024 * 1024:
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Image too large. Maximum size is 1MB."), status_code=303)
import base64
profile_pic_data = f"data:{content_type};base64,{base64.b64encode(data).decode()}"
db.update_user_profile(user_id, username, None, display_name if display_name else None, profile_pic_data)
# Update session with new username and display_name # Update session with new username and display_name
request.session['username'] = username request.session['username'] = username
request.session['display_name'] = display_name or '' request.session['display_name'] = display_name or ''
...@@ -8205,12 +8217,18 @@ async def dashboard_wallet_topup(request: Request): ...@@ -8205,12 +8217,18 @@ async def dashboard_wallet_topup(request: Request):
if not gw.get('enabled', False): if not gw.get('enabled', False):
return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400) return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400)
# Crypto: return deposit address (manual transfer) # Crypto: generate per-user HD wallet address
crypto_methods = {'bitcoin', 'ethereum', 'usdt', 'usdc'} crypto_methods = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
if method in crypto_methods: if method in crypto_methods:
address = gw.get('address', '') crypto_type = crypto_methods[method]
if not address: ps = getattr(request.app.state, 'payment_service', None)
return JSONResponse({"error": "Crypto address not configured"}, status_code=503) if ps is None:
return JSONResponse({"error": "Payment service unavailable"}, status_code=503)
try:
address = await ps.wallet_manager.get_or_create_user_address(user_id, crypto_type)
except Exception as e:
logger.error(f"Crypto address generation error: {e}")
return JSONResponse({"error": "Could not generate deposit address"}, status_code=503)
return JSONResponse({ return JSONResponse({
"type": "crypto", "type": "crypto",
"method": method, "method": method,
...@@ -8257,22 +8275,14 @@ async def dashboard_wallet_topup(request: Request): ...@@ -8257,22 +8275,14 @@ async def dashboard_wallet_topup(request: Request):
if method == 'paypal': if method == 'paypal':
try: try:
payment_service = getattr(request.app.state, 'payment_service', None) payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'paypal_handler'): if not payment_service or not hasattr(payment_service, 'paypal_handler'):
return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
from decimal import Decimal from decimal import Decimal
order = await payment_service.paypal_handler.create_order( result = await payment_service.paypal_handler.create_topup_order(user_id, Decimal(str(amount)))
user_id, Decimal(str(amount)), metadata={"type": "wallet_topup"} if not result.get('success'):
) logger.error(f"PayPal top-up error: {result.get('error')}")
return JSONResponse({"type": "paypal", "order_id": order.id}) return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
# Fallback: direct PayPal redirect return JSONResponse({"type": "paypal", "approval_url": result['approval_url']})
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: except Exception as e:
logger.error(f"PayPal top-up error: {e}") logger.error(f"PayPal top-up error: {e}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502) return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
...@@ -8994,7 +9004,7 @@ async def dashboard_docs(request: Request): ...@@ -8994,7 +9004,7 @@ async def dashboard_docs(request: Request):
# Convert markdown to HTML with extensions for better formatting # Convert markdown to HTML with extensions for better formatting
html_content = markdown.markdown( html_content = markdown.markdown(
markdown_content, markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'] extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists', 'toc']
) )
else: else:
html_content = "<p>Documentation file not found.</p>" html_content = "<p>Documentation file not found.</p>"
...@@ -9039,6 +9049,17 @@ async def dashboard_about(request: Request): ...@@ -9039,6 +9049,17 @@ async def dashboard_about(request: Request):
markdown_content, markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'] extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
) )
# Rewrite DOCUMENTATION.md links to /dashboard/docs
html_content = re.sub(
r'href="DOCUMENTATION\.md#([^"]*)"',
r'href="/dashboard/docs#\1"',
html_content
)
html_content = re.sub(
r'href="DOCUMENTATION\.md"',
'href="/dashboard/docs"',
html_content
)
else: else:
html_content = "<p>README file not found.</p>" html_content = "<p>README file not found.</p>"
......
...@@ -135,4 +135,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -135,4 +135,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="markdown-content"> <div class="markdown-content">
{{ content|safe }} {{ content|safe }}
</div> </div>
<script>
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView();
}
</script>
{% endblock %} {% endblock %}
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<div class="card"> <div class="card">
<h2>Account Information</h2> <h2>Account Information</h2>
<form method="POST" action="{{ url_for(request, '/dashboard/profile') }}"> <form method="POST" action="{{ url_for(request, '/dashboard/profile') }}" enctype="multipart/form-data">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ session.username }}" required> <input type="text" id="username" name="username" value="{{ session.username }}" required>
...@@ -48,9 +48,22 @@ ...@@ -48,9 +48,22 @@
<div class="form-group"> <div class="form-group">
<label>Profile Picture</label> <label>Profile Picture</label>
<div style="display: flex; align-items: center; gap: 1rem;"> <div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<img src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Current avatar" style="border-radius: 8px;"> <div style="position: relative; cursor: pointer;" onclick="document.getElementById('profile_pic').click()">
<p style="color: #a0a0a0;">Profile pictures are managed via <a href="https://gravatar.com" target="_blank">Gravatar</a> using your email address</p> {% if user.profile_pic %}
<img id="avatar-preview" src="{{ user.profile_pic }}" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% else %}
<img id="avatar-preview" src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% endif %}
<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s;" id="avatar-overlay">
<span style="color: #fff; font-size: 0.8rem; text-align: center;">Change</span>
</div>
</div>
<div>
<input type="file" id="profile_pic" name="profile_pic" accept="image/*" style="display: none;" onchange="previewAvatar(this)">
<button type="button" class="btn" style="background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;" onclick="document.getElementById('profile_pic').click()">Upload Image</button>
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Max 1 MB. JPG, PNG, GIF, WebP.</small>
</div>
</div> </div>
</div> </div>
...@@ -106,4 +119,19 @@ ...@@ -106,4 +119,19 @@
border-color: #667eea; border-color: #667eea;
} }
</style> </style>
<script>
function previewAvatar(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
reader.onload = e => { document.getElementById('avatar-preview').src = e.target.result; };
reader.readAsDataURL(input.files[0]);
}
const avatarWrap = document.querySelector('[onclick="document.getElementById(\'profile_pic\').click()"]');
const overlay = document.getElementById('avatar-overlay');
if (avatarWrap && overlay) {
avatarWrap.addEventListener('mouseenter', () => overlay.style.opacity = '1');
avatarWrap.addEventListener('mouseleave', () => overlay.style.opacity = '0');
}
</script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -31,7 +31,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -31,7 +31,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<label style="font-weight: 500; margin-bottom: 10px; display: block;">Select Prompt File:</label> <label style="font-weight: 500; margin-bottom: 10px; display: block;">Select Prompt File:</label>
<select id="prompt-selector" onchange="switchPrompt()" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;"> <select id="prompt-selector" onchange="switchPrompt()">
{% for prompt in prompts %} {% for prompt in prompts %}
<option value="{{ prompt.key }}" {% if loop.first %}selected{% endif %}>{{ prompt.name }}</option> <option value="{{ prompt.key }}" {% if loop.first %}selected{% endif %}>{{ prompt.name }}</option>
{% endfor %} {% endfor %}
...@@ -42,9 +42,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -42,9 +42,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<input type="hidden" name="prompt_key" id="prompt_key" value=""> <input type="hidden" name="prompt_key" id="prompt_key" value="">
<div class="form-group"> <div class="form-group">
<label for="prompt_content" style="font-weight: 500; margin-bottom: 10px; display: block;">Prompt Content:</label> <label for="prompt_content">Prompt Content:</label>
<textarea id="prompt_content" name="prompt_content" style="width: 100%; min-height: 400px; padding: 10px; border: 1px solid #ddd; border-radius: 3px; font-family: monospace; font-size: 13px; line-height: 1.5;"></textarea> <textarea id="prompt_content" name="prompt_content" style="min-height: 400px;"></textarea>
<small style="color: #666; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small> <small style="color: #a0a0a0; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small>
</div> </div>
<div style="display: flex; gap: 10px; margin-top: 20px;"> <div style="display: flex; gap: 10px; margin-top: 20px;">
...@@ -52,7 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -52,7 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if not is_admin %} {% if not is_admin %}
<button type="button" class="btn btn-secondary" onclick="resetPrompt()">Reset to Default</button> <button type="button" class="btn btn-secondary" onclick="resetPrompt()">Reset to Default</button>
{% endif %} {% endif %}
<a href="/dashboard" class="btn btn-secondary">Cancel</a> <a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
</div> </div>
</form> </form>
...@@ -96,33 +96,4 @@ if (prompts.length > 0) { ...@@ -96,33 +96,4 @@ if (prompts.length > 0) {
document.getElementById('prompt_key').value = prompts[0].key; document.getElementById('prompt_key').value = prompts[0].key;
} }
</script> </script>
<style>
.form-group {
margin-bottom: 20px;
}
textarea {
resize: vertical;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
{% endblock %} {% endblock %}
...@@ -94,8 +94,7 @@ ...@@ -94,8 +94,7 @@
<div style="display: flex; flex-wrap: wrap; gap: 8px;"> <div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for name in crypto_gateways %} {% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %} {% set cfg = enabled_gateways[name] %}
{% set address = cfg.get('wallet_address') or cfg.get('address') or '' %} <button onclick="openCryptoModal('{{ name }}')"
<button onclick="openCryptoModal('{{ name }}','{{ address }}')"
style="background:#1a1a2e; border:1px solid #0f3460; color:#e0e0e0; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; transition:border-color .15s;" style="background:#1a1a2e; border:1px solid #0f3460; color:#e0e0e0; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; transition:border-color .15s;"
onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'"> onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i> {% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i>
...@@ -243,9 +242,10 @@ ...@@ -243,9 +242,10 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// ── Amount buttons ────────────────────────────────────────── // ── Amount buttons ──────────────────────────────────────────
let selectedAmount = null; let selectedAmount = 15;
document.querySelectorAll('.amount-btn').forEach(btn => { document.querySelectorAll('.amount-btn').forEach(btn => {
if (parseFloat(btn.dataset.amount) === selectedAmount) btn.classList.add('active');
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active'); this.classList.add('active');
...@@ -423,17 +423,35 @@ function copyAddress(addr, btn) { ...@@ -423,17 +423,35 @@ function copyAddress(addr, btn) {
let _cryptoAddr = ''; let _cryptoAddr = '';
function openCryptoModal(name, address) { function openCryptoModal(name) {
const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' }; const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' };
document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase(); document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase();
document.getElementById('cryptoAddress').textContent = address || 'Address not configured'; document.getElementById('cryptoAddress').textContent = 'Loading…';
_cryptoAddr = address; _cryptoAddr = '';
const qrEl = document.getElementById('cryptoQR'); const qrEl = document.getElementById('cryptoQR');
qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>'; qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>';
document.getElementById('cryptoModal').classList.add('active'); document.getElementById('cryptoModal').classList.add('active');
// Fetch a per-user deposit address from the server
const amount = getAmount() || 15;
fetch('/dashboard/wallet/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, payment_method: name })
})
.then(r => r.json())
.then(data => {
if (data.error) {
document.getElementById('cryptoAddress').textContent = data.error;
qrEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Error</span>';
return;
}
const address = data.address || '';
_cryptoAddr = address;
document.getElementById('cryptoAddress').textContent = address || 'Address unavailable';
if (address) { if (address) {
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`; const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`;
const img = new Image(); const img = new Image();
...@@ -441,7 +459,14 @@ function openCryptoModal(name, address) { ...@@ -441,7 +459,14 @@ function openCryptoModal(name, address) {
img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; }; img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; };
img.src = qrUrl; img.src = qrUrl;
img.style.cssText = 'width:200px;height:200px;border-radius:8px;'; img.style.cssText = 'width:200px;height:200px;border-radius:8px;';
} else {
qrEl.innerHTML = '<span style="color:#888;font-size:12px;">No address</span>';
} }
})
.catch(() => {
document.getElementById('cryptoAddress').textContent = 'Failed to load address';
qrEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Error</span>';
});
} }
function closeCryptoModal() { function closeCryptoModal() {
......
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