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,638 @@
1
+ """
2
+ Billing REST API for A2A Server.
3
+
4
+ Provides endpoints for subscription billing, customer management, and usage tracking:
5
+ - Setup Stripe customers for tenants
6
+ - Create checkout and billing portal sessions
7
+ - Manage subscriptions (view, cancel, change)
8
+ - Track usage metrics and invoices
9
+ """
10
+
11
+ import logging
12
+ from datetime import datetime
13
+ from typing import List, Optional
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Query
16
+ from pydantic import BaseModel, Field
17
+
18
+ from .billing_service import (
19
+ BillingService,
20
+ BillingServiceError,
21
+ CustomerNotFoundError,
22
+ PlanNotFoundError,
23
+ SubscriptionNotFoundError,
24
+ PLANS,
25
+ get_billing_service,
26
+ )
27
+ from .database import (
28
+ db_list_codebases,
29
+ db_list_tasks,
30
+ db_list_workers,
31
+ get_tenant_by_id,
32
+ update_tenant_stripe,
33
+ )
34
+ from .keycloak_auth import require_auth, UserSession
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ router = APIRouter(prefix='/v1/billing', tags=['billing'])
39
+
40
+
41
+ # ========================================
42
+ # Helper Functions
43
+ # ========================================
44
+
45
+
46
+ def get_plan_limits(plan: str) -> dict:
47
+ """Get the resource limits for a given plan.
48
+
49
+ Args:
50
+ plan: Plan identifier ('free', 'pro', 'enterprise')
51
+
52
+ Returns:
53
+ Dict with 'workers', 'codebases', 'tasks_per_month' limits (-1 = unlimited)
54
+ """
55
+ plan_data = PLANS.get(plan, PLANS['free'])
56
+ return plan_data['limits']
57
+
58
+
59
+ # ========================================
60
+ # Request/Response Models
61
+ # ========================================
62
+
63
+
64
+ class BillingSetupResponse(BaseModel):
65
+ """Response model for billing setup."""
66
+
67
+ customer_id: str = Field(..., description='Stripe customer ID')
68
+ has_payment_method: bool = Field(
69
+ ..., description='Whether customer has a default payment method'
70
+ )
71
+
72
+
73
+ class CheckoutRequest(BaseModel):
74
+ """Request model for checkout session creation."""
75
+
76
+ plan: str = Field(..., description='Plan to subscribe to (pro, enterprise)')
77
+ success_url: str = Field(
78
+ ..., description='URL to redirect to on successful checkout'
79
+ )
80
+ cancel_url: str = Field(
81
+ ..., description='URL to redirect to on cancelled checkout'
82
+ )
83
+
84
+
85
+ class CheckoutResponse(BaseModel):
86
+ """Response model for checkout session."""
87
+
88
+ checkout_url: str = Field(..., description='Stripe Checkout session URL')
89
+
90
+
91
+ class PortalRequest(BaseModel):
92
+ """Request model for billing portal session."""
93
+
94
+ return_url: str = Field(
95
+ ..., description='URL to return to after portal session'
96
+ )
97
+
98
+
99
+ class PortalResponse(BaseModel):
100
+ """Response model for billing portal session."""
101
+
102
+ portal_url: str = Field(..., description='Stripe Billing Portal URL')
103
+
104
+
105
+ class SubscriptionResponse(BaseModel):
106
+ """Response model for subscription details."""
107
+
108
+ plan: str = Field(..., description='Current plan name')
109
+ status: str = Field(
110
+ ...,
111
+ description='Subscription status (active, canceled, past_due, etc.)',
112
+ )
113
+ current_period_end: Optional[datetime] = Field(
114
+ None, description='End of current billing period'
115
+ )
116
+ cancel_at_period_end: bool = Field(
117
+ ..., description='Whether subscription will cancel at period end'
118
+ )
119
+
120
+
121
+ class CancelSubscriptionRequest(BaseModel):
122
+ """Request model for subscription cancellation."""
123
+
124
+ at_period_end: bool = Field(
125
+ default=True,
126
+ description='If True, cancel at end of billing period. If False, cancel immediately.',
127
+ )
128
+
129
+
130
+ class ChangeSubscriptionRequest(BaseModel):
131
+ """Request model for subscription plan change."""
132
+
133
+ new_plan: str = Field(
134
+ ..., description='New plan to switch to (free, pro, enterprise)'
135
+ )
136
+
137
+
138
+ class UsageResponse(BaseModel):
139
+ """Response model for usage metrics."""
140
+
141
+ tasks_used: int = Field(..., description='Tasks used in current period')
142
+ tasks_limit: int = Field(
143
+ ..., description='Task limit for plan (-1 = unlimited)'
144
+ )
145
+ workers_used: int = Field(..., description='Active workers')
146
+ workers_limit: int = Field(
147
+ ..., description='Worker limit for plan (-1 = unlimited)'
148
+ )
149
+ codebases_used: int = Field(..., description='Registered codebases')
150
+ codebases_limit: int = Field(
151
+ ..., description='Codebase limit for plan (-1 = unlimited)'
152
+ )
153
+
154
+
155
+ class InvoiceResponse(BaseModel):
156
+ """Response model for invoice."""
157
+
158
+ id: str = Field(..., description='Invoice ID')
159
+ number: Optional[str] = Field(None, description='Invoice number')
160
+ status: str = Field(
161
+ ..., description='Status (draft, open, paid, void, uncollectible)'
162
+ )
163
+ amount_due: int = Field(..., description='Amount due in cents')
164
+ amount_paid: int = Field(..., description='Amount paid in cents')
165
+ currency: str = Field(..., description='Currency code')
166
+ created: datetime = Field(..., description='Invoice creation date')
167
+ period_start: datetime = Field(..., description='Billing period start')
168
+ period_end: datetime = Field(..., description='Billing period end')
169
+ pdf_url: Optional[str] = Field(None, description='URL to download PDF')
170
+ hosted_invoice_url: Optional[str] = Field(
171
+ None, description='URL to view invoice online'
172
+ )
173
+
174
+
175
+ class InvoiceListResponse(BaseModel):
176
+ """Response model for invoice listing."""
177
+
178
+ invoices: List[InvoiceResponse]
179
+
180
+
181
+ # ========================================
182
+ # Endpoints
183
+ # ========================================
184
+
185
+
186
+ @router.post('/setup', response_model=BillingSetupResponse)
187
+ async def setup_billing(
188
+ user: UserSession = Depends(require_auth),
189
+ billing: BillingService = Depends(get_billing_service),
190
+ ):
191
+ """
192
+ Set up billing for the current tenant.
193
+
194
+ Creates a Stripe customer if one doesn't exist and links it to the tenant.
195
+ """
196
+ tenant_id = getattr(user, 'tenant_id', None)
197
+ if not tenant_id:
198
+ raise HTTPException(
199
+ status_code=400, detail='No tenant associated with this user'
200
+ )
201
+
202
+ # Get tenant details
203
+ tenant = await get_tenant_by_id(tenant_id)
204
+ if not tenant:
205
+ raise HTTPException(status_code=404, detail='Tenant not found')
206
+
207
+ # Check if customer already exists
208
+ customer_id = tenant.get('stripe_customer_id')
209
+ has_payment_method = False
210
+
211
+ if customer_id:
212
+ # Customer exists, check for payment method
213
+ try:
214
+ customer = await billing.get_customer(customer_id)
215
+ has_payment_method = bool(customer.get('default_source'))
216
+ except CustomerNotFoundError:
217
+ # Customer was deleted in Stripe, create new one
218
+ customer_id = None
219
+ except BillingServiceError as e:
220
+ logger.error(f'Failed to get customer: {e}')
221
+ raise HTTPException(status_code=500, detail=str(e))
222
+
223
+ if not customer_id:
224
+ # Create new customer
225
+ try:
226
+ customer_id = await billing.create_customer(
227
+ tenant_id=tenant_id,
228
+ email=user.email,
229
+ name=tenant.get('display_name') or user.name,
230
+ )
231
+ # Update tenant with customer ID
232
+ await update_tenant_stripe(
233
+ tenant_id=tenant_id,
234
+ customer_id=customer_id,
235
+ subscription_id=tenant.get('stripe_subscription_id') or '',
236
+ )
237
+ except BillingServiceError as e:
238
+ logger.error(f'Failed to create customer: {e}')
239
+ raise HTTPException(status_code=500, detail=str(e))
240
+
241
+ logger.info(f'Billing setup completed for tenant {tenant_id}')
242
+
243
+ return BillingSetupResponse(
244
+ customer_id=customer_id,
245
+ has_payment_method=has_payment_method,
246
+ )
247
+
248
+
249
+ @router.post('/checkout', response_model=CheckoutResponse)
250
+ async def create_checkout(
251
+ request: CheckoutRequest,
252
+ user: UserSession = Depends(require_auth),
253
+ billing: BillingService = Depends(get_billing_service),
254
+ ):
255
+ """
256
+ Create a Stripe Checkout session for subscription purchase.
257
+ """
258
+ tenant_id = getattr(user, 'tenant_id', None)
259
+ if not tenant_id:
260
+ raise HTTPException(
261
+ status_code=400, detail='No tenant associated with this user'
262
+ )
263
+
264
+ tenant = await get_tenant_by_id(tenant_id)
265
+ if not tenant:
266
+ raise HTTPException(status_code=404, detail='Tenant not found')
267
+
268
+ customer_id = tenant.get('stripe_customer_id')
269
+ if not customer_id:
270
+ raise HTTPException(
271
+ status_code=400,
272
+ detail='Billing not set up. Call /v1/billing/setup first.',
273
+ )
274
+
275
+ # Get plan price ID
276
+ try:
277
+ plan = billing.get_plan(request.plan)
278
+ price_id = plan.get('price_id')
279
+ if not price_id:
280
+ raise HTTPException(
281
+ status_code=400,
282
+ detail=f'Plan {request.plan} is not available for purchase',
283
+ )
284
+ except PlanNotFoundError:
285
+ raise HTTPException(
286
+ status_code=400, detail=f'Unknown plan: {request.plan}'
287
+ )
288
+
289
+ try:
290
+ checkout_url = await billing.create_checkout_session(
291
+ customer_id=customer_id,
292
+ price_id=price_id,
293
+ success_url=request.success_url,
294
+ cancel_url=request.cancel_url,
295
+ )
296
+ except CustomerNotFoundError:
297
+ raise HTTPException(
298
+ status_code=400, detail='Customer not found in Stripe'
299
+ )
300
+ except BillingServiceError as e:
301
+ logger.error(f'Failed to create checkout session: {e}')
302
+ raise HTTPException(status_code=500, detail=str(e))
303
+
304
+ return CheckoutResponse(checkout_url=checkout_url)
305
+
306
+
307
+ @router.post('/portal', response_model=PortalResponse)
308
+ async def create_portal(
309
+ request: PortalRequest,
310
+ user: UserSession = Depends(require_auth),
311
+ billing: BillingService = Depends(get_billing_service),
312
+ ):
313
+ """
314
+ Create a Stripe Billing Portal session.
315
+
316
+ Allows customers to manage payment methods, view invoices, and cancel subscriptions.
317
+ """
318
+ tenant_id = getattr(user, 'tenant_id', None)
319
+ if not tenant_id:
320
+ raise HTTPException(
321
+ status_code=400, detail='No tenant associated with this user'
322
+ )
323
+
324
+ tenant = await get_tenant_by_id(tenant_id)
325
+ if not tenant:
326
+ raise HTTPException(status_code=404, detail='Tenant not found')
327
+
328
+ customer_id = tenant.get('stripe_customer_id')
329
+ if not customer_id:
330
+ raise HTTPException(
331
+ status_code=400,
332
+ detail='Billing not set up. Call /v1/billing/setup first.',
333
+ )
334
+
335
+ try:
336
+ portal_url = await billing.create_billing_portal_session(
337
+ customer_id=customer_id,
338
+ return_url=request.return_url,
339
+ )
340
+ except CustomerNotFoundError:
341
+ raise HTTPException(
342
+ status_code=400, detail='Customer not found in Stripe'
343
+ )
344
+ except BillingServiceError as e:
345
+ logger.error(f'Failed to create portal session: {e}')
346
+ raise HTTPException(status_code=500, detail=str(e))
347
+
348
+ return PortalResponse(portal_url=portal_url)
349
+
350
+
351
+ @router.get('/subscription', response_model=SubscriptionResponse)
352
+ async def get_subscription(
353
+ user: UserSession = Depends(require_auth),
354
+ billing: BillingService = Depends(get_billing_service),
355
+ ):
356
+ """
357
+ Get the current subscription details for the tenant.
358
+ """
359
+ tenant_id = getattr(user, 'tenant_id', None)
360
+ if not tenant_id:
361
+ raise HTTPException(
362
+ status_code=400, detail='No tenant associated with this user'
363
+ )
364
+
365
+ tenant = await get_tenant_by_id(tenant_id)
366
+ if not tenant:
367
+ raise HTTPException(status_code=404, detail='Tenant not found')
368
+
369
+ # Check for Stripe subscription
370
+ subscription_id = tenant.get('stripe_subscription_id')
371
+ if subscription_id:
372
+ try:
373
+ sub = await billing.get_subscription(subscription_id)
374
+ # Determine plan from price ID
375
+ plan = tenant.get('plan', 'free')
376
+ if sub.get('items'):
377
+ price_id = sub['items'][0].get('price_id')
378
+ for plan_name, plan_data in PLANS.items():
379
+ if plan_data.get('price_id') == price_id:
380
+ plan = plan_name
381
+ break
382
+
383
+ current_period_end = sub.get('current_period_end')
384
+ if current_period_end and isinstance(current_period_end, int):
385
+ current_period_end = datetime.fromtimestamp(current_period_end)
386
+
387
+ return SubscriptionResponse(
388
+ plan=plan,
389
+ status=sub.get('status', 'active'),
390
+ current_period_end=current_period_end,
391
+ cancel_at_period_end=sub.get('cancel_at_period_end', False),
392
+ )
393
+ except SubscriptionNotFoundError:
394
+ pass # Fall through to return free plan
395
+ except BillingServiceError as e:
396
+ logger.error(f'Failed to get subscription: {e}')
397
+ raise HTTPException(status_code=500, detail=str(e))
398
+
399
+ # No active subscription - return free plan
400
+ return SubscriptionResponse(
401
+ plan=tenant.get('plan', 'free'),
402
+ status='active',
403
+ current_period_end=None,
404
+ cancel_at_period_end=False,
405
+ )
406
+
407
+
408
+ @router.post('/subscription/cancel', response_model=SubscriptionResponse)
409
+ async def cancel_subscription(
410
+ request: CancelSubscriptionRequest,
411
+ user: UserSession = Depends(require_auth),
412
+ billing: BillingService = Depends(get_billing_service),
413
+ ):
414
+ """
415
+ Cancel the current subscription.
416
+ """
417
+ tenant_id = getattr(user, 'tenant_id', None)
418
+ if not tenant_id:
419
+ raise HTTPException(
420
+ status_code=400, detail='No tenant associated with this user'
421
+ )
422
+
423
+ tenant = await get_tenant_by_id(tenant_id)
424
+ if not tenant:
425
+ raise HTTPException(status_code=404, detail='Tenant not found')
426
+
427
+ subscription_id = tenant.get('stripe_subscription_id')
428
+ if not subscription_id:
429
+ raise HTTPException(
430
+ status_code=400, detail='No active subscription to cancel'
431
+ )
432
+
433
+ try:
434
+ sub = await billing.cancel_subscription(
435
+ subscription_id=subscription_id,
436
+ at_period_end=request.at_period_end,
437
+ )
438
+
439
+ current_period_end = sub.get('current_period_end')
440
+ if current_period_end and isinstance(current_period_end, int):
441
+ current_period_end = datetime.fromtimestamp(current_period_end)
442
+
443
+ # Determine plan from price ID
444
+ plan = tenant.get('plan', 'free')
445
+ if sub.get('items'):
446
+ price_id = sub['items'][0].get('price_id')
447
+ for plan_name, plan_data in PLANS.items():
448
+ if plan_data.get('price_id') == price_id:
449
+ plan = plan_name
450
+ break
451
+
452
+ logger.info(
453
+ f'Subscription {subscription_id} cancelled for tenant {tenant_id}'
454
+ )
455
+
456
+ return SubscriptionResponse(
457
+ plan=plan,
458
+ status=sub.get('status', 'canceled'),
459
+ current_period_end=current_period_end,
460
+ cancel_at_period_end=sub.get('cancel_at_period_end', True),
461
+ )
462
+ except SubscriptionNotFoundError:
463
+ raise HTTPException(status_code=404, detail='Subscription not found')
464
+ except BillingServiceError as e:
465
+ logger.error(f'Failed to cancel subscription: {e}')
466
+ raise HTTPException(status_code=500, detail=str(e))
467
+
468
+
469
+ @router.post('/subscription/change', response_model=SubscriptionResponse)
470
+ async def change_subscription(
471
+ request: ChangeSubscriptionRequest,
472
+ user: UserSession = Depends(require_auth),
473
+ billing: BillingService = Depends(get_billing_service),
474
+ ):
475
+ """
476
+ Change the subscription to a new plan.
477
+ """
478
+ tenant_id = getattr(user, 'tenant_id', None)
479
+ if not tenant_id:
480
+ raise HTTPException(
481
+ status_code=400, detail='No tenant associated with this user'
482
+ )
483
+
484
+ tenant = await get_tenant_by_id(tenant_id)
485
+ if not tenant:
486
+ raise HTTPException(status_code=404, detail='Tenant not found')
487
+
488
+ subscription_id = tenant.get('stripe_subscription_id')
489
+ if not subscription_id:
490
+ raise HTTPException(
491
+ status_code=400,
492
+ detail='No active subscription. Use /v1/billing/checkout to subscribe.',
493
+ )
494
+
495
+ # Get new plan price ID
496
+ try:
497
+ plan = billing.get_plan(request.new_plan)
498
+ new_price_id = plan.get('price_id')
499
+ if not new_price_id:
500
+ raise HTTPException(
501
+ status_code=400,
502
+ detail=f'Plan {request.new_plan} is not available for purchase',
503
+ )
504
+ except PlanNotFoundError:
505
+ raise HTTPException(
506
+ status_code=400, detail=f'Unknown plan: {request.new_plan}'
507
+ )
508
+
509
+ try:
510
+ sub = await billing.update_subscription(
511
+ subscription_id=subscription_id,
512
+ new_price_id=new_price_id,
513
+ )
514
+
515
+ current_period_end = sub.get('current_period_end')
516
+ if current_period_end and isinstance(current_period_end, int):
517
+ current_period_end = datetime.fromtimestamp(current_period_end)
518
+
519
+ logger.info(
520
+ f'Subscription {subscription_id} changed to {request.new_plan} '
521
+ f'for tenant {tenant_id}'
522
+ )
523
+
524
+ return SubscriptionResponse(
525
+ plan=request.new_plan,
526
+ status=sub.get('status', 'active'),
527
+ current_period_end=current_period_end,
528
+ cancel_at_period_end=sub.get('cancel_at_period_end', False),
529
+ )
530
+ except SubscriptionNotFoundError:
531
+ raise HTTPException(status_code=404, detail='Subscription not found')
532
+ except BillingServiceError as e:
533
+ logger.error(f'Failed to change subscription: {e}')
534
+ raise HTTPException(status_code=500, detail=str(e))
535
+
536
+
537
+ @router.get('/usage', response_model=UsageResponse)
538
+ async def get_usage(
539
+ user: UserSession = Depends(require_auth),
540
+ ):
541
+ """
542
+ Get usage metrics for the current billing period.
543
+ """
544
+ tenant_id = getattr(user, 'tenant_id', None)
545
+ if not tenant_id:
546
+ raise HTTPException(
547
+ status_code=400, detail='No tenant associated with this user'
548
+ )
549
+
550
+ tenant = await get_tenant_by_id(tenant_id)
551
+ if not tenant:
552
+ raise HTTPException(status_code=404, detail='Tenant not found')
553
+
554
+ # Get plan limits
555
+ plan = tenant.get('plan', 'free')
556
+ limits = get_plan_limits(plan)
557
+
558
+ # Count current usage
559
+ try:
560
+ workers = await db_list_workers(tenant_id=tenant_id)
561
+ codebases = await db_list_codebases(tenant_id=tenant_id)
562
+ tasks = await db_list_tasks(tenant_id=tenant_id, limit=10000)
563
+ except Exception as e:
564
+ logger.error(f'Failed to get usage metrics: {e}')
565
+ raise HTTPException(status_code=500, detail='Failed to get usage data')
566
+
567
+ return UsageResponse(
568
+ tasks_used=len(tasks),
569
+ tasks_limit=limits.get('tasks_per_month', 100),
570
+ workers_used=len(workers),
571
+ workers_limit=limits.get('workers', 1),
572
+ codebases_used=len(codebases),
573
+ codebases_limit=limits.get('codebases', 3),
574
+ )
575
+
576
+
577
+ @router.get('/invoices', response_model=InvoiceListResponse)
578
+ async def get_invoices(
579
+ limit: int = Query(default=10, ge=1, le=100, description='Max invoices'),
580
+ user: UserSession = Depends(require_auth),
581
+ billing: BillingService = Depends(get_billing_service),
582
+ ):
583
+ """
584
+ Get invoices for the tenant.
585
+ """
586
+ tenant_id = getattr(user, 'tenant_id', None)
587
+ if not tenant_id:
588
+ raise HTTPException(
589
+ status_code=400, detail='No tenant associated with this user'
590
+ )
591
+
592
+ tenant = await get_tenant_by_id(tenant_id)
593
+ if not tenant:
594
+ raise HTTPException(status_code=404, detail='Tenant not found')
595
+
596
+ customer_id = tenant.get('stripe_customer_id')
597
+ if not customer_id:
598
+ # No billing set up, return empty list
599
+ return InvoiceListResponse(invoices=[])
600
+
601
+ try:
602
+ # Fetch invoices from Stripe
603
+ import stripe
604
+
605
+ await billing._ensure_initialized()
606
+ invoices_response = stripe.Invoice.list(
607
+ customer=customer_id,
608
+ limit=limit,
609
+ )
610
+
611
+ invoices = []
612
+ for inv in invoices_response.data:
613
+ invoices.append(
614
+ InvoiceResponse(
615
+ id=inv.id,
616
+ number=inv.number,
617
+ status=inv.status,
618
+ amount_due=inv.amount_due,
619
+ amount_paid=inv.amount_paid,
620
+ currency=inv.currency,
621
+ created=datetime.fromtimestamp(inv.created),
622
+ period_start=datetime.fromtimestamp(inv.period_start),
623
+ period_end=datetime.fromtimestamp(inv.period_end),
624
+ pdf_url=inv.invoice_pdf,
625
+ hosted_invoice_url=inv.hosted_invoice_url,
626
+ )
627
+ )
628
+
629
+ return InvoiceListResponse(invoices=invoices)
630
+
631
+ except CustomerNotFoundError:
632
+ return InvoiceListResponse(invoices=[])
633
+ except BillingServiceError as e:
634
+ logger.error(f'Failed to get invoices: {e}')
635
+ raise HTTPException(status_code=500, detail=str(e))
636
+ except Exception as e:
637
+ logger.error(f'Failed to get invoices: {e}')
638
+ return InvoiceListResponse(invoices=[])