Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
A
aisbf
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
nexlab
aisbf
Commits
13b80367
Commit
13b80367
authored
Apr 22, 2026
by
Stefy Lanza (nextime / spora )
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Payment systems...
parent
946dfd79
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
371 additions
and
230 deletions
+371
-230
database.py
aisbf/database.py
+17
-14
monitor.py
aisbf/payments/crypto/monitor.py
+212
-121
service.py
aisbf/payments/service.py
+15
-19
main.py
main.py
+45
-24
docs.html
templates/dashboard/docs.html
+6
-0
profile.html
templates/dashboard/profile.html
+32
-4
prompts.html
templates/dashboard/prompts.html
+5
-34
wallet.html
templates/dashboard/wallet.html
+39
-14
No files found.
aisbf/database.py
View file @
13b80367
...
@@ -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
:
...
...
aisbf/payments/crypto/monitor.py
View file @
13b80367
...
@@ -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
/
100
000000
# 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}"
)
...
...
aisbf/payments/service.py
View file @
13b80367
...
@@ -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
...
...
main.py
View file @
13b80367
...
@@ -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>"
...
...
templates/dashboard/docs.html
View file @
13b80367
...
@@ -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 %}
templates/dashboard/profile.html
View file @
13b80367
...
@@ -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
templates/dashboard/prompts.html
View file @
13b80367
...
@@ -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 %}
templates/dashboard/wallet.html
View file @
13b80367
...
@@ -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
()
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment