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
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 60 additions & 0 deletions docs/adr/0002-flat-tier-pricing.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions packages/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
```
Expand Down
38 changes: 38 additions & 0 deletions packages/backend/drizzle/0044_drop_pro_plan_flat_tiers.sql
Original file line number Diff line number Diff line change
@@ -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";
9 changes: 8 additions & 1 deletion packages/backend/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
}
6 changes: 0 additions & 6 deletions packages/backend/src/api/v1/routes/billing.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 11 additions & 20 deletions packages/backend/src/config/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserPlan, PlanLimits> = {
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,
Expand All @@ -60,9 +52,8 @@ export const PLANS: Record<UserPlan, PlanLimits> = {

const PLAN_RANK: Record<UserPlan, number> = {
free: 0,
pro: 1,
team: 2,
business: 3,
team: 1,
business: 2,
};

export function planRank(plan: UserPlan): number {
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
45 changes: 12 additions & 33 deletions packages/backend/src/services/billing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, UserPlan> = {
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<string, UserPlan> = 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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
*/
Expand All @@ -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));
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/services/trial.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export async function expireTrial(input: ExpireTrialInput): Promise<StartTrialRe
* - If trial is expired (not converted) -> 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;
Expand Down
Loading
Loading