lib/billing-plans.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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);
}