From 7533e82242bc1fb4760275cfbdb173dd0b5dda10 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:45:55 -0800 Subject: [PATCH] fix(team): TEAM-DIALOG-2; drop the header Plan & billing button, invite line is only the seat rule --- .../billing/team/TeamInviteComposer.test.tsx | 32 +++++++ .../billing/team/TeamInviteComposer.tsx | 30 +----- .../billing/team/TeamPageView.fixture.tsx | 23 +---- .../billing/team/TeamPageView.test.tsx | 63 ++++++++++++ .../components/billing/team/TeamPageView.tsx | 20 +--- .../team/team-invite-impact-copy.test.ts | 96 ------------------- .../billing/team/team-invite-impact-copy.ts | 86 ----------------- .../billing/team/team-seat-math.test.ts | 77 +-------------- .../components/billing/team/team-seat-math.ts | 81 +--------------- .../src/i18n/en-runtime-required.json | 8 -- src/renderer/src/i18n/locales/en.json | 16 +--- 11 files changed, 106 insertions(+), 426 deletions(-) create mode 100644 src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx create mode 100644 src/renderer/src/components/billing/team/TeamPageView.test.tsx delete mode 100644 src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts delete mode 100644 src/renderer/src/components/billing/team/team-invite-impact-copy.ts diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx new file mode 100644 index 00000000000..3eb9fe09a3b --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx @@ -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( ({ 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( ({ 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.`) +}) diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx index 566ea08c500..4c1429b5d25 100644 --- a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx @@ -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 inputRef?: React.Ref @@ -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([]) @@ -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 => { const all = commit(draft) @@ -271,13 +254,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El

- {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', diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx index 67784fa9137..1eb77834e17 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx @@ -244,34 +244,13 @@ async function typeInvites(text: string): Promise { 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: () => , 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: () => , - 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: () => , - 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: () => , - interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ') -}) - /** Short roster so the Enterprise row sits inside the frame. */ export const ssoRow = defineFixture({ width, diff --git a/src/renderer/src/components/billing/team/TeamPageView.test.tsx b/src/renderer/src/components/billing/team/TeamPageView.test.tsx new file mode 100644 index 00000000000..5ae89ebf7cf --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamPageView.test.tsx @@ -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(, { 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() +}) diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 427c63bb752..ccf26d7b5a2 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -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')}

- {!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. - - ) : null} {props.arrival?.kind === 'checkout-success' && entitlement ? ( @@ -213,10 +199,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { {roster?.canManageMembers ? ( diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts deleted file mode 100644 index e642e810f4c..00000000000 --- a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts +++ /dev/null @@ -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 { - 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.') - }) -}) diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.ts deleted file mode 100644 index c51d6e50e79..00000000000 --- a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts +++ /dev/null @@ -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.' - } - ) - } - } -} diff --git a/src/renderer/src/components/billing/team/team-seat-math.test.ts b/src/renderer/src/components/billing/team/team-seat-math.test.ts index 94e18f86817..6c6919b83d0 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.test.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.test.ts @@ -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 { return { @@ -25,73 +17,6 @@ function team(overrides: Partial = {}): 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( diff --git a/src/renderer/src/components/billing/team/team-seat-math.ts b/src/renderer/src/components/billing/team/team-seat-math.ts index 928ac36cc41..c4465884cd7 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.ts @@ -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 diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index c8cdcaf698d..689c39c5ed9 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ef21b2d308..4133e13111b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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",