mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #626 from nwparker/cloud/team-dialog-2
fix(team): TEAM-DIALOG-2; no header Plan & billing button, invite line is only the seat rule
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { TeamInviteComposer } from './TeamInviteComposer'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const RULE = 'Each invite uses a seat once accepted.'
|
||||
|
||||
it('says only the seat rule, with no seat counts or prices, however many are entered', () => {
|
||||
render(<TeamInviteComposer viewerRole="owner" onInvite={async () => ({ failed: [] })} />, {
|
||||
wrapper: TooltipProvider
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
const hint = document.getElementById(input.getAttribute('aria-describedby')!)!
|
||||
expect(hint.textContent).toBe(RULE)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'jordan@acme.dev, kim@acme.dev ' } })
|
||||
expect(hint.textContent).toBe(RULE)
|
||||
expect(hint.textContent).not.toMatch(/open seat|\$|prorated/)
|
||||
})
|
||||
|
||||
it('still flags invalid entries after the rule', () => {
|
||||
render(<TeamInviteComposer viewerRole="owner" onInvite={async () => ({ failed: [] })} />, {
|
||||
wrapper: TooltipProvider
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'jordan@acme.dev, not-an-email ' } })
|
||||
const hint = document.getElementById(input.getAttribute('aria-describedby')!)!
|
||||
expect(hint.textContent).toBe(`${RULE} Skipping 1 invalid entry.`)
|
||||
})
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/class-names'
|
||||
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
|
||||
import type { OrcaOrgRole } from '../../../../../shared/orca-profiles'
|
||||
import {
|
||||
MAX_INVITES_PER_BATCH,
|
||||
@@ -22,17 +21,11 @@ import {
|
||||
} from './parse-invite-emails'
|
||||
import { roleDescription } from './team-copy'
|
||||
import { assignableRoles, roleLabel } from './team-roles'
|
||||
import { inviteSeatImpactLine } from './team-invite-impact-copy'
|
||||
import { inviteSeatImpact } from './team-seat-math'
|
||||
|
||||
export type InviteBatchOutcome = { failed: { email: string; message: string }[] }
|
||||
|
||||
export type TeamInviteComposerProps = {
|
||||
viewerRole: OrcaOrgRole
|
||||
entitlement: OrcaBillingEntitlement | null
|
||||
members: number
|
||||
pendingInvites: number
|
||||
now: number
|
||||
/** Sends every invite; resolves with the ones that failed so they stay in the field. */
|
||||
onInvite: (emails: string[], role: OrcaOrgRole) => Promise<InviteBatchOutcome>
|
||||
inputRef?: React.Ref<HTMLInputElement>
|
||||
@@ -41,7 +34,6 @@ export type TeamInviteComposerProps = {
|
||||
/** Multi-email invite field: paste a list, review the chips, pick a role, send. */
|
||||
export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const locale = useIntlLocale()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [candidates, setCandidates] = useState<InviteCandidate[]>([])
|
||||
@@ -65,19 +57,10 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
|
||||
const pendingDraft = draft.trim()
|
||||
? mergeInviteCandidates(candidates, splitInviteText(draft))
|
||||
: candidates
|
||||
// The button and the seat line both count what Send would send, including a typed,
|
||||
// unseparated address, so the seat impact always matches the invites about to go out.
|
||||
// The button counts what Send would send, including a typed, unseparated address.
|
||||
const valid = pendingDraft.filter((candidate) => candidate.valid)
|
||||
const committedValid = candidates.filter((candidate) => candidate.valid)
|
||||
const invalidCount = candidates.length - committedValid.length
|
||||
const paymentFailed = props.entitlement?.payment.pastDueSince != null
|
||||
const impact = inviteSeatImpact({
|
||||
entitlement: props.entitlement,
|
||||
members: props.members,
|
||||
pendingInvites: props.pendingInvites,
|
||||
newInvites: valid.length,
|
||||
now: props.now
|
||||
})
|
||||
|
||||
const send = async (): Promise<void> => {
|
||||
const all = commit(draft)
|
||||
@@ -271,13 +254,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
|
||||
</div>
|
||||
</div>
|
||||
<p id={hintId} className="text-xs text-muted-foreground text-pretty">
|
||||
{inviteSeatImpactLine(impact, valid.length, locale, t)}
|
||||
{paymentFailed && valid.length > 0
|
||||
? ` ${t(
|
||||
'auto.components.billing.team.invitePaymentFailed2',
|
||||
'New seats are charged once the failed payment is fixed.'
|
||||
)}`
|
||||
: null}
|
||||
{/* Only the rule: seat counts and prices live in Plan & billing (Neil, TEAM-DIALOG-2). */}
|
||||
{t('auto.components.billing.team.inviteSeatRule', 'Each invite uses a seat once accepted.')}
|
||||
{invalidCount > 0
|
||||
? ` ${t(
|
||||
'auto.components.billing.team.skipInvalid',
|
||||
|
||||
@@ -244,34 +244,13 @@ async function typeInvites(text: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
/** Will add seats: 4 paid, 4 members and 2 pending, so both new invites add a seat. */
|
||||
/** Pasted list: the chips, one invalid entry, and the seat rule (no counts or prices). */
|
||||
export const invitePasted = defineFixture({
|
||||
width,
|
||||
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, seats: 4 } }} />,
|
||||
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev, not-an-email ')
|
||||
})
|
||||
|
||||
/** Seats free: 8 paid, 6 taken (members plus pending), so one invite adds nothing. */
|
||||
export const inviteSeatsFree = defineFixture({
|
||||
width,
|
||||
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, seats: 8 } }} />,
|
||||
interact: () => typeInvites('jordan@acme.dev ')
|
||||
})
|
||||
|
||||
/** Billing unknown (disabled in this build, or it failed): the rule without counts or prices. */
|
||||
export const inviteBillingUnknown = defineFixture({
|
||||
width,
|
||||
render: () => <Page billing={{ status: 'unconfigured' }} />,
|
||||
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ')
|
||||
})
|
||||
|
||||
/** Billing sent no price: seats are still counted, no price is shown. */
|
||||
export const inviteNoPrice = defineFixture({
|
||||
width,
|
||||
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, offers: null } }} />,
|
||||
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ')
|
||||
})
|
||||
|
||||
/** Short roster so the Enterprise row sits inside the frame. */
|
||||
export const ssoRow = defineFixture({
|
||||
width,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
|
||||
import { sampleTeam } from '../plan-billing-fixtures'
|
||||
import { TeamPageView, type TeamPageViewProps } from './TeamPageView'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const roster: OrcaOrgMembersRoster = {
|
||||
viewerRole: 'owner',
|
||||
canManageMembers: true,
|
||||
members: [
|
||||
{ userId: 'u-neil', email: 'neil@acme.dev', displayName: 'Neil Parker', role: 'owner' },
|
||||
{ userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', role: 'admin' }
|
||||
],
|
||||
pendingInvites: [{ email: 'sam@acme.dev', role: 'member', createdAt: 0 }]
|
||||
}
|
||||
|
||||
const noop = (): void => {}
|
||||
const props: TeamPageViewProps = {
|
||||
org: { orgId: 'org-acme', name: 'Acme Engineering' },
|
||||
viewerUserId: 'u-neil',
|
||||
roster: { status: 'ok', roster },
|
||||
billing: { status: 'ok', entitlement: { ...sampleTeam, canManageBilling: true } },
|
||||
arrival: null,
|
||||
rowStates: {},
|
||||
refreshing: false,
|
||||
billingBusy: false,
|
||||
now: 0,
|
||||
create: {
|
||||
organizations: [],
|
||||
creating: false,
|
||||
error: null,
|
||||
unavailable: false,
|
||||
switchingOrgId: null
|
||||
},
|
||||
onCreateTeam: noop,
|
||||
onSwitchOrg: noop,
|
||||
onRefresh: noop,
|
||||
onInvite: async () => ({ failed: [] }),
|
||||
onChangeRole: noop,
|
||||
onMakeOwner: noop,
|
||||
onRemove: noop,
|
||||
onResend: noop,
|
||||
onRevoke: noop,
|
||||
onLeave: noop,
|
||||
onSeePlans: noop,
|
||||
onManageBilling: noop,
|
||||
onCreateWorkspace: noop,
|
||||
onDismissArrival: noop
|
||||
}
|
||||
|
||||
it('keeps the header to the name and counts: no role sentence, no Plan & billing button', () => {
|
||||
render(<TeamPageView {...props} />, { wrapper: TooltipProvider })
|
||||
const header = document.querySelector('header')!
|
||||
expect(header.textContent).toContain('2 members · 1 invited')
|
||||
expect(header.textContent).not.toMatch(/You’re an|Plan & billing/)
|
||||
expect(header.querySelector('button')).toBeNull()
|
||||
// Seat counts and prices live only in Plan & billing.
|
||||
expect(screen.queryByText(/open seat/)).toBeNull()
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CreditCard, RefreshCw } from 'lucide-react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InlineError } from '@/components/ui/inline-error'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -141,20 +141,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
|
||||
: t('auto.components.billing.team.loadFailedTitle', 'Couldn’t load team')}
|
||||
</p>
|
||||
</div>
|
||||
{!inline && entitlement?.canManageBilling ? (
|
||||
// Seats, prices and invoices live in Plan & billing; one quiet way there from here.
|
||||
// mr-6 clears the dialog's close button, which sits in this corner.
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mr-6 shrink-0"
|
||||
onClick={props.onSeePlans}
|
||||
>
|
||||
<CreditCard />
|
||||
{t('auto.components.billing.team.planBillingLink', 'Plan & billing')}
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{props.arrival?.kind === 'checkout-success' && entitlement ? (
|
||||
@@ -213,10 +199,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
|
||||
{roster?.canManageMembers ? (
|
||||
<TeamInviteComposer
|
||||
viewerRole={viewerRole}
|
||||
entitlement={entitlement}
|
||||
members={roster.members.length}
|
||||
pendingInvites={roster.pendingInvites.length}
|
||||
now={props.now}
|
||||
onInvite={props.onInvite}
|
||||
inputRef={inviteInput}
|
||||
/>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { sampleTeam } from '../plan-billing-fixtures'
|
||||
import { formatCents } from '../plans/plans-pricing'
|
||||
import { inviteSeatImpactLine } from './team-invite-impact-copy'
|
||||
import { inviteSeatImpact } from './team-seat-math'
|
||||
|
||||
const DAY = 86_400_000
|
||||
|
||||
// Mirrors i18next's `_one` plural pick so both forms are checked.
|
||||
const t: Translate = (_key, fallback, options) => {
|
||||
const one = options?.count === 1 ? options.defaultValue_one : undefined
|
||||
const text = typeof one === 'string' ? one : fallback
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? ''))
|
||||
}
|
||||
|
||||
function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntitlement {
|
||||
return {
|
||||
...sampleTeam,
|
||||
status: 'active',
|
||||
interval: 'month',
|
||||
seats: 3,
|
||||
seatChange: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
period: { start: 0, end: 30 * DAY },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function line(
|
||||
entitlement: OrcaBillingEntitlement | null,
|
||||
counts: { members: number; pendingInvites: number; newInvites: number }
|
||||
): string {
|
||||
const impact = inviteSeatImpact({ entitlement, ...counts, now: 0 })
|
||||
return inviteSeatImpactLine(impact, counts.newInvites, 'en-US', t)
|
||||
}
|
||||
|
||||
const monthly = sampleTeam.offers?.team.monthlyPriceCentsPerSeat ?? 0
|
||||
|
||||
describe('invite seat impact line', () => {
|
||||
it('says how many seats are open before anything is typed', () => {
|
||||
expect(line(team({ seats: 5 }), { members: 2, pendingInvites: 1, newInvites: 0 })).toBe(
|
||||
'Each invite uses a seat once accepted. You have 2 open seats.'
|
||||
)
|
||||
expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 0 })).toBe(
|
||||
'Each invite uses a seat once accepted. You have no open seats.'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds nothing while the invites fit in open seats', () => {
|
||||
expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 1 })).toBe(
|
||||
'Each invite uses a seat once accepted. You have 1 open seat, so no seats will be added.'
|
||||
)
|
||||
})
|
||||
|
||||
it('prices the added seats from billing, per period, prorated', () => {
|
||||
const total = formatCents(2 * monthly, 'en-US')
|
||||
expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
|
||||
`Each invite uses a seat once accepted. You have no open seats, so 2 more seats (${total}/mo, prorated) will be added.`
|
||||
)
|
||||
})
|
||||
|
||||
it('counts open seats first, then pluralizes the added ones apart', () => {
|
||||
const one = formatCents(monthly, 'en-US')
|
||||
expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
|
||||
`Each invite uses a seat once accepted. You have 1 open seat, so 1 more seat (${one}/mo, prorated) will be added.`
|
||||
)
|
||||
})
|
||||
|
||||
it('names the yearly price on annual plans', () => {
|
||||
expect(
|
||||
line(team({ seats: 1, interval: 'year' }), { members: 1, pendingInvites: 0, newInvites: 1 })
|
||||
).toMatch(/\/yr, prorated\) will be added\.$/)
|
||||
})
|
||||
|
||||
it('never shows a price billing did not send', () => {
|
||||
expect(
|
||||
line(team({ seats: 3, offers: null }), { members: 3, pendingInvites: 0, newInvites: 2 })
|
||||
).toBe(
|
||||
'Each invite uses a seat once accepted. You have no open seats, so 2 more seats will be added, prorated.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps to the rule when billing is off or unreachable', () => {
|
||||
expect(line(null, { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
|
||||
'Each invite uses a seat once accepted.'
|
||||
)
|
||||
})
|
||||
|
||||
it('says invites are free before the team has a plan', () => {
|
||||
expect(
|
||||
line(team({ plan: null, status: 'none' }), { members: 1, pendingInvites: 0, newInvites: 1 })
|
||||
).toBe('Invites are free. Seats are billed once your team picks a plan.')
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
// The one line under "Invite teammates" that says what the invites do to seats. Billing's own
|
||||
// numbers only (ENTITLEMENTS-C): no price is shown when billing didn't send one.
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { formatCents } from '../plans/plans-pricing'
|
||||
import type { InviteSeatImpact } from './team-seat-math'
|
||||
|
||||
/** "You have N open seats", its own key so the seat count and the added count pluralize apart. */
|
||||
function openSeatsClause(open: number, t: Translate): string {
|
||||
return open === 0
|
||||
? t('auto.components.billing.team.openSeatsNone', 'You have no open seats')
|
||||
: t('auto.components.billing.team.openSeatsClause', 'You have {{count}} open seats', {
|
||||
count: open,
|
||||
defaultValue_one: 'You have {{count}} open seat'
|
||||
})
|
||||
}
|
||||
|
||||
export function inviteSeatImpactLine(
|
||||
impact: InviteSeatImpact,
|
||||
invites: number,
|
||||
locale: string,
|
||||
t: Translate
|
||||
): string {
|
||||
switch (impact.kind) {
|
||||
case 'unknown':
|
||||
return t(
|
||||
'auto.components.billing.team.inviteSeatUnknown',
|
||||
'Each invite uses a seat once accepted.'
|
||||
)
|
||||
case 'no-plan':
|
||||
return t(
|
||||
'auto.components.billing.team.inviteNoPlan',
|
||||
'Invites are free. Seats are billed once your team picks a plan.'
|
||||
)
|
||||
case 'covered':
|
||||
return invites === 0
|
||||
? t(
|
||||
'auto.components.billing.team.inviteSeatOpen',
|
||||
'Each invite uses a seat once accepted. {{openSeats}}.',
|
||||
{
|
||||
openSeats: openSeatsClause(impact.freeSeats, t)
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'auto.components.billing.team.inviteSeatCovered',
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.',
|
||||
{ openSeats: openSeatsClause(impact.freeSeats, t) }
|
||||
)
|
||||
case 'adds-seats-unpriced':
|
||||
return t(
|
||||
'auto.components.billing.team.inviteSeatAddsUnpriced',
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.',
|
||||
{
|
||||
openSeats: openSeatsClause(impact.freeSeats, t),
|
||||
count: impact.addedSeats,
|
||||
defaultValue_one:
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.'
|
||||
}
|
||||
)
|
||||
case 'adds-seats': {
|
||||
const price = formatCents(impact.addedSeats * impact.perSeatPeriodCents, locale)
|
||||
return impact.interval === 'year'
|
||||
? t(
|
||||
'auto.components.billing.team.inviteSeatAddsYear',
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.',
|
||||
{
|
||||
openSeats: openSeatsClause(impact.freeSeats, t),
|
||||
count: impact.addedSeats,
|
||||
price,
|
||||
defaultValue_one:
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.'
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'auto.components.billing.team.inviteSeatAddsMonth',
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.',
|
||||
{
|
||||
openSeats: openSeatsClause(impact.freeSeats, t),
|
||||
count: impact.addedSeats,
|
||||
price,
|
||||
defaultValue_one:
|
||||
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
|
||||
import { sampleTeam as activeTeamEntitlement } from '../plan-billing-fixtures'
|
||||
import { inviteSeatImpact, nextInvoiceEstimate, proratedSeatCents } from './team-seat-math'
|
||||
import { nextInvoiceEstimate } from './team-seat-math'
|
||||
|
||||
const DAY = 86_400_000
|
||||
const noPlanEntitlement: OrcaBillingEntitlement = {
|
||||
...activeTeamEntitlement,
|
||||
plan: null,
|
||||
planLabel: null,
|
||||
status: 'none',
|
||||
interval: null,
|
||||
period: null
|
||||
}
|
||||
|
||||
function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntitlement {
|
||||
return {
|
||||
@@ -25,73 +17,6 @@ function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntit
|
||||
}
|
||||
}
|
||||
|
||||
describe('proratedSeatCents', () => {
|
||||
it('charges the remaining share of the period', () => {
|
||||
const pricing = { interval: 'month' as const, perSeatPeriodCents: 5000 }
|
||||
expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 15 * DAY)).toBe(2500)
|
||||
expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 31 * DAY)).toBe(0)
|
||||
expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, -DAY)).toBe(5000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inviteSeatImpact', () => {
|
||||
it('reports no plan when unsubscribed', () => {
|
||||
expect(
|
||||
inviteSeatImpact({
|
||||
entitlement: noPlanEntitlement,
|
||||
members: 1,
|
||||
pendingInvites: 0,
|
||||
newInvites: 2,
|
||||
now: 0
|
||||
})
|
||||
).toEqual({ kind: 'no-plan' })
|
||||
})
|
||||
|
||||
it('uses seats already paid this period first (peak seats)', () => {
|
||||
// 3 paid, 1 member: two free seats, so two invites cost nothing.
|
||||
expect(
|
||||
inviteSeatImpact({
|
||||
entitlement: team(),
|
||||
members: 1,
|
||||
pendingInvites: 0,
|
||||
newInvites: 2,
|
||||
now: 0
|
||||
})
|
||||
).toEqual({ kind: 'covered', freeSeats: 2 })
|
||||
})
|
||||
|
||||
it('counts pending invites against free seats before new ones', () => {
|
||||
const impact = inviteSeatImpact({
|
||||
entitlement: team(),
|
||||
members: 2,
|
||||
pendingInvites: 1,
|
||||
newInvites: 2,
|
||||
now: 15 * DAY
|
||||
})
|
||||
expect(impact).toMatchObject({
|
||||
kind: 'adds-seats',
|
||||
addedSeats: 2,
|
||||
coveredSeats: 0,
|
||||
proratedCentsEach: 2500
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('inviteSeatImpact without prices', () => {
|
||||
it('still says seats are added (never "covered") when pricing is unknown', () => {
|
||||
const unpriced = team({ offers: null })
|
||||
expect(
|
||||
inviteSeatImpact({
|
||||
entitlement: unpriced,
|
||||
members: 3,
|
||||
pendingInvites: 0,
|
||||
newInvites: 2,
|
||||
now: 0
|
||||
})
|
||||
).toEqual({ kind: 'adds-seats-unpriced', addedSeats: 2, freeSeats: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextInvoiceEstimate', () => {
|
||||
it('bills the renewal seat count, not the period peak', () => {
|
||||
const estimate = nextInvoiceEstimate(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Seat money shown on the Team page. Display estimates only: Stripe computes the real invoice.
|
||||
// Mirrors the billing design: seats billed = the period's peak membership; a member who joins
|
||||
// above the paid peak is charged now, prorated; removals only lower seats at renewal; pending
|
||||
// invites cost nothing until accepted.
|
||||
// Seat money shown in Plan & billing. Display estimates only: Stripe computes the real invoice.
|
||||
// Mirrors the billing design: seats billed = the period's peak membership; removals only lower
|
||||
// seats at renewal.
|
||||
import type {
|
||||
OrcaBillingEntitlement,
|
||||
OrcaBillingInterval
|
||||
@@ -33,80 +32,6 @@ export function isActivePlan(entitlement: OrcaBillingEntitlement | null): boolea
|
||||
)
|
||||
}
|
||||
|
||||
/** One seat for the rest of the current period, rounded to the cent like Stripe's proration. */
|
||||
export function proratedSeatCents(
|
||||
pricing: SeatPricing,
|
||||
period: { start: number; end: number },
|
||||
now: number
|
||||
): number {
|
||||
const length = period.end - period.start
|
||||
if (length <= 0) {
|
||||
return pricing.perSeatPeriodCents
|
||||
}
|
||||
const remaining = Math.min(Math.max(period.end - now, 0), length)
|
||||
return Math.round((pricing.perSeatPeriodCents * remaining) / length)
|
||||
}
|
||||
|
||||
export type InviteSeatImpact =
|
||||
/** Billing didn't answer (disabled in this build, signed out or failed): no seat facts to show. */
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'no-plan' }
|
||||
/** Every new invite fits in seats already paid this period. */
|
||||
| { kind: 'covered'; freeSeats: number }
|
||||
/** Seats will be added but the price or period isn't known (older billing service). */
|
||||
| { kind: 'adds-seats-unpriced'; addedSeats: number; freeSeats: number }
|
||||
| {
|
||||
kind: 'adds-seats'
|
||||
/** Paid seats not taken by a member or a pending invite. */
|
||||
freeSeats: number
|
||||
/** How many of the new invites would add a seat when accepted. */
|
||||
addedSeats: number
|
||||
/** Seats already paid but unused that the first acceptances take for free. */
|
||||
coveredSeats: number
|
||||
proratedCentsEach: number
|
||||
perSeatPeriodCents: number
|
||||
interval: OrcaBillingInterval
|
||||
}
|
||||
|
||||
/**
|
||||
* What inviting `newInvites` people costs if they all accept now. Pending invites are assumed to
|
||||
* accept first (conservative: never promises a free seat that an earlier invite will take).
|
||||
*/
|
||||
export function inviteSeatImpact(args: {
|
||||
entitlement: OrcaBillingEntitlement | null
|
||||
members: number
|
||||
pendingInvites: number
|
||||
newInvites: number
|
||||
now: number
|
||||
}): InviteSeatImpact {
|
||||
const { entitlement } = args
|
||||
if (!entitlement) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
if (!isActivePlan(entitlement)) {
|
||||
return { kind: 'no-plan' }
|
||||
}
|
||||
const pricing = seatPricing(entitlement)
|
||||
const paidSeats = Math.max(entitlement.seats, 1)
|
||||
const freeSeats = Math.max(0, paidSeats - args.members - args.pendingInvites)
|
||||
const addedSeats = Math.max(0, args.newInvites - freeSeats)
|
||||
if (addedSeats === 0) {
|
||||
return { kind: 'covered', freeSeats }
|
||||
}
|
||||
if (!pricing || !entitlement.period) {
|
||||
return { kind: 'adds-seats-unpriced', addedSeats, freeSeats }
|
||||
}
|
||||
return {
|
||||
kind: 'adds-seats',
|
||||
freeSeats,
|
||||
addedSeats,
|
||||
coveredSeats: Math.min(freeSeats, args.newInvites),
|
||||
proratedCentsEach: proratedSeatCents(pricing, entitlement.period, args.now),
|
||||
perSeatPeriodCents: pricing.perSeatPeriodCents,
|
||||
interval: pricing.interval
|
||||
}
|
||||
}
|
||||
|
||||
export type NextInvoiceEstimate = {
|
||||
seats: number
|
||||
amountCents: number
|
||||
|
||||
-8
@@ -242,18 +242,10 @@
|
||||
"blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.",
|
||||
"blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first."
|
||||
},
|
||||
"inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.",
|
||||
"inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.",
|
||||
"inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.",
|
||||
"inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.",
|
||||
"inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.",
|
||||
"inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.",
|
||||
"invitedCount_one": "{{count}} invited",
|
||||
"invitedCount_other": "{{count}} invited",
|
||||
"memberCount_one": "{{count}} member",
|
||||
"memberCount_other": "{{count}} members",
|
||||
"openSeatsClause_one": "You have {{count}} open seat",
|
||||
"openSeatsClause_other": "You have {{count}} open seats",
|
||||
"seatCount_one": "{{count}} seat",
|
||||
"seatCount_other": "{{count}} seats",
|
||||
"sendInvites_one": "Send {{count}} invite",
|
||||
|
||||
@@ -21254,7 +21254,6 @@
|
||||
"dismiss": "Dismiss",
|
||||
"enterEmail": "Enter a teammate’s email",
|
||||
"inviteLabel": "Invite teammates",
|
||||
"inviteNoPlan": "Invites are free. Seats are billed once your team picks a plan.",
|
||||
"invitePlaceholder2": "alex@company.com, sam@company.com",
|
||||
"inviteSentBody": "They join, and take a seat, when they accept.",
|
||||
"inviteSentMany": "{{value0}} invites sent",
|
||||
@@ -21310,7 +21309,6 @@
|
||||
"createdNotSwitched": "{{value0}} was created, but Orca couldn’t switch to it.",
|
||||
"invitedCount_one": "{{count}} invited",
|
||||
"invitedCount_other": "{{count}} invited",
|
||||
"invitePaymentFailed2": "New seats are charged once the failed payment is fixed.",
|
||||
"memberCount_one": "{{count}} member",
|
||||
"memberCount_other": "{{count}} members",
|
||||
"seatCount_one": "{{count}} seat",
|
||||
@@ -21351,22 +21349,10 @@
|
||||
"deletedToast2": "{{value0}} was deleted"
|
||||
},
|
||||
"leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team.",
|
||||
"planBillingLink": "Plan & billing",
|
||||
"perSeat": "Per seat",
|
||||
"perSeatMonthValue": "{{value0}}/mo",
|
||||
"seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.",
|
||||
"openSeatsNone": "You have no open seats",
|
||||
"openSeatsClause_one": "You have {{count}} open seat",
|
||||
"openSeatsClause_other": "You have {{count}} open seats",
|
||||
"inviteSeatUnknown": "Each invite uses a seat once accepted.",
|
||||
"inviteSeatOpen": "Each invite uses a seat once accepted. {{openSeats}}.",
|
||||
"inviteSeatCovered": "Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.",
|
||||
"inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.",
|
||||
"inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.",
|
||||
"inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.",
|
||||
"inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.",
|
||||
"inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.",
|
||||
"inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added."
|
||||
"inviteSeatRule": "Each invite uses a seat once accepted."
|
||||
},
|
||||
"manageBilling": "Manage billing",
|
||||
"comparePlans": "Compare plans",
|
||||
|
||||
Reference in New Issue
Block a user