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

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 

10 

11from ..api.services.neon_service import neon_service 

12 

13 

14class AbandonedCheckoutService: 

15 """Service for handling abandoned checkout reminders""" 

16 

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") 

23 

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 

27  

28 Args: 

29 hours_since_signup: How many hours after signup to check (default: 1) 

30  

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 [] 

37 

38 try: 

39 cutoff_time = datetime.utcnow() - timedelta(hours=hours_since_signup) 

40 

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 """ 

65 

66 result = await neon_service.execute(query, cutoff_time) 

67 

68 logger.info(f"Found {len(result)} users with abandoned checkouts") 

69 return result 

70 

71 except Exception as e: 

72 logger.error(f"Error finding abandoned users: {e}") 

73 return [] 

74 

75 async def send_reminder_email(self, user: Dict[str, Any]) -> bool: 

76 """ 

77 Send abandoned checkout reminder email 

78  

79 Args: 

80 user: User data dictionary with id, email, full_name, created_at 

81  

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 

88 

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')) 

94 

95 hours_ago = int((datetime.utcnow() - signup_date.replace(tzinfo=None)).total_seconds() / 3600) 

96 

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] 

99 

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) 

103 

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 } 

111 

112 response = resend.Emails.send(params) 

113 

114 logger.info(f"✅ Sent abandoned checkout email to {user['email']} (ID: {response.get('id')})") 

115 

116 # Mark as sent in database 

117 await self._mark_email_sent(user['id']) 

118 

119 return True 

120 

121 except Exception as e: 

122 logger.error(f"Failed to send email to {user['email']}: {e}") 

123 return False 

124 

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}") 

136 

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> 

160  

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> 

167  

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> 

171  

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> 

175  

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> 

199  

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> 

211  

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> 

219  

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> 

233  

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> 

237  

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> 

244  

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""" 

267 

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}, 

272 

273You created an Alprina account {hours_ago} hour{"s" if hours_ago != 1 else ""} ago, but you haven't chosen a plan yet. 

274 

275Ready to start securing your code with AI-powered scanning? 

276 

277What you get with Alprina: 

278✓ 18 AI security agents 

279✓ Find vulnerabilities others miss 

280✓ GitHub integration 

281✓ 7-day money-back guarantee 

282 

283Choose your plan: https://alprina.com/pricing?welcome=true 

284 

285Starting at just €39/month 

286Plans: Developer • Pro • Team • Enterprise 

287 

288"Alprina found 12 critical vulnerabilities our other tools missed. Worth every penny." 

289— Sarah K., Lead Security Engineer 

290 

291Questions? Just reply to this email — we're here to help! 

292 

293Best regards, 

294The Alprina Team 

295 

296--- 

297Alprina.com | Documentation: https://docs.alprina.com | Support: support@alprina.com 

298""" 

299 

300 async def process_abandoned_checkouts(self, hours_since_signup: int = 1) -> Dict[str, int]: 

301 """ 

302 Find and process all abandoned checkouts 

303  

304 Args: 

305 hours_since_signup: Hours after signup to send reminder (default: 1) 

306  

307 Returns: 

308 Dictionary with counts of found, sent, failed 

309 """ 

310 users = await self.find_abandoned_users(hours_since_signup) 

311 

312 sent = 0 

313 failed = 0 

314 

315 for user in users: 

316 success = await self.send_reminder_email(user) 

317 if success: 

318 sent += 1 

319 else: 

320 failed += 1 

321 

322 logger.info(f"Processed {len(users)} abandoned checkouts: {sent} sent, {failed} failed") 

323 

324 return { 

325 "found": len(users), 

326 "sent": sent, 

327 "failed": failed 

328 } 

329 

330 

331# Singleton instance 

332abandoned_checkout_service = AbandonedCheckoutService()