From 5327969137fc12578ec6cbb17c18737e6061a268 Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Tue, 9 Jun 2026 21:49:42 -0700 Subject: [PATCH 01/12] feat(billing): add apply_credit/apply_debit to AccountBase, wire TaskMetrics to ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountBase gets four new billing methods as no-op defaults (OSS): apply_credit, apply_debit, get_credit_balance, get_transactions. SaaS overrides them with real ledger operations. TaskMetrics._report_to_billing_system() is no longer a stub — it now calls account.apply_debit() per resource with cumulative token totals every CONST_BILLING_REPORT_INTERVAL seconds and on task completion. Each resource gets its own UPSERT row keyed on task:{task_id}:{resource}. OSS mode is a no-op via AccountBase polymorphism. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ai/src/ai/account/base.py | 117 +++++++++++ .../ai/src/ai/modules/task/task_metrics.py | 192 +++++------------- 2 files changed, 170 insertions(+), 139 deletions(-) diff --git a/packages/ai/src/ai/account/base.py b/packages/ai/src/ai/account/base.py index e0c512ca8..30b2f5726 100644 --- a/packages/ai/src/ai/account/base.py +++ b/packages/ai/src/ai/account/base.py @@ -185,6 +185,123 @@ async def audit( """ pass + # ========================================================================= + # BILLING — no-op in OSS; SaaS overrides with real ledger writes + # ========================================================================= + + async def apply_credit( + self, + org_id: str, + type: str, + resource: str, + amount: float, + idempotency_key: str, + user_id: str | None = None, + team_id: str | None = None, + context: dict | None = None, + ) -> bool: + """ + Add credits to an org's ledger. + + OSS default is a no-op returning False. The SaaS implementation + INSERTs a positive-amount row into ``credit_ledger``. + + Args: + org_id: Organisation to credit. + type: Transaction type (``purchase``, ``credit``, ``refund``, etc.). + resource: Resource being credited (``tokens``, ``video``, etc.). + amount: Positive amount to credit. + idempotency_key: Namespaced dedup key (e.g. ``stripe:cs_xxx:tokens``). + user_id: Optional actor who initiated the credit. + team_id: Optional team context. + context: Optional audit metadata. + + Returns: + True on first apply, False on duplicate or no-op. + """ + return False + + async def apply_debit( + self, + org_id: str, + user_id: str, + team_id: str, + resource: str, + amount: float, + idempotency_key: str, + context: dict, + ) -> bool: + """ + Debit an org's ledger (UPSERT for task usage). + + OSS default is a no-op returning False. The SaaS implementation + UPSERTs a negative-amount row into ``credit_ledger``. + + The caller passes a **positive** amount; the implementation negates + it internally. + + Args: + org_id: Organisation to debit. + user_id: User whose task triggered the burn (required for attribution). + team_id: Team the task belongs to (required for attribution). + resource: Resource being consumed (e.g. ``cpu_utilization``, ``gpu_memory``). + amount: Positive amount to debit (negated internally). + idempotency_key: Namespaced dedup key (e.g. ``task:abc123:cpu_utilization``). + context: Human-readable audit context — pipeline name, source, etc. + Required so org admins can understand what the charge is for. + + Returns: + True on success, False on duplicate or no-op. + """ + return False + + async def get_credit_balance(self, org_id: str) -> dict: + """ + Get the net credit balance for an org, grouped by resource. + + OSS default returns empty balances. The SaaS implementation + queries ``SELECT resource, SUM(amount) GROUP BY resource``. + + Args: + org_id: Organisation to query. + + Returns: + ``{'balances': {resource: float, ...}}`` + """ + return {'balances': {}} + + async def get_transactions( + self, + org_id: str, + scope: str = 'org', + scope_id: str | None = None, + page: int = 1, + page_size: int = 50, + since: str | None = None, + ) -> dict: + """ + Paginated transaction detail for an org, optionally scoped to a team or user. + + OSS default returns empty results. The SaaS implementation queries + the ``credit_ledger`` table with pagination and scope filtering. + + Args: + org_id: Organisation to query. + scope: ``org``, ``team``, or ``user``. + scope_id: Team or user ID when scope is not ``org``. + page: 1-based page number. + page_size: Rows per page (max 100). + since: ISO datetime string — only return rows at or after this time. + + Returns: + ``{'transactions': [...], 'total': int, 'page': int, 'pageSize': int}`` + """ + return {'transactions': [], 'total': 0, 'page': page, 'pageSize': page_size} + + # ========================================================================= + # DAP COMMAND DISPATCH — SaaS overrides all three + # ========================================================================= + async def handle_account(self, conn, request): """ Dispatch an ``rrext_account_*`` DAP command to the account handler. diff --git a/packages/ai/src/ai/modules/task/task_metrics.py b/packages/ai/src/ai/modules/task/task_metrics.py index c57408a6a..cafb6c9c9 100644 --- a/packages/ai/src/ai/modules/task/task_metrics.py +++ b/packages/ai/src/ai/modules/task/task_metrics.py @@ -439,152 +439,66 @@ def merge_subprocess_metrics(self, metrics_dict: dict) -> None: if self._on_update_callback: self._on_update_callback() - def _report_to_billing_system(self) -> None: + async def _report_to_billing_system(self) -> None: """ - Send periodic billing report to the billing system. - - This method is called every 5 minutes to report INCREMENTAL usage - since the last report. Only the delta (new usage in this period) is - sent to the billing system. - - STUB: Implementation pending - will send HTTP POST to billing API. - - The report includes: - - Task identification (task_id, customer_id, etc. from status) - - INCREMENTAL resource usage since last report: - * CPU usage delta (vCPU-seconds) - * Memory usage delta (MB-seconds) - * GPU memory usage delta (MB-seconds) - - INCREMENTAL token charges since last report (cpu, memory, gpu, total) - - Current metrics snapshot (cpu_percent, memory_mb, gpu_memory_mb) - - Peak and average metrics (lifetime cumulative) - """ - # Calculate incremental usage since last report - delta_cpu_seconds = self._cpu_seconds - self._last_report_cpu_seconds - delta_memory_mb_seconds = self._memory_mb_seconds - self._last_report_memory_mb_seconds - delta_gpu_memory_mb_seconds = self._gpu_memory_mb_seconds - self._last_report_gpu_memory_mb_seconds - - # Calculate incremental token charges since last report - delta_tokens_cpu = self._status.tokens.cpu_utilization - self._last_report_tokens_cpu - delta_tokens_memory = self._status.tokens.cpu_memory - self._last_report_tokens_memory - delta_tokens_gpu = self._status.tokens.gpu_memory - self._last_report_tokens_gpu - delta_tokens_gpu_inference = self._status.tokens.gpu_inference - self._last_report_tokens_gpu_inference - delta_tokens_custom = { - k: round(v - self._last_report_tokens_custom.get(k, 0.0), 1) for k, v in self._status.tokens.custom.items() - } - delta_tokens_total = ( - delta_tokens_cpu - + delta_tokens_memory - + delta_tokens_gpu - + delta_tokens_gpu_inference - + sum(delta_tokens_custom.values()) - ) + Write cumulative token usage to the credit ledger via ``account.apply_debit()``. - # GPU inference stats for monitoring - gpu_inference_count = self._subprocess_counters.get('gpu_inference_count', 0) - gpu_inference_seconds = self._subprocess_timers.get('gpu', 0.0) / 1000.0 - gpu_inference_avg = (gpu_inference_seconds / gpu_inference_count) if gpu_inference_count > 0 else 0.0 - - # Prepare incremental report data structure (will be sent to billing API) - report_data = { - 'report_timestamp': time.time(), - 'report_period_seconds': self._report_interval_seconds, - 'task_id': self.task_id, - 'client_id': self.client_id, - 'user_id': self.user_id, - 'team_id': self.team_id, - 'org_id': self.org_id, - # INCREMENTAL usage for this 5-minute period only - 'incremental_usage': { - 'cpu_seconds': round(delta_cpu_seconds, 2), - 'memory_mb_seconds': round(delta_memory_mb_seconds, 2), - 'gpu_memory_mb_seconds': round(delta_gpu_memory_mb_seconds, 2), - }, - # INCREMENTAL token charges for this 5-minute period only - 'incremental_tokens': { - 'cpu_utilization': round(delta_tokens_cpu, 1), - 'cpu_memory': round(delta_tokens_memory, 1), - 'gpu_memory': round(delta_tokens_gpu, 1), - 'gpu_inference': round(delta_tokens_gpu_inference, 1), - 'custom': delta_tokens_custom, - 'total': round(delta_tokens_total, 2), - }, - # Cumulative values (for reference/validation) - 'cumulative_total': { - 'duration_seconds': self._duration_seconds, - 'tokens_total': self._status.tokens.total, - }, - # Current state snapshot - 'current_metrics': { - 'cpu_percent': self._status.metrics.cpu_percent, - 'cpu_memory_mb': self._status.metrics.cpu_memory_mb, - 'gpu_memory_mb': self._status.metrics.gpu_memory_mb, - }, - # Lifetime peaks and averages - 'peak_metrics': { - 'cpu_percent': self._status.metrics.peak_cpu_percent, - 'cpu_memory_mb': self._status.metrics.peak_cpu_memory_mb, - 'gpu_memory_mb': self._status.metrics.peak_gpu_memory_mb, - }, - 'average_metrics': { - 'cpu_percent': self._status.metrics.avg_cpu_percent, - 'cpu_memory_mb': self._status.metrics.avg_cpu_memory_mb, - 'gpu_memory_mb': self._status.metrics.avg_gpu_memory_mb, - }, - # Subprocess-reported metrics (raw data for audit) - 'subprocess_metrics': { - 'timers': dict(self._subprocess_timers), - 'counters': dict(self._subprocess_counters), - }, - # GPU inference stats (for monitoring) - 'gpu_inference_stats': { - 'total_seconds': round(gpu_inference_seconds, 3), - 'call_count': int(gpu_inference_count), - 'avg_seconds': round(gpu_inference_avg, 4), - }, - } + Called every ``CONST_BILLING_REPORT_INTERVAL`` seconds and once on + task completion. Each resource gets its own UPSERT row keyed on + ``task:{task_id}:{resource}`` — the amount is the cumulative total + (not an incremental delta), so repeated calls update the row in-place. - # Update "last report" tracking FIRST (before any potential failures) - # This ensures we don't double-report the same period if logging fails - self._last_report_cpu_seconds = self._cpu_seconds - self._last_report_memory_mb_seconds = self._memory_mb_seconds - self._last_report_gpu_memory_mb_seconds = self._gpu_memory_mb_seconds - self._last_report_tokens_cpu = self._status.tokens.cpu_utilization - self._last_report_tokens_memory = self._status.tokens.cpu_memory - self._last_report_tokens_gpu = self._status.tokens.gpu_memory - self._last_report_tokens_gpu_inference = self._status.tokens.gpu_inference - self._last_report_tokens_custom = dict(self._status.tokens.custom) - - # STUB: Log the report (will be replaced with actual API call) + In OSS mode (no SaaS extension), ``account.apply_debit()`` is a no-op. + """ + # ── Build cumulative token totals ──────────────────────────────── + # All values are already converted to tokens by _update_tokens(). + # OS-level metrics (cpu, memory, gpu) and subprocess-reported custom + # counters are treated identically — one ledger row per resource. + token_totals: dict[str, float] = { + 'cpu_utilization': self._status.tokens.cpu_utilization, + 'cpu_memory': self._status.tokens.cpu_memory, + 'gpu_memory': self._status.tokens.gpu_memory, + 'gpu_inference': self._status.tokens.gpu_inference, + **self._status.tokens.custom, + } + # ── Debug logging ──────────────────────────────────────────────── try: - tag = f'[TaskMetrics:{self.task_id}]' if self.task_id else '[TaskMetrics]' - debug(f'{tag} Billing report:') debug( - f' Incremental Tokens (this period): CPU={delta_tokens_cpu:.2f}, Memory={delta_tokens_memory:.2f}, GPU Memory={delta_tokens_gpu:.2f}, GPU Inference={delta_tokens_gpu_inference:.2f}, Custom={delta_tokens_custom}, Total={delta_tokens_total:.2f}' - ) - debug(f' Cumulative Tokens (lifetime): Total={self._status.tokens.total}') - debug( - f' GPU Inference Stats: {gpu_inference_count} calls, {gpu_inference_seconds:.3f}s total, {gpu_inference_avg:.4f}s avg' + f'[TaskMetrics] Billing report: task={self.task_id} total={self._status.tokens.total:.2f} resources={token_totals}' ) except Exception: - # Don't let print failures break billing tracking pass - # TODO: Implement actual billing API call - # When billing system is ready: - # 1. Add CONST_BILLING_API_TIMEOUT to imports at top of file - # 2. Send report_data via HTTP POST: - # - # import requests - # response = requests.post( - # f'{billing_system_url}/api/v1/billing/report', - # json=report_data, - # timeout=CONST_BILLING_API_TIMEOUT - # ) - # response.raise_for_status() - # - # For now, report_data is prepared but only logged above - _ = report_data # Suppress unused variable warning + # ── Write to ledger via account.apply_debit() ──────────────────── + # The account singleton dispatches to the SaaS implementation (UPSERT + # into credit_ledger) or the OSS no-op depending on the active edition. + if not self.org_id or not token_totals: + return + + try: + from ai.account import account + + context = { + 'task_id': self.task_id, + 'client_id': self.client_id, + 'duration_seconds': round(self._duration_seconds, 1), + 'tokens_total': round(self._status.tokens.total, 2), + } + for resource, amount in token_totals.items(): + if amount <= 0: + continue + idem_key = f'task:{self.task_id}:{resource}' + await account.apply_debit( + org_id=self.org_id, + user_id=self.user_id, + team_id=self.team_id, + resource=resource, + amount=amount, + idempotency_key=idem_key, + context=context, + ) + except Exception as e: + debug(f'[TaskMetrics] Error writing to billing ledger: {e}') async def _monitoring_loop(self) -> None: """ @@ -622,7 +536,7 @@ async def _monitoring_loop(self) -> None: time_since_last_report = current_time - self._last_report_time if time_since_last_report >= self._report_interval_seconds: try: - self._report_to_billing_system() + await self._report_to_billing_system() self._last_report_time = current_time except Exception as e: # Log error but don't crash monitoring loop @@ -704,4 +618,4 @@ async def stop_monitoring(self) -> None: # Send final billing report on shutdown (captures any remaining incremental usage) async with self._metrics_lock: - self._report_to_billing_system() + await self._report_to_billing_system() From 8a1e363670cd8c79b97f51d41d820bc08957ff07 Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Wed, 10 Jun 2026 20:24:12 -0700 Subject: [PATCH 02/12] feat(billing): billing dashboard UI, DB-backed plans, SDK type overhaul (#billing-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Billing Dashboard (new): - BillingDashboard.tsx: 4 admin insight sections — spending velocity with top-up buttons (primary when <7 days remaining, secondary otherwise), usage leaderboard (by user/team toggle), paginated transaction log with user name resolution, and active tasks view. - Wired into BillingPanel for org admins on both VSCode and shell-ui. Plans served from local DB instead of Stripe API: - AccountProvider.ts: fetchBillingData no longer calls listCreditPacks() or Stripe. All plan/price data comes from app_prices table via the existing getProductPrices() call. Billing page no longer breaks when Stripe is unreachable. - AccountProvider.ts: sendInitialData() now fetches ALL account data upfront (org, members, teams, keys, billing) so tab badges and counts are visible immediately without clicking into each section. - shell-ui AccountPage: combined loadBilling + loadDashboard into a single fetch to fix useEffect race condition where dashboard data would get stuck in loading state. SDK type overhaul: - CheckoutPlan type replaced with DB-native shape: id, appId, stripePriceId, nickname, amountCents, currency, interval, metadata, isActive, createdAt. Matches _price_to_dict() output directly. - StripePlan renamed to AppPrice (StripePlan kept as deprecated alias). - PlanPicker.tsx rewritten to use DB field names (nickname, stripePriceId, amountCents, metadata.action, metadata.order, metadata.description, metadata.displayAmount, metadata.kind). - CheckoutModal.tsx updated for new field names. - New types: LedgerTransaction, TransactionsResult, UsageRollup, TopupPlan. - New BillingApi methods: getTransactions(), getUsageByUser(), getUsageByTeam(). - Python SDK: AppPrice type, updated BillingApi with matching methods. Plan picker improvements: - Filters out kind='topup' plans from subscription modal — top-ups only appear on the spending velocity pane. - Cancel button changed from danger to secondary style. CreditsPanel cleanup: - Removed credit packs grid and "No credit packs configured" message. Credit packs concept is eliminated; replaced by top-up plans in app_prices with metadata.kind='topup'. TaskMetrics improvements: - billing_run_id: unique UUID per pipeline run for idempotency keys, preventing collisions when the same pipeline runs multiple times. - pipeline_name and source_name passed to context for human-readable billing audit trail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/layout/ShellLayout.tsx | 4 +- .../src/views/account/AccountPage.tsx | 41 +- .../src/views/settings/SettingsPage.tsx | 24 + apps/vscode/src/providers/AccountProvider.ts | 145 +++- .../vscode/src/providers/BarStatusProvider.ts | 18 +- apps/vscode/src/providers/SettingsProvider.ts | 57 +- apps/vscode/src/providers/template.html | 2 +- .../views/Account/AccountWebview.tsx | 201 ++++-- .../views/Settings/ConnectionSettings.tsx | 12 + .../views/Settings/DeploySettings.tsx | 12 + .../views/Settings/SettingsWebview.tsx | 99 ++- .../views/components/ConnectionConfig.tsx | 9 +- .../views/components/panels/CloudPanel.tsx | 51 +- .../src/shared/util/subscriptionGate.ts | 8 +- packages/ai/src/ai/account/base.py | 7 +- .../ai/src/ai/modules/task/task_engine.py | 2 + .../ai/src/ai/modules/task/task_metrics.py | 38 +- .../client-python/src/rocketride/billing.py | 78 ++- .../src/rocketride/types/__init__.py | 8 + .../src/rocketride/types/billing.py | 127 +++- .../client-typescript/src/client/billing.ts | 54 +- .../src/client/types/billing.ts | 127 +++- .../src/assets/icons/PadlockIcon.tsx | 8 +- .../node-component/run-button/RunButton.tsx | 4 +- .../src/components/status/StatusHeader.tsx | 4 +- .../src/modules/account/AccountView.tsx | 29 +- .../account/components/BillingPanel.tsx | 47 +- .../billing/components/BillingDashboard.tsx | 632 ++++++++++++++++++ .../billing/components/CreditsPanel.tsx | 109 +-- .../shared-ui/src/modules/billing/index.ts | 4 +- .../shared-ui/src/modules/billing/types.ts | 2 +- .../src/modules/checkout/CheckoutModal.tsx | 22 +- .../src/modules/checkout/PlanPicker.tsx | 135 ++-- .../shared-ui/src/modules/checkout/types.ts | 52 +- 34 files changed, 1799 insertions(+), 373 deletions(-) create mode 100644 packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx diff --git a/apps/shell-ui/src/components/layout/ShellLayout.tsx b/apps/shell-ui/src/components/layout/ShellLayout.tsx index 27f01fd66..0dfc957e7 100644 --- a/apps/shell-ui/src/components/layout/ShellLayout.tsx +++ b/apps/shell-ui/src/components/layout/ShellLayout.tsx @@ -282,9 +282,7 @@ export const ShellLayout: React.FC = ({ {/* Client area */}
- {subGateActive ? ( -
Subscription required
- ) : activeApp?.components?.App ? ( + {activeApp?.components?.App ? ( { (authUser as { credits?: CreditBalance })?.credits ?? null, ); const [creditPacks, setCreditPacks] = useState([]); + const [allPlans, setAllPlans] = useState([]); + const [transactions, setTransactions] = useState(null); + const [usageByUser, setUsageByUser] = useState([]); + const [usageByTeam, setUsageByTeam] = useState([]); + const [dashboardLoading, setDashboardLoading] = useState(false); + const [txPage, setTxPage] = useState(1); // ── Refresh signal (bumped by shell:accountUpdate) ───────────────────── const [refreshSignal, setRefreshSignal] = useState(0); @@ -189,30 +197,48 @@ const AccountPage: React.FC = () => { // ── Load billing data ─────────────────────────────────────────────────── /** Fetches subscriptions, credit balance, and credit packs in parallel. */ + /** Fetches all billing data in one shot: subscriptions, balance, plans, transactions, usage. */ const loadBilling = useCallback(async () => { if (!client || !isConnected || !orgId) { setBillingLoading(false); return; } setBillingLoading(true); + setDashboardLoading(true); setBillingError(null); try { - const [subs, balance, packs] = await Promise.all([ + const [subs, balance, plans, tx, byUser, byTeam] = await Promise.all([ client.billing.getDetails(orgId).catch((err: any) => { setBillingError(err.message ?? 'Failed to load subscriptions'); return [] as BillingDetail[]; }), client.billing.getCreditBalance(orgId).catch(() => null), - client.billing.listCreditPacks().catch(() => [] as CreditPack[]), + client.billing.getProductPrices('rocketride.pipeBuilder').catch(() => []), + client.billing.getTransactions(orgId, { page: 1, pageSize: 20 }).catch(() => null), + client.billing.getUsageByUser(orgId).catch(() => [] as UsageRollup[]), + client.billing.getUsageByTeam(orgId).catch(() => [] as UsageRollup[]), ]); setSubscriptions(subs); setCreditBalance(balance); - setCreditPacks(packs); + setAllPlans(plans); + setCreditPacks([]); + setTransactions(tx); + setUsageByUser(byUser); + setUsageByTeam(byTeam); + setTxPage(1); } finally { setBillingLoading(false); + setDashboardLoading(false); } }, [client, isConnected, orgId]); + /** Fetches just the transactions page (for pagination). */ + const handleTransactionPage = useCallback(async (page: number) => { + if (!client || !orgId) return; + const tx = await client.billing.getTransactions(orgId, { page, pageSize: 20 }).catch(() => null); + if (tx) { setTransactions(tx); setTxPage(page); } + }, [client, orgId]); + // ── Load non-env data on section change ───────────────────────────────── useEffect(() => { setSectionError(null); @@ -421,6 +447,15 @@ const AccountPage: React.FC = () => { onCancelSubscription={handleCancelSubscription} onOpenPortal={handleOpenPortal} onBuyCredits={handleBuyCredits} + transactions={transactions} + usageByUser={usageByUser} + usageByTeam={usageByTeam} + activeTasks={[]} + topupPlans={allPlans.filter((p: any) => p.metadata?.kind === 'topup').map((p: any) => ({ id: p.id, stripePriceId: p.stripePriceId, nickname: p.nickname, amountCents: p.amountCents, metadata: p.metadata }))} + dashboardLoading={dashboardLoading} + onTransactionPage={handleTransactionPage} + memberNames={Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId]))} + teamNames={Object.fromEntries(teams.map((t: any) => [t.id, t.name || t.id]))} section={section} onSectionChange={setSection} activeTeamId={activeTeamId} diff --git a/apps/shell-ui/src/views/settings/SettingsPage.tsx b/apps/shell-ui/src/views/settings/SettingsPage.tsx index 01beca2e8..d3390a76a 100644 --- a/apps/shell-ui/src/views/settings/SettingsPage.tsx +++ b/apps/shell-ui/src/views/settings/SettingsPage.tsx @@ -383,6 +383,8 @@ interface SettingsSection { * This page handles UI-only settings (trace level, preferences). * */ +const PIPE_BUILDER_APP_ID = 'rocketride.pipeBuilder'; + const SettingsPage: React.FC = () => { const { appManifest, settings, updateSetting } = useWorkspace(); const { client, isConnected } = useShellConnection(); @@ -391,6 +393,17 @@ const SettingsPage: React.FC = () => { const [search, setSearch] = useState(''); const [selectedNav, setSelectedNav] = useState(null); + // ── Subscription status ───────────────────────────────────────────── + const info = client?.getAccountInfo(); + const pipeBuilderApp = (info?.apps ?? []).find((a: any) => a.id === PIPE_BUILDER_APP_ID); + const isSubscribed = pipeBuilderApp?.appStatus === 'subscribed' || pipeBuilderApp?.appStatus === 'trialing'; + + /** Opens the checkout modal via the shell:subscribe event. */ + const handleSubscribe = useCallback(() => { + if (!pipeBuilderApp) return; + ConnectionManager.getInstance().emit('shell:subscribe', { app: pipeBuilderApp }); + }, [pipeBuilderApp]); + // ── Services from cached catalog ───────────────────────────────────── const [services, setServices] = useState>({}); @@ -573,6 +586,17 @@ const SettingsPage: React.FC = () => { {/* ── Content area ────────────────────────────────── */}
+ {/* Subscribe prompt for unsubscribed users */} + {info && !isSubscribed && ( +
+ + Subscribe to unlock pipeline execution and deployment. + + +
+ )} {!hasAny && (
{query ? 'No settings match your search.' : 'No settings defined.'} diff --git a/apps/vscode/src/providers/AccountProvider.ts b/apps/vscode/src/providers/AccountProvider.ts index bd38336a1..8d52b8a97 100644 --- a/apps/vscode/src/providers/AccountProvider.ts +++ b/apps/vscode/src/providers/AccountProvider.ts @@ -24,6 +24,7 @@ import { ConnectionState } from '../shared/types'; import type { ConnectionStatus } from '../shared/types'; import type { ConnectResult, TeamDetail } from 'rocketride'; import { CloudAuthProvider } from '../auth/CloudAuthProvider'; +import { PIPE_BUILDER_APP_ID } from '../shared/types'; // ============================================================================= // INTERFACES @@ -42,6 +43,8 @@ interface AccountWebviewMessage { params?: Record; appId?: string; packId?: string; + priceId?: string; + subscriptionId?: string; } // ============================================================================= @@ -228,11 +231,17 @@ export class AccountProvider { await this.handleBuyCredits(message.packId as string); break; - case 'billing:subscribe': - // Open the pipeline editor which has the embedded Stripe checkout flow - // for new subscriptions. The portal is for managing existing subscriptions - // only; new subscriptions require the checkout session flow in ProjectProvider. - await this.handleSubscribe(panel); + // -- Checkout flow (embedded Stripe Elements in the Account webview) --- + case 'checkout:fetchPlans': + await this.handleCheckoutFetchPlans(panel); + break; + + case 'checkout:createSession': + await this.handleCheckoutCreateSession(panel, message); + break; + + case 'checkout:confirmPending': + await this.handleCheckoutConfirmPending(panel, message); break; // Environment variables removed — now handled by EnvironmentProvider. @@ -257,29 +266,46 @@ export class AccountProvider { */ private async sendInitialData(panel: vscode.WebviewPanel): Promise { // Resolve the best available client (dev → deploy cascade). - const { client, accountInfo } = this.resolveClient(); + const { client, accountInfo, orgId } = this.resolveClient(); const isConnected = client !== undefined; - // Fetch profile for the default tab; other sections load on demand. + // Fetch all account data upfront in parallel so every tab has data + // immediately (badges, counts, billing) without waiting for the user + // to click into each section. let profile: ConnectResult | null = accountInfo ?? null; + let org = null; + let members: any[] = []; + let teams: any[] = []; + let keys: any[] = []; + if (client && isConnected) { - const fresh = await client.account.getProfile().catch(() => null); - if (fresh) profile = fresh; + const results = await Promise.all([ + client.account.getProfile().catch(() => null), + orgId ? client.account.getOrg(orgId).catch(() => null) : null, + orgId ? client.account.listMembers(orgId).catch(() => []) : [], + orgId ? client.account.listTeams(orgId).catch(() => []) : [], + client.account.listKeys().catch(() => []), + ]); + if (results[0]) profile = results[0]; + org = results[1]; + members = results[2] as any[]; + teams = results[3] as any[]; + keys = results[4] as any[]; } - // Post init with profile only — org/members/teams/keys load lazily. - // authUser is the ConnectResult (includes defaultTeam); profile is the - // richer profile dict from getProfile (includes org/team structure). await panel.webview.postMessage({ type: 'account:init', isConnected, profile, authUser: accountInfo ?? null, - org: null, - members: [], - teams: [], - keys: [], + org, + members, + teams, + keys, }); + + // Also fetch billing data immediately + await this.fetchBillingData(panel); } /** @@ -813,14 +839,28 @@ export class AccountProvider { }); try { - // Fetch all billing data in parallel via the SDK - const [subscriptions, creditBalance, creditPacks] = await Promise.all([client.billing.getDetails(orgId), client.billing.getCreditBalance(orgId), client.billing.listCreditPacks()]); + // Fetch all billing data in parallel — all from local DB, no Stripe calls + const [subscriptions, creditBalance, allPlans, transactions, usageByUser, usageByTeam] = await Promise.all([ + client.billing.getDetails(orgId), + client.billing.getCreditBalance(orgId), + client.billing.getProductPrices(PIPE_BUILDER_APP_ID).catch(() => []), + client.billing.getTransactions(orgId, { page: 1, pageSize: 20 }).catch(() => null), + client.billing.getUsageByUser(orgId).catch(() => []), + client.billing.getUsageByTeam(orgId).catch(() => []), + ]); + + // Split plans into topups for the billing dashboard + const topupPlans = (allPlans as any[]).filter((p: any) => p.metadata?.kind === 'topup'); await panel.webview.postMessage({ type: 'account:billing', subscriptions, creditBalance, - creditPacks, + creditPacks: [], + topupPlans, + transactions, + usageByUser, + usageByTeam, billingLoading: false, billingError: null, }); @@ -831,6 +871,9 @@ export class AccountProvider { subscriptions: [], creditBalance: null, creditPacks: [], + transactions: null, + usageByUser: [], + usageByTeam: [], billingLoading: false, billingError: `Failed to load billing data: ${error}`, }); @@ -858,27 +901,61 @@ export class AccountProvider { } /** - * Handles a new subscription request from the billing UI. + * Fetches available subscription plans for the checkout modal. + * + * @param panel - The webview panel to post the result to. + */ + private async handleCheckoutFetchPlans(panel: vscode.WebviewPanel): Promise { + try { + const { client } = this.resolveClient(); + if (!client) throw new Error('Not connected'); + const plans = await client.billing.getProductPrices(PIPE_BUILDER_APP_ID); + await panel.webview.postMessage({ type: 'checkout:plansResult', plans, error: null }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + await panel.webview.postMessage({ type: 'checkout:plansResult', plans: [], error: msg }); + } + } + + /** + * Creates a Stripe checkout session and returns the client secret. * - * New subscriptions require the Stripe Elements checkout flow which is - * hosted in the ProjectProvider's custom editor webview. We open a - * pipeline file to trigger that editor, which shows the subscribe - * banner with the embedded checkout when the user is not yet subscribed. + * @param panel - The webview panel to post the result to. + * @param message - The incoming message containing the priceId. + */ + private async handleCheckoutCreateSession(panel: vscode.WebviewPanel, message: AccountWebviewMessage): Promise { + try { + const { client, orgId } = this.resolveClient(); + if (!client) throw new Error('Not connected'); + if (!orgId) throw new Error('No organisation found'); + const result = await client.billing.createCheckoutSession(orgId, PIPE_BUILDER_APP_ID, message.priceId as string); + await panel.webview.postMessage({ type: 'checkout:sessionResult', ...result, error: null }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + await panel.webview.postMessage({ type: 'checkout:sessionResult', clientSecret: '', subscriptionId: '', error: msg }); + } + } + + /** + * Notifies the server that Stripe payment was confirmed client-side. * - * @param panel - The webview panel (used to post error messages if needed). + * @param panel - The webview panel to post the result to. + * @param message - The incoming message containing subscriptionId and priceId. */ - private async handleSubscribe(_panel: vscode.WebviewPanel): Promise { + private async handleCheckoutConfirmPending(panel: vscode.WebviewPanel, message: AccountWebviewMessage): Promise { try { - // Open or focus the pipeline editor — its webview renders the - // embedded Stripe checkout flow for new subscriptions - await vscode.commands.executeCommand('workbench.action.files.newUntitledFile', { - languageId: 'rocketride-pipeline', + const { client } = this.resolveClient(); + if (!client) throw new Error('Not connected'); + await (client as any).dapRequest('rrext_account_billing', { + subcommand: 'confirm_pending', + appId: PIPE_BUILDER_APP_ID, + subscriptionId: message.subscriptionId, + priceId: message.priceId, }); + await panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); } catch { - // Fallback: inform the user to open a pipeline file manually - vscode.window.showInformationMessage( - 'To subscribe, create or open a pipeline file. The pipeline editor includes the checkout flow.' - ); + // Non-fatal -- the webhook will still update the DB + await panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); } } diff --git a/apps/vscode/src/providers/BarStatusProvider.ts b/apps/vscode/src/providers/BarStatusProvider.ts index efa479272..1058ca6f2 100644 --- a/apps/vscode/src/providers/BarStatusProvider.ts +++ b/apps/vscode/src/providers/BarStatusProvider.ts @@ -124,10 +124,22 @@ export class BarStatus { * Handles connection-level status changes (connected, disconnected, auth). * This is the primary driver of the status bar — shows dev connection state. */ + /** Reads the current connection mode from config (single source of truth). */ + private getCurrentMode(): string { + try { + const { ConfigManager } = require('../config'); + return ConfigManager.getInstance().getConfig().development.connectionMode ?? 'local'; + } catch { + return 'local'; + } + } + private handleConnectionStatusChange(status: ConnectionStatus): void { + const modeLabel: Record = { cloud: 'Cloud', docker: 'Docker', service: 'Service', onprem: 'Direct', local: 'Local' }; + const currentMode = this.getCurrentMode(); + if (status.state === ConnectionState.CONNECTED) { - const modeLabel: Record = { cloud: 'Cloud', docker: 'Docker', service: 'Service', onprem: 'Direct', local: 'Local' }; - this.statusBarItem.text = `$(debug-console) RocketRide: Connected (${modeLabel[status.connectionMode] || status.connectionMode})`; + this.statusBarItem.text = `$(debug-console) RocketRide: Connected (${modeLabel[currentMode] || currentMode})`; this.statusBarItem.command = 'rocketride.sidebar.connection.disconnect'; this.statusBarItem.tooltip = 'Connected - Click to disconnect'; this.statusBarItem.backgroundColor = undefined; @@ -145,7 +157,7 @@ export class BarStatus { this.statusBarItem.tooltip = status.lastError || 'Authentication failed — click to sign in'; this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); vscode.commands.executeCommand('setContext', 'rocketride.connected', false); - } else if (!status.hasCredentials && (status.connectionMode === 'cloud' || status.connectionMode === 'onprem')) { + } else if (!status.hasCredentials && (currentMode === 'cloud' || currentMode === 'onprem')) { this.statusBarItem.text = '$(key) RocketRide: Setup Required'; this.statusBarItem.command = 'rocketride.page.settings.open'; this.statusBarItem.tooltip = 'Click to open settings page'; diff --git a/apps/vscode/src/providers/SettingsProvider.ts b/apps/vscode/src/providers/SettingsProvider.ts index 0c5022d87..d02578a8c 100644 --- a/apps/vscode/src/providers/SettingsProvider.ts +++ b/apps/vscode/src/providers/SettingsProvider.ts @@ -166,10 +166,51 @@ export class SettingsProvider { await this.clearCredentials(panel.webview); break; - case 'openSubscribe': - // Open the Account page billing tab to start the subscribe flow - await vscode.commands.executeCommand('rocketride.page.account.open', 'billing'); + // -- Checkout flow (embedded Stripe Elements) -------------------- + case 'checkout:fetchPlans': { + try { + const billingClient = getConnectionManager()?.getClient(); + if (!billingClient) throw new Error('Not connected'); + const plans = await billingClient.billing.getProductPrices(PIPE_BUILDER_APP_ID); + panel.webview.postMessage({ type: 'checkout:plansResult', plans, error: null }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + panel.webview.postMessage({ type: 'checkout:plansResult', plans: [], error: msg }); + } break; + } + + case 'checkout:createSession': { + try { + const billingClient = getConnectionManager()?.getClient(); + if (!billingClient) throw new Error('Not connected'); + const orgId = billingClient.getAccountInfo()?.organizations?.[0]?.id; + if (!orgId) throw new Error('No organisation found'); + const result = await billingClient.billing.createCheckoutSession(orgId, PIPE_BUILDER_APP_ID, message.priceId as string); + panel.webview.postMessage({ type: 'checkout:sessionResult', ...result, error: null }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + panel.webview.postMessage({ type: 'checkout:sessionResult', clientSecret: '', subscriptionId: '', error: msg }); + } + break; + } + + case 'checkout:confirmPending': { + try { + const billingClient = getConnectionManager()?.getClient(); + if (!billingClient) throw new Error('Not connected'); + await (billingClient as any).dapRequest('rrext_account_billing', { + subcommand: 'confirm_pending', + appId: PIPE_BUILDER_APP_ID, + subscriptionId: message.subscriptionId, + priceId: message.priceId, + }); + panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); + } catch { + panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); + } + break; + } default: { // Delegate connection messages (cloud, docker, service, test, engine versions, sudo) @@ -272,16 +313,12 @@ export class SettingsProvider { integrationAgentsMd: workspaceConfig.get('integrations.agentsMd', false), }; - webview.postMessage({ - type: 'settingsLoaded', - settings: allSettings, - }); - - // Send subscription status so the subscribe banner can render + // Include subscription status with settings so it's always in sync const cm = getConnectionManager(); const client = cm?.getClient(); webview.postMessage({ - type: 'subscriptionStatus', + type: 'settingsLoaded', + settings: allSettings, isSubscribed: isSubscribed(client, PIPE_BUILDER_APP_ID), }); diff --git a/apps/vscode/src/providers/template.html b/apps/vscode/src/providers/template.html index c0df080e2..71ab6a3d8 100644 --- a/apps/vscode/src/providers/template.html +++ b/apps/vscode/src/providers/template.html @@ -31,7 +31,7 @@ - + RocketRide diff --git a/apps/vscode/src/providers/views/Account/AccountWebview.tsx b/apps/vscode/src/providers/views/Account/AccountWebview.tsx index 04efceea8..a3fbf8c13 100644 --- a/apps/vscode/src/providers/views/Account/AccountWebview.tsx +++ b/apps/vscode/src/providers/views/Account/AccountWebview.tsx @@ -16,8 +16,8 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { AccountView } from 'shared'; -import type { ApiKeyRecord, OrgDetail, MemberRecord, TeamRecord, TeamDetail, AccountSection, ProfileUpdate } from 'shared'; +import { AccountView, CheckoutModal } from 'shared'; +import type { ApiKeyRecord, OrgDetail, MemberRecord, TeamRecord, TeamDetail, AccountSection, ProfileUpdate, CheckoutPlan } from 'shared'; import type { ConnectResult } from 'rocketride'; import { useMessaging } from '../hooks/useMessaging'; import type { AccountHostToWebview, AccountWebviewToHost } from '../types'; @@ -54,6 +54,18 @@ const AccountWebview: React.FC = () => { const [billingError, setBillingError] = useState(null); const [creditBalance, setCreditBalance] = useState(null); const [creditPacks, setCreditPacks] = useState([]); + const [transactions, setTransactions] = useState(null); + const [usageByUser, setUsageByUser] = useState([]); + const [usageByTeam, setUsageByTeam] = useState([]); + const [topupPlans, setTopupPlans] = useState([]); + + // Checkout modal state + const [showCheckout, setShowCheckout] = useState(false); + const checkoutResolvers = useRef<{ + plans?: { resolve: (v: CheckoutPlan[]) => void; reject: (e: Error) => void }; + session?: { resolve: (v: { clientSecret: string; subscriptionId: string }) => void; reject: (e: Error) => void }; + confirm?: { resolve: () => void; reject: (e: Error) => void }; + }>({}); // Section load error const [sectionError, setSectionError] = useState(null); @@ -134,8 +146,41 @@ const AccountWebview: React.FC = () => { setBillingError((message as any).billingError ?? null); setCreditBalance((message as any).creditBalance ?? null); setCreditPacks((message as any).creditPacks ?? []); + setTransactions((message as any).transactions ?? null); + setUsageByUser((message as any).usageByUser ?? []); + setUsageByTeam((message as any).usageByTeam ?? []); + setTopupPlans((message as any).topupPlans ?? []); break; + // -- Checkout flow responses ------------------------------------------- + case 'checkout:plansResult': { + const r = checkoutResolvers.current.plans; + if (r) { + checkoutResolvers.current.plans = undefined; + if ((message as any).error) r.reject(new Error((message as any).error)); + else r.resolve((message as any).plans ?? []); + } + break; + } + case 'checkout:sessionResult': { + const r = checkoutResolvers.current.session; + if (r) { + checkoutResolvers.current.session = undefined; + if ((message as any).error) r.reject(new Error((message as any).error)); + else r.resolve({ clientSecret: (message as any).clientSecret, subscriptionId: (message as any).subscriptionId }); + } + break; + } + case 'checkout:confirmResult': { + const r = checkoutResolvers.current.confirm; + if (r) { + checkoutResolvers.current.confirm = undefined; + if ((message as any).error) r.reject(new Error((message as any).error)); + else r.resolve(); + } + break; + } + // Env variables removed — now handled by EnvironmentWebview. // -- Error ------------------------------------------------------------ @@ -261,9 +306,42 @@ const AccountWebview: React.FC = () => { sendMessageRef.current({ type: 'billing:buyCredits', packId: pack.id } as any); }, []); - /** Opens the checkout flow for Pipe Builder subscription. */ + /** Opens the inline checkout modal for Pipe Builder subscription. */ const handleSubscribe = useCallback((): void => { - sendMessageRef.current({ type: 'billing:subscribe' } as any); + setShowCheckout(true); + }, []); + + // -- Checkout flow callbacks (bridge to host via postMessage) ---------- + + /** Fetches available plans from the server via the host. */ + const handleFetchPlans = useCallback((): Promise => { + return new Promise((resolve, reject) => { + checkoutResolvers.current.plans = { resolve, reject }; + sendMessageRef.current({ type: 'checkout:fetchPlans' } as any); + }); + }, []); + + /** Creates a Stripe checkout session via the host. */ + const handleCreateCheckout = useCallback((priceId: string): Promise<{ clientSecret: string; subscriptionId: string }> => { + return new Promise((resolve, reject) => { + checkoutResolvers.current.session = { resolve, reject }; + sendMessageRef.current({ type: 'checkout:createSession', priceId } as any); + }); + }, []); + + /** Confirms pending payment via the host. */ + const handleConfirmPending = useCallback((subscriptionId: string, priceId: string): Promise => { + return new Promise((resolve, reject) => { + checkoutResolvers.current.confirm = { resolve, reject }; + sendMessageRef.current({ type: 'checkout:confirmPending', subscriptionId, priceId } as any); + }); + }, []); + + /** Closes the checkout modal and refreshes billing data on success. */ + const handleCheckoutSuccess = useCallback((): void => { + setShowCheckout(false); + // Trigger billing data re-fetch so subscriptions list updates + sendMessageRef.current({ type: 'account:sectionChange', section: 'billing' }); }, []); // ========================================================================= @@ -274,52 +352,77 @@ const AccountWebview: React.FC = () => { // "disconnected" flash while the provider fetches data. if (!ready) return null; + const stripeKey = process.env.RR_STRIPE_PUBLISHABLE_KEY || ''; + return ( - { - setSection(s); - setSectionError(null); - sendMessageRef.current({ type: 'account:sectionChange', section: s }); - }} - activeTeamId={activeTeamId} - onActiveTeamIdChange={setActiveTeamId} - onSaveProfile={handleSaveProfile} - onSetDefaultTeam={handleSetDefaultTeam} - onLogout={handleLogout} - onDeleteAccount={handleDeleteAccount} - onSaveOrgName={handleSaveOrgName} - onCreateKey={handleCreateKey} - onRevokeKey={handleRevokeKey} - onInviteMember={handleInviteMember} - onUpdateMemberRole={handleUpdateMemberRole} - onRemoveMember={handleRemoveMember} - onCreateTeam={handleCreateTeam} - onDeleteTeam={handleDeleteTeam} - onAddTeamMember={handleAddTeamMember} - onEditTeamMemberPerms={handleEditTeamMemberPerms} - onRemoveTeamMember={handleRemoveTeamMember} - onLoadTeamDetail={handleLoadTeamDetail} - /> + <> + {}} + topupPlans={topupPlans} + memberNames={Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId]))} + teamNames={Object.fromEntries(teams.map((t: any) => [t.id, t.name || t.id]))} + section={section} + onSectionChange={(s) => { + setSection(s); + setSectionError(null); + sendMessageRef.current({ type: 'account:sectionChange', section: s }); + }} + activeTeamId={activeTeamId} + onActiveTeamIdChange={setActiveTeamId} + onSaveProfile={handleSaveProfile} + onSetDefaultTeam={handleSetDefaultTeam} + onLogout={handleLogout} + onDeleteAccount={handleDeleteAccount} + onSaveOrgName={handleSaveOrgName} + onCreateKey={handleCreateKey} + onRevokeKey={handleRevokeKey} + onInviteMember={handleInviteMember} + onUpdateMemberRole={handleUpdateMemberRole} + onRemoveMember={handleRemoveMember} + onCreateTeam={handleCreateTeam} + onDeleteTeam={handleDeleteTeam} + onAddTeamMember={handleAddTeamMember} + onEditTeamMemberPerms={handleEditTeamMemberPerms} + onRemoveTeamMember={handleRemoveTeamMember} + onLoadTeamDetail={handleLoadTeamDetail} + /> + {showCheckout && stripeKey && ( + setShowCheckout(false)} + /> + )} + ); }; diff --git a/apps/vscode/src/providers/views/Settings/ConnectionSettings.tsx b/apps/vscode/src/providers/views/Settings/ConnectionSettings.tsx index 34535bf83..7af68cb48 100644 --- a/apps/vscode/src/providers/views/Settings/ConnectionSettings.tsx +++ b/apps/vscode/src/providers/views/Settings/ConnectionSettings.tsx @@ -53,6 +53,13 @@ interface ConnectionSettingsProps { /** Whether the probed server supports SaaS/OAuth. */ isSaas?: boolean; teams?: Array<{ id: string; name: string }>; + /** Whether the user has an active subscription. */ + isSubscribed?: boolean; + /** Checkout callbacks for CloudPanel's embedded CheckoutModal. */ + onFetchPlans?: () => Promise; + onCreateCheckout?: (priceId: string) => Promise<{ clientSecret: string; subscriptionId: string }>; + onConfirmPending?: (subscriptionId: string, priceId: string) => Promise; + onCheckoutSuccess?: () => void; // -- Docker panel props -- dockerStatus: DockerStatus; dockerProgress: string | null; @@ -180,6 +187,11 @@ export const ConnectionSettings: React.FC = (props) => sudoPasswordInput={props.sudoPasswordInput} onSudoPasswordChange={props.onSudoPasswordChange} onSudoSubmit={props.onSudoSubmit} + isSubscribed={props.isSubscribed} + onFetchPlans={props.onFetchPlans} + onCreateCheckout={props.onCreateCheckout} + onConfirmPending={props.onConfirmPending} + onCheckoutSuccess={props.onCheckoutSuccess} />
diff --git a/apps/vscode/src/providers/views/Settings/DeploySettings.tsx b/apps/vscode/src/providers/views/Settings/DeploySettings.tsx index e694455db..9ae6f4d9f 100644 --- a/apps/vscode/src/providers/views/Settings/DeploySettings.tsx +++ b/apps/vscode/src/providers/views/Settings/DeploySettings.tsx @@ -50,6 +50,13 @@ interface DeploySettingsProps { onProbeCloudServer?: () => void; onFetchTeams?: () => void; isSaas?: boolean; + /** Whether the user has an active subscription. */ + isSubscribed?: boolean; + /** Checkout callbacks for CloudPanel. */ + onFetchPlans?: () => Promise; + onCreateCheckout?: (priceId: string) => Promise<{ clientSecret: string; subscriptionId: string }>; + onConfirmPending?: (subscriptionId: string, priceId: string) => Promise; + onCheckoutSuccess?: () => void; // -- Docker panel props -- dockerStatus: DockerStatus; dockerProgress: string | null; @@ -183,6 +190,11 @@ export const DeploySettings: React.FC = (props) => { sudoPasswordInput={props.sudoPasswordInput} onSudoPasswordChange={props.onSudoPasswordChange} onSudoSubmit={props.onSudoSubmit} + isSubscribed={props.isSubscribed} + onFetchPlans={props.onFetchPlans} + onCreateCheckout={props.onCreateCheckout} + onConfirmPending={props.onConfirmPending} + onCheckoutSuccess={props.onCheckoutSuccess} /> )}
diff --git a/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx b/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx index 65e8c5f77..0ba762c7f 100644 --- a/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx +++ b/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx @@ -31,6 +31,7 @@ import { IntegrationSettings } from './IntegrationSettings'; import { DeploySettings } from './DeploySettings'; import { MessageDisplay } from './MessageDisplay'; import { commonStyles } from 'shared/themes/styles'; +import type { CheckoutPlan } from 'shared'; import { TabPanel } from 'shared/components/tab-panel/TabPanel'; import type { ITabPanelTab, ITabPanelPanel } from 'shared/components/tab-panel/TabPanel'; import type { ServiceStatus, DockerStatus, VersionOption } from '../components/panels/shared'; @@ -396,11 +397,18 @@ export const Settings: React.FC = () => { // Cloud auth state const [cloudSignedIn, setCloudSignedIn] = useState(false); - // Subscription state — true means subscribed, defaults to true (no banner until confirmed unsubscribed) - const [subscribed, setSubscribed] = useState(true); + // Subscription state — defaults to false so the subscribe button shows until the host confirms + const [subscribed, setSubscribed] = useState(false); const [cloudUserName, setCloudUserName] = useState(''); const [teams, setTeams] = useState>([]); + // Checkout modal state + const checkoutResolvers = useRef<{ + plans?: { resolve: (v: CheckoutPlan[]) => void; reject: (e: Error) => void }; + session?: { resolve: (v: { clientSecret: string; subscriptionId: string }) => void; reject: (e: Error) => void }; + confirm?: { resolve: () => void; reject: (e: Error) => void }; + }>({}); + // Docker state const [dockerStatus, setDockerStatus] = useState({ state: 'not-installed', version: null, publishedAt: null, imageTag: null }); const [dockerProgress, setDockerProgress] = useState(null); @@ -444,6 +452,10 @@ export const Settings: React.FC = () => { // Deep-clone for cancel/reset so future edits don't mutate the snapshot savedSettingsRef.current = JSON.parse(JSON.stringify(message.settings)); setDirty(false); + // Subscription status is included in the settingsLoaded payload + if ((message as any).isSubscribed !== undefined) { + setSubscribed((message as any).isSubscribed); + } // Pre-fetch versions from GitHub (cached on backend, shared across all modes) setEngineVersionsLoading(true); sendMessage({ type: 'fetchVersions' }); @@ -465,6 +477,35 @@ export const Settings: React.FC = () => { setSubscribed(message.isSubscribed); break; + // -- Checkout flow responses ------------------------------------ + case 'checkout:plansResult' as any: { + const r = checkoutResolvers.current.plans; + if (r) { + checkoutResolvers.current.plans = undefined; + if ((message as any).error) r.reject(new Error((message as any).error)); + else r.resolve((message as any).plans ?? []); + } + break; + } + case 'checkout:sessionResult' as any: { + const r = checkoutResolvers.current.session; + if (r) { + checkoutResolvers.current.session = undefined; + if ((message as any).error) r.reject(new Error((message as any).error)); + else r.resolve({ clientSecret: (message as any).clientSecret, subscriptionId: (message as any).subscriptionId }); + } + break; + } + case 'checkout:confirmResult' as any: { + const r = checkoutResolvers.current.confirm; + if (r) { + checkoutResolvers.current.confirm = undefined; + if ((message as any).error) r.reject(new Error((message as any).error)); + else r.resolve(); + } + break; + } + case 'teamsLoaded' as any: setTeams((message as any).teams || []); break; @@ -759,6 +800,32 @@ export const Settings: React.FC = () => { [] ); + // ── Checkout callbacks (passed to CloudPanel) ────────────────────── + const handleFetchPlans = useCallback((): Promise => { + return new Promise((resolve, reject) => { + checkoutResolvers.current.plans = { resolve, reject }; + sendMessage({ type: 'checkout:fetchPlans' } as any); + }); + }, [sendMessage]); + + const handleCreateCheckout = useCallback((priceId: string): Promise<{ clientSecret: string; subscriptionId: string }> => { + return new Promise((resolve, reject) => { + checkoutResolvers.current.session = { resolve, reject }; + sendMessage({ type: 'checkout:createSession', priceId } as any); + }); + }, [sendMessage]); + + const handleConfirmPending = useCallback((subscriptionId: string, priceId: string): Promise => { + return new Promise((resolve, reject) => { + checkoutResolvers.current.confirm = { resolve, reject }; + sendMessage({ type: 'checkout:confirmPending', subscriptionId, priceId } as any); + }); + }, [sendMessage]); + + const handleCheckoutSuccess = useCallback(() => { + setSubscribed(true); + }, []); + const panels: Record = useMemo( () => ({ development: { @@ -816,6 +883,11 @@ export const Settings: React.FC = () => { sudoPasswordInput={sudoPasswordInput} onSudoPasswordChange={setSudoPasswordInput} onSudoSubmit={handleSudoSubmit} + isSubscribed={subscribed} + onFetchPlans={handleFetchPlans} + onCreateCheckout={handleCreateCheckout} + onConfirmPending={handleConfirmPending} + onCheckoutSuccess={handleCheckoutSuccess} />
), @@ -875,6 +947,11 @@ export const Settings: React.FC = () => { sudoPasswordInput={sudoPasswordInput} onSudoPasswordChange={setSudoPasswordInput} onSudoSubmit={handleSudoSubmit} + isSubscribed={subscribed} + onFetchPlans={handleFetchPlans} + onCreateCheckout={handleCreateCheckout} + onConfirmPending={handleConfirmPending} + onCheckoutSuccess={handleCheckoutSuccess} />
), @@ -907,11 +984,6 @@ export const Settings: React.FC = () => { [settings, message, testMessage, engineVersions, engineVersionsLoading, serverCapabilities, cloudSignedIn, cloudUserName, teams, dockerStatus, dockerProgress, dockerError, dockerBusy, dockerAction, dockerVersionOptions, dockerSelectedVersion, serviceStatus, serviceProgress, serviceError, serviceBusy, serviceAction, serviceVersionOptions, serviceSelectedVersion, sudoPromptVisible, sudoPasswordInput] ); - // ── Subscribe handler ─────────────────────────────────────────────── - const handleSubscribeClick = useCallback(() => { - sendMessage({ type: 'openSubscribe' }); - }, [sendMessage]); - return (
{/* ── Auth error banner (shown when opened due to auth failure) ── */} @@ -930,19 +1002,6 @@ export const Settings: React.FC = () => {
)} - {/* ── Subscribe banner (cloud-signed-in but not subscribed) ── */} - {cloudSignedIn && !subscribed && ( -
-
- - Subscribe to RocketRide Pipe Builder to unlock pipeline execution and advanced features. - - -
-
- )} {/* ── Tab panel ─────────────────────────────────────────── */} diff --git a/apps/vscode/src/providers/views/components/ConnectionConfig.tsx b/apps/vscode/src/providers/views/components/ConnectionConfig.tsx index 2007a4207..01faf331a 100644 --- a/apps/vscode/src/providers/views/components/ConnectionConfig.tsx +++ b/apps/vscode/src/providers/views/components/ConnectionConfig.tsx @@ -63,6 +63,13 @@ export interface ConnectionConfigProps { onFetchTeams?: () => void; isSaas?: boolean; teams: Array<{ id: string; name: string }>; + /** Whether the user has an active subscription. */ + isSubscribed?: boolean; + /** Checkout callbacks for CloudPanel's embedded CheckoutModal. */ + onFetchPlans?: () => Promise; + onCreateCheckout?: (priceId: string) => Promise<{ clientSecret: string; subscriptionId: string }>; + onConfirmPending?: (subscriptionId: string, priceId: string) => Promise; + onCheckoutSuccess?: () => void; // On-prem onClearCredentials: () => void; @@ -212,7 +219,7 @@ export const ConnectionConfig: React.FC = (props) => { {/* Mode-specific panel — hidden when no mode selected or mode has a conflict */} {connectionMode && !modeConflict &&
- {connectionMode === 'cloud' && changeGroup({ teamId: id })} simplified={simplified} isSaas={props.isSaas} onProbeServer={props.onProbeCloudServer} onFetchTeams={props.onFetchTeams} />} + {connectionMode === 'cloud' && changeGroup({ teamId: id })} simplified={simplified} isSaas={props.isSaas} onProbeServer={props.onProbeCloudServer} onFetchTeams={props.onFetchTeams} isSubscribed={props.isSubscribed} onFetchPlans={props.onFetchPlans} onCreateCheckout={props.onCreateCheckout} onConfirmPending={props.onConfirmPending} onCheckoutSuccess={props.onCheckoutSuccess} />} {connectionMode === 'onprem' && changeGroup({ hostUrl: url })} apiKey={groupSettings.apiKey} onApiKeyChange={(key) => changeGroup({ apiKey: key, hasApiKey: key.trim().length > 0 })} onClearApiKey={onClearCredentials} debugOutput={groupSettings.local.debugOutput} onDebugOutputChange={(c) => changeGroup({ local: { debugOutput: c } })} onTestConnection={(hostUrl, apiKey) => onTestConnection('onprem', { hostUrl, apiKey })} testMessage={testMessage} simplified={simplified} />} diff --git a/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx b/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx index 5096d2215..7506f5741 100644 --- a/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx +++ b/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx @@ -10,11 +10,13 @@ * Used by ConnectionSettings (dev) and DeployTargetSettings (deploy). */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import cloudLogoDark from '../../../../../rocketride-dark-icon.png'; import cloudLogoLight from '../../../../../rocketride-light-icon.png'; import { settingsStyles as S } from '../../Settings/SettingsWebview'; import { useTheme } from '../../hooks/useTheme'; +import { CheckoutModal } from 'shared'; +import type { CheckoutPlan } from 'shared'; // ============================================================================= // TYPES @@ -46,15 +48,30 @@ export interface CloudPanelProps { onProbeServer?: (cloudUrl: string) => void; /** Called when isSaas becomes true, to fetch the team list. Receives the cloud endpoint URL. */ onFetchTeams?: (cloudUrl: string) => void; + /** Whether the user has an active subscription. When false, shows a subscribe button. */ + isSubscribed?: boolean; + /** Checkout callbacks -- when provided, CloudPanel renders the CheckoutModal itself. */ + onFetchPlans?: () => Promise; + onCreateCheckout?: (priceId: string) => Promise<{ clientSecret: string; subscriptionId: string }>; + onConfirmPending?: (subscriptionId: string, priceId: string) => Promise; + onCheckoutSuccess?: () => void; } // ============================================================================= // COMPONENT // ============================================================================= -export const CloudPanel: React.FC = ({ cloudSignedIn, cloudUserName, onCloudSignIn, onCloudSignOut, teams, selectedTeamId, onTeamChange, idPrefix, isSaas, onProbeServer, onFetchTeams }) => { +export const CloudPanel: React.FC = ({ cloudSignedIn, cloudUserName, onCloudSignIn, onCloudSignOut, teams, selectedTeamId, onTeamChange, idPrefix, isSaas, onProbeServer, onFetchTeams, isSubscribed, onFetchPlans, onCreateCheckout, onConfirmPending, onCheckoutSuccess }) => { const id = (name: string) => `${idPrefix}-${name}`; const theme = useTheme(); + const [showCheckout, setShowCheckout] = useState(false); + + const stripeKey = process.env.RR_STRIPE_PUBLISHABLE_KEY || ''; + + const handleCheckoutSuccess = useCallback(() => { + setShowCheckout(false); + onCheckoutSuccess?.(); + }, [onCheckoutSuccess]); const cloudUrl = process.env.ROCKETRIDE_URI || ''; @@ -131,6 +148,36 @@ export const CloudPanel: React.FC = ({ cloudSignedIn, cloudUser
)} + {/* Subscribe prompt — shown when signed in but not subscribed */} + {isSaas && cloudSignedIn && isSubscribed === false && onFetchPlans && ( +
+
+ You are currently not subscribed to the RocketRide Cloud. You will be able to run all your pipelines locally, but to run them in the cloud, or deploy pipelines to the cloud, requires a subscription. +
+ +
+ )} + + {/* Checkout modal overlay */} + {showCheckout && stripeKey && onFetchPlans && onCreateCheckout && onConfirmPending && ( + setShowCheckout(false)} + /> + )} + ); }; diff --git a/apps/vscode/src/shared/util/subscriptionGate.ts b/apps/vscode/src/shared/util/subscriptionGate.ts index d65c7c73c..ba501b711 100644 --- a/apps/vscode/src/shared/util/subscriptionGate.ts +++ b/apps/vscode/src/shared/util/subscriptionGate.ts @@ -38,11 +38,9 @@ export function isSubscribed(client: RocketRideClient | undefined, appId: string const capabilities: string[] = info.capabilities ?? []; if (!capabilities.includes('saas')) return true; - // Check ConnectResult.apps for the desktop app entry + // Subscribed only if the app has an active or trialing subscription. + // past_due means payment failed — lock until resolved. const entry = (info.apps ?? []).find((a) => a.id === appId); if (!entry) return false; - - // Allow launch for free, subscribed, trialing, and unsubscribed (paywall base features) - const allowed = ['free', 'subscribed', 'trialing', 'unsubscribed']; - return !!entry.appStatus && allowed.includes(entry.appStatus); + return entry.appStatus === 'subscribed' || entry.appStatus === 'trialing'; } diff --git a/packages/ai/src/ai/account/base.py b/packages/ai/src/ai/account/base.py index 30b2f5726..17c57da9b 100644 --- a/packages/ai/src/ai/account/base.py +++ b/packages/ai/src/ai/account/base.py @@ -230,6 +230,7 @@ async def apply_debit( amount: float, idempotency_key: str, context: dict, + description: str | None = None, ) -> bool: """ Debit an org's ledger (UPSERT for task usage). @@ -244,11 +245,11 @@ async def apply_debit( org_id: Organisation to debit. user_id: User whose task triggered the burn (required for attribution). team_id: Team the task belongs to (required for attribution). - resource: Resource being consumed (e.g. ``cpu_utilization``, ``gpu_memory``). + resource: Billing bucket (e.g. tokens, video, audio). amount: Positive amount to debit (negated internally). - idempotency_key: Namespaced dedup key (e.g. ``task:abc123:cpu_utilization``). + idempotency_key: Namespaced dedup key (e.g. ``task:abc123:gpu_memory``). context: Human-readable audit context — pipeline name, source, etc. - Required so org admins can understand what the charge is for. + description: Line-item detail (e.g. gpu_memory, cpu_utilization). Returns: True on success, False on duplicate or no-op. diff --git a/packages/ai/src/ai/modules/task/task_engine.py b/packages/ai/src/ai/modules/task/task_engine.py index 3a4a64255..6b68c8248 100644 --- a/packages/ai/src/ai/modules/task/task_engine.py +++ b/packages/ai/src/ai/modules/task/task_engine.py @@ -1600,6 +1600,8 @@ async def start_task(self) -> None: user_id=getattr(_control, 'userId', '') if _control else '', team_id=getattr(_control, 'teamId', '') if _control else '', org_id=getattr(_control, 'orgId', '') if _control else '', + pipeline_name=self._task_name or '', + source_name=self._status.name or self.source or '', on_update_callback=self._on_metrics_updated, ) self._task_metrics.start_monitoring() diff --git a/packages/ai/src/ai/modules/task/task_metrics.py b/packages/ai/src/ai/modules/task/task_metrics.py index cafb6c9c9..e6d867ed4 100644 --- a/packages/ai/src/ai/modules/task/task_metrics.py +++ b/packages/ai/src/ai/modules/task/task_metrics.py @@ -18,6 +18,7 @@ import asyncio import time +import uuid import psutil from typing import Optional, TYPE_CHECKING, Callable from rocketlib import debug @@ -65,6 +66,8 @@ def __init__( user_id: Optional[str] = None, team_id: Optional[str] = None, org_id: Optional[str] = None, + pipeline_name: Optional[str] = None, + source_name: Optional[str] = None, sample_interval: Optional[float] = None, on_update_callback: Optional[Callable[[], None]] = None, ): @@ -91,10 +94,17 @@ def __init__( """ self.pid = pid self.task_id = task_id + # Unique per-run identifier for billing idempotency. task_id is a + # display ID (e.g. "44568e99.dropper_1") that can repeat across runs + # with the same token. The billing_run_id ensures each run gets its + # own ledger rows even if the display task_id is reused. + self.billing_run_id = str(uuid.uuid4()) self.client_id = client_id self.user_id = user_id or '' self.team_id = team_id or '' self.org_id = org_id or '' + self.pipeline_name = pipeline_name or '' + self.source_name = source_name or '' self.sample_interval = sample_interval if sample_interval is not None else CONST_METRICS_SAMPLE_INTERVAL self._on_update_callback = on_update_callback @@ -452,15 +462,18 @@ async def _report_to_billing_system(self) -> None: """ # ── Build cumulative token totals ──────────────────────────────── # All values are already converted to tokens by _update_tokens(). - # OS-level metrics (cpu, memory, gpu) and subprocess-reported custom - # counters are treated identically — one ledger row per resource. - token_totals: dict[str, float] = { - 'cpu_utilization': self._status.tokens.cpu_utilization, - 'cpu_memory': self._status.tokens.cpu_memory, - 'gpu_memory': self._status.tokens.gpu_memory, - 'gpu_inference': self._status.tokens.gpu_inference, - **self._status.tokens.custom, - } + # Each entry is (resource_bucket, description, amount). + # Infrastructure metrics (cpu, memory, gpu) bill against 'tokens'; + # custom counters may bill against their own resource bucket. + token_totals: list[tuple[str, str, float]] = [ + ('tokens', 'cpu_utilization', self._status.tokens.cpu_utilization), + ('tokens', 'cpu_memory', self._status.tokens.cpu_memory), + ('tokens', 'gpu_memory', self._status.tokens.gpu_memory), + ('tokens', 'gpu_inference', self._status.tokens.gpu_inference), + ] + # Custom counters use the counter name as both resource and description + for counter_name, counter_tokens in self._status.tokens.custom.items(): + token_totals.append((counter_name, counter_name, counter_tokens)) # ── Debug logging ──────────────────────────────────────────────── try: debug( @@ -480,14 +493,16 @@ async def _report_to_billing_system(self) -> None: context = { 'task_id': self.task_id, + 'pipeline': self.pipeline_name, + 'source': self.source_name, 'client_id': self.client_id, 'duration_seconds': round(self._duration_seconds, 1), 'tokens_total': round(self._status.tokens.total, 2), } - for resource, amount in token_totals.items(): + for resource, description, amount in token_totals: if amount <= 0: continue - idem_key = f'task:{self.task_id}:{resource}' + idem_key = f'task:{self.billing_run_id}:{description}' await account.apply_debit( org_id=self.org_id, user_id=self.user_id, @@ -496,6 +511,7 @@ async def _report_to_billing_system(self) -> None: amount=amount, idempotency_key=idem_key, context=context, + description=description, ) except Exception as e: debug(f'[TaskMetrics] Error writing to billing ledger: {e}') diff --git a/packages/client-python/src/rocketride/billing.py b/packages/client-python/src/rocketride/billing.py index e968d453b..3c8a0dad8 100644 --- a/packages/client-python/src/rocketride/billing.py +++ b/packages/client-python/src/rocketride/billing.py @@ -38,10 +38,12 @@ from typing import TYPE_CHECKING from .types.billing import ( + AppPrice, BillingDetail, CreditBalance, CreditPack, - StripePlan, + TransactionsResult, + UsageRollup, ) if TYPE_CHECKING: @@ -83,7 +85,7 @@ async def get_details(self, org_id: str) -> list[BillingDetail]: body = await self._client.call('rrext_account_billing', subcommand='list', orgId=org_id) return body.get('subscriptions', []) - async def get_product_prices(self, app_id: str) -> list[StripePlan]: + async def get_product_prices(self, app_id: str) -> list[AppPrice]: """ Fetch the active subscription plans (prices) for an app. @@ -96,7 +98,7 @@ async def get_product_prices(self, app_id: str) -> list[StripePlan]: app_id: App identifier (e.g. "rocketride.pipeBuilder"). Returns: - Array of StripePlan objects ready for display. + Array of AppPrice rows from the local database. """ body = await self._client.call('rrext_account_billing', subcommand='prices', appId=app_id) return body.get('plans', []) @@ -204,6 +206,76 @@ async def list_credit_packs(self) -> list[CreditPack]: body = await self._client.call('rrext_account_billing', subcommand='credits_packs') return body.get('packs', []) + # ========================================================================= + # TRANSACTIONS & USAGE + # ========================================================================= + + async def get_transactions( + self, + org_id: str, + scope: str = 'org', + scope_id: str | None = None, + page: int = 1, + page_size: int = 50, + since: str | None = None, + ) -> TransactionsResult: + """ + Fetch paginated transaction detail from the credit ledger. + + Args: + org_id: Organisation UUID. + scope: ``org``, ``team``, or ``user``. + scope_id: Team or user ID when scope is not ``org``. + page: 1-based page number. + page_size: Rows per page (max 100). + since: ISO datetime string -- only return rows at or after this time. + + Returns: + Paginated transaction result. + """ + kwargs: dict = { + 'subcommand': 'transactions', + 'orgId': org_id, + 'scope': scope, + 'page': page, + 'pageSize': page_size, + } + if scope_id: + kwargs['scopeId'] = scope_id + if since: + kwargs['since'] = since + return await self._client.call('rrext_account_billing', **kwargs) + + async def get_usage_by_user(self, org_id: str) -> list[UsageRollup]: + """ + Fetch per-user consumption rollup for an org. + + Args: + org_id: Organisation UUID. + + Returns: + List of usage rollup rows ordered by total consumption descending. + """ + body = await self._client.call('rrext_account_billing', subcommand='usage_by_user', orgId=org_id) + return body.get('usage', []) + + async def get_usage_by_team(self, org_id: str) -> list[UsageRollup]: + """ + Fetch per-team consumption rollup for an org. + + Args: + org_id: Organisation UUID. + + Returns: + List of usage rollup rows ordered by total consumption descending. + """ + body = await self._client.call('rrext_account_billing', subcommand='usage_by_team', orgId=org_id) + return body.get('usage', []) + + # ========================================================================= + # CREDIT PACK CHECKOUT + # ========================================================================= + async def create_credit_checkout( self, org_id: str, diff --git a/packages/client-python/src/rocketride/types/__init__.py b/packages/client-python/src/rocketride/types/__init__.py index be5eb8783..9e03c80eb 100644 --- a/packages/client-python/src/rocketride/types/__init__.py +++ b/packages/client-python/src/rocketride/types/__init__.py @@ -155,11 +155,15 @@ def handle_status(status: TASK_STATUS) -> None: # Billing types: subscriptions, Stripe plans, compute credits. from .billing import ( + AppPrice, BillingDetail, PlanAction, StripePlan, CreditBalance, CreditPack, + LedgerTransaction, + TransactionsResult, + UsageRollup, ) # Service types: shapes for service discovery responses, slot/lane descriptors, @@ -255,9 +259,13 @@ def handle_status(status: TASK_STATUS) -> None: # Deploy types 'DeploymentRecord', # Billing types + 'AppPrice', 'BillingDetail', 'PlanAction', 'StripePlan', 'CreditBalance', 'CreditPack', + 'LedgerTransaction', + 'TransactionsResult', + 'UsageRollup', ] diff --git a/packages/client-python/src/rocketride/types/billing.py b/packages/client-python/src/rocketride/types/billing.py index f5111ffd2..3ad151c19 100644 --- a/packages/client-python/src/rocketride/types/billing.py +++ b/packages/client-python/src/rocketride/types/billing.py @@ -95,36 +95,39 @@ class PlanAction(TypedDict): label: str -class StripePlan(TypedDict): +class AppPrice(TypedDict): """ - Stripe plan/price row for a given product, returned by the ``prices`` - subcommand. Used in the checkout plan picker. + App pricing tier row from the ``app_prices`` table. + + Returned by the ``prices`` subcommand. Used in the checkout plan picker. Attributes: - priceId: Stripe price_* identifier. - label: Human-readable label shown in the plan selector (e.g. "Starter", "Pro"). - amount: Display price string (e.g. "$29", "$290", "Free", "Custom"). - cents: Price in USD cents. - currency: ISO currency code. - interval: Billing interval: "month", "year", "one_time", or empty for non-recurring plans. - description: Feature description lines from Stripe price metadata, or None. - action: Alternative click action (link/mailto). None means normal checkout. - order: Sort order for card positioning. Lower values appear first. Defaults to 500. - credits: Credit grants config from Stripe price metadata, or None. - labels: Display templates for credit resource types, or None. + id: Internal price UUID. + appId: App identifier. + stripePriceId: Stripe price_* identifier. + nickname: Human-readable tier label (e.g. "Starter", "Pro"). + amountCents: Price in smallest currency unit (e.g. cents for USD). + currency: ISO 4217 currency code. + interval: Billing interval: "month", "year", or "one_time". + metadata: Full plan metadata (description, action, order, kind, credits, etc.). + isActive: Whether the price is active. + createdAt: ISO 8601 creation timestamp, or None. """ - priceId: str - label: str - amount: str - cents: int + id: str + appId: str + stripePriceId: str + nickname: str + amountCents: int currency: str interval: Literal['month', 'year', 'one_time', ''] - description: NotRequired[list[str] | None] - action: NotRequired[PlanAction | None] - order: NotRequired[int] - credits: NotRequired[dict[str, dict[str, int]] | None] - labels: NotRequired[dict[str, str] | None] + metadata: NotRequired[dict | None] + isActive: bool + createdAt: str | None + + +# Backward compatibility alias +StripePlan = AppPrice # ============================================================================= @@ -134,23 +137,19 @@ class StripePlan(TypedDict): class CreditBalance(TypedDict): """ - Multi-resource credit balance for an organisation's wallet. + Net credit balance for an organisation, grouped by resource. - Returned by the ``credits_balance`` subcommand. Each field is a dict - keyed by resource type (e.g. ``{"tokens": 4200, "video": 80}``). + Returned by the ``credits_balance`` subcommand. Balance is computed from + ``SUM(amount) GROUP BY resource`` on the credit ledger. Attributes: - balances: Current unspent balances per resource type. - lifetimePurchased: Total purchased per resource type. - lifetimeConsumed: Total consumed per resource type. + balances: Net balance per resource type (positive = remaining, negative = overspent). labels: Human-readable display templates per resource type, from Stripe price metadata. Supports ``{amount}`` substitution. Falls back to the raw resource key when a label is not configured. """ - balances: dict[str, int] - lifetimePurchased: dict[str, int] - lifetimeConsumed: dict[str, int] + balances: dict[str, float] labels: dict[str, str] @@ -174,3 +173,67 @@ class CreditPack(TypedDict): usdCents: int credits: int nickname: str + + +# ============================================================================= +# TRANSACTION TYPES +# ============================================================================= + + +class LedgerTransaction(TypedDict): + """ + A single ledger transaction row returned by the ``transactions`` subcommand. + + Attributes: + id: Auto-increment row ID. + type: Transaction type (purchase, usage, credit, refund, etc.). + resource: Resource type (e.g. cpu_utilization, gpu_memory, tokens). + amount: Signed amount (positive for credits, negative for debits). + idempotencyKey: Namespaced dedup key. + userId: User who triggered the transaction, or None. + teamId: Team context, or None. + context: Human-readable audit context, or None. + createdAt: ISO 8601 creation timestamp, or None. + """ + + id: int + type: str + resource: str + amount: float + idempotencyKey: str + userId: str | None + teamId: str | None + context: dict | None + createdAt: str | None + + +class TransactionsResult(TypedDict): + """ + Paginated result from the ``transactions`` subcommand. + + Attributes: + transactions: Transaction rows for the current page. + total: Total matching rows (for pagination). + page: Current page number (1-based). + pageSize: Rows per page. + """ + + transactions: list[LedgerTransaction] + total: int + page: int + pageSize: int + + +class UsageRollup(TypedDict): + """ + Per-user or per-team consumption rollup row. + + Returned by ``usage_by_user`` / ``usage_by_team`` subcommands. + + Attributes: + id: User or team ID (or '__none__' for unattributed). + credits: Consumption per resource type (absolute values). + """ + + id: str + credits: dict[str, float] diff --git a/packages/client-typescript/src/client/billing.ts b/packages/client-typescript/src/client/billing.ts index 2b687b23b..c45c227ae 100644 --- a/packages/client-typescript/src/client/billing.ts +++ b/packages/client-typescript/src/client/billing.ts @@ -31,7 +31,7 @@ */ import type { RocketRideClient } from './client.js'; -import type { BillingDetail, StripePlan, CreditBalance, CreditPack } from './types/billing.js'; +import type { BillingDetail, AppPrice, CreditBalance, CreditPack, TransactionsResult, UsageRollup } from './types/billing.js'; // ============================================================================= // BILLING API CLASS @@ -72,9 +72,9 @@ export class BillingApi { * changes in the Stripe dashboard are reflected immediately. * * @param appId - App identifier (e.g. "rocketride.pipeBuilder"). - * @returns Array of StripePlan objects ready for display. + * @returns Array of AppPrice rows from the local database. */ - async getProductPrices(appId: string): Promise { + async getProductPrices(appId: string): Promise { const body = await this.client.call('rrext_account_billing', { subcommand: 'prices', appId }); return body.plans ?? []; } @@ -163,6 +163,54 @@ export class BillingApi { return body.packs ?? []; } + // ========================================================================= + // TRANSACTIONS & USAGE + // ========================================================================= + + /** + * Fetches paginated transaction detail from the credit ledger. + * + * @param orgId - Organisation UUID. + * @param options - Pagination and scope options. + * @returns Paginated transaction result. + */ + async getTransactions( + orgId: string, + options: { scope?: 'org' | 'team' | 'user'; scopeId?: string; page?: number; pageSize?: number; since?: string } = {}, + ): Promise { + return this.client.call('rrext_account_billing', { + subcommand: 'transactions', + orgId, + ...options, + }); + } + + /** + * Fetches per-user consumption rollup for an org. + * + * @param orgId - Organisation UUID. + * @returns Array of usage rollup rows ordered by total consumption descending. + */ + async getUsageByUser(orgId: string): Promise { + const body = await this.client.call('rrext_account_billing', { subcommand: 'usage_by_user', orgId }); + return body.usage ?? []; + } + + /** + * Fetches per-team consumption rollup for an org. + * + * @param orgId - Organisation UUID. + * @returns Array of usage rollup rows ordered by total consumption descending. + */ + async getUsageByTeam(orgId: string): Promise { + const body = await this.client.call('rrext_account_billing', { subcommand: 'usage_by_team', orgId }); + return body.usage ?? []; + } + + // ========================================================================= + // CREDIT PACK CHECKOUT + // ========================================================================= + /** * Creates a one-off Stripe Checkout session for a credit pack purchase * and returns the redirect URL. diff --git a/packages/client-typescript/src/client/types/billing.ts b/packages/client-typescript/src/client/types/billing.ts index 59761f053..04c49f28b 100644 --- a/packages/client-typescript/src/client/types/billing.ts +++ b/packages/client-typescript/src/client/types/billing.ts @@ -96,64 +96,59 @@ export interface PlanAction { } /** - * Stripe plan/price row for a given product, returned by the `prices` - * subcommand. Used in the checkout plan picker. + * App pricing tier row from the ``app_prices`` table. + * Returned by the ``prices`` subcommand. Used in the checkout plan picker. */ -export interface StripePlan { - /** Stripe price_* identifier. */ - priceId: string; +export interface AppPrice { + /** Internal price UUID. */ + id: string; - /** Human-readable label shown in the plan selector (e.g. "Starter", "Pro"). */ - label: string; + /** App identifier. */ + appId: string; - /** Display price string (e.g. "$29 / mo", "$290 / yr", "Free", "Custom"). */ - amount: string; + /** Stripe price_* identifier. */ + stripePriceId: string; - /** Price in USD cents. */ - cents: number; + /** Human-readable tier label (e.g. "Starter", "Pro", "3,700 tokens"). */ + nickname: string; + + /** Price in smallest currency unit (e.g. cents for USD). */ + amountCents: number; - /** ISO currency code. */ + /** ISO 4217 currency code. */ currency: string; - /** Billing interval: "month", "year", "one_time", or empty for non-recurring plans. */ + /** Billing interval: "month", "year", or "one_time". */ interval: 'month' | 'year' | 'one_time' | ''; - /** Feature description lines from Stripe price metadata, or null. */ - description?: string[] | null; - - /** Alternative click action (link/mailto). Null means normal checkout. */ - action?: PlanAction | null; + /** Full plan metadata from the app manifest (description, action, order, kind, credits, labels, seats, features, etc.). */ + metadata?: Record | null; - /** Sort order for card positioning. Lower values appear first. Defaults to 500. */ - order?: number; - - /** Credit grants config from Stripe price metadata, or null. */ - credits?: { initial?: Record; recurring?: Record } | null; + /** Whether the price is active. */ + isActive: boolean; - /** Display templates for credit resource types, or null. */ - labels?: Record | null; + /** ISO 8601 creation timestamp. */ + createdAt: string | null; } +/** @deprecated Use {@link AppPrice} instead. */ +export type StripePlan = AppPrice; + // ============================================================================= // COMPUTE CREDITS TYPES // ============================================================================= /** - * Multi-resource credit balance for an organisation's wallet. + * Net credit balance for an organisation, grouped by resource. * Returned by the `credits_balance` subcommand. * - * Each field is a dict keyed by resource type (e.g. ``{ tokens: 4200, video: 80 }``). + * Balance is computed from ``SUM(amount) GROUP BY resource`` on the credit + * ledger. Positive = net credit remaining, negative = overspent. */ export interface CreditBalance { - /** Current unspent balances per resource type. */ + /** Net balance per resource type (positive = remaining, negative = overspent). */ balances: Record; - /** Total purchased per resource type — useful for ledger display. */ - lifetimePurchased: Record; - - /** Total consumed per resource type — useful for ledger display. */ - lifetimeConsumed: Record; - /** * Human-readable display templates per resource type, from Stripe price metadata. * Supports ``{amount}`` substitution (e.g. ``"{amount} minutes of Audio"``). @@ -162,6 +157,70 @@ export interface CreditBalance { labels: Record; } +// ============================================================================= +// TRANSACTION TYPES +// ============================================================================= + +/** + * A single ledger transaction row returned by the `transactions` subcommand. + */ +export interface LedgerTransaction { + /** Auto-increment row ID. */ + id: number; + + /** Transaction type: purchase, usage, credit, refund, etc. */ + type: string; + + /** Resource type (e.g. cpu_utilization, gpu_memory, tokens). */ + resource: string; + + /** Signed amount: positive for credits, negative for debits. */ + amount: number; + + /** Namespaced idempotency key (e.g. task:abc123:cpu_utilization, stripe:cs_xxx:tokens). */ + idempotencyKey: string; + + /** User who triggered the transaction, or null for system events. */ + userId: string | null; + + /** Team context, or null. */ + teamId: string | null; + + /** Human-readable context (pipeline name, source, pack_id, etc.). */ + context: Record | null; + + /** ISO 8601 creation timestamp. */ + createdAt: string | null; +} + +/** + * Paginated result from the `transactions` subcommand. + */ +export interface TransactionsResult { + /** Transaction rows for the current page. */ + transactions: LedgerTransaction[]; + + /** Total matching rows (for pagination). */ + total: number; + + /** Current page number (1-based). */ + page: number; + + /** Rows per page. */ + pageSize: number; +} + +/** + * Per-user or per-team consumption rollup row returned by usage_by_user / usage_by_team. + */ +export interface UsageRollup { + /** User or team ID (or '__none__' for unattributed). */ + id: string; + + /** Consumption per resource type (absolute values — always positive). */ + credits: Record; +} + /** * Per-pack pricing row for the credit top-up modal. * Mirrors the output of the Terraform `credit_packs` map so operators diff --git a/packages/shared-ui/src/assets/icons/PadlockIcon.tsx b/packages/shared-ui/src/assets/icons/PadlockIcon.tsx index fc5a7c89b..8f857535e 100644 --- a/packages/shared-ui/src/assets/icons/PadlockIcon.tsx +++ b/packages/shared-ui/src/assets/icons/PadlockIcon.tsx @@ -31,11 +31,11 @@ import { IIconProps } from './types'; * @param props - Standard icon props for controlling size. */ const PadlockIcon: FunctionComponent = ({ size }) => { + const s = size ?? 24; return ( - - - - + + + ); }; diff --git a/packages/shared-ui/src/components/canvas/components/node/node-component/run-button/RunButton.tsx b/packages/shared-ui/src/components/canvas/components/node/node-component/run-button/RunButton.tsx index 762bd842b..bb258eca8 100644 --- a/packages/shared-ui/src/components/canvas/components/node/node-component/run-button/RunButton.tsx +++ b/packages/shared-ui/src/components/canvas/components/node/node-component/run-button/RunButton.tsx @@ -161,8 +161,8 @@ export default function RunButton({ nodeId }: IRunButtonProps): ReactElement { > - - + + diff --git a/packages/shared-ui/src/components/status/StatusHeader.tsx b/packages/shared-ui/src/components/status/StatusHeader.tsx index a7af378da..d52b2991c 100644 --- a/packages/shared-ui/src/components/status/StatusHeader.tsx +++ b/packages/shared-ui/src/components/status/StatusHeader.tsx @@ -214,8 +214,8 @@ export const StatusActions: React.FC = ({ taskStatus, onPipe > {btn.label} {isSubscribed === false && btn.action === 'run' && ( - - + + )} diff --git a/packages/shared-ui/src/modules/account/AccountView.tsx b/packages/shared-ui/src/modules/account/AccountView.tsx index b42a0ce64..853fbb48b 100644 --- a/packages/shared-ui/src/modules/account/AccountView.tsx +++ b/packages/shared-ui/src/modules/account/AccountView.tsx @@ -23,7 +23,8 @@ import { TabPanel } from '../../components/tab-panel/TabPanel'; import { commonStyles } from '../../themes/styles'; import type { ITabPanelTab, ITabPanelPanel } from '../../components/tab-panel/TabPanel'; import type { ConnectResult, ApiKeyRecord, OrgDetail, MemberRecord, TeamRecord, TeamDetail, TeamMemberRecord, AccountSection, ProfileUpdate } from './types'; -import type { BillingDetail, CreditBalance, CreditPack } from '../billing/types'; +import type { BillingDetail, CreditBalance, CreditPack, TransactionsResult, UsageRollup } from '../billing/types'; +import type { ActiveTask } from '../billing/components/BillingDashboard'; import { ProfilePanel } from './components/ProfilePanel'; // EnvScopeCard removed — env management is now in the standalone Environment page import { BillingPanel } from './components/BillingPanel'; @@ -153,6 +154,28 @@ export interface IAccountViewProps { /** Called when the user clicks the Subscribe CTA. Opens the checkout flow. */ onSubscribe?: () => void; + // -- Dashboard data (admin billing insights) ------------------------------- + /** Paginated transaction result for the transaction log. */ + transactions?: TransactionsResult | null; + /** Per-user usage rollup. */ + usageByUser?: UsageRollup[]; + /** Per-team usage rollup. */ + usageByTeam?: UsageRollup[]; + /** Currently running tasks with live token data. */ + activeTasks?: ActiveTask[]; + /** Whether dashboard data is still loading. */ + dashboardLoading?: boolean; + /** Callback to change the transaction page. */ + onTransactionPage?: (page: number) => void; + /** Member lookup: userId -> display name. */ + memberNames?: Record; + /** Team lookup: teamId -> display name. */ + teamNames?: Record; + /** Available top-up packs (filtered from plans by kind='topup'). */ + topupPlans?: any[]; + /** Callback when user clicks a top-up pack. */ + onBuyTopup?: (plan: any) => void; + // -- Navigation state ------------------------------------------------------ /** The currently active section / tab. */ section: AccountSection; @@ -214,7 +237,7 @@ export interface IAccountViewProps { * to the host via async callback props defined in IAccountViewProps. */ const AccountView: React.FC = (props) => { - const { isConnected, sectionError, profile, authUser, keys, org, members, teams, teamDetail, subscriptions, billingLoading, billingError, creditBalance, creditPacks, apps, onCancelSubscription, onOpenPortal, onBuyCredits, onSubscribe, section, onSectionChange, activeTeamId, onActiveTeamIdChange, onSaveProfile, onSetDefaultTeam, onLogout, onDeleteAccount, onSaveOrgName, onCreateKey, onRevokeKey, onInviteMember, onUpdateMemberRole, onRemoveMember, onCreateTeam, onDeleteTeam, onAddTeamMember, onEditTeamMemberPerms, onRemoveTeamMember, onLoadTeamDetail } = props; + const { isConnected, sectionError, profile, authUser, keys, org, members, teams, teamDetail, subscriptions, billingLoading, billingError, creditBalance, creditPacks, apps, onCancelSubscription, onOpenPortal, onBuyCredits, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, memberNames, teamNames, topupPlans, onBuyTopup, section, onSectionChange, activeTeamId, onActiveTeamIdChange, onSaveProfile, onSetDefaultTeam, onLogout, onDeleteAccount, onSaveOrgName, onCreateKey, onRevokeKey, onInviteMember, onUpdateMemberRole, onRemoveMember, onCreateTeam, onDeleteTeam, onAddTeamMember, onEditTeamMemberPerms, onRemoveTeamMember, onLoadTeamDetail } = props; // ========================================================================= // PERMISSION HELPERS @@ -678,7 +701,7 @@ const AccountView: React.FC = (props) => { billing: { content: (
- +
), }, diff --git a/packages/shared-ui/src/modules/account/components/BillingPanel.tsx b/packages/shared-ui/src/modules/account/components/BillingPanel.tsx index 528a329f6..07561e919 100644 --- a/packages/shared-ui/src/modules/account/components/BillingPanel.tsx +++ b/packages/shared-ui/src/modules/account/components/BillingPanel.tsx @@ -18,8 +18,10 @@ import React from 'react'; import type { CSSProperties } from 'react'; import { commonStyles } from '../../../themes/styles'; -import type { BillingDetail, CreditBalance, CreditPack } from '../../billing/types'; +import type { BillingDetail, CreditBalance, CreditPack, TransactionsResult, UsageRollup } from '../../billing/types'; import { CreditsPanel } from '../../billing/components/CreditsPanel'; +import { BillingDashboard } from '../../billing/components/BillingDashboard'; +import type { ActiveTask, TopupPlan } from '../../billing/components/BillingDashboard'; import { S as SharedS, Badge } from './shared'; // ============================================================================= @@ -148,6 +150,28 @@ export interface BillingPanelProps { apps?: Array<{ id: string; name: string; icon?: string; description?: string }>; /** Called when the user clicks the Subscribe CTA. Opens the checkout flow. */ onSubscribe?: () => void; + + // ── Dashboard data (admin insights) ───────────────────────────────────── + /** Paginated transaction result for the transaction log. */ + transactions?: TransactionsResult | null; + /** Per-user usage rollup. */ + usageByUser?: UsageRollup[]; + /** Per-team usage rollup. */ + usageByTeam?: UsageRollup[]; + /** Currently running tasks with live token data. */ + activeTasks?: ActiveTask[]; + /** Whether dashboard data is still loading. */ + dashboardLoading?: boolean; + /** Callback to change the transaction page. */ + onTransactionPage?: (page: number) => void; + /** Available top-up packs. */ + topupPlans?: TopupPlan[]; + /** Callback when user clicks a top-up pack. */ + onBuyTopup?: (plan: TopupPlan) => void; + /** Member lookup: userId -> display name. */ + memberNames?: Record; + /** Team lookup: teamId -> display name. */ + teamNames?: Record; } // ============================================================================= @@ -160,7 +184,7 @@ export interface BillingPanelProps { * Renders compute credits and subscription rows using the standard card * pattern. The cancel confirmation dialog is owned by AccountView. */ -export const BillingPanel: React.FC = ({ isConnected, subscriptions, loading, error, creditBalance, creditPacks, apps, onCancelSubscription, onOpenPortal, onBuyCredits, isOrgAdmin, onSubscribe }) => { +export const BillingPanel: React.FC = ({ isConnected, subscriptions, loading, error, creditBalance, creditPacks, apps, onCancelSubscription, onOpenPortal, onBuyCredits, isOrgAdmin, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, topupPlans, onBuyTopup, memberNames, teamNames }) => { // Build appId → app lookup for display name resolution const appMap = React.useMemo(() => { const map: Record = {}; @@ -284,7 +308,7 @@ export const BillingPanel: React.FC = ({ isConnected, subscri
{sv.label} {isCancelable && isOrgAdmin && ( - )} @@ -296,6 +320,23 @@ export const BillingPanel: React.FC = ({ isConnected, subscri )}
+ + {/* Admin billing dashboard */} + {isOrgAdmin && isConnected && ( + {})} + onBuyTopup={onBuyTopup} + memberNames={memberNames} + teamNames={teamNames} + /> + )} ); }; diff --git a/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx b/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx new file mode 100644 index 000000000..3b8f2630e --- /dev/null +++ b/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx @@ -0,0 +1,632 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// ============================================================================= + +/** + * BillingDashboard -- admin billing insights rendered below the credits panel. + * + * Five sections: + * 1. Balance breakdown -- purchased vs consumed per resource with bars + * 2. Spending velocity -- burn rate + days remaining projection + * 3. Usage leaderboard -- top consumers by user or team + * 4. Transaction log -- paginated ledger detail + * 5. Active tasks -- live running tasks (placeholder for live data) + * + * All data is received as props; the host (AccountPage) is responsible for + * fetching via the BillingApi. + */ + +import React, { useState, useMemo } from 'react'; +import type { CSSProperties } from 'react'; +import { commonStyles } from '../../../themes/styles'; +import type { CreditBalance, LedgerTransaction, TransactionsResult, UsageRollup } from '../types'; + +// ============================================================================= +// STYLES +// ============================================================================= + +const S = { + /** Dashboard section card. */ + card: { + ...commonStyles.card, + marginTop: 16, + marginBottom: 0, + } as CSSProperties, + + /** Card section heading. */ + heading: { + ...commonStyles.cardHeader, + } as CSSProperties, + + /** Card body with padding. */ + body: { + padding: '12px 18px', + } as CSSProperties, + + /** Resource row in the balance breakdown. */ + resourceRow: { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '6px 0', + } as CSSProperties, + + /** Resource name label. */ + resourceLabel: { + fontSize: 12, + fontWeight: 500, + color: 'var(--rr-text-primary)', + width: 120, + flexShrink: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + } as CSSProperties, + + /** Bar track (background). */ + barTrack: { + flex: 1, + height: 8, + borderRadius: 4, + background: 'var(--rr-bg-surface-alt)', + overflow: 'hidden', + position: 'relative' as const, + } as CSSProperties, + + /** Bar fill (consumed portion). */ + barFill: (pct: number): CSSProperties => ({ + width: `${Math.min(pct, 100)}%`, + height: '100%', + borderRadius: 4, + background: pct > 90 ? 'var(--rr-color-error)' : pct > 70 ? 'var(--rr-color-warning)' : 'var(--rr-brand)', + transition: 'width 300ms ease', + }), + + /** Amount label next to the bar. */ + barAmount: { + fontSize: 11, + fontWeight: 600, + color: 'var(--rr-text-primary)', + width: 80, + textAlign: 'right' as const, + flexShrink: 0, + } as CSSProperties, + + /** Velocity stat row. */ + statRow: { + display: 'flex', + gap: 24, + padding: '8px 0', + flexWrap: 'wrap' as const, + } as CSSProperties, + + /** Individual stat card. */ + stat: { + flex: '1 1 140px', + padding: 12, + background: 'var(--rr-bg-surface-alt)', + borderRadius: 8, + textAlign: 'center' as const, + } as CSSProperties, + + /** Stat value. */ + statValue: { + fontSize: 20, + fontWeight: 700, + color: 'var(--rr-text-primary)', + } as CSSProperties, + + /** Stat label. */ + statLabel: { + fontSize: 11, + color: 'var(--rr-text-secondary)', + marginTop: 2, + } as CSSProperties, + + /** Leaderboard table. */ + table: { + width: '100%', + fontSize: 12, + borderCollapse: 'collapse' as const, + } as CSSProperties, + + /** Table header cell. */ + th: { + textAlign: 'left' as const, + padding: '6px 8px', + fontWeight: 600, + color: 'var(--rr-text-secondary)', + borderBottom: '1px solid var(--rr-border)', + fontSize: 11, + textTransform: 'uppercase' as const, + letterSpacing: '0.3px', + } as CSSProperties, + + /** Table body cell. */ + td: { + padding: '6px 8px', + color: 'var(--rr-text-primary)', + borderBottom: '1px solid var(--rr-border)', + } as CSSProperties, + + /** Right-aligned table cell. */ + tdRight: { + padding: '6px 8px', + color: 'var(--rr-text-primary)', + borderBottom: '1px solid var(--rr-border)', + textAlign: 'right' as const, + fontWeight: 500, + } as CSSProperties, + + /** Toggle button group for user/team switch. */ + toggleGroup: { + display: 'flex', + gap: 0, + marginBottom: 8, + } as CSSProperties, + + /** Toggle button (active / inactive) — matches commonStyles.cardHeaderButton sizing. */ + toggle: (active: boolean): CSSProperties => ({ + ...commonStyles.cardHeaderButton, + border: '1px solid var(--rr-border)', + background: active ? 'var(--rr-brand)' : 'var(--rr-bg-default)', + color: active ? '#fff' : 'var(--rr-text-secondary)', + cursor: 'pointer', + font: 'inherit', + }), + + /** Pagination row. */ + pagination: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '8px 0', + fontSize: 11, + color: 'var(--rr-text-secondary)', + } as CSSProperties, + + /** Pagination button. */ + pageBtn: (disabled: boolean): CSSProperties => ({ + padding: '2px 8px', + fontSize: 11, + border: '1px solid var(--rr-border)', + borderRadius: 4, + background: 'var(--rr-bg-default)', + color: disabled ? 'var(--rr-text-disabled)' : 'var(--rr-text-primary)', + cursor: disabled ? 'default' : 'pointer', + font: 'inherit', + opacity: disabled ? 0.5 : 1, + }), + + /** Empty state text. */ + empty: { + fontSize: 12, + color: 'var(--rr-text-disabled)', + padding: '12px 0', + } as CSSProperties, + + /** Transaction type badge. */ + typeBadge: (type: string): CSSProperties => ({ + display: 'inline-block', + padding: '1px 6px', + borderRadius: 3, + fontSize: 10, + fontWeight: 600, + background: type === 'purchase' || type === 'credit' ? 'rgba(52, 211, 153, 0.15)' : type === 'usage' ? 'rgba(247, 144, 31, 0.15)' : 'var(--rr-bg-surface-alt)', + color: type === 'purchase' || type === 'credit' ? 'var(--rr-color-success)' : type === 'usage' ? 'var(--rr-brand)' : 'var(--rr-text-secondary)', + }), + + /** Active task row. */ + taskRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 0', + borderBottom: '1px solid var(--rr-border)', + fontSize: 12, + } as CSSProperties, + + /** Task name. */ + taskName: { + fontWeight: 500, + color: 'var(--rr-text-primary)', + } as CSSProperties, + + /** Task token count. */ + taskTokens: { + fontWeight: 600, + color: 'var(--rr-brand)', + } as CSSProperties, +}; + +// ============================================================================= +// HELPERS +// ============================================================================= + +/** Formats a number to a compact display string (e.g. 1234 -> "1,234.0"). */ +function fmt(n: number): string { + return Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); +} + +/** Formats a number with no decimals. */ +function fmtInt(n: number): string { + return Math.round(n).toLocaleString(); +} + +// ============================================================================= +// PROPS +// ============================================================================= + +/** Active task entry for the live view. */ +export interface ActiveTask { + /** Task identifier. */ + taskId: string; + /** Pipeline or source name. */ + name: string; + /** Current cumulative token total. */ + tokensTotal: number; + /** Task state string. */ + state: string; + /** Duration in seconds. */ + durationSeconds: number; +} + +/** Top-up plan from app_prices table. */ +export interface TopupPlan { + /** Internal price UUID. */ + id: string; + /** Stripe price_* identifier. */ + stripePriceId: string; + /** Display name (e.g. "3,700 tokens"). */ + nickname: string; + /** Price in USD cents. */ + amountCents: number; + /** Plan metadata with credits, kind, etc. */ + metadata?: Record | null; +} + +/** Props for the BillingDashboard component. */ +export interface BillingDashboardProps { + /** Net credit balance per resource. */ + balance: CreditBalance | null; + /** Paginated transaction result. */ + transactions: TransactionsResult | null; + /** Per-user usage rollup. */ + usageByUser: UsageRollup[]; + /** Per-team usage rollup. */ + usageByTeam: UsageRollup[]; + /** Currently running tasks with live token data. */ + activeTasks: ActiveTask[]; + /** Available top-up packs for purchase. */ + topupPlans: TopupPlan[]; + /** Whether data is still loading. */ + loading: boolean; + /** Callback to change the transaction page. */ + onTransactionPage: (page: number) => void; + /** Callback when user clicks a top-up pack to purchase. */ + onBuyTopup?: (plan: TopupPlan) => void; + /** Member lookup: userId -> display name. */ + memberNames?: Record; + /** Team lookup: teamId -> display name. */ + teamNames?: Record; +} + +// ============================================================================= +// BALANCE BREAKDOWN +// ============================================================================= + +/** Balance breakdown with purchased vs consumed bars per resource. */ +const BalanceBreakdown: React.FC<{ balance: CreditBalance | null; transactions: TransactionsResult | null }> = ({ balance, transactions }) => { + // Compute purchased and consumed totals from transactions (if available) + const breakdown = useMemo(() => { + if (!transactions?.transactions?.length && !balance?.balances) return []; + const purchased: Record = {}; + const consumed: Record = {}; + // Walk all transactions to build per-resource totals + for (const tx of transactions?.transactions ?? []) { + if (tx.amount > 0) { + purchased[tx.resource] = (purchased[tx.resource] ?? 0) + tx.amount; + } else { + consumed[tx.resource] = (consumed[tx.resource] ?? 0) + Math.abs(tx.amount); + } + } + // If we don't have transaction data, use balance as fallback + const resources = new Set([...Object.keys(balance?.balances ?? {}), ...Object.keys(purchased), ...Object.keys(consumed)]); + return Array.from(resources).map((resource) => { + const p = purchased[resource] ?? 0; + const c = consumed[resource] ?? 0; + const net = balance?.balances?.[resource] ?? p - c; + const total = Math.max(p, c + net, 1); + return { resource, purchased: p, consumed: c, net, pct: p > 0 ? (c / p) * 100 : 0 }; + }); + }, [balance, transactions]); + + if (!breakdown.length) return null; + + return ( +
+
+ Balance Breakdown +
+
+ {breakdown.map(({ resource, purchased, consumed, net, pct }) => ( +
+ {resource} +
+
+
+ {fmt(net)} +
+ ))} +
+
+ ); +}; + +// ============================================================================= +// SPENDING VELOCITY +// ============================================================================= + +/** Spending velocity -- burn rate, days remaining, and top-up buttons. */ +const SpendingVelocity: React.FC<{ + balance: CreditBalance | null; + transactions: TransactionsResult | null; + topupPlans: TopupPlan[]; + onBuyTopup?: (plan: TopupPlan) => void; +}> = ({ balance, transactions, topupPlans, onBuyTopup }) => { + const stats = useMemo(() => { + if (!transactions?.transactions?.length) return null; + + // Calculate daily burn from usage transactions in the last 7 days + const now = Date.now(); + const weekAgo = now - 7 * 24 * 60 * 60 * 1000; + let totalBurn = 0; + let earliestUsage = now; + + for (const tx of transactions.transactions) { + if (tx.type === 'usage' && tx.amount < 0) { + const txTime = tx.createdAt ? new Date(tx.createdAt).getTime() : now; + if (txTime >= weekAgo) { + totalBurn += Math.abs(tx.amount); + earliestUsage = Math.min(earliestUsage, txTime); + } + } + } + + const daysOfData = Math.max((now - earliestUsage) / (24 * 60 * 60 * 1000), 1); + const dailyRate = totalBurn / daysOfData; + + // Sum all positive balances + const totalBalance = Object.values(balance?.balances ?? {}).reduce((sum, v) => sum + Math.max(v, 0), 0); + + const daysRemaining = dailyRate > 0 ? totalBalance / dailyRate : null; + + return { dailyRate, totalBurn, totalBalance, daysRemaining }; + }, [balance, transactions]); + + if (!stats) return null; + + // Urgent = within 7 days of running out + const isUrgent = stats.daysRemaining !== null && stats.daysRemaining < 7; + + return ( +
+
+ Spending Velocity +
+
+
+
+
{fmt(stats.dailyRate)}
+
tokens / day
+
+
+
{fmt(stats.totalBurn)}
+
burned (recent)
+
+
+
+ {stats.daysRemaining !== null ? fmtInt(stats.daysRemaining) : '--'} +
+
days remaining
+
+
+ {/* Top-up buttons — primary when urgent (<7 days), secondary otherwise */} + {topupPlans.length > 0 && onBuyTopup && ( +
+ {topupPlans.map((plan) => ( + + ))} +
+ )} +
+
+ ); +}; + +// ============================================================================= +// USAGE LEADERBOARD +// ============================================================================= + +/** Usage leaderboard -- top consumers by user or team. */ +const UsageLeaderboard: React.FC<{ usageByUser: UsageRollup[]; usageByTeam: UsageRollup[]; memberNames?: Record; teamNames?: Record }> = ({ usageByUser, usageByTeam, memberNames, teamNames }) => { + const [mode, setMode] = useState<'user' | 'team'>('user'); + const data = mode === 'user' ? usageByUser : usageByTeam; + const names = mode === 'user' ? memberNames : teamNames; + + if (!usageByUser.length && !usageByTeam.length) return null; + + return ( +
+
+ Usage Leaderboard +
+ + +
+
+
+ {data.length === 0 ? ( +
No usage data.
+ ) : ( + + + + + + + + + + {data.slice(0, 10).map((row) => { + const total = Object.values(row.credits).reduce((s, v) => s + v, 0); + const displayName = row.id === '__none__' ? '(unassigned)' : names?.[row.id] ?? row.id.slice(0, 8); + return ( + + + + + + ); + })} + +
{mode === 'user' ? 'User' : 'Team'}Total TokensResources
{displayName}{fmt(total)}{Object.keys(row.credits).length}
+ )} +
+
+ ); +}; + +// ============================================================================= +// TRANSACTION LOG +// ============================================================================= + +/** Paginated transaction log with user name resolution. */ +const TransactionLog: React.FC<{ transactions: TransactionsResult | null; onPageChange: (page: number) => void; memberNames?: Record }> = ({ transactions, onPageChange, memberNames }) => { + if (!transactions) return null; + + const { transactions: rows, total, page, pageSize } = transactions; + const totalPages = Math.ceil(total / pageSize) || 1; + + return ( +
+
+ Transaction Log + {total} total +
+
+ {rows.length === 0 ? ( +
No transactions yet.
+ ) : ( + <> + + + + + + + + + + + + + + {rows.map((tx) => ( + + + + + + + + + + ))} + +
DateUserTypeResourceDescriptionAmountContext
{tx.createdAt ? new Date(tx.createdAt).toLocaleString() : '--'}{tx.userId ? (memberNames?.[tx.userId] ?? tx.userId.slice(0, 8)) : '--'}{tx.type}{tx.resource}{(tx as any).description || '--'}= 0 ? 'var(--rr-color-success)' : 'var(--rr-text-primary)' }}> + {tx.amount >= 0 ? '+' : ''}{fmt(tx.amount)} + + {tx.context?.pipeline || tx.context?.source || tx.context?.pack_id || tx.context?.subscription_id || '--'} +
+
+ + Page {page} of {totalPages} + +
+ + )} +
+
+ ); +}; + +// ============================================================================= +// ACTIVE TASKS +// ============================================================================= + +/** Active tasks with live token burn. */ +const ActiveTasksView: React.FC<{ activeTasks: ActiveTask[] }> = ({ activeTasks }) => { + if (!activeTasks.length) return null; + + return ( +
+
+ Active Tasks + {activeTasks.length} running +
+
+ {activeTasks.map((task) => ( +
+
+
{task.name || task.taskId}
+
+ {Math.floor(task.durationSeconds / 60)}m {Math.floor(task.durationSeconds % 60)}s +
+
+
{fmt(task.tokensTotal)} tokens
+
+ ))} +
+
+ ); +}; + +// ============================================================================= +// MAIN DASHBOARD +// ============================================================================= + +/** Billing dashboard with admin insight sections. */ +export const BillingDashboard: React.FC = ({ + balance, + transactions, + usageByUser, + usageByTeam, + activeTasks, + topupPlans, + loading, + onTransactionPage, + onBuyTopup, + memberNames, + teamNames, +}) => { + if (loading) { + return
Loading billing data...
; + } + + return ( + <> + + + + + + ); +}; diff --git a/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx b/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx index be8f67f63..0d1556968 100644 --- a/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx +++ b/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx @@ -39,29 +39,48 @@ const S = { color: 'var(--rr-text-primary)', } as CSSProperties, - /** Multi-resource balance list — one line per resource. */ - balanceGrid: { - display: 'flex', - flexDirection: 'column' as const, - gap: 4, + /** Summary table for granted / consumed / net balance. */ + summaryTable: { + width: '100%', + borderCollapse: 'collapse' as const, marginBottom: 16, + fontSize: 14, } as CSSProperties, - /** Single resource balance line. */ - balanceItem: { + summaryHeader: { + textAlign: 'left' as const, + fontWeight: 600, + fontSize: 12, + color: 'var(--rr-text-secondary)', + textTransform: 'uppercase' as const, + letterSpacing: 0.5, + padding: '4px 8px', + borderBottom: '1px solid var(--rr-border)', } as CSSProperties, - /** Balance label text. */ - balance: { - fontSize: 14, + summaryCell: { + padding: '6px 8px', + color: 'var(--rr-text-primary)', + } as CSSProperties, + + summaryCellRight: { + padding: '6px 8px', + textAlign: 'right' as const, fontWeight: 500, color: 'var(--rr-text-primary)', } as CSSProperties, - /** Resource type + unit label. */ - balanceUnit: { - fontSize: 13, + netRow: { + borderTop: '1px solid var(--rr-border)', + fontWeight: 700, + } as CSSProperties, + + /** Fallback when no balance data. */ + balanceEmpty: { + fontSize: 14, + fontWeight: 500, color: 'var(--rr-text-secondary)', + marginBottom: 16, } as CSSProperties, /** Responsive grid of purchasable pack cards. */ @@ -206,40 +225,38 @@ export const CreditsPanel: React.FC = ({ balance, packs, onBu
Compute credits
- {/* Balance display — one pill per resource type */} -
- {balance && balance.balances && Object.keys(balance.balances).length > 0 ? ( - Object.entries(balance.balances).map(([resource, amount]) => ( -
- {applyLabel(resource, amount, balance.labels)} -
- )) - ) : ( -
- - credits available -
- )} -
- - {/* Pack grid or empty state */} - {packs.length === 0 ? ( -

No credit packs configured.

+ {/* Balance summary table — granted, consumed, net per resource */} + {balance && balance.balances && Object.keys(balance.balances).length > 0 ? ( + + + + + + + + + + + {Object.entries(balance.balances).map(([resource, net]) => { + const granted = (balance as any).granted?.[resource] ?? 0; + const consumed = (balance as any).consumed?.[resource] ?? 0; + const label = balance.labels?.[resource] ?? resource; + const resourceName = label.replace('{amount}', '').trim() || resource; + return ( + + + + + + + ); + })} + +
ResourceGrantedConsumedBalance
{resourceName}{formatCredits(granted)}{formatCredits(consumed)} + {formatCredits(Math.round(net * 10) / 10)} +
) : ( -
- {packs.map((pack) => { - // Disable all packs while any purchase is in-flight - const disabled = purchasing !== null; - const style = { ...S.pack, ...(disabled ? S.packDisabled : {}) }; - return ( - - ); - })} -
+
— credits available
)} {/* Error banner */} diff --git a/packages/shared-ui/src/modules/billing/index.ts b/packages/shared-ui/src/modules/billing/index.ts index dc955bac8..6a4428b6c 100644 --- a/packages/shared-ui/src/modules/billing/index.ts +++ b/packages/shared-ui/src/modules/billing/index.ts @@ -17,6 +17,8 @@ export type { IBillingViewProps } from './BillingView'; // ── Sub-components ────────────────────────────────────────────────────────── export { CreditsPanel } from './components/CreditsPanel'; export type { CreditsPanelProps } from './components/CreditsPanel'; +export { BillingDashboard } from './components/BillingDashboard'; +export type { BillingDashboardProps, ActiveTask, TopupPlan } from './components/BillingDashboard'; // ── Types ─────────────────────────────────────────────────────────────────── -export type { BillingDetail, StripePlan, CreditBalance, CreditPack } from './types'; +export type { BillingDetail, StripePlan, CreditBalance, CreditPack, LedgerTransaction, TransactionsResult, UsageRollup } from './types'; diff --git a/packages/shared-ui/src/modules/billing/types.ts b/packages/shared-ui/src/modules/billing/types.ts index c29c46734..189a9c2f3 100644 --- a/packages/shared-ui/src/modules/billing/types.ts +++ b/packages/shared-ui/src/modules/billing/types.ts @@ -8,4 +8,4 @@ * the RocketRide SDK so all consumers share a single type definition. */ -export type { BillingDetail, StripePlan, CreditBalance, CreditPack } from 'rocketride'; +export type { BillingDetail, AppPrice, StripePlan, CreditBalance, CreditPack, LedgerTransaction, TransactionsResult, UsageRollup } from 'rocketride'; diff --git a/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx b/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx index 641d28a14..b02698bd5 100644 --- a/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx +++ b/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx @@ -17,7 +17,7 @@ import React, { useEffect, useState, useCallback, useMemo, type CSSProperties } import { loadStripe } from '@stripe/stripe-js'; import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { commonStyles } from '../../themes/styles'; -import { PlanPicker } from './PlanPicker'; +import { PlanPicker, planAmount } from './PlanPicker'; import type { CheckoutModalProps, CheckoutPlan } from './types'; // ============================================================================= @@ -31,7 +31,7 @@ const S = { border: '1px solid var(--rr-border)', borderRadius: 16, width: '100%', - maxWidth: 720, + maxWidth: 960, overflow: 'hidden', boxShadow: '0 24px 64px var(--rr-shadow-widget)', position: 'relative' as const, @@ -197,7 +197,7 @@ const PaymentForm: React.FC = ({ plan, subscriptionId, onConfi // Step 2: Notify server — writes 'incomplete', webhook flips to 'active' try { - await onConfirmPending(subscriptionId, plan.priceId); + await onConfirmPending(subscriptionId, plan.stripePriceId); } catch { // Non-fatal — the webhook will still update the DB } @@ -219,14 +219,14 @@ const PaymentForm: React.FC = ({ plan, subscriptionId, onConfi {/* Plan recap bar */}
- {plan.label} - {plan.amount} + {plan.nickname} + {planAmount(plan)}
@@ -271,7 +271,7 @@ export const CheckoutModal: React.FC = ({ .then((fetched) => { setPlans(fetched); // Pre-select the first checkout-able plan - const first = fetched.find((p) => !p.action); + const first = fetched.find((p) => !p.metadata?.action); if (first) setSelectedPlan(first); }) .catch((err) => setError(err.message ?? 'Failed to load subscription plans.')) @@ -280,12 +280,12 @@ export const CheckoutModal: React.FC = ({ /** Creates a Stripe subscription and advances to payment. */ const handleContinue = useCallback(async () => { - if (!selectedPlan || selectedPlan.action) return; + if (!selectedPlan || selectedPlan.metadata?.action) return; setLoadingSecret(true); setError(null); try { - const res = await onCreateCheckout(selectedPlan.priceId); + const res = await onCreateCheckout(selectedPlan.stripePriceId); setClientSecret(res.clientSecret); setSubscriptionId(res.subscriptionId); } catch (err: any) { @@ -362,8 +362,8 @@ export const CheckoutModal: React.FC = ({ onSelectPlan={setSelectedPlan} footer={ - )} - {sub.cancelAtPeriodEnd && Access ends at period end} -
-
- ); - })} - - {/* Stripe customer portal link */} -
- -
- - )} - - {/* ── Cancel confirmation dialog ──────────────────────────────────── */} - {confirmAppId && ( -
-
e.stopPropagation()}> -
Cancel Subscription
-
- Are you sure you want to cancel {appMap[confirmAppId!]?.name ?? confirmAppId}? Your access will continue until the end of the current billing period, after which the subscription will not renew. -
-
- - -
-
-
- )} - - ); -}; - -export default BillingView; diff --git a/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx b/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx index 3b8f2630e..cd148bf47 100644 --- a/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx +++ b/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx @@ -306,6 +306,8 @@ export interface BillingDashboardProps { onTransactionPage: (page: number) => void; /** Callback when user clicks a top-up pack to purchase. */ onBuyTopup?: (plan: TopupPlan) => void; + /** Called when the user clicks "Add more capacity" in the velocity card. */ + onAddCapacity?: () => void; /** Member lookup: userId -> display name. */ memberNames?: Record; /** Team lookup: teamId -> display name. */ @@ -372,9 +374,8 @@ const BalanceBreakdown: React.FC<{ balance: CreditBalance | null; transactions: const SpendingVelocity: React.FC<{ balance: CreditBalance | null; transactions: TransactionsResult | null; - topupPlans: TopupPlan[]; - onBuyTopup?: (plan: TopupPlan) => void; -}> = ({ balance, transactions, topupPlans, onBuyTopup }) => { + onAddCapacity?: () => void; +}> = ({ balance, transactions, onAddCapacity }) => { const stats = useMemo(() => { if (!transactions?.transactions?.length) return null; @@ -432,18 +433,19 @@ const SpendingVelocity: React.FC<{
days remaining
- {/* Top-up buttons — primary when urgent (<7 days), secondary otherwise */} - {topupPlans.length > 0 && onBuyTopup && ( -
- {topupPlans.map((plan) => ( - - ))} + {/* Low-capacity warning + top-up CTA — only shown when running low (<7 days) */} + {isUrgent && ( +
+
+ Based on your current usage velocity, you will be running out of capacity soon. + We suggest you upgrade your current plan or purchase more capacity to ensure uninterrupted service. +
+
)}
@@ -544,7 +546,7 @@ const TransactionLog: React.FC<{ transactions: TransactionsResult | null; onPage {tx.createdAt ? new Date(tx.createdAt).toLocaleString() : '--'} {tx.userId ? (memberNames?.[tx.userId] ?? tx.userId.slice(0, 8)) : '--'} {tx.type} - {tx.resource} + {tx.resource} {(tx as any).description || '--'} = 0 ? 'var(--rr-color-success)' : 'var(--rr-text-primary)' }}> {tx.amount >= 0 ? '+' : ''}{fmt(tx.amount)} @@ -614,6 +616,7 @@ export const BillingDashboard: React.FC = ({ loading, onTransactionPage, onBuyTopup, + onAddCapacity, memberNames, teamNames, }) => { @@ -623,7 +626,7 @@ export const BillingDashboard: React.FC = ({ return ( <> - + diff --git a/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx b/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx index 0d1556968..1993172e4 100644 --- a/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx +++ b/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx @@ -15,6 +15,7 @@ */ import React, { useState, useRef, type CSSProperties } from 'react'; +import { commonStyles } from '../../../themes/styles'; import type { CreditBalance, CreditPack } from '../types'; // ============================================================================= @@ -188,6 +189,8 @@ export interface CreditsPanelProps { packs: CreditPack[]; /** Called when the user clicks a pack to purchase. Host handles checkout. */ onBuy: (pack: CreditPack) => Promise; + /** Called when the user clicks "Add more capacity". */ + onAddCapacity?: () => void; } // ============================================================================= @@ -195,7 +198,7 @@ export interface CreditsPanelProps { // ============================================================================= /** Pure credit balance widget with purchasable pack grid. */ -export const CreditsPanel: React.FC = ({ balance, packs, onBuy }) => { +export const CreditsPanel: React.FC = ({ balance, packs, onBuy, onAddCapacity }) => { // ── Purchase state ────────────────────────────────────────────────────── const [purchasing, setPurchasing] = useState(null); const [error, setError] = useState(null); @@ -223,7 +226,7 @@ export const CreditsPanel: React.FC = ({ balance, packs, onBu // ── Render ────────────────────────────────────────────────────────────── return (
-
Compute credits
+
Account Balance
{/* Balance summary table — granted, consumed, net per resource */} {balance && balance.balances && Object.keys(balance.balances).length > 0 ? ( @@ -244,7 +247,7 @@ export const CreditsPanel: React.FC = ({ balance, packs, onBu const resourceName = label.replace('{amount}', '').trim() || resource; return ( - {resourceName} + {resourceName} {formatCredits(granted)} {formatCredits(consumed)} @@ -259,6 +262,15 @@ export const CreditsPanel: React.FC = ({ balance, packs, onBu
— credits available
)} + {/* Add more capacity button */} + {onAddCapacity && ( +
+ +
+ )} + {/* Error banner */} {error &&
{error}
}
diff --git a/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx b/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx new file mode 100644 index 000000000..1c6841da7 --- /dev/null +++ b/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx @@ -0,0 +1,211 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// ============================================================================= + +/** + * TopUpModal -- modal dialog for purchasing token top-up packs. + * + * Reuses the PlanPicker card grid to display top-up plans (filtered by + * metadata.kind === 'topup'). On selection and confirmation, calls the + * host's purchase callback which charges the customer's card on file + * via a server-side PaymentIntent. + * + * No Stripe Elements or payment form -- the customer's existing payment + * method is charged directly. If 3D Secure is required (rare), the host + * handles the confirmation separately. + */ + +import React, { useState, useMemo, type CSSProperties } from 'react'; +import { commonStyles } from '../../../themes/styles'; +import { PlanPicker, planAmount } from '../../checkout/PlanPicker'; +import type { CheckoutPlan } from '../../checkout/types'; + +// ============================================================================= +// STYLES +// ============================================================================= + +const S = { + /** Modal overlay -- full-screen backdrop. */ + overlay: { + position: 'fixed' as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + } as CSSProperties, + + /** Modal dialog container. */ + dialog: { + background: 'var(--rr-bg-paper)', + borderRadius: 12, + padding: 24, + width: '90%', + maxWidth: 600, + maxHeight: '80vh', + overflow: 'auto', + boxShadow: '0 8px 32px rgba(0,0,0,0.3)', + scrollbarWidth: 'thin' as const, + scrollbarColor: 'var(--rr-scrollbar-thumb, rgba(128,128,128,0.3)) transparent', + } as CSSProperties, + + /** Dialog header row. */ + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + } as CSSProperties, + + /** Dialog title. */ + title: { + fontSize: 18, + fontWeight: 700, + color: 'var(--rr-text-primary)', + } as CSSProperties, + + /** Close button. */ + close: { + background: 'none', + border: 'none', + fontSize: 20, + cursor: 'pointer', + color: 'var(--rr-text-secondary)', + padding: '4px 8px', + font: 'inherit', + } as CSSProperties, + + /** Footer row with confirm button. */ + footer: { + display: 'flex', + justifyContent: 'flex-end', + gap: 10, + marginTop: 16, + } as CSSProperties, + + /** Success message. */ + success: { + padding: 16, + textAlign: 'center' as const, + color: 'var(--rr-color-success)', + fontSize: 14, + fontWeight: 600, + } as CSSProperties, + + /** Error message. */ + error: { + marginTop: 8, + padding: 10, + background: 'var(--rr-bg-error, #ffe5e5)', + color: 'var(--rr-color-error, #c62828)', + borderRadius: 8, + fontSize: 13, + } as CSSProperties, +}; + +// ============================================================================= +// PROPS +// ============================================================================= + +/** Props for the TopUpModal component. */ +export interface TopUpModalProps { + /** All plans from app_prices -- the modal filters to kind='topup'. */ + plans: CheckoutPlan[]; + /** Called when the user confirms a purchase. Returns status from the server. */ + onPurchase: (plan: CheckoutPlan) => Promise<{ status: string; clientSecret?: string }>; + /** Called when the modal is dismissed. */ + onClose: () => void; +} + +// ============================================================================= +// COMPONENT +// ============================================================================= + +/** Modal dialog for purchasing token top-up packs. */ +export const TopUpModal: React.FC = ({ plans, onPurchase, onClose }) => { + const [selectedPlan, setSelectedPlan] = useState(null); + const [purchasing, setPurchasing] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + // Filter to top-up plans only + const topupPlans = useMemo( + () => plans.filter((p) => p.metadata?.kind === 'topup'), + [plans], + ); + + /** Handle purchase confirmation. */ + const handleConfirm = async () => { + if (!selectedPlan || purchasing) return; + setPurchasing(true); + setError(null); + try { + const result = await onPurchase(selectedPlan); + if (result.status === 'succeeded') { + setSuccess(true); + // Auto-close after a brief success display + setTimeout(() => onClose(), 1500); + } else if (result.status === 'requires_action') { + // 3DS required -- for now show a message; full inline handling is future work + setError('Your card requires additional verification. Please try using the Stripe billing portal.'); + } + } catch (e: any) { + setError(e?.message ?? 'Purchase failed. Please try again.'); + } finally { + setPurchasing(false); + } + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
Add More Capacity
+ +
+ + {success ? ( +
Purchase successful! Your tokens have been added.
+ ) : ( + <> + {/* Plan picker -- reuses the same card grid */} + + + {/* Error banner */} + {error &&
{error}
} + + {/* Footer with confirm button */} +
+ + +
+ + )} +
+
+ ); +}; diff --git a/packages/shared-ui/src/modules/billing/index.ts b/packages/shared-ui/src/modules/billing/index.ts index 6a4428b6c..2077ae6c3 100644 --- a/packages/shared-ui/src/modules/billing/index.ts +++ b/packages/shared-ui/src/modules/billing/index.ts @@ -10,15 +10,13 @@ * the billingApi DAP wrappers, and all related types. */ -// ── View ──────────────────────────────────────────────────────────────────── -export { default as BillingView } from './BillingView'; -export type { IBillingViewProps } from './BillingView'; - // ── Sub-components ────────────────────────────────────────────────────────── export { CreditsPanel } from './components/CreditsPanel'; export type { CreditsPanelProps } from './components/CreditsPanel'; export { BillingDashboard } from './components/BillingDashboard'; export type { BillingDashboardProps, ActiveTask, TopupPlan } from './components/BillingDashboard'; +export { TopUpModal } from './components/TopUpModal'; +export type { TopUpModalProps } from './components/TopUpModal'; // ── Types ─────────────────────────────────────────────────────────────────── export type { BillingDetail, StripePlan, CreditBalance, CreditPack, LedgerTransaction, TransactionsResult, UsageRollup } from './types'; diff --git a/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx b/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx index b02698bd5..7cb019ea5 100644 --- a/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx +++ b/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx @@ -269,9 +269,11 @@ export const CheckoutModal: React.FC = ({ useEffect(() => { onFetchPlans() .then((fetched) => { - setPlans(fetched); + // Filter out top-up packs — those are handled by the TopUpModal + const subscriptionPlans = fetched.filter((p) => p.metadata?.kind !== 'topup'); + setPlans(subscriptionPlans); // Pre-select the first checkout-able plan - const first = fetched.find((p) => !p.metadata?.action); + const first = subscriptionPlans.find((p) => !p.metadata?.action); if (first) setSelectedPlan(first); }) .catch((err) => setError(err.message ?? 'Failed to load subscription plans.')) diff --git a/packages/shared-ui/src/modules/checkout/PlanPicker.tsx b/packages/shared-ui/src/modules/checkout/PlanPicker.tsx index 128b9c5e4..4dd2307a2 100644 --- a/packages/shared-ui/src/modules/checkout/PlanPicker.tsx +++ b/packages/shared-ui/src/modules/checkout/PlanPicker.tsx @@ -297,12 +297,10 @@ export const PlanPicker: React.FC = ({ }, [plans]); // Plans visible at the current interval, sorted by order ascending. - // Top-up packs (kind='topup') are excluded. const visiblePlans = useMemo(() => { - const filtered = (showToggle + const filtered = showToggle ? plans.filter((p) => !p.interval || p.interval === 'one_time' || p.interval === interval) - : plans - ).filter((p) => p.metadata?.kind !== 'topup'); + : plans; return [...filtered].sort((a, b) => planOrder(a) - planOrder(b)); }, [plans, interval, showToggle]); From 36cd1d928456b03739293f2bfb535210aa3e32f3 Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Thu, 11 Jun 2026 14:10:12 -0700 Subject: [PATCH 05/12] feat(billing): plan upgrade/downgrade UI, SDK methods, depends fix (#billing-6) Add UpgradeModal component and wire Change Plan flow end-to-end: - UpgradeModal in shared-ui shows available plans with current highlighted - BillingPanel adds "Change Plan" button on active subscription rows - AccountPage (shell-ui) and AccountProvider (vscode) handle the billing:upgrade message and call the SDK - Python and TypeScript SDKs gain upgradeSubscription() method that calls rrext_account_billing subcommand='upgrade' - depends.py: remove premature updateProgress() call that logged "Installing requirements.txt" via monitorStatus before dry-run determined no packages needed installing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/views/account/AccountPage.tsx | 9 + apps/vscode/src/providers/AccountProvider.ts | 31 ++ .../views/Account/AccountWebview.tsx | 20 ++ .../client-python/src/rocketride/billing.py | 30 ++ .../client-typescript/src/client/billing.ts | 28 ++ .../rocketlib-python/lib/depends.py | 1 - .../src/modules/account/AccountView.tsx | 6 +- .../account/components/BillingPanel.tsx | 23 +- .../billing/components/UpgradeModal.tsx | 298 ++++++++++++++++++ .../shared-ui/src/modules/billing/index.ts | 2 + 10 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 packages/shared-ui/src/modules/billing/components/UpgradeModal.tsx diff --git a/apps/shell-ui/src/views/account/AccountPage.tsx b/apps/shell-ui/src/views/account/AccountPage.tsx index 1e8747539..39055ffb2 100644 --- a/apps/shell-ui/src/views/account/AccountPage.tsx +++ b/apps/shell-ui/src/views/account/AccountPage.tsx @@ -245,6 +245,14 @@ const AccountPage: React.FC = () => { return result; }, [client, orgId, loadBilling]); + /** Upgrade or downgrade an existing subscription to a new plan. */ + const handleUpgradeSubscription = useCallback(async (appId: string, newPriceId: string) => { + if (!client || !orgId) throw new Error('Not connected'); + await client.billing.upgradeSubscription(orgId, appId, newPriceId); + // Re-fetch billing data to reflect the updated subscription + loadBilling(); + }, [client, orgId, loadBilling]); + // ── Load ALL data upfront on connect (badges, counts, billing) ────────── useEffect(() => { if (!isConnected || !client) return; @@ -475,6 +483,7 @@ const AccountPage: React.FC = () => { topupPlans={allPlans.filter((p: any) => p.metadata?.kind === 'topup').map((p: any) => ({ id: p.id, stripePriceId: p.stripePriceId, nickname: p.nickname, amountCents: p.amountCents, metadata: p.metadata }))} allPlans={allPlans} onPurchaseTopup={handlePurchaseTopup} + onUpgradeSubscription={handleUpgradeSubscription} dashboardLoading={dashboardLoading} onTransactionPage={handleTransactionPage} memberNames={Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId]))} diff --git a/apps/vscode/src/providers/AccountProvider.ts b/apps/vscode/src/providers/AccountProvider.ts index 0dca8ed7e..adb65ada5 100644 --- a/apps/vscode/src/providers/AccountProvider.ts +++ b/apps/vscode/src/providers/AccountProvider.ts @@ -231,6 +231,10 @@ export class AccountProvider { await this.handlePurchaseTopup(panel, message.priceId as string); break; + case 'billing:upgrade': + await this.handleUpgradeSubscription(panel, message.appId as string, message.newPriceId as string); + break; + // -- Checkout flow (embedded Stripe Elements in the Account webview) --- case 'checkout:fetchPlans': await this.handleCheckoutFetchPlans(panel); @@ -1005,6 +1009,33 @@ export class AccountProvider { } } + /** + * Handles an upgrade/downgrade subscription request from the webview. + * + * Calls the SDK to change the subscription plan on the server, then + * re-fetches billing data and sends the result back to the webview. + * + * @param panel - The webview panel to post the result to. + * @param appId - The app whose subscription is being changed. + * @param newPriceId - Stripe price_* identifier for the target plan. + */ + private async handleUpgradeSubscription(panel: vscode.WebviewPanel, appId: string, newPriceId: string): Promise { + const { client, orgId } = this.resolveClient(); + if (!client || !orgId) { + await panel.webview.postMessage({ type: 'billing:upgradeResult', error: 'Not connected' }); + return; + } + try { + await client.billing.upgradeSubscription(orgId, appId, newPriceId); + await panel.webview.postMessage({ type: 'billing:upgradeResult' }); + // Re-fetch billing data to reflect the updated subscription + await this.fetchBillingData(panel); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + await panel.webview.postMessage({ type: 'billing:upgradeResult', error: msg }); + } + } + // ========================================================================= // HELPERS // ========================================================================= diff --git a/apps/vscode/src/providers/views/Account/AccountWebview.tsx b/apps/vscode/src/providers/views/Account/AccountWebview.tsx index f4f73b24f..4099144c0 100644 --- a/apps/vscode/src/providers/views/Account/AccountWebview.tsx +++ b/apps/vscode/src/providers/views/Account/AccountWebview.tsx @@ -61,6 +61,8 @@ const AccountWebview: React.FC = () => { // Top-up purchase resolver (promise-based like key creation) const topupResolverRef = useRef<{ resolve: (v: any) => void; reject: (e: Error) => void } | null>(null); + // Upgrade subscription resolver + const upgradeResolverRef = useRef<{ resolve: () => void; reject: (e: Error) => void } | null>(null); // Checkout modal state const [showCheckout, setShowCheckout] = useState(false); @@ -167,6 +169,18 @@ const AccountWebview: React.FC = () => { } break; + // Upgrade subscription result + case 'billing:upgradeResult': + if (upgradeResolverRef.current) { + if ((message as any).error) { + upgradeResolverRef.current.reject(new Error((message as any).error)); + } else { + upgradeResolverRef.current.resolve(); + } + upgradeResolverRef.current = null; + } + break; + // -- Checkout flow responses ------------------------------------------- case 'checkout:plansResult': { const r = checkoutResolvers.current.plans; @@ -399,6 +413,12 @@ const AccountWebview: React.FC = () => { sendMessageRef.current({ type: 'billing:purchaseTopup', priceId: plan.stripePriceId } as any); }); }} + onUpgradeSubscription={async (appId: string, newPriceId: string) => { + return new Promise((resolve, reject) => { + upgradeResolverRef.current = { resolve, reject }; + sendMessageRef.current({ type: 'billing:upgrade', appId, newPriceId } as any); + }); + }} memberNames={Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId]))} teamNames={Object.fromEntries(teams.map((t: any) => [t.id, t.name || t.id]))} section={section} diff --git a/packages/client-python/src/rocketride/billing.py b/packages/client-python/src/rocketride/billing.py index 14f85bb72..53099c957 100644 --- a/packages/client-python/src/rocketride/billing.py +++ b/packages/client-python/src/rocketride/billing.py @@ -170,6 +170,36 @@ async def cancel_subscription(self, org_id: str, app_id: str) -> dict: appId=app_id, ) + async def upgrade_subscription( + self, + org_id: str, + app_id: str, + new_price_id: str, + ) -> dict: + """ + Upgrade (or downgrade) an existing subscription to a different plan. + + The server swaps the Stripe subscription item to the new price and + handles proration automatically. The local database row is updated + before the response is returned. + + Args: + org_id: Organisation UUID that owns the subscription. + app_id: App whose plan is changing. + new_price_id: Stripe price_* identifier for the target plan. + + Returns: + Dict with ``status``, ``subscriptionId``, ``newPriceId``, + ``planNickname``, ``unitAmount``, ``billingInterval``. + """ + return await self._client.call( + 'rrext_account_billing', + subcommand='upgrade', + orgId=org_id, + appId=app_id, + newPriceId=new_price_id, + ) + # ========================================================================= # TOP-UP PURCHASE # ========================================================================= diff --git a/packages/client-typescript/src/client/billing.ts b/packages/client-typescript/src/client/billing.ts index e639023a4..3ef99cc1e 100644 --- a/packages/client-typescript/src/client/billing.ts +++ b/packages/client-typescript/src/client/billing.ts @@ -132,6 +132,34 @@ export class BillingApi { }); } + /** + * Upgrades (or downgrades) an existing subscription to a different plan. + * + * The server swaps the Stripe subscription item to the new price and + * handles proration automatically. The local database row is updated + * before the response is returned. + * + * @param orgId - Organisation UUID that owns the subscription. + * @param appId - App whose plan is changing (e.g. "rocketride.pipeBuilder"). + * @param newPriceId - Stripe price_* identifier for the target plan. + * @returns Object with status, new plan details, and subscription ID. + */ + async upgradeSubscription(orgId: string, appId: string, newPriceId: string): Promise<{ + status: string; + subscriptionId: string; + newPriceId: string; + planNickname: string | null; + unitAmount: number | null; + billingInterval: string | null; + }> { + return this.client.call('rrext_account_billing', { + subcommand: 'upgrade', + orgId, + appId, + newPriceId, + }); + } + // ========================================================================= // TOP-UP PURCHASE // ========================================================================= diff --git a/packages/server/engine-lib/rocketlib-python/lib/depends.py b/packages/server/engine-lib/rocketlib-python/lib/depends.py index 8fa3f7d69..537b2d9bd 100644 --- a/packages/server/engine-lib/rocketlib-python/lib/depends.py +++ b/packages/server/engine-lib/rocketlib-python/lib/depends.py @@ -797,7 +797,6 @@ def _install_requirements(requirements_path: str, constraints_path: str): # Start heartbeat early — the dry-run can block on uv's internal lock # for minutes, and we need monitorStatus events to keep the task startup # timeout alive during that time. - updateProgress(f'Installing {os.path.basename(requirements_path)}') _start_heartbeat() try: return _install_requirements_inner(requirements_path, constraints_path) diff --git a/packages/shared-ui/src/modules/account/AccountView.tsx b/packages/shared-ui/src/modules/account/AccountView.tsx index 7053f59d1..c3653582c 100644 --- a/packages/shared-ui/src/modules/account/AccountView.tsx +++ b/packages/shared-ui/src/modules/account/AccountView.tsx @@ -175,6 +175,8 @@ export interface IAccountViewProps { allPlans?: any[]; /** Called to purchase a top-up pack (charges card on file). */ onPurchaseTopup?: (plan: any) => Promise<{ status: string; clientSecret?: string }>; + /** Called when the user confirms a plan upgrade/downgrade from the billing panel. */ + onUpgradeSubscription?: (appId: string, newPriceId: string) => Promise; // -- Navigation state ------------------------------------------------------ /** The currently active section / tab. */ @@ -237,7 +239,7 @@ export interface IAccountViewProps { * to the host via async callback props defined in IAccountViewProps. */ const AccountView: React.FC = (props) => { - const { isConnected, sectionError, profile, authUser, keys, org, members, teams, teamDetail, subscriptions, billingLoading, billingError, creditBalance, apps, onCancelSubscription, onOpenPortal, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, memberNames, teamNames, topupPlans, onBuyTopup, allPlans, onPurchaseTopup, section, onSectionChange, activeTeamId, onActiveTeamIdChange, onSaveProfile, onSetDefaultTeam, onLogout, onDeleteAccount, onSaveOrgName, onCreateKey, onRevokeKey, onInviteMember, onUpdateMemberRole, onRemoveMember, onCreateTeam, onDeleteTeam, onAddTeamMember, onEditTeamMemberPerms, onRemoveTeamMember, onLoadTeamDetail } = props; + const { isConnected, sectionError, profile, authUser, keys, org, members, teams, teamDetail, subscriptions, billingLoading, billingError, creditBalance, apps, onCancelSubscription, onOpenPortal, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, memberNames, teamNames, topupPlans, onBuyTopup, allPlans, onPurchaseTopup, onUpgradeSubscription, section, onSectionChange, activeTeamId, onActiveTeamIdChange, onSaveProfile, onSetDefaultTeam, onLogout, onDeleteAccount, onSaveOrgName, onCreateKey, onRevokeKey, onInviteMember, onUpdateMemberRole, onRemoveMember, onCreateTeam, onDeleteTeam, onAddTeamMember, onEditTeamMemberPerms, onRemoveTeamMember, onLoadTeamDetail } = props; // ========================================================================= // PERMISSION HELPERS @@ -701,7 +703,7 @@ const AccountView: React.FC = (props) => { billing: { content: (
- +
), }, diff --git a/packages/shared-ui/src/modules/account/components/BillingPanel.tsx b/packages/shared-ui/src/modules/account/components/BillingPanel.tsx index 3c1f6b972..8824af73c 100644 --- a/packages/shared-ui/src/modules/account/components/BillingPanel.tsx +++ b/packages/shared-ui/src/modules/account/components/BillingPanel.tsx @@ -22,6 +22,7 @@ import type { BillingDetail, CreditBalance, TransactionsResult, UsageRollup } fr import { CreditsPanel } from '../../billing/components/CreditsPanel'; import { BillingDashboard } from '../../billing/components/BillingDashboard'; import { TopUpModal } from '../../billing/components/TopUpModal'; +import { UpgradeModal } from '../../billing/components/UpgradeModal'; import type { ActiveTask, TopupPlan } from '../../billing/components/BillingDashboard'; import type { CheckoutPlan } from '../../checkout/types'; import { S as SharedS, Badge } from './shared'; @@ -175,6 +176,8 @@ export interface BillingPanelProps { memberNames?: Record; /** Team lookup: teamId -> display name. */ teamNames?: Record; + /** Called when the user confirms a plan change. */ + onUpgradeSubscription?: (appId: string, newPriceId: string) => Promise; } // ============================================================================= @@ -187,9 +190,11 @@ export interface BillingPanelProps { * Renders compute credits and subscription rows using the standard card * pattern. The cancel confirmation dialog is owned by AccountView. */ -export const BillingPanel: React.FC = ({ isConnected, subscriptions, loading, error, creditBalance, apps, onCancelSubscription, onOpenPortal, isOrgAdmin, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, topupPlans, onBuyTopup, allPlans, onPurchaseTopup, memberNames, teamNames }) => { +export const BillingPanel: React.FC = ({ isConnected, subscriptions, loading, error, creditBalance, apps, onCancelSubscription, onOpenPortal, isOrgAdmin, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, topupPlans, onBuyTopup, allPlans, onPurchaseTopup, memberNames, teamNames, onUpgradeSubscription }) => { // ── Top-up modal state ────────────────────────────────────────────────── const [showTopUpModal, setShowTopUpModal] = useState(false); + // ── Upgrade modal state ───────────────────────────────────────────────── + const [upgradeTarget, setUpgradeTarget] = useState(null); const isSubscribed = subscriptions.length > 0; const handleAddCapacity = useCallback(() => setShowTopUpModal(true), []); // Build appId → app lookup for display name resolution @@ -314,6 +319,11 @@ export const BillingPanel: React.FC = ({ isConnected, subscri {/* Status badge + actions */}
{sv.label} + {isCancelable && isOrgAdmin && onUpgradeSubscription && ( + + )} {isCancelable && isOrgAdmin && ( +
+ + {success ? ( +
+ {changeDirection === 'downgrade' + ? 'Downgrade scheduled! Your current plan stays active until the end of this billing period.' + : 'Plan upgraded! Your new features and prorated token credits are available now.'} +
+ ) : ( + <> + {/* Current plan info */} +
+ Current + + {currentPlanName ?? 'Unknown plan'} + +
+ + {/* Plan picker -- reuses the same card grid */} + + + {/* Proration info -- explains what happens on upgrade vs downgrade */} + {isValidSelection && changeDirection === 'upgrade' && ( +
+ You will be charged the prorated difference for the remainder of your current billing period. Token credits will be adjusted accordingly. +
+ )} + {isValidSelection && changeDirection === 'downgrade' && ( +
+ Your current plan will remain active until the end of your billing period. The new plan takes effect at your next renewal. +
+ )} + + {/* Error banner */} + {error &&
{error}
} + + {/* Footer with confirm button */} +
+ + +
+ + )} + + + ); +}; diff --git a/packages/shared-ui/src/modules/billing/index.ts b/packages/shared-ui/src/modules/billing/index.ts index 2077ae6c3..a6b6f0221 100644 --- a/packages/shared-ui/src/modules/billing/index.ts +++ b/packages/shared-ui/src/modules/billing/index.ts @@ -17,6 +17,8 @@ export { BillingDashboard } from './components/BillingDashboard'; export type { BillingDashboardProps, ActiveTask, TopupPlan } from './components/BillingDashboard'; export { TopUpModal } from './components/TopUpModal'; export type { TopUpModalProps } from './components/TopUpModal'; +export { UpgradeModal } from './components/UpgradeModal'; +export type { UpgradeModalProps } from './components/UpgradeModal'; // ── Types ─────────────────────────────────────────────────────────────────── export type { BillingDetail, StripePlan, CreditBalance, CreditPack, LedgerTransaction, TransactionsResult, UsageRollup } from './types'; From 5842476205fdd24452471f071da5b7c991059a92 Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Thu, 11 Jun 2026 19:23:43 -0700 Subject: [PATCH 06/12] feat(shell,build,profiler): post-auth MF remote registration, workspace hash, cprofile permissions (#billing-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell.tsx — register permission-gated apps as MF remotes after auth: The pre-auth server probe only returns public apps (owner_type='public'), so apps with requiredPermissions (e.g. ["sys.admin"]) were never included in the probe's registerRemotes() call. After authentication, the ConnectResult.apps payload includes the user's full entitled app set with complete manifest data (entry URL, moduleId, etc.), but Shell.tsx was only overlaying desktop metadata (appStatus, onDesktop) onto the existing probe entries — it never added new apps from the ConnectResult. This meant permission-gated apps appeared on the desktop tile grid (because the catalog list and desktop_add worked correctly) but clicking them did nothing: the MF remote was never registered, so loadRemote() had no entry to resolve, and no network request was ever made. The fix detects ConnectResult apps that are absent from the probe set, calls registerAndMapApps() to register their MF remotes, and appends them to the merged app list passed to WorkspaceProvider. This allows any app — regardless of requiredPermissions or authenticated flags — to be launched after auth as long as the server includes it in the ConnectResult. deps-tasks.js — include pnpm-workspace.yaml in dependency hash: The builder's auto-install detection hashes all package.json dependency fields to decide whether pnpm install is needed. However, adding or removing entries in pnpm-workspace.yaml (which controls which packages are part of the pnpm workspace) did NOT trigger auto-install because the yaml file was not included in the hash. This caused build failures when a new app was added to the workspace: the builder skipped pnpm install, and the new package's dependencies were unresolved, resulting in npm 404 errors (e.g. "rsbuild@* not found") because the fallback npm resolver couldn't find workspace-linked packages. The fix hashes pnpm-workspace.yaml alongside the package.json files so any workspace topology change triggers pnpm install automatically. cmd_cprofile.py — add task.control permission checks: All five cProfile DAP commands (start, stop, status, report, report_tree) were missing permission verification. Any authenticated user could start or stop CPU profiling sessions on the server or on pipeline tasks. Added verify_permission('task.control') to each handler to require the same permission level as other task control operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/shell-ui/src/components/layout/Shell.tsx | 37 +++++++++++++------ .../ai/modules/task/commands/cmd_cprofile.py | 5 +++ scripts/deps-tasks.js | 12 +++++- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/apps/shell-ui/src/components/layout/Shell.tsx b/apps/shell-ui/src/components/layout/Shell.tsx index 58caf544c..e09c71037 100644 --- a/apps/shell-ui/src/components/layout/Shell.tsx +++ b/apps/shell-ui/src/components/layout/Shell.tsx @@ -50,6 +50,8 @@ import { CheckoutFlow } from './CheckoutFlow'; import { ApiKeyLogin } from './ApiKeyLogin'; import LoadingScreen from './LoadingScreen'; import { SS_PENDING_APP_ID } from '../../constants'; +import { registerAndMapApps } from '../../lib/appLoader'; +import type { ServerAppEntry } from '../../lib/appLoader'; // ============================================================================= // STYLES @@ -182,23 +184,36 @@ const Shell: React.FC = ({ config }) => { // ── Connection state ────────────────────────────────────────────────── const { client, isConnected, statusMessage } = useShellConnection(); - // ── Apps — probe catalog + post-auth desktop metadata ───────────────── - // MF remotes are registered once at bootstrap from the probe — they - // never change after auth. Post-auth, ConnectResult.apps only adds - // desktop metadata (appStatus, onDesktop) onto existing probe entries. + // ── Apps — probe catalog + post-auth merge ──────────────────────────── + // The pre-auth probe registers public MF remotes. Post-auth, the + // ConnectResult may include additional apps the user is entitled to + // (e.g. apps gated by requiredPermissions). Those need to be registered + // as MF remotes and merged into the app list so they can be launched. const apps = useMemo(() => { if (!identity?.apps?.length) return config.apps; - // Index desktop metadata by app id for fast lookup - const desktopById = new Map( - (identity.apps as Array<{ id: string; appStatus?: string; onDesktop?: boolean }>) - .map((a) => [a.id, a]), - ); + // Index ConnectResult apps by id + const identityApps = identity.apps as Array; + const identityById = new Map(identityApps.map((a) => [a.id, a])); + // Overlay desktop metadata onto probe entries - return config.apps.map((a) => { - const da = desktopById.get(a.id); + const probeIds = new Set(config.apps.map((a) => a.id)); + const merged = config.apps.map((a) => { + const da = identityById.get(a.id); return da ? { ...a, appStatus: da.appStatus, onDesktop: da.onDesktop } : a; }); + + // Register and append apps that were NOT in the probe (e.g. permission-gated) + const newApps = identityApps.filter((a) => !probeIds.has(a.id) && a.entry && a.moduleId); + if (newApps.length > 0) { + const registered = registerAndMapApps(newApps); + for (const app of registered) { + const da = identityById.get(app.id); + merged.push(da ? { ...app, appStatus: da.appStatus, onDesktop: da.onDesktop } : app); + } + } + + return merged; }, [identity?.apps, config.apps]); // ===================================================================== diff --git a/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py b/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py index 2c4aeede3..b2811ba48 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_cprofile.py @@ -157,6 +157,7 @@ async def on_rrext_cprofile_start(self, request: Dict[str, Any]) -> Dict[str, An - Local: { "command": "rrext_cprofile_start", "arguments": { "session": "test" } } - Proxy: { "command": "rrext_cprofile_start", "arguments": { "target": "tk_abc", "session": "test" } } """ + self.verify_permission('task.control') args = request.get('arguments', {}) target = args.get('target', None) @@ -187,6 +188,7 @@ async def on_rrext_cprofile_stop(self, request: Dict[str, Any]) -> Dict[str, Any Usage Example: { "command": "rrext_cprofile_stop" } """ + self.verify_permission('task.control') args = request.get('arguments', {}) target = args.get('target', None) @@ -215,6 +217,7 @@ async def on_rrext_cprofile_status(self, request: Dict[str, Any]) -> Dict[str, A Usage Example: { "command": "rrext_cprofile_status" } """ + self.verify_permission('task.control') args = request.get('arguments', {}) target = args.get('target', None) @@ -243,6 +246,7 @@ async def on_rrext_cprofile_report(self, request: Dict[str, Any]) -> Dict[str, A Usage Example: { "command": "rrext_cprofile_report" } """ + self.verify_permission('task.control') args = request.get('arguments', {}) target = args.get('target', None) @@ -274,6 +278,7 @@ async def on_rrext_cprofile_report_tree(self, request: Dict[str, Any]) -> Dict[s Usage Example: { "command": "rrext_cprofile_report_tree", "arguments": { "max_depth": 30, "min_pct": 0.5 } } """ + self.verify_permission('task.control') args = request.get('arguments', {}) target = args.get('target', None) diff --git a/scripts/deps-tasks.js b/scripts/deps-tasks.js index becdedab9..09d875812 100644 --- a/scripts/deps-tasks.js +++ b/scripts/deps-tasks.js @@ -61,12 +61,20 @@ async function hashDependencies(filePath) { async function getPackageJsonHashes(root) { const files = await findPackageJsonFiles(root); const hashes = {}; - + for (const file of files) { const relativePath = path.relative(root, file); hashes[relativePath] = await hashDependencies(file); } - + + // Include pnpm-workspace.yaml — adding/removing workspace entries must + // trigger pnpm install even when no package.json content changed. + const workspaceYaml = path.join(root, 'pnpm-workspace.yaml'); + if (await exists(workspaceYaml)) { + const content = await readFile(workspaceYaml, null); + hashes['pnpm-workspace.yaml'] = crypto.createHash('md5').update(content).digest('hex'); + } + return hashes; } From b5368d08e99116137b6f6434b81562d551fda481 Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Thu, 11 Jun 2026 20:00:50 -0700 Subject: [PATCH 07/12] refactor(account): enforce single-org membership model (#billing-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users may now belong to exactly one organization instead of many. The multi-org array was never used in practice — every frontend accessor already read organizations[0] — and the M:N model added unnecessary complexity to permission resolution, billing scoping, and invite logic. Backend (Python): - AccountInfo.organizations: list[OrgInfo] -> organization: Optional[OrgInfo] - resolve_task_permissions / resolve_team_permissions simplified from org-list iteration to single-org lookup - OSS provider emits organization={...} instead of organizations=[{...}] - _primary_org_id() in account_handler, app_handler simplified - cmd_task, cmd_debug, cmd_monitor, cmd_misc all updated to read .organization instead of iterating .organizations - connect_routes, stripe_webhooks, marketplace routes updated - base.py docstring updated TypeScript SDK: - ConnectResult.organizations: OrgInfo[] -> organization: OrgInfo | null - RocketRideClient.getOrgId() reads .organization?.id Frontend (shell-ui, shared-ui, vscode): - All organizations?.[0] accessors replaced with organization? - ProfilePanel renders single org instead of iterating org list - AccountView, EnvironmentPage, Shell, AccountPage simplified - All VSCode providers (Account, Environment, Project, Settings, Sidebar, ConnectionMessageHandler) updated Python SDK: - ConnectResult.organizations -> organization - AccountProfile.organizations -> organization Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/shell-ui/src/components/layout/Shell.tsx | 2 +- .../src/views/account/AccountPage.tsx | 6 +- .../src/views/environment/EnvironmentPage.tsx | 8 +-- apps/vscode/src/providers/AccountProvider.ts | 2 +- .../src/providers/EnvironmentProvider.ts | 2 +- apps/vscode/src/providers/ProjectProvider.ts | 2 +- apps/vscode/src/providers/SettingsProvider.ts | 2 +- apps/vscode/src/providers/SidebarProvider.ts | 4 +- .../shared/connection-message-handler.ts | 4 +- packages/ai/src/ai/account/base.py | 2 +- packages/ai/src/ai/account/models.py | 46 ++++++------ packages/ai/src/ai/account/oss/__init__.py | 46 ++++++------ .../src/ai/modules/task/commands/cmd_debug.py | 7 +- .../src/ai/modules/task/commands/cmd_misc.py | 5 +- .../ai/modules/task/commands/cmd_monitor.py | 6 +- .../src/ai/modules/task/commands/cmd_task.py | 7 +- .../src/rocketride/types/account.py | 6 +- .../src/rocketride/types/client.py | 4 +- .../client-typescript/src/client/client.ts | 4 +- .../src/client/types/client.ts | 13 ++-- .../src/modules/account/AccountView.tsx | 17 +++-- .../account/components/ProfilePanel.tsx | 72 +++++++++---------- 22 files changed, 129 insertions(+), 138 deletions(-) diff --git a/apps/shell-ui/src/components/layout/Shell.tsx b/apps/shell-ui/src/components/layout/Shell.tsx index e09c71037..f37ea0969 100644 --- a/apps/shell-ui/src/components/layout/Shell.tsx +++ b/apps/shell-ui/src/components/layout/Shell.tsx @@ -488,7 +488,7 @@ const Shell: React.FC = ({ config }) => { } : config; const stripeKey = config.apiConfig.RR_STRIPE_PUBLISHABLE_KEY ?? ''; - const orgId = identity?.organizations?.[0]?.id ?? ''; + const orgId = identity?.organization?.id ?? ''; return ( diff --git a/apps/shell-ui/src/views/account/AccountPage.tsx b/apps/shell-ui/src/views/account/AccountPage.tsx index 39055ffb2..5c88827ca 100644 --- a/apps/shell-ui/src/views/account/AccountPage.tsx +++ b/apps/shell-ui/src/views/account/AccountPage.tsx @@ -112,7 +112,7 @@ const AccountPage: React.FC = () => { // ── Permission flags ──────────────────────────────────────────────────── /** Whether the current user is an org admin. */ - const isOrgAdminFlag = authUser?.organizations?.[0]?.permissions?.includes('org.admin') ?? false; + const isOrgAdminFlag = authUser?.organization?.permissions?.includes('org.admin') ?? false; // Keep profile in sync with server-pushed account updates, bump refresh // signal for env, and bump reload counter to re-fetch the active section @@ -128,8 +128,8 @@ const AccountPage: React.FC = () => { // ── Data loaders ──────────────────────────────────────────────────────── - /** Derives the orgId from the auth user's first organization. */ - const orgId = authUser?.organizations?.[0]?.id ?? ''; + /** Derives the orgId from the auth user's organization. */ + const orgId = authUser?.organization?.id ?? ''; /** Extracts a human-readable message from a thrown value. */ const errMsg = (e: unknown): string => e instanceof Error ? e.message : String(e); diff --git a/apps/shell-ui/src/views/environment/EnvironmentPage.tsx b/apps/shell-ui/src/views/environment/EnvironmentPage.tsx index 6bce57ab8..4a976e1cb 100644 --- a/apps/shell-ui/src/views/environment/EnvironmentPage.tsx +++ b/apps/shell-ui/src/views/environment/EnvironmentPage.tsx @@ -40,11 +40,11 @@ const EnvironmentPage: React.FC = () => { const [envs, setEnvs] = useState | undefined>>({}); // ── Permission flags ──────────────────────────────────────────────── - const orgId = authUser?.organizations?.[0]?.id; - const teamId = (authUser as any)?.defaultTeamId ?? authUser?.organizations?.[0]?.teams?.[0]?.id; - const isOrgAdmin = authUser?.organizations?.[0]?.permissions?.includes('org.admin') ?? false; + const orgId = authUser?.organization?.id; + const teamId = (authUser as any)?.defaultTeamId ?? authUser?.organization?.teams?.[0]?.id; + const isOrgAdmin = authUser?.organization?.permissions?.includes('org.admin') ?? false; const isTeamAdmin = teamId - ? (authUser?.organizations?.[0]?.teams?.find((t: any) => t.id === teamId)?.permissions?.includes('team.admin') ?? false) + ? (authUser?.organization?.teams?.find((t: any) => t.id === teamId)?.permissions?.includes('team.admin') ?? false) : false; // ── Single slot config ────────────────────────────────────────────── diff --git a/apps/vscode/src/providers/AccountProvider.ts b/apps/vscode/src/providers/AccountProvider.ts index adb65ada5..1d74746bb 100644 --- a/apps/vscode/src/providers/AccountProvider.ts +++ b/apps/vscode/src/providers/AccountProvider.ts @@ -1065,7 +1065,7 @@ export class AccountProvider { (config.development.connectionMode === 'cloud' && devClient ? devClient : null) ?? (config.deployment.connectionMode === 'cloud' && deployClient ? deployClient : null); - const orgId = accountInfo?.organizations?.[0]?.id; + const orgId = accountInfo?.organization?.id; return { client: client ?? undefined, accountInfo: accountInfo ?? undefined, orgId }; } diff --git a/apps/vscode/src/providers/EnvironmentProvider.ts b/apps/vscode/src/providers/EnvironmentProvider.ts index 3b548e1d8..d91f6893c 100644 --- a/apps/vscode/src/providers/EnvironmentProvider.ts +++ b/apps/vscode/src/providers/EnvironmentProvider.ts @@ -282,7 +282,7 @@ export class EnvironmentProvider { const isSaas = capabilities.includes('saas'); // Extract org info and permissions - const org = resolved?.accountInfo?.organizations?.[0]; + const org = resolved?.accountInfo?.organization; const orgId = org?.id; const orgPermissions: string[] = org?.permissions ?? []; const isOrgAdmin = orgPermissions.includes('org.admin'); diff --git a/apps/vscode/src/providers/ProjectProvider.ts b/apps/vscode/src/providers/ProjectProvider.ts index 425ccc9f8..b7a586910 100644 --- a/apps/vscode/src/providers/ProjectProvider.ts +++ b/apps/vscode/src/providers/ProjectProvider.ts @@ -516,7 +516,7 @@ export class ProjectProvider implements vscode.CustomTextEditorProvider { try { const billingClient = this.connectionManager.getClient(); if (!billingClient) throw new Error('Not connected'); - const orgId = billingClient.getAccountInfo()?.organizations?.[0]?.id; + const orgId = billingClient.getAccountInfo()?.organization?.id; if (!orgId) throw new Error('No organisation found'); const result = await billingClient.billing.createCheckoutSession(orgId, PIPE_BUILDER_APP_ID, data.priceId as string); webview.postMessage({ type: 'checkout:sessionResult', ...result, error: null }); diff --git a/apps/vscode/src/providers/SettingsProvider.ts b/apps/vscode/src/providers/SettingsProvider.ts index d02578a8c..82e033594 100644 --- a/apps/vscode/src/providers/SettingsProvider.ts +++ b/apps/vscode/src/providers/SettingsProvider.ts @@ -184,7 +184,7 @@ export class SettingsProvider { try { const billingClient = getConnectionManager()?.getClient(); if (!billingClient) throw new Error('Not connected'); - const orgId = billingClient.getAccountInfo()?.organizations?.[0]?.id; + const orgId = billingClient.getAccountInfo()?.organization?.id; if (!orgId) throw new Error('No organisation found'); const result = await billingClient.billing.createCheckoutSession(orgId, PIPE_BUILDER_APP_ID, message.priceId as string); panel.webview.postMessage({ type: 'checkout:sessionResult', ...result, error: null }); diff --git a/apps/vscode/src/providers/SidebarProvider.ts b/apps/vscode/src/providers/SidebarProvider.ts index b237caf7c..7d1ae0ff5 100644 --- a/apps/vscode/src/providers/SidebarProvider.ts +++ b/apps/vscode/src/providers/SidebarProvider.ts @@ -367,8 +367,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { */ private getTeamsFromClient(client: import('rocketride').RocketRideClient | undefined): Array<{ id: string; name: string }> { const info = client?.getAccountInfo(); - if (!info?.organizations?.length) return []; - return info.organizations[0].teams ?? []; + if (!info?.organization) return []; + return info.organization.teams ?? []; } /** Sends connection state + entries + user identity + teams to the webview. */ diff --git a/apps/vscode/src/providers/shared/connection-message-handler.ts b/apps/vscode/src/providers/shared/connection-message-handler.ts index 10663b05b..b38a40d76 100644 --- a/apps/vscode/src/providers/shared/connection-message-handler.ts +++ b/apps/vscode/src/providers/shared/connection-message-handler.ts @@ -336,8 +336,8 @@ export class ConnectionMessageHandler { } private extractTeams(account: ReturnType): Array<{ id: string; name: string }> { - if (!account?.organizations?.length) return []; - return account.organizations.flatMap((org) => (org.teams ?? []).map((t) => ({ id: t.id, name: t.name }))); + if (!account?.organization) return []; + return (account.organization.teams ?? []).map((t) => ({ id: t.id, name: t.name })); } // ========================================================================= diff --git a/packages/ai/src/ai/account/base.py b/packages/ai/src/ai/account/base.py index 17c57da9b..201fe4e6c 100644 --- a/packages/ai/src/ai/account/base.py +++ b/packages/ai/src/ai/account/base.py @@ -153,7 +153,7 @@ async def get_apps_for_user(self, user_id: str, organizations: list) -> list: Args: user_id: Internal user ID from the ConnectResult. - organizations: List of org dicts with nested teams (from ConnectResult). + organizations: List containing the user's single org dict with nested teams. Returns: List of app manifest dicts. diff --git a/packages/ai/src/ai/account/models.py b/packages/ai/src/ai/account/models.py index 381d026ef..0150884be 100644 --- a/packages/ai/src/ai/account/models.py +++ b/packages/ai/src/ai/account/models.py @@ -27,7 +27,7 @@ # ============================================================================= import time -from typing import Literal, TypedDict +from typing import Literal, Optional, TypedDict from pydantic import BaseModel, Field @@ -36,8 +36,8 @@ # ============================================================================= # NESTED SHAPES -# Lightweight TypedDicts documenting the element shape of AccountInfo's -# ``organizations`` and ``apps`` lists. Mirrors the public +# Lightweight TypedDicts documenting the shape of AccountInfo's +# ``organization`` and ``apps`` fields. Mirrors the public # ``OrgInfo`` / ``TeamInfo`` defined in ``rocketride.types.client`` but kept # local to avoid a server→client cross-package import. # ============================================================================= @@ -52,7 +52,7 @@ class TeamInfo(TypedDict): class OrgInfo(TypedDict): - """Shape of an entry inside ``AccountInfo.organizations``.""" + """Shape of the ``AccountInfo.organization`` field (single org per user).""" id: str name: str @@ -91,8 +91,9 @@ class AccountInfo(BaseModel): # Default team ID for this session (pre-resolved server-side) defaultTeam: str = '' - # Full org/team/permissions structure — all permission checks resolve through this - organizations: list[OrgInfo] = [] + # Single org/team/permissions structure — all permission checks resolve through this. + # None when the user has no org membership (e.g. freshly invited, not yet provisioned). + organization: Optional[OrgInfo] = None # Apps on the user's desktop — full manifest entries with appStatus + onDesktop. # OSS: all apps with appStatus="free", onDesktop=True. @@ -175,12 +176,12 @@ def resolve_task_permissions(account_info: AccountInfo, task_team_id: str) -> li has no relationship to the team — it returns an empty list instead, signalling "no access". - Resolution order (first match wins): - 1. Walk ``account_info.organizations``. - 2. For each org, walk its teams looking for *task_team_id*. + Resolution order: + 1. Check ``account_info.organization``. + 2. Walk its teams looking for *task_team_id*. 3. If found and org has ``org.admin`` → full permissions. 4. If found → that team's stored permissions. - 5. Not found in any org → ``[]`` (no access). + 5. Not found → ``[]`` (no access). Args: account_info: The authenticated caller's session. @@ -190,12 +191,14 @@ def resolve_task_permissions(account_info: AccountInfo, task_team_id: str) -> li Effective permission list, or empty list if the caller has no membership in the task's team. """ - for org in account_info.organizations: - for team in org.get('teams', []): - if team['id'] == task_team_id: - if 'org.admin' in org.get('permissions', []): - return list(_FULL_TEAM_PERMISSIONS) - return list(team.get('permissions', [])) + org = account_info.organization + if not org: + return [] + for team in org.get('teams', []): + if team['id'] == task_team_id: + if 'org.admin' in org.get('permissions', []): + return list(_FULL_TEAM_PERMISSIONS) + return list(team.get('permissions', [])) return [] @@ -208,7 +211,7 @@ def resolve_team_permissions(account_info: AccountInfo, team_id: str) -> list[st Args: account_info (AccountInfo): The authenticated session whose - ``organizations`` list is inspected. + ``organization`` field is inspected. team_id (str): The team whose permission list should be resolved. Returns: @@ -219,12 +222,10 @@ def resolve_team_permissions(account_info: AccountInfo, team_id: str) -> list[st per-team record. Raises: - PermissionError: If ``team_id`` is not found in any organisation - the caller belongs to. + PermissionError: If ``team_id`` is not found in the user's org. """ - # Walk every organisation the user is a member of. - for org in account_info.organizations: - # Walk every team within this organisation. + org = account_info.organization + if org: for team in org.get('teams', []): if team['id'] == team_id: # Org admins get the full permission set regardless of what is @@ -233,5 +234,4 @@ def resolve_team_permissions(account_info: AccountInfo, team_id: str) -> list[st return list(_FULL_TEAM_PERMISSIONS) # Otherwise return the permissions explicitly stored on the team. return list(team.get('permissions', [])) - # The team was not found in any of the user's organisations. raise PermissionError(f'No membership in team {team_id!r}') diff --git a/packages/ai/src/ai/account/oss/__init__.py b/packages/ai/src/ai/account/oss/__init__.py index e341cf775..8f0bad986 100644 --- a/packages/ai/src/ai/account/oss/__init__.py +++ b/packages/ai/src/ai/account/oss/__init__.py @@ -115,30 +115,28 @@ async def authenticate(self, credential: str) -> Union[Any, Tuple[int, str]]: defaultTeam='local', # Single synthetic organisation with org.admin so that # resolve_team_permissions expands to the full permission set. - organizations=[ - { - 'id': 'local', - 'name': 'Local', - 'permissions': ['org.admin'], - 'teams': [ - { - 'id': 'local', - 'name': 'Development', - 'permissions': [ - 'team.admin', - 'read', - 'write', - 'execute', - 'task.control', - 'task.data', - 'task.monitor', - 'task.debug', - 'task.store', - ], - } - ], - } - ], + organization={ + 'id': 'local', + 'name': 'Local', + 'permissions': ['org.admin'], + 'teams': [ + { + 'id': 'local', + 'name': 'Development', + 'permissions': [ + 'team.admin', + 'read', + 'write', + 'execute', + 'task.control', + 'task.data', + 'task.monitor', + 'task.debug', + 'task.store', + ], + } + ], + }, # OSS: all apps are on the desktop and free — return full manifest # entries so the shell can register MF remotes after auth apps=[ diff --git a/packages/ai/src/ai/modules/task/commands/cmd_debug.py b/packages/ai/src/ai/modules/task/commands/cmd_debug.py index 910622704..bbd614029 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_debug.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_debug.py @@ -214,15 +214,14 @@ async def on_launch(self, request: Dict[str, Any]) -> Dict[str, Any]: args = request.get('arguments') or {} team_id = args.get('teamId') or self._account_info.defaultTeam - # Resolve org_id by walking the organizations/teams tree. + # Resolve org_id from the user's single organization. org_id: Optional[str] = None - for org in self._account_info.organizations or []: + org = self._account_info.organization + if org: for team in org.get('teams', []): if team.get('id') == team_id: org_id = org.get('id', '') break - if org_id is not None: - break if org_id is None: raise PermissionError( f'Team {team_id!r} does not belong to any organisation for user {self._account_info.userId!r}' diff --git a/packages/ai/src/ai/modules/task/commands/cmd_misc.py b/packages/ai/src/ai/modules/task/commands/cmd_misc.py index f0b035b56..30a03daf3 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_misc.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_misc.py @@ -188,10 +188,9 @@ async def on_rrext_validate(self, request: Dict[str, Any]) -> Dict[str, Any]: # Determine org and team IDs from account info org_id = '' team_id = getattr(self._account_info, 'defaultTeam', '') or '' - for org in getattr(self._account_info, 'organizations', []): + org = getattr(self._account_info, 'organization', None) + if org: org_id = org.get('id', '') if isinstance(org, dict) else getattr(org, 'id', '') - if org_id: - break merged_env = await account.get_merged_env( user_id=self._account_info.userId, org_id=org_id, diff --git a/packages/ai/src/ai/modules/task/commands/cmd_monitor.py b/packages/ai/src/ai/modules/task/commands/cmd_monitor.py index dd6a41e1f..c39cbaf35 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_monitor.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_monitor.py @@ -140,9 +140,9 @@ async def send_server_event( # Step 3: org scoping if org_id is not None and hasattr(self, '_account_info') and self._account_info: conn_org = '' - if hasattr(self._account_info, 'organizations') and self._account_info.organizations: - first_org = self._account_info.organizations[0] - conn_org = first_org.get('id', '') if isinstance(first_org, dict) else getattr(first_org, 'id', '') + if hasattr(self._account_info, 'organization') and self._account_info.organization: + org = self._account_info.organization + conn_org = org.get('id', '') if isinstance(org, dict) else getattr(org, 'id', '') if conn_org != org_id: return # Step 4: user scoping diff --git a/packages/ai/src/ai/modules/task/commands/cmd_task.py b/packages/ai/src/ai/modules/task/commands/cmd_task.py index 787715e79..10d237ab1 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_task.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_task.py @@ -151,15 +151,14 @@ async def on_execute(self, request: Dict[str, Any]) -> Dict[str, Any]: # Use client-supplied teamId if present, otherwise fall back to defaultTeam. team_id = args.get('teamId') or self._account_info.defaultTeam - # Resolve org_id by walking the organizations/teams tree. + # Resolve org_id from the user's single organization. org_id = '' - for org in self._account_info.organizations or []: + org = self._account_info.organization + if org: for team in org.get('teams', []): if team.get('id') == team_id: org_id = org.get('id', '') break - if org_id: - break # Build merged environment for pipeline variable resolution. # Combines .env → org → team → user secrets (SaaS) or just .env (OSS). diff --git a/packages/client-python/src/rocketride/types/account.py b/packages/client-python/src/rocketride/types/account.py index 09d04e80a..ee30db8cb 100644 --- a/packages/client-python/src/rocketride/types/account.py +++ b/packages/client-python/src/rocketride/types/account.py @@ -73,7 +73,7 @@ class AccountOrganization(TypedDict, total=False): """ An organization entry nested inside AccountProfile. - Mirrors the shape returned by the server's ``organizations`` array. + Mirrors the shape of the server's ``organization`` field. Attributes: id: Unique identifier for the organization. @@ -107,7 +107,7 @@ class AccountProfile(TypedDict, total=False): phoneNumberVerified: Whether the phone number has been verified. locale: Locale / language preference (e.g. "en"). defaultTeam: The ID of the user's default team context. - organizations: Organizations the user belongs to. + organization: The organization the user belongs to, or None. """ userId: str @@ -121,7 +121,7 @@ class AccountProfile(TypedDict, total=False): phoneNumberVerified: bool locale: str defaultTeam: str - organizations: list[AccountOrganization] + organization: AccountOrganization # ============================================================================= diff --git a/packages/client-python/src/rocketride/types/client.py b/packages/client-python/src/rocketride/types/client.py index 6b81a6a52..de2cfb168 100644 --- a/packages/client-python/src/rocketride/types/client.py +++ b/packages/client-python/src/rocketride/types/client.py @@ -296,7 +296,7 @@ class ConnectResult(TypedDict, total=False): phoneNumberVerified (bool): True when the phone number has been verified. locale (str): BCP-47 locale tag (e.g. "en-US"). defaultTeam (str): ID of the team selected as the default context. - organizations (list[OrgInfo]): All organisations the user belongs to. + organization (OrgInfo | None): The organisation the user belongs to, or None. apps (list[AppManifestEntry]): Apps on the user's desktop — full manifest entries with subscription status. waitlisted (bool): True when authenticated but not yet granted full app access. """ @@ -313,7 +313,7 @@ class ConnectResult(TypedDict, total=False): phoneNumberVerified: bool locale: str defaultTeam: str - organizations: list[OrgInfo] + organization: OrgInfo capabilities: list[str] sysPermissions: list[str] credits: dict diff --git a/packages/client-typescript/src/client/client.ts b/packages/client-typescript/src/client/client.ts index afe65751f..9a8de9503 100644 --- a/packages/client-typescript/src/client/client.ts +++ b/packages/client-typescript/src/client/client.ts @@ -834,10 +834,10 @@ export class RocketRideClient extends DAPClient { } /** - * Returns the ID of the user's primary organization. + * Returns the ID of the user's organization. */ getOrgId(): string | undefined { - return this._connectResult?.organizations?.[0]?.id; + return this._connectResult?.organization?.id; } /** diff --git a/packages/client-typescript/src/client/types/client.ts b/packages/client-typescript/src/client/types/client.ts index 1ed7dfbba..d1e446547 100644 --- a/packages/client-typescript/src/client/types/client.ts +++ b/packages/client-typescript/src/client/types/client.ts @@ -279,11 +279,11 @@ export interface TeamInfo { } /** - * Describes an organisation the authenticated user is a member of. + * Describes the organisation the authenticated user belongs to. * * Organisations group users and teams for billing and access management. - * A user may belong to multiple organisations; each carries its own permission - * set at the organisation level plus a list of contained teams. + * Each user belongs to exactly one organisation, which carries its own + * permission set at the organisation level plus a list of contained teams. */ export interface OrgInfo { /** Unique identifier of the organisation (UUID or short slug) */ @@ -359,10 +359,11 @@ export interface ConnectResult { defaultTeam: string; /** - * List of organisations the authenticated user belongs to, each with - * its own permission set and nested team memberships. + * The organisation the authenticated user belongs to, with its own + * permission set and nested team memberships. Null when the user + * has no org membership. */ - organizations: OrgInfo[]; + organization: OrgInfo | null; /** * Apps on the user's desktop with ``appStatus`` and ``onDesktop``. diff --git a/packages/shared-ui/src/modules/account/AccountView.tsx b/packages/shared-ui/src/modules/account/AccountView.tsx index c3653582c..6f298ceab 100644 --- a/packages/shared-ui/src/modules/account/AccountView.tsx +++ b/packages/shared-ui/src/modules/account/AccountView.tsx @@ -245,8 +245,8 @@ const AccountView: React.FC = (props) => { // PERMISSION HELPERS // ========================================================================= - /** The primary organization's ID (used for org-scoped env calls). */ - const orgId = profile?.organizations?.[0]?.id; + /** The organization's ID (used for org-scoped env calls). */ + const orgId = profile?.organization?.id; // Build appId → app lookup for display name resolution const appMap = useMemo(() => { @@ -255,9 +255,9 @@ const AccountView: React.FC = (props) => { return map; }, [apps]); - /** True when the current user has org.admin on their primary organization. */ + /** True when the current user has org.admin on their organization. */ const isOrgAdmin = useMemo(() => { - return profile?.organizations?.[0]?.permissions?.includes('org.admin') ?? false; + return profile?.organization?.permissions?.includes('org.admin') ?? false; }, [profile]); /** @@ -267,11 +267,10 @@ const AccountView: React.FC = (props) => { const isTeamAdmin = useMemo(() => { return (teamId: string): boolean => { if (isOrgAdmin) return true; - const orgs = profile?.organizations ?? []; - for (const o of orgs) { - for (const t of o.teams ?? []) { - if (t.id === teamId && t.permissions?.includes('team.admin')) return true; - } + const org = profile?.organization; + if (!org) return false; + for (const t of org.teams ?? []) { + if (t.id === teamId && t.permissions?.includes('team.admin')) return true; } return false; }; diff --git a/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx b/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx index 890082eb2..3b3226088 100644 --- a/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx +++ b/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx @@ -134,7 +134,7 @@ export const ProfilePanel: React.FC = ({ profile, authUser, o // Prefer the server-side profile value over the cached auth token value. const displayName = profile?.displayName || authUser?.displayName || '\u2014'; const email = profile?.email || authUser?.email || ''; - const orgs = profile?.organizations ?? authUser?.organizations ?? []; + const org = profile?.organization ?? authUser?.organization ?? null; return (
@@ -170,50 +170,46 @@ export const ProfilePanel: React.FC = ({ profile, authUser, o - {orgs.length > 0 && ( + {org && (
- Organizations / Workspaces + Organization / Workspace
- {orgs.map((o, oi) => ( - - {/* Org row */} -
- + {/* Org row */} +
+ +
+
{org.name}
+
+ {org.permissions?.includes('org.admin') && Admin} +
+ {/* Teams sub-header */} + {org.teams.length > 0 && ( +
+ Teams +
+ )} + {/* Team rows — indented under the org */} + {org.teams.map((t, i) => { + const isDefault = authUser?.defaultTeam === t.id; + const isLast = i === org.teams.length - 1; + return ( +
+
{t.name[0]}
-
{o.name}
+
{t.name}
- {o.permissions?.includes('org.admin') && Admin} + {isDefault ? ( + {'\u2713'} Default + ) : ( + + )}
- {/* Teams sub-header */} - {o.teams.length > 0 && ( -
- Teams -
- )} - {/* Team rows — indented under the org */} - {o.teams.map((t, i) => { - const isDefault = authUser?.defaultTeam === t.id; - const isLast = i === o.teams.length - 1; - return ( -
-
{t.name[0]}
-
-
{t.name}
-
- {isDefault ? ( - {'\u2713'} Default - ) : ( - - )} -
- ); - })} - - ))} + ); + })}
)} From 89d2f7284e4e8f4b575f36d603ee2008bbe130df Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Fri, 12 Jun 2026 15:26:55 -0700 Subject: [PATCH 08/12] feat(account): multi-org switcher, invite team assignment, Zitadel sync, resend invite (#billing-6) Multi-org with active org switcher: - Reverted single-org unique constraint; users can belong to multiple orgs but only one is active at a time via users.default_org_id - ConnectResult.organization stays as single object (the active org) - get_authentication_result scopes to default_org_id, falls back to first active membership and auto-sets if stale - get_user_profile returns memberships[] (all orgs) + defaultOrgId for the org switcher UI - New set_default_org service method + handler: validates membership, updates default_org_id, resets default_team_id to first team in new org - provision_new_user sets default_org_id alongside default_team_id - ConnectResult type: added optional memberships/defaultOrgId fields Org switcher UI (ProfilePanel): - Shows all org memberships; active org at full opacity with checkmark - Inactive orgs grayed out (opacity 0.45) with "Switch to" button - Teams only expanded under the active org - Wired through AccountView, AccountPage, AccountWebview, AccountProvider Invite with team assignment: - InviteMemberParams: added optional teamAssignments array - Invite modal: team checkboxes with per-team PermGrid for permissions - Selected permissions shown as PermPill badges (outlined style matching Teams panel) - Backend creates TeamMember rows in same transaction as the invite Resend invite: - New resendInvite SDK method + handler subcommand - MembersPanel: "Resend" button on pending members with loading/sent feedback states (Sending... -> Sent! for 3s) - Zitadel: resend_zitadel_init_email uses v1 _resend_initialization endpoint Zitadel cleanup on invite cancel: - delete_zitadel_user function: DELETE /management/v1/users/{id}, ignores 404 - remove_org_member: when cancelling a pending invite for an orphaned user (zero remaining memberships), deletes Zitadel account + local User row - Only deletes locally if Zitadel delete succeeds (prevents orphaned state) Other fixes: - remove_org_member no longer deletes user on last org removal; clears default_org_id instead, user becomes org-less - ensure_org_membership: auto-creates personal org on login for org-less users (e.g. after being removed from all orgs) - Avatar colors: replaced bright palette with muted professional tones Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/views/account/AccountPage.tsx | 16 ++- apps/vscode/src/providers/AccountProvider.ts | 48 +++++++++ .../views/Account/AccountWebview.tsx | 14 ++- .../client-typescript/src/client/account.ts | 23 +++++ .../src/client/types/account.ts | 6 ++ .../src/client/types/client.ts | 12 +++ .../src/modules/account/AccountView.tsx | 97 +++++++++++++++++-- .../account/components/MembersPanel.tsx | 36 ++++++- .../account/components/ProfilePanel.tsx | 84 ++++++++++------ .../src/modules/account/components/shared.tsx | 2 +- 10 files changed, 291 insertions(+), 47 deletions(-) diff --git a/apps/shell-ui/src/views/account/AccountPage.tsx b/apps/shell-ui/src/views/account/AccountPage.tsx index 5c88827ca..feedd7386 100644 --- a/apps/shell-ui/src/views/account/AccountPage.tsx +++ b/apps/shell-ui/src/views/account/AccountPage.tsx @@ -315,6 +315,12 @@ const AccountPage: React.FC = () => { await client.account.setDefaultTeam(teamId); }, [client]); + /** Switches the user's active organization. */ + const handleSetDefaultOrg = useCallback(async (orgId: string) => { + if (!client) return; + await client.account.setDefaultOrg(orgId); + }, [client]); + /** Deletes the user account. */ const handleDeleteAccount = useCallback(async () => { if (!client) return; @@ -345,7 +351,7 @@ const AccountPage: React.FC = () => { }, [client, loadKeys]); /** Sends an invitation to a new organization member. */ - const handleInviteMember = useCallback(async (params: { email: string; givenName: string; familyName: string; role: string }) => { + const handleInviteMember = useCallback(async (params: { email: string; givenName: string; familyName: string; role: string; teamAssignments?: Array<{ teamId: string; permissions: string[] }> }) => { if (!client || !orgId) return; await client.account.inviteMember(orgId, params); await loadMembers(); @@ -365,6 +371,12 @@ const AccountPage: React.FC = () => { await loadMembers(); }, [client, orgId, loadMembers]); + /** Resends the initialization email for a pending member. */ + const handleResendInvite = useCallback(async (userId: string) => { + if (!client || !orgId) return; + await client.account.resendInvite(orgId, userId); + }, [client, orgId]); + /** Creates a new team. */ const handleCreateTeam = useCallback(async (name: string) => { if (!client || !orgId) return; @@ -494,6 +506,7 @@ const AccountPage: React.FC = () => { onActiveTeamIdChange={setActiveTeamId} onSaveProfile={handleSaveProfile} onSetDefaultTeam={handleSetDefaultTeam} + onSetDefaultOrg={handleSetDefaultOrg} onLogout={() => logout?.()} onDeleteAccount={handleDeleteAccount} onSaveOrgName={handleSaveOrgName} @@ -502,6 +515,7 @@ const AccountPage: React.FC = () => { onInviteMember={handleInviteMember} onUpdateMemberRole={handleUpdateMemberRole} onRemoveMember={handleRemoveMember} + onResendInvite={handleResendInvite} onCreateTeam={handleCreateTeam} onDeleteTeam={handleDeleteTeam} onAddTeamMember={handleAddTeamMember} diff --git a/apps/vscode/src/providers/AccountProvider.ts b/apps/vscode/src/providers/AccountProvider.ts index 1d74746bb..750ee08b7 100644 --- a/apps/vscode/src/providers/AccountProvider.ts +++ b/apps/vscode/src/providers/AccountProvider.ts @@ -156,6 +156,10 @@ export class AccountProvider { await this.handleSetDefaultTeam(panel, message.teamId as string); break; + case 'account:setDefaultOrg': + await this.handleSetDefaultOrg(panel, message.orgId as string); + break; + // -- API Keys --------------------------------------------------------- case 'account:createKey': await this.handleCreateKey(panel, message.params as { name: string; teamId: string; permissions: string[]; expiresAt?: string }); @@ -183,6 +187,10 @@ export class AccountProvider { await this.handleRemoveMember(panel, message.userId as string); break; + case 'account:resendInvite': + await this.handleResendInvite(panel, message.userId as string); + break; + // -- Teams ------------------------------------------------------------ case 'account:createTeam': await this.handleCreateTeam(panel, message.name as string); @@ -411,6 +419,30 @@ export class AccountProvider { await panel.webview.postMessage({ type: 'account:authUser', authUser }); } + /** + * Switches the user's active organization. + * + * @param panel - The webview panel. + * @param orgId - The org ID to switch to. + */ + private async handleSetDefaultOrg(panel: vscode.WebviewPanel, orgId: string): Promise { + const { client } = this.resolveClient(); + if (!client) { + this.postError(panel, 'Not connected'); + return; + } + + // Step 1: send the set_default_org request. + await client.account.setDefaultOrg(orgId); + + // Step 2: the server pushes a refreshed ConnectResult to all connections. + // Re-fetch profile and authUser so the UI reflects the new active org. + const profile = await client.account.getProfile().catch(() => null); + const authUser = client.getAccountInfo(); + await panel.webview.postMessage({ type: 'account:profile', profile: profile || authUser || null }); + await panel.webview.postMessage({ type: 'account:authUser', authUser }); + } + // ========================================================================= // API KEY HANDLERS // ========================================================================= @@ -575,6 +607,22 @@ export class AccountProvider { await this.refreshMembers(panel); } + /** + * Resends the initialization email for a pending org member. + * + * @param panel - The webview panel. + * @param userId - The pending member's user ID. + */ + private async handleResendInvite(panel: vscode.WebviewPanel, userId: string): Promise { + const { client, orgId } = this.resolveClient(); + if (!client) { + this.postError(panel, 'Not connected'); + return; + } + + await client.account.resendInvite(orgId!, userId); + } + /** * Fetches the current member list and posts it to the webview. * diff --git a/apps/vscode/src/providers/views/Account/AccountWebview.tsx b/apps/vscode/src/providers/views/Account/AccountWebview.tsx index 4099144c0..0a07e68d5 100644 --- a/apps/vscode/src/providers/views/Account/AccountWebview.tsx +++ b/apps/vscode/src/providers/views/Account/AccountWebview.tsx @@ -240,6 +240,11 @@ const AccountWebview: React.FC = () => { sendMessageRef.current({ type: 'account:setDefaultTeam', teamId }); }, []); + /** Switches the user's active organization. */ + const handleSetDefaultOrg = useCallback(async (orgId: string): Promise => { + sendMessageRef.current({ type: 'account:setDefaultOrg', orgId }); + }, []); + /** Triggers the logout flow on the host side. */ const handleLogout = useCallback(() => { sendMessageRef.current({ type: 'account:logout' }); @@ -274,7 +279,7 @@ const AccountWebview: React.FC = () => { }, []); /** Sends an invitation to a new organization member. */ - const handleInviteMember = useCallback(async (params: { email: string; givenName: string; familyName: string; role: string }): Promise => { + const handleInviteMember = useCallback(async (params: { email: string; givenName: string; familyName: string; role: string; teamAssignments?: Array<{ teamId: string; permissions: string[] }> }): Promise => { sendMessageRef.current({ type: 'account:inviteMember', params }); }, []); @@ -288,6 +293,11 @@ const AccountWebview: React.FC = () => { sendMessageRef.current({ type: 'account:removeMember', userId }); }, []); + /** Resends the initialization email for a pending member. */ + const handleResendInvite = useCallback(async (userId: string): Promise => { + sendMessageRef.current({ type: 'account:resendInvite', userId }); + }, []); + /** Creates a new team. */ const handleCreateTeam = useCallback(async (name: string): Promise => { sendMessageRef.current({ type: 'account:createTeam', name }); @@ -431,6 +441,7 @@ const AccountWebview: React.FC = () => { onActiveTeamIdChange={setActiveTeamId} onSaveProfile={handleSaveProfile} onSetDefaultTeam={handleSetDefaultTeam} + onSetDefaultOrg={handleSetDefaultOrg} onLogout={handleLogout} onDeleteAccount={handleDeleteAccount} onSaveOrgName={handleSaveOrgName} @@ -439,6 +450,7 @@ const AccountWebview: React.FC = () => { onInviteMember={handleInviteMember} onUpdateMemberRole={handleUpdateMemberRole} onRemoveMember={handleRemoveMember} + onResendInvite={handleResendInvite} onCreateTeam={handleCreateTeam} onDeleteTeam={handleDeleteTeam} onAddTeamMember={handleAddTeamMember} diff --git a/packages/client-typescript/src/client/account.ts b/packages/client-typescript/src/client/account.ts index d1646e4f3..2e15b00aa 100644 --- a/packages/client-typescript/src/client/account.ts +++ b/packages/client-typescript/src/client/account.ts @@ -80,6 +80,19 @@ export class AccountApi { await this.client.call('rrext_account_me', { subcommand: 'set_default_team', teamId }); } + /** + * Switches the user's active organization. + * + * The server updates the user's default_org_id and resets the default + * team to the first team in the new org. All connections for this user + * receive a refreshed AccountInfo via shell:accountUpdate. + * + * @param orgId - The org ID to switch to. + */ + async setDefaultOrg(orgId: string): Promise { + await this.client.call('rrext_account_me', { subcommand: 'set_default_org', orgId }); + } + /** * Permanently deletes the current user's account. */ @@ -191,6 +204,16 @@ export class AccountApi { await this.client.call('rrext_account_members', { subcommand: 'delete', orgId, userId }); } + /** + * Resends the initialization email for a pending org member. + * + * @param orgId - Organisation UUID. + * @param userId - The pending member's user ID. + */ + async resendInvite(orgId: string, userId: string): Promise { + await this.client.call('rrext_account_members', { subcommand: 'resend_invite', orgId, userId }); + } + // ========================================================================= // TEAMS // ========================================================================= diff --git a/packages/client-typescript/src/client/types/account.ts b/packages/client-typescript/src/client/types/account.ts index 3161744d3..5669a57df 100644 --- a/packages/client-typescript/src/client/types/account.ts +++ b/packages/client-typescript/src/client/types/account.ts @@ -236,6 +236,12 @@ export interface InviteMemberParams { /** Organization-level role to assign (e.g. "admin" or "member"). */ role: string; + + /** + * Optional team assignments to create when the invite is accepted. + * Each entry specifies a team ID and the permissions to grant. + */ + teamAssignments?: Array<{ teamId: string; permissions: string[] }>; } /** Parameters for adding or updating a team member. */ diff --git a/packages/client-typescript/src/client/types/client.ts b/packages/client-typescript/src/client/types/client.ts index d1e446547..ceeb4e016 100644 --- a/packages/client-typescript/src/client/types/client.ts +++ b/packages/client-typescript/src/client/types/client.ts @@ -392,6 +392,18 @@ export interface ConnectResult { * The shell should show a waitlist page instead of the main workspace. */ waitlisted?: boolean; + + /** + * All org memberships the user has (for the org switcher UI). + * Only present in profile responses, not in the auth handshake. + */ + memberships?: OrgInfo[]; + + /** + * The ID of the user's currently active (default) organization. + * Only present in profile responses. + */ + defaultOrgId?: string; } /** diff --git a/packages/shared-ui/src/modules/account/AccountView.tsx b/packages/shared-ui/src/modules/account/AccountView.tsx index 6f298ceab..fccbf3bd9 100644 --- a/packages/shared-ui/src/modules/account/AccountView.tsx +++ b/packages/shared-ui/src/modules/account/AccountView.tsx @@ -32,7 +32,7 @@ import { ApiKeysPanel } from './components/ApiKeysPanel'; import { OrganizationPanel } from './components/OrganizationPanel'; import { TeamsPanel } from './components/TeamsPanel'; import { MembersPanel } from './components/MembersPanel'; -import { S, Modal, PermGrid, ExpiryOpts, Avatar, relativeTime } from './components/shared'; +import { S, Modal, PermGrid, PermPill, ExpiryOpts, Avatar, relativeTime, PERM_DISPLAY } from './components/shared'; // ============================================================================= // REVEAL STYLES @@ -193,6 +193,8 @@ export interface IAccountViewProps { onSaveProfile: (fields: ProfileUpdate) => Promise; /** Sets the user's preferred default team. */ onSetDefaultTeam: (teamId: string) => Promise; + /** Switches the user's active organization. */ + onSetDefaultOrg: (orgId: string) => Promise; /** Triggers the logout flow. */ onLogout: () => void; /** Permanently deletes the user account. */ @@ -204,11 +206,13 @@ export interface IAccountViewProps { /** Revokes an API key by its ID. */ onRevokeKey: (keyId: string) => Promise; /** Sends an invitation to a new organization member. */ - onInviteMember: (params: { email: string; givenName: string; familyName: string; role: string }) => Promise; + onInviteMember: (params: { email: string; givenName: string; familyName: string; role: string; teamAssignments?: Array<{ teamId: string; permissions: string[] }> }) => Promise; /** Updates an organization member's role. */ onUpdateMemberRole: (userId: string, role: string) => Promise; /** Removes an organization member. */ onRemoveMember: (userId: string) => Promise; + /** Resends the initialization email for a pending member. */ + onResendInvite: (userId: string) => Promise; /** Creates a new team. */ onCreateTeam: (name: string) => Promise; /** Deletes a team. */ @@ -239,7 +243,7 @@ export interface IAccountViewProps { * to the host via async callback props defined in IAccountViewProps. */ const AccountView: React.FC = (props) => { - const { isConnected, sectionError, profile, authUser, keys, org, members, teams, teamDetail, subscriptions, billingLoading, billingError, creditBalance, apps, onCancelSubscription, onOpenPortal, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, memberNames, teamNames, topupPlans, onBuyTopup, allPlans, onPurchaseTopup, onUpgradeSubscription, section, onSectionChange, activeTeamId, onActiveTeamIdChange, onSaveProfile, onSetDefaultTeam, onLogout, onDeleteAccount, onSaveOrgName, onCreateKey, onRevokeKey, onInviteMember, onUpdateMemberRole, onRemoveMember, onCreateTeam, onDeleteTeam, onAddTeamMember, onEditTeamMemberPerms, onRemoveTeamMember, onLoadTeamDetail } = props; + const { isConnected, sectionError, profile, authUser, keys, org, members, teams, teamDetail, subscriptions, billingLoading, billingError, creditBalance, apps, onCancelSubscription, onOpenPortal, onSubscribe, transactions, usageByUser, usageByTeam, activeTasks, dashboardLoading, onTransactionPage, memberNames, teamNames, topupPlans, onBuyTopup, allPlans, onPurchaseTopup, onUpgradeSubscription, section, onSectionChange, activeTeamId, onActiveTeamIdChange, onSaveProfile, onSetDefaultTeam, onSetDefaultOrg, onLogout, onDeleteAccount, onSaveOrgName, onCreateKey, onRevokeKey, onInviteMember, onUpdateMemberRole, onRemoveMember, onResendInvite, onCreateTeam, onDeleteTeam, onAddTeamMember, onEditTeamMemberPerms, onRemoveTeamMember, onLoadTeamDetail } = props; // ========================================================================= // PERMISSION HELPERS @@ -301,6 +305,8 @@ const AccountView: React.FC = (props) => { const [inviteGivenName, setInviteGivenName] = useState(''); const [inviteFamilyName, setInviteFamilyName] = useState(''); const [inviteRole, setInviteRole] = useState('member'); + const [inviteTeams, setInviteTeams] = useState>({}); + const [inviteEditPermsTeamId, setInviteEditPermsTeamId] = useState(null); const [editRoleTarget, setEditRoleTarget] = useState(null); const [editRoleValue, setEditRoleValue] = useState('member'); const [editPermsTarget, setEditPermsTarget] = useState(null); @@ -402,8 +408,16 @@ const AccountView: React.FC = (props) => { setSaving(true); setSaveError(null); try { - // Step 2: call the host callback. - await onInviteMember({ email: inviteEmail.trim(), givenName: inviteGivenName.trim(), familyName: inviteFamilyName.trim(), role: inviteRole }); + // Step 2: build team assignments from the checked teams. + const teamAssignments = Object.entries(inviteTeams).map(([teamId, permissions]) => ({ teamId, permissions })); + // Step 3: call the host callback. + await onInviteMember({ + email: inviteEmail.trim(), + givenName: inviteGivenName.trim(), + familyName: inviteFamilyName.trim(), + role: inviteRole, + teamAssignments: teamAssignments.length > 0 ? teamAssignments : undefined, + }); setModal(null); } catch (e) { setSaveError(e instanceof Error ? e.message : 'Failed to invite member'); @@ -612,6 +626,8 @@ const AccountView: React.FC = (props) => { setInviteGivenName(''); setInviteFamilyName(''); setInviteRole('member'); + setInviteTeams({}); + setInviteEditPermsTeamId(null); setSaveError(null); setModal('invite'); }; @@ -695,7 +711,7 @@ const AccountView: React.FC = (props) => { content: (
{sectionError &&

{sectionError}

} - +
), }, @@ -759,7 +775,7 @@ const AccountView: React.FC = (props) => { content: (
{sectionError &&

{sectionError}

} - + onResendInvite(m.userId)} isOrgAdmin={isOrgAdmin} />
), }, @@ -953,6 +969,73 @@ const AccountView: React.FC = (props) => {
+ {/* Team Access */} + {teams.length > 0 && ( +
+
Team Access
+
+ {teams.map((t) => { + const checked = t.id in inviteTeams; + const perms = inviteTeams[t.id] ?? []; + const editingPerms = inviteEditPermsTeamId === t.id; + return ( +
+
+ {/* Checkbox */} +
{ + if (checked) { + const next = { ...inviteTeams }; + delete next[t.id]; + setInviteTeams(next); + if (editingPerms) setInviteEditPermsTeamId(null); + } else { + setInviteTeams({ ...inviteTeams, [t.id]: ['task.control', 'task.monitor'] }); + } + }} + style={{ + width: 14, height: 14, borderRadius: 3, flexShrink: 0, + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: 9, cursor: 'pointer', + border: `1px solid ${checked ? 'var(--rr-color-info)' : 'var(--rr-border-input)'}`, + background: checked ? 'var(--rr-color-info)' : 'var(--rr-bg-input)', + color: 'var(--rr-fg-button)', + }} + > + {checked && '\u2713'} +
+ {/* Team name */} + {t.name} + {/* Edit Perms button */} + {checked && ( + + )} +
+ {/* Permission badges (when not editing) */} + {checked && !editingPerms && perms.length > 0 && ( +
+ {perms.map((p) => ( + + ))} +
+ )} + {/* PermGrid (when editing) */} + {editingPerms && ( +
+ setInviteTeams({ ...inviteTeams, [t.id]: v })} /> +
+ )} +
+ ); + })} +
+
+ )} {saveError &&
{saveError}
} )} diff --git a/packages/shared-ui/src/modules/account/components/MembersPanel.tsx b/packages/shared-ui/src/modules/account/components/MembersPanel.tsx index 1cfba7211..3d5ec8be6 100644 --- a/packages/shared-ui/src/modules/account/components/MembersPanel.tsx +++ b/packages/shared-ui/src/modules/account/components/MembersPanel.tsx @@ -114,6 +114,8 @@ export interface MembersPanelProps { onChangeRole: (m: MemberRecord) => void; /** Opens the remove/cancel-invite confirmation modal for the given member. */ onRemove: (m: MemberRecord) => void; + /** Resends the initialization email for a pending member. */ + onResendInvite: (m: MemberRecord) => Promise; /** True when the current user has org.admin permissions. */ isOrgAdmin: boolean; } @@ -128,9 +130,11 @@ export interface MembersPanelProps { * Lists all organization members with their avatar, name, email, role badge, * and status. Includes a top search bar and a right-side A-Z letter selector. */ -export const MembersPanel: React.FC = ({ org, members, profile, onInvite, onChangeRole, onRemove, isOrgAdmin }) => { +export const MembersPanel: React.FC = ({ org, members, profile, onInvite, onChangeRole, onRemove, onResendInvite, isOrgAdmin }) => { const [search, setSearch] = useState(''); const [activeLetter, setActiveLetter] = useState(null); + const [resendingUserId, setResendingUserId] = useState(null); + const [resentUserId, setResentUserId] = useState(null); // Determine which letters have at least one matching member. const availableLetters = useMemo(() => { @@ -211,13 +215,35 @@ export const MembersPanel: React.FC = ({ org, members, profil // Current user: show role badge only, no edit/remove. {m.role} ) : m.status === 'pending' ? ( - // Pending invitation: show badge and cancel button (admin only). + // Pending invitation: show badge, resend, and cancel buttons (admin only).
Pending {isOrgAdmin && ( - + <> + {resentUserId === m.userId ? ( + Sent! + ) : ( + + )} + + )}
) : ( diff --git a/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx b/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx index 3b3226088..8a84a0f8a 100644 --- a/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx +++ b/packages/shared-ui/src/modules/account/components/ProfilePanel.tsx @@ -32,6 +32,8 @@ export interface ProfilePanelProps { onSave: (fields: ProfileUpdate) => Promise; /** Sets the user's preferred default team by its ID. */ onSetDefaultTeam: (teamId: string) => void; + /** Switches the user's active organization by its ID. */ + onSetDefaultOrg: (orgId: string) => void; /** Triggers the logout flow. */ onLogout: () => void; /** Async handler that permanently deletes the user account. */ @@ -72,7 +74,7 @@ const VerifiedBadge: React.FC = () => ( * a list of their organizations and team memberships with a "Set default" * action per team, a Sign Out button, and an inline Edit Profile modal. */ -export const ProfilePanel: React.FC = ({ profile, authUser, onSave, onSetDefaultTeam, onLogout, onDeleteAccount }) => { +export const ProfilePanel: React.FC = ({ profile, authUser, onSave, onSetDefaultTeam, onSetDefaultOrg, onLogout, onDeleteAccount }) => { /** * Builds a ProfileUpdate snapshot from the current profile/authUser props. * Called both on mount and whenever the underlying data changes, so the @@ -135,6 +137,8 @@ export const ProfilePanel: React.FC = ({ profile, authUser, o const displayName = profile?.displayName || authUser?.displayName || '\u2014'; const email = profile?.email || authUser?.email || ''; const org = profile?.organization ?? authUser?.organization ?? null; + const memberships = profile?.memberships ?? (org ? [org] : []); + const defaultOrgId = profile?.defaultOrgId ?? org?.id; return (
@@ -170,44 +174,60 @@ export const ProfilePanel: React.FC = ({ profile, authUser, o - {org && ( + {memberships.length > 0 && (
- Organization / Workspace + Organizations / Workspaces
- {/* Org row */} -
- -
-
{org.name}
-
- {org.permissions?.includes('org.admin') && Admin} -
- {/* Teams sub-header */} - {org.teams.length > 0 && ( -
- Teams -
- )} - {/* Team rows — indented under the org */} - {org.teams.map((t, i) => { - const isDefault = authUser?.defaultTeam === t.id; - const isLast = i === org.teams.length - 1; + {memberships.map((o, oi) => { + const isActive = o.id === defaultOrgId; + const inactiveOpacity = isActive ? 1 : 0.45; return ( -
-
{t.name[0]}
-
-
{t.name}
+ + {/* Org row */} +
+ +
+
{o.name}
+
+ {o.permissions?.includes('org.admin') && Admin} + {isActive ? ( + {'\u2713'} Active + ) : ( + + )}
- {isDefault ? ( - {'\u2713'} Default - ) : ( - + {/* Teams — only shown for the active org */} + {isActive && o.teams.length > 0 && ( + <> +
+ Teams +
+ {o.teams.map((t, i) => { + const isDefaultTeam = authUser?.defaultTeam === t.id; + const isLast = i === o.teams.length - 1; + return ( +
+
{t.name[0]}
+
+
{t.name}
+
+ {isDefaultTeam ? ( + {'\u2713'} Default + ) : ( + + )} +
+ ); + })} + )} -
+ ); })}
diff --git a/packages/shared-ui/src/modules/account/components/shared.tsx b/packages/shared-ui/src/modules/account/components/shared.tsx index 42196baf6..0c8ac03b3 100644 --- a/packages/shared-ui/src/modules/account/components/shared.tsx +++ b/packages/shared-ui/src/modules/account/components/shared.tsx @@ -50,7 +50,7 @@ export function initials(name: string, email: string): string { * @returns A CSS hex color string. */ export function avatarColor(seed: string): string { - const colors = ['#f7901f', '#3794ff', '#a78bfa', '#34d399', '#f59e0b', '#ec4899', '#14b8a6']; + const colors = ['#4a6fa5', '#6b7b8d', '#5b7a6e', '#7c6d82', '#5c798f', '#6e7f6b', '#8a7968']; // Polynomial rolling hash — keeps the result in unsigned 32-bit range via >>> 0. let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; From 0870057bffcf570e2a479c185f3756846431231c Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Fri, 12 Jun 2026 15:38:39 -0700 Subject: [PATCH 09/12] fix(vscode): add missing newPriceId to AccountWebviewMessage (#billing-6) The upgrade/downgrade handler reads message.newPriceId but the type interface was missing the field, causing a TS2551 compile error. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/vscode/src/providers/AccountProvider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode/src/providers/AccountProvider.ts b/apps/vscode/src/providers/AccountProvider.ts index 750ee08b7..b2dc0c76c 100644 --- a/apps/vscode/src/providers/AccountProvider.ts +++ b/apps/vscode/src/providers/AccountProvider.ts @@ -44,6 +44,7 @@ interface AccountWebviewMessage { appId?: string; packId?: string; priceId?: string; + newPriceId?: string; subscriptionId?: string; } From b525f92991f4cc25ec0de1640b6be0b67d1c9bc6 Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Fri, 12 Jun 2026 15:58:21 -0700 Subject: [PATCH 10/12] fix: address CodeRabbit review findings (#billing-6) Fixes from CodeRabbit review on PR #1258: - apply_debit: team_id type str -> str | None to match apply_credit and downstream callers that pass None (base.py) - SettingsProvider: confirmPending catch block now reports actual error instead of swallowing it with error: null - CloudPanel: removed unused useRef import - BillingDashboard: removed unused total variable in BalanceBreakdown, added BalanceBreakdown to the render output (was defined but not rendered) - BillingDashboard: removed (tx as any).description cast, added description field to LedgerTransaction SDK type - BillingPanel: UpgradeModal now filters allPlans by upgradeTarget.appId to prevent cross-app upgrade requests - billing/index.ts: added missing AppPrice type re-export Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/vscode/src/providers/SettingsProvider.ts | 5 +++-- .../src/providers/views/components/panels/CloudPanel.tsx | 2 +- packages/ai/src/ai/account/base.py | 4 ++-- packages/client-typescript/src/client/types/billing.ts | 3 +++ .../src/modules/account/components/BillingPanel.tsx | 2 +- .../src/modules/billing/components/BillingDashboard.tsx | 4 ++-- packages/shared-ui/src/modules/billing/index.ts | 2 +- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/vscode/src/providers/SettingsProvider.ts b/apps/vscode/src/providers/SettingsProvider.ts index 82e033594..8bf0f2ee5 100644 --- a/apps/vscode/src/providers/SettingsProvider.ts +++ b/apps/vscode/src/providers/SettingsProvider.ts @@ -206,8 +206,9 @@ export class SettingsProvider { priceId: message.priceId, }); panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); - } catch { - panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + panel.webview.postMessage({ type: 'checkout:confirmResult', error: msg }); } break; } diff --git a/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx b/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx index 7506f5741..17ca1c617 100644 --- a/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx +++ b/apps/vscode/src/providers/views/components/panels/CloudPanel.tsx @@ -10,7 +10,7 @@ * Used by ConnectionSettings (dev) and DeployTargetSettings (deploy). */ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import cloudLogoDark from '../../../../../rocketride-dark-icon.png'; import cloudLogoLight from '../../../../../rocketride-light-icon.png'; import { settingsStyles as S } from '../../Settings/SettingsWebview'; diff --git a/packages/ai/src/ai/account/base.py b/packages/ai/src/ai/account/base.py index 201fe4e6c..46d2aaea0 100644 --- a/packages/ai/src/ai/account/base.py +++ b/packages/ai/src/ai/account/base.py @@ -225,7 +225,7 @@ async def apply_debit( self, org_id: str, user_id: str, - team_id: str, + team_id: str | None, resource: str, amount: float, idempotency_key: str, @@ -244,7 +244,7 @@ async def apply_debit( Args: org_id: Organisation to debit. user_id: User whose task triggered the burn (required for attribution). - team_id: Team the task belongs to (required for attribution). + team_id: Team the task belongs to (None when task has no team scope). resource: Billing bucket (e.g. tokens, video, audio). amount: Positive amount to debit (negated internally). idempotency_key: Namespaced dedup key (e.g. ``task:abc123:gpu_memory``). diff --git a/packages/client-typescript/src/client/types/billing.ts b/packages/client-typescript/src/client/types/billing.ts index 04c49f28b..6d707652e 100644 --- a/packages/client-typescript/src/client/types/billing.ts +++ b/packages/client-typescript/src/client/types/billing.ts @@ -189,6 +189,9 @@ export interface LedgerTransaction { /** Human-readable context (pipeline name, source, pack_id, etc.). */ context: Record | null; + /** Line-item detail (e.g. gpu_memory, cpu_utilization). */ + description: string | null; + /** ISO 8601 creation timestamp. */ createdAt: string | null; } diff --git a/packages/shared-ui/src/modules/account/components/BillingPanel.tsx b/packages/shared-ui/src/modules/account/components/BillingPanel.tsx index 8824af73c..343c44e8e 100644 --- a/packages/shared-ui/src/modules/account/components/BillingPanel.tsx +++ b/packages/shared-ui/src/modules/account/components/BillingPanel.tsx @@ -368,7 +368,7 @@ export const BillingPanel: React.FC = ({ isConnected, subscri {/* Upgrade / change plan modal */} {upgradeTarget && allPlans && onUpgradeSubscription && ( p.appId === upgradeTarget.appId)} currentPriceId={upgradeTarget.stripePriceId} currentPlanName={upgradeTarget.planNickname} onUpgrade={(newPriceId) => onUpgradeSubscription(upgradeTarget.appId, newPriceId)} diff --git a/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx b/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx index cd148bf47..30320e994 100644 --- a/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx +++ b/packages/shared-ui/src/modules/billing/components/BillingDashboard.tsx @@ -339,7 +339,6 @@ const BalanceBreakdown: React.FC<{ balance: CreditBalance | null; transactions: const p = purchased[resource] ?? 0; const c = consumed[resource] ?? 0; const net = balance?.balances?.[resource] ?? p - c; - const total = Math.max(p, c + net, 1); return { resource, purchased: p, consumed: c, net, pct: p > 0 ? (c / p) * 100 : 0 }; }); }, [balance, transactions]); @@ -547,7 +546,7 @@ const TransactionLog: React.FC<{ transactions: TransactionsResult | null; onPage {tx.userId ? (memberNames?.[tx.userId] ?? tx.userId.slice(0, 8)) : '--'} {tx.type} {tx.resource} - {(tx as any).description || '--'} + {tx.description || '--'} = 0 ? 'var(--rr-color-success)' : 'var(--rr-text-primary)' }}> {tx.amount >= 0 ? '+' : ''}{fmt(tx.amount)} @@ -626,6 +625,7 @@ export const BillingDashboard: React.FC = ({ return ( <> + diff --git a/packages/shared-ui/src/modules/billing/index.ts b/packages/shared-ui/src/modules/billing/index.ts index a6b6f0221..5eeaa7ba2 100644 --- a/packages/shared-ui/src/modules/billing/index.ts +++ b/packages/shared-ui/src/modules/billing/index.ts @@ -21,4 +21,4 @@ export { UpgradeModal } from './components/UpgradeModal'; export type { UpgradeModalProps } from './components/UpgradeModal'; // ── Types ─────────────────────────────────────────────────────────────────── -export type { BillingDetail, StripePlan, CreditBalance, CreditPack, LedgerTransaction, TransactionsResult, UsageRollup } from './types'; +export type { AppPrice, BillingDetail, StripePlan, CreditBalance, CreditPack, LedgerTransaction, TransactionsResult, UsageRollup } from './types'; From 590f7928d5bcb4752a115ce09fccc87f3ecbe08d Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Fri, 12 Jun 2026 16:19:56 -0700 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20address=20all=20CodeRabbit=20revie?= =?UTF-8?q?w=20findings=20=E2=80=94=20round=202=20(#billing-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountPage.tsx: - Removed unused txPage state and all setTxPage calls - Removed unused isOrgAdminFlag computed value - Memoized memberNames and teamNames with useMemo SettingsPage.tsx: - Subscribe CTA now guards on pipeBuilderApp existence AccountProvider.ts: - confirm_pending catch block propagates actual error message - Added orgId to AccountWebviewMessage interface SettingsWebview.tsx: - Added isSubscribed to SettingsIncomingMessage type - Removed (message as any) casts for isSubscribed access CloudPanel.tsx: - Removed unused useRef import (already fixed in prior commit) BillingDashboard.tsx: - BalanceBreakdown rendered in component output (already fixed) - Removed unused total variable (already fixed) - Removed (tx as any).description cast (already fixed) LedgerTransaction type (billing.ts): - Added description field - Added granted/consumed fields to CreditBalance type CreditsPanel.tsx: - Removed (balance as any) casts for granted/consumed access BillingPanel.tsx: - UpgradeModal filters allPlans by upgradeTarget.appId (already fixed) TopUpModal.tsx: - Filter inactive plans (isActive !== false) - Block modal dismiss while purchase is in-flight UpgradeModal.tsx: - Filter inactive plans from subscription list - Normalize billing periods (yearly /12) for upgrade/downgrade labels CheckoutModal.tsx: - Filter inactive prices from subscription plan list PlanPicker.tsx: - planOrder handles order=0 correctly (Number.isFinite instead of ||) - planAmount respects plan.currency for EUR symbol billing/index.ts: - Added AppPrice type re-export (already fixed) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/views/account/AccountPage.tsx | 23 +++++++++++-------- .../src/views/settings/SettingsPage.tsx | 2 +- apps/vscode/src/providers/AccountProvider.ts | 8 ++++--- .../views/Settings/SettingsWebview.tsx | 5 ++-- .../src/client/types/billing.ts | 6 +++++ .../billing/components/CreditsPanel.tsx | 4 ++-- .../modules/billing/components/TopUpModal.tsx | 6 ++--- .../billing/components/UpgradeModal.tsx | 12 +++++++--- .../src/modules/checkout/CheckoutModal.tsx | 2 +- .../src/modules/checkout/PlanPicker.tsx | 7 +++--- 10 files changed, 47 insertions(+), 28 deletions(-) diff --git a/apps/shell-ui/src/views/account/AccountPage.tsx b/apps/shell-ui/src/views/account/AccountPage.tsx index feedd7386..7f8738dc6 100644 --- a/apps/shell-ui/src/views/account/AccountPage.tsx +++ b/apps/shell-ui/src/views/account/AccountPage.tsx @@ -101,7 +101,6 @@ const AccountPage: React.FC = () => { const [usageByUser, setUsageByUser] = useState([]); const [usageByTeam, setUsageByTeam] = useState([]); const [dashboardLoading, setDashboardLoading] = useState(false); - const [txPage, setTxPage] = useState(1); // ── Refresh signal (bumped by shell:accountUpdate) ───────────────────── const [refreshSignal, setRefreshSignal] = useState(0); @@ -109,11 +108,6 @@ const AccountPage: React.FC = () => { // ── Section load error ────────────────────────────────────────────────── const [sectionError, setSectionError] = useState(null); - // ── Permission flags ──────────────────────────────────────────────────── - - /** Whether the current user is an org admin. */ - const isOrgAdminFlag = authUser?.organization?.permissions?.includes('org.admin') ?? false; - // Keep profile in sync with server-pushed account updates, bump refresh // signal for env, and bump reload counter to re-fetch the active section useEffect(() => ConnectionManager.getInstance().on('shell:accountUpdate', (data) => { @@ -220,7 +214,6 @@ const AccountPage: React.FC = () => { setTransactions(tx); setUsageByUser(byUser); setUsageByTeam(byTeam); - setTxPage(1); } finally { setBillingLoading(false); setDashboardLoading(false); @@ -231,7 +224,7 @@ const AccountPage: React.FC = () => { const handleTransactionPage = useCallback(async (page: number) => { if (!client || !orgId) return; const tx = await client.billing.getTransactions(orgId, { page, pageSize: 20 }).catch(() => null); - if (tx) { setTransactions(tx); setTxPage(page); } + if (tx) { setTransactions(tx); } }, [client, orgId]); /** Purchase a top-up pack by charging the card on file. */ @@ -468,6 +461,16 @@ const AccountPage: React.FC = () => { await client.account.setEnv(scope, env, scopeId); }, [client]); + // ── Memoized lookups ──────────────────────────────────────────────────── + const memberNames = useMemo( + () => Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId])), + [members], + ); + const teamNames = useMemo( + () => Object.fromEntries(teams.map((t: any) => [t.id, t.name || t.id])), + [teams], + ); + // ── Render ────────────────────────────────────────────────────────────── return (
@@ -498,8 +501,8 @@ const AccountPage: React.FC = () => { onUpgradeSubscription={handleUpgradeSubscription} dashboardLoading={dashboardLoading} onTransactionPage={handleTransactionPage} - memberNames={Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId]))} - teamNames={Object.fromEntries(teams.map((t: any) => [t.id, t.name || t.id]))} + memberNames={memberNames} + teamNames={teamNames} section={section} onSectionChange={setSection} activeTeamId={activeTeamId} diff --git a/apps/shell-ui/src/views/settings/SettingsPage.tsx b/apps/shell-ui/src/views/settings/SettingsPage.tsx index d3390a76a..14f8e966b 100644 --- a/apps/shell-ui/src/views/settings/SettingsPage.tsx +++ b/apps/shell-ui/src/views/settings/SettingsPage.tsx @@ -587,7 +587,7 @@ const SettingsPage: React.FC = () => {
{/* Subscribe prompt for unsubscribed users */} - {info && !isSubscribed && ( + {info && pipeBuilderApp && !isSubscribed && (
Subscribe to unlock pipeline execution and deployment. diff --git a/apps/vscode/src/providers/AccountProvider.ts b/apps/vscode/src/providers/AccountProvider.ts index b2dc0c76c..c8fe294f1 100644 --- a/apps/vscode/src/providers/AccountProvider.ts +++ b/apps/vscode/src/providers/AccountProvider.ts @@ -45,6 +45,7 @@ interface AccountWebviewMessage { packId?: string; priceId?: string; newPriceId?: string; + orgId?: string; subscriptionId?: string; } @@ -1014,9 +1015,10 @@ export class AccountProvider { priceId: message.priceId, }); await panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); - } catch { - // Non-fatal -- the webhook will still update the DB - await panel.webview.postMessage({ type: 'checkout:confirmResult', error: null }); + } catch (err: unknown) { + // Non-fatal -- the webhook will still update the DB, but surface the error + const msg = err instanceof Error ? err.message : String(err); + await panel.webview.postMessage({ type: 'checkout:confirmResult', error: msg }); } } diff --git a/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx b/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx index 0ba762c7f..f7d59525c 100644 --- a/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx +++ b/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx @@ -111,6 +111,7 @@ export type SettingsIncomingMessage = | { type: 'settingsLoaded'; settings: SettingsData; + isSubscribed?: boolean; } | { type: 'showMessage'; @@ -453,8 +454,8 @@ export const Settings: React.FC = () => { savedSettingsRef.current = JSON.parse(JSON.stringify(message.settings)); setDirty(false); // Subscription status is included in the settingsLoaded payload - if ((message as any).isSubscribed !== undefined) { - setSubscribed((message as any).isSubscribed); + if (message.isSubscribed !== undefined) { + setSubscribed(message.isSubscribed); } // Pre-fetch versions from GitHub (cached on backend, shared across all modes) setEngineVersionsLoading(true); diff --git a/packages/client-typescript/src/client/types/billing.ts b/packages/client-typescript/src/client/types/billing.ts index 6d707652e..ae9b805da 100644 --- a/packages/client-typescript/src/client/types/billing.ts +++ b/packages/client-typescript/src/client/types/billing.ts @@ -149,6 +149,12 @@ export interface CreditBalance { /** Net balance per resource type (positive = remaining, negative = overspent). */ balances: Record; + /** Total credits granted (purchased/credited) per resource. */ + granted: Record; + + /** Total credits consumed (debited) per resource. */ + consumed: Record; + /** * Human-readable display templates per resource type, from Stripe price metadata. * Supports ``{amount}`` substitution (e.g. ``"{amount} minutes of Audio"``). diff --git a/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx b/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx index 1993172e4..26d07bea0 100644 --- a/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx +++ b/packages/shared-ui/src/modules/billing/components/CreditsPanel.tsx @@ -241,8 +241,8 @@ export const CreditsPanel: React.FC = ({ balance, packs, onBu {Object.entries(balance.balances).map(([resource, net]) => { - const granted = (balance as any).granted?.[resource] ?? 0; - const consumed = (balance as any).consumed?.[resource] ?? 0; + const granted = balance.granted?.[resource] ?? 0; + const consumed = balance.consumed?.[resource] ?? 0; const label = balance.labels?.[resource] ?? resource; const resourceName = label.replace('{amount}', '').trim() || resource; return ( diff --git a/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx b/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx index 1c6841da7..626e49913 100644 --- a/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx +++ b/packages/shared-ui/src/modules/billing/components/TopUpModal.tsx @@ -135,7 +135,7 @@ export const TopUpModal: React.FC = ({ plans, onPurchase, onClo // Filter to top-up plans only const topupPlans = useMemo( - () => plans.filter((p) => p.metadata?.kind === 'topup'), + () => plans.filter((p) => p.metadata?.kind === 'topup' && p.isActive !== false), [plans], ); @@ -162,12 +162,12 @@ export const TopUpModal: React.FC = ({ plans, onPurchase, onClo }; return ( -
+
e.stopPropagation()}> {/* Header */}
Add More Capacity
- +
{success ? ( diff --git a/packages/shared-ui/src/modules/billing/components/UpgradeModal.tsx b/packages/shared-ui/src/modules/billing/components/UpgradeModal.tsx index 9894c0b90..c99e5a93b 100644 --- a/packages/shared-ui/src/modules/billing/components/UpgradeModal.tsx +++ b/packages/shared-ui/src/modules/billing/components/UpgradeModal.tsx @@ -172,20 +172,26 @@ export const UpgradeModal: React.FC = ({ // Filter out top-up packs and action-only plans, keep subscription plans const subscriptionPlans = useMemo( - () => plans.filter((p) => p.metadata?.kind !== 'topup' && !p.metadata?.action), + () => plans.filter((p) => p.metadata?.kind !== 'topup' && !p.metadata?.action && p.isActive !== false), [plans], ); /** Whether the selected plan differs from the current plan. */ const isValidSelection = selectedPlan && selectedPlan.stripePriceId !== currentPriceId; + /** Normalize amount to monthly cost for comparison across intervals. */ + const monthlyAmount = (plan: CheckoutPlan) => { + if (plan.interval === 'year') return plan.amountCents / 12; + return plan.amountCents; + }; + /** Determine if the selected plan is an upgrade or downgrade. */ const changeDirection = useMemo(() => { if (!selectedPlan) return null; const currentPlan = subscriptionPlans.find((p) => p.stripePriceId === currentPriceId); if (!currentPlan) return 'change'; - if (selectedPlan.amountCents > currentPlan.amountCents) return 'upgrade'; - if (selectedPlan.amountCents < currentPlan.amountCents) return 'downgrade'; + if (monthlyAmount(selectedPlan) > monthlyAmount(currentPlan)) return 'upgrade'; + if (monthlyAmount(selectedPlan) < monthlyAmount(currentPlan)) return 'downgrade'; return 'change'; }, [selectedPlan, currentPriceId, subscriptionPlans]); diff --git a/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx b/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx index 7cb019ea5..059a4979e 100644 --- a/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx +++ b/packages/shared-ui/src/modules/checkout/CheckoutModal.tsx @@ -270,7 +270,7 @@ export const CheckoutModal: React.FC = ({ onFetchPlans() .then((fetched) => { // Filter out top-up packs — those are handled by the TopUpModal - const subscriptionPlans = fetched.filter((p) => p.metadata?.kind !== 'topup'); + const subscriptionPlans = fetched.filter((p) => p.metadata?.kind !== 'topup' && p.isActive !== false); setPlans(subscriptionPlans); // Pre-select the first checkout-able plan const first = subscriptionPlans.find((p) => !p.metadata?.action); diff --git a/packages/shared-ui/src/modules/checkout/PlanPicker.tsx b/packages/shared-ui/src/modules/checkout/PlanPicker.tsx index 4dd2307a2..a07873986 100644 --- a/packages/shared-ui/src/modules/checkout/PlanPicker.tsx +++ b/packages/shared-ui/src/modules/checkout/PlanPicker.tsx @@ -35,7 +35,7 @@ function planAction(plan: CheckoutPlan): PlanAction | null { /** Extract the sort order from plan metadata, defaulting to 500. */ function planOrder(plan: CheckoutPlan): number { - try { return parseInt(plan.metadata?.order, 10) || 500; } catch { return 500; } + try { const n = parseInt(plan.metadata?.order, 10); return Number.isFinite(n) ? n : 500; } catch { return 500; } } /** Extract description lines from plan metadata. */ @@ -49,8 +49,9 @@ function planDescription(plan: CheckoutPlan): string[] | null { export function planAmount(plan: CheckoutPlan): string { const display = plan.metadata?.displayAmount; if (display) return display; - const dollars = (plan.amountCents || 0) / 100; - return dollars === Math.floor(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`; + const amount = (plan.amountCents || 0) / 100; + const symbol = (plan as any).currency?.toUpperCase() === 'EUR' ? '\u20AC' : '$'; + return amount === Math.floor(amount) ? `${symbol}${amount}` : `${symbol}${amount.toFixed(2)}`; } /** From 2417fb48de67e1b9275d72597e5216fd83eb690a Mon Sep 17 00:00:00 2001 From: "Rod.Christensen" Date: Sat, 13 Jun 2026 09:50:02 -0700 Subject: [PATCH 12/12] fix(tests): align test suite with single-org model, async billing, and broadcast signature (#billing-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-org refactor (organizations[] → organization) and async billing changes broke several test files. This commit fixes all affected tests and the metrics source: - **AccountInfo stubs**: All test helpers (_account_info, inline SimpleNamespace) updated from `organizations=[{...}]` to `organization={...}` to match the new single-org AccountInfo model. Affects test_cmd_debug, test_cmd_misc, test_cmd_monitor, test_cmd_task. - **_report_to_billing_system is now async**: test_task_metrics calls wrapped with asyncio.run() / await since the method became a coroutine. Consecutive-report test refactored into a single async function to share the event loop across both awaits. - **Last-report state advancement made unconditional**: Moved the _last_report_* accumulator updates in task_metrics.py to run before the ledger write and outside the org_id guard, so internal tracking state stays consistent even in OSS mode (no org_id). This was the root cause of test_report_to_billing_system_advances_last_report_state failing. - **broadcast_server_event org_id kwarg**: test_task_server assertion updated to expect the new org_id=None keyword argument added to the broadcast call. - **nodes/test/conftest.py path resolution**: Replaced hardcoded PROJECT_ROOT with sys.executable-relative path so conftest resolves correctly whether rocketride-server is standalone or a submodule. Co-Authored-By: Claude Opus 4.6 (1M context) --- nodes/test/conftest.py | 13 ++++--- .../ai/src/ai/modules/task/task_metrics.py | 12 ++++++ .../modules/task/commands/test_cmd_debug.py | 10 ++--- .../ai/modules/task/commands/test_cmd_misc.py | 24 +++++------- .../modules/task/commands/test_cmd_monitor.py | 22 +++++------ .../ai/modules/task/commands/test_cmd_task.py | 39 ++++++++----------- .../ai/modules/task/test_task_metrics.py | 23 ++++++----- .../tests/ai/modules/task/test_task_server.py | 2 +- 8 files changed, 73 insertions(+), 72 deletions(-) diff --git a/nodes/test/conftest.py b/nodes/test/conftest.py index a672eb024..ff283bac6 100644 --- a/nodes/test/conftest.py +++ b/nodes/test/conftest.py @@ -29,22 +29,23 @@ """ import os -import sys import asyncio import pytest import pytest_asyncio from pathlib import Path from typing import Dict, Any, List -# Add project paths -PROJECT_ROOT = Path(__file__).parent.parent.parent -sys.path.insert(0, str(PROJECT_ROOT / 'dist' / 'server')) +# Derive paths from the engine executable (dist/server/engine.exe) +# so they resolve correctly whether rocketride-server is standalone or a submodule. +import sys + +_ENGINE_DIR = Path(sys.executable).resolve().parent -# Load environment variables +# Load environment variables from the build output root (next to the engine). try: from dotenv import load_dotenv - load_dotenv(PROJECT_ROOT / '.env') + load_dotenv(_ENGINE_DIR / '.env') except ImportError: pass # dotenv is optional diff --git a/packages/ai/src/ai/modules/task/task_metrics.py b/packages/ai/src/ai/modules/task/task_metrics.py index e6d867ed4..09008dca4 100644 --- a/packages/ai/src/ai/modules/task/task_metrics.py +++ b/packages/ai/src/ai/modules/task/task_metrics.py @@ -482,6 +482,18 @@ async def _report_to_billing_system(self) -> None: except Exception: pass + # ── Advance last-report tracking state ────────────────────────── + # Record current accumulator values so the next report (or any + # external consumer) can compute the delta accrued since this report. + # This runs unconditionally — even in OSS mode (no org_id) — so that + # internal tracking state stays consistent for tests and consumers. + self._last_report_cpu_seconds = self._cpu_seconds + self._last_report_memory_mb_seconds = self._memory_mb_seconds + self._last_report_gpu_memory_mb_seconds = self._gpu_memory_mb_seconds + self._last_report_tokens_cpu = self._status.tokens.cpu_utilization + self._last_report_tokens_memory = self._status.tokens.cpu_memory + self._last_report_tokens_gpu = self._status.tokens.gpu_memory + # ── Write to ledger via account.apply_debit() ──────────────────── # The account singleton dispatches to the SaaS implementation (UPSERT # into credit_ledger) or the OSS no-op depending on the active edition. diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py index 8dc9336c0..076826d86 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_debug.py @@ -45,18 +45,14 @@ def _make_conn(*, account_info=None, server=None, debug_token=None, debug_id=Non return conn -def _account_info(*, organizations=None, default_team='team-1'): +def _account_info(*, organization=None, default_team='team-1'): """Build an AccountInfo stub.""" return SimpleNamespace( userId='user-1', auth='ak_x', userToken='token-user-1', defaultTeam=default_team, - organizations=organizations - if organizations is not None - else [ - {'id': 'org-1', 'teams': [{'id': 'team-1'}]}, - ], + organization=organization if organization is not None else {'id': 'org-1', 'teams': [{'id': 'team-1'}]}, ) @@ -112,7 +108,7 @@ async def test_on_launch_rejects_when_already_debugging(): @pytest.mark.asyncio async def test_on_launch_rejects_when_default_team_not_in_any_org(): """If the default team is not part of any org, PermissionError is raised.""" - account = _account_info(organizations=[{'id': 'org-X', 'teams': [{'id': 'team-other'}]}]) + account = _account_info(organization={'id': 'org-X', 'teams': [{'id': 'team-other'}]}) conn = _make_conn(account_info=account) with pytest.raises(PermissionError, match='does not belong to any organisation'): await DebugCommands.on_launch(conn, {'arguments': {}}) diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py index 772a43dd1..8706cb6b8 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py @@ -285,13 +285,11 @@ async def test_on_rrext_dashboard_filters_to_caller_user_id(monkeypatch): userId='user-1', auth='ak_caller', userToken='ak_caller_secret_token', - organizations=[ - { - 'id': 'org-1', - 'permissions': [], - 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], - } - ], + organization={ + 'id': 'org-1', + 'permissions': [], + 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], + }, ) # Server state: one task in caller's team, one in a team they cannot see. @@ -360,13 +358,11 @@ async def test_on_rrext_dashboard_tk_auth_locks_to_owning_task(monkeypatch): userId='user-1', auth='tk_my-only-task', userToken='tk_token', - organizations=[ - { - 'id': 'org-1', - 'permissions': [], - 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], - } - ], + organization={ + 'id': 'org-1', + 'permissions': [], + 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], + }, ) def _make_control(token): diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_monitor.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_monitor.py index 064b3b3df..4dae7cb1a 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_monitor.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_monitor.py @@ -54,18 +54,16 @@ def _account_info(*, user_id='user-1', team_id='team-1'): return SimpleNamespace( userId=user_id, userToken='token-' + user_id, - organizations=[ - { - 'id': 'org-1', - 'permissions': [], - 'teams': [ - { - 'id': team_id, - 'permissions': ['task.monitor', 'task.data', 'task.control'], - } - ], - } - ], + organization={ + 'id': 'org-1', + 'permissions': [], + 'teams': [ + { + 'id': team_id, + 'permissions': ['task.monitor', 'task.data', 'task.control'], + } + ], + }, ) diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py index 15546ec4f..fd495478b 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_task.py @@ -53,14 +53,14 @@ def _make_conn(*, account_info=None, server=None, connection_id=1): return conn -def _account_info(*, user_id='user-1', auth='ak_x', default_team='team-1', organizations=None): +def _account_info(*, user_id='user-1', auth='ak_x', default_team='team-1', organization=None): """Build an AccountInfo-shaped stub.""" return SimpleNamespace( userId=user_id, auth=auth, userToken='token-' + user_id, defaultTeam=default_team, - organizations=organizations if organizations is not None else [], + organization=organization, ) @@ -72,11 +72,8 @@ def _account_info(*, user_id='user-1', auth='ak_x', default_team='team-1', organ @pytest.mark.asyncio async def test_on_execute_starts_task_with_resolved_org_id(): """on_execute resolves org_id from the user's default team and calls start_task.""" - organizations = [ - {'id': 'org-A', 'teams': [{'id': 'team-other'}]}, - {'id': 'org-B', 'teams': [{'id': 'team-1'}, {'id': 'team-other'}]}, - ] - account = _account_info(user_id='user-1', default_team='team-1', organizations=organizations) + organization = {'id': 'org-B', 'teams': [{'id': 'team-1'}, {'id': 'team-other'}]} + account = _account_info(user_id='user-1', default_team='team-1', organization=organization) server = MagicMock() server.start_task = AsyncMock(return_value={'token': 'tk_new'}) @@ -230,14 +227,12 @@ def _ctrl(token, team_id, status): } # Caller has access to team-1 only; team-other is invisible. - organizations = [ - { - 'id': 'org-1', - 'permissions': [], - 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], - } - ] - conn = _make_conn(account_info=_account_info(user_id='user-1', organizations=organizations), server=server) + organization = { + 'id': 'org-1', + 'permissions': [], + 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], + } + conn = _make_conn(account_info=_account_info(user_id='user-1', organization=organization), server=server) response = await TaskCommands.on_rrext_get_tasks(conn, {}) tokens = [t['token'] for t in response['body']['tasks']] @@ -265,14 +260,12 @@ async def test_on_rrext_get_tasks_falls_back_to_source_name(): server = MagicMock() server._task_control = {'tk_1': control} - organizations = [ - { - 'id': 'org-1', - 'permissions': [], - 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], - } - ] - conn = _make_conn(account_info=_account_info(user_id='user-1', organizations=organizations), server=server) + organization = { + 'id': 'org-1', + 'permissions': [], + 'teams': [{'id': 'team-1', 'permissions': ['task.monitor']}], + } + conn = _make_conn(account_info=_account_info(user_id='user-1', organization=organization), server=server) response = await TaskCommands.on_rrext_get_tasks(conn, {}) assert response['body']['tasks'][0]['name'] == 'my-source' assert response['body']['tasks'][0]['description'] == 'RocketRide DTC MCP Tool' diff --git a/packages/ai/tests/ai/modules/task/test_task_metrics.py b/packages/ai/tests/ai/modules/task/test_task_metrics.py index 5903dcc22..e8ed9ec3b 100644 --- a/packages/ai/tests/ai/modules/task/test_task_metrics.py +++ b/packages/ai/tests/ai/modules/task/test_task_metrics.py @@ -455,7 +455,7 @@ def test_report_to_billing_system_advances_last_report_state(fake_psutil, no_gpu status.tokens.gpu_memory = 5.0 status.tokens.total = 35.0 - tm._report_to_billing_system() + asyncio.run(tm._report_to_billing_system()) # State advanced — the next report's delta starts from these. assert tm._last_report_cpu_seconds == 100.0 @@ -469,15 +469,20 @@ def test_report_to_billing_system_advances_last_report_state(fake_psutil, no_gpu def test_report_to_billing_system_handles_consecutive_reports(fake_psutil, no_gpu): """Second report sees only the delta accrued since the first.""" tm, _ = _make_metrics(fake_psutil) - # First period - tm._cpu_seconds = 100.0 - tm._report_to_billing_system() - assert tm._last_report_cpu_seconds == 100.0 - # Second period — accumulators grew by 50 - tm._cpu_seconds = 150.0 - tm._report_to_billing_system() - assert tm._last_report_cpu_seconds == 150.0 + async def run(): + """Run two consecutive billing reports.""" + # First period + tm._cpu_seconds = 100.0 + await tm._report_to_billing_system() + assert tm._last_report_cpu_seconds == 100.0 + + # Second period — accumulators grew by 50 + tm._cpu_seconds = 150.0 + await tm._report_to_billing_system() + assert tm._last_report_cpu_seconds == 150.0 + + asyncio.run(run()) # --------------------------------------------------------------------------- diff --git a/packages/ai/tests/ai/modules/task/test_task_server.py b/packages/ai/tests/ai/modules/task/test_task_server.py index 7b5126575..473138747 100644 --- a/packages/ai/tests/ai/modules/task/test_task_server.py +++ b/packages/ai/tests/ai/modules/task/test_task_server.py @@ -298,7 +298,7 @@ async def test_broadcast_server_event_calls_each_connection(): await TaskServer.broadcast_server_event(ts, type='etype', event=payload, user_id='user-1') for conn in (a, b, c): - conn.send_server_event.assert_awaited_once_with('etype', event=payload, user_id='user-1') + conn.send_server_event.assert_awaited_once_with('etype', event=payload, user_id='user-1', org_id=None) @pytest.mark.asyncio