mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1607 from escapade-mckv/billing-improvements-clean
Billing improvements clean
This commit is contained in:
commit
4a56636dc3
|
@ -74,6 +74,7 @@ async def advanced_user_search(
|
|||
'''
|
||||
id,
|
||||
created_at,
|
||||
primary_owner_user_id,
|
||||
billing_customers(email),
|
||||
billing_subscriptions(status)
|
||||
'''
|
||||
|
@ -168,10 +169,21 @@ async def advanced_user_search(
|
|||
subscription_status = item['billing_subscriptions'][0].get('status')
|
||||
|
||||
credit_account = credit_accounts.get(item['id'], {})
|
||||
|
||||
email = 'N/A'
|
||||
if item.get('billing_customers') and item['billing_customers'][0].get('email'):
|
||||
email = item['billing_customers'][0]['email']
|
||||
elif item.get('primary_owner_user_id'):
|
||||
try:
|
||||
user_email_result = await client.rpc('get_user_email', {'user_id': item['primary_owner_user_id']}).execute()
|
||||
if user_email_result.data:
|
||||
email = user_email_result.data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get email for account {item['id']}: {e}")
|
||||
|
||||
users.append(UserSummary(
|
||||
id=item['id'],
|
||||
email=item['billing_customers'][0]['email'] if item.get('billing_customers') else 'N/A',
|
||||
email=email,
|
||||
created_at=datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')),
|
||||
tier=credit_account.get('tier', 'free'),
|
||||
credit_balance=float(credit_account.get('balance', 0)),
|
||||
|
@ -227,6 +239,7 @@ async def list_users(
|
|||
'''
|
||||
id,
|
||||
created_at,
|
||||
primary_owner_user_id,
|
||||
billing_customers(email),
|
||||
billing_subscriptions(status)
|
||||
'''
|
||||
|
@ -238,6 +251,7 @@ async def list_users(
|
|||
'''
|
||||
id,
|
||||
created_at,
|
||||
primary_owner_user_id,
|
||||
billing_customers(email),
|
||||
billing_subscriptions(status)
|
||||
'''
|
||||
|
@ -305,9 +319,22 @@ async def list_users(
|
|||
|
||||
credit_account = credit_accounts.get(item['id'], {})
|
||||
|
||||
# Get email from billing_customers or fetch from auth.users for OAuth users
|
||||
email = 'N/A'
|
||||
if item.get('billing_customers') and item['billing_customers'][0].get('email'):
|
||||
email = item['billing_customers'][0]['email']
|
||||
elif item.get('primary_owner_user_id'):
|
||||
# Try to get email for OAuth users
|
||||
try:
|
||||
user_email_result = await client.rpc('get_user_email', {'user_id': item['primary_owner_user_id']}).execute()
|
||||
if user_email_result.data:
|
||||
email = user_email_result.data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get email for account {item['id']}: {e}")
|
||||
|
||||
users.append(UserSummary(
|
||||
id=item['id'],
|
||||
email=item['billing_customers'][0]['email'] if item.get('billing_customers') else 'N/A',
|
||||
email=email,
|
||||
created_at=datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')),
|
||||
tier=credit_account.get('tier', 'free'),
|
||||
credit_balance=float(credit_account.get('balance', 0)),
|
||||
|
@ -340,6 +367,7 @@ async def get_user_details(
|
|||
'''
|
||||
id,
|
||||
created_at,
|
||||
primary_owner_user_id,
|
||||
billing_customers(email),
|
||||
billing_subscriptions(status, created, current_period_end)
|
||||
'''
|
||||
|
@ -350,6 +378,18 @@ async def get_user_details(
|
|||
|
||||
account = account_result.data[0]
|
||||
|
||||
# Get email if not present (OAuth users)
|
||||
if not account.get('billing_customers') or not account['billing_customers'][0].get('email'):
|
||||
if account.get('primary_owner_user_id'):
|
||||
try:
|
||||
user_email_result = await client.rpc('get_user_email', {'user_id': account['primary_owner_user_id']}).execute()
|
||||
if user_email_result.data:
|
||||
if not account.get('billing_customers'):
|
||||
account['billing_customers'] = [{}]
|
||||
account['billing_customers'][0]['email'] = user_email_result.data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get email for account {user_id}: {e}")
|
||||
|
||||
credit_result = await client.from_('credit_accounts').select(
|
||||
'balance, tier, lifetime_granted, lifetime_purchased, lifetime_used, last_grant_date'
|
||||
).eq('account_id', user_id).execute()
|
||||
|
|
|
@ -55,7 +55,19 @@ async def adjust_user_credits(
|
|||
description=f"Admin adjustment: {request.reason}",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=30) if request.is_expiring else None
|
||||
)
|
||||
new_balance = result['total_balance']
|
||||
if result.get('duplicate_prevented'):
|
||||
logger.info(f"[ADMIN] Duplicate credit adjustment prevented for {request.account_id}")
|
||||
balance_info = await credit_manager.get_balance(request.account_id)
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Credit adjustment already processed (duplicate prevented)',
|
||||
'new_balance': float(balance_info.get('total', 0)),
|
||||
'adjustment_amount': float(request.amount),
|
||||
'is_expiring': request.is_expiring,
|
||||
'duplicate_prevented': True
|
||||
}
|
||||
else:
|
||||
new_balance = result.get('total_balance', 0)
|
||||
else:
|
||||
result = await credit_manager.use_credits(
|
||||
account_id=request.account_id,
|
||||
|
@ -113,10 +125,15 @@ async def grant_credits_to_users(
|
|||
description=f"Admin grant: {request.reason}",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=30) if request.is_expiring else None
|
||||
)
|
||||
if result.get('duplicate_prevented'):
|
||||
balance_info = await credit_manager.get_balance(account_id)
|
||||
new_balance = balance_info.get('total', 0)
|
||||
else:
|
||||
new_balance = result.get('total_balance', 0)
|
||||
results.append({
|
||||
'account_id': account_id,
|
||||
'success': True,
|
||||
'new_balance': result['total_balance']
|
||||
'new_balance': new_balance
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
|
@ -150,6 +167,12 @@ async def process_refund(
|
|||
type='admin_grant'
|
||||
)
|
||||
|
||||
if result.get('duplicate_prevented'):
|
||||
balance_info = await credit_manager.get_balance(request.account_id)
|
||||
new_balance = balance_info.get('total_balance', 0)
|
||||
else:
|
||||
new_balance = result.get('total_balance', 0)
|
||||
|
||||
refund_id = None
|
||||
if request.stripe_refund and request.payment_intent_id:
|
||||
try:
|
||||
|
@ -168,7 +191,7 @@ async def process_refund(
|
|||
|
||||
return {
|
||||
'success': True,
|
||||
'new_balance': float(result['total_balance']),
|
||||
'new_balance': float(new_balance),
|
||||
'refund_amount': float(request.amount),
|
||||
'stripe_refund_id': refund_id,
|
||||
'is_expiring': request.is_expiring
|
||||
|
|
|
@ -42,9 +42,29 @@ class SubscriptionService:
|
|||
|
||||
account = account_result.data[0]
|
||||
user_id = account['primary_owner_user_id']
|
||||
|
||||
email = None
|
||||
|
||||
user_result = await client.auth.admin.get_user_by_id(user_id)
|
||||
email = user_result.user.email if user_result and user_result.user else None
|
||||
try:
|
||||
user_result = await client.auth.admin.get_user_by_id(user_id)
|
||||
email = user_result.user.email if user_result and user_result.user else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get user via auth.admin API for user {user_id}: {e}")
|
||||
|
||||
if not email:
|
||||
try:
|
||||
user_email_result = await client.rpc('get_user_email', {'user_id': user_id}).execute()
|
||||
if user_email_result.data:
|
||||
email = user_email_result.data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get email via RPC for user {user_id}: {e}")
|
||||
|
||||
if not email:
|
||||
logger.error(f"Could not find email for user {user_id} / account {account_id}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Unable to retrieve user email. Please ensure your account has a valid email address."
|
||||
)
|
||||
|
||||
customer = await stripe.Customer.create_async(
|
||||
email=email,
|
||||
|
@ -57,7 +77,7 @@ class SubscriptionService:
|
|||
'email': email
|
||||
}).execute()
|
||||
|
||||
logger.info(f"Created Stripe customer {customer.id} for account {account_id}")
|
||||
logger.info(f"Created Stripe customer {customer.id} for account {account_id} with email {email}")
|
||||
return customer.id
|
||||
|
||||
async def get_subscription(self, account_id: str) -> Dict:
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
-- NOTE: This migration has been rolled back by 20250910103000_rollback_oauth_email_fix.sql
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_user_email(user_id UUID)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
SELECT email INTO user_email
|
||||
FROM auth.users
|
||||
WHERE id = user_id;
|
||||
|
||||
IF user_email IS NULL THEN
|
||||
SELECT
|
||||
COALESCE(
|
||||
raw_user_meta_data->>'email',
|
||||
raw_user_meta_data->>'user_email',
|
||||
email
|
||||
) INTO user_email
|
||||
FROM auth.users
|
||||
WHERE id = user_id;
|
||||
END IF;
|
||||
|
||||
RETURN user_email;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_user_email(UUID) TO authenticated, service_role;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT bc.account_id, a.primary_owner_user_id
|
||||
FROM basejump.billing_customers bc
|
||||
JOIN basejump.accounts a ON bc.account_id = a.id
|
||||
WHERE bc.email IS NULL OR bc.email = ''
|
||||
LOOP
|
||||
user_email := public.get_user_email(rec.primary_owner_user_id);
|
||||
|
||||
IF user_email IS NOT NULL THEN
|
||||
UPDATE basejump.billing_customers
|
||||
SET email = user_email
|
||||
WHERE account_id = rec.account_id;
|
||||
|
||||
RAISE NOTICE 'Updated email for account %: %', rec.account_id, user_email;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_users_email ON auth.users(email);
|
||||
|
||||
CREATE OR REPLACE FUNCTION basejump.ensure_billing_customer_email()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
owner_id UUID;
|
||||
BEGIN
|
||||
IF NEW.email IS NULL OR NEW.email = '' THEN
|
||||
SELECT primary_owner_user_id INTO owner_id
|
||||
FROM basejump.accounts
|
||||
WHERE id = NEW.account_id;
|
||||
|
||||
user_email := public.get_user_email(owner_id);
|
||||
|
||||
IF user_email IS NOT NULL THEN
|
||||
NEW.email := user_email;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS ensure_billing_customer_email_trigger ON basejump.billing_customers;
|
||||
CREATE TRIGGER ensure_billing_customer_email_trigger
|
||||
BEFORE INSERT OR UPDATE ON basejump.billing_customers
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION basejump.ensure_billing_customer_email();
|
||||
|
||||
COMMIT;
|
|
@ -0,0 +1,9 @@
|
|||
BEGIN;
|
||||
|
||||
DROP TRIGGER IF EXISTS ensure_billing_customer_email_trigger ON basejump.billing_customers;
|
||||
|
||||
DROP FUNCTION IF EXISTS basejump.ensure_billing_customer_email();
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_user_email(UUID);
|
||||
|
||||
COMMIT;
|
|
@ -0,0 +1,89 @@
|
|||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_user_email(user_id UUID)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
SELECT email INTO user_email
|
||||
FROM auth.users
|
||||
WHERE id = user_id;
|
||||
|
||||
IF user_email IS NULL THEN
|
||||
SELECT
|
||||
COALESCE(
|
||||
raw_user_meta_data->>'email',
|
||||
raw_user_meta_data->>'user_email',
|
||||
email
|
||||
) INTO user_email
|
||||
FROM auth.users
|
||||
WHERE id = user_id;
|
||||
END IF;
|
||||
|
||||
RETURN user_email;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_user_email(UUID) TO authenticated, service_role;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT bc.account_id, a.primary_owner_user_id
|
||||
FROM basejump.billing_customers bc
|
||||
JOIN basejump.accounts a ON bc.account_id = a.id
|
||||
WHERE bc.email IS NULL OR bc.email = ''
|
||||
LOOP
|
||||
user_email := public.get_user_email(rec.primary_owner_user_id);
|
||||
|
||||
IF user_email IS NOT NULL THEN
|
||||
UPDATE basejump.billing_customers
|
||||
SET email = user_email
|
||||
WHERE account_id = rec.account_id;
|
||||
|
||||
RAISE NOTICE 'Updated email for account %: %', rec.account_id, user_email;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_users_email ON auth.users(email);
|
||||
|
||||
CREATE OR REPLACE FUNCTION basejump.ensure_billing_customer_email()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
owner_id UUID;
|
||||
BEGIN
|
||||
IF NEW.email IS NULL OR NEW.email = '' THEN
|
||||
SELECT primary_owner_user_id INTO owner_id
|
||||
FROM basejump.accounts
|
||||
WHERE id = NEW.account_id;
|
||||
|
||||
user_email := public.get_user_email(owner_id);
|
||||
|
||||
IF user_email IS NOT NULL THEN
|
||||
NEW.email := user_email;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS ensure_billing_customer_email_trigger ON basejump.billing_customers;
|
||||
CREATE TRIGGER ensure_billing_customer_email_trigger
|
||||
BEFORE INSERT OR UPDATE ON basejump.billing_customers
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION basejump.ensure_billing_customer_email();
|
||||
|
||||
COMMIT;
|
|
@ -359,87 +359,8 @@ export function AdminUserDetailsDialog({
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Adjust Credits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-3 border border-amber-200 bg-amber-50 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5" />
|
||||
<p className="text-sm text-amber-700">
|
||||
Use positive amounts to add credits, negative to deduct.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adjust-amount">Amount (USD)</Label>
|
||||
<Input
|
||||
id="adjust-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="10.00"
|
||||
value={adjustAmount}
|
||||
onChange={(e) => setAdjustAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adjust-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="adjust-reason"
|
||||
placeholder="Customer support ticket #123 - Billing dispute resolution"
|
||||
value={adjustReason}
|
||||
onChange={(e) => setAdjustReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="adjust-expiring" className="cursor-pointer flex items-center gap-2">
|
||||
{adjustIsExpiring ? (
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<Infinity className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{adjustIsExpiring ? 'Expiring Credits' : 'Non-Expiring Credits'}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="adjust-expiring"
|
||||
checked={!adjustIsExpiring}
|
||||
onCheckedChange={(checked) => setAdjustIsExpiring(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
{adjustIsExpiring
|
||||
? 'Credits will expire at the end of the billing cycle (30 days)'
|
||||
: 'Credits will never expire and remain until used'}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleAdjustCredits}
|
||||
disabled={adjustCreditsMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{adjustCreditsMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Apply Adjustment'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
@ -448,7 +369,7 @@ export function AdminUserDetailsDialog({
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-3 border border-red-200 bg-red-50 rounded-lg">
|
||||
<div className="p-3 border border-red-200 dark:border-red-950 bg-red-50 dark:bg-red-950/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
|
||||
<p className="text-sm text-red-700">
|
||||
|
@ -468,7 +389,7 @@ export function AdminUserDetailsDialog({
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="refund-reason">Refund Reason</Label>
|
||||
<Label htmlFor="refund-reason mb-2">Refund Reason</Label>
|
||||
<Textarea
|
||||
id="refund-reason"
|
||||
placeholder="Service outage compensation"
|
||||
|
|
Loading…
Reference in New Issue