Coverage for src/alprina_cli/api/polar_meters.py: 31%
58 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"""
2Polar Meter Integration for Usage-Based Billing
3Reports credit consumption to Polar for automatic billing
4"""
5import os
6import requests
7from datetime import datetime
8from typing import Optional, Dict, Any
9import logging
11logger = logging.getLogger(__name__)
13class PolarMeterService:
14 """Report usage to Polar meters for billing"""
16 POLAR_API_URL = "https://api.polar.sh"
17 POLAR_ACCESS_TOKEN = os.getenv("POLAR_ACCESS_TOKEN") or os.getenv("POLAR_API_TOKEN")
19 # Credit costs for different operations
20 CREDIT_COSTS = {
21 # Scans - start simple (all = 1 credit)
22 "scan_basic": 1,
23 "scan_standard": 1, # Can adjust later: 5 credits
24 "scan_deep": 1, # Can adjust later: 10 credits
25 "scan_red_team": 1,
26 "scan_blue_team": 1,
27 "scan_owasp": 1,
29 # Future features (commented for now)
30 # "chat_query": 0.5,
31 # "chat_analysis": 2,
32 # "report_html": 1,
33 # "report_pdf": 2,
34 # "api_call": 0.1,
35 }
37 @classmethod
38 async def report_credit_usage(
39 cls,
40 external_customer_id: str,
41 credits: int,
42 operation: str,
43 metadata: Optional[Dict[str, Any]] = None
44 ) -> bool:
45 """
46 Report credit consumption to Polar meter using Events API.
48 Args:
49 external_customer_id: Your internal customer ID (email or user_id)
50 credits: Number of credits consumed
51 operation: What operation consumed credits (e.g., "scan_basic")
52 metadata: Additional context (optional)
54 Returns:
55 True if successfully reported, False otherwise
56 """
57 if not cls.POLAR_ACCESS_TOKEN:
58 logger.error("POLAR_ACCESS_TOKEN not set")
59 return False
61 if not external_customer_id:
62 logger.error("No external_customer_id provided")
63 return False
65 # Prepare event payload (matches Polar SDK format)
66 event_metadata = {
67 "credits": credits, # This field is summed by meter
68 "operation": operation,
69 }
71 # Add optional metadata
72 if metadata:
73 event_metadata.update(metadata)
75 payload = {
76 "events": [
77 {
78 "name": "credit_consumed", # Must match meter event name filter
79 "external_customer_id": external_customer_id, # Your customer ID (snake_case!)
80 "metadata": event_metadata
81 }
82 ]
83 }
85 headers = {
86 "Authorization": f"Bearer {cls.POLAR_ACCESS_TOKEN}",
87 "Content-Type": "application/json"
88 }
90 try:
91 # Send to Polar Events Ingest API
92 response = requests.post(
93 f"{cls.POLAR_API_URL}/v1/events/ingest",
94 json=payload,
95 headers=headers,
96 timeout=10
97 )
99 if response.status_code in [200, 201, 204]:
100 logger.info(f"✅ Reported {credits} credits to Polar for {operation} (customer: {external_customer_id})")
101 return True
102 else:
103 logger.error(f"❌ Polar API error: {response.status_code} - {response.text}")
104 return False
106 except Exception as e:
107 logger.error(f"❌ Failed to report to Polar: {str(e)}")
108 return False
110 @classmethod
111 def get_operation_cost(cls, operation: str) -> int:
112 """Get credit cost for an operation"""
113 return cls.CREDIT_COSTS.get(operation, 1)
115 @classmethod
116 async def report_scan(
117 cls,
118 user_email: str,
119 scan_type: str,
120 target: str,
121 user_id: str
122 ) -> bool:
123 """
124 Convenience method to report scan usage.
126 Args:
127 user_email: User's email (used as external_customer_id)
128 scan_type: Type of scan (e.g., "red_team", "owasp")
129 target: Scan target
130 user_id: User who ran the scan
132 Returns:
133 True if reported successfully
134 """
135 operation = f"scan_{scan_type}"
136 credits = cls.get_operation_cost(operation)
138 metadata = {
139 "scan_type": scan_type,
140 "target": target,
141 "user_id": user_id
142 }
144 return await cls.report_credit_usage(
145 external_customer_id=user_email, # Use email as customer identifier
146 credits=credits,
147 operation=operation,
148 metadata=metadata
149 )
151 @classmethod
152 async def get_customer_usage(cls, external_customer_id: str) -> Optional[Dict[str, Any]]:
153 """
154 Get current usage for a customer from Polar.
156 Args:
157 external_customer_id: Your internal customer ID (email)
159 Returns:
160 {
161 "consumed_units": 150,
162 "credited_units": 100,
163 "balance": -50 # Negative = overage
164 }
165 """
166 if not cls.POLAR_ACCESS_TOKEN:
167 return None
169 headers = {
170 "Authorization": f"Bearer {cls.POLAR_ACCESS_TOKEN}",
171 }
173 try:
174 response = requests.get(
175 f"{cls.POLAR_API_URL}/v1/customer-meters/",
176 params={"external_customer_id": external_customer_id},
177 headers=headers,
178 timeout=10
179 )
181 if response.status_code == 200:
182 data = response.json()
183 if data.get("items"):
184 meter = data["items"][0] # Get first meter
185 return {
186 "consumed_units": meter.get("consumed_units", 0),
187 "credited_units": meter.get("credited_units", 0),
188 "balance": meter.get("balance", 0),
189 "overage": max(0, -meter.get("balance", 0))
190 }
192 return None
194 except Exception as e:
195 logger.error(f"Failed to get customer usage: {str(e)}")
196 return None