codetether 1.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stripe Billing Service for A2A Server.
|
|
3
|
+
|
|
4
|
+
Provides subscription billing, customer management, and usage tracking
|
|
5
|
+
via Stripe. API key is fetched from Vault or environment variable.
|
|
6
|
+
|
|
7
|
+
Configuration (environment variables):
|
|
8
|
+
STRIPE_API_KEY: Stripe API key (optional if using Vault)
|
|
9
|
+
|
|
10
|
+
Vault Configuration:
|
|
11
|
+
Path: kv/codetether/stripe
|
|
12
|
+
Key: api_key
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
import stripe
|
|
23
|
+
|
|
24
|
+
from .vault_client import get_vault_client
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Thread pool for running sync Stripe operations
|
|
29
|
+
_executor = ThreadPoolExecutor(max_workers=4)
|
|
30
|
+
|
|
31
|
+
# Plan definitions
|
|
32
|
+
PLANS: Dict[str, Dict[str, Any]] = {
|
|
33
|
+
'free': {
|
|
34
|
+
'name': 'Free',
|
|
35
|
+
'price_id': None,
|
|
36
|
+
'limits': {
|
|
37
|
+
'workers': 1,
|
|
38
|
+
'codebases': 3,
|
|
39
|
+
'tasks_per_month': 100,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
'pro': {
|
|
43
|
+
'name': 'Pro',
|
|
44
|
+
'price_id': 'price_1SoawKE8yr4fu4JjkHQA2Y2c', # $49/month
|
|
45
|
+
'limits': {
|
|
46
|
+
'workers': 5,
|
|
47
|
+
'codebases': 20,
|
|
48
|
+
'tasks_per_month': 5000,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
'enterprise': {
|
|
52
|
+
'name': 'Enterprise',
|
|
53
|
+
'price_id': 'price_1SoawKE8yr4fu4Jj7iDEjsk6', # $199/month
|
|
54
|
+
'limits': {
|
|
55
|
+
'workers': -1, # Unlimited
|
|
56
|
+
'codebases': -1, # Unlimited
|
|
57
|
+
'tasks_per_month': -1, # Unlimited
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BillingServiceError(Exception):
|
|
64
|
+
"""Base exception for billing service errors."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CustomerNotFoundError(BillingServiceError):
|
|
70
|
+
"""Raised when a customer is not found."""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SubscriptionNotFoundError(BillingServiceError):
|
|
76
|
+
"""Raised when a subscription is not found."""
|
|
77
|
+
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PlanNotFoundError(BillingServiceError):
|
|
82
|
+
"""Raised when a plan is not found."""
|
|
83
|
+
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BillingService:
|
|
88
|
+
"""
|
|
89
|
+
Stripe billing service for subscription management.
|
|
90
|
+
|
|
91
|
+
Supports both async operations (using thread pool executor for sync Stripe SDK)
|
|
92
|
+
and provides customer, subscription, and usage management.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
96
|
+
"""
|
|
97
|
+
Initialize the billing service.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
api_key: Optional Stripe API key. If not provided, will be
|
|
101
|
+
fetched from STRIPE_API_KEY env var or Vault.
|
|
102
|
+
"""
|
|
103
|
+
self._api_key = api_key
|
|
104
|
+
self._initialized = False
|
|
105
|
+
self._init_lock = asyncio.Lock()
|
|
106
|
+
|
|
107
|
+
async def _ensure_initialized(self) -> None:
|
|
108
|
+
"""Ensure the Stripe API key is loaded and configured."""
|
|
109
|
+
if self._initialized:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
async with self._init_lock:
|
|
113
|
+
if self._initialized:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
api_key = self._api_key
|
|
117
|
+
|
|
118
|
+
# Try environment variable first
|
|
119
|
+
if not api_key:
|
|
120
|
+
api_key = os.environ.get('STRIPE_API_KEY')
|
|
121
|
+
if api_key:
|
|
122
|
+
logger.info(
|
|
123
|
+
'Using Stripe API key from environment variable'
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Fall back to Vault
|
|
127
|
+
if not api_key:
|
|
128
|
+
try:
|
|
129
|
+
vault_client = get_vault_client()
|
|
130
|
+
secret = await vault_client.read_secret(
|
|
131
|
+
'kv/codetether/stripe'
|
|
132
|
+
)
|
|
133
|
+
if secret and 'api_key' in secret:
|
|
134
|
+
api_key = secret['api_key']
|
|
135
|
+
logger.info('Using Stripe API key from Vault')
|
|
136
|
+
else:
|
|
137
|
+
logger.warning(
|
|
138
|
+
'Stripe API key not found in Vault at kv/codetether/stripe'
|
|
139
|
+
)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(
|
|
142
|
+
f'Failed to fetch Stripe API key from Vault: {e}'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not api_key:
|
|
146
|
+
raise BillingServiceError(
|
|
147
|
+
'Stripe API key not configured. Set STRIPE_API_KEY env var '
|
|
148
|
+
'or store in Vault at kv/codetether/stripe'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
stripe.api_key = api_key
|
|
152
|
+
self._api_key = api_key
|
|
153
|
+
self._initialized = True
|
|
154
|
+
logger.info('Stripe billing service initialized')
|
|
155
|
+
|
|
156
|
+
async def _run_sync(self, func, *args, **kwargs) -> Any:
|
|
157
|
+
"""Run a synchronous function in the thread pool executor."""
|
|
158
|
+
await self._ensure_initialized()
|
|
159
|
+
loop = asyncio.get_event_loop()
|
|
160
|
+
return await loop.run_in_executor(
|
|
161
|
+
_executor, lambda: func(*args, **kwargs)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# =========================================================================
|
|
165
|
+
# Customer Management
|
|
166
|
+
# =========================================================================
|
|
167
|
+
|
|
168
|
+
async def create_customer(
|
|
169
|
+
self, tenant_id: str, email: str, name: str
|
|
170
|
+
) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Create a new Stripe customer.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
tenant_id: Internal tenant/user ID for reference
|
|
176
|
+
email: Customer email address
|
|
177
|
+
name: Customer display name
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Stripe customer ID
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
customer = await self._run_sync(
|
|
184
|
+
stripe.Customer.create,
|
|
185
|
+
email=email,
|
|
186
|
+
name=name,
|
|
187
|
+
metadata={'tenant_id': tenant_id},
|
|
188
|
+
)
|
|
189
|
+
logger.info(
|
|
190
|
+
f'Created Stripe customer {customer.id} for tenant {tenant_id}'
|
|
191
|
+
)
|
|
192
|
+
return customer.id
|
|
193
|
+
except stripe.StripeError as e:
|
|
194
|
+
logger.error(f'Failed to create Stripe customer: {e}')
|
|
195
|
+
raise BillingServiceError(f'Failed to create customer: {e}')
|
|
196
|
+
|
|
197
|
+
async def get_customer(self, customer_id: str) -> Dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
Get customer details.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
customer_id: Stripe customer ID
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Customer data dictionary
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
customer = await self._run_sync(
|
|
209
|
+
stripe.Customer.retrieve, customer_id
|
|
210
|
+
)
|
|
211
|
+
if customer.deleted:
|
|
212
|
+
raise CustomerNotFoundError(
|
|
213
|
+
f'Customer {customer_id} has been deleted'
|
|
214
|
+
)
|
|
215
|
+
return {
|
|
216
|
+
'id': customer.id,
|
|
217
|
+
'email': customer.email,
|
|
218
|
+
'name': customer.name,
|
|
219
|
+
'metadata': dict(customer.metadata)
|
|
220
|
+
if customer.metadata
|
|
221
|
+
else {},
|
|
222
|
+
'created': customer.created,
|
|
223
|
+
'default_source': customer.default_source,
|
|
224
|
+
}
|
|
225
|
+
except stripe.InvalidRequestError as e:
|
|
226
|
+
if 'No such customer' in str(e):
|
|
227
|
+
raise CustomerNotFoundError(f'Customer {customer_id} not found')
|
|
228
|
+
raise BillingServiceError(f'Failed to get customer: {e}')
|
|
229
|
+
except stripe.StripeError as e:
|
|
230
|
+
logger.error(f'Failed to get customer {customer_id}: {e}')
|
|
231
|
+
raise BillingServiceError(f'Failed to get customer: {e}')
|
|
232
|
+
|
|
233
|
+
async def update_customer(
|
|
234
|
+
self,
|
|
235
|
+
customer_id: str,
|
|
236
|
+
email: Optional[str] = None,
|
|
237
|
+
name: Optional[str] = None,
|
|
238
|
+
) -> Dict[str, Any]:
|
|
239
|
+
"""
|
|
240
|
+
Update customer details.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
customer_id: Stripe customer ID
|
|
244
|
+
email: New email address (optional)
|
|
245
|
+
name: New display name (optional)
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Updated customer data dictionary
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
update_params = {}
|
|
252
|
+
if email is not None:
|
|
253
|
+
update_params['email'] = email
|
|
254
|
+
if name is not None:
|
|
255
|
+
update_params['name'] = name
|
|
256
|
+
|
|
257
|
+
if not update_params:
|
|
258
|
+
return await self.get_customer(customer_id)
|
|
259
|
+
|
|
260
|
+
customer = await self._run_sync(
|
|
261
|
+
stripe.Customer.modify, customer_id, **update_params
|
|
262
|
+
)
|
|
263
|
+
logger.info(f'Updated Stripe customer {customer_id}')
|
|
264
|
+
return {
|
|
265
|
+
'id': customer.id,
|
|
266
|
+
'email': customer.email,
|
|
267
|
+
'name': customer.name,
|
|
268
|
+
'metadata': dict(customer.metadata)
|
|
269
|
+
if customer.metadata
|
|
270
|
+
else {},
|
|
271
|
+
'created': customer.created,
|
|
272
|
+
}
|
|
273
|
+
except stripe.InvalidRequestError as e:
|
|
274
|
+
if 'No such customer' in str(e):
|
|
275
|
+
raise CustomerNotFoundError(f'Customer {customer_id} not found')
|
|
276
|
+
raise BillingServiceError(f'Failed to update customer: {e}')
|
|
277
|
+
except stripe.StripeError as e:
|
|
278
|
+
logger.error(f'Failed to update customer {customer_id}: {e}')
|
|
279
|
+
raise BillingServiceError(f'Failed to update customer: {e}')
|
|
280
|
+
|
|
281
|
+
# =========================================================================
|
|
282
|
+
# Subscription Management
|
|
283
|
+
# =========================================================================
|
|
284
|
+
|
|
285
|
+
async def create_subscription(
|
|
286
|
+
self,
|
|
287
|
+
customer_id: str,
|
|
288
|
+
price_id: str,
|
|
289
|
+
trial_days: int = 14,
|
|
290
|
+
) -> Dict[str, Any]:
|
|
291
|
+
"""
|
|
292
|
+
Create a new subscription for a customer.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
customer_id: Stripe customer ID
|
|
296
|
+
price_id: Stripe price ID for the plan
|
|
297
|
+
trial_days: Number of trial days (default 14)
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Subscription data dictionary
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
subscription_params = {
|
|
304
|
+
'customer': customer_id,
|
|
305
|
+
'items': [{'price': price_id}],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if trial_days > 0:
|
|
309
|
+
subscription_params['trial_period_days'] = trial_days
|
|
310
|
+
|
|
311
|
+
subscription = await self._run_sync(
|
|
312
|
+
stripe.Subscription.create, **subscription_params
|
|
313
|
+
)
|
|
314
|
+
logger.info(
|
|
315
|
+
f'Created subscription {subscription.id} for customer {customer_id}'
|
|
316
|
+
)
|
|
317
|
+
return self._format_subscription(subscription)
|
|
318
|
+
except stripe.InvalidRequestError as e:
|
|
319
|
+
if 'No such customer' in str(e):
|
|
320
|
+
raise CustomerNotFoundError(f'Customer {customer_id} not found')
|
|
321
|
+
raise BillingServiceError(f'Failed to create subscription: {e}')
|
|
322
|
+
except stripe.StripeError as e:
|
|
323
|
+
logger.error(f'Failed to create subscription: {e}')
|
|
324
|
+
raise BillingServiceError(f'Failed to create subscription: {e}')
|
|
325
|
+
|
|
326
|
+
async def cancel_subscription(
|
|
327
|
+
self, subscription_id: str, at_period_end: bool = True
|
|
328
|
+
) -> Dict[str, Any]:
|
|
329
|
+
"""
|
|
330
|
+
Cancel a subscription.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
subscription_id: Stripe subscription ID
|
|
334
|
+
at_period_end: If True, cancel at end of billing period.
|
|
335
|
+
If False, cancel immediately.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Updated subscription data dictionary
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
if at_period_end:
|
|
342
|
+
subscription = await self._run_sync(
|
|
343
|
+
stripe.Subscription.modify,
|
|
344
|
+
subscription_id,
|
|
345
|
+
cancel_at_period_end=True,
|
|
346
|
+
)
|
|
347
|
+
else:
|
|
348
|
+
subscription = await self._run_sync(
|
|
349
|
+
stripe.Subscription.cancel, subscription_id
|
|
350
|
+
)
|
|
351
|
+
logger.info(
|
|
352
|
+
f'Cancelled subscription {subscription_id} '
|
|
353
|
+
f'(at_period_end={at_period_end})'
|
|
354
|
+
)
|
|
355
|
+
return self._format_subscription(subscription)
|
|
356
|
+
except stripe.InvalidRequestError as e:
|
|
357
|
+
if 'No such subscription' in str(e):
|
|
358
|
+
raise SubscriptionNotFoundError(
|
|
359
|
+
f'Subscription {subscription_id} not found'
|
|
360
|
+
)
|
|
361
|
+
raise BillingServiceError(f'Failed to cancel subscription: {e}')
|
|
362
|
+
except stripe.StripeError as e:
|
|
363
|
+
logger.error(
|
|
364
|
+
f'Failed to cancel subscription {subscription_id}: {e}'
|
|
365
|
+
)
|
|
366
|
+
raise BillingServiceError(f'Failed to cancel subscription: {e}')
|
|
367
|
+
|
|
368
|
+
async def update_subscription(
|
|
369
|
+
self, subscription_id: str, new_price_id: str
|
|
370
|
+
) -> Dict[str, Any]:
|
|
371
|
+
"""
|
|
372
|
+
Update a subscription to a new plan/price.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
subscription_id: Stripe subscription ID
|
|
376
|
+
new_price_id: New Stripe price ID
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Updated subscription data dictionary
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
# Get current subscription to find the item ID
|
|
383
|
+
subscription = await self._run_sync(
|
|
384
|
+
stripe.Subscription.retrieve, subscription_id
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if not subscription.items.data:
|
|
388
|
+
raise BillingServiceError('Subscription has no items')
|
|
389
|
+
|
|
390
|
+
item_id = subscription.items.data[0].id
|
|
391
|
+
|
|
392
|
+
# Update the subscription item with new price
|
|
393
|
+
updated_subscription = await self._run_sync(
|
|
394
|
+
stripe.Subscription.modify,
|
|
395
|
+
subscription_id,
|
|
396
|
+
items=[{'id': item_id, 'price': new_price_id}],
|
|
397
|
+
proration_behavior='create_prorations',
|
|
398
|
+
)
|
|
399
|
+
logger.info(
|
|
400
|
+
f'Updated subscription {subscription_id} to price {new_price_id}'
|
|
401
|
+
)
|
|
402
|
+
return self._format_subscription(updated_subscription)
|
|
403
|
+
except stripe.InvalidRequestError as e:
|
|
404
|
+
if 'No such subscription' in str(e):
|
|
405
|
+
raise SubscriptionNotFoundError(
|
|
406
|
+
f'Subscription {subscription_id} not found'
|
|
407
|
+
)
|
|
408
|
+
raise BillingServiceError(f'Failed to update subscription: {e}')
|
|
409
|
+
except stripe.StripeError as e:
|
|
410
|
+
logger.error(
|
|
411
|
+
f'Failed to update subscription {subscription_id}: {e}'
|
|
412
|
+
)
|
|
413
|
+
raise BillingServiceError(f'Failed to update subscription: {e}')
|
|
414
|
+
|
|
415
|
+
async def get_subscription(self, subscription_id: str) -> Dict[str, Any]:
|
|
416
|
+
"""
|
|
417
|
+
Get subscription details.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
subscription_id: Stripe subscription ID
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Subscription data dictionary
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
subscription = await self._run_sync(
|
|
427
|
+
stripe.Subscription.retrieve, subscription_id
|
|
428
|
+
)
|
|
429
|
+
return self._format_subscription(subscription)
|
|
430
|
+
except stripe.InvalidRequestError as e:
|
|
431
|
+
if 'No such subscription' in str(e):
|
|
432
|
+
raise SubscriptionNotFoundError(
|
|
433
|
+
f'Subscription {subscription_id} not found'
|
|
434
|
+
)
|
|
435
|
+
raise BillingServiceError(f'Failed to get subscription: {e}')
|
|
436
|
+
except stripe.StripeError as e:
|
|
437
|
+
logger.error(f'Failed to get subscription {subscription_id}: {e}')
|
|
438
|
+
raise BillingServiceError(f'Failed to get subscription: {e}')
|
|
439
|
+
|
|
440
|
+
def _format_subscription(
|
|
441
|
+
self, subscription: stripe.Subscription
|
|
442
|
+
) -> Dict[str, Any]:
|
|
443
|
+
"""Format a Stripe subscription object to a dictionary."""
|
|
444
|
+
items = []
|
|
445
|
+
for item in subscription.items.data:
|
|
446
|
+
items.append(
|
|
447
|
+
{
|
|
448
|
+
'id': item.id,
|
|
449
|
+
'price_id': item.price.id,
|
|
450
|
+
'product_id': item.price.product,
|
|
451
|
+
'quantity': item.quantity,
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
'id': subscription.id,
|
|
457
|
+
'customer_id': subscription.customer,
|
|
458
|
+
'status': subscription.status,
|
|
459
|
+
'current_period_start': subscription.current_period_start,
|
|
460
|
+
'current_period_end': subscription.current_period_end,
|
|
461
|
+
'cancel_at_period_end': subscription.cancel_at_period_end,
|
|
462
|
+
'canceled_at': subscription.canceled_at,
|
|
463
|
+
'trial_start': subscription.trial_start,
|
|
464
|
+
'trial_end': subscription.trial_end,
|
|
465
|
+
'items': items,
|
|
466
|
+
'metadata': dict(subscription.metadata)
|
|
467
|
+
if subscription.metadata
|
|
468
|
+
else {},
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
# =========================================================================
|
|
472
|
+
# Billing Portal
|
|
473
|
+
# =========================================================================
|
|
474
|
+
|
|
475
|
+
async def create_billing_portal_session(
|
|
476
|
+
self, customer_id: str, return_url: str
|
|
477
|
+
) -> str:
|
|
478
|
+
"""
|
|
479
|
+
Create a Stripe Billing Portal session.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
customer_id: Stripe customer ID
|
|
483
|
+
return_url: URL to return to after the session
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Portal session URL
|
|
487
|
+
"""
|
|
488
|
+
try:
|
|
489
|
+
session = await self._run_sync(
|
|
490
|
+
stripe.billing_portal.Session.create,
|
|
491
|
+
customer=customer_id,
|
|
492
|
+
return_url=return_url,
|
|
493
|
+
)
|
|
494
|
+
logger.info(
|
|
495
|
+
f'Created billing portal session for customer {customer_id}'
|
|
496
|
+
)
|
|
497
|
+
return session.url
|
|
498
|
+
except stripe.InvalidRequestError as e:
|
|
499
|
+
if 'No such customer' in str(e):
|
|
500
|
+
raise CustomerNotFoundError(f'Customer {customer_id} not found')
|
|
501
|
+
raise BillingServiceError(
|
|
502
|
+
f'Failed to create billing portal session: {e}'
|
|
503
|
+
)
|
|
504
|
+
except stripe.StripeError as e:
|
|
505
|
+
logger.error(
|
|
506
|
+
f'Failed to create billing portal session for {customer_id}: {e}'
|
|
507
|
+
)
|
|
508
|
+
raise BillingServiceError(
|
|
509
|
+
f'Failed to create billing portal session: {e}'
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def create_checkout_session(
|
|
513
|
+
self,
|
|
514
|
+
customer_id: str,
|
|
515
|
+
price_id: str,
|
|
516
|
+
success_url: str,
|
|
517
|
+
cancel_url: str,
|
|
518
|
+
) -> str:
|
|
519
|
+
"""
|
|
520
|
+
Create a Stripe Checkout session for subscription purchase.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
customer_id: Stripe customer ID
|
|
524
|
+
price_id: Stripe price ID for the plan
|
|
525
|
+
success_url: URL to redirect on successful checkout
|
|
526
|
+
cancel_url: URL to redirect on cancelled checkout
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Checkout session URL
|
|
530
|
+
"""
|
|
531
|
+
try:
|
|
532
|
+
session = await self._run_sync(
|
|
533
|
+
stripe.checkout.Session.create,
|
|
534
|
+
customer=customer_id,
|
|
535
|
+
mode='subscription',
|
|
536
|
+
line_items=[{'price': price_id, 'quantity': 1}],
|
|
537
|
+
success_url=success_url,
|
|
538
|
+
cancel_url=cancel_url,
|
|
539
|
+
)
|
|
540
|
+
logger.info(
|
|
541
|
+
f'Created checkout session for customer {customer_id}, '
|
|
542
|
+
f'price {price_id}'
|
|
543
|
+
)
|
|
544
|
+
return session.url
|
|
545
|
+
except stripe.InvalidRequestError as e:
|
|
546
|
+
if 'No such customer' in str(e):
|
|
547
|
+
raise CustomerNotFoundError(f'Customer {customer_id} not found')
|
|
548
|
+
raise BillingServiceError(f'Failed to create checkout session: {e}')
|
|
549
|
+
except stripe.StripeError as e:
|
|
550
|
+
logger.error(
|
|
551
|
+
f'Failed to create checkout session for {customer_id}: {e}'
|
|
552
|
+
)
|
|
553
|
+
raise BillingServiceError(f'Failed to create checkout session: {e}')
|
|
554
|
+
|
|
555
|
+
# =========================================================================
|
|
556
|
+
# Plan Configuration
|
|
557
|
+
# =========================================================================
|
|
558
|
+
|
|
559
|
+
def get_plan(self, plan_name: str) -> Dict[str, Any]:
|
|
560
|
+
"""
|
|
561
|
+
Get plan configuration by name.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
plan_name: Plan name (free, pro, enterprise)
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Plan configuration dictionary
|
|
568
|
+
"""
|
|
569
|
+
plan = PLANS.get(plan_name.lower())
|
|
570
|
+
if not plan:
|
|
571
|
+
raise PlanNotFoundError(f'Plan {plan_name} not found')
|
|
572
|
+
return plan.copy()
|
|
573
|
+
|
|
574
|
+
def get_plan_limits(self, plan_name: str) -> Dict[str, int]:
|
|
575
|
+
"""
|
|
576
|
+
Get plan limits by name.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
plan_name: Plan name (free, pro, enterprise)
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
Plan limits dictionary
|
|
583
|
+
"""
|
|
584
|
+
plan = self.get_plan(plan_name)
|
|
585
|
+
return plan['limits'].copy()
|
|
586
|
+
|
|
587
|
+
def list_plans(self) -> Dict[str, Dict[str, Any]]:
|
|
588
|
+
"""
|
|
589
|
+
List all available plans.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
Dictionary of all plans
|
|
593
|
+
"""
|
|
594
|
+
return {name: plan.copy() for name, plan in PLANS.items()}
|
|
595
|
+
|
|
596
|
+
# =========================================================================
|
|
597
|
+
# Usage Tracking
|
|
598
|
+
# =========================================================================
|
|
599
|
+
|
|
600
|
+
async def report_usage(
|
|
601
|
+
self,
|
|
602
|
+
subscription_item_id: str,
|
|
603
|
+
quantity: int,
|
|
604
|
+
timestamp: Optional[int] = None,
|
|
605
|
+
) -> Dict[str, Any]:
|
|
606
|
+
"""
|
|
607
|
+
Report metered usage for a subscription item.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
subscription_item_id: Stripe subscription item ID
|
|
611
|
+
quantity: Usage quantity to report
|
|
612
|
+
timestamp: Unix timestamp for the usage (default: current time)
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Usage record data dictionary
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
usage_params = {
|
|
619
|
+
'quantity': quantity,
|
|
620
|
+
'action': 'increment',
|
|
621
|
+
}
|
|
622
|
+
if timestamp:
|
|
623
|
+
usage_params['timestamp'] = timestamp
|
|
624
|
+
else:
|
|
625
|
+
usage_params['timestamp'] = int(time.time())
|
|
626
|
+
|
|
627
|
+
usage_record = await self._run_sync(
|
|
628
|
+
stripe.SubscriptionItem.create_usage_record,
|
|
629
|
+
subscription_item_id,
|
|
630
|
+
**usage_params,
|
|
631
|
+
)
|
|
632
|
+
logger.info(
|
|
633
|
+
f'Reported usage for subscription item {subscription_item_id}: '
|
|
634
|
+
f'{quantity} units'
|
|
635
|
+
)
|
|
636
|
+
return {
|
|
637
|
+
'id': usage_record.id,
|
|
638
|
+
'quantity': usage_record.quantity,
|
|
639
|
+
'timestamp': usage_record.timestamp,
|
|
640
|
+
'subscription_item': usage_record.subscription_item,
|
|
641
|
+
}
|
|
642
|
+
except stripe.InvalidRequestError as e:
|
|
643
|
+
raise BillingServiceError(f'Failed to report usage: {e}')
|
|
644
|
+
except stripe.StripeError as e:
|
|
645
|
+
logger.error(
|
|
646
|
+
f'Failed to report usage for {subscription_item_id}: {e}'
|
|
647
|
+
)
|
|
648
|
+
raise BillingServiceError(f'Failed to report usage: {e}')
|
|
649
|
+
|
|
650
|
+
async def get_usage(
|
|
651
|
+
self,
|
|
652
|
+
subscription_item_id: str,
|
|
653
|
+
start_date: int,
|
|
654
|
+
end_date: int,
|
|
655
|
+
) -> Dict[str, Any]:
|
|
656
|
+
"""
|
|
657
|
+
Get usage records for a subscription item.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
subscription_item_id: Stripe subscription item ID
|
|
661
|
+
start_date: Unix timestamp for start of period
|
|
662
|
+
end_date: Unix timestamp for end of period
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Usage summary dictionary
|
|
666
|
+
"""
|
|
667
|
+
try:
|
|
668
|
+
# Use usage record summaries for the period
|
|
669
|
+
summaries = await self._run_sync(
|
|
670
|
+
stripe.SubscriptionItem.list_usage_record_summaries,
|
|
671
|
+
subscription_item_id,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
total_usage = 0
|
|
675
|
+
records = []
|
|
676
|
+
for summary in summaries.data:
|
|
677
|
+
# Filter by date range
|
|
678
|
+
if start_date <= summary.period.start <= end_date:
|
|
679
|
+
total_usage += summary.total_usage
|
|
680
|
+
records.append(
|
|
681
|
+
{
|
|
682
|
+
'id': summary.id,
|
|
683
|
+
'total_usage': summary.total_usage,
|
|
684
|
+
'period_start': summary.period.start,
|
|
685
|
+
'period_end': summary.period.end,
|
|
686
|
+
}
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
'subscription_item_id': subscription_item_id,
|
|
691
|
+
'start_date': start_date,
|
|
692
|
+
'end_date': end_date,
|
|
693
|
+
'total_usage': total_usage,
|
|
694
|
+
'records': records,
|
|
695
|
+
}
|
|
696
|
+
except stripe.InvalidRequestError as e:
|
|
697
|
+
raise BillingServiceError(f'Failed to get usage: {e}')
|
|
698
|
+
except stripe.StripeError as e:
|
|
699
|
+
logger.error(f'Failed to get usage for {subscription_item_id}: {e}')
|
|
700
|
+
raise BillingServiceError(f'Failed to get usage: {e}')
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# Singleton instance
|
|
704
|
+
_billing_service: Optional[BillingService] = None
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def get_billing_service() -> BillingService:
|
|
708
|
+
"""Get the singleton billing service instance."""
|
|
709
|
+
global _billing_service
|
|
710
|
+
if _billing_service is None:
|
|
711
|
+
_billing_service = BillingService()
|
|
712
|
+
return _billing_service
|