Ctrl K
ComunidadGuías…

lib/billing-plans.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Plans & billing (MIN-72) — the source of truth for plans, shared side
 * (importable client AND server, no server-only dependency).
 *
 * Model: each plan includes a monthly USAGE BUDGET in USD, deducted as
 * GROSS COST (the actual OpenRouter/Vercel cost, without overhead per action — the
 * margin is on the subscription price). The ledger `ai_usage` provides the
 * spent ; window is the current Stripe period (or calendar month
 * for free accounts). Prices displayed in EUR, budgets in USD (the currency of
 * costs reported by OpenRouter).
 */

export type BillingPlanId = "free" | "go" | "pro";

/** Billing rate for a subscription (= `recurring.interval` on the Stripe side). */
export type BillingInterval = "month" | "year";

export interface BillingPlan {
  id: BillingPlanId;
  /** Display price (the price charged comes from the Stripe price configured in approx). */
  priceEurMonthly: number;
  /** Monthly AI usage budget included, in gross cost USD. */
  includedUsageUsd: number;
  /**
   * The most expensive model that this plan can CHOOSE on the minddy quota, expressed in
   * minddy's default model multiplier (see lib/model-multiplier.ts).
   *
   * A USD budget was not enough to protect scale: on a small scale
   * budget, a ×12 model empties it in three runs, and the user discovers the
   * ceiling as it falls. The multiplier is read BEFORE
   * throw, in the picker, next to each model.
   *
   * This applies only to the Minddy quota. BYOK users pay for their own
   * tokens, so the entire catalog is available. The pricing baseline (Numo's
   * admin model, `PRICING_BASELINE_CONFIG_KEY`) is not an account model selection.
   *
   * These values ​​are HOOKED to the current default. Propped on
   * deepseek-v4-flash (~$0.21/Mtok), they give each plane a boundary
   * which is called: Go goes up to Claude Haiku 4.5 (×14), Pro up to Sonnet 5
   * (×29) and GPT-5.2 (×38), Opus 5 (×71) remains BYOK. Changing Numo's
   * model in the admin dashboard moves the WHOLE scale — a default twice as expensive divides
   * all multipliers by two and opens the caps by that much. The day
   * where it happens, these three numbers are replayed against the new baseline.
   */
  maxModelMultiplier: number;
  /**
   * BYTES that projects in this account can hold in storage —
   * ticket attachments and page files combined (MIN-348).
   *
   * The only product cap that TypeScript doesn't enforce: sending
   * leaves the browser DIRECTLY to the bucket, with the JWT of its author, and
   * does not cross any of our roads. So this is the policy `attachments insert`
   * (migration 20261229090000) which applies it, reading the table
   * `plan_storage_quotas`. These three numbers and this table must say the
   * same thing — `lib/billing-plans.test.ts` checks it by reading the SQL.
   *
   * The imputation follows that of AI use: what counts is the OWNER of the
   * project where the file lands, not the member who dropped it.
   */
  maxStorageBytes: number;
  /** null = unlimited. */
  maxProjects: number | null;
  /** null = unlimited. */
  maxIssuesPerProject: number | null;
  /** Access to code agents (launch of runs on the platform key or BYOK). */
  allowAgents: boolean;
  /**
   * Guests per project — the people we bring in, the OWNER NOT INCLUDED
   * (“3 guests” = three people in addition to yourself). `null` = unlimited.
   *
   * Collaboration is part of the product, not a paid capability. The Free plan
   * has a small team cap; every paid plan is unlimited.
   */
  maxMembersPerProject: number | null;
  /** Plan highlighted in the UI. */
  highlighted?: boolean;
}

export const BILLING_PLANS: BillingPlan[] = [
  {
    id: "free",
    priceEurMonthly: 0,
    includedUsageUsd: 0.5,
    maxModelMultiplier: 1,
    maxStorageBytes: 1 * 1024 * 1024 * 1024, // 1 Go
    maxProjects: 2,
    maxIssuesPerProject: 300,
    allowAgents: true,
    maxMembersPerProject: 3,
  },
  {
    id: "go",
    priceEurMonthly: 8,
    includedUsageUsd: 5,
    maxModelMultiplier: 15,
    maxStorageBytes: 20 * 1024 * 1024 * 1024, // 20 Go
    maxProjects: null,
    maxIssuesPerProject: null,
    allowAgents: true,
    maxMembersPerProject: null,
    highlighted: true,
  },
  {
    id: "pro",
    priceEurMonthly: 20,
    includedUsageUsd: 15,
    maxModelMultiplier: 40,
    maxStorageBytes: 100 * 1024 * 1024 * 1024, // 100 Go
    maxProjects: null,
    maxIssuesPerProject: null,
    allowAgents: true,
    maxMembersPerProject: null,
  },
];
// An “Ultra” plan (~100-200 €, large usage budget) is considered: add it
// here + a Stripe price is enough, everything else is driven by these fields.

export const DEFAULT_BILLING_PLAN_ID: BillingPlanId = "free";

export function getBillingPlan(id: BillingPlanId): BillingPlan {
  return BILLING_PLANS.find((plan) => plan.id === id) ?? BILLING_PLANS[0];
}

export function coerceBillingPlanId(value: unknown): BillingPlanId | null {
  return typeof value === "string" &&
    BILLING_PLANS.some((plan) => plan.id === value)
    ? (value as BillingPlanId)
    : null;
}

/** Rank to compare plans (upgrade vs manage in the UI). */
export function billingPlanRank(id: BillingPlanId): number {
  return BILLING_PLANS.findIndex((plan) => plan.id === id);
}

/**
 * The plan to PROPOSE to someone who has exhausted their usage budget: the first
 * above which actually gives MORE budget and which authorizes agents.
 * `null` = there is none — the user is at the top of the ladder, and the
 * exhausted budget card must then only suggest waiting or switching to
 * BYOK. Proposing an upgrade that does not exist would be a dead end.
 *
 * The day an “Ultra” plan is added to `BILLING_PLANS`, it is offered all
 * alone — nothing else to wire.
 */
export function nextBillingPlanId(current: BillingPlanId): BillingPlanId | null {
  const budget = getBillingPlan(current).includedUsageUsd;
  const better = BILLING_PLANS.slice(billingPlanRank(current) + 1).find(
    (plan) => plan.allowAgents && plan.includedUsageUsd > budget,
  );
  return better?.id ?? null;
}

// ── Facturation annuelle ─────────────────────────────────────────────────────

/** Months offered on the annual: we charge 10 months for 12 (2 months offered). */
export const ANNUAL_FREE_MONTHS = 2;
export const ANNUAL_BILLED_MONTHS = 12 - ANNUAL_FREE_MONTHS;

/** Annual display price (the price charged comes from the annual Stripe price). */
export function annualPriceEur(plan: BillingPlan): number {
  return plan.priceEurMonthly * ANNUAL_BILLED_MONTHS;
}

/** Equivalent monthly cost of an annual subscription, rounded to the nearest cent. */
export function annualMonthlyEquivalentEur(plan: BillingPlan): number {
  return Math.round((annualPriceEur(plan) / 12) * 100) / 100;
}

// ── Segments d'affichage de l'usage ─────────────────────────────────────────

/**
 * The ledger features that the user sees in their usage bar — 1:1 with
 * `AiFeature` (lib/server/ai-usage.ts) + the CHECK of the migration, except for this
 * that the internal features (`import_map`, `brief_split`, `landing_demo`) are not
 * are not: no one reads them.
 *
 * A LIST, not just a union: it is this that `billing-plans.test.ts`
 * scans to verify that each feature falls into exactly ONE segment.
 * A feature added to the union without a segment would not raise anything — the bar
 * would silently undercount the total, and the history would file it under
 * “Numo” by fallback. The test is the only place that catches it; he needs
 * enough to list.
 */
export const BILLABLE_FEATURES = [
  "numo_chat",
  "numo_comment",
  "dictation",
  "transcription",
  "smart_assign",
  "smart_fill",
  "feedback_classify",
  "feedback_analyze",
  "feedback_voice",
  "embedding",
  "web_search",
  // A ROUTINE (MIN-185): the same Numo calls as `numo_chat`, on another
  // invoice line. See the `routines` segment below.
  "routine_code",
  // AI decisions (MIN-561, MIN-694): the decision-model calls of the AI
  // decision layer, whichever model the instance configured (historical
  // `jev_` id). Billed to the user like the automations it backs, so it
  // joins their segment below.
  "jev_decision",
  // Smart Triage (MIN-566): the LLM scoring pass of a column reorder — the
  // decision-model half rides `jev_decision`, this is the fallback engine's own line.
  // Same segment below, same gesture.
  "smart_triage",
  // The shadow comparison of the decision layer (MIN-567): the LLM pass
  // replayed on a ~5% sample after a confident decision-model decision. One line under
  // its own feature so the sampling delta stays readable (and removable)
  // next to the decisions it measures.
  "jev_shadow",
  // Smart Fill relations (MIN-717): issue indexing and the relation decision's
  // LLM fallback. Same segment as Smart Fill below: the same gesture.
  "issue_embedding",
  "issue_relation",
  // Issue search by meaning (MIN-718): the query embeddings of the pickers.
  "issue_search",
  // Background Numo (MIN-928): the LLM fallback of the wake decision.
  "numo_wake",
  // Numo's word on the home page (MIN-930).
  "numo_home_brief",
] as const;

export type BillableFeature = (typeof BILLABLE_FEATURES)[number];

export type UsageSegmentId =
  | "routines"
  | "numo"
  | "dictation"
  | "feedback"
  | "automations";

export interface UsageSegment {
  id: UsageSegmentId;
  features: BillableFeature[];
  /** Tailwind class of bar filling + legend patch. */
  barClass: string;
}

/**
 * The typed grouping shown to the user (“5% on routines, 2% on
 * dictation…”): the unified bar of the header and the page billing tiles these
 * segments in this order.
 */
export const USAGE_SEGMENTS: UsageSegment[] = [
  // The ROUTINES (MIN-185): technically the same Numo calls as a chat; in
  // billing this is not the same expense — a chat is a gesture that we made,
  // a routine is a subscription that we let run, and “what ate my budget this
  // month?” is only answered if the two are read separately.
  {
    id: "routines",
    features: ["routine_code"],
    barClass: "bg-sky-500",
  },
  // Web search is a Numo tool (chat, comments), but it remains anecdotal compared to the rest: we place it with Numo rather than
  // to add a color to the bar for a few cents. (Routines,
  // the opposite, have deserved theirs: a recurring expense that we want to be able to
  // look alone, not a few pennies drowned.)
  {
    id: "numo",
    features: ["numo_chat", "numo_comment", "web_search", "numo_wake", "numo_home_brief"],
    barClass: "bg-blue-500",
  },
  { id: "dictation", features: ["dictation", "transcription"], barClass: "bg-amber-500" },
  // Returns: the sorting of a return upon its arrival, the dictation of the one we
  // written, AND the embeddings, which only serve them (reconciliation of
  // duplicates of the public board). The voice remains here rather than with the dictation of
  // tickets: this is the expense of a return, and it is this line that we read
  // when you wonder what the board costs.
  {
    id: "feedback",
    features: ["feedback_classify", "feedback_analyze", "feedback_voice", "embedding"],
    barClass: "bg-emerald-500",
  },
  // FORM AUTOMATIONS: what minddy fills out for you
  // moment the ticket is born — who takes it (Smart Assign, MIN-31) and what it
  // is (Smart-fill, MIN-260). Two features, one line: we don’t arm them
  // together, but we wonder their cost together, because it is the same
  // question — “what does it cost me to no longer fill out my tickets?” ".
  //
  // The line was called "Smart Assign", and the commentary at the time dismissed
  // expressly the word “automations”, which already designates chains of rules
  // (MIN-147). He always points to it — but these channels spend nothing on
  // own: their cost is that of the runs they launch, and it can be read in the
  // agent line. The word was therefore free, and with two features the name of a
  // single product hid the other half of the line.
  {
    id: "automations",
    features: [
      "smart_assign",
      "smart_fill",
      "jev_decision",
      "smart_triage",
      "jev_shadow",
      "issue_embedding",
      "issue_relation",
      "issue_search",
    ],
    barClass: "bg-fuchsia-500",
  },
];

/** Multiple usage of a plan vs. Free (“10× more usage”) — for the UI, which
    speaks in percentages and multiples, never in USD amounts. */
export function usageMultiplierVsFree(plan: BillingPlan): number {
  const free = getBillingPlan("free");
  if (free.includedUsageUsd <= 0) return 1;
  return Math.round(plan.includedUsageUsd / free.includedUsageUsd);
}