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,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=[])
|