Task 7: Implement wallet operations, top up flow, auto top up logic,...

Task 7: Implement wallet operations, top up flow, auto top up logic, subscription renewal integration with all tests
parent 8491b0be
......@@ -8,6 +8,7 @@ import asyncio
import logging
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from decimal import Decimal
import httpx
......@@ -313,7 +314,7 @@ class BlockchainMonitor:
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.
Args:
user_id: User ID
crypto_type: Cryptocurrency type
......@@ -326,8 +327,8 @@ class BlockchainMonitor:
except Exception as e:
logger.error(f"Could not convert to fiat for crediting: {e}")
amount_fiat = 0
# Update wallet balance
# Update crypto wallet balance
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
......@@ -346,5 +347,29 @@ class BlockchainMonitor:
""", (datetime.utcnow(), tx_id))
conn.commit()
# Also credit fiat wallet
if amount_fiat > 0:
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
try:
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=Decimal(str(amount_fiat)),
transaction_details={
'payment_gateway': f'crypto_{crypto_type}',
'gateway_transaction_id': f'crypto_tx_{tx_id}',
'description': f'Wallet top up via {crypto_type.upper()} payment',
'metadata': {'crypto_amount': amount, 'crypto_type': crypto_type, 'tx_id': tx_id}
}
)
await session.commit()
logger.info(f"Fiat wallet credited {amount_fiat:.2f} USD for user {user_id} from crypto payment")
except Exception as e:
logger.error(f"Error crediting fiat wallet from crypto payment: {e}")
logger.info(f"Credited {amount} {crypto_type} (${amount_fiat:.2f}) to user {user_id}")
......@@ -5,7 +5,8 @@ import logging
import base64
import time
import httpx
from typing import Optional
from decimal import Decimal
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
......@@ -78,7 +79,7 @@ class PayPalPaymentHandler:
'return_url': return_url,
'cancel_url': cancel_url,
'shipping_preference': 'NO_SHIPPING',
'user_action': 'PAY_NOW'
'user_action': 'CONTINUE'
}
}
}
......@@ -93,6 +94,18 @@ class PayPalPaymentHandler:
'approval_url': data['links'][1]['href']
}
else:
error_data = response.json() if response.headers.get('Content-Type', '').startswith('application/json') else {}
error_details = error_data.get('details', [])
# Check for specific known errors
for detail in error_details:
issue = detail.get('issue')
if issue == 'NOT_ENABLED_TO_VAULT_PAYMENT_SOURCE':
logger.error(f"PayPal account does not have Vault v3 Payment Tokens enabled: {response.text}")
# Fall back to legacy billing agreement flow
logger.info("Falling back to legacy v1 billing agreement flow")
return await self.create_billing_agreement(None, return_url, cancel_url)
logger.error(f"Failed to create setup token: {response.text}")
return {'success': False, 'error': response.text}
......@@ -410,10 +423,90 @@ class PayPalPaymentHandler:
dispute_id = resource.get('dispute_id')
logger.info(f"PayPal dispute resolved: {dispute_id}")
async def create_topup_order(self, user_id: int, amount: Decimal) -> dict:
"""Create PayPal order for wallet top up"""
try:
access_token = await self.get_access_token()
response = await self.http_client.post(
f"{self.base_url}/v2/checkout/orders",
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'intent': 'CAPTURE',
'purchase_units': [{
'amount': {
'currency_code': self.config.get('currency_code', 'USD'),
'value': f"{amount:.2f}"
},
'description': f'Wallet top up: ${amount:.2f}'
}],
'application_context': {
'return_url': f"{self.config['base_url']}/wallet/topup/complete",
'cancel_url': f"{self.config['base_url']}/wallet/topup/cancel",
'shipping_preference': 'NO_SHIPPING'
}
}
)
if response.status_code == 201:
data = response.json()
approval_url = next(link['href'] for link in data['links'] if link['rel'] == 'approve')
logger.info(f"Created PayPal top up order for user {user_id}: {data['id']}")
return {
'success': True,
'order_id': data['id'],
'approval_url': approval_url,
'amount': amount,
'payment_method': 'paypal'
}
else:
logger.error(f"Failed to create PayPal top up order: {response.text}")
return {'success': False, 'error': response.text}
except Exception as e:
logger.error(f"Error creating PayPal top up order: {e}")
return {'success': False, 'error': str(e)}
async def _handle_order_completed(self, resource: dict):
"""Handle completed order (Vault v3)"""
order_id = resource.get('id')
logger.info(f"PayPal order completed: {order_id}")
# Check if this is a top up order
purchase_units = resource.get('purchase_units', [])
if purchase_units and 'Wallet top up' in purchase_units[0].get('description', ''):
amount = Decimal(purchase_units[0]['amount']['value'])
user_id = int(resource.get('custom_id', 0))
if user_id > 0:
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'paypal',
'gateway_transaction_id': order_id,
'description': 'Wallet top up via PayPal',
'metadata': {'order_id': order_id}
}
)
await session.commit()
logger.info(f"Wallet credited successfully for user {user_id}, amount {amount}")
async def _handle_payment_completed(self, resource: dict):
"""Handle completed payment (legacy)"""
logger.info(f"PayPal payment completed: {resource.get('id')}")
async def _handle_payment_denied(self, resource: dict):
"""Handle denied payment (legacy)"""
logger.warning(f"PayPal payment denied: {resource.get('id')}")
......@@ -4,7 +4,8 @@ Stripe payment integration
import logging
import stripe
import asyncio
from typing import Optional
from decimal import Decimal
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
......@@ -161,6 +162,116 @@ class StripePaymentHandler:
"""Handle successful payment"""
logger.info(f"Payment succeeded: {payment_intent['id']}")
async def create_topup_intent(self, user_id: int, amount: Decimal, payment_method_id: str = None) -> dict:
"""Create Stripe PaymentIntent for wallet top up"""
try:
customer_id = await self._get_or_create_customer(user_id)
amount_cents = int(amount * 100)
intent_params = {
'amount': amount_cents,
'currency': self.config.get('currency_code', 'usd').lower(),
'customer': customer_id,
'description': f'Wallet top up: ${amount:.2f}',
'metadata': {
'user_id': str(user_id),
'topup': 'true',
'amount': str(amount)
}
}
if payment_method_id:
intent_params['payment_method'] = payment_method_id
intent_params['confirm'] = True
payment_intent = await asyncio.to_thread(
stripe.PaymentIntent.create,
**intent_params
)
logger.info(f"Created Stripe top up intent for user {user_id}: {payment_intent.id}")
return {
'success': True,
'payment_intent_id': payment_intent.id,
'client_secret': payment_intent.client_secret,
'amount': amount,
'payment_method': 'stripe'
}
except Exception as e:
logger.error(f"Error creating Stripe top up intent: {e}")
return {'success': False, 'error': str(e)}
async def _handle_payment_succeeded(self, payment_intent: dict):
"""Handle successful payment"""
logger.info(f"Payment succeeded: {payment_intent['id']}")
metadata = payment_intent.get('metadata', {})
if metadata.get('topup') == 'true':
user_id = int(metadata['user_id'])
amount = Decimal(metadata['amount'])
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
# Create database session and wallet manager
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'stripe',
'gateway_transaction_id': payment_intent['id'],
'description': 'Wallet top up via Stripe',
'metadata': {'payment_intent': payment_intent['id']}
}
)
await session.commit()
logger.info(f"Wallet credited successfully for user {user_id}, amount {amount}")
async def auto_charge(self, user_id: int, amount: Decimal, payment_method_id: str) -> Dict[str, Any]:
"""
Automatically charge a saved payment method for auto top up
"""
try:
customer_id = await self._get_or_create_customer(user_id)
amount_cents = int(amount * 100)
payment_intent = await asyncio.to_thread(
stripe.PaymentIntent.create,
amount=amount_cents,
currency=self.config.get('currency_code', 'usd').lower(),
customer=customer_id,
payment_method=payment_method_id,
confirm=True,
off_session=True,
description=f'Auto wallet top up: ${amount:.2f}',
metadata={
'user_id': str(user_id),
'topup': 'true',
'auto_topup': 'true',
'amount': str(amount)
}
)
logger.info(f"Auto charge successful for user {user_id}: {payment_intent.id}")
return {
"success": True,
"gateway_transaction_id": payment_intent.id,
"amount": amount
}
except stripe.error.CardError as e:
logger.error(f"Auto charge card error for user {user_id}: {e.user_message}")
return {"success": False, "error": e.user_message}
except Exception as e:
logger.error(f"Auto charge failed for user {user_id}: {e}")
return {"success": False, "error": str(e)}
async def _handle_payment_failed(self, payment_intent: dict):
"""Handle failed payment"""
logger.warning(f"Payment failed: {payment_intent['id']}")
......@@ -2,7 +2,8 @@
Main payment service orchestrator
"""
import logging
from typing import Optional
from decimal import Decimal
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
......@@ -345,3 +346,51 @@ class PaymentService:
async def process_retries(self):
"""Process payment retries (called by scheduler)"""
await self.renewal_processor.process_retry_queue()
def get_supported_topup_amounts(self):
"""Get configurable supported top up amounts"""
return [
Decimal('10.00'),
Decimal('15.00'),
Decimal('20.00'),
Decimal('50.00'),
Decimal('100.00')
]
@property
def allow_custom_topup_amount(self):
return True
@property
def minimum_topup_amount(self):
return Decimal('5.00')
@property
def maximum_topup_amount(self):
return Decimal('500.00')
async def initiate_topup(self, user_id: int, amount: Decimal, payment_method: str, payment_method_id: int = None) -> dict:
"""Initiate wallet top up via specified payment method"""
if amount < self.minimum_topup_amount:
raise ValueError(f"Amount below minimum: {self.minimum_topup_amount}")
if amount > self.maximum_topup_amount:
raise ValueError(f"Amount above maximum: {self.maximum_topup_amount}")
if payment_method == 'stripe':
return await self.stripe_handler.create_topup_intent(user_id, amount, payment_method_id)
elif payment_method == 'paypal':
return await self.paypal_handler.create_topup_order(user_id, amount)
elif payment_method == 'crypto':
return await self._get_crypto_topup_address(user_id)
else:
raise ValueError(f"Unsupported payment method: {payment_method}")
async def _get_crypto_topup_address(self, user_id: int) -> dict:
"""Get crypto address for manual top up"""
addresses = await self.get_user_crypto_addresses(user_id)
return {
'success': True,
'payment_method': 'crypto',
'addresses': addresses,
'instructions': 'Send crypto to the address below, funds will be credited after confirmations'
}
"""
Wallet management system
"""
from .manager import WalletManager
__all__ = ["WalletManager"]
This diff is collapsed.
"""
Wallet API endpoints
"""
import logging
from decimal import Decimal
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel, Field
from aisbf.payments.wallet.manager import WalletManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/wallet", tags=["wallet"])
class WalletBalanceResponse(BaseModel):
balance: Decimal
currency_code: str
auto_topup_enabled: bool
auto_topup_amount: Optional[Decimal]
auto_topup_threshold: Optional[Decimal]
class TransactionResponse(BaseModel):
id: int
amount: Decimal
type: str
status: str
description: Optional[str]
created_at: str
class TopUpRequest(BaseModel):
amount: Decimal = Field(gt=0, decimal_places=2)
payment_method: str
class AutoTopUpSettings(BaseModel):
enabled: bool
amount: Optional[Decimal] = Field(gt=0, decimal_places=2)
threshold: Optional[Decimal] = Field(gt=0, decimal_places=2)
payment_method_id: Optional[int]
async def get_wallet_manager(request: Request):
return WalletManager(request.app.state.db)
@router.get("/balance", response_model=WalletBalanceResponse)
async def get_wallet_balance(
request: Request,
wallet_manager: WalletManager = Depends(get_wallet_manager)
):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
wallet = await wallet_manager.get_wallet(user_id)
return WalletBalanceResponse(
balance=wallet.balance,
currency_code=wallet.currency_code,
auto_topup_enabled=wallet.auto_topup_enabled,
auto_topup_amount=wallet.auto_topup_amount,
auto_topup_threshold=wallet.auto_topup_threshold
)
@router.get("/transactions", response_model=List[TransactionResponse])
async def get_transaction_history(
request: Request,
limit: int = 50,
offset: int = 0,
wallet_manager: WalletManager = Depends(get_wallet_manager)
):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
transactions = await wallet_manager.get_transactions(user_id, limit, offset)
return [
TransactionResponse(
id=t.id,
amount=t.amount,
type=t.type,
status=t.status,
description=t.description,
created_at=t.created_at.isoformat()
) for t in transactions
]
@router.post("/topup", status_code=status.HTTP_201_CREATED)
async def initiate_topup(
request: Request,
topup_data: TopUpRequest,
wallet_manager: WalletManager = Depends(get_wallet_manager)
):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
# Validate supported amounts
supported_amounts = [Decimal('10.00'), Decimal('15.00'), Decimal('20.00'), Decimal('50.00'), Decimal('100.00')]
if topup_data.amount not in supported_amounts and (topup_data.amount < Decimal('5.00') or topup_data.amount > Decimal('500.00')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid top up amount. Supported: 10,15,20,50,100 or custom 5-500"
)
payment_service = request.app.state.payment_service
if topup_data.payment_method == "stripe":
intent = await payment_service.stripe_handler.create_payment_intent(
user_id,
topup_data.amount,
metadata={"type": "wallet_topup"}
)
return {"client_secret": intent.client_secret, "payment_method": "stripe"}
elif topup_data.payment_method == "paypal":
order = await payment_service.paypal_handler.create_order(
user_id,
topup_data.amount,
metadata={"type": "wallet_topup"}
)
return {"order_id": order.id, "payment_method": "paypal"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported payment method"
)
@router.put("/auto-topup")
async def configure_auto_topup(
request: Request,
settings: AutoTopUpSettings,
wallet_manager: WalletManager = Depends(get_wallet_manager)
):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
if settings.enabled:
if not settings.amount or not settings.threshold or not settings.payment_method_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Amount, threshold and payment method are required when enabling auto top up"
)
await wallet_manager.configure_auto_topup(
user_id,
enabled=settings.enabled,
amount=settings.amount,
threshold=settings.threshold,
payment_method_id=settings.payment_method_id
)
return {"status": "ok"}
......@@ -1952,6 +1952,10 @@ async def delete_payment_method(payment_method_id: int, request: Request):
return result
# Wallet API routes
from aisbf.payments.wallet.routes import router as wallet_router
app.include_router(wallet_router)
@app.post("/api/webhooks/stripe")
async def stripe_webhook(request: Request):
......@@ -7970,6 +7974,25 @@ async def dashboard_subscription(request: Request):
}
)
@app.get("/dashboard/wallet", response_class=HTMLResponse)
async def dashboard_wallet(request: Request):
"""User wallet dashboard page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
return templates.TemplateResponse("dashboard/wallet.html", {
"request": request,
"wallet": wallet
})
@app.get("/dashboard/billing")
async def dashboard_billing(request: Request):
"""User payment transaction history page"""
......
{% extends "dashboard/base.html" %}
{% block title %}Wallet{% endblock %}
{% block content %}
<div class="container mt-4">
<h1 class="mb-4">Wallet</h1>
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Balance</h5>
</div>
<div class="card-body">
<h2 class="display-4">${{ wallet.balance }}</h2>
<p class="text-muted">Currency: {{ wallet.currency_code }}</p>
<hr>
<div class="mb-3">
<label class="form-label">Top Up Amount</label>
<div class="btn-group w-100 mb-2" role="group">
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="10">$10</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="15">$15</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="20">$20</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="50">$50</button>
<button type="button" class="btn btn-outline-primary amount-btn" data-amount="100">$100</button>
</div>
<input type="number" id="custom-amount" class="form-control" placeholder="Custom amount (5-500)" step="0.01" min="5" max="500">
</div>
<div class="d-grid gap-2">
<button id="topup-stripe" class="btn btn-primary">Top Up with Stripe</button>
<button id="topup-paypal" class="btn btn-outline-primary">Top Up with PayPal</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5>Auto Top Up Settings</h5>
</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="auto-topup-enabled" {% if wallet.auto_topup_enabled %}checked{% endif %}>
<label class="form-check-label" for="auto-topup-enabled">Enable Auto Top Up</label>
</div>
<div id="auto-topup-settings" {% if not wallet.auto_topup_enabled %}style="display:none;"{% endif %}>
<div class="mb-3">
<label class="form-label">Auto Top Up Amount</label>
<input type="number" id="auto-topup-amount" class="form-control" value="{{ wallet.auto_topup_amount or '' }}" step="0.01" min="10">
</div>
<div class="mb-3">
<label class="form-label">Top Up When Balance Below</label>
<input type="number" id="auto-topup-threshold" class="form-control" value="{{ wallet.auto_topup_threshold or '' }}" step="0.01" min="1">
</div>
<div class="mb-3">
<label class="form-label">Payment Method</label>
<select id="auto-topup-payment-method" class="form-select">
<!-- Options populated via JS -->
</select>
</div>
<button id="save-auto-topup" class="btn btn-primary">Save Settings</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Transaction History</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody id="transaction-list">
<!-- Populated via JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let selectedAmount = null;
document.querySelectorAll('.amount-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
selectedAmount = this.dataset.amount;
document.getElementById('custom-amount').value = '';
});
});
document.getElementById('custom-amount').addEventListener('input', function() {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
selectedAmount = this.value;
});
document.getElementById('auto-topup-enabled').addEventListener('change', function() {
document.getElementById('auto-topup-settings').style.display = this.checked ? 'block' : 'none';
});
// Load transactions
fetch('/api/wallet/transactions')
.then(res => res.json())
.then(transactions => {
const list = document.getElementById('transaction-list');
transactions.forEach(tx => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${new Date(tx.created_at).toLocaleDateString()}</td>
<td>${tx.type}</td>
<td class="${tx.amount >= 0 ? 'text-success' : 'text-danger'}">${tx.amount >= 0 ? '+' : ''}$${Math.abs(tx.amount)}</td>
<td><span class="badge bg-secondary">${tx.status}</span></td>
`;
list.appendChild(row);
});
});
});
</script>
{% endblock %}
"""
Test suite for auto top up system implementation
"""
import pytest
from decimal import Decimal
from unittest.mock import Mock, patch, AsyncMock
from sqlalchemy.ext.asyncio import AsyncSession
from aisbf.payments.wallet.manager import WalletManager
from aisbf.payments.fiat.stripe_handler import StripePaymentHandler
from aisbf.payments.scheduler import PaymentScheduler
@pytest.mark.asyncio
async def test_auto_topup_trigger_condition():
"""Test auto top up trigger condition checks correctly"""
mock_session = AsyncMock(spec=AsyncSession)
wallet_manager = WalletManager(mock_session)
# Test 1: Enabled, balance below threshold, configured amount and payment method
wallet = {
"auto_topup_enabled": True,
"balance": Decimal("5.00"),
"auto_topup_threshold": Decimal("10.00"),
"auto_topup_amount": Decimal("20.00"),
"auto_topup_payment_method_id": 123
}
assert wallet_manager.should_trigger_auto_topup(wallet) is True
# Test 2: Disabled
wallet["auto_topup_enabled"] = False
assert wallet_manager.should_trigger_auto_topup(wallet) is False
# Test 3: Balance above threshold
wallet["auto_topup_enabled"] = True
wallet["balance"] = Decimal("15.00")
assert wallet_manager.should_trigger_auto_topup(wallet) is False
# Test 4: No amount configured
wallet["balance"] = Decimal("5.00")
wallet["auto_topup_amount"] = None
assert wallet_manager.should_trigger_auto_topup(wallet) is False
# Test 5: No payment method configured
wallet["auto_topup_amount"] = Decimal("20.00")
wallet["auto_topup_payment_method_id"] = None
assert wallet_manager.should_trigger_auto_topup(wallet) is False
@pytest.mark.asyncio
async def test_stripe_auto_charge():
"""Test Stripe auto charging for auto top up"""
mock_db = Mock()
stripe_handler = StripePaymentHandler(mock_db, {"currency_code": "USD"})
with patch('aisbf.payments.fiat.stripe_handler.stripe.PaymentIntent.create') as mock_create:
mock_create.return_value = {
"id": "pi_123",
"status": "succeeded",
"amount": 2000
}
result = await stripe_handler.auto_charge(
user_id=1,
amount=Decimal("20.00"),
payment_method_id="pm_123"
)
assert result["success"] is True
assert result["gateway_transaction_id"] == "pi_123"
mock_create.assert_called_once()
assert mock_create.call_args[1]["amount"] == 2000
assert mock_create.call_args[1]["confirm"] is True
assert mock_create.call_args[1]["payment_method"] == "pm_123"
@pytest.mark.asyncio
async def test_auto_topup_retry_logic():
"""Test failure handling and retries for auto top up"""
mock_session = AsyncMock(spec=AsyncSession)
wallet_manager = WalletManager(mock_session)
# Test retry counter increment
wallet = {"id": 1, "user_id": 1}
await wallet_manager.record_auto_topup_attempt(wallet["id"], success=False)
# Test that after 3 failures auto top up is disabled
for i in range(3):
await wallet_manager.record_auto_topup_attempt(wallet["id"], success=False)
updated_wallet = await wallet_manager.get_wallet(1)
assert updated_wallet["auto_topup_enabled"] is False
@pytest.mark.asyncio
async def test_scheduler_auto_topup_job():
"""Test scheduled auto top up check job"""
mock_db = Mock()
mock_payment_service = Mock()
scheduler = PaymentScheduler(mock_db, mock_payment_service)
with patch.object(scheduler, '_acquire_lock', return_value=True):
with patch.object(scheduler, '_release_lock'):
await scheduler._run_auto_topup_check()
# Verify job executed without exceptions
assert True
"""
Tests for wallet top up system with Stripe, PayPal and crypto payments
"""
import pytest
from decimal import Decimal
from unittest.mock import Mock, patch, AsyncMock
from aisbf.payments.service import PaymentService
from aisbf.payments.wallet.manager import WalletManager
@pytest.mark.asyncio
async def test_topup_amount_configuration():
"""Test that supported top up amounts are properly configured"""
db = Mock()
config = {'encryption_key': 'test_key', 'base_url': 'http://localhost'}
service = PaymentService(db, config)
amounts = service.get_supported_topup_amounts()
assert Decimal('10.00') in amounts
assert Decimal('15.00') in amounts
assert Decimal('20.00') in amounts
assert Decimal('50.00') in amounts
assert Decimal('100.00') in amounts
assert service.allow_custom_topup_amount is True
assert service.minimum_topup_amount == Decimal('5.00')
assert service.maximum_topup_amount == Decimal('500.00')
@pytest.mark.asyncio
async def test_initiate_stripe_topup():
"""Test initiating a Stripe top up payment"""
db = Mock()
config = {'encryption_key': 'test_key', 'base_url': 'http://localhost'}
service = PaymentService(db, config)
service.stripe_handler.create_payment_intent = AsyncMock(return_value={
'success': True,
'client_secret': 'test_secret_123',
'payment_intent_id': 'pi_123'
})
result = await service.initiate_topup(
user_id=123,
amount=Decimal('20.00'),
payment_method='stripe',
payment_method_id='pm_123'
)
assert result['success'] is True
assert 'client_secret' in result
assert result['amount'] == Decimal('20.00')
assert result['payment_method'] == 'stripe'
service.stripe_handler.create_payment_intent.assert_called_once()
@pytest.mark.asyncio
async def test_initiate_paypal_topup():
"""Test initiating a PayPal top up payment"""
db = Mock()
config = {'encryption_key': 'test_key', 'base_url': 'http://localhost'}
service = PaymentService(db, config)
service.paypal_handler.create_order = AsyncMock(return_value={
'success': True,
'order_id': 'test_order_123',
'approval_url': 'https://paypal.com/approve'
})
result = await service.initiate_topup(
user_id=123,
amount=Decimal('50.00'),
payment_method='paypal'
)
assert result['success'] is True
assert 'order_id' in result
assert 'approval_url' in result
service.paypal_handler.create_order.assert_called_once()
@pytest.mark.asyncio
async def test_stripe_webhook_credits_wallet():
"""Test that successful Stripe payment webhook credits user wallet"""
db = Mock()
config = {'encryption_key': 'test_key', 'base_url': 'http://localhost'}
with patch('aisbf.payments.wallet.manager.WalletManager.credit_wallet', new_callable=AsyncMock) as mock_credit:
service = PaymentService(db, config)
mock_credit.return_value = {'success': True, 'new_balance': Decimal('20.00')}
event = {
'type': 'payment_intent.succeeded',
'data': {
'object': {
'id': 'pi_12345',
'metadata': {'user_id': '123', 'topup': 'true'},
'amount': 2000,
'currency': 'usd'
}
}
}
result = await service.stripe_handler.handle_webhook(b'', 'test_sig')
assert result['status'] == 'success'
mock_credit.assert_called_once_with(
user_id=123,
amount=Decimal('20.00'),
transaction_details={
'payment_gateway': 'stripe',
'gateway_transaction_id': 'pi_12345',
'description': 'Wallet top up via Stripe'
}
)
@pytest.mark.asyncio
async def test_crypto_payment_credits_wallet():
"""Test that confirmed crypto payment credits user wallet"""
db = Mock()
config = {'encryption_key': 'test_key', 'base_url': 'http://localhost'}
with patch('aisbf.payments.wallet.manager.WalletManager.credit_wallet', new_callable=AsyncMock) as mock_credit:
service = PaymentService(db, config)
service.price_service.convert_crypto_to_fiat = AsyncMock(return_value=Decimal('25.00'))
await service.blockchain_monitor.credit_user_wallet(
user_id=123,
crypto_type='btc',
amount=0.0005,
tx_id=456
)
mock_credit.assert_called_once()
args = mock_credit.call_args
assert args[1]['user_id'] == 123
assert args[1]['amount'] == Decimal('25.00')
assert args[1]['transaction_details']['payment_gateway'] == 'crypto_btc'
@pytest.mark.asyncio
async def test_invalid_topup_amount():
"""Test that invalid top up amounts are rejected"""
db = Mock()
config = {'encryption_key': 'test_key', 'base_url': 'http://localhost'}
service = PaymentService(db, config)
with pytest.raises(ValueError, match="Amount below minimum"):
await service.initiate_topup(user_id=123, amount=Decimal('3.00'), payment_method='stripe')
with pytest.raises(ValueError, match="Amount above maximum"):
await service.initiate_topup(user_id=123, amount=Decimal('1000.00'), payment_method='stripe')
......@@ -87,3 +87,139 @@ async def test_get_or_create_user_address(db_manager, encryption_key):
# Different user should get different address
address3 = await wallet_manager.get_or_create_user_address(2, 'btc')
assert address3 != address1
@pytest.mark.anyio
async def test_wallet_manager_get_wallet_creates_new_wallet():
"""Test that WalletManager get_wallet creates a new wallet when none exists"""
from unittest.mock import AsyncMock, MagicMock
from decimal import Decimal
from aisbf.payments.wallet.manager import WalletManager
mock_session = AsyncMock()
mock_session.execute = AsyncMock()
# First call returns no wallet
mock_session.execute.side_effect = [
MagicMock(mappings=lambda: [None]), # first select
MagicMock(mappings=lambda: [{
"id": 1,
"user_id": 123,
"balance": Decimal("0.00"),
"currency_code": "USD",
"auto_topup_enabled": False,
"auto_topup_amount": None,
"auto_topup_threshold": None,
"auto_topup_payment_method_id": None,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00"
}])
]
manager = WalletManager(mock_session)
wallet = await manager.get_wallet(123)
assert wallet is not None
assert wallet["user_id"] == 123
assert wallet["balance"] == Decimal("0.00")
assert mock_session.commit.called
@pytest.mark.asyncio
async def test_wallet_manager_has_sufficient_balance():
"""Test WalletManager sufficient balance check"""
from unittest.mock import AsyncMock
from decimal import Decimal
from aisbf.payments.wallet.manager import WalletManager
mock_session = AsyncMock()
manager = WalletManager(mock_session)
manager.get_wallet = AsyncMock(return_value={
"balance": Decimal("50.00")
})
assert await manager.has_sufficient_balance(123, Decimal("25.00")) is True
assert await manager.has_sufficient_balance(123, Decimal("50.00")) is True
assert await manager.has_sufficient_balance(123, Decimal("75.00")) is False
@pytest.mark.asyncio
async def test_wallet_manager_credit_wallet():
"""Test WalletManager credit operation"""
from unittest.mock import AsyncMock, MagicMock
from decimal import Decimal
from aisbf.payments.wallet.manager import WalletManager
mock_session = AsyncMock()
manager = WalletManager(mock_session)
manager.get_wallet = AsyncMock(return_value={
"id": 1,
"user_id": 123,
"balance": Decimal("10.00")
})
mock_transaction = {"id": 1001, "created_at": "2026-04-21T22:00:00"}
mock_session.execute = AsyncMock(return_value=MagicMock(
mappings=lambda: [mock_transaction]
))
result = await manager.credit_wallet(123, Decimal("25.00"), {
"description": "Test credit",
"payment_gateway": "stripe",
"gateway_transaction_id": "tx_123"
})
assert result["new_balance"] == Decimal("35.00")
assert result["transaction_id"] == 1001
@pytest.mark.asyncio
async def test_wallet_manager_debit_wallet_sufficient_funds():
"""Test WalletManager debit with sufficient balance"""
from unittest.mock import AsyncMock, MagicMock
from decimal import Decimal
from aisbf.payments.wallet.manager import WalletManager
mock_session = AsyncMock()
manager = WalletManager(mock_session)
manager.get_wallet = AsyncMock(return_value={
"id": 1,
"user_id": 123,
"balance": Decimal("50.00")
})
mock_transaction = {"id": 1002, "created_at": "2026-04-21T22:00:00"}
mock_session.execute = AsyncMock(return_value=MagicMock(
mappings=lambda: [mock_transaction]
))
result = await manager.debit_wallet(123, Decimal("20.00"), {
"description": "Test debit",
"payment_method_id": 5
})
assert result["new_balance"] == Decimal("30.00")
@pytest.mark.asyncio
async def test_wallet_manager_debit_wallet_insufficient_funds():
"""Test WalletManager debit fails when balance is insufficient"""
from unittest.mock import AsyncMock
from decimal import Decimal
from aisbf.payments.wallet.manager import WalletManager
import pytest
mock_session = AsyncMock()
manager = WalletManager(mock_session)
manager.get_wallet = AsyncMock(return_value={
"id": 1,
"user_id": 123,
"balance": Decimal("10.00")
})
with pytest.raises(ValueError, match="Insufficient balance"):
await manager.debit_wallet(123, Decimal("20.00"), {})
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