diff --git a/src/main/billing/usage-alert-desktop.ts b/src/main/billing/usage-alert-desktop.ts new file mode 100644 index 00000000000..e21e610b9aa --- /dev/null +++ b/src/main/billing/usage-alert-desktop.ts @@ -0,0 +1,43 @@ +// Electron side of the usage alert: the native notification, and its click opening +// Settings › Orca account (Plan & billing) in the main window. +import { app, Notification } from 'electron' +import { retainNotificationUntilRelease } from '../ipc/native-notification-lifecycle' +import { getTrustedUIRendererWindow } from '../ipc/ui' +import { safelyRevealWindow } from '../window/focus-existing-window' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainI18n, translateMain } from '../i18n/main-i18n' +import { usageAlertSentence } from '../../shared/orca-billing-usage-alerts' +import { + ORCA_PLAN_BILLING_SETTINGS_TARGET, + type OrcaBillingEntitlement +} from '../../shared/orca-billing' + +export function usageAlertBody(entitlement: OrcaBillingEntitlement): string | null { + // Same sentence and keys as the in-app notice, so both read (and translate) alike. + return usageAlertSentence(entitlement, translateMain, mainI18n.language || 'en') +} + +export function showUsageAlertNotification({ title, body }: { title: string; body: string }): void { + if (!Notification.isSupported()) { + return + } + const notification = new Notification({ title, body }) + const onClick = (): void => { + release() + const win = getTrustedUIRendererWindow() + if (!win || win.isDestroyed()) { + return + } + if (process.platform === 'darwin' && !isBackgroundLaunch()) { + app.focus({ steal: true }) + } + safelyRevealWindow(win) + win.webContents.send('ui:openSettings', ORCA_PLAN_BILLING_SETTINGS_TARGET) + } + // Keep the notification (and its click handler) alive until it closes. + const release = retainNotificationUntilRelease(notification, () => { + notification.removeListener('click', onClick) + }) + notification.on('click', onClick) + notification.show() +} diff --git a/src/main/billing/usage-alert-notifier.test.ts b/src/main/billing/usage-alert-notifier.test.ts new file mode 100644 index 00000000000..259c47e61ce --- /dev/null +++ b/src/main/billing/usage-alert-notifier.test.ts @@ -0,0 +1,194 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaBillingEntitlement } from '../../shared/orca-billing' +import { + decideUsageAlert, + usageAlertLevel, + usageAlertSentence +} from '../../shared/orca-billing-usage-alerts' +import { + notifyUsageAlertIfDue, + readUsageAlertLedger, + USAGE_ALERT_TITLE, + type UsageAlertDeps +} from './usage-alert-notifier' + +const HOUR = 3600 +const NOV = Date.UTC(2026, 10, 3) +const DEC = Date.UTC(2026, 11, 3) +const JAN = Date.UTC(2027, 0, 3) + +const team = (patch: Partial = {}): OrcaBillingEntitlement => ({ + plan: 'team', + planLabel: 'Team', + status: 'active', + interval: 'month', + seats: 2, + seatChange: null, + period: { start: NOV, end: DEC }, + usageWindow: { start: NOV, end: DEC }, + cancelAtPeriodEnd: false, + includedSeconds: 120 * HOUR, + usedSeconds: 41.5 * HOUR, + remainingIncludedSeconds: 78.5 * HOUR, + usageWarningPercent: null, + overageSeconds: 0, + overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 100 }, + payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, + canStartSandbox: true, + reason: null, + canManageBilling: true, + offers: null, + ...patch +}) + +/** `used` hours of 120, with billing's level. */ +const at = ( + level: number | null, + used: number, + patch: Partial = {} +): OrcaBillingEntitlement => + team({ + usedSeconds: used * HOUR, + remainingIncludedSeconds: Math.max(0, 120 - used) * HOUR, + usageWarningPercent: level, + ...patch + }) +const nextMonth = { period: { start: DEC, end: JAN }, usageWindow: { start: DEC, end: JAN } } + +describe('decideUsageAlert', () => { + it('does nothing below 75%', () => { + expect(decideUsageAlert({}, 'u:o', at(null, 80)).notify).toBeNull() + expect(usageAlertLevel(at(null, 89))).toBeNull() + }) + + it('never alerts members', () => { + const decision = decideUsageAlert({}, 'u:o', at(90, 110, { canManageBilling: false })) + expect(decision.notify).toBeNull() + expect(decision.ledger).toEqual({}) + }) + + it('alerts once per level with the floored percent used', () => { + const first = decideUsageAlert({}, 'u:o', at(75, 92)) + expect(first.notify).toEqual({ level: 75, percent: 76 }) + expect(decideUsageAlert(first.ledger, 'u:o', at(75, 95)).notify).toBeNull() + const second = decideUsageAlert(first.ledger, 'u:o', at(90, 109)) + expect(second.notify).toEqual({ level: 90, percent: 90 }) + const third = decideUsageAlert(second.ledger, 'u:o', at(100, 120)) + expect(third.notify).toEqual({ level: 100, percent: 100 }) + expect(decideUsageAlert(third.ledger, 'u:o', at(100, 130)).notify).toBeNull() + }) + + it('sends one alert for the highest level when several are crossed at once', () => { + const jump = decideUsageAlert({}, 'u:o', at(90, 110)) + expect(jump.notify?.level).toBe(90) + expect(jump.ledger['u:o'].levels).toEqual([75, 90]) + expect(decideUsageAlert(jump.ledger, 'u:o', at(75, 110)).notify).toBeNull() + }) + + it('keeps accounts and teams apart', () => { + const one = decideUsageAlert({}, 'u:org-a', at(75, 92)) + expect(decideUsageAlert(one.ledger, 'u:org-b', at(75, 92)).notify?.level).toBe(75) + expect(decideUsageAlert(one.ledger, 'other:org-a', at(75, 92)).notify?.level).toBe(75) + }) + + it('starts over in a new billing period', () => { + const done = decideUsageAlert({}, 'u:o', at(90, 110)).ledger + const fresh = decideUsageAlert(done, 'u:o', at(75, 92, nextMonth)) + expect(fresh.notify?.level).toBe(75) + expect(fresh.ledger['u:o']).toEqual({ periodStart: DEC, levels: [75] }) + }) + + it('needs a period to dedupe against', () => { + expect( + decideUsageAlert({}, 'u:o', at(75, 92, { period: null, usageWindow: null })).notify + ).toBeNull() + }) +}) + +const interpolate = (_key: string, fallback: string, options?: Record): string => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => options?.[name] ?? '') + +describe('notifyUsageAlertIfDue', () => { + let dir: string + let show: ReturnType> + let enabled: boolean + const deps = (): UsageAlertDeps => ({ + userDataPath: dir, + notificationsEnabled: () => enabled, + body: (entitlement) => usageAlertSentence(entitlement, interpolate, 'en'), + show + }) + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'usage-alerts-')) + show = vi.fn() + enabled = true + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('shows the alert and records it on disk, so a reload or second window does not repeat it', async () => { + expect(await notifyUsageAlertIfDue('u:o', at(75, 92), deps())).toBe(75) + expect(show).toHaveBeenCalledWith({ + title: USAGE_ALERT_TITLE, + body: 'Your team has used 76% of this month’s sandbox hours.' + }) + // Every later read (another window, a reload, a restart) reads the same ledger file. + expect(await notifyUsageAlertIfDue('u:o', at(75, 93), deps())).toBeNull() + expect(await notifyUsageAlertIfDue('u:o', at(75, 93), deps())).toBeNull() + expect(show).toHaveBeenCalledTimes(1) + expect(await readUsageAlertLedger(dir)).toEqual({ 'u:o': { periodStart: NOV, levels: [75] } }) + }) + + it('does not notify members or below 75%', async () => { + expect( + await notifyUsageAlertIfDue('u:o', at(90, 110, { canManageBilling: false }), deps()) + ).toBeNull() + expect(await notifyUsageAlertIfDue('u:o', at(null, 60), deps())).toBeNull() + expect(show).not.toHaveBeenCalled() + }) + + it('notifies once when two windows read at the same moment', async () => { + const [a, b] = await Promise.all([ + notifyUsageAlertIfDue('u:o', at(75, 92), deps()), + notifyUsageAlertIfDue('u:o', at(75, 92), deps()) + ]) + expect([a, b].filter((level) => level !== null)).toEqual([75]) + expect(show).toHaveBeenCalledTimes(1) + }) + + it('says what happens next at 100%, by overage', async () => { + expect(await notifyUsageAlertIfDue('u:o', at(100, 120), deps())).toBe(100) + expect(show).toHaveBeenLastCalledWith({ + title: USAGE_ALERT_TITLE, + body: 'Your team has used all of this month’s sandbox hours. Running sandboxes go to sleep and new ones can’t start. Turn on overage to keep going.' + }) + const on = { overage: { ...team().overage, optedIn: true, spendCapCents: 5000 } } + expect(await notifyUsageAlertIfDue('u:b', at(100, 125, on), deps())).toBe(100) + expect(show).toHaveBeenLastCalledWith({ + title: USAGE_ALERT_TITLE, + body: 'Your team has used all of this month’s sandbox hours. Overage is on, up to your $50 spend cap.' + }) + }) + + it('counts a level as handled while notifications are off', async () => { + enabled = false + expect(await notifyUsageAlertIfDue('u:o', at(75, 92), deps())).toBeNull() + enabled = true + expect(await notifyUsageAlertIfDue('u:o', at(75, 92), deps())).toBeNull() + expect(await notifyUsageAlertIfDue('u:o', at(90, 110), deps())).toBe(90) + expect(show).toHaveBeenCalledTimes(1) + }) + + it('treats a corrupt ledger as empty', async () => { + writeFileSync(join(dir, 'orca-billing-usage-alerts.json'), '{not json') + expect(await notifyUsageAlertIfDue('u:o', at(75, 92), deps())).toBe(75) + expect(JSON.parse(readFileSync(join(dir, 'orca-billing-usage-alerts.json'), 'utf8'))).toEqual({ + 'u:o': { periodStart: NOV, levels: [75] } + }) + }) +}) diff --git a/src/main/billing/usage-alert-notifier.ts b/src/main/billing/usage-alert-notifier.ts new file mode 100644 index 00000000000..2f7123a9521 --- /dev/null +++ b/src/main/billing/usage-alert-notifier.ts @@ -0,0 +1,103 @@ +// Desktop notification when the team crosses 75%, 90% or 100% of its included sandbox hours. +// Main owns the decision so every window shares one ledger: a reload, a re-render or a second +// window reading billing never repeats an alert. Levels reset when the billing period rolls over. +import { readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import type { OrcaBillingEntitlement } from '../../shared/orca-billing' +import { decideUsageAlert, type UsageAlertLedger } from '../../shared/orca-billing-usage-alerts' + +const LEDGER_FILE = 'orca-billing-usage-alerts.json' +/** Accounts kept in the ledger; the oldest drop first (a desktop rarely sees more than a few). */ +const MAX_ACCOUNTS = 50 + +export const USAGE_ALERT_TITLE = 'Orca Cloud' + +const LedgerSchema = z.record( + z.string(), + z.object({ periodStart: z.number(), levels: z.array(z.number()) }) +) + +export type UsageAlertDeps = { + userDataPath: string + /** The user's global desktop-notification switch. */ + notificationsEnabled: () => boolean + /** The alert sentence for this entitlement (the same one the in-app notice shows). */ + body: (entitlement: OrcaBillingEntitlement) => string | null + show: (input: { title: string; body: string }) => void +} + +function ledgerPath(userDataPath: string): string { + return join(userDataPath, LEDGER_FILE) +} + +export async function readUsageAlertLedger(userDataPath: string): Promise { + try { + const parsed = LedgerSchema.safeParse( + JSON.parse(await readFile(ledgerPath(userDataPath), 'utf8')) + ) + return parsed.success ? parsed.data : {} + } catch { + return {} + } +} + +async function writeUsageAlertLedger( + userDataPath: string, + ledger: UsageAlertLedger +): Promise { + const entries = Object.entries(ledger) + const kept = Object.fromEntries(entries.slice(Math.max(0, entries.length - MAX_ACCOUNTS))) + const path = ledgerPath(userDataPath) + const tmp = `${path}.${process.pid}.tmp` + await writeFile(tmp, JSON.stringify(kept), { mode: 0o600 }) + await rename(tmp, path) +} + +/** One read-decide-write at a time, so two reads answering back to back can't both notify. */ +let queue: Promise = Promise.resolve() + +/** + * Records and shows the alert for a fresh billing read, if it crosses a new level. + * Resolves to the level notified, or null. + */ +export function notifyUsageAlertIfDue( + accountKey: string, + entitlement: OrcaBillingEntitlement, + deps: UsageAlertDeps +): Promise { + const run = queue.then(() => decideAndNotify(accountKey, entitlement, deps)) + queue = run.catch(() => null) + return run +} + +async function decideAndNotify( + accountKey: string, + entitlement: OrcaBillingEntitlement, + deps: UsageAlertDeps +): Promise { + const ledger = await readUsageAlertLedger(deps.userDataPath) + const decision = decideUsageAlert(ledger, accountKey, entitlement) + if (!decision.notify) { + return null + } + try { + // Move the account to the end so pruning drops the least recently alerted ones. + const { [accountKey]: entry, ...rest } = decision.ledger + await writeUsageAlertLedger(deps.userDataPath, { ...rest, [accountKey]: entry }) + } catch { + // Without a durable record the alert could repeat on every read; skip it this time. + return null + } + // Notifications switched off: the level still counts as handled, so turning them on later + // doesn't replay a stale alert. The in-app notice still shows it. + if (!deps.notificationsEnabled()) { + return null + } + const body = deps.body(entitlement) + if (!body) { + return null + } + deps.show({ title: USAGE_ALERT_TITLE, body }) + return decision.notify.level +} diff --git a/src/main/ipc/profiles/orca-profile-billing-handlers.ts b/src/main/ipc/profiles/orca-profile-billing-handlers.ts index 8048981f330..cce8468ceb6 100644 --- a/src/main/ipc/profiles/orca-profile-billing-handlers.ts +++ b/src/main/ipc/profiles/orca-profile-billing-handlers.ts @@ -22,6 +22,10 @@ import { setOrcaBillingOverage } from '../../billing/billing-service' import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' +import { ensureActiveOrcaProfile } from '../../orca-profiles/profile-index-store' +import { notifyUsageAlertIfDue } from '../../billing/usage-alert-notifier' +import { showUsageAlertNotification, usageAlertBody } from '../../billing/usage-alert-desktop' +import type { Store } from '../../persistence/loading-store/store' const CheckoutArgs = z .object({ @@ -77,10 +81,29 @@ export function billingOverageArgsFromUnknown(args: unknown): OrcaBillingOverage return parsed.data } -export function registerOrcaProfileBillingHandlers(): void { - ipcMain.handle('orcaProfiles:billingStatus', async (): Promise => - getOrcaBillingStatus(getProfileUserDataPath()) - ) +/** `${userId}:${activeOrgId}` for the active profile's account (as the renderer keys it), or null. */ +function billingAccountKey(userDataPath: string): string | null { + const cloud = ensureActiveOrcaProfile(userDataPath).profile.cloud + return cloud ? `${cloud.userId}:${cloud.activeOrgId ?? ''}` : null +} + +export function registerOrcaProfileBillingHandlers(store: Store): void { + ipcMain.handle('orcaProfiles:billingStatus', async (): Promise => { + const userDataPath = getProfileUserDataPath() + const account = billingAccountKey(userDataPath) + const result = await getOrcaBillingStatus(userDataPath) + // An account or team switch mid-read means this answer isn't for the account now active. + if (result.status === 'ok' && account && account === billingAccountKey(userDataPath)) { + // Fire and forget: an alert must never delay or fail the billing read. + void notifyUsageAlertIfDue(account, result.entitlement, { + userDataPath, + notificationsEnabled: () => store.getSettings().notifications?.enabled !== false, + body: usageAlertBody, + show: showUsageAlertNotification + }).catch(() => {}) + } + return result + }) ipcMain.handle( 'orcaProfiles:billingOpenCheckout', async (_event, rawArgs: unknown): Promise => diff --git a/src/main/ipc/profiles/orca-profiles.ts b/src/main/ipc/profiles/orca-profiles.ts index d9bec353b8c..55e562d284d 100644 --- a/src/main/ipc/profiles/orca-profiles.ts +++ b/src/main/ipc/profiles/orca-profiles.ts @@ -325,5 +325,5 @@ export function registerOrcaProfileHandlers( ) registerOrcaProfileOrgMemberHandlers() - registerOrcaProfileBillingHandlers() + registerOrcaProfileBillingHandlers(store) } diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index d565a3d81eb..a2937e43036 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -1,6 +1,10 @@ import type { MarkdownDocument } from '../../shared/file-listing/file-document' import type { BrowserTabCreateRequest } from '../../shared/browser-tab-create-request' import { ipcRenderer } from 'electron' +import { + ORCA_PLAN_BILLING_SETTINGS_TARGET, + type OrcaOpenSettingsTarget +} from '../../shared/orca-billing' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' import type { PreloadApi } from '../api-types' @@ -23,8 +27,16 @@ export const uiStateAndMenuCommandsApi = { ipcRenderer.on('ui:stateChanged', listener) return () => ipcRenderer.removeListener('ui:stateChanged', listener) }, - onOpenSettings: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() + onOpenSettings: (callback: (target?: OrcaOpenSettingsTarget) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, target?: unknown) => + callback( + typeof target === 'object' && + target !== null && + 'pane' in target && + target.pane === ORCA_PLAN_BILLING_SETTINGS_TARGET.pane + ? ORCA_PLAN_BILLING_SETTINGS_TARGET + : undefined + ) ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index f9895cec986..81412d30011 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -88,7 +88,7 @@ export type UiCommandEventApi = { setWithAck?: (args: Partial) => Promise recordFeatureInteraction: (id: FeatureInteractionId) => Promise onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void - onOpenSettings: (callback: () => void) => () => void + onOpenSettings: (callback: (target?: { pane: 'orca-account' }) => void) => () => void consumePendingOpenSettings: () => Promise /** Main confirmed Quit Completely; consume the request before acting. */ onQuitAndStopAllSessions: (callback: () => void) => () => void diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx index e453da5969b..bfb259da91a 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -193,6 +193,68 @@ export const usageWarning90 = defineFixture({ ) }) +/** 100% with overage off: the alert says sandboxes sleep and points at overage (no repeat below). */ +export const usageAlert100OverageOff = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +/** 100% with overage on: the alert names the spend cap the team keeps going up to. */ +export const usageAlert100OverageOn = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +/** A member at billing's 90% level: no admin usage notice (members aren't alerted). */ +export const usageWarningMember = defineFixture({ + width: 600, + render: () => ( + + ) +}) + /** An admin types a cap above billing's maximum: the field says what the maximum is. */ export const capTooHigh = defineFixture({ width: 600, diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index d76cba535d8..9fb00b46582 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -20,6 +20,7 @@ import { denyReasonCopy, openFailureCopy, overageFailureCopy } from './plan-bill import { PlanStatusBadge } from './PlanStatusBadge' import { useOrcaBillingStatus } from './use-orca-billing-status' import { useAppStore } from '@/store/index' +import { usageAlertLevel } from '../../../../shared/orca-billing-usage-alerts' import { BILLING_CAPABILITY } from './register-plan-billing-menu-item' export function PlanBillingSection(): React.JSX.Element | null { @@ -212,11 +213,17 @@ export function PlanBillingView({ ) : null} - {/* The overage card carries the cap-reached hint for admins; don't repeat it here. */} + {/* Admins already read these above: the overage card's cap-reached hint, and the usage + alert's out-of-hours sentence. */} {hasPlan && entitlement.reason && entitlement.payment.pastDueSince === null && - !(entitlement.reason === 'overage_cap_reached' && entitlement.canManageBilling) ? ( + !( + entitlement.canManageBilling && + (entitlement.reason === 'overage_cap_reached' || + (entitlement.reason === 'included_hours_exhausted' && + usageAlertLevel(entitlement) === 100)) + ) ? (

{denyReasonCopy(entitlement.reason, t, entitlement.canManageBilling)}

diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index 1a1ca165028..2bc773e0042 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -63,7 +63,7 @@ export function UsageSummary({ const date = (ms: number): string => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(ms) const overLine = sandboxHoursOverLine(entitlement, t) - const warning = usageWarningLine(entitlement, t) + const warning = usageWarningLine(entitlement, t, locale) return (
@@ -208,6 +208,12 @@ export function OverageControls({

{optedIn ? descriptionOn : description}

+

+ {t( + 'auto.components.billing.usageAlertsNote', + 'Owners and admins are alerted in Orca at 75%, 90% and 100% of your team’s sandbox hours.' + )} +

{ }) it('warns at the level billing reports until the hours run out', () => { - expect(usageWarningLine(team({ usageWarningPercent: null }), t)).toBeNull() - expect(usageWarningLine(team({ usageWarningPercent: 75 }), t)).toBe( + expect(usageWarningLine(team({ usageWarningPercent: null }), t, 'en')).toBeNull() + expect(usageWarningLine(team({ usageWarningPercent: 75 }), t, 'en')).toBe( 'Your team has used 75% of this month’s sandbox hours.' ) - expect(usageWarningLine(team({ usageWarningPercent: 90 }), t)).toBe( + expect(usageWarningLine(team({ usageWarningPercent: 90 }), t, 'en')).toBe( 'Your team has used 90% of this month’s sandbox hours.' ) - expect(usageWarningLine(team({ usageWarningPercent: 80 }), t)).toBe( + expect(usageWarningLine(team({ usageWarningPercent: 80 }), t, 'en')).toBe( 'Your team has used 80% of this month’s sandbox hours.' ) - expect(usageWarningLine(team({ usageWarningPercent: 100 }), t)).toBeNull() + }) + + it('says what happens next at 100%, by overage', () => { + const out = { usedSeconds: 120 * HOUR, remainingIncludedSeconds: 0, usageWarningPercent: 100 } + expect(usageWarningLine(team(out), t, 'en')).toBe( + 'Your team has used all of this month’s sandbox hours. Running sandboxes go to sleep and new ones can’t start. Turn on overage to keep going.' + ) + expect( + usageWarningLine( + team({ ...out, overage: { ...sampleTeam.overage, optedIn: true, spendCapCents: 2500 } }), + t, + 'en' + ) + ).toBe( + 'Your team has used all of this month’s sandbox hours. Overage is on, up to your $25 spend cap.' + ) }) it('states the actual share used, floored, not the level that triggered it', () => { - expect(usageWarningLine(team({ usedSeconds: 119 * HOUR, usageWarningPercent: 90 }), t)).toBe( - 'Your team has used 99% of this month’s sandbox hours.' - ) - expect(usageWarningLine(team({ usedSeconds: 92 * HOUR, usageWarningPercent: 75 }), t)).toBe( - 'Your team has used 76% of this month’s sandbox hours.' - ) + expect( + usageWarningLine(team({ usedSeconds: 119 * HOUR, usageWarningPercent: 90 }), t, 'en') + ).toBe('Your team has used 99% of this month’s sandbox hours.') + expect( + usageWarningLine(team({ usedSeconds: 92 * HOUR, usageWarningPercent: 75 }), t, 'en') + ).toBe('Your team has used 76% of this month’s sandbox hours.') // Never "100%" while billing still reports hours left. - expect(usageWarningLine(team({ usedSeconds: 119.9 * HOUR, usageWarningPercent: 90 }), t)).toBe( - 'Your team has used 99% of this month’s sandbox hours.' - ) + expect( + usageWarningLine(team({ usedSeconds: 119.9 * HOUR, usageWarningPercent: 90 }), t, 'en') + ).toBe('Your team has used 99% of this month’s sandbox hours.') + }) + + it('shows the warning to owners and admins only: members are not alerted', () => { + const member = team({ + usedSeconds: 110 * HOUR, + usageWarningPercent: 90, + canManageBilling: false + }) + expect(usageWarningLine(member, t, 'en')).toBeNull() + expect(accountMenuUsageLines({ status: 'ok', entitlement: member }, t, 'en')).toEqual({ + hoursLeft: '78.5 sandbox hours left', + warning: null + }) }) it('turns the hours bar to the warning tone at 90%', () => { @@ -87,7 +115,8 @@ describe('sandbox hours copy', () => { status: 'ok', entitlement: team({ remainingIncludedSeconds: 20 * HOUR, usageWarningPercent: 90 }) }, - t + t, + 'en' ) ).toEqual({ hoursLeft: '20 sandbox hours left', @@ -96,9 +125,10 @@ describe('sandbox hours copy', () => { expect( accountMenuUsageLines( { status: 'ok', entitlement: team({ plan: null, planLabel: null, status: 'none' }) }, - t + t, + 'en' ) ).toBeNull() - expect(accountMenuUsageLines({ status: 'failed', error: 'offline' }, t)).toBeNull() + expect(accountMenuUsageLines({ status: 'failed', error: 'offline' }, t, 'en')).toBeNull() }) }) diff --git a/src/renderer/src/components/billing/plan-billing-usage-copy.ts b/src/renderer/src/components/billing/plan-billing-usage-copy.ts index 4b2852f860c..c19c5b7ee7f 100644 --- a/src/renderer/src/components/billing/plan-billing-usage-copy.ts +++ b/src/renderer/src/components/billing/plan-billing-usage-copy.ts @@ -5,6 +5,7 @@ import type { OrcaBillingEntitlement, OrcaBillingStatusResult } from '../../../../shared/orca-billing' +import { usageAlertSentence } from '../../../../shared/orca-billing-usage-alerts' import { includedUsagePercent } from './plan-billing-format' import { isActivePlan } from './team/team-seat-math' @@ -56,45 +57,28 @@ export function sandboxHoursOverLine( } /** - * The share of included hours actually used, floored, for the warning sentence: never below the - * level billing reported (its count may be fresher) and never 100 while hours remain. + * The usage alert for owners and admins (75%, 90%: the share used; 100%: what happens next). The + * same sentence goes out as the desktop notification. Members aren't alerted, so they don't see it. */ -export function usedPercentForWarning(entitlement: OrcaBillingEntitlement): number { - const level = entitlement.usageWarningPercent ?? 0 - const used = - entitlement.includedSeconds > 0 - ? Math.floor((entitlement.usedSeconds / entitlement.includedSeconds) * 100) - : level - return Math.min(99, Math.max(level, used)) -} - -/** - * Shown once billing reports a warning level (75%, 90%) until the hours run out; the sentence states - * the actual share used, not the level. At 100% the bar, the hours-left line and the out-of-hours - * states say it instead. - */ -export function usageWarningLine(entitlement: OrcaBillingEntitlement, t: Translate): string | null { - const level = entitlement.usageWarningPercent - if (level === null || level >= 100) { - return null - } - return t( - 'auto.components.billing.teamUsedPercent', - 'Your team has used {{percent}}% of this month’s sandbox hours.', - { percent: String(usedPercentForWarning(entitlement)) } - ) +export function usageWarningLine( + entitlement: OrcaBillingEntitlement, + t: Translate, + locale: string +): string | null { + return usageAlertSentence(entitlement, t, locale) } /** The account menu's plan lines: hours left, and the early warning once the team reaches it. */ export function accountMenuUsageLines( result: OrcaBillingStatusResult | null, - t: Translate + t: Translate, + locale: string ): { hoursLeft: string; warning: string | null } | null { if (result?.status !== 'ok' || !isActivePlan(result.entitlement)) { return null } return { hoursLeft: sandboxHoursLeftLine(result.entitlement, t), - warning: usageWarningLine(result.entitlement, t) + warning: usageWarningLine(result.entitlement, t, locale) } } diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx index 65c1e3fb12d..13b5b709022 100644 --- a/src/renderer/src/components/billing/plans/PlansView.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -102,7 +102,7 @@ function CurrentPlanStrip({ const resets = entitlement.usageWindow ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(entitlement.usageWindow.end) : null - const warning = usageWarningLine(entitlement, t) + const warning = usageWarningLine(entitlement, t, locale) return (
diff --git a/src/renderer/src/components/billing/plans/plans-pricing.ts b/src/renderer/src/components/billing/plans/plans-pricing.ts index 1d83adea42b..e49b88b0371 100644 --- a/src/renderer/src/components/billing/plans/plans-pricing.ts +++ b/src/renderer/src/components/billing/plans/plans-pricing.ts @@ -35,16 +35,7 @@ export function annualSavingsPercent(team: TeamOffer): number | null { return percent > 0 ? percent : null } -/** "$50", "$40", "$0.75", "$1,200" in the given locale. */ -export function formatCents(cents: number, locale: string): string { - const whole = cents % 100 === 0 - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: 'USD', - minimumFractionDigits: whole ? 0 : 2, - maximumFractionDigits: whole ? 0 : 2 - }).format(cents / 100) -} +export { formatCents } from '../../../../../shared/orca-billing-usage-alerts' /** A per-hour rate always shows cents ("$1.00", "$0.75"), unlike whole-dollar prices. */ export function formatRateCents(cents: number, locale: string): string { diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx index 0e661f7425a..53c571ec0c1 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx @@ -132,6 +132,45 @@ export const signedInMenuUsageWarning = footer({ interact: openAccountMenu }) +/** The team at billing's 90% level: the warning states the actual share used (owner or admin). */ +export const signedInMenuUsageWarning90 = footer({ + height: 360, + authStatus: signedInWithBilling, + setup: withBilling(true, { + usedSeconds: 109 * 3600, + remainingIncludedSeconds: 11 * 3600, + usageWarningPercent: 90 + }), + interact: openAccountMenu +}) + +/** Out of hours with overage off (owner or admin): the 100% alert in the header. */ +export const signedInMenuUsageAlert100 = footer({ + height: 400, + authStatus: signedInWithBilling, + setup: withBilling(true, { + usedSeconds: 120 * 3600, + remainingIncludedSeconds: 0, + usageWarningPercent: 100, + canStartSandbox: false, + reason: 'included_hours_exhausted' + }), + interact: openAccountMenu +}) + +/** A member at the same 90% level: hours left only, no admin usage notice. */ +export const signedInMenuUsageWarningMember = footer({ + height: 360, + authStatus: signedInWithBilling, + setup: withBilling(true, { + usedSeconds: 109 * 3600, + remainingIncludedSeconds: 11 * 3600, + usageWarningPercent: 90, + canManageBilling: false + }), + interact: openAccountMenu +}) + const openTeamSwitcher = async (frame: HTMLElement): Promise => { await openAccountMenu(frame) const trigger = document.querySelector('[data-account-menu-team-switcher]') diff --git a/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx index 4c15bd4dce6..b7e1597a75f 100644 --- a/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx +++ b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/dropdown-menu' import { useMountedRef } from '@/hooks/useMountedRef' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' -import { useTranslate } from '@/i18n/use-translate' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import { useBillingState } from '../../billing/billing-summary-store' import { accountMenuUsageLines } from '../../billing/plan-billing-usage-copy' @@ -53,6 +53,7 @@ function AccountAvatar({ export function SidebarAccountMenu(): React.JSX.Element { const t = useTranslate() + const locale = useIntlLocale() const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) const signingIn = useAppStore((s) => Boolean(s.orcaProfileSignInProgress)) const connectCurrentOrcaProfile = useAppStore((s) => s.connectCurrentOrcaProfile) @@ -70,7 +71,7 @@ export function SidebarAccountMenu(): React.JSX.Element { const identity = getAccountMenuIdentity(authStatus) // The team is named once, on the switcher below; the header carries the plan's hours instead. - const usage = accountMenuUsageLines(useBillingState().result, t) + const usage = accountMenuUsageLines(useBillingState().result, t, locale) const visibleItems = selectVisibleAccountMenuItems(registeredItems, authStatus) // Freeze the item list while the menu is open: a billing answer arriving mid-read must not // swap rows under the pointer (e.g. Upgrade replacing Plan & billing). diff --git a/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts index c5a3eddc0fc..d622a1ce997 100644 --- a/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts @@ -20,8 +20,13 @@ function getShortcutPlatform(): NodeJS.Platform { export function registerSettingsAndSidebarIpcBridge(unsubs: (() => void)[]): void { unsubs.push( - window.api.ui.onOpenSettings(() => { - useAppStore.getState().openSettingsPage() + window.api.ui.onOpenSettings((target) => { + const store = useAppStore.getState() + if (target) { + // A usage alert's click: Settings › Orca account, where Plan & billing lives. + store.openSettingsTarget({ pane: target.pane, repoId: null }) + } + store.openSettingsPage() }) ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 4288c5adab7..022b4961979 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -244,7 +244,10 @@ "sendInvites_other": "Send {{count}} invites", "skipInvalid_one": "Skipping {{count}} invalid entry.", "skipInvalid_other": "Skipping {{count}} invalid entries." - } + }, + "teamUsedAllOverageOff": "Your team has used all of this month’s sandbox hours. Running sandboxes go to sleep and new ones can’t start. Turn on overage to keep going.", + "teamUsedAllOverageOn": "Your team has used all of this month’s sandbox hours. Overage is on, up to your {{cap}} spend cap.", + "teamUsedPercent": "Your team has used {{percent}}% of this month’s sandbox hours." }, "browser-pane": { "markup": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 831d878fddd..c480d52a29a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20734,7 +20734,10 @@ "sandboxHoursLeftCount_other": "{{count}} sandbox hours left", "sandboxHoursOverIncluded_one": "{{count}} sandbox hour over the included amount this month", "sandboxHoursOverIncluded_other": "{{count}} sandbox hours over the included amount this month", - "teamUsedPercent": "Your team has used {{percent}}% of this month’s sandbox hours." + "teamUsedAllOverageOff": "Your team has used all of this month’s sandbox hours. Running sandboxes go to sleep and new ones can’t start. Turn on overage to keep going.", + "teamUsedAllOverageOn": "Your team has used all of this month’s sandbox hours. Overage is on, up to your {{cap}} spend cap.", + "teamUsedPercent": "Your team has used {{percent}}% of this month’s sandbox hours.", + "usageAlertsNote": "Owners and admins are alerted in Orca at 75%, 90% and 100% of your team’s sandbox hours." }, "ports": { "RuntimePortAutoForwarder": { diff --git a/src/shared/orca-billing-usage-alerts.ts b/src/shared/orca-billing-usage-alerts.ts new file mode 100644 index 00000000000..c29540d08f3 --- /dev/null +++ b/src/shared/orca-billing-usage-alerts.ts @@ -0,0 +1,138 @@ +// Usage alerts for owners and admins: the in-app notice and the desktop notification both come +// from billing's usageWarningPercent, so they always agree on the level and the percent. +import type { OrcaBillingEntitlement } from './orca-billing' + +/** The included-hours levels owners and admins are alerted at. */ +export const ORCA_USAGE_ALERT_LEVELS = [75, 90, 100] as const + +/** + * The share of included hours actually used, floored, for the warning sentence: never below the + * level billing reported (its count may be fresher) and never 100 while hours remain. + */ +export function usageWarningUsedPercent(entitlement: OrcaBillingEntitlement): number { + const level = entitlement.usageWarningPercent ?? 0 + const used = + entitlement.includedSeconds > 0 + ? Math.floor((entitlement.usedSeconds / entitlement.includedSeconds) * 100) + : level + return Math.min(99, Math.max(level, used)) +} + +/** The highest alert level the team has reached, or null below 75% (or with no warning). */ +export function usageAlertLevel(entitlement: OrcaBillingEntitlement): number | null { + if (entitlement.usageWarningPercent === null) { + return null + } + const used = Math.max( + entitlement.usageWarningPercent, + entitlement.includedSeconds > 0 + ? Math.floor((entitlement.usedSeconds / entitlement.includedSeconds) * 100) + : 0 + ) + let reached: number | null = null + for (const level of ORCA_USAGE_ALERT_LEVELS) { + if (used >= level) { + reached = level + } + } + return reached +} + +/** The start of the window the hours count against: levels reset when it rolls over. */ +export function usageAlertPeriodStart(entitlement: OrcaBillingEntitlement): number | null { + return entitlement.usageWindow?.start ?? entitlement.period?.start ?? null +} + +/** Levels already notified, per account (`${userId}:${orgId}`) and billing period. */ +export type UsageAlertLedger = Record + +export type UsageAlertDecision = { + /** The notification to show, or null. */ + notify: { level: number; percent: number } | null + /** The ledger to persist (the same object when nothing changed). */ + ledger: UsageAlertLedger +} + +/** + * Decides whether a billing read crosses a new alert level for this account and period. Only + * owners and admins are alerted, once per level per period; crossing several levels at once sends + * one notification for the highest and marks the lower ones done. A new period starts empty. + */ +export function decideUsageAlert( + ledger: UsageAlertLedger, + accountKey: string, + entitlement: OrcaBillingEntitlement +): UsageAlertDecision { + const unchanged: UsageAlertDecision = { notify: null, ledger } + if (!entitlement.canManageBilling) { + return unchanged + } + const periodStart = usageAlertPeriodStart(entitlement) + const level = usageAlertLevel(entitlement) + if (periodStart === null || level === null) { + return unchanged + } + const entry = ledger[accountKey] + const notified = entry && entry.periodStart === periodStart ? entry.levels : [] + if (notified.includes(level)) { + return unchanged + } + const levels = ORCA_USAGE_ALERT_LEVELS.filter((l) => l <= level || notified.includes(l)) + return { + notify: { level, percent: level >= 100 ? 100 : usageWarningUsedPercent(entitlement) }, + ledger: { ...ledger, [accountKey]: { periodStart, levels: [...levels] } } + } +} + +/** "$50", "$25", "$0.75" in the given locale (shared by the plans page and the usage alert). */ +export function formatCents(cents: number, locale: string): string { + const whole = cents % 100 === 0 + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: whole ? 0 : 2, + maximumFractionDigits: whole ? 0 : 2 + }).format(cents / 100) +} + +type UsageAlertTranslate = ( + key: string, + fallback: string, + options?: Record +) => string + +/** + * The alert sentence for the in-app notice and the desktop notification alike, or null when there + * is nothing to say (no level reached, or a member: members aren't alerted). At 75% and 90% it + * states the share used; at 100% it says what happens next, which depends on overage. + */ +export function usageAlertSentence( + entitlement: OrcaBillingEntitlement, + t: UsageAlertTranslate, + locale: string +): string | null { + if (!entitlement.canManageBilling) { + return null + } + const level = usageAlertLevel(entitlement) + if (level === null) { + return null + } + if (level < 100) { + return t( + 'auto.components.billing.teamUsedPercent', + 'Your team has used {{percent}}% of this month’s sandbox hours.', + { percent: String(usageWarningUsedPercent(entitlement)) } + ) + } + return entitlement.overage.optedIn + ? t( + 'auto.components.billing.teamUsedAllOverageOn', + 'Your team has used all of this month’s sandbox hours. Overage is on, up to your {{cap}} spend cap.', + { cap: formatCents(entitlement.overage.spendCapCents, locale) } + ) + : t( + 'auto.components.billing.teamUsedAllOverageOff', + 'Your team has used all of this month’s sandbox hours. Running sandboxes go to sleep and new ones can’t start. Turn on overage to keep going.' + ) +} diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index 42075b350a1..8cee347aa50 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -181,6 +181,10 @@ export function isOrcaBillingInterval(value: unknown): value is OrcaBillingInter /** Where Stripe's hosted pages send the browser back: the Worker's return page opens this link. */ export const ORCA_BILLING_RETURN_CHANNEL = 'billing:return' + +/** Target sent with `ui:openSettings` to land on Settings › Orca account (Plan & billing). */ +export const ORCA_PLAN_BILLING_SETTINGS_TARGET = { pane: 'orca-account' } as const +export type OrcaOpenSettingsTarget = typeof ORCA_PLAN_BILLING_SETTINGS_TARGET export type OrcaBillingReturn = { kind: 'checkout-success' | 'checkout-cancel' | 'portal' } /** Parses `orca://billing/return?checkout=success|cancel` or `?portal=return`; anything else is null. */