Coverage for src/alprina_cli/services/abandoned_checkout_service.py: 0%
67 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Abandoned Checkout Service
3Sends reminder emails to users who signed up but haven't completed checkout
4"""
5import os
6from typing import List, Dict, Any
7from datetime import datetime, timedelta
8import resend
9from loguru import logger
11from ..api.services.neon_service import neon_service
14class AbandonedCheckoutService:
15 """Service for handling abandoned checkout reminders"""
17 def __init__(self):
18 self.resend_api_key = os.getenv('RESEND_API_KEY')
19 if self.resend_api_key:
20 resend.api_key = self.resend_api_key
21 else:
22 logger.warning("RESEND_API_KEY not set - email notifications disabled")
24 async def find_abandoned_users(self, hours_since_signup: int = 1) -> List[Dict[str, Any]]:
25 """
26 Find users who signed up but haven't paid yet
28 Args:
29 hours_since_signup: How many hours after signup to check (default: 1)
31 Returns:
32 List of users with abandoned checkouts
33 """
34 if not neon_service.is_enabled():
35 logger.error("Database not enabled")
36 return []
38 try:
39 cutoff_time = datetime.utcnow() - timedelta(hours=hours_since_signup)
41 query = """
42 SELECT
43 u.id,
44 u.email,
45 u.full_name,
46 u.created_at,
47 u.tier,
48 u.abandoned_checkout_email_sent_at
49 FROM users u
50 WHERE
51 -- User has no paid tier
52 (u.tier IS NULL OR u.tier = 'none')
53 -- Signed up more than X hours ago
54 AND u.created_at < $1
55 -- Haven't sent reminder yet, or sent more than 7 days ago
56 AND (
57 u.abandoned_checkout_email_sent_at IS NULL
58 OR u.abandoned_checkout_email_sent_at < NOW() - INTERVAL '7 days'
59 )
60 -- Not a deleted/banned account
61 AND u.subscription_status != 'cancelled'
62 ORDER BY u.created_at DESC
63 LIMIT 100
64 """
66 result = await neon_service.execute(query, cutoff_time)
68 logger.info(f"Found {len(result)} users with abandoned checkouts")
69 return result
71 except Exception as e:
72 logger.error(f"Error finding abandoned users: {e}")
73 return []
75 async def send_reminder_email(self, user: Dict[str, Any]) -> bool:
76 """
77 Send abandoned checkout reminder email
79 Args:
80 user: User data dictionary with id, email, full_name, created_at
82 Returns:
83 True if email sent successfully
84 """
85 if not self.resend_api_key:
86 logger.warning(f"Cannot send email to {user['email']} - RESEND_API_KEY not set")
87 return False
89 try:
90 # Calculate time since signup
91 signup_date = user['created_at']
92 if isinstance(signup_date, str):
93 signup_date = datetime.fromisoformat(signup_date.replace('Z', '+00:00'))
95 hours_ago = int((datetime.utcnow() - signup_date.replace(tzinfo=None)).total_seconds() / 3600)
97 # Get user's first name or use email
98 first_name = user.get('full_name', '').split()[0] if user.get('full_name') else user['email'].split('@')[0]
100 # Send email via Resend
101 email_html = self._generate_email_html(first_name, hours_ago)
102 email_text = self._generate_email_text(first_name, hours_ago)
104 params = {
105 "from": "Alprina <noreply@alprina.com>",
106 "to": [user['email']],
107 "subject": "Complete your Alprina setup 🚀",
108 "html": email_html,
109 "text": email_text,
110 }
112 response = resend.Emails.send(params)
114 logger.info(f"✅ Sent abandoned checkout email to {user['email']} (ID: {response.get('id')})")
116 # Mark as sent in database
117 await self._mark_email_sent(user['id'])
119 return True
121 except Exception as e:
122 logger.error(f"Failed to send email to {user['email']}: {e}")
123 return False
125 async def _mark_email_sent(self, user_id: str):
126 """Mark that we sent the abandoned checkout email"""
127 try:
128 query = """
129 UPDATE users
130 SET abandoned_checkout_email_sent_at = NOW()
131 WHERE id = $1
132 """
133 await neon_service.execute(query, user_id)
134 except Exception as e:
135 logger.error(f"Failed to mark email sent for user {user_id}: {e}")
137 def _generate_email_html(self, first_name: str, hours_ago: int) -> str:
138 """Generate HTML email content"""
139 return f"""
140<!DOCTYPE html>
141<html>
142<head>
143 <meta charset="utf-8">
144 <meta name="viewport" content="width=device-width, initial-scale=1.0">
145 <title>Complete Your Alprina Setup</title>
146</head>
147<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f6f8fa;">
148 <table role="presentation" style="width: 100%; border-collapse: collapse;">
149 <tr>
150 <td style="padding: 40px 20px;">
151 <table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
152 <!-- Header -->
153 <tr>
154 <td style="padding: 40px 40px 20px; text-align: center;">
155 <h1 style="margin: 0; color: #1a1a1a; font-size: 24px; font-weight: 600;">
156 🛡️ Complete Your Alprina Setup
157 </h1>
158 </td>
159 </tr>
161 <!-- Content -->
162 <tr>
163 <td style="padding: 0 40px 40px;">
164 <p style="margin: 0 0 16px; color: #444; font-size: 16px; line-height: 1.5;">
165 Hi {first_name},
166 </p>
168 <p style="margin: 0 0 16px; color: #444; font-size: 16px; line-height: 1.5;">
169 You created an Alprina account {hours_ago} hour{"s" if hours_ago != 1 else ""} ago, but you haven't chosen a plan yet.
170 </p>
172 <p style="margin: 0 0 24px; color: #444; font-size: 16px; line-height: 1.5;">
173 Ready to start securing your code with AI-powered scanning?
174 </p>
176 <!-- Benefits -->
177 <table role="presentation" style="width: 100%; margin-bottom: 24px;">
178 <tr>
179 <td style="padding: 16px; background-color: #f6f8fa; border-radius: 6px;">
180 <div style="margin-bottom: 12px;">
181 <span style="color: #10b981; font-size: 18px;">✓</span>
182 <span style="color: #444; margin-left: 8px; font-size: 14px;">18 AI security agents</span>
183 </div>
184 <div style="margin-bottom: 12px;">
185 <span style="color: #10b981; font-size: 18px;">✓</span>
186 <span style="color: #444; margin-left: 8px; font-size: 14px;">Find vulnerabilities others miss</span>
187 </div>
188 <div style="margin-bottom: 12px;">
189 <span style="color: #10b981; font-size: 18px;">✓</span>
190 <span style="color: #444; margin-left: 8px; font-size: 14px;">GitHub integration</span>
191 </div>
192 <div>
193 <span style="color: #10b981; font-size: 18px;">✓</span>
194 <span style="color: #444; margin-left: 8px; font-size: 14px;">7-day money-back guarantee</span>
195 </div>
196 </td>
197 </tr>
198 </table>
200 <!-- CTA Button -->
201 <table role="presentation" style="width: 100%; margin-bottom: 24px;">
202 <tr>
203 <td style="text-align: center;">
204 <a href="https://alprina.com/pricing?welcome=true"
205 style="display: inline-block; padding: 14px 32px; background-color: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">
206 Choose Your Plan →
207 </a>
208 </td>
209 </tr>
210 </table>
212 <!-- Plans Preview -->
213 <p style="margin: 0 0 12px; color: #666; font-size: 14px; text-align: center;">
214 <strong>Starting at just €39/month</strong>
215 </p>
216 <p style="margin: 0 0 24px; color: #666; font-size: 14px; text-align: center;">
217 Developer • Pro • Team • Enterprise
218 </p>
220 <!-- Social Proof -->
221 <table role="presentation" style="width: 100%; margin-bottom: 24px;">
222 <tr>
223 <td style="padding: 16px; background-color: #eff6ff; border-left: 4px solid #3b82f6; border-radius: 6px;">
224 <p style="margin: 0; color: #1e40af; font-size: 14px; font-style: italic;">
225 "Alprina found 12 critical vulnerabilities our other tools missed. Worth every penny."
226 </p>
227 <p style="margin: 8px 0 0; color: #60a5fa; font-size: 12px;">
228 — Sarah K., Lead Security Engineer
229 </p>
230 </td>
231 </tr>
232 </table>
234 <p style="margin: 0 0 8px; color: #666; font-size: 14px; line-height: 1.5;">
235 Questions? Just reply to this email — we're here to help!
236 </p>
238 <p style="margin: 0; color: #666; font-size: 14px; line-height: 1.5;">
239 Best regards,<br>
240 The Alprina Team
241 </p>
242 </td>
243 </tr>
245 <!-- Footer -->
246 <tr>
247 <td style="padding: 24px 40px; border-top: 1px solid #e5e7eb; text-align: center;">
248 <p style="margin: 0 0 8px; color: #999; font-size: 12px;">
249 <a href="https://alprina.com" style="color: #3b82f6; text-decoration: none;">Alprina.com</a>
250 •
251 <a href="https://docs.alprina.com" style="color: #3b82f6; text-decoration: none;">Documentation</a>
252 •
253 <a href="mailto:support@alprina.com" style="color: #3b82f6; text-decoration: none;">Support</a>
254 </p>
255 <p style="margin: 0; color: #999; font-size: 11px;">
256 This email was sent to you because you created an Alprina account.
257 </p>
258 </td>
259 </tr>
260 </table>
261 </td>
262 </tr>
263 </table>
264</body>
265</html>
266"""
268 def _generate_email_text(self, first_name: str, hours_ago: int) -> str:
269 """Generate plain text email content"""
270 return f"""
271Hi {first_name},
273You created an Alprina account {hours_ago} hour{"s" if hours_ago != 1 else ""} ago, but you haven't chosen a plan yet.
275Ready to start securing your code with AI-powered scanning?
277What you get with Alprina:
278✓ 18 AI security agents
279✓ Find vulnerabilities others miss
280✓ GitHub integration
281✓ 7-day money-back guarantee
283Choose your plan: https://alprina.com/pricing?welcome=true
285Starting at just €39/month
286Plans: Developer • Pro • Team • Enterprise
288"Alprina found 12 critical vulnerabilities our other tools missed. Worth every penny."
289— Sarah K., Lead Security Engineer
291Questions? Just reply to this email — we're here to help!
293Best regards,
294The Alprina Team
296---
297Alprina.com | Documentation: https://docs.alprina.com | Support: support@alprina.com
298"""
300 async def process_abandoned_checkouts(self, hours_since_signup: int = 1) -> Dict[str, int]:
301 """
302 Find and process all abandoned checkouts
304 Args:
305 hours_since_signup: Hours after signup to send reminder (default: 1)
307 Returns:
308 Dictionary with counts of found, sent, failed
309 """
310 users = await self.find_abandoned_users(hours_since_signup)
312 sent = 0
313 failed = 0
315 for user in users:
316 success = await self.send_reminder_email(user)
317 if success:
318 sent += 1
319 else:
320 failed += 1
322 logger.info(f"Processed {len(users)} abandoned checkouts: {sent} sent, {failed} failed")
324 return {
325 "found": len(users),
326 "sent": sent,
327 "failed": failed
328 }
331# Singleton instance
332abandoned_checkout_service = AbandonedCheckoutService()