diff --git a/.gitignore b/.gitignore index f93c86f..ff61ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,25 @@ postgres_data/ # Local development certificates certs/ + +# Beads issue tracker — roadmap kept OUT of this PUBLIC repo (synced privately, see keyway-admin) +.beads/ + +# Private pricing/strategy spec — must NOT be committed to public repo (ADR 0001 policy) +SPEC.md + +# Beads / Dolt files (added by bd init) +.dolt/ +/*.db +.beads-credential-key + +# Local AI-tooling state and scratch artifacts — never commit +.claude/ +.codex/ +.deepsec/ +.playwright-mcp/ +/AGENTS.md + +# Local test screenshots and private review notes +/*.png +security_code_review-*.md diff --git a/docs/adr/0002-flat-tier-pricing.md b/docs/adr/0002-flat-tier-pricing.md new file mode 100644 index 0000000..db03c58 --- /dev/null +++ b/docs/adr/0002-flat-tier-pricing.md @@ -0,0 +1,60 @@ +# 2. Flat-tier pricing: drop the Pro tier, uncap paid private repos + +- **Status:** Accepted — supersedes [ADR 0001](./0001-pricing-plan-model.md) +- **Date:** 2026-07-02 + +## Context + +ADR 0001 modelled four tiers differentiated by a private-repo count (Free 1, +Pro 10, Team 20, Business 50), purchasable by individuals as well as +organizations. Two problems surfaced. Differentiating tiers by a repo counter +tied entitlements to repository topology rather than to anything the product +gates, and selling the same paid tiers to both personal accounts and +organizations made the payment workflow ambiguous (an owner could pay for +themselves while intending to pay for their org). + +## Decision + +1. **Tiers:** `Free` · `Team` · `Business`. The `pro` value is removed from the + `user_plan` enum (migration `0044` remaps data then rebuilds the enum — + Postgres cannot drop an enum value in place). +2. **Paid tiers are flat per organization and differ by features, not quotas.** + Team and Business have unlimited private repos, providers, and environments; + Business additionally unlocks Exposure (secret-access tracking) — the only + feature gate enforced in code today (`hasExposureAccess`); further + governance features on Business (e.g. SSO) are planned but not yet + implemented. Members/collaborators are never capped on any tier (access + mirrors GitHub: repo access = secret access). The Free tier allows 10 + private repos (up from 1); public repos remain unlimited everywhere. +3. **Paid plans are sold org-only going forward.** Personal accounts stay on + `free`; a follow-up change retires the personal checkout path. Migration + `0044` resets personal plans to `free` **except** accounts with a Stripe + customer, which fold into a valid paid tier (`pro` → `team`) so an active + subscription is never divorced from its entitlements while it is wound down. +4. **Lapsed trials are normalized.** Orgs whose trial expired unconverted (no + Stripe customer) previously kept the granted plan in the DB while their + effective plan was already `free`; migration `0044` aligns the stored value. + Org `team`/`business` plans granted manually (no Stripe customer, no trial) + are deliberately kept; residual org `pro` rows follow the Stripe rule above. +5. **Stripe remains the source of truth for amounts** via `lookup_key` + (`team_*_eur`, `business_*_eur`); the `pro_*` lookup keys are retired and the + reverse lookup map is now derived from the forward one. Feature gating is + unchanged: hierarchical, fail-closed, `hasExposureAccess` at `business`. + +## Consequences + +- Migration `0044` must run **before** the new backend deploys: rows still + carrying `pro` would make `PLANS['pro']` lookups throw at runtime. +- The prices endpoint no longer returns a `pro` entry — deployed clients must + tolerate its absence before this ships (dashboard: #23). +- The four `team_*`/`business_*` lookup keys must exist in Stripe (Test and + Live) before deploy; `pro_*` prices can be archived afterwards. +- Webhooks for legacy `pro_*`-priced subscriptions no longer resolve to a plan + and are dropped with a warning. No such live subscriptions exist today — the + migration's `pro`→`team` fold is purely defensive — and the personal-checkout + retirement handles anything that would remain. + +## Notes + +Product/go-to-market rationale (positioning, amounts, pricing strategy) is +maintained separately, outside this repository, per ADR 0001. diff --git a/packages/backend/CLAUDE.md b/packages/backend/CLAUDE.md index 9a44bdb..5118c48 100644 --- a/packages/backend/CLAUDE.md +++ b/packages/backend/CLAUDE.md @@ -37,7 +37,7 @@ src/ │ ├── billing.routes.ts # Stripe integration │ └── integrations.routes.ts # Provider sync (Vercel, etc.) ├── config/ -│ └── plans.ts # Plan limits (free/pro/team) +│ └── plans.ts # Plan limits (free/team/business) ├── db/ │ ├── schema.ts # Drizzle ORM schema │ └── index.ts # Database connection @@ -85,9 +85,9 @@ throw new NotFoundError('Vault not found'); **Plan Limits** (`src/config/plans.ts`): ```typescript -// Free: 1 private repo, 2 providers, 3 envs, unlimited secrets -// Pro/Team/Business: 10/20/50 private repos, unlimited providers/envs/secrets -// (collaborators are never capped on any plan; Business adds Exposure reports) +// Free: 10 private repos, 2 providers, 3 envs per vault, unlimited secrets +// Team/Business: unlimited private repos/providers/envs (flat price, org-only) +// (collaborators are never capped on any plan; Business adds Exposure reports + SSO) const check = canCreateSecret(user.plan, count, isPrivate); if (!check.allowed) throw new PlanLimitError(check.reason); ``` diff --git a/packages/backend/drizzle/0044_drop_pro_plan_flat_tiers.sql b/packages/backend/drizzle/0044_drop_pro_plan_flat_tiers.sql new file mode 100644 index 0000000..59d0200 --- /dev/null +++ b/packages/backend/drizzle/0044_drop_pro_plan_flat_tiers.sql @@ -0,0 +1,38 @@ +-- Flat-tier pricing: plans are now free / team / business (the 'pro' tier is +-- removed). Going forward, paid plans are sold org-only. +-- +-- Remap policy: an account (user or org) WITH a Stripe customer is never +-- divorced from a paid tier ('pro' folds into 'team'); winding down legacy +-- personal subscriptions is handled separately in code. Accounts WITHOUT a +-- Stripe customer never paid: personal accounts go to 'free', and residual +-- org 'pro' rows go to 'free' too ('pro' was never a legitimate org tier). +-- Org team/business plans granted manually (no Stripe, no trial) are +-- deliberately kept. + +-- 1) Remap data while 'pro' still exists in the enum +UPDATE "organizations" SET "plan" = 'team' WHERE "plan" = 'pro' AND "stripe_customer_id" IS NOT NULL;--> statement-breakpoint +UPDATE "organizations" SET "plan" = 'free' WHERE "plan" = 'pro';--> statement-breakpoint +UPDATE "users" SET "plan" = 'free' WHERE "plan" != 'free' AND "stripe_customer_id" IS NULL;--> statement-breakpoint +UPDATE "users" SET "plan" = 'team' WHERE "plan" = 'pro' AND "stripe_customer_id" IS NOT NULL;--> statement-breakpoint + +-- 2) Lapsed trials (ended, never converted, no Stripe customer) previously kept +-- their granted plan in the DB even though the effective plan was 'free'; +-- align the stored plan with reality. +UPDATE "organizations" +SET "plan" = 'free' +WHERE "plan" != 'free' + AND "stripe_customer_id" IS NULL + AND "trial_converted_at" IS NULL + AND "trial_ends_at" IS NOT NULL + AND "trial_ends_at" < now();--> statement-breakpoint + +-- 3) Rebuild the enum without 'pro' (Postgres cannot drop an enum value in place) +ALTER TYPE "user_plan" RENAME TO "user_plan_old";--> statement-breakpoint +CREATE TYPE "user_plan" AS ENUM ('free', 'team', 'business');--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "plan" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "plan" TYPE "user_plan" USING "plan"::text::"user_plan";--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "plan" SET DEFAULT 'free';--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "plan" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "plan" TYPE "user_plan" USING "plan"::text::"user_plan";--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "plan" SET DEFAULT 'free';--> statement-breakpoint +DROP TYPE "user_plan_old"; diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 5a42906..ad60c60 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1767700000000, "tag": "0043_rename_startup_to_business", "breakpoints": true + }, + { + "idx": 44, + "version": "5", + "when": 1782967000000, + "tag": "0044_drop_pro_plan_flat_tiers", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/backend/src/api/v1/routes/billing.routes.ts b/packages/backend/src/api/v1/routes/billing.routes.ts index fcd4fe9..a297a59 100644 --- a/packages/backend/src/api/v1/routes/billing.routes.ts +++ b/packages/backend/src/api/v1/routes/billing.routes.ts @@ -72,10 +72,6 @@ export async function billingRoutes(fastify: FastifyInstance) { reply, { prices: { - pro: { - monthly: toApiPrice(prices?.pro.monthly), - yearly: toApiPrice(prices?.pro.yearly), - }, team: { monthly: toApiPrice(prices?.team.monthly), yearly: toApiPrice(prices?.team.yearly), @@ -201,8 +197,6 @@ export async function billingRoutes(fastify: FastifyInstance) { const prices = await getAvailablePrices(); const validPriceIds = [ - prices?.pro.monthly?.id, - prices?.pro.yearly?.id, prices?.team.monthly?.id, prices?.team.yearly?.id, prices?.business.monthly?.id, diff --git a/packages/backend/src/config/plans.ts b/packages/backend/src/config/plans.ts index aea0c20..a7bd965 100644 --- a/packages/backend/src/config/plans.ts +++ b/packages/backend/src/config/plans.ts @@ -19,39 +19,31 @@ export interface PlanLimits { /** * Plan definitions. Pricing is flat per tier — collaborators/team members are - * never capped (access mirrors GitHub: repo access = secret access). The only - * scaling lever is private repos. - * - free: 1 private repo, 2 providers, 3 envs - * - pro: 10 private repos, unlimited providers/envs (€9/month) - * - team: 20 private repos, unlimited providers/envs (€19/month) - * - business: 50 private repos, unlimited providers/envs (€39/month) - * Top tier — unlocks advanced team features (Exposure reports). Organizations subscribe to this tier. + * never capped (access mirrors GitHub: repo access = secret access), and paid + * tiers have unlimited private repos. Paid tiers differ by governance features + * (Exposure reports on Business — see hasExposureAccess), not by quotas. + * - free: 10 private repos, 2 providers, 3 envs per vault + * - team: unlimited repos/providers/envs (flat monthly price, org-only) + * - business: everything in team + Exposure reports (org-only) */ export const PLANS: Record = { free: { maxPublicRepos: Infinity, - maxPrivateRepos: 1, + maxPrivateRepos: 10, maxProviders: 2, maxEnvironmentsPerVault: 3, maxSecretsPerPrivateVault: Infinity, }, - pro: { - maxPublicRepos: Infinity, - maxPrivateRepos: 10, - maxProviders: Infinity, - maxEnvironmentsPerVault: Infinity, - maxSecretsPerPrivateVault: Infinity, - }, team: { maxPublicRepos: Infinity, - maxPrivateRepos: 20, + maxPrivateRepos: Infinity, maxProviders: Infinity, maxEnvironmentsPerVault: Infinity, maxSecretsPerPrivateVault: Infinity, }, business: { maxPublicRepos: Infinity, - maxPrivateRepos: 50, + maxPrivateRepos: Infinity, maxProviders: Infinity, maxEnvironmentsPerVault: Infinity, maxSecretsPerPrivateVault: Infinity, @@ -60,9 +52,8 @@ export const PLANS: Record = { const PLAN_RANK: Record = { free: 0, - pro: 1, - team: 2, - business: 3, + team: 1, + business: 2, }; export function planRank(plan: UserPlan): number { diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index 6a15915..82ec3d3 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -96,7 +96,7 @@ export const securityAlertTypeEnum = pgEnum("security_alert_type", [ ]); // User plan types -export const userPlanEnum = pgEnum("user_plan", ["free", "pro", "team", "business"]); +export const userPlanEnum = pgEnum("user_plan", ["free", "team", "business"]); // Provider sync status export const syncStatusEnum = pgEnum("sync_status", ["success", "failed", "partial"]); diff --git a/packages/backend/src/services/billing.service.ts b/packages/backend/src/services/billing.service.ts index d1214cb..499e73e 100644 --- a/packages/backend/src/services/billing.service.ts +++ b/packages/backend/src/services/billing.service.ts @@ -12,24 +12,25 @@ import { getOrganizationById, } from "./organization.service"; import { convertTrial, hasHadTrial } from "./trial.service"; +import { planRank } from "../config/plans"; // Initialize Stripe client (only if configured) const stripe = config.stripe ? new Stripe(config.stripe.secretKey) : null; const LOOKUP_KEYS = { - pro: { monthly: "pro_month_eur", yearly: "pro_year_eur" }, team: { monthly: "team_month_eur", yearly: "team_year_eur" }, business: { monthly: "business_month_eur", yearly: "business_year_eur" }, } as const; -const LOOKUP_KEY_TO_PLAN: Record = { - pro_month_eur: "pro", - pro_year_eur: "pro", - team_month_eur: "team", - team_year_eur: "team", - business_month_eur: "business", - business_year_eur: "business", -}; +// Derived inverse of LOOKUP_KEYS so adding/removing a tier is a single edit +const LOOKUP_KEY_TO_PLAN: Record = Object.fromEntries( + (Object.entries(LOOKUP_KEYS) as [UserPlan, { monthly: string; yearly: string }][]).flatMap( + ([plan, keys]) => [ + [keys.monthly, plan], + [keys.yearly, plan], + ] + ) +); export interface ResolvedPrice { id: string; @@ -352,7 +353,7 @@ async function handleSubscriptionChange(subscription: Stripe.Subscription): Prom // Track billing upgrade/downgrade events if (previousPlan !== plan) { - const isUpgrade = getPlanRank(plan) > getPlanRank(previousPlan); + const isUpgrade = planRank(plan) > planRank(previousPlan); // Log activity await logActivity({ @@ -380,24 +381,6 @@ async function handleSubscriptionChange(subscription: Stripe.Subscription): Prom } } -/** - * Get numeric rank for plan comparison (free=0, pro=1, team=2, business=3) - */ -function getPlanRank(plan: UserPlan): number { - switch (plan) { - case "free": - return 0; - case "pro": - return 1; - case "team": - return 2; - case "business": - return 3; - default: - return 0; - } -} - /** * Handle subscription deleted event */ @@ -415,7 +398,7 @@ async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Pro .where(eq(users.id, userId)) .limit(1); - const previousPlan = currentUser?.plan || "pro"; + const previousPlan = currentUser?.plan || "team"; // Delete subscription record await db.delete(subscriptions).where(eq(subscriptions.userId, userId)); @@ -593,10 +576,6 @@ export async function getAvailablePrices() { const prices = await resolvePrices(); return { - pro: { - monthly: toResolvedPrice(prices.get(LOOKUP_KEYS.pro.monthly), "month"), - yearly: toResolvedPrice(prices.get(LOOKUP_KEYS.pro.yearly), "year"), - }, team: { monthly: toResolvedPrice(prices.get(LOOKUP_KEYS.team.monthly), "month"), yearly: toResolvedPrice(prices.get(LOOKUP_KEYS.team.yearly), "year"), diff --git a/packages/backend/src/services/trial.service.ts b/packages/backend/src/services/trial.service.ts index 75e85a9..592af3a 100644 --- a/packages/backend/src/services/trial.service.ts +++ b/packages/backend/src/services/trial.service.ts @@ -319,7 +319,7 @@ export async function expireTrial(input: ExpireTrialInput): Promise return 'free' * - Otherwise -> return actual plan */ -export function getEffectivePlanWithTrial(org: Organization): "free" | "pro" | "team" | "business" { +export function getEffectivePlanWithTrial(org: Organization): UserPlan { // Paid customer takes precedence if (org.stripeCustomerId && (org.plan === "team" || org.plan === "business")) { return org.plan; diff --git a/packages/backend/src/services/usage.service.ts b/packages/backend/src/services/usage.service.ts index b1462e6..e1c8869 100644 --- a/packages/backend/src/services/usage.service.ts +++ b/packages/backend/src/services/usage.service.ts @@ -167,7 +167,7 @@ export interface PrivateVaultAccess { * Get user's private vaults ordered by creation date (oldest first). * The first N vaults (within plan limit) are "allowed", rest are "excess". * - * For Pro/Team plans (unlimited), returns empty sets (all vaults are allowed). + * For paid plans (unlimited), returns empty sets (all vaults are allowed). */ export async function getPrivateVaultAccess( userId: string, @@ -180,12 +180,13 @@ export async function getPrivateVaultAccess( return { allowedVaultIds: new Set(), excessVaultIds: new Set() }; } - // Get private vaults ordered by creation date (FIFO - oldest first) + // Get private vaults ordered by creation date (FIFO - oldest first). + // id breaks createdAt ties so the allowed/excess split is deterministic. const privateVaults = await db .select({ id: vaults.id }) .from(vaults) .where(and(eq(vaults.ownerId, userId), eq(vaults.isPrivate, true))) - .orderBy(asc(vaults.createdAt)); + .orderBy(asc(vaults.createdAt), asc(vaults.id)); const allowedVaultIds = new Set(privateVaults.slice(0, limit).map((v) => v.id)); const excessVaultIds = new Set(privateVaults.slice(limit).map((v) => v.id)); @@ -200,8 +201,8 @@ export async function getPrivateVaultAccess( * otherwise the user's plan). * * - Public vaults: always writable - * - Pro/Team plans: always writable - * - Free plan + private vault: only if within the 1-vault limit (oldest vault) + * - Paid plans: always writable + * - Free plan + private vault: only if within the plan's private-vault limit (oldest first) */ export async function canWriteToVault( userId: string, @@ -223,7 +224,7 @@ export async function canWriteToVault( ? effectivePlan : userPlan; - // Pro/Team plans: always allowed (unlimited private repos) + // Paid plans: always allowed (unlimited private repos) if (PLANS[plan].maxPrivateRepos === Infinity) { return { allowed: true }; } @@ -232,13 +233,11 @@ export async function canWriteToVault( const { excessVaultIds } = await getPrivateVaultAccess(userId, plan); if (excessVaultIds.has(vaultId)) { + // Only reachable on the free plan — paid plans returned early (unlimited) const limit = PLANS[plan].maxPrivateRepos; - const nextPlan = - plan === "free" ? "Pro" : plan === "pro" ? "Team" : plan === "team" ? "Business" : null; - const upgradeHint = nextPlan ? ` Upgrade to ${nextPlan} to unlock more.` : ""; return { allowed: false, - reason: `You've exceeded your ${plan} plan limit of ${limit} private vault${limit === 1 ? "" : "s"}.${upgradeHint}`, + reason: `You've exceeded your ${plan} plan limit of ${limit} private vault${limit === 1 ? "" : "s"}. Upgrade to Team to unlock more.`, }; } diff --git a/packages/backend/src/services/vault.service.ts b/packages/backend/src/services/vault.service.ts index 3618647..e00dd39 100644 --- a/packages/backend/src/services/vault.service.ts +++ b/packages/backend/src/services/vault.service.ts @@ -95,12 +95,13 @@ async function getExcessPrivateVaultIds(userId: string, plan: UserPlan): Promise return new Set(); } - // Get private vaults ordered by creation date (FIFO - oldest first) + // Get private vaults ordered by creation date (FIFO - oldest first). + // id breaks createdAt ties so the allowed/excess split is deterministic. const privateVaults = await db .select({ id: vaults.id }) .from(vaults) .where(and(eq(vaults.ownerId, userId), eq(vaults.isPrivate, true))) - .orderBy(asc(vaults.createdAt)); + .orderBy(asc(vaults.createdAt), asc(vaults.id)); // Vaults beyond the limit are "excess" (read-only) return new Set(privateVaults.slice(limit).map((v) => v.id)); diff --git a/packages/backend/tests/helpers/mocks.ts b/packages/backend/tests/helpers/mocks.ts index 5209415..82626ca 100644 --- a/packages/backend/tests/helpers/mocks.ts +++ b/packages/backend/tests/helpers/mocks.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; /** * Mock user data for testing - * Note: plan defaults to 'pro' for tests to avoid limit checks + * Note: plan defaults to 'team' for tests to avoid limit checks * Tests that need to verify limit enforcement should explicitly use mockFreeUser */ export const mockUser = { @@ -13,7 +13,7 @@ export const mockUser = { email: 'test@example.com', avatarUrl: 'https://github.com/testuser.png', accessToken: 'gho_testtoken123', - plan: 'pro' as const, + plan: 'team' as const, stripeCustomerId: null, stripeSubscriptionId: null, createdAt: new Date(), @@ -29,6 +29,22 @@ export const mockFreeUser = { plan: 'free' as const, }; +/** + * Mock resolved Stripe prices, shape of getAvailablePrices(). + * Shared by billing.routes and organizations.billing suites so the + * fixture cannot silently diverge between them. + */ +export const mockAvailablePrices = { + team: { + monthly: { id: 'price_team_monthly', amount: 1900, currency: 'eur', interval: 'month' }, + yearly: { id: 'price_team_yearly', amount: 19000, currency: 'eur', interval: 'year' }, + }, + business: { + monthly: { id: 'price_business_monthly', amount: 3900, currency: 'eur', interval: 'month' }, + yearly: { id: 'price_business_yearly', amount: 39000, currency: 'eur', interval: 'year' }, + }, +}; + /** * Mock organization data for testing */ diff --git a/packages/backend/tests/integration/billing.routes.test.ts b/packages/backend/tests/integration/billing.routes.test.ts index 5165935..2115117 100644 --- a/packages/backend/tests/integration/billing.routes.test.ts +++ b/packages/backend/tests/integration/billing.routes.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FastifyInstance } from 'fastify'; import { createTestApp } from '../helpers/testApp'; +import { mockAvailablePrices } from '../helpers/mocks'; // Use vi.hoisted for mocks that need to be available in vi.mock const mockStripeEnabled = vi.hoisted(() => vi.fn()); @@ -64,20 +65,7 @@ describe('Billing Routes', () => { // Default mock implementations mockStripeEnabled.mockReturnValue(true); // getAvailablePrices is async and returns resolved prices (id/amount/currency/interval) - mockGetAvailablePrices.mockResolvedValue({ - pro: { - monthly: { id: 'price_pro_monthly', amount: 900, currency: 'eur', interval: 'month' }, - yearly: { id: 'price_pro_yearly', amount: 9000, currency: 'eur', interval: 'year' }, - }, - team: { - monthly: { id: 'price_team_monthly', amount: 1900, currency: 'eur', interval: 'month' }, - yearly: { id: 'price_team_yearly', amount: 19000, currency: 'eur', interval: 'year' }, - }, - business: { - monthly: { id: 'price_business_monthly', amount: 3900, currency: 'eur', interval: 'month' }, - yearly: { id: 'price_business_yearly', amount: 39000, currency: 'eur', interval: 'year' }, - }, - }); + mockGetAvailablePrices.mockResolvedValue(mockAvailablePrices); mockGetUserSubscription.mockResolvedValue(null); mockCreateCheckoutSession.mockResolvedValue('https://checkout.stripe.com/session/123'); mockCreatePortalSession.mockResolvedValue('https://billing.stripe.com/portal/123'); @@ -120,9 +108,11 @@ describe('Billing Routes', () => { // Verify data contents expect(body.data).toHaveProperty('prices'); - expect(body.data.prices).toHaveProperty('pro'); - expect(body.data.prices.pro).toHaveProperty('monthly'); - expect(body.data.prices.pro).toHaveProperty('yearly'); + expect(body.data.prices).toHaveProperty('team'); + expect(body.data.prices.team).toHaveProperty('monthly'); + expect(body.data.prices.team).toHaveProperty('yearly'); + expect(body.data.prices).toHaveProperty('business'); + expect(body.data.prices).not.toHaveProperty('pro'); }); it('should return 503 when Stripe is disabled', async () => { @@ -213,7 +203,7 @@ describe('Billing Routes', () => { 'content-type': 'application/json', }, payload: { - priceId: 'price_pro_monthly', + priceId: 'price_team_monthly', successUrl: 'https://app.keyway.sh/billing/success', cancelUrl: 'https://app.keyway.sh/billing', }, @@ -264,7 +254,7 @@ describe('Billing Routes', () => { 'content-type': 'application/json', }, payload: { - priceId: 'price_pro_monthly', + priceId: 'price_team_monthly', successUrl: 'not-a-url', cancelUrl: 'https://app.keyway.sh/billing', }, @@ -285,7 +275,7 @@ describe('Billing Routes', () => { 'content-type': 'application/json', }, payload: { - priceId: 'price_pro_monthly', + priceId: 'price_team_monthly', successUrl: 'https://app.keyway.sh/billing/success', cancelUrl: 'https://app.keyway.sh/billing', }, @@ -300,7 +290,7 @@ describe('Billing Routes', () => { githubId: 12345, username: 'testuser', email: 'test@example.com', - plan: 'pro', + plan: 'team', billingStatus: 'active', stripeCustomerId: 'cus_123', }); @@ -329,7 +319,7 @@ describe('Billing Routes', () => { githubId: 12345, username: 'testuser', email: 'test@example.com', - plan: 'pro', + plan: 'team', billingStatus: 'trialing', stripeCustomerId: 'cus_123', }); @@ -379,7 +369,7 @@ describe('Billing Routes', () => { githubId: 12345, username: 'testuser', email: 'test@example.com', - plan: 'pro', + plan: 'team', billingStatus: 'past_due', stripeCustomerId: 'cus_123', }); @@ -391,7 +381,7 @@ describe('Billing Routes', () => { 'content-type': 'application/json', }, payload: { - priceId: 'price_pro_monthly', + priceId: 'price_team_monthly', successUrl: 'https://app.keyway.sh/billing/success', cancelUrl: 'https://app.keyway.sh/billing', }, @@ -410,7 +400,7 @@ describe('Billing Routes', () => { githubId: 12345, username: 'testuser', email: 'test@example.com', - plan: 'pro', + plan: 'team', billingStatus: 'active', stripeCustomerId: 'cus_123', }); diff --git a/packages/backend/tests/integration/environments.test.ts b/packages/backend/tests/integration/environments.test.ts index 263fd7c..3282d4c 100644 --- a/packages/backend/tests/integration/environments.test.ts +++ b/packages/backend/tests/integration/environments.test.ts @@ -31,12 +31,12 @@ vi.mock('../../src/utils/user-lookup', () => ({ getOrThrowUser: vi.fn().mockResolvedValue({ id: 'test-user-id-123', username: 'testuser', - plan: 'pro', + plan: 'team', }), getUserFromVcsUser: vi.fn().mockResolvedValue({ id: 'test-user-id-123', username: 'testuser', - plan: 'pro', + plan: 'team', }), })); diff --git a/packages/backend/tests/integration/integrations.test.ts b/packages/backend/tests/integration/integrations.test.ts index 5a7b9d4..a391102 100644 --- a/packages/backend/tests/integration/integrations.test.ts +++ b/packages/backend/tests/integration/integrations.test.ts @@ -12,7 +12,7 @@ const { mockUser, mockVault, mockConnection, createMockDbWithConnections, create email: 'test@example.com', avatarUrl: 'https://github.com/testuser.png', accessToken: 'gho_testtoken123', - plan: 'pro' as const, + plan: 'team' as const, stripeCustomerId: null, stripeSubscriptionId: null, createdAt: new Date(), diff --git a/packages/backend/tests/integration/organizations.billing.test.ts b/packages/backend/tests/integration/organizations.billing.test.ts index 5ae2423..c1f1fb1 100644 --- a/packages/backend/tests/integration/organizations.billing.test.ts +++ b/packages/backend/tests/integration/organizations.billing.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FastifyInstance } from 'fastify'; import { createTestApp } from '../helpers/testApp'; +import { mockAvailablePrices } from '../helpers/mocks'; // Use vi.hoisted for mocks that need to be available in vi.mock const mockStripeEnabled = vi.hoisted(() => vi.fn()); @@ -170,21 +171,8 @@ describe('Organization Billing Routes', () => { // Default mock implementations mockStripeEnabled.mockReturnValue(true); - // getAvailablePrices is async; orgs subscribe to the Business tier - mockGetAvailablePrices.mockResolvedValue({ - pro: { - monthly: { id: 'price_pro_monthly', amount: 900, currency: 'eur', interval: 'month' }, - yearly: { id: 'price_pro_yearly', amount: 9000, currency: 'eur', interval: 'year' }, - }, - team: { - monthly: { id: 'price_team_monthly', amount: 1900, currency: 'eur', interval: 'month' }, - yearly: { id: 'price_team_yearly', amount: 19000, currency: 'eur', interval: 'year' }, - }, - business: { - monthly: { id: 'price_business_monthly', amount: 3900, currency: 'eur', interval: 'month' }, - yearly: { id: 'price_business_yearly', amount: 39000, currency: 'eur', interval: 'year' }, - }, - }); + // getAvailablePrices is async; shared fixture (see helpers/mocks.ts) + mockGetAvailablePrices.mockResolvedValue(mockAvailablePrices); mockGetOrganizationByLogin.mockResolvedValue(mockOrg); mockGetOrganizationDetails.mockResolvedValue(mockOrgDetails); mockGetOrganizationMembership.mockResolvedValue({ @@ -291,10 +279,10 @@ describe('Organization Billing Routes', () => { const body = JSON.parse(response.body); // Orgs can subscribe to Team or Business; both tiers are returned - expect(body.data.prices.team.monthly.price).toBe(1900); // €19.00 (Team) - expect(body.data.prices.team.yearly.price).toBe(19000); // €190.00 (Team) - expect(body.data.prices.business.monthly.price).toBe(3900); // €39.00 (Business) - expect(body.data.prices.business.yearly.price).toBe(39000); // €390.00 (Business) + expect(body.data.prices.team.monthly.price).toBe(1900); // arbitrary fixture amount + expect(body.data.prices.team.yearly.price).toBe(19000); // arbitrary fixture amount + expect(body.data.prices.business.monthly.price).toBe(3900); // arbitrary fixture amount + expect(body.data.prices.business.yearly.price).toBe(39000); // arbitrary fixture amount }); it('should return null tier prices when Stripe prices not configured', async () => { diff --git a/packages/backend/tests/integration/organizations.privilege-escalation.test.ts b/packages/backend/tests/integration/organizations.privilege-escalation.test.ts index 119739d..776fd73 100644 --- a/packages/backend/tests/integration/organizations.privilege-escalation.test.ts +++ b/packages/backend/tests/integration/organizations.privilege-escalation.test.ts @@ -197,7 +197,7 @@ const bobUser = { username: "bob", email: "bob@example.com", avatarUrl: null, - plan: "pro", + plan: "team", }; function resetAll() { @@ -213,7 +213,7 @@ function resetAll() { trialEndsAt: null, trialConvertedAt: null, }); - mocks.getEffectivePlanWithTrial.mockReturnValue("pro"); + mocks.getEffectivePlanWithTrial.mockReturnValue("team"); mocks.getTrialEligibility.mockReturnValue({ canStart: false }); mocks.checkVaultCreationAllowed.mockResolvedValue({ allowed: true }); mocks.getOrganizationDetails.mockResolvedValue({ id: "org-acme", login: "acme-corp" }); diff --git a/packages/backend/tests/integration/trash.routes.test.ts b/packages/backend/tests/integration/trash.routes.test.ts index 8f0e683..4f80a5d 100644 --- a/packages/backend/tests/integration/trash.routes.test.ts +++ b/packages/backend/tests/integration/trash.routes.test.ts @@ -15,7 +15,7 @@ const { mockUserForLookup } = vi.hoisted(() => ({ email: 'test@example.com', avatarUrl: 'https://github.com/testuser.png', accessToken: 'gho_testtoken123', - plan: 'pro' as const, + plan: 'team' as const, stripeCustomerId: null, stripeSubscriptionId: null, createdAt: new Date(), diff --git a/packages/backend/tests/integration/vaults.routes.test.ts b/packages/backend/tests/integration/vaults.routes.test.ts index ff559c9..166826b 100644 --- a/packages/backend/tests/integration/vaults.routes.test.ts +++ b/packages/backend/tests/integration/vaults.routes.test.ts @@ -14,7 +14,7 @@ const { mockUserForLookup } = vi.hoisted(() => ({ email: 'test@example.com', avatarUrl: 'https://github.com/testuser.png', accessToken: 'gho_testtoken123', - plan: 'pro' as const, + plan: 'team' as const, stripeCustomerId: null, stripeSubscriptionId: null, createdAt: new Date(), diff --git a/packages/backend/tests/planLimits.test.ts b/packages/backend/tests/planLimits.test.ts index 3caf4b2..c042134 100644 --- a/packages/backend/tests/planLimits.test.ts +++ b/packages/backend/tests/planLimits.test.ts @@ -14,39 +14,35 @@ describe('Plan Configuration', () => { it('should define free plan with correct limits', () => { expect(PLANS.free).toEqual({ maxPublicRepos: Infinity, - maxPrivateRepos: 1, + maxPrivateRepos: 10, maxProviders: 2, maxEnvironmentsPerVault: 3, maxSecretsPerPrivateVault: Infinity, }); }); - it('should define pro plan with 10 private repos and unlimited envs', () => { - expect(PLANS.pro.maxPrivateRepos).toBe(10); - expect(PLANS.pro.maxProviders).toBe(Infinity); - expect(PLANS.pro.maxEnvironmentsPerVault).toBe(Infinity); - expect(PLANS.pro.maxSecretsPerPrivateVault).toBe(Infinity); - }); - - it('should define team plan with 20 private repos and unlimited envs', () => { - expect(PLANS.team.maxPrivateRepos).toBe(20); + it('should define team plan with unlimited repos and envs', () => { + expect(PLANS.team.maxPrivateRepos).toBe(Infinity); expect(PLANS.team.maxProviders).toBe(Infinity); expect(PLANS.team.maxEnvironmentsPerVault).toBe(Infinity); expect(PLANS.team.maxSecretsPerPrivateVault).toBe(Infinity); }); - it('should define business plan with 50 private repos and unlimited envs', () => { - expect(PLANS.business.maxPrivateRepos).toBe(50); + it('should define business plan with unlimited repos and envs', () => { + expect(PLANS.business.maxPrivateRepos).toBe(Infinity); expect(PLANS.business.maxProviders).toBe(Infinity); expect(PLANS.business.maxEnvironmentsPerVault).toBe(Infinity); expect(PLANS.business.maxSecretsPerPrivateVault).toBe(Infinity); }); + + it('should not define a pro plan', () => { + expect(Object.keys(PLANS)).toEqual(['free', 'team', 'business']); + }); }); describe('getPlanLimits', () => { it('should return correct limits for each plan', () => { expect(getPlanLimits('free')).toBe(PLANS.free); - expect(getPlanLimits('pro')).toBe(PLANS.pro); expect(getPlanLimits('team')).toBe(PLANS.team); expect(getPlanLimits('business')).toBe(PLANS.business); }); @@ -71,44 +67,36 @@ describe('Plan Limit Checks', () => { expect(result.allowed).toBe(true); }); - it('should allow free plan first private repo', () => { - const result = canCreateRepo('free', 0, 0, true, false); + it('should allow free plan up to 10 private repos', () => { + const result = canCreateRepo('free', 0, 9, true, false); expect(result.allowed).toBe(true); }); - it('should reject free plan second private repo', () => { - const result = canCreateRepo('free', 0, 1, true, false); + it('should reject free plan 11th private repo', () => { + const result = canCreateRepo('free', 0, 10, true, false); expect(result.allowed).toBe(false); - expect(result.reason).toContain('1 private repo'); + expect(result.reason).toContain('10 private repos'); }); it('should allow free plan private org repos within limit', () => { - // Free plan can now create private org repos, but still limited to 1 total private repo const result = canCreateRepo('free', 0, 0, true, true); expect(result.allowed).toBe(true); }); - it('should reject free plan second private org repo', () => { + it('should reject free plan 11th private org repo', () => { // Org repos count toward the same limit as personal repos - const result = canCreateRepo('free', 0, 1, true, true); + const result = canCreateRepo('free', 0, 10, true, true); expect(result.allowed).toBe(false); - expect(result.reason).toContain('1 private repo'); + expect(result.reason).toContain('10 private repos'); }); - it('should allow pro plan up to 10 private repos', () => { - const result = canCreateRepo('pro', 0, 9, true, false); - expect(result.allowed).toBe(true); - }); - - it('should deny pro plan 11th private repo', () => { - const result = canCreateRepo('pro', 0, 10, true, false); - expect(result.allowed).toBe(false); + it('should allow paid plans unlimited private repos', () => { + expect(canCreateRepo('team', 0, 1000, true, false).allowed).toBe(true); + expect(canCreateRepo('business', 0, 1000, true, false).allowed).toBe(true); }); it('should allow all plans to create private org repos within their limits', () => { - // All plans can create private org repos, limited by their maxPrivateRepos expect(canCreateRepo('free', 0, 0, true, true).allowed).toBe(true); - expect(canCreateRepo('pro', 0, 0, true, true).allowed).toBe(true); expect(canCreateRepo('team', 0, 0, true, true).allowed).toBe(true); expect(canCreateRepo('business', 0, 0, true, true).allowed).toBe(true); }); @@ -126,8 +114,8 @@ describe('Plan Limit Checks', () => { expect(result.reason).toContain('2 provider connections'); }); - it('should allow pro plan unlimited providers', () => { - const result = canConnectProvider('pro', 100); + it('should allow team plan unlimited providers', () => { + const result = canConnectProvider('team', 100); expect(result.allowed).toBe(true); }); }); @@ -145,8 +133,8 @@ describe('Plan Limit Checks', () => { expect(result.reason).toContain('3 environments'); }); - it('should allow pro plan unlimited environments', () => { - const result = canCreateEnvironment('pro', 100); + it('should allow team plan unlimited environments', () => { + const result = canCreateEnvironment('team', 100); expect(result.allowed).toBe(true); }); }); @@ -163,8 +151,8 @@ describe('Plan Limit Checks', () => { expect(canCreateSecret('free', 1000, true).allowed).toBe(true); }); - it('should allow pro plan unlimited secrets in private vault', () => { - const result = canCreateSecret('pro', 1000, true); + it('should allow team plan unlimited secrets in private vault', () => { + const result = canCreateSecret('team', 1000, true); expect(result.allowed).toBe(true); }); }); diff --git a/packages/backend/tests/plans.test.ts b/packages/backend/tests/plans.test.ts index a9d3661..afb5cee 100644 --- a/packages/backend/tests/plans.test.ts +++ b/packages/backend/tests/plans.test.ts @@ -5,9 +5,8 @@ import type { UserPlan } from '../src/db/schema'; describe('Plan hierarchy & feature gating', () => { describe('planRank', () => { - it('should order plans free < pro < team < business', () => { - expect(planRank('free')).toBeLessThan(planRank('pro')); - expect(planRank('pro')).toBeLessThan(planRank('team')); + it('should order plans free < team < business', () => { + expect(planRank('free')).toBeLessThan(planRank('team')); expect(planRank('team')).toBeLessThan(planRank('business')); }); }); @@ -17,14 +16,13 @@ describe('Plan hierarchy & feature gating', () => { expect(hasExposureAccess('business')).toBe(true); }); - it('should deny all lower tiers (free, pro, team)', () => { + it('should deny all lower tiers (free, team)', () => { expect(hasExposureAccess('free')).toBe(false); - expect(hasExposureAccess('pro')).toBe(false); expect(hasExposureAccess('team')).toBe(false); }); it('should fail closed for every non-business plan', () => { - const plans: UserPlan[] = ['free', 'pro', 'team', 'business']; + const plans: UserPlan[] = ['free', 'team', 'business']; plans.forEach((plan) => { expect(hasExposureAccess(plan)).toBe(plan === 'business'); }); @@ -34,28 +32,23 @@ describe('Plan hierarchy & feature gating', () => { describe('Plans Configuration', () => { describe('PLANS constant', () => { - it('should define free plan with 1 private repo limit', () => { - expect(PLANS.free.maxPrivateRepos).toBe(1); + it('should define free plan with 10 private repo limit', () => { + expect(PLANS.free.maxPrivateRepos).toBe(10); expect(PLANS.free.maxPublicRepos).toBe(Infinity); }); - it('should define pro plan with 10 private repos', () => { - expect(PLANS.pro.maxPrivateRepos).toBe(10); - expect(PLANS.pro.maxPublicRepos).toBe(Infinity); - }); - - it('should define team plan with 20 private repos', () => { - expect(PLANS.team.maxPrivateRepos).toBe(20); + it('should define team plan with unlimited private repos', () => { + expect(PLANS.team.maxPrivateRepos).toBe(Infinity); expect(PLANS.team.maxPublicRepos).toBe(Infinity); }); - it('should define business plan with 50 private repos', () => { - expect(PLANS.business.maxPrivateRepos).toBe(50); + it('should define business plan with unlimited private repos', () => { + expect(PLANS.business.maxPrivateRepos).toBe(Infinity); expect(PLANS.business.maxPublicRepos).toBe(Infinity); }); it('should have all plan types defined', () => { - const planTypes: UserPlan[] = ['free', 'pro', 'team', 'business']; + const planTypes: UserPlan[] = ['free', 'team', 'business']; planTypes.forEach((plan) => { expect(PLANS[plan]).toBeDefined(); expect(PLANS[plan].maxPublicRepos).toBeDefined(); @@ -67,25 +60,19 @@ describe('Plans Configuration', () => { describe('getPlanLimits', () => { it('should return correct limits for free plan', () => { const limits = getPlanLimits('free'); - expect(limits.maxPrivateRepos).toBe(1); - expect(limits.maxPublicRepos).toBe(Infinity); - }); - - it('should return correct limits for pro plan', () => { - const limits = getPlanLimits('pro'); expect(limits.maxPrivateRepos).toBe(10); expect(limits.maxPublicRepos).toBe(Infinity); }); it('should return correct limits for team plan', () => { const limits = getPlanLimits('team'); - expect(limits.maxPrivateRepos).toBe(20); + expect(limits.maxPrivateRepos).toBe(Infinity); expect(limits.maxPublicRepos).toBe(Infinity); }); it('should return correct limits for business plan', () => { const limits = getPlanLimits('business'); - expect(limits.maxPrivateRepos).toBe(50); + expect(limits.maxPrivateRepos).toBe(Infinity); expect(limits.maxPublicRepos).toBe(Infinity); }); }); @@ -105,21 +92,24 @@ describe('Plans Configuration', () => { describe('canCreateRepo', () => { describe('free plan', () => { - it('should allow first private repo', () => { - const result = canCreateRepo('free', 0, 0, true); - expect(result.allowed).toBe(true); - expect(result.reason).toBeUndefined(); + it('should allow up to 10 private repos', () => { + const result1 = canCreateRepo('free', 0, 0, true); + expect(result1.allowed).toBe(true); + expect(result1.reason).toBeUndefined(); + + const result2 = canCreateRepo('free', 0, 9, true); + expect(result2.allowed).toBe(true); }); - it('should deny second private repo', () => { - const result = canCreateRepo('free', 0, 1, true); + it('should deny 11th private repo', () => { + const result = canCreateRepo('free', 0, 10, true); expect(result.allowed).toBe(false); - expect(result.reason).toContain('free plan allows 1 private repo'); + expect(result.reason).toContain('free plan allows 10 private repos'); expect(result.reason).toContain('Upgrade'); }); - it('should deny third private repo', () => { - const result = canCreateRepo('free', 5, 2, true); + it('should deny 12th private repo', () => { + const result = canCreateRepo('free', 5, 11, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('free plan'); }); @@ -137,50 +127,20 @@ describe('Plans Configuration', () => { }); it('should allow public repos even at private limit', () => { - const result = canCreateRepo('free', 10, 1, false); + const result = canCreateRepo('free', 10, 10, false); expect(result.allowed).toBe(true); }); }); - describe('pro plan', () => { - it('should allow up to 10 private repos', () => { - const result1 = canCreateRepo('pro', 0, 0, true); - expect(result1.allowed).toBe(true); - - const result2 = canCreateRepo('pro', 0, 9, true); - expect(result2.allowed).toBe(true); - }); - - it('should deny 11th private repo', () => { - const result = canCreateRepo('pro', 0, 10, true); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('pro plan allows 10 private repos'); - }); - - it('should allow unlimited public repos', () => { - const result1 = canCreateRepo('pro', 0, 0, false); - expect(result1.allowed).toBe(true); - - const result2 = canCreateRepo('pro', 100, 0, false); - expect(result2.allowed).toBe(true); - }); - }); - describe('team plan', () => { - it('should allow up to 20 private repos', () => { + it('should allow unlimited private repos', () => { const result1 = canCreateRepo('team', 0, 0, true); expect(result1.allowed).toBe(true); - const result2 = canCreateRepo('team', 0, 19, true); + const result2 = canCreateRepo('team', 0, 1000, true); expect(result2.allowed).toBe(true); }); - it('should deny 21st private repo', () => { - const result = canCreateRepo('team', 0, 20, true); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('team plan allows 20 private repos'); - }); - it('should allow unlimited public repos', () => { const result1 = canCreateRepo('team', 0, 0, false); expect(result1.allowed).toBe(true); @@ -191,20 +151,14 @@ describe('Plans Configuration', () => { }); describe('business plan', () => { - it('should allow up to 50 private repos', () => { + it('should allow unlimited private repos', () => { const result1 = canCreateRepo('business', 0, 0, true); expect(result1.allowed).toBe(true); - const result2 = canCreateRepo('business', 0, 49, true); + const result2 = canCreateRepo('business', 0, 1000, true); expect(result2.allowed).toBe(true); }); - it('should deny 51st private repo', () => { - const result = canCreateRepo('business', 0, 50, true); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('business plan allows 50 private repos'); - }); - it('should allow unlimited public repos', () => { const result1 = canCreateRepo('business', 0, 0, false); expect(result1.allowed).toBe(true); @@ -216,8 +170,8 @@ describe('Plans Configuration', () => { describe('edge cases', () => { it('should handle exactly at limit', () => { - // At 1 private repo, should deny - const result = canCreateRepo('free', 0, 1, true); + // At 10 private repos, should deny + const result = canCreateRepo('free', 0, 10, true); expect(result.allowed).toBe(false); }); @@ -231,18 +185,18 @@ describe('Plans Configuration', () => { describe('Plan limit error messages', () => { it('should provide upgrade guidance in denial reason', () => { - const result = canCreateRepo('free', 0, 1, true); + const result = canCreateRepo('free', 0, 10, true); expect(result.reason).toContain('Upgrade'); }); it('should mention the plan name in denial reason', () => { - const result = canCreateRepo('free', 0, 1, true); + const result = canCreateRepo('free', 0, 10, true); expect(result.reason).toContain('free'); }); it('should mention the limit in denial reason', () => { - const result = canCreateRepo('free', 0, 1, true); - expect(result.reason).toContain('1 private repo'); + const result = canCreateRepo('free', 0, 10, true); + expect(result.reason).toContain('10 private repos'); }); }); @@ -285,7 +239,7 @@ describe('PlanLimitError (RFC 7807)', () => { }); it('should work with canCreateRepo denial', () => { - const result = canCreateRepo('free', 0, 1, true); + const result = canCreateRepo('free', 0, 10, true); if (!result.allowed) { const error = new PlanLimitError(result.reason!); expect(error.detail).toContain('free plan'); diff --git a/packages/backend/tests/services/vault.service.test.ts b/packages/backend/tests/services/vault.service.test.ts index a935d31..8284ba0 100644 --- a/packages/backend/tests/services/vault.service.test.ts +++ b/packages/backend/tests/services/vault.service.test.ts @@ -86,7 +86,7 @@ describe('VaultService', () => { }), }); - const result = await getVaultsForUser(mockUser.id, mockUser.username, 'pro'); + const result = await getVaultsForUser(mockUser.id, mockUser.username, 'team'); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ @@ -109,6 +109,37 @@ describe('VaultService', () => { expect(result[0].syncs[0].provider).toBe('vercel'); }); + it('should flag excess private vaults as read-only (plan_limit_exceeded)', async () => { + const privateVault = { + ...mockVault, + isPrivate: true, + orgId: null, + secrets: [], + vaultSyncs: [], + }; + + (db.query.vaults.findMany as any).mockResolvedValue([privateVault]); + // 11 private vaults ordered oldest-first: the listed vault is the 11th, + // beyond the free plan's 10-vault limit → excess → read-only + const orderedIds = [ + ...Array.from({ length: 10 }, (_, i) => ({ id: `older-vault-${i + 1}` })), + { id: privateVault.id }, + ]; + (db.select as any).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(orderedIds), + }), + }), + }); + + const result = await getVaultsForUser(mockUser.id, mockUser.username, 'free'); + + expect(result).toHaveLength(1); + expect(result[0].isReadOnly).toBe(true); + expect(result[0].readonlyReason).toBe('plan_limit_exceeded'); + }); + it('should use default environments for legacy vaults', async () => { const legacyVault = { ...mockVault, @@ -129,7 +160,7 @@ describe('VaultService', () => { }), }); - const result = await getVaultsForUser(mockUser.id, mockUser.username, 'pro'); + const result = await getVaultsForUser(mockUser.id, mockUser.username, 'team'); // Default environments - string array for backwards compatibility expect(result[0].environments).toHaveLength(3); @@ -191,7 +222,7 @@ describe('VaultService', () => { }), }); - const result = await getVaultsForUser(mockUser.id, mockUser.username, 'pro'); + const result = await getVaultsForUser(mockUser.id, mockUser.username, 'team'); expect(result).toEqual([]); }); @@ -222,7 +253,7 @@ describe('VaultService', () => { }), }); - const result = await getVaultByRepo('testuser/test-repo', mockUser.username, 'pro'); + const result = await getVaultByRepo('testuser/test-repo', mockUser.username, 'team'); expect(result.hasAccess).toBe(true); expect(result.vault).toMatchObject({ @@ -240,7 +271,7 @@ describe('VaultService', () => { it('should return no access for non-existent vault', async () => { (db.query.vaults.findFirst as any).mockResolvedValue(null); - const result = await getVaultByRepo('unknown/repo', mockUser.username, 'pro'); + const result = await getVaultByRepo('unknown/repo', mockUser.username, 'team'); expect(result.hasAccess).toBe(false); }); @@ -258,7 +289,7 @@ describe('VaultService', () => { (db.query.vaults.findFirst as any).mockResolvedValue(vaultWithRelations); - const result = await getVaultByRepo('testuser/test-repo', 'stranger', 'pro'); + const result = await getVaultByRepo('testuser/test-repo', 'stranger', 'team'); expect(result.hasAccess).toBe(false); }); @@ -284,7 +315,7 @@ describe('VaultService', () => { }), }); - const result = await getVaultByRepo('testuser/test-repo', mockUser.username, 'pro'); + const result = await getVaultByRepo('testuser/test-repo', mockUser.username, 'team'); expect(result.vault.secretCount).toBe(2); }); @@ -381,7 +412,7 @@ describe('VaultService', () => { }), }); - const result = await getVaultByRepo(renamedRepo, mockUser.username, 'pro'); + const result = await getVaultByRepo(renamedRepo, mockUser.username, 'team'); expect(result.hasAccess).toBe(true); expect(result.vault).toBeDefined(); @@ -390,7 +421,7 @@ describe('VaultService', () => { it('should return no access when both lookups fail', async () => { (db.query.vaults.findFirst as any).mockResolvedValue(null); - const result = await getVaultByRepo('nonexistent/repo', mockUser.username, 'pro'); + const result = await getVaultByRepo('nonexistent/repo', mockUser.username, 'team'); expect(result.hasAccess).toBe(false); }); diff --git a/packages/backend/tests/softLimits.test.ts b/packages/backend/tests/softLimits.test.ts index 7c1d6fa..ac23929 100644 --- a/packages/backend/tests/softLimits.test.ts +++ b/packages/backend/tests/softLimits.test.ts @@ -1,301 +1,148 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PLANS } from '../src/config/plans'; -import type { UserPlan } from '../src/db/schema'; /** - * Soft limits unit tests for the vault read-only feature. - * These tests verify the business logic without database access. + * Soft limits tests for the vault read-only feature. + * Exercises the REAL getPrivateVaultAccess / canWriteToVault from + * usage.service.ts with a mocked db, so regressions in the production + * logic (not a simulation of it) fail the suite. */ -describe('Soft Limits - Business Logic', () => { - describe('Plan limits configuration', () => { - it('free plan should have maxPrivateRepos = 1', () => { - expect(PLANS.free.maxPrivateRepos).toBe(1); - }); - - it('pro plan should have maxPrivateRepos = 10', () => { - expect(PLANS.pro.maxPrivateRepos).toBe(10); - }); - - it('team plan should have maxPrivateRepos = 20', () => { - expect(PLANS.team.maxPrivateRepos).toBe(20); - }); - - it('business plan should have maxPrivateRepos = 50', () => { - expect(PLANS.business.maxPrivateRepos).toBe(50); - }); - }); - describe('FIFO vault ordering logic', () => { - // Simulate the logic from getPrivateVaultAccess - function computeVaultAccess( - vaults: { id: string; createdAt: Date }[], - plan: UserPlan - ): { allowedIds: Set; excessIds: Set } { - const limit = PLANS[plan].maxPrivateRepos; - - if (limit === Infinity) { - return { allowedIds: new Set(), excessIds: new Set() }; - } - - // Sort by createdAt (oldest first) - const sorted = [...vaults].sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime() - ); - - const allowedIds = new Set(sorted.slice(0, limit).map((v) => v.id)); - const excessIds = new Set(sorted.slice(limit).map((v) => v.id)); - - return { allowedIds, excessIds }; - } - - it('should allow up to 5 vaults for pro plan', () => { - const vaults = [ - { id: 'v1', createdAt: new Date('2024-01-01') }, - { id: 'v2', createdAt: new Date('2024-02-01') }, - { id: 'v3', createdAt: new Date('2024-03-01') }, - ]; - - const result = computeVaultAccess(vaults, 'pro'); - - // Pro plan allows 5 private repos, so 3 vaults should all be allowed - expect(result.allowedIds.size).toBe(3); - expect(result.excessIds.size).toBe(0); - }); - - it('should allow oldest vault for free plan with 1 vault', () => { - const vaults = [{ id: 'v1', createdAt: new Date('2024-01-01') }]; - - const result = computeVaultAccess(vaults, 'free'); - - expect(result.allowedIds.has('v1')).toBe(true); - expect(result.excessIds.size).toBe(0); - }); - - it('should mark only oldest vault as allowed for free plan with 2 vaults', () => { - const vaults = [ - { id: 'v2', createdAt: new Date('2024-02-01') }, // newer - { id: 'v1', createdAt: new Date('2024-01-01') }, // older (should be allowed) - ]; - - const result = computeVaultAccess(vaults, 'free'); - - expect(result.allowedIds.has('v1')).toBe(true); - expect(result.allowedIds.has('v2')).toBe(false); - expect(result.excessIds.has('v2')).toBe(true); - expect(result.excessIds.has('v1')).toBe(false); - }); - - it('should mark multiple excess vaults correctly', () => { - const vaults = [ - { id: 'v3', createdAt: new Date('2024-03-01') }, - { id: 'v1', createdAt: new Date('2024-01-01') }, // oldest - allowed - { id: 'v4', createdAt: new Date('2024-04-01') }, - { id: 'v2', createdAt: new Date('2024-02-01') }, - ]; - - const result = computeVaultAccess(vaults, 'free'); - - expect(result.allowedIds.size).toBe(1); - expect(result.allowedIds.has('v1')).toBe(true); - expect(result.excessIds.size).toBe(3); - expect(result.excessIds.has('v2')).toBe(true); - expect(result.excessIds.has('v3')).toBe(true); - expect(result.excessIds.has('v4')).toBe(true); - }); - - it('should handle empty vault list', () => { - const result = computeVaultAccess([], 'free'); - - expect(result.allowedIds.size).toBe(0); - expect(result.excessIds.size).toBe(0); - }); +// db.select(...).from(...).where(...).orderBy(...) resolves with mockVaultRows +const mockOrderBy = vi.hoisted(() => vi.fn()); +const mockGetEffectivePlanForVault = vi.hoisted(() => vi.fn()); + +vi.mock('../src/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: mockOrderBy, + })), + })), + })), + }, + vaults: { id: 'id', ownerId: 'ownerId', isPrivate: 'isPrivate', createdAt: 'createdAt' }, + usageMetrics: { userId: 'userId' }, + providerConnections: { userId: 'userId' }, +})); + +vi.mock('../src/services/organization.service', () => ({ + getEffectivePlanForVault: mockGetEffectivePlanForVault, +})); + +// Import after mocks +import { getPrivateVaultAccess, canWriteToVault } from '../src/services/usage.service'; + +// The db mock returns rows already ordered by createdAt ASC (oldest first), +// matching the real query's ORDER BY. +function vaultRows(count: number): { id: string }[] { + return Array.from({ length: count }, (_, i) => ({ id: `v${i + 1}` })); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetEffectivePlanForVault.mockResolvedValue('free'); +}); + +describe('getPrivateVaultAccess', () => { + it('should skip the db entirely for unlimited plans (team)', async () => { + const result = await getPrivateVaultAccess('user-1', 'team'); + + expect(result.allowedVaultIds.size).toBe(0); + expect(result.excessVaultIds.size).toBe(0); + expect(mockOrderBy).not.toHaveBeenCalled(); }); - describe('canWriteToVault logic', () => { - // Simulate the logic from canWriteToVault - function checkWritePermission( - plan: UserPlan, - isPrivate: boolean, - vaultId: string, - excessVaultIds: Set - ): { allowed: boolean; reason?: string } { - // Public vaults: always allowed - if (!isPrivate) { - return { allowed: true }; - } - - // Pro/Team plans: always allowed (unlimited private repos) - if (PLANS[plan].maxPrivateRepos === Infinity) { - return { allowed: true }; - } - - // Free plan with private vault: check if it's within limit - if (excessVaultIds.has(vaultId)) { - return { - allowed: false, - reason: - 'This private vault is read-only on the Free plan. Upgrade to Pro to unlock editing.', - }; - } - - return { allowed: true }; - } - - describe('public vaults', () => { - it('should always allow writes to public vaults on free plan', () => { - const result = checkWritePermission('free', false, 'v1', new Set(['v1'])); - expect(result.allowed).toBe(true); - }); - - it('should always allow writes to public vaults on pro plan', () => { - const result = checkWritePermission('pro', false, 'v1', new Set()); - expect(result.allowed).toBe(true); - }); - }); - - describe('private vaults - pro/team/business plans', () => { - it('should allow writes to private vault within pro plan limit', () => { - // v1 is not in excess set, so it should be allowed - const result = checkWritePermission('pro', true, 'v1', new Set()); - expect(result.allowed).toBe(true); - }); - - it('should deny writes to excess private vault on pro plan', () => { - // v6 is the 6th vault, exceeding the 5 vault limit for pro - const result = checkWritePermission('pro', true, 'v6', new Set(['v6'])); - expect(result.allowed).toBe(false); - }); - - it('should allow writes to private vault within team plan limit', () => { - const result = checkWritePermission('team', true, 'v1', new Set()); - expect(result.allowed).toBe(true); - }); - - it('should allow writes to any private vault on business plan', () => { - // Business has 40 repos, testing with vault within limit - const result = checkWritePermission('business', true, 'v1', new Set()); - expect(result.allowed).toBe(true); - }); - }); - - describe('private vaults - free plan', () => { - it('should allow writes to vault within limit', () => { - const result = checkWritePermission('free', true, 'v1', new Set(['v2', 'v3'])); - expect(result.allowed).toBe(true); - }); - - it('should deny writes to excess vault', () => { - const excessVaultIds = new Set(['v2', 'v3']); - const result = checkWritePermission('free', true, 'v2', excessVaultIds); - - expect(result.allowed).toBe(false); - expect(result.reason).toContain('read-only'); - expect(result.reason).toContain('Free plan'); - expect(result.reason).toContain('Upgrade to Pro'); - }); - - it('should deny writes to all excess vaults', () => { - const excessVaultIds = new Set(['v2', 'v3', 'v4']); - - expect(checkWritePermission('free', true, 'v2', excessVaultIds).allowed).toBe(false); - expect(checkWritePermission('free', true, 'v3', excessVaultIds).allowed).toBe(false); - expect(checkWritePermission('free', true, 'v4', excessVaultIds).allowed).toBe(false); - }); - }); + it('should allow all vaults for free plan within the 10-vault limit', async () => { + mockOrderBy.mockResolvedValue(vaultRows(10)); + + const result = await getPrivateVaultAccess('user-1', 'free'); + + expect(result.allowedVaultIds.size).toBe(10); + expect(result.excessVaultIds.size).toBe(0); }); - describe('isReadOnly field logic', () => { - // Simulate the isReadOnly calculation from vault.service.ts - function computeIsReadOnly( - vaultId: string, - isPrivate: boolean, - excessVaultIds: Set - ): boolean { - if (!isPrivate) return false; - return excessVaultIds.has(vaultId); - } - - it('should return false for public vaults', () => { - expect(computeIsReadOnly('v1', false, new Set(['v1']))).toBe(false); - }); - - it('should return false for private vault within limit', () => { - expect(computeIsReadOnly('v1', true, new Set(['v2']))).toBe(false); - }); - - it('should return true for private vault exceeding limit', () => { - expect(computeIsReadOnly('v2', true, new Set(['v2', 'v3']))).toBe(true); - }); + it('should mark only the newest vaults as excess for free plan beyond 10', async () => { + mockOrderBy.mockResolvedValue(vaultRows(13)); + + const result = await getPrivateVaultAccess('user-1', 'free'); + + expect(result.allowedVaultIds.size).toBe(10); + expect(result.allowedVaultIds.has('v1')).toBe(true); + expect(result.allowedVaultIds.has('v10')).toBe(true); + expect(result.excessVaultIds.size).toBe(3); + expect(result.excessVaultIds.has('v11')).toBe(true); + expect(result.excessVaultIds.has('v12')).toBe(true); + expect(result.excessVaultIds.has('v13')).toBe(true); }); - describe('Edge cases', () => { - it('should handle vault created at exact same time (stable sort)', () => { - const sameTime = new Date('2024-01-01T00:00:00Z'); - const vaults = [ - { id: 'v1', createdAt: sameTime }, - { id: 'v2', createdAt: sameTime }, - ]; - - // Sort should be stable - first one in array should remain first - const sorted = [...vaults].sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime() - ); - - // In JavaScript, sort is stable as of ES2019 - expect(sorted[0].id).toBe('v1'); - }); - - it('should handle plan upgrade scenario (all vaults become writable)', () => { - const excessOnFree = new Set(['v2', 'v3']); - - // On free plan, v2 and v3 are read-only - expect(excessOnFree.has('v2')).toBe(true); - - // On pro plan, excessVaultIds would be empty - const excessOnPro = new Set(); - expect(excessOnPro.has('v2')).toBe(false); - }); - - it('should handle plan downgrade scenario (excess vaults become read-only)', () => { - // User with 3 private vaults downgrades from pro to free - const vaults = [ - { id: 'v1', createdAt: new Date('2024-01-01') }, - { id: 'v2', createdAt: new Date('2024-02-01') }, - { id: 'v3', createdAt: new Date('2024-03-01') }, - ]; - - const limit = PLANS.free.maxPrivateRepos; // 1 - const sorted = [...vaults].sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime() - ); - const excessIds = new Set(sorted.slice(limit).map((v) => v.id)); - - // Only oldest vault (v1) remains writable - expect(excessIds.has('v1')).toBe(false); - expect(excessIds.has('v2')).toBe(true); - expect(excessIds.has('v3')).toBe(true); - }); + it('should handle empty vault list', async () => { + mockOrderBy.mockResolvedValue([]); + + const result = await getPrivateVaultAccess('user-1', 'free'); + + expect(result.allowedVaultIds.size).toBe(0); + expect(result.excessVaultIds.size).toBe(0); }); }); -describe('Soft Limits - Error Messages', () => { - it('should include upgrade guidance in error message', () => { - const reason = - 'This private vault is read-only on the Free plan. Upgrade to Pro to unlock editing.'; +describe('canWriteToVault', () => { + it('should always allow writes to public vaults without touching plans', async () => { + const result = await canWriteToVault('user-1', 'free', 'v1', false); + + expect(result.allowed).toBe(true); + expect(mockGetEffectivePlanForVault).not.toHaveBeenCalled(); + }); + + it('should allow writes to any private vault on team plan', async () => { + mockGetEffectivePlanForVault.mockResolvedValue('team'); + + const result = await canWriteToVault('user-1', 'team', 'v99', true); + + expect(result.allowed).toBe(true); + expect(mockOrderBy).not.toHaveBeenCalled(); + }); + + it('should allow writes to any private vault on business plan', async () => { + mockGetEffectivePlanForVault.mockResolvedValue('business'); + + const result = await canWriteToVault('user-1', 'business', 'v99', true); + + expect(result.allowed).toBe(true); + }); + + it("should use the org's effective plan when it beats the user plan", async () => { + // Free user writing to a vault owned by a Team org: org plan wins + mockGetEffectivePlanForVault.mockResolvedValue('team'); + + const result = await canWriteToVault('user-1', 'free', 'v42', true); + + expect(result.allowed).toBe(true); + expect(mockOrderBy).not.toHaveBeenCalled(); + }); + + it('should allow writes to a free-plan vault within the limit', async () => { + mockOrderBy.mockResolvedValue(vaultRows(12)); + + const result = await canWriteToVault('user-1', 'free', 'v3', true); + + expect(result.allowed).toBe(true); + }); + + it('should deny writes to an excess free-plan vault with an actionable reason', async () => { + mockOrderBy.mockResolvedValue(vaultRows(12)); + + const result = await canWriteToVault('user-1', 'free', 'v11', true); - expect(reason).toContain('Upgrade to Pro'); - expect(reason).toContain('read-only'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('free plan limit of 10 private vaults'); + expect(result.reason).toContain('Upgrade to Team'); }); - it('should be user-friendly and actionable', () => { - const reason = - 'This private vault is read-only on the Free plan. Upgrade to Pro to unlock editing.'; + it('should deny writes to every excess vault', async () => { + mockOrderBy.mockResolvedValue(vaultRows(12)); - // Should explain the situation - expect(reason).toContain('read-only'); - // Should mention the plan - expect(reason).toContain('Free plan'); - // Should provide action - expect(reason).toContain('Upgrade'); + expect((await canWriteToVault('user-1', 'free', 'v11', true)).allowed).toBe(false); + expect((await canWriteToVault('user-1', 'free', 'v12', true)).allowed).toBe(false); }); });