diff --git a/apps/api/src/tools/ai-providers/plan-change.test.ts b/apps/api/src/tools/ai-providers/plan-change.test.ts index 27b5b60cd7..7d4b7c423d 100644 --- a/apps/api/src/tools/ai-providers/plan-change.test.ts +++ b/apps/api/src/tools/ai-providers/plan-change.test.ts @@ -4,7 +4,6 @@ import { AI_PLAN_INVOICE_UPGRADE, invoiceUpgradeRefusal } from "./plan-change"; const flagged = { planId: "custom", features: { invoice_upgrade: true }, - usageState: "exhausted" as const, }; const allowed = { @@ -19,17 +18,6 @@ describe("invoiceUpgradeRefusal", () => { expect(invoiceUpgradeRefusal(allowed)).toBeNull(); }); - it("refuses until the usage bar is full, and when usage is unknown", () => { - for (const usageState of ["ok", "warn", null] as const) { - expect( - invoiceUpgradeRefusal({ - ...allowed, - entitlements: { ...flagged, usageState }, - }), - ).not.toBeNull(); - } - }); - it("refuses when plans are off", () => { expect( invoiceUpgradeRefusal({ ...allowed, plansEnabled: false }), diff --git a/apps/api/src/tools/ai-providers/plan-change.ts b/apps/api/src/tools/ai-providers/plan-change.ts index f507f5f4bc..ea416d8db8 100644 --- a/apps/api/src/tools/ai-providers/plan-change.ts +++ b/apps/api/src/tools/ai-providers/plan-change.ts @@ -119,8 +119,6 @@ export function invoiceUpgradeRefusal(input: { entitlements: { planId: string; features: Record; - /** Null when the gateway could not read usage — which refuses. */ - usageState: "ok" | "warn" | "exhausted" | null; } | null; subscriptionBound: boolean; targetPlanId: string; @@ -130,11 +128,6 @@ export function invoiceUpgradeRefusal(input: { if (input.entitlements.features.invoice_upgrade !== true) { return "This organization cannot add a plan to its invoice."; } - // The offer is for an org that has run out, not a standing self-serve - // upgrade path around Stripe. - if (input.entitlements.usageState !== "exhausted") { - return "Invoice upgrades open once this organization's AI usage limit is reached."; - } // A Stripe subscription owns this org's plan; a second, invoiced one would // be overwritten by the next webhook or billed twice. if (input.subscriptionBound) { @@ -175,7 +168,6 @@ export const AI_PLAN_INVOICE_UPGRADE = defineTool({ let entitlements: { planId: string; features: Record; - usageState: "ok" | "warn" | "exhausted" | null; } | null = null; if (plansEnabled && adapter?.getEntitlements) { const read = await adapter.getEntitlements( @@ -186,7 +178,6 @@ export const AI_PLAN_INVOICE_UPGRADE = defineTool({ entitlements = { planId: read.plan.id, features: read.features, - usageState: read.usage?.state ?? null, }; } } diff --git a/apps/web/src/components/feature-paywall.tsx b/apps/web/src/components/feature-paywall.tsx index 18181ec539..d0489070dd 100644 --- a/apps/web/src/components/feature-paywall.tsx +++ b/apps/web/src/components/feature-paywall.tsx @@ -109,8 +109,8 @@ export function FeaturePaywall({ const name = t(`settings.planUsage.feature.${feature}`); /** The rung to climb to. For a missing feature it is the cheapest one that * has it; for a spent allowance nothing is missing, so it is simply the - * next rung up from where the org already is. A contract plan is off the - * ladder, so it is quoted no rung and no price. */ + * next rung up from where the org already is. deco's own plan is off + * the ladder, so it is quoted no rung and no price. */ const target = isStaffManagedPlan(entitlements?.plan.id) ? null : copy diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index 8ac4e31b7a..c94eef7e34 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -677,7 +677,6 @@ export const settings = { "For large teams with their own contract", "settings.plans.custom.cta": "Talk to us", "settings.plans.managed": "Your plan: {plan}. Contact us to change it.", - "settings.plans.limitReached": "You've reached your AI usage limit", "settings.plans.invoice.cta": "Add to my invoice", "settings.plans.invoice.confirm": "{plan} will be added to your next invoice. Switch now?", diff --git a/apps/web/src/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index cce51210c6..3d3b2af898 100644 --- a/apps/web/src/i18n/pt-br/settings.ts +++ b/apps/web/src/i18n/pt-br/settings.ts @@ -697,7 +697,6 @@ export const settings = { "Para grandes times com contrato próprio", "settings.plans.custom.cta": "Fale com a gente", "settings.plans.managed": "Seu plano: {plan}. Fale com a gente para mudá-lo.", - "settings.plans.limitReached": "Você atingiu o seu limite de uso de AI", "settings.plans.invoice.cta": "Adicionar à minha fatura", "settings.plans.invoice.confirm": "O plano {plan} será adicionado à sua próxima fatura. Mudar agora?", diff --git a/apps/web/src/views/settings/ai-providers/plan-catalog.tsx b/apps/web/src/views/settings/ai-providers/plan-catalog.tsx index 0ccbfa8138..6b4c9676f7 100644 --- a/apps/web/src/views/settings/ai-providers/plan-catalog.tsx +++ b/apps/web/src/views/settings/ai-providers/plan-catalog.tsx @@ -20,6 +20,7 @@ import { SettingsSection } from "@/components/settings/settings-section"; import { useT } from "@/i18n/use-t.ts"; import { useEntitlements, usePlansEnabled } from "@/hooks/use-entitlements"; import { + CUSTOM_PLAN_ID, FEATURE_ROWS, PlanPlant, SALES_CONTACT_HREF, @@ -47,11 +48,11 @@ import { KEYS } from "@/lib/query-keys"; * * Allowances stay on the gateway; prices come from Stripe (`usePlanPrices`). * Custom is not in the gateway's public list — deco staff assign it by - * contract — so it is a static card here with no price, only a contact. + * contract — so it is a static card here with no price, only a contact. An org + * on it still sees the catalog and may move to any listed plan. * * An org deco flagged `invoice_upgrade` may also take a plan now and pay for - * it on its deco invoice (`AI_PLAN_INVOICE_UPGRADE`) — on a contract plan, - * only once its AI usage is spent. + * it on its deco invoice (`AI_PLAN_INVOICE_UPGRADE`). */ /** The plans `AI_PLAN_INVOICE_UPGRADE` accepts. */ @@ -81,16 +82,15 @@ export function PlanCatalog() { const { org } = useProjectContext(); const studio = useStudioTools(); const queryClient = useQueryClient(); - // Only a flagged org, and only at its limit — the same two conditions - // AI_PLAN_INVOICE_UPGRADE enforces, so the button never offers a refusal. - const canInvoice = - entitlements?.features.invoice_upgrade === true && - entitlements.usage?.state === "exhausted"; + // Same flag AI_PLAN_INVOICE_UPGRADE enforces, so the button never offers a + // refusal. + const canInvoice = entitlements?.features.invoice_upgrade === true; + const onContract = entitlements?.plan.id === CUSTOM_PLAN_ID; // Same query as the plan card's billing button. A live subscription owns the // plan, so the invoice path waits for a definite "none" before it shows. const { data: billingAccount } = useQuery({ queryKey: KEYS.orgBillingAccount(org.id), - enabled: canInvoice, + enabled: canInvoice || onContract, staleTime: 60_000, queryFn: () => studio.call("ORGANIZATION_TASK_QUOTA_GET", {}), }); @@ -118,10 +118,7 @@ export function PlanCatalog() { const currentId = entitlements?.plan.id ?? null; - const staffManaged = isStaffManagedPlan(currentId); - const limitReached = staffManaged && canInvoice; - - if (staffManaged && !limitReached) { + if (isStaffManagedPlan(currentId)) { return ( @@ -148,18 +145,12 @@ export function PlanCatalog() { // button, and the word is the only thing that says which of the two it is. // `free` is the absence of a plan, not one. // A contract org has no plan Stripe knows about; ask Stripe instead. - const subscribed = limitReached + const subscribed = onContract ? billingAccount?.subscribed === true : currentId !== null && currentId !== "free"; return ( - + {isLoading || isLoadingPlan ? (
{Array.from({ length: 3 }).map((_, i) => ( @@ -196,7 +187,7 @@ export function PlanCatalog() { } /> ))} - {!limitReached && } +
)} @@ -358,10 +349,21 @@ function PlanCard({ } /** Contract pricing, set by deco staff: no price to quote, only a contact. */ -function CustomPlanCard({ index }: { index: number }) { +function CustomPlanCard({ + index, + isCurrent, +}: { + index: number; + isCurrent: boolean; +}) { const t = useT(); return ( - +

@@ -373,9 +375,15 @@ function CustomPlanCard({ index }: { index: number }) {

- + {isCurrent ? ( +
+ {t("settings.plans.currentPlan")} +
+ ) : ( + + )}
); } diff --git a/apps/web/src/views/settings/ai-providers/plan-ladder.tsx b/apps/web/src/views/settings/ai-providers/plan-ladder.tsx index 33fef686eb..2215ce0514 100644 --- a/apps/web/src/views/settings/ai-providers/plan-ladder.tsx +++ b/apps/web/src/views/settings/ai-providers/plan-ladder.tsx @@ -104,14 +104,13 @@ export function planUnlocking( return plan ? { plan, index } : null; } -/** - * Plans deco staff assign by contract. They are not on the ladder, so an org on - * one is shown neither the catalog nor a checkout — only who to talk to. - */ -const STAFF_MANAGED_PLAN_IDS: ReadonlySet = new Set([ - "custom", - "ai_service", -]); +/** Contract pricing, assigned by deco staff. Off the ladder, but an org on it + * may still move to any catalog plan. */ +export const CUSTOM_PLAN_ID = "custom"; + +/** deco's own plan: shown neither the catalog nor a checkout — only who to + * talk to. */ +const STAFF_MANAGED_PLAN_IDS: ReadonlySet = new Set(["ai_service"]); export function isStaffManagedPlan(planId: string | null | undefined): boolean { return !!planId && STAFF_MANAGED_PLAN_IDS.has(planId);