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.
Files changed (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. 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