mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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] }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user