Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions apps/api/src/tools/ai-providers/plan-change.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 }),
Expand Down
9 changes: 0 additions & 9 deletions apps/api/src/tools/ai-providers/plan-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,6 @@ export function invoiceUpgradeRefusal(input: {
entitlements: {
planId: string;
features: Record<string, boolean>;
/** Null when the gateway could not read usage — which refuses. */
usageState: "ok" | "warn" | "exhausted" | null;
} | null;
subscriptionBound: boolean;
targetPlanId: string;
Expand All @@ -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) {
Expand Down Expand Up @@ -175,7 +168,6 @@ export const AI_PLAN_INVOICE_UPGRADE = defineTool({
let entitlements: {
planId: string;
features: Record<string, boolean>;
usageState: "ok" | "warn" | "exhausted" | null;
} | null = null;
if (plansEnabled && adapter?.getEntitlements) {
const read = await adapter.getEntitlements(
Expand All @@ -186,7 +178,6 @@ export const AI_PLAN_INVOICE_UPGRADE = defineTool({
entitlements = {
planId: read.plan.id,
features: read.features,
usageState: read.usage?.state ?? null,
};
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/feature-paywall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/i18n/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/i18n/pt-br/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
62 changes: 35 additions & 27 deletions apps/web/src/views/settings/ai-providers/plan-catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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", {}),
});
Expand Down Expand Up @@ -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 (
<SettingsSection title={t("settings.plans.title")}>
<Card className="p-5 flex-row items-center justify-between gap-3">
Expand All @@ -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 (
<SettingsSection
title={
limitReached
? t("settings.plans.limitReached")
: t("settings.plans.title")
}
>
<SettingsSection title={t("settings.plans.title")}>
{isLoading || isLoadingPlan ? (
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
Expand Down Expand Up @@ -196,7 +187,7 @@ export function PlanCatalog() {
}
/>
))}
{!limitReached && <CustomPlanCard index={plans.length} />}
<CustomPlanCard index={plans.length} isCurrent={onContract} />
</div>
)}

Expand Down Expand Up @@ -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 (
<Card className="p-6 gap-6">
<Card
className={cn(
"p-6 gap-6",
isCurrent && "ring-1 ring-primary shadow-none",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-3">
<h3 className="text-base font-medium leading-tight">
Expand All @@ -373,9 +375,15 @@ function CustomPlanCard({ index }: { index: number }) {
</div>
<PlanPlant index={index} />
</div>
<Button variant="outline" className="w-full" asChild>
<a href={SALES_CONTACT_HREF}>{t("settings.plans.custom.cta")}</a>
</Button>
{isCurrent ? (
<div className="flex h-8 items-center justify-center rounded-md border border-dashed border-border text-sm text-muted-foreground">
{t("settings.plans.currentPlan")}
</div>
) : (
<Button variant="outline" className="w-full" asChild>
<a href={SALES_CONTACT_HREF}>{t("settings.plans.custom.cta")}</a>
</Button>
)}
</Card>
);
}
15 changes: 7 additions & 8 deletions apps/web/src/views/settings/ai-providers/plan-ladder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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<string> = new Set(["ai_service"]);

export function isStaffManagedPlan(planId: string | null | undefined): boolean {
return !!planId && STAFF_MANAGED_PLAN_IDS.has(planId);
Expand Down
Loading