Merge pull request #243 from nwparker/cloud/usage-alerts

feat(billing): usage alerts for owners and admins at 75/90/100%
This commit is contained in:
Neil
2026-11-10 06:05:30 -08:00
committed by GitHub
21 changed files with 721 additions and 73 deletions
+43
View File
@@ -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()
}
@@ -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> = {}): 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> = {}
): 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, string>): string =>
fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => options?.[name] ?? '')
describe('notifyUsageAlertIfDue', () => {
let dir: string
let show: ReturnType<typeof vi.fn<UsageAlertDeps['show']>>
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<UsageAlertDeps['show']>()
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] }
})
})
})
+103
View File
@@ -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<UsageAlertLedger> {
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<void> {
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<unknown> = 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<number | null> {
const run = queue.then(() => decideAndNotify(accountKey, entitlement, deps))
queue = run.catch(() => null)
return run
}
async function decideAndNotify(
accountKey: string,
entitlement: OrcaBillingEntitlement,
deps: UsageAlertDeps
): Promise<number | null> {
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
}
@@ -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<OrcaBillingStatusResult> =>
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<OrcaBillingStatusResult> => {
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<OrcaBillingOpenResult> =>
+1 -1
View File
@@ -325,5 +325,5 @@ export function registerOrcaProfileHandlers(
)
registerOrcaProfileOrgMemberHandlers()
registerOrcaProfileBillingHandlers()
registerOrcaProfileBillingHandlers(store)
}
@@ -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)
},
+1 -1
View File
@@ -88,7 +88,7 @@ export type UiCommandEventApi = {
setWithAck?: (args: Partial<PersistedUIState>) => Promise<void>
recordFeatureInteraction: (id: FeatureInteractionId) => Promise<PersistedUIState>
onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void
onOpenSettings: (callback: () => void) => () => void
onOpenSettings: (callback: (target?: { pane: 'orca-account' }) => void) => () => void
consumePendingOpenSettings: () => Promise<boolean>
/** Main confirmed Quit Completely; consume the request before acting. */
onQuitAndStopAllSessions: (callback: () => void) => () => void
@@ -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: () => (
<PlanBillingView
{...handlers}
result={{
status: 'ok',
entitlement: {
...sampleTeam,
usedSeconds: 120 * HOUR,
remainingIncludedSeconds: 0,
usageWarningPercent: 100,
canStartSandbox: false,
reason: 'included_hours_exhausted'
}
}}
/>
)
})
/** 100% with overage on: the alert names the spend cap the team keeps going up to. */
export const usageAlert100OverageOn = defineFixture({
width: 600,
render: () => (
<PlanBillingView
{...handlers}
result={{
status: 'ok',
entitlement: {
...sampleTeam,
usedSeconds: 123 * HOUR,
remainingIncludedSeconds: 0,
overageSeconds: 3 * HOUR,
usageWarningPercent: 100,
overage: { ...sampleTeam.overage, optedIn: true, active: true, spentCents: 300 }
}
}}
/>
)
})
/** A member at billing's 90% level: no admin usage notice (members aren't alerted). */
export const usageWarningMember = defineFixture({
width: 600,
render: () => (
<PlanBillingView
{...handlers}
result={{
status: 'ok',
entitlement: {
...sampleTeam,
usedSeconds: 109 * HOUR,
remainingIncludedSeconds: 11 * HOUR,
usageWarningPercent: 90,
canManageBilling: false
}
}}
/>
)
})
/** An admin types a cap above billing's maximum: the field says what the maximum is. */
export const capTooHigh = defineFixture({
width: 600,
@@ -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({
</div>
) : 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))
) ? (
<p className="text-xs text-muted-foreground">
{denyReasonCopy(entitlement.reason, t, entitlement.canManageBilling)}
</p>
@@ -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 (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3 text-xs">
@@ -208,6 +208,12 @@ export function OverageControls({
<p id={descriptionId} className="text-xs text-muted-foreground">
{optedIn ? descriptionOn : description}
</p>
<p className="text-xs text-muted-foreground">
{t(
'auto.components.billing.usageAlertsNote',
'Owners and admins are alerted in Orca at 75%, 90% and 100% of your team’s sandbox hours.'
)}
</p>
</div>
<SettingsDisabledControlTooltip
reason={
@@ -48,30 +48,58 @@ describe('sandbox hours copy', () => {
})
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()
})
})
@@ -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)
}
}
@@ -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 (
<div className="space-y-2 rounded-xl border border-border bg-card p-4 shadow-xs">
<div className="flex flex-wrap items-center gap-2 text-sm">
@@ -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 {
@@ -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<void> => {
await openAccountMenu(frame)
const trigger = document.querySelector<HTMLElement>('[data-account-menu-team-switcher]')
@@ -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).
@@ -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()
})
)
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+138
View File
@@ -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<string, { periodStart: number; levels: number[] }>
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, string>
) => 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.'
)
}
+4
View File
@@ -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. */