Add wallet and profile features for brokers and players

- Add WalletModel and WalletTransactionModel for balance tracking
- Add wallet API endpoints: balance, deposit, withdraw, transactions
- Add profile API endpoints: password change, email change with verification, avatar upload
- Create profile.html page with wallet and profile management UI
- Update player.html and broker.html with profile links
- Add wallet and profile JavaScript functions to app.js
- Add comprehensive CSS styling for profile and wallet components
parent 716c6371
This diff is collapsed.
......@@ -1789,3 +1789,209 @@ class UserOAuthAccountModel(BaseModel):
def __repr__(self):
return f'<UserOAuthAccount user={self.user_id} provider={self.provider_id}>'
class WalletModel(BaseModel):
"""User wallet for brokers and players"""
__tablename__ = 'wallets'
__table_args__ = (
Index('ix_wallets_user_id', 'user_id'),
Index('ix_wallets_currency', 'currency'),
Index('ix_wallets_is_active', 'is_active'),
UniqueConstraint('user_id', 'currency', name='uq_wallets_user_currency'),
)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, comment='Foreign key to users table')
currency = Column(String(10), nullable=False, default='USD', comment='Wallet currency code')
balance = Column(Float(precision=2), default=0.0, nullable=False, comment='Current wallet balance')
pending_balance = Column(Float(precision=2), default=0.0, nullable=False, comment='Pending balance from ongoing transactions')
total_deposited = Column(Float(precision=2), default=0.0, nullable=False, comment='Total amount deposited')
total_withdrawn = Column(Float(precision=2), default=0.0, nullable=False, comment='Total amount withdrawn')
total_wagered = Column(Float(precision=2), default=0.0, nullable=False, comment='Total amount wagered')
total_winnings = Column(Float(precision=2), default=0.0, nullable=False, comment='Total winnings received')
is_active = Column(Boolean, default=True, nullable=False, comment='Whether wallet is active')
is_primary = Column(Boolean, default=True, nullable=False, comment='Whether this is the primary wallet')
# Relationships
user = relationship('UserModel', backref='wallets')
transactions = relationship('WalletTransactionModel', back_populates='wallet', cascade='all, delete-orphan')
def get_available_balance(self) -> float:
"""Get available balance (excluding pending)"""
return max(0.0, self.balance - self.pending_balance)
def can_withdraw(self, amount: float) -> bool:
"""Check if user can withdraw the specified amount"""
return self.get_available_balance() >= amount and self.is_active
def deposit(self, amount: float, description: str = None) -> 'WalletTransactionModel':
"""Create a deposit transaction"""
if amount <= 0:
raise ValueError("Deposit amount must be positive")
transaction = WalletTransactionModel(
wallet_id=self.id,
transaction_type='deposit',
amount=amount,
balance_before=self.balance,
balance_after=self.balance + amount,
status='completed',
description=description or 'Deposit'
)
self.balance += amount
self.total_deposited += amount
self.updated_at = datetime.utcnow()
return transaction
def withdraw(self, amount: float, description: str = None) -> 'WalletTransactionModel':
"""Create a withdrawal transaction"""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if not self.can_withdraw(amount):
raise ValueError("Insufficient balance")
transaction = WalletTransactionModel(
wallet_id=self.id,
transaction_type='withdrawal',
amount=amount,
balance_before=self.balance,
balance_after=self.balance - amount,
status='pending',
description=description or 'Withdrawal'
)
self.balance -= amount
self.total_withdrawn += amount
self.updated_at = datetime.utcnow()
return transaction
def add_winnings(self, amount: float, description: str = None) -> 'WalletTransactionModel':
"""Add winnings to wallet"""
if amount <= 0:
raise ValueError("Winnings amount must be positive")
transaction = WalletTransactionModel(
wallet_id=self.id,
transaction_type='winnings',
amount=amount,
balance_before=self.balance,
balance_after=self.balance + amount,
status='completed',
description=description or 'Winnings'
)
self.balance += amount
self.total_winnings += amount
self.updated_at = datetime.utcnow()
return transaction
def place_bet(self, amount: float, description: str = None) -> 'WalletTransactionModel':
"""Deduct bet amount from wallet"""
if amount <= 0:
raise ValueError("Bet amount must be positive")
if not self.can_withdraw(amount):
raise ValueError("Insufficient balance")
transaction = WalletTransactionModel(
wallet_id=self.id,
transaction_type='bet',
amount=amount,
balance_before=self.balance,
balance_after=self.balance - amount,
status='completed',
description=description or 'Bet placement'
)
self.balance -= amount
self.total_wagered += amount
self.updated_at = datetime.utcnow()
return transaction
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary with calculated fields"""
result = super().to_dict(exclude_fields)
result['available_balance'] = self.get_available_balance()
return result
def __repr__(self):
return f'<Wallet user={self.user_id} balance={self.balance} {self.currency}>'
class WalletTransactionModel(BaseModel):
"""Wallet transaction history"""
__tablename__ = 'wallet_transactions'
__table_args__ = (
Index('ix_wallet_transactions_wallet_id', 'wallet_id'),
Index('ix_wallet_transactions_transaction_type', 'transaction_type'),
Index('ix_wallet_transactions_status', 'status'),
Index('ix_wallet_transactions_created_at', 'created_at'),
Index('ix_wallet_transactions_reference_id', 'reference_id'),
Index('ix_wallet_transactions_reference_type', 'reference_type'),
)
wallet_id = Column(Integer, ForeignKey('wallets.id', ondelete='CASCADE'), nullable=False, comment='Foreign key to wallets table')
transaction_type = Column(String(50), nullable=False, comment='Type: deposit, withdrawal, bet, winnings, refund, adjustment')
amount = Column(Float(precision=2), nullable=False, comment='Transaction amount (positive for credit, negative for debit)')
balance_before = Column(Float(precision=2), nullable=False, comment='Balance before transaction')
balance_after = Column(Float(precision=2), nullable=False, comment='Balance after transaction')
status = Column(String(20), default='pending', nullable=False, comment='Status: pending, completed, failed, cancelled')
description = Column(Text, comment='Transaction description')
# Reference to related entity (bet, payment, etc.)
reference_id = Column(String(255), comment='Reference ID (bet UUID, payment ID, etc.)')
reference_type = Column(String(50), comment='Reference type (bet, payment, manual, etc.)')
# Payment gateway info for deposits/withdrawals
payment_gateway = Column(String(100), comment='Payment gateway used')
payment_gateway_transaction_id = Column(String(255), comment='Gateway transaction ID')
payment_details = Column(JSON, comment='Additional payment details')
# Admin/audit fields
processed_by = Column(Integer, ForeignKey('users.id'), comment='Admin user who processed (for manual transactions)')
notes = Column(Text, comment='Internal notes')
# Relationships
wallet = relationship('WalletModel', back_populates='transactions')
processor = relationship('UserModel', foreign_keys=[processed_by])
def is_credit(self) -> bool:
"""Check if this is a credit transaction (adds to balance)"""
return self.transaction_type in ['deposit', 'winnings', 'refund']
def is_debit(self) -> bool:
"""Check if this is a debit transaction (subtracts from balance)"""
return self.transaction_type in ['withdrawal', 'bet']
def mark_completed(self):
"""Mark transaction as completed"""
self.status = 'completed'
self.updated_at = datetime.utcnow()
def mark_failed(self, reason: str = None):
"""Mark transaction as failed"""
self.status = 'failed'
if reason:
self.notes = reason
self.updated_at = datetime.utcnow()
def cancel(self, reason: str = None):
"""Cancel transaction"""
self.status = 'cancelled'
if reason:
self.notes = reason
self.updated_at = datetime.utcnow()
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary with additional info"""
result = super().to_dict(exclude_fields)
result['is_credit'] = self.is_credit()
result['is_debit'] = self.is_debit()
return result
def __repr__(self):
return f'<WalletTransaction {self.transaction_type}: {self.amount} ({self.status})>'
This diff is collapsed.
......@@ -11207,6 +11207,82 @@ def get_overlay_template_config():
})
# ==================== Wallet API Routes ====================
@api_bp.route('/wallet', methods=['GET'])
@get_api_auth_decorator()
def api_get_wallet():
"""Get wallet for authenticated user"""
from .api import get_wallet
return get_wallet()
@api_bp.route('/wallet/transactions', methods=['GET'])
@get_api_auth_decorator()
def api_get_wallet_transactions():
"""Get transaction history for authenticated user's wallet"""
from .api import get_wallet_transactions
return get_wallet_transactions()
@api_bp.route('/wallet/deposit', methods=['POST'])
@get_api_auth_decorator()
def api_deposit_to_wallet():
"""Deposit funds to authenticated user's wallet"""
from .api import deposit_to_wallet
return deposit_to_wallet()
@api_bp.route('/wallet/withdraw', methods=['POST'])
@get_api_auth_decorator()
def api_withdraw_from_wallet():
"""Withdraw funds from authenticated user's wallet"""
from .api import withdraw_from_wallet
return withdraw_from_wallet()
# ==================== Profile API Routes ====================
@api_bp.route('/profile', methods=['GET'])
@get_api_auth_decorator()
def api_get_profile():
"""Get profile for authenticated user"""
from .api import get_profile
return get_profile()
@api_bp.route('/profile/password', methods=['POST'])
@get_api_auth_decorator()
def api_change_password():
"""Change password for authenticated user"""
from .api import change_password
return change_password()
@api_bp.route('/profile/email', methods=['POST'])
@get_api_auth_decorator()
def api_request_email_change():
"""Request email change for authenticated user"""
from .api import request_email_change
return request_email_change()
@api_bp.route('/profile/email/verify', methods=['POST'])
@get_api_auth_decorator()
def api_verify_email_change():
"""Verify email change for authenticated user"""
from .api import verify_email_change
return verify_email_change()
@api_bp.route('/profile/avatar', methods=['POST'])
@get_api_auth_decorator()
def api_update_avatar():
"""Update avatar for authenticated user"""
from .api import update_avatar
return update_avatar()
# Web templates route - serves overlay templates for web player
@main_bp.route('/web-templates/<template_name>')
def serve_web_template(template_name):
......
This diff is collapsed.
......@@ -15,6 +15,8 @@
</div>
<div class="user-info">
<span id="user-name">Welcome, Broker</span>
<span id="user-balance" class="balance-display">Balance: $0.00</span>
<a href="profile.html" class="btn btn-secondary btn-sm">Profile</a>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</header>
......
......@@ -17,6 +17,7 @@
<div class="user-info">
<span id="user-name">Welcome, Player</span>
<span id="user-balance" class="balance-display">Balance: $0.00</span>
<a href="profile.html" class="btn btn-secondary btn-sm">Profile</a>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</header>
......
This diff is collapsed.
......@@ -2302,4 +2302,402 @@ body {
animation: none;
transition: none;
}
}
\ No newline at end of file
}
/* ============================================
Profile and Wallet Styles
============================================ */
/* Profile Overview */
.profile-overview {
margin-bottom: 30px;
}
.profile-header {
display: flex;
align-items: center;
gap: 30px;
padding: 20px;
}
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.avatar-large {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--accent-color, #00d4ff);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.avatar-btn {
font-size: 0.85rem;
padding: 8px 16px;
}
.profile-info h3 {
margin: 0 0 10px 0;
font-size: 1.5rem;
color: var(--text-color, #fff);
}
.profile-info p {
margin: 5px 0;
color: var(--text-muted, #aaa);
}
/* Wallet Overview */
.wallet-overview {
display: flex;
flex-wrap: wrap;
gap: 30px;
margin-bottom: 20px;
}
.wallet-balance {
flex: 1;
min-width: 200px;
text-align: center;
padding: 20px;
background: linear-gradient(135deg, var(--accent-color, #00d4ff), var(--secondary-color, #2d2d2d));
border-radius: 12px;
}
.wallet-balance h3 {
margin: 0 0 10px 0;
font-size: 1rem;
color: var(--text-muted, #aaa);
}
.balance-amount {
font-size: 2.5rem;
font-weight: bold;
color: var(--text-color, #fff);
margin: 0;
}
.wallet-stats {
flex: 2;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.stat-item {
display: flex;
flex-direction: column;
padding: 15px;
background: var(--secondary-color, #2d2d2d);
border-radius: 8px;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-muted, #aaa);
margin-bottom: 5px;
}
.stat-value {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-color, #fff);
}
.wallet-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* Transaction List */
.transaction-filters {
margin-bottom: 20px;
}
.transaction-filters select {
padding: 10px 15px;
background: var(--secondary-color, #2d2d2d);
border: 1px solid var(--border-color, #3d3d3d);
border-radius: 6px;
color: var(--text-color, #fff);
font-size: 0.9rem;
}
.transaction-list {
max-height: 400px;
overflow-y: auto;
}
.transaction-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: var(--secondary-color, #2d2d2d);
border-radius: 8px;
border-left: 4px solid transparent;
}
.transaction-item.credit {
border-left-color: var(--success-color, #00ff88);
}
.transaction-item.debit {
border-left-color: var(--error-color, #ff4d4d);
}
.transaction-info {
display: flex;
flex-direction: column;
gap: 5px;
}
.transaction-type {
font-weight: 600;
text-transform: capitalize;
color: var(--text-color, #fff);
}
.transaction-date {
font-size: 0.85rem;
color: var(--text-muted, #aaa);
}
.transaction-details {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.transaction-description {
font-size: 0.85rem;
color: var(--text-muted, #aaa);
}
.transaction-status {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
text-transform: capitalize;
}
.status-completed, .status-success {
background: rgba(0, 255, 136, 0.2);
color: var(--success-color, #00ff88);
}
.status-pending {
background: rgba(255, 165, 0, 0.2);
color: #ffa500;
}
.status-failed, .status-cancelled {
background: rgba(255, 77, 77, 0.2);
color: var(--error-color, #ff4d4d);
}
.transaction-amount {
font-size: 1.1rem;
font-weight: 600;
}
.transaction-amount.positive {
color: var(--success-color, #00ff88);
}
.transaction-amount.negative {
color: var(--error-color, #ff4d4d);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 20px;
}
.page-info {
color: var(--text-muted, #aaa);
font-size: 0.9rem;
}
/* Form Hints */
.form-hint {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #aaa);
margin-top: 5px;
}
/* Message Display */
.message {
padding: 12px 15px;
border-radius: 6px;
margin: 10px 0;
font-size: 0.9rem;
}
.message.success {
background: rgba(0, 255, 136, 0.2);
color: var(--success-color, #00ff88);
border: 1px solid rgba(0, 255, 136, 0.3);
}
.message.error {
background: rgba(255, 77, 77, 0.2);
color: var(--error-color, #ff4d4d);
border: 1px solid rgba(255, 77, 77, 0.3);
}
/* Avatar Preview */
.avatar-preview-container {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
/* Toast Notification */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: #fff;
font-weight: 500;
z-index: 10000;
animation: slideInRight 0.3s ease-out;
}
.toast.success {
background: var(--success-color, #00ff88);
color: #000;
}
.toast.error {
background: var(--error-color, #ff4d4d);
}
.toast.info {
background: var(--accent-color, #00d4ff);
color: #000;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Small Button Variant */
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
}
/* Balance Display in Header */
.balance-display {
background: linear-gradient(135deg, var(--accent-color, #00d4ff), #667eea);
padding: 6px 12px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
color: #000;
}
/* Modal Improvements */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background: var(--primary-color, #1a1a1a);
border-radius: 12px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border-color, #3d3d3d);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-muted, #aaa);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text-color, #fff);
}
.modal-body {
padding: 20px;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
text-align: center;
}
.wallet-overview {
flex-direction: column;
}
.wallet-balance {
min-width: 100%;
}
.transaction-item {
flex-direction: column;
gap: 10px;
text-align: center;
}
.transaction-info,
.transaction-details {
align-items: center;
}
}
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