feat: migrate PayPal integration from deprecated Billing Agreements API to Vault v3 API

- Add create_setup_token() for PayPal Vault setup token generation
- Add create_payment_token() to exchange setup token for permanent payment token
- Add charge_payment_token() for off-session merchant-initiated transactions
- Update payment_methods table schema with gateway, last4, brand, paypal_email columns
- Update dashboard routes to use new vault flow instead of billing agreements
- No longer requires Reference Transactions to be enabled on PayPal account
- Supports merchant-initiated billing for auto top-ups without user presence
parent f04a16a3
......@@ -3,6 +3,7 @@ PayPal payment integration
"""
import logging
import base64
import time
import httpx
from typing import Optional
......@@ -58,6 +59,127 @@ class PayPalPaymentHandler:
else:
raise Exception(f"Failed to get PayPal access token: {response.text}")
async def create_setup_token(self, return_url: str, cancel_url: str) -> dict:
"""Create PayPal Vault Setup Token for payment method saving"""
try:
access_token = await self.get_access_token()
response = await self.http_client.post(
f"{self.base_url}/v3/vault/setup-tokens",
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'payment_source': {
'paypal': {
'usage_type': 'MERCHANT',
'experience_context': {
'return_url': return_url,
'cancel_url': cancel_url,
'shipping_preference': 'NO_SHIPPING',
'user_action': 'PAY_NOW'
}
}
}
}
)
if response.status_code == 201:
data = response.json()
return {
'success': True,
'id': data['id'],
'approval_url': data['links'][1]['href']
}
else:
logger.error(f"Failed to create setup token: {response.text}")
return {'success': False, 'error': response.text}
except Exception as e:
logger.error(f"Error creating setup token: {e}")
return {'success': False, 'error': str(e)}
async def create_payment_token(self, setup_token_id: str) -> dict:
"""Exchange setup token for permanent payment token"""
try:
access_token = await self.get_access_token()
response = await self.http_client.post(
f"{self.base_url}/v3/vault/payment-tokens",
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'setup_token_id': setup_token_id
}
)
if response.status_code == 201:
data = response.json()
return {
'success': True,
'payment_token_id': data['id'],
'payer_email': data.get('payer', {}).get('email_address'),
'payment_method_type': data['payment_source']['paypal']['card_type'] if 'card_type' in data['payment_source']['paypal'] else 'PAYPAL'
}
else:
logger.error(f"Failed to create payment token: {response.text}")
return {'success': False, 'error': response.text}
except Exception as e:
logger.error(f"Error creating payment token: {e}")
return {'success': False, 'error': str(e)}
async def charge_payment_token(self, payment_token_id: str, amount: float, currency_code: str = 'USD') -> dict:
"""Charge saved payment token (off-session merchant initiated transaction)"""
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',
'PayPal-Request-Id': f'charge_{payment_token_id}_{int(time.time())}'
},
json={
'intent': 'CAPTURE',
'purchase_units': [{
'amount': {
'currency_code': currency_code,
'value': f"{amount:.2f}"
}
}],
'payment_source': {
'token': {
'id': payment_token_id,
'type': 'PAYMENT_METHOD_TOKEN'
}
},
'payment_instruction': {
'usage': 'MERCHANT',
'customer_present': False
}
}
)
if response.status_code == 201:
data = response.json()
return {
'success': True,
'order_id': data['id'],
'status': data['status']
}
else:
logger.error(f"Failed to charge payment token: {response.text}")
return {'success': False, 'error': response.text}
except Exception as e:
logger.error(f"Error charging payment token: {e}")
return {'success': False, 'error': str(e)}
async def create_billing_agreement(self, user_id: int, return_url: str,
cancel_url: str) -> dict:
"""Create PayPal billing agreement for recurring payments"""
......@@ -102,6 +224,20 @@ class PayPalPaymentHandler:
'approval_url': approval_url
}
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:
error_name = detail.get('name')
if error_name == 'REFUSED_MARK_REF_TXN_NOT_ENABLED':
logger.error(f"PayPal account does not have Reference Transactions enabled: {response.text}")
return {
'success': False,
'error': 'PayPal merchant account is not configured for Reference Transactions. Please contact PayPal support to enable this feature for your account.',
'error_code': 'reference_transactions_not_enabled'
}
logger.error(f"Failed to create billing agreement: {response.text}")
return {'success': False, 'error': response.text}
......
......@@ -181,6 +181,48 @@ class PaymentMigrations:
def _create_payment_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
"""Create payment-related tables"""
# Payment methods table
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS payment_methods (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
type VARCHAR(50) NOT NULL,
gateway VARCHAR(50),
identifier VARCHAR(255),
crypto_type VARCHAR(20),
last4 VARCHAR(4),
brand VARCHAR(50),
paypal_email VARCHAR(255),
is_default {boolean_type} DEFAULT 0,
status VARCHAR(20) DEFAULT 'active',
metadata {text_type},
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
# Payment transactions table
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS payment_transactions (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
subscription_id INTEGER,
payment_method_id INTEGER,
amount {decimal_type} NOT NULL,
currency VARCHAR(10) DEFAULT 'USD',
status VARCHAR(50) NOT NULL,
transaction_type VARCHAR(50) NOT NULL,
external_transaction_id VARCHAR(255),
metadata {text_type},
created_at TIMESTAMP DEFAULT {timestamp_default},
completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id),
FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id)
)
''')
# Payment retry queue
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS payment_retry_queue (
......@@ -216,10 +258,14 @@ class PaymentMigrations:
# Create indexes
try:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_methods_user ON payment_methods(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_transactions_user ON payment_transactions(user_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_payment_retry_status ON payment_retry_queue(status, next_retry_at)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_api_requests_user_time ON api_requests(user_id, created_at)')
except:
pass
logger.info("✅ Created/verified payment tables")
def _create_subscription_tables(self, cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type):
"""Create subscription-related tables"""
......
......@@ -165,6 +165,40 @@ class PaymentService:
logger.error(f"Error completing PayPal billing agreement: {e}")
return {'success': False, 'error': str(e)}
async def initiate_paypal_vault_setup(self, user_id: int, return_url: str, cancel_url: str) -> dict:
"""Initiate PayPal Vault Setup Token for saving payment method"""
try:
result = await self.paypal_handler.create_setup_token(return_url, cancel_url)
return result
except Exception as e:
logger.error(f"Error initiating PayPal vault setup: {e}")
return {'success': False, 'error': str(e)}
async def complete_paypal_vault_setup(self, user_id: int, setup_token_id: str) -> dict:
"""Complete PayPal vault setup and store payment method"""
try:
result = await self.paypal_handler.create_payment_token(setup_token_id)
if not result['success']:
return result
# Store payment method
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO payment_methods
(user_id, type, gateway, identifier, paypal_email, is_default, status)
VALUES (?, 'paypal', 'paypal_v3', ?, ?, 1, 'active')
""", (user_id, result['payment_token_id'], result['payer_email']))
conn.commit()
logger.info(f"Added PayPal vault payment method for user {user_id}")
return result
except Exception as e:
logger.error(f"Error completing PayPal vault setup: {e}")
return {'success': False, 'error': str(e)}
async def get_payment_methods(self, user_id: int) -> list:
"""Get all payment methods for user"""
try:
......
......@@ -8148,7 +8148,7 @@ async def dashboard_set_default_payment_method(request: Request, method_id: int)
@app.get("/dashboard/billing/add-method/paypal/oauth")
async def dashboard_add_payment_method_paypal_oauth(request: Request):
"""Initiate PayPal billing agreement flow"""
"""Initiate PayPal Vault setup flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
......@@ -8183,23 +8183,23 @@ async def dashboard_add_payment_method_paypal_oauth(request: Request):
return_url = f"{base_url}/dashboard/billing/add-method/paypal/callback"
cancel_url = f"{base_url}/dashboard/billing?error=PayPal connection cancelled"
# Create billing agreement token using payment service
result = await payment_service.initiate_paypal_billing_agreement(user_id, return_url, cancel_url)
# Create vault setup token using payment service
result = await payment_service.initiate_paypal_vault_setup(user_id, return_url, cancel_url)
if not result['success']:
logger.error(f"Failed to create PayPal billing agreement: {result.get('error')}")
logger.error(f"Failed to create PayPal vault setup: {result.get('error')}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to initialize PayPal connection",
status_code=302
)
logger.info(f"Initiating PayPal billing agreement for user {user_id}")
logger.info(f"Initiating PayPal vault setup for user {user_id}")
return RedirectResponse(url=result['approval_url'], status_code=302)
@app.get("/dashboard/billing/add-method/paypal/callback")
async def dashboard_add_payment_method_paypal_callback(request: Request):
"""Handle PayPal billing agreement callback"""
"""Handle PayPal vault setup callback"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
......@@ -8212,32 +8212,32 @@ async def dashboard_add_payment_method_paypal_callback(request: Request):
# Handle user cancellation
if error:
logger.info(f"PayPal billing agreement cancelled by user {user_id}: {error}")
logger.info(f"PayPal vault setup cancelled by user {user_id}: {error}")
return RedirectResponse(
url="/dashboard/billing?error=PayPal connection cancelled",
status_code=302
)
# Validate billing agreement token
# Validate setup token
if not token:
logger.error(f"PayPal callback missing billing agreement token (user_id={user_id})")
logger.error(f"PayPal callback missing setup token (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Invalid PayPal response",
status_code=302
)
try:
# Execute the billing agreement using payment service
result = await payment_service.complete_paypal_billing_agreement(user_id, token)
# Complete vault setup using payment service
result = await payment_service.complete_paypal_vault_setup(user_id, token)
if not result['success']:
logger.error(f"Failed to execute PayPal billing agreement: {result.get('error')}")
logger.error(f"Failed to complete PayPal vault setup: {result.get('error')}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
logger.info(f"PayPal payment method added for user {user_id} (agreement_id={result['agreement_id']})")
logger.info(f"PayPal payment method added for user {user_id} (payment_token={result['payment_token_id']})")
return RedirectResponse(
url="/dashboard/billing?success=PayPal account connected successfully",
......@@ -8250,32 +8250,6 @@ async def dashboard_add_payment_method_paypal_callback(request: Request):
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
# Validate billing agreement token
if not token:
logger.error(f"PayPal callback missing billing agreement token (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Invalid PayPal response",
status_code=302
)
try:
# Execute the billing agreement using payment service
result = await payment_service.complete_paypal_billing_agreement(user_id, token)
if not result['success']:
logger.error(f"Failed to execute PayPal billing agreement: {result.get('error')}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
logger.info(f"PayPal payment method added for user {user_id} (agreement_id={result['agreement_id']})")
return RedirectResponse(
url="/dashboard/billing?success=PayPal account connected successfully",
status_code=302
)
except Exception as e:
logger.error(f"PayPal callback error (user_id={user_id}): {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