diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 35856e08..de44ca2b 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -40,6 +40,7 @@ import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedu import { search } from '@x/core/dist/search/search.js'; import { versionHistory, voice } from '@x/core'; import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -710,5 +711,9 @@ export function setupIpcHandlers() { 'voice:getDeepgramToken': async () => { return voice.getDeepgramToken(); }, + // Billing handler + 'billing:getInfo': async () => { + return await getBillingInfo(); + }, }); } diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 2ae699a9..40061dbf 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -87,6 +87,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover" import { HelpPopover } from "@/components/help-popover" import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" +import { useBilling } from "@/hooks/useBilling" import { ServiceEvent } from "@x/shared/src/service-events.js" import z from "zod" @@ -401,6 +402,8 @@ export function SidebarContentPanel({ const [connectorsOpen, setConnectorsOpen] = useState(false) const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false) const connectorsButtonRef = useRef(null) + const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const { billing } = useBilling(isRowboatConnected) useEffect(() => { let mounted = true @@ -412,6 +415,7 @@ export function SidebarContentPanel({ const hasError = Object.values(config).some((entry) => Boolean(entry?.error)) if (mounted) { setHasOauthError(hasError) + setIsRowboatConnected(config['rowboat']?.connected ?? false) if (!hasError) { setShowOauthAlert(true) } @@ -420,6 +424,7 @@ export function SidebarContentPanel({ console.error('Failed to fetch OAuth state:', error) if (mounted) { setHasOauthError(false) + setIsRowboatConnected(false) setShowOauthAlert(true) } } @@ -483,6 +488,24 @@ export function SidebarContentPanel({ /> )} + {/* Billing status */} + {isRowboatConnected && billing && ( +
+
+
+ + {billing.subscriptionPlan ?? 'Free'} + + + 8 days left + +
+ +
+
+ )} {/* Bottom actions */}
diff --git a/apps/x/apps/renderer/src/hooks/useBilling.ts b/apps/x/apps/renderer/src/hooks/useBilling.ts new file mode 100644 index 00000000..d17897ee --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useBilling.ts @@ -0,0 +1,37 @@ +import { useState, useEffect, useCallback } from 'react' + +interface BillingInfo { + subscriptionPlan: string + subscriptionStatus: string + trialUsed: boolean + sanctionedCredits: number + availableCredits: number +} + +export function useBilling(isRowboatConnected: boolean) { + const [billing, setBilling] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchBilling = useCallback(async () => { + if (!isRowboatConnected) { + setBilling(null) + return + } + try { + setIsLoading(true) + const result = await window.ipc.invoke('billing:getInfo', null) + setBilling(result) + } catch (error) { + console.error('Failed to fetch billing info:', error) + setBilling(null) + } finally { + setIsLoading(false) + } + }, [isRowboatConnected]) + + useEffect(() => { + fetchBilling() + }, [fetchBilling]) + + return { billing, isLoading, refresh: fetchBilling } +} diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts new file mode 100644 index 00000000..2b93cbd2 --- /dev/null +++ b/apps/x/packages/core/src/billing/billing.ts @@ -0,0 +1,38 @@ +import { getAccessToken } from '../models/gateway.js'; +import { ROWBOAT_BILLING_BASE_URL } from '../config/env.js'; + +export interface BillingInfo { + subscriptionPlan: string | null; + subscriptionStatus: string | null; + trialUsed: boolean; + sanctionedCredits: number; + availableCredits: number; +} + +export async function getBillingInfo(): Promise { + const accessToken = await getAccessToken(); + const response = await fetch(`${ROWBOAT_BILLING_BASE_URL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error(`Billing API failed: ${response.status}`); + } + const body = await response.json() as { + customer: { + subscriptionPlan: string | null; + subscriptionStatus: string | null; + trialUsed: boolean; + }; + usage: { + sanctionedCredits: number; + availableCredits: number; + }; + }; + return { + subscriptionPlan: body.customer.subscriptionPlan, + subscriptionStatus: body.customer.subscriptionStatus, + trialUsed: body.customer.trialUsed, + sanctionedCredits: body.usage.sanctionedCredits, + availableCredits: body.usage.availableCredits, + }; +} diff --git a/apps/x/packages/core/src/config/env.ts b/apps/x/packages/core/src/config/env.ts index 2ec62881..cadf23df 100644 --- a/apps/x/packages/core/src/config/env.ts +++ b/apps/x/packages/core/src/config/env.ts @@ -3,3 +3,6 @@ export const API_URL = export const SUPABASE_PROJECT_URL = process.env.SUPABASE_PROJECT_URL || 'http://127.0.0.1:54321'; + +export const ROWBOAT_BILLING_BASE_URL = + process.env.ROWBOAT_BILLING_BASE_URL || 'https://billing.staging.x.rowboatlabs.com'; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 557df845..edf01117 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -516,6 +516,17 @@ const ipcSchemas = { ]).nullable(), }), }, + // Billing channels + 'billing:getInfo': { + req: z.null(), + res: z.object({ + subscriptionPlan: z.string().nullable(), + subscriptionStatus: z.string().nullable(), + trialUsed: z.boolean(), + sanctionedCredits: z.number(), + availableCredits: z.number(), + }), + }, } as const; // ============================================================================