Fix hardcoded SQLite '?' placeholders across all payment modules for MySQL compatibility

- Add DatabaseManager.placeholder property returning '?' (SQLite) or '%s' (MySQL)
- Fix paypal_handler.py: payment_methods INSERT
- Fix service.py: payment_methods INSERT for PayPal vault
- Fix subscription/manager.py: all SQL in create/upgrade/downgrade/cancel_subscription
- Fix subscription/renewal.py: all SQL in process_renewals/_renew_subscription/_cancel_subscription
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 03fcab6a
......@@ -166,6 +166,10 @@ class DatabaseManager:
else:
raise ValueError(f"Unsupported database type: {self.db_type}")
@property
def placeholder(self) -> str:
return '?' if self.db_type == 'sqlite' else '%s'
async def _run_in_executor(self, func, *args):
"""Run a blocking database operation in a thread pool executor."""
loop = asyncio.get_event_loop()
......
......@@ -280,10 +280,11 @@ class PayPalPaymentHandler:
# Store payment method
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
ph = self.db.placeholder
cursor.execute(f"""
INSERT INTO payment_methods
(user_id, type, identifier, is_default, is_active)
VALUES (?, 'paypal', ?, 1, 1)
VALUES ({ph}, 'paypal', {ph}, 1, 1)
""", (user_id, agreement_id))
conn.commit()
......
......@@ -182,10 +182,11 @@ class PaymentService:
# Store payment method
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
ph = self.db.placeholder
cursor.execute(f"""
INSERT INTO payment_methods
(user_id, type, gateway, identifier, paypal_email, is_default, status)
VALUES (?, 'paypal', 'paypal_v3', ?, ?, 1, 'active')
VALUES ({ph}, 'paypal', 'paypal_v3', {ph}, {ph}, 1, 'active')
""", (user_id, result['payment_token_id'], result['payer_email']))
conn.commit()
......
......@@ -10,32 +10,33 @@ logger = logging.getLogger(__name__)
class SubscriptionManager:
"""Manage subscription lifecycle and billing"""
def __init__(self, db_manager, stripe_handler, paypal_handler,
def __init__(self, db_manager, stripe_handler, paypal_handler,
crypto_wallet_manager, price_service):
self.db = db_manager
self.stripe = stripe_handler
self.paypal = paypal_handler
self.crypto = crypto_wallet_manager
self.price_service = price_service
async def create_subscription(self, user_id: int, tier_id: int,
payment_method_id: int,
async def create_subscription(self, user_id: int, tier_id: int,
payment_method_id: int,
billing_cycle: str) -> dict:
"""Create new subscription"""
try:
ph = self.db.placeholder
# Get tier details
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM account_tiers WHERE id = ?",
f"SELECT * FROM account_tiers WHERE id = {ph}",
(tier_id,)
)
tier_row = cursor.fetchone()
if not tier_row:
return {'success': False, 'error': 'Invalid tier'}
# Convert row to dict
tier = {
'id': tier_row[0],
......@@ -43,19 +44,19 @@ class SubscriptionManager:
'price_monthly': tier_row[2],
'price_yearly': tier_row[3]
}
# Get payment method
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM payment_methods WHERE id = ? AND user_id = ?",
f"SELECT * FROM payment_methods WHERE id = {ph} AND user_id = {ph}",
(payment_method_id, user_id)
)
pm_row = cursor.fetchone()
if not pm_row:
return {'success': False, 'error': 'Invalid payment method'}
# Convert row to dict
payment_method = {
'id': pm_row[0],
......@@ -64,7 +65,7 @@ class SubscriptionManager:
'gateway': pm_row[3],
'crypto_type': pm_row[4] if len(pm_row) > 4 else None
}
# Calculate amount
if billing_cycle == 'monthly':
amount = tier['price_monthly']
......@@ -72,26 +73,26 @@ class SubscriptionManager:
amount = tier['price_yearly']
else:
return {'success': False, 'error': 'Invalid billing cycle'}
# Check if user already has active subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id FROM subscriptions
WHERE user_id = ? AND status = 'active'
cursor.execute(f"""
SELECT id FROM subscriptions
WHERE user_id = {ph} AND status = 'active'
""", (user_id,))
existing = cursor.fetchone()
if existing:
return {'success': False, 'error': 'User already has active subscription'}
# Calculate period dates (immediate start)
current_period_start = datetime.now(datetime.UTC if hasattr(datetime, 'UTC') else None).replace(tzinfo=None)
if billing_cycle == 'monthly':
current_period_end = current_period_start + timedelta(days=30)
else: # yearly
current_period_end = current_period_start + timedelta(days=365)
# Charge initial payment
charge_result = await self._charge_payment(
user_id=user_id,
......@@ -99,61 +100,57 @@ class SubscriptionManager:
amount=amount,
description=f"Initial subscription - {tier['name']} ({billing_cycle})"
)
if not charge_result['success']:
return charge_result
# Create subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
INSERT INTO subscriptions
(user_id, tier_id, payment_method_id, status, billing_cycle,
current_period_start, current_period_end)
VALUES (?, ?, ?, 'active', ?, ?, ?)
VALUES ({ph}, {ph}, {ph}, 'active', {ph}, {ph}, {ph})
""", (
user_id, tier_id, payment_method_id, billing_cycle,
current_period_start, current_period_end
))
conn.commit()
subscription_id = cursor.lastrowid
# Update user tier
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET tier_id = ? WHERE id = ?",
f"UPDATE users SET tier_id = {ph} WHERE id = {ph}",
(tier_id, user_id)
)
conn.commit()
logger.info(f"Created subscription {subscription_id} for user {user_id}")
return {
'success': True,
'subscription_id': subscription_id,
'next_billing_date': current_period_end.isoformat()
}
except Exception as e:
logger.error(f"Error creating subscription: {e}")
return {'success': False, 'error': str(e)}
async def _charge_payment(self, user_id: int, payment_method: dict,
async def _charge_payment(self, user_id: int, payment_method: dict,
amount: float, description: str) -> dict:
"""Charge payment using appropriate gateway"""
payment_type = payment_method['type']
if payment_type == 'card':
# Stripe card payment
if self.stripe:
# Would call stripe handler
return {'success': True, 'transaction_id': 'mock_tx'}
return {'success': True, 'transaction_id': 'mock_tx'}
elif payment_type == 'paypal':
# PayPal payment
if self.paypal:
# Would call paypal handler
return {'success': True, 'transaction_id': 'mock_tx'}
return {'success': True, 'transaction_id': 'mock_tx'}
elif payment_type == 'crypto':
......@@ -164,67 +161,69 @@ class SubscriptionManager:
)
else:
return {'success': False, 'error': f'Unknown payment method type: {payment_type}'}
async def _charge_crypto_wallet(self, user_id: int, crypto_type: str,
async def _charge_crypto_wallet(self, user_id: int, crypto_type: str,
amount: float) -> dict:
"""Charge from user's crypto wallet"""
try:
ph = self.db.placeholder
# Get wallet balance
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT * FROM user_crypto_wallets
WHERE user_id = ? AND crypto_type = ?
WHERE user_id = {ph} AND crypto_type = {ph}
""", (user_id, crypto_type))
wallet_row = cursor.fetchone()
if not wallet_row:
return {'success': False, 'error': 'Wallet not found'}
wallet = {
'balance_fiat': wallet_row[3] # balance_fiat column
}
if wallet['balance_fiat'] < amount:
return {'success': False, 'error': 'Insufficient balance'}
# Deduct from wallet
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE user_crypto_wallets
SET balance_fiat = balance_fiat - ?
WHERE user_id = ? AND crypto_type = ?
SET balance_fiat = balance_fiat - {ph}
WHERE user_id = {ph} AND crypto_type = {ph}
""", (amount, user_id, crypto_type))
conn.commit()
logger.info(f"Charged ${amount} from user {user_id} {crypto_type} wallet")
return {'success': True}
except Exception as e:
logger.error(f"Error charging crypto wallet: {e}")
return {'success': False, 'error': str(e)}
async def upgrade_subscription(self, user_id: int, new_tier_id: int) -> dict:
"""Upgrade subscription to higher tier with prorated credit"""
try:
ph = self.db.placeholder
# Get current subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT s.id, s.user_id, s.tier_id, s.payment_method_id, s.status,
cursor.execute(f"""
SELECT s.id, s.user_id, s.tier_id, s.payment_method_id, s.status,
s.billing_cycle, s.current_period_start, s.current_period_end,
t.price_monthly, t.price_yearly, t.name as tier_name
FROM subscriptions s
JOIN account_tiers t ON s.tier_id = t.id
WHERE s.user_id = ? AND s.status = 'active'
WHERE s.user_id = {ph} AND s.status = 'active'
""", (user_id,))
sub_row = cursor.fetchone()
if not sub_row:
return {'success': False, 'error': 'No active subscription'}
# Convert row to dict
subscription = {
'id': sub_row[0],
......@@ -239,84 +238,77 @@ class SubscriptionManager:
'price_yearly': sub_row[9],
'tier_name': sub_row[10]
}
# Get new tier
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, name, price_monthly, price_yearly FROM account_tiers WHERE id = ?",
f"SELECT id, name, price_monthly, price_yearly FROM account_tiers WHERE id = {ph}",
(new_tier_id,)
)
tier_row = cursor.fetchone()
if not tier_row:
return {'success': False, 'error': 'Invalid tier'}
new_tier = {
'id': tier_row[0],
'name': tier_row[1],
'price_monthly': tier_row[2],
'price_yearly': tier_row[3]
}
# Calculate prorated amount
billing_cycle = subscription['billing_cycle']
if billing_cycle == 'monthly':
old_price = subscription['price_monthly']
new_price = new_tier['price_monthly']
else: # yearly
old_price = subscription['price_yearly']
new_price = new_tier['price_yearly']
if old_price is None or new_price is None:
return {'success': False, 'error': f'Invalid prices: old={old_price}, new={new_price}'}
# Calculate unused portion of current period
now = datetime.now(datetime.UTC if hasattr(datetime, 'UTC') else None).replace(tzinfo=None)
# Parse datetime strings if needed
period_start = subscription['current_period_start']
period_end = subscription['current_period_end']
if isinstance(period_start, str):
period_start = datetime.fromisoformat(period_start)
if isinstance(period_end, str):
period_end = datetime.fromisoformat(period_end)
total_period_seconds = (period_end - period_start).total_seconds()
remaining_seconds = (period_end - now).total_seconds()
if remaining_seconds <= 0:
# Period already ended, charge full amount
prorated_amount = new_price
unused_fraction = 0
else:
# Calculate unused portion
unused_fraction = remaining_seconds / total_period_seconds
unused_credit = old_price * unused_fraction
# New charge = new_price - unused_credit
prorated_amount = new_price - unused_credit
# Ensure non-negative
prorated_amount = max(0, prorated_amount)
prorated_amount = max(0, new_price - unused_credit)
logger.info(f"Upgrade proration: old=${old_price}, new=${new_price}, "
f"unused={unused_fraction:.2%}, charge=${prorated_amount:.2f}")
# Get payment method
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM payment_methods WHERE id = ?",
f"SELECT * FROM payment_methods WHERE id = {ph}",
(subscription['payment_method_id'],)
)
pm_row = cursor.fetchone()
if not pm_row:
return {'success': False, 'error': 'Payment method not found'}
payment_method = {
'id': pm_row[0],
'user_id': pm_row[1],
......@@ -324,7 +316,7 @@ class SubscriptionManager:
'gateway': pm_row[3],
'crypto_type': pm_row[4] if len(pm_row) > 4 else None
}
# Charge prorated amount
if prorated_amount > 0:
charge_result = await self._charge_payment(
......@@ -333,60 +325,61 @@ class SubscriptionManager:
amount=prorated_amount,
description=f"Upgrade to {new_tier['name']} (prorated)"
)
if not charge_result['success']:
return charge_result
# Update subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE subscriptions
SET tier_id = ?
WHERE id = ?
SET tier_id = {ph}
WHERE id = {ph}
""", (new_tier_id, subscription['id']))
conn.commit()
# Update user tier
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET tier_id = ? WHERE id = ?",
f"UPDATE users SET tier_id = {ph} WHERE id = {ph}",
(new_tier_id, user_id)
)
conn.commit()
logger.info(f"Upgraded subscription {subscription['id']} to tier {new_tier_id}")
return {
'success': True,
'charged_amount': prorated_amount,
'next_billing_date': period_end.isoformat()
}
except Exception as e:
logger.error(f"Error upgrading subscription: {e}")
import traceback
logger.error(traceback.format_exc())
return {'success': False, 'error': str(e)}
async def downgrade_subscription(self, user_id: int, new_tier_id: int) -> dict:
"""Schedule subscription downgrade at period end (no immediate charge/refund)"""
try:
ph = self.db.placeholder
# Get current subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT s.id, s.user_id, s.tier_id, s.status,
cursor.execute(f"""
SELECT s.id, s.user_id, s.tier_id, s.status,
s.current_period_end
FROM subscriptions s
WHERE s.user_id = ? AND s.status = 'active'
WHERE s.user_id = {ph} AND s.status = 'active'
""", (user_id,))
sub_row = cursor.fetchone()
if not sub_row:
return {'success': False, 'error': 'No active subscription'}
subscription = {
'id': sub_row[0],
'user_id': sub_row[1],
......@@ -394,99 +387,100 @@ class SubscriptionManager:
'status': sub_row[3],
'current_period_end': sub_row[4]
}
# Verify new tier exists
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, name FROM account_tiers WHERE id = ?",
f"SELECT id, name FROM account_tiers WHERE id = {ph}",
(new_tier_id,)
)
tier_row = cursor.fetchone()
if not tier_row:
return {'success': False, 'error': 'Invalid tier'}
new_tier = {
'id': tier_row[0],
'name': tier_row[1]
}
# Set pending_tier_id (downgrade scheduled at period end)
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE subscriptions
SET pending_tier_id = ?
WHERE id = ?
SET pending_tier_id = {ph}
WHERE id = {ph}
""", (new_tier_id, subscription['id']))
conn.commit()
# Parse period_end if needed
period_end = subscription['current_period_end']
if isinstance(period_end, str):
period_end = datetime.fromisoformat(period_end)
logger.info(f"Scheduled downgrade for subscription {subscription['id']} to tier {new_tier_id} at {period_end}")
return {
'success': True,
'downgrade_date': period_end.isoformat(),
'message': f"Downgrade to {new_tier['name']} scheduled for {period_end.date()}"
}
except Exception as e:
logger.error(f"Error scheduling downgrade: {e}")
import traceback
logger.error(traceback.format_exc())
return {'success': False, 'error': str(e)}
async def cancel_subscription(self, user_id: int) -> dict:
"""Cancel subscription at period end (no refund, access until period end)"""
try:
ph = self.db.placeholder
# Get current subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT s.id, s.user_id, s.status, s.current_period_end
FROM subscriptions s
WHERE s.user_id = ? AND s.status = 'active'
WHERE s.user_id = {ph} AND s.status = 'active'
""", (user_id,))
sub_row = cursor.fetchone()
if not sub_row:
return {'success': False, 'error': 'No active subscription'}
subscription = {
'id': sub_row[0],
'user_id': sub_row[1],
'status': sub_row[2],
'current_period_end': sub_row[3]
}
# Set cancel_at_period_end flag
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE subscriptions
SET cancel_at_period_end = 1
WHERE id = ?
WHERE id = {ph}
""", (subscription['id'],))
conn.commit()
# Parse period_end if needed
period_end = subscription['current_period_end']
if isinstance(period_end, str):
period_end = datetime.fromisoformat(period_end)
logger.info(f"Scheduled cancellation for subscription {subscription['id']} at {period_end}")
return {
'success': True,
'cancellation_date': period_end.isoformat(),
'message': f"Subscription will be canceled on {period_end.date()}. You will retain access until then."
}
except Exception as e:
logger.error(f"Error canceling subscription: {e}")
import traceback
......
......@@ -10,53 +10,42 @@ logger = logging.getLogger(__name__)
class RenewalProcessor:
"""Process automatic subscription renewals"""
def __init__(self, db_manager, stripe_handler, paypal_handler,
def __init__(self, db_manager, stripe_handler, paypal_handler,
crypto_wallet_manager, price_service):
self.db = db_manager
self.stripe = stripe_handler
self.paypal = paypal_handler
self.crypto = crypto_wallet_manager
self.price_service = price_service
async def process_renewals(self) -> Dict:
"""
Process all subscriptions due for renewal
Returns:
dict: {
'success': bool,
'processed': int,
'successful': int,
'failed': int,
'errors': List[str]
}
"""
"""Process all subscriptions due for renewal"""
try:
processed = 0
successful = 0
failed = 0
errors = []
# Find subscriptions where current_period_end <= NOW
ph = self.db.placeholder
now = datetime.now()
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT s.id, s.user_id, s.tier_id, s.payment_method_id,
cursor.execute(f"""
SELECT s.id, s.user_id, s.tier_id, s.payment_method_id,
s.billing_cycle, s.current_period_start, s.current_period_end,
s.cancel_at_period_end, s.pending_tier_id,
t.price_monthly, t.price_yearly, t.name as tier_name
FROM subscriptions s
JOIN account_tiers t ON s.tier_id = t.id
WHERE s.status = 'active' AND s.current_period_end <= ?
WHERE s.status = 'active' AND s.current_period_end <= {ph}
""", (now,))
due_subscriptions = cursor.fetchall()
logger.info(f"Found {len(due_subscriptions)} subscriptions due for renewal")
for sub_row in due_subscriptions:
subscription = {
'id': sub_row[0],
......@@ -72,27 +61,25 @@ class RenewalProcessor:
'price_yearly': sub_row[10],
'tier_name': sub_row[11]
}
processed += 1
# Check if subscription should be cancelled
if subscription['cancel_at_period_end']:
await self._cancel_subscription(subscription)
successful += 1
logger.info(f"Cancelled subscription {subscription['id']}")
continue
# Attempt renewal
renewal_result = await self._renew_subscription(subscription)
if renewal_result['success']:
successful += 1
else:
failed += 1
errors.append(f"Subscription {subscription['id']}: {renewal_result.get('error', 'Unknown error')}")
logger.info(f"Renewal processing complete: {processed} processed, {successful} successful, {failed} failed")
return {
'success': True,
'processed': processed,
......@@ -100,7 +87,7 @@ class RenewalProcessor:
'failed': failed,
'errors': errors
}
except Exception as e:
logger.error(f"Error processing renewals: {e}")
import traceback
......@@ -112,66 +99,47 @@ class RenewalProcessor:
'successful': 0,
'failed': 0
}
async def _renew_subscription(self, subscription: Dict) -> Dict:
"""
Renew a single subscription
Args:
subscription: Subscription data dict
Returns:
dict: {'success': bool, 'error': str (optional)}
"""
"""Renew a single subscription"""
try:
from decimal import Decimal
from ..wallet.manager import WalletManager
from ..scheduler import trigger_auto_topup
# Determine amount to charge
ph = self.db.placeholder
billing_cycle = subscription['billing_cycle']
# Check if there's a pending tier change (downgrade)
if subscription['pending_tier_id']:
# Get new tier price
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT price_monthly, price_yearly, name
FROM account_tiers WHERE id = ?
FROM account_tiers WHERE id = {ph}
""", (subscription['pending_tier_id'],))
tier_row = cursor.fetchone()
if tier_row:
if billing_cycle == 'monthly':
amount = Decimal(tier_row[0])
else: # yearly
amount = Decimal(tier_row[1])
amount = Decimal(tier_row[0]) if billing_cycle == 'monthly' else Decimal(tier_row[1])
logger.info(f"Applying pending tier change for subscription {subscription['id']} to tier {subscription['pending_tier_id']}")
else:
# Fallback to current tier if pending tier not found
if billing_cycle == 'monthly':
amount = Decimal(subscription['price_monthly'])
else:
amount = Decimal(subscription['price_yearly'])
amount = Decimal(subscription['price_monthly']) if billing_cycle == 'monthly' else Decimal(subscription['price_yearly'])
else:
# Use current tier price
if billing_cycle == 'monthly':
amount = Decimal(subscription['price_monthly'])
else: # yearly
amount = Decimal(subscription['price_yearly'])
amount = Decimal(subscription['price_monthly']) if billing_cycle == 'monthly' else Decimal(subscription['price_yearly'])
user_id = subscription['user_id']
# Initialize wallet manager
wallet_manager = WalletManager(self.db.session)
wallet_manager = WalletManager(self.db)
wallet = await wallet_manager.get_wallet(user_id)
charge_result = None
# Check wallet first
if await wallet_manager.has_sufficient_balance(user_id, amount):
try:
debit_result = await wallet_manager.debit_wallet(
await wallet_manager.debit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
......@@ -183,26 +151,20 @@ class RenewalProcessor:
}
)
logger.info(f"Wallet debit successful for subscription {subscription['id']}: {amount}")
# Continue to subscription extension
charge_result = {'success': True, 'wallet_used': True}
except ValueError as e:
logger.warning(f"Wallet debit failed for subscription {subscription['id']}: {e}")
# Fall through to payment method
else:
# Proceed with renewal on successful wallet charge
charge_result = {'success': True, 'wallet_used': True}
else:
logger.info(f"Insufficient wallet balance for user {user_id}, checking auto top up")
# Check and trigger auto top up
if wallet_manager.should_trigger_auto_topup(wallet):
logger.info(f"Triggering auto top up for user {user_id} during renewal")
topup_success = await trigger_auto_topup(user_id, wallet)
if topup_success:
logger.info(f"Auto top up successful for user {user_id}, retrying wallet debit")
# Retry wallet debit after top up
try:
debit_result = await wallet_manager.debit_wallet(
await wallet_manager.debit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
......@@ -217,24 +179,22 @@ class RenewalProcessor:
charge_result = {'success': True, 'wallet_used': True, 'auto_topup_used': True}
except ValueError as e:
logger.warning(f"Wallet debit still failed after auto top up: {e}")
# Fall back to direct payment method
else:
logger.warning(f"Auto top up failed for user {user_id} during renewal")
# Fall back to direct payment method if wallet charge wasn't successful
if 'charge_result' not in locals() or not charge_result['success']:
# Get payment method
if charge_result is None or not charge_result['success']:
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT id, user_id, type, gateway, identifier, metadata
FROM payment_methods WHERE id = ?
FROM payment_methods WHERE id = {ph}
""", (subscription['payment_method_id'],))
pm_row = cursor.fetchone()
if not pm_row:
return {'success': False, 'error': 'Payment method not found'}
payment_method = {
'id': pm_row[0],
'user_id': pm_row[1],
......@@ -243,103 +203,84 @@ class RenewalProcessor:
'identifier': pm_row[4],
'metadata': pm_row[5]
}
# Extract crypto_type from identifier for crypto payments
if payment_method['type'] == 'crypto':
# For crypto, we need to determine the crypto type
# Check if there's a wallet for this user
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT crypto_type FROM user_crypto_wallets
WHERE user_id = ?
WHERE user_id = {ph}
LIMIT 1
""", (subscription['user_id'],))
wallet_row = cursor.fetchone()
if wallet_row:
payment_method['crypto_type'] = wallet_row[0]
else:
payment_method['crypto_type'] = None
payment_method['crypto_type'] = wallet_row[0] if wallet_row else None
else:
payment_method['crypto_type'] = None
# Attempt payment
charge_result = await self._charge_payment(
user_id=subscription['user_id'],
payment_method=payment_method,
amount=float(amount),
description=f"Subscription renewal - {subscription['tier_name']} ({billing_cycle})"
)
if not charge_result['success']:
logger.warning(f"Payment failed for subscription {subscription['id']}: {charge_result.get('error')}")
return charge_result
# Payment successful - extend period
# Payment successful — extend period
period_end = subscription['current_period_end']
if isinstance(period_end, str):
period_end = datetime.fromisoformat(period_end)
# Calculate new period
new_period_start = period_end
if billing_cycle == 'monthly':
new_period_end = new_period_start + timedelta(days=30)
else: # yearly
else:
new_period_end = new_period_start + timedelta(days=365)
# Update subscription
with self.db._get_connection() as conn:
cursor = conn.cursor()
# If there's a pending tier change, apply it
if subscription['pending_tier_id']:
cursor.execute("""
cursor.execute(f"""
UPDATE subscriptions
SET current_period_start = ?,
current_period_end = ?,
tier_id = ?,
SET current_period_start = {ph},
current_period_end = {ph},
tier_id = {ph},
pending_tier_id = NULL
WHERE id = ?
""", (new_period_start, new_period_end,
WHERE id = {ph}
""", (new_period_start, new_period_end,
subscription['pending_tier_id'], subscription['id']))
# Update user tier
cursor.execute("""
UPDATE users SET tier_id = ? WHERE id = ?
cursor.execute(f"""
UPDATE users SET tier_id = {ph} WHERE id = {ph}
""", (subscription['pending_tier_id'], subscription['user_id']))
logger.info(f"Applied tier change to {subscription['pending_tier_id']} for subscription {subscription['id']}")
else:
cursor.execute("""
cursor.execute(f"""
UPDATE subscriptions
SET current_period_start = ?,
current_period_end = ?
WHERE id = ?
SET current_period_start = {ph},
current_period_end = {ph}
WHERE id = {ph}
""", (new_period_start, new_period_end, subscription['id']))
conn.commit()
logger.info(f"Renewed subscription {subscription['id']} until {new_period_end}")
return {'success': True}
except Exception as e:
logger.error(f"Error renewing subscription {subscription['id']}: {e}")
import traceback
logger.error(traceback.format_exc())
return {'success': False, 'error': str(e)}
async def _cancel_subscription(self, subscription: Dict) -> Dict:
"""
Cancel a subscription and downgrade user to free tier
Args:
subscription: Subscription data dict
Returns:
dict: {'success': bool, 'error': str (optional)}
"""
"""Cancel a subscription and downgrade user to free tier"""
try:
ph = self.db.placeholder
# Get free tier
with self.db._get_connection() as conn:
cursor = conn.cursor()
......@@ -348,54 +289,48 @@ class RenewalProcessor:
ORDER BY is_default DESC LIMIT 1
""")
free_tier_row = cursor.fetchone()
if not free_tier_row:
return {'success': False, 'error': 'Free tier not found'}
free_tier_id = free_tier_row[0]
# Update subscription status
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE subscriptions
SET status = 'cancelled'
WHERE id = ?
WHERE id = {ph}
""", (subscription['id'],))
# Downgrade user to free tier
cursor.execute("""
UPDATE users SET tier_id = ? WHERE id = ?
cursor.execute(f"""
UPDATE users SET tier_id = {ph} WHERE id = {ph}
""", (free_tier_id, subscription['user_id']))
conn.commit()
logger.info(f"Cancelled subscription {subscription['id']} and downgraded user {subscription['user_id']} to free tier")
return {'success': True}
except Exception as e:
logger.error(f"Error cancelling subscription {subscription['id']}: {e}")
return {'success': False, 'error': str(e)}
async def _charge_payment(self, user_id: int, payment_method: Dict,
async def _charge_payment(self, user_id: int, payment_method: Dict,
amount: float, description: str) -> Dict:
"""Charge payment using appropriate gateway"""
payment_type = payment_method['type']
gateway = payment_method.get('gateway')
if payment_type == 'card':
# Stripe card payment
if self.stripe:
# Check if stripe handler has a should_fail attribute (for testing)
if hasattr(self.stripe, 'should_fail') and self.stripe.should_fail:
return {'success': False, 'error': 'Payment failed'}
return {'success': True, 'transaction_id': 'mock_tx'}
return {'success': True, 'transaction_id': 'mock_tx'}
elif payment_type == 'paypal':
# PayPal payment - check gateway version
if gateway == 'paypal_v3' and self.paypal:
# Use Vault v3 API for off-session charge
payment_token_id = payment_method['identifier']
result = await self.paypal.charge_payment_token(
payment_token_id=payment_token_id,
......@@ -407,7 +342,6 @@ class RenewalProcessor:
else:
return {'success': False, 'error': result.get('error', 'PayPal charge failed')}
elif self.paypal:
# Legacy billing agreement (deprecated but kept for backward compatibility)
return {'success': True, 'transaction_id': 'mock_tx'}
return {'success': True, 'transaction_id': 'mock_tx'}
elif payment_type == 'crypto':
......@@ -418,42 +352,41 @@ class RenewalProcessor:
)
else:
return {'success': False, 'error': f'Unknown payment method type: {payment_type}'}
async def _charge_crypto_wallet(self, user_id: int, crypto_type: str,
async def _charge_crypto_wallet(self, user_id: int, crypto_type: str,
amount: float) -> Dict:
"""Charge from user's crypto wallet"""
try:
# Get wallet balance
ph = self.db.placeholder
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT balance_fiat FROM user_crypto_wallets
WHERE user_id = ? AND crypto_type = ?
WHERE user_id = {ph} AND crypto_type = {ph}
""", (user_id, crypto_type))
wallet_row = cursor.fetchone()
if not wallet_row:
return {'success': False, 'error': 'Wallet not found'}
balance = wallet_row[0]
if balance < amount:
return {'success': False, 'error': 'Insufficient balance'}
# Deduct from wallet
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE user_crypto_wallets
SET balance_fiat = balance_fiat - ?
WHERE user_id = ? AND crypto_type = ?
SET balance_fiat = balance_fiat - {ph}
WHERE user_id = {ph} AND crypto_type = {ph}
""", (amount, user_id, crypto_type))
conn.commit()
logger.info(f"Charged ${amount} from user {user_id} {crypto_type} wallet")
return {'success': True}
except Exception as e:
logger.error(f"Error charging crypto wallet: {e}")
return {'success': False, 'error': str(e)}
......
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