diff --git a/apps/api/src/core/plan-feature-gate.ts b/apps/api/src/core/plan-feature-gate.ts index bd3c9d5bee..78b32cb579 100644 --- a/apps/api/src/core/plan-feature-gate.ts +++ b/apps/api/src/core/plan-feature-gate.ts @@ -31,6 +31,9 @@ export type PlanFeature = | "model_choice" | "diagnostic" | "diagnostic_enriched" + /** Per-org, not a plan surface: may move to a paid plan billed on deco's + * invoice instead of Stripe (`AI_PLAN_INVOICE_UPGRADE`). */ + | "invoice_upgrade" /** May buy AI credits on top of the allowance — every plan, Free included. * What it buys is the wallet, a second pool: more work, never a bigger * allowance. `isUsageBlocked` is what lets that wallet outlive a full diff --git a/apps/api/src/tools/ai-providers/index.ts b/apps/api/src/tools/ai-providers/index.ts index 48d72a124a..c8ead90899 100644 --- a/apps/api/src/tools/ai-providers/index.ts +++ b/apps/api/src/tools/ai-providers/index.ts @@ -12,4 +12,8 @@ export { AI_PROVIDER_PROVISION_KEY } from "./provision-key"; export { AI_PROVIDER_TOPUP_URL } from "./topup-url"; export { AI_PROVIDER_CREDITS } from "./credits"; export { AI_PLAN_ENTITLEMENTS } from "./entitlements"; -export { AI_PLAN_LIST, AI_PLAN_SET } from "./plan-change"; +export { + AI_PLAN_INVOICE_UPGRADE, + AI_PLAN_LIST, + AI_PLAN_SET, +} from "./plan-change"; diff --git a/apps/api/src/tools/ai-providers/plan-change.test.ts b/apps/api/src/tools/ai-providers/plan-change.test.ts new file mode 100644 index 0000000000..27b5b60cd7 --- /dev/null +++ b/apps/api/src/tools/ai-providers/plan-change.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import { AI_PLAN_INVOICE_UPGRADE, invoiceUpgradeRefusal } from "./plan-change"; + +const flagged = { + planId: "custom", + features: { invoice_upgrade: true }, + usageState: "exhausted" as const, +}; + +const allowed = { + plansEnabled: true, + entitlements: flagged, + subscriptionBound: false, + targetPlanId: "starter", +}; + +describe("invoiceUpgradeRefusal", () => { + it("allows a flagged org with no subscription moving to another plan", () => { + 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 }), + ).not.toBeNull(); + }); + + it("refuses when the gateway gave no answer", () => { + expect( + invoiceUpgradeRefusal({ ...allowed, entitlements: null }), + ).not.toBeNull(); + }); + + it("refuses when the flag is absent or not exactly true", () => { + const cases: Record[] = [ + {}, + { invoice_upgrade: false }, + { cms: true }, + ]; + for (const features of cases) { + expect( + invoiceUpgradeRefusal({ + ...allowed, + entitlements: { ...flagged, features }, + }), + ).not.toBeNull(); + } + }); + + it("refuses while a Stripe subscription is bound", () => { + expect( + invoiceUpgradeRefusal({ ...allowed, subscriptionBound: true }), + ).not.toBeNull(); + }); + + it("refuses moving to the plan the org is already on", () => { + expect( + invoiceUpgradeRefusal({ + ...allowed, + entitlements: { ...flagged, planId: "starter" }, + }), + ).not.toBeNull(); + }); +}); + +describe("AI_PLAN_INVOICE_UPGRADE input validation", () => { + it("accepts only the self-serve paid plans", () => { + for (const planId of ["starter", "business"]) { + expect( + AI_PLAN_INVOICE_UPGRADE.inputSchema.safeParse({ planId }).success, + ).toBe(true); + } + for (const planId of ["free", "custom", "ai_service", "", undefined]) { + expect( + AI_PLAN_INVOICE_UPGRADE.inputSchema.safeParse({ planId }).success, + ).toBe(false); + } + }); +}); diff --git a/apps/api/src/tools/ai-providers/plan-change.ts b/apps/api/src/tools/ai-providers/plan-change.ts index 4b6db4ffc8..f507f5f4bc 100644 --- a/apps/api/src/tools/ai-providers/plan-change.ts +++ b/apps/api/src/tools/ai-providers/plan-change.ts @@ -9,6 +9,12 @@ import { HOSTED_PROVIDER_IDS } from "../../ai-providers/provider-ids"; import { getProviders } from "../../ai-providers/registry"; import { mintGatewayJwt } from "../../auth/jwt"; import { invalidateOrgFeaturesEverywhere } from "../../billing/plan-cache-broadcast"; +import { + gatewayAdminConfigured, + setGatewayOrgPlan, +} from "../../billing/gateway-admin"; +import { getSettings } from "../../settings"; +import { captureOrgEvent } from "@/posthog"; const planSchema = z.object({ id: z.string(), @@ -100,3 +106,118 @@ export const AI_PLAN_SET = defineTool({ return { plan: result.plan, features: result.features }; }, }); + +/** + * Why an invoice upgrade must be refused, or null when it may go ahead. + * + * Every unknown refuses: this grants a paid plan with no payment, so it is the + * opposite of the feature gate's fail-open bias. `entitlements` is null when + * the gateway was not asked or did not answer. + */ +export function invoiceUpgradeRefusal(input: { + plansEnabled: boolean; + 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; +}): string | null { + if (!input.plansEnabled) return "Plans are not enabled on this deployment."; + if (!input.entitlements) return "Could not read this organization's plan."; + 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) { + return "This organization has an active subscription — change plans through billing."; + } + if (input.entitlements.planId === input.targetPlanId) { + return "This organization is already on that plan."; + } + return null; +} + +/** + * Move a flagged org (`invoice_upgrade`) onto a paid plan now and bill it on + * the org's next invoice, outside Stripe. deco finance learns of it from the + * `plan_invoice_upgrade` event. + * + * Not `requiresFeature`: that gate fails OPEN when the gateway cannot answer, + * which would hand out a paid plan. The flag is read fresh, past the gate's + * cache, and anything short of a `true` refuses. + */ +export const AI_PLAN_INVOICE_UPGRADE = defineTool({ + name: "AI_PLAN_INVOICE_UPGRADE", + description: + "Move the organization to a paid plan now, billed on its next deco invoice. Only for organizations deco enabled for invoice billing.", + inputSchema: z.object({ planId: z.enum(["starter", "business"]) }), + outputSchema: z.object({ planId: z.string() }), + handler: async (input, ctx) => { + requireAuth(ctx); + const org = requireOrganization(ctx); + await ctx.access.check(); + + const userId = getUserId(ctx); + if (!userId) throw new Error("Unable to determine user ID"); + + const plansEnabled = getSettings().plansEnabled && gatewayAdminConfigured(); + const billing = await ctx.storage.organizationBilling.getBilling(org.id); + const adapter = getProviders().deco; + let entitlements: { + planId: string; + features: Record; + usageState: "ok" | "warn" | "exhausted" | null; + } | null = null; + if (plansEnabled && adapter?.getEntitlements) { + const read = await adapter.getEntitlements( + await mintGatewayJwt(userId), + org.id, + ); + if (read.features && typeof read.features === "object") { + entitlements = { + planId: read.plan.id, + features: read.features, + usageState: read.usage?.state ?? null, + }; + } + } + const refusal = invoiceUpgradeRefusal({ + plansEnabled, + entitlements, + subscriptionBound: !!billing?.stripeSubscriptionId, + targetPlanId: input.planId, + }); + if (refusal || !entitlements) { + throw new Error(refusal ?? "Could not read this organization's plan."); + } + + const by = ctx.auth.user?.email ?? userId; + await setGatewayOrgPlan({ + organizationId: org.id, + planId: input.planId, + note: `invoice upgrade by ${by} at ${new Date().toISOString()} — bill on next invoice`, + }); + invalidateOrgFeaturesEverywhere(org.id); + captureOrgEvent({ + event: "plan_invoice_upgrade", + organizationId: org.id, + userId, + properties: { + plan_id: input.planId, + previous_plan_id: entitlements.planId, + org_slug: org.slug, + org_name: org.name, + }, + }); + return { planId: input.planId }; + }, +}); diff --git a/apps/api/src/tools/index.ts b/apps/api/src/tools/index.ts index 8a3c6758f2..769c572197 100644 --- a/apps/api/src/tools/index.ts +++ b/apps/api/src/tools/index.ts @@ -239,6 +239,7 @@ export const CORE_TOOLS = [ AiProvidersTools.AI_PLAN_ENTITLEMENTS, AiProvidersTools.AI_PLAN_LIST, AiProvidersTools.AI_PLAN_SET, + AiProvidersTools.AI_PLAN_INVOICE_UPGRADE, // Claude subscription (per-user OAuth credential for the claude-code harness) ClaudeSubscriptionTools.CLAUDE_SUBSCRIPTION_CONNECT, ClaudeSubscriptionTools.CLAUDE_SUBSCRIPTION_STATUS, diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index b2a91e5986..8b038e430b 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -673,6 +673,12 @@ 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?", + "settings.plans.invoice.cancel": "Cancel", + "settings.plans.invoice.switch": "Switch plan", "settings.paywall.bullets.kanban.1": "Agents that do the work and move the cards", "settings.paywall.bullets.kanban.2": "A board the whole team shares", diff --git a/apps/web/src/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index 3ba63aa34f..813e6641dc 100644 --- a/apps/web/src/i18n/pt-br/settings.ts +++ b/apps/web/src/i18n/pt-br/settings.ts @@ -693,6 +693,12 @@ 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?", + "settings.plans.invoice.cancel": "Cancelar", + "settings.plans.invoice.switch": "Mudar de plano", "settings.paywall.bullets.kanban.1": "Agentes que fazem o trabalho e movem os cards", "settings.paywall.bullets.kanban.2": 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 896aae1fc5..0ccbfa8138 100644 --- a/apps/web/src/views/settings/ai-providers/plan-catalog.tsx +++ b/apps/web/src/views/settings/ai-providers/plan-catalog.tsx @@ -1,4 +1,17 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { Check, Minus } from "@untitledui/icons"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@decocms/ui/components/alert-dialog.tsx"; import { Button } from "@decocms/ui/components/button.tsx"; import { Card } from "@decocms/ui/components/card.tsx"; import { Skeleton } from "@decocms/ui/components/skeleton.tsx"; @@ -19,6 +32,9 @@ import { } from "./plan-ladder"; import { usePreferences } from "@/hooks/use-preferences.ts"; import { useOpenBillingUrl } from "@/hooks/use-open-billing-url"; +import { useProjectContext } from "@/sdk"; +import { useStudioTools } from "@/lib/studio-tools"; +import { KEYS } from "@/lib/query-keys"; /** * The plans, side by side, on the page. @@ -32,8 +48,17 @@ import { useOpenBillingUrl } from "@/hooks/use-open-billing-url"; * 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. + * + * 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. */ +/** The plans `AI_PLAN_INVOICE_UPGRADE` accepts. */ +function invoicePlanId(id: string): "starter" | "business" | null { + return id === "starter" || id === "business" ? id : null; +} + export function PlanCatalog() { const t = useT(); const plansEnabled = usePlansEnabled(); @@ -53,6 +78,39 @@ export function PlanCatalog() { "settings.planUsage.changeFailed", ); + 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 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, + staleTime: 60_000, + queryFn: () => studio.call("ORGANIZATION_TASK_QUOTA_GET", {}), + }); + const offerInvoice = canInvoice && billingAccount?.subscribed === false; + const [invoicePlan, setInvoicePlan] = useState(null); + const { mutate: invoiceUpgrade, isPending: isInvoicing } = useMutation({ + mutationFn: (planId: "starter" | "business") => + studio.call("AI_PLAN_INVOICE_UPGRADE", { planId }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: KEYS.aiPlanEntitlements(org.id), + }); + toast.success(t("settings.planUsage.changed")); + }, + onError: (err) => + toast.error( + t("settings.planUsage.changeFailed", { message: err.message }), + ), + }); + // Without the org's plan we cannot tell a contract org from any other, and // the catalog is exactly what a contract org must not be shown. The plan // card above already says the read failed and offers the retry. @@ -60,7 +118,10 @@ export function PlanCatalog() { const currentId = entitlements?.plan.id ?? null; - if (isStaffManagedPlan(currentId)) { + const staffManaged = isStaffManagedPlan(currentId); + const limitReached = staffManaged && canInvoice; + + if (staffManaged && !limitReached) { return ( @@ -86,10 +147,19 @@ export function PlanCatalog() { // moves the subscription it has, on Stripe's own confirm screen. Same // 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. - const subscribed = currentId !== null && currentId !== "free"; + // A contract org has no plan Stripe knows about; ask Stripe instead. + const subscribed = limitReached + ? billingAccount?.subscribed === true + : currentId !== null && currentId !== "free"; return ( - + {isLoading || isLoadingPlan ? (
{Array.from({ length: 3 }).map((_, i) => ( @@ -117,13 +187,49 @@ export function PlanCatalog() { subscribed={subscribed} isCurrent={plan.id === currentId} emphasis={plan.id === suggestedId} - disabled={isPending} + disabled={isPending || isInvoicing} onChoose={() => startCheckout(plan.id)} + onInvoice={ + offerInvoice && invoicePlanId(plan.id) + ? () => setInvoicePlan(plan) + : undefined + } /> ))} - + {!limitReached && }
)} + + !open && setInvoicePlan(null)} + > + + + + {t("settings.plans.invoice.cta")} + + + {t("settings.plans.invoice.confirm", { + plan: invoicePlan?.name ?? "", + })} + + + + + {t("settings.plans.invoice.cancel")} + + { + const planId = invoicePlan && invoicePlanId(invoicePlan.id); + if (planId) invoiceUpgrade(planId); + }} + > + {t("settings.plans.invoice.switch")} + + + +
); } @@ -137,6 +243,7 @@ function PlanCard({ emphasis, disabled, onChoose, + onInvoice, }: { plan: Plan; index: number; @@ -146,6 +253,8 @@ function PlanCard({ emphasis: boolean; disabled: boolean; onChoose: () => void; + /** Set when the org may take this plan on its invoice instead of Stripe. */ + onInvoice?: () => void; }) { const t = useT(); const [preferences] = usePreferences(); @@ -172,7 +281,9 @@ function PlanCard({ {t("settings.plans.perMonth")} - {trial && ( + {/* The trial is Stripe's; an invoiced plan bills from day one, + so beside that button the Stripe CTA alone names it. */} + {trial && !onInvoice && ( <> {" · "} @@ -192,18 +303,30 @@ function PlanCard({ {t("settings.plans.currentPlan")} ) : ( - +
+ + {onInvoice && ( + + )} +
)}
    diff --git a/packages/shared/src/tools/registry-metadata.ts b/packages/shared/src/tools/registry-metadata.ts index 1c2aedae14..dd6d3c60c2 100644 --- a/packages/shared/src/tools/registry-metadata.ts +++ b/packages/shared/src/tools/registry-metadata.ts @@ -184,6 +184,7 @@ const ALL_TOOL_NAMES = [ "AI_PLAN_ENTITLEMENTS", "AI_PLAN_LIST", "AI_PLAN_SET", + "AI_PLAN_INVOICE_UPGRADE", // Claude subscription (per-user OAuth credential for the claude-code harness) "CLAUDE_SUBSCRIPTION_CONNECT", @@ -986,6 +987,11 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Change the organization's plan", category: "AI Providers", }, + { + name: "AI_PLAN_INVOICE_UPGRADE", + description: "Move the organization to a paid plan billed on its invoice", + category: "AI Providers", + }, // Secrets tools { name: "SECRET_CREATE", @@ -1904,6 +1910,7 @@ const PERMISSION_CAPABILITIES: PermissionCapability[] = [ "AI_PLAN_ENTITLEMENTS", "AI_PLAN_LIST", "AI_PLAN_SET", + "AI_PLAN_INVOICE_UPGRADE", "CLAUDE_SUBSCRIPTION_CONNECT", "CLAUDE_SUBSCRIPTION_STATUS", "CLAUDE_SUBSCRIPTION_DISCONNECT", diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 7b82d8ccea..efa6e26908 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -5836,6 +5836,10 @@ export interface StudioToolIO { features: Record; }; }; + AI_PLAN_INVOICE_UPGRADE: { + input: { planId: "starter" | "business" }; + output: { planId: string }; + }; CLAUDE_SUBSCRIPTION_CONNECT: { input: { token: string }; output: {