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
3 changes: 3 additions & 0 deletions apps/api/src/core/plan-feature-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/tools/ai-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
90 changes: 90 additions & 0 deletions apps/api/src/tools/ai-providers/plan-change.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>[] = [
{},
{ 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);
}
});
});
121 changes: 121 additions & 0 deletions apps/api/src/tools/ai-providers/plan-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<string, boolean>;
/** 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<string, boolean>;
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 };
},
});
1 change: 1 addition & 0 deletions apps/api/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/i18n/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/i18n/pt-br/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading
Loading