From de294bc715498d70cc343b38643e61e659f4c577 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:49:50 +0100 Subject: [PATCH 01/10] docs: add v2.0 self-hosting transformation roadmap Document the complete vision for LynxPrompt v2.0: pivot from SaaS to self-hostable platform with feature toggles, database consolidation, Stripe platform commission model, and federated interconnect plan. --- docs/ROADMAP.md | 253 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 184 insertions(+), 69 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ca1c5f..5b0d977 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,167 @@ This document tracks planned features, improvements, and business decisions for LynxPrompt. +--- + +## πŸ”₯ v2.0 β€” Self-Hostable Platform Transformation (February 2026) + +### Vision + +LynxPrompt v2.0 pivots from a SaaS product to a **self-hostable platform** that companies can deploy on their own premises. The core product becomes open and deployable, with monetization shifting to marketplace commission and optional services. + +Companies deploy their own instance to manage AI IDE configurations (AGENTS.md, .cursor/rules/, etc.) internally. All features are available to all users β€” no tier gating. + +### Key Decisions (Confirmed) + +#### 1. Remove Pricing & Teams Subscription Tier +- Delete the `/pricing` page entirely +- Remove `SubscriptionPlan` enum and all tier-gating logic +- All features (AI, SSO, wizard, API) available to everyone +- Keep `Team` model for organizational grouping (useful for companies) +- Remove `TeamBillingRecord`, Stripe subscription fields from Team +- Remove all "Upgrade to Teams" CTAs from web UI, CLI, and docs + +#### 2. Stripe: Optional with Platform Commission +- `ENABLE_STRIPE=false` by default β€” when disabled, all blueprints are free +- When enabled, **the default Stripe account is LynxPrompt's (hardcoded)** +- Any deployment that enables paid marketplace blueprints routes payments through LynxPrompt's Stripe account β€” LynxPrompt earns the 30% platform commission +- Companies that want their own Stripe account must provide their own keys explicitly +- This is the monetization model for the open-source platform: free to deploy, LynxPrompt earns from marketplace transactions across all federated instances + +#### 3. Remove GlitchTip (Error Tracking) +- Remove all GlitchTip-specific code and hardcoded DSNs +- Make Sentry integration optional β€” works if `SENTRY_DSN` is set, skip entirely if not +- Delete GlitchTip infrastructure (containers, Caddy entry, DNS record, gitea repo) +- Keep `@sentry/nextjs` as optional for companies that want their own Sentry/GlitchTip + +#### 4. Remove ClickHouse (Analytics) +- Remove all ClickHouse code (analytics lib, API routes, env vars, docker-compose service) +- ClickHouse was used for trending templates, search stats, wizard funnel +- Keep ClickHouse as a wizard database option (it's a valid DB users might configure) + +#### 5. Feature Toggles via Environment Variables +All features configurable via env vars for maximum deployment flexibility: + +**Auth:** +- `ENABLE_GITHUB_OAUTH=false` β€” show/hide GitHub login +- `ENABLE_GOOGLE_OAUTH=false` β€” show/hide Google login +- `ENABLE_EMAIL_AUTH=true` β€” magic link / email login +- `ENABLE_PASSKEYS=true` β€” WebAuthn passkeys +- `ENABLE_TURNSTILE=false` β€” Cloudflare Turnstile CAPTCHA +- `ENABLE_SSO=false` β€” SAML/OIDC/LDAP (promoted from Teams-only to first-class) +- `ENABLE_USER_REGISTRATION=true` β€” set false for invite-only instances + +**AI:** +- `ENABLE_AI=false` β€” master toggle for all AI features (editing, wizard assistant) +- `ANTHROPIC_API_KEY` β€” required when AI enabled +- `AI_MODEL=claude-3-5-haiku-latest` β€” configurable model + +**Content:** +- `ENABLE_BLOG=false` β€” blog nav item and routes +- `ENABLE_SUPPORT_FORUM=false` β€” support forum nav item and routes + +**Marketplace:** +- `ENABLE_STRIPE=false` β€” paid blueprints and Stripe checkout + +**Analytics:** +- `UMAMI_SCRIPT_URL` β€” configurable Umami script URL +- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` β€” Umami website ID (already exists) + +**Branding:** +- `APP_NAME=LynxPrompt` β€” app title in header, emails, meta tags +- `APP_URL=http://localhost:3000` β€” base URL +- `APP_LOGO_URL` β€” custom logo URL + +#### 6. Database Consolidation +- **Default**: single PostgreSQL database (all 4 Prisma schemas share one DB) +- **Advanced**: users can split into separate databases via different `DATABASE_URL_*` vars +- Remove Percona pg_tde from development docker-compose β€” use standard `postgres:18-alpine` everywhere +- Keep Percona pg_tde only in production/dev-server docker-compose (gitea) where it's already deployed +- New `docker-compose.selfhost.yml`: 1 Postgres + 1 LynxPrompt container + +#### 7. Dynamic CSP Headers +- Build Content-Security-Policy in `proxy.ts` based on enabled services +- Only include Umami, Turnstile, Sentry domains when those services are configured +- Cleaner security headers for minimal deployments + +#### 8. Hardcoded URL Audit +- Replace all `lynxprompt.com` hardcoded references with `APP_URL` env var +- Affects: email templates, fallback URLs, image references, API fallbacks, structured data + +#### 9. Health Check Enhancement +- `/api/health` checks actual DB connectivity, not just returns 200 +- Critical for container orchestration and monitoring + +#### 10. Auto-Migration on Startup +- `entrypoint.sh` runs `prisma migrate deploy` for all schemas on container start +- Idempotent β€” safe for every restart +- Companies don't need to run migrations manually + +### Federated Interconnect (Planned v2.x) + +A decentralized blueprint sharing network across LynxPrompt instances. + +**Concept:** +- Each instance opts in via `ENABLE_FEDERATION=true` (default `true`) +- Instances register themselves in a central registry (GitHub Gist or lightweight discovery service) +- Each instance publishes its public blueprints via a standardized API +- When browsing the marketplace, users see blueprints from all federated instances +- Each blueprint shows its origin domain (e.g., "from lynxprompt.com", "from acme-corp.internal") +- Results are lazy-loaded to keep the marketplace responsive + +**Architecture:** +- Central registry: a JSON file (Gist or static endpoint) listing participating instances + - Each instance writes its URL, name, and public API endpoint + - Registry is polled periodically by each instance +- Federation API: `/api/v1/federation/blueprints` β€” returns public blueprints +- Blueprint metadata includes `origin_domain`, `origin_instance_name` +- Rate limiting and API key exchange for security +- Instance verification (domain ownership check) + +**Stripe Integration with Federation:** +- When a user purchases a paid blueprint from a remote federated instance, the payment routes through the origin instance's Stripe (which defaults to LynxPrompt's account) +- This means LynxPrompt earns commission on all marketplace transactions across the entire federation + +**Testing Plan:** +- Use prod (lynxprompt.com) and dev (dev.lynxprompt.com) as the first two federated instances +- Dev instance should show prod blueprints with "from lynxprompt.com" label +- Validate lazy loading, search across instances, and cross-instance purchases + +**Implementation Phases:** +1. Define federation API schema and protocol +2. Build the central registry mechanism +3. Implement federation client (fetching remote blueprints) +4. UI for federated blueprints (origin badges, lazy loading) +5. Cross-instance purchasing via Stripe +6. Admin controls (allowlist/blocklist federated instances) + +### Documentation Changes for v2.0 + +- Delete: pricing page, pricing docs, billing FAQ +- Rewrite: AI features docs (remove "Teams-only" language) +- Rewrite: marketplace selling docs (Stripe optional) +- Add: self-hosting guide with env var reference +- Add: `docker-compose.selfhost.yml` quick start +- Rewrite: README.md (self-hostable platform positioning) +- Update: CLI docs (configurable server URL for self-hosted) + +### Infrastructure Changes for v2.0 + +- Delete GlitchTip stack from watchtower (containers + gitea repo + Caddy entry + DNS) +- Update prod docker-compose (gitea/watchtower/lynxprompt/) with all new feature flags enabled +- Update dev docker-compose (gitea/geiserback/lynxprompt-dev/) with all new feature flags enabled +- Bump image tag to `drumsergio/lynxprompt:2.0.0` +- Remove Sentry DSN env vars from prod/dev docker-compose + +### Version + +- Web app: 2.0.0 +- CLI: 2.0.0 +- Single PR: `feat/v2.0-self-hosting` β†’ `main` +- Preservation branch: `sergio-before-internationalization` (captures pre-v2.0 state) + +--- + ## 🏒 Business & Legal Foundation ### Entity & Operator @@ -61,7 +222,7 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF - [x] GDPR Article 6 legal basis (Contract + Legitimate Interest) - [x] Physical address disclosure - [x] "No DPO appointed" statement -- [x] Third-party processors detailed (GitHub, Google, Stripe, Umami, Anthropic, GlitchTip) +- [x] Third-party processors detailed (GitHub, Google, Stripe, Umami, Anthropic, ~~GlitchTip~~ removed in v2.0) - [x] Umami: self-hosted in EU, cookieless, legitimate interest basis - [x] International transfers + SCCs - [x] No automated decision-making statement @@ -126,6 +287,7 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF - [x] Project scaffolding with Next.js 15, React 19, TypeScript - [x] PostgreSQL database with Prisma ORM (dual-database architecture) +- [x] ClickHouse for analytics (self-hosted EU) - [x] Umami analytics (self-hosted EU, cookieless) - [x] Authentication with NextAuth.js (GitHub, Google, Magic Link, Passkeys) - [x] Homepage with platform carousel @@ -295,23 +457,11 @@ Based on GitHub's recommended agents, offer one-click presets: | `@api-agent` | Builds API endpoints | `npm run dev`, `curl` tests | Modify routes, ask before schema changes | | `@deploy-agent` | Handles dev deployments | `npm run build`, `docker build` | Only deploy to dev, require approval | -#### Wizard Tiers (Feature Gating) βœ… IMPLEMENTED - -| Feature | Free | Pro | Max | -| -------------------------------------- | ---- | --- | --- | -| Basic wizard steps | βœ… | βœ… | βœ… | -| Intermediate wizard steps | ❌ | βœ… | βœ… | -| Advanced wizard steps | ❌ | ❌ | βœ… | -| All community blueprints (including paid) | ❌ | ❌ | βœ… | +#### ~~Wizard Tiers (Feature Gating)~~ β€” REMOVED in v2.0 -**Wizard Step Tiers (Updated):** -- **Basic** (Free): Project Info, Tech Stack, Platforms, Generate -- **Intermediate** (Pro): + Repository, Release Strategy, Commands -- **Advanced** (Max): + Persona, Code Style, Boundaries, Agent Presets +> **v2.0 Change:** All wizard steps are available to all users. No tier gating. -**Admin Privileges:** -- ADMIN and SUPERADMIN roles automatically receive MAX tier (no payment required) -- Displayed as "Admin" badge in billing section +All wizard steps (Basic, Intermediate, Advanced) are accessible to everyone. #### User Dashboard @@ -436,7 +586,7 @@ When downloading, user sees: #### Template Analytics -- [ ] Track template downloads/usage +- [ ] Track template downloads/usage ~~(ClickHouse)~~ (alternative TBD post-v2.0) - [ ] Show trending templates - [ ] Usage statistics for template authors - [ ] Revenue reports for paid templates @@ -445,58 +595,22 @@ When downloading, user sees: ## πŸ’° Monetization Strategy -### Subscription Tiers - -| Tier | Monthly | Annual (10% off) | Features | -| --------- | -------------- | ---------------- | -------------------------------------------------------------- | -| **Free** | €0/month | €0/year | Basic templates, limited wizard features | -| **Pro** | €5/month | €54/year | Intermediate repo wizards, priority support | -| **Max** | €20/month | €216/year | Advanced wizards + ALL community prompts (including paid ones) | -| **Teams** | €10/seat/month | €108/seat/year | Everything in Max + team features, SSO, centralized billing | +### ~~Subscription Tiers~~ β€” REMOVED in v2.0 -#### Key Subscription Rules +> **v2.0 Change:** All subscription tiers (Free/Pro/Max/Teams) have been removed. All features are available to all users. The monetization model is now marketplace commission (see v2.0 section above). -- **Users (free)**: Full wizard access, all platforms, API, sell blueprints -- **Teams**: Everything in Users + AI features, SSO, team-shared blueprints +Previously: -#### Billing Intervals +| Tier | Status | +|------|--------| +| Free | Now the only tier β€” all features included | +| Pro | Removed | +| Max | Removed | +| Teams | Removed (Team org model kept for grouping, billing removed) | -- **Monthly**: Can be canceled anytime. Access continues until end of billing period. -- **Annual**: 10% discount. Cannot be canceled mid-cycle (yearly commitment). Access continues until year ends. +### ~~Teams Tier~~ β€” REMOVED in v2.0 -### Teams Tier Details βœ… IMPLEMENTED - -| Setting | Value | -|---------|-------| -| Price | €10/seat/month | -| Minimum seats | 3 | -| Maximum seats | Unlimited | -| Color | Teal/Cyan gradient | -| AI usage limit | €5/user/month | - -#### Teams Features - -- **Team-shared blueprints**: Share blueprints privately within your team -- **Blueprint visibility**: Private, Team, or Public options -- **SSO authentication**: SAML 2.0, OpenID Connect, LDAP/Active Directory -- **Centralized billing**: One admin pays for all seats -- **Active user billing**: Only pay for users who logged in during the billing period -- **Roles**: ADMIN (full control) and MEMBER (team access) -- **Multiple admins**: Teams can have multiple administrators -- **Pro-rated billing**: Adding seats mid-cycle charges prorated amount -- **Credits**: Unused seats generate credits for next cycle - -#### Teams Billing Logic - -``` -Monthly Bill = €10 Γ— MAX(active_users, 3) - -Where: -- active_users = users who logged in during the billing period -- Minimum 3 seats always billed (even if only 2 active) -- Mid-cycle additions: (€10 / 30 days) Γ— days_remaining Γ— new_seats -- Credits: (billed_seats - active_seats) Γ— €10 β†’ next cycle -``` +> Team management (members, invitations, SSO) is kept as an organizational feature but is no longer a paid tier. SSO is promoted to a first-class feature available to all instances via `ENABLE_SSO` env var. ### Template Marketplace Pricing @@ -733,7 +847,7 @@ POST /api/generate - Generate config files from wizard data - [ ] Redis for caching/sessions - [ ] S3/R2 for file storage (template assets, user uploads) -- [x] GlitchTip error tracking (self-hosted at glitchtip.lynxprompt.com) +- [x] ~~GlitchTip error tracking~~ β†’ **Removed in v2.0** (Sentry optional via env var) - [x] Status page (Uptime Kuma) at status.lynxprompt.com - [ ] CDN for static assets - [ ] Database backups automation @@ -741,8 +855,9 @@ POST /api/generate - Generate config files from wizard data ### Current Infrastructure -- [x] PostgreSQL (4 databases: app, users, blog, support) -- [x] Umami (self-hosted EU, cookieless analytics) +- [x] PostgreSQL (4 databases: app, users, blog, support) β€” **v2.0: single DB default, multi-DB optional** +- [x] ~~ClickHouse (self-hosted EU, analytics)~~ β†’ **Removed in v2.0** +- [x] Umami (self-hosted EU, cookieless analytics) β€” **v2.0: configurable via env var** - [x] Docker deployment with GitOps (Portainer) - [x] Cloudflare DDoS protection and WAF - [x] TLS 1.3 encryption in transit @@ -758,7 +873,7 @@ POST /api/generate - Generate config files from wizard data - [ ] Annual third-party penetration test - [ ] Bug bounty program (HackerOne or similar) -> **Note:** GlitchTip is preferred over Sentry for self-hosted error tracking. It keeps all data in EU. +> **Note (v2.0):** GlitchTip and ClickHouse have been removed. Error tracking is optional via generic Sentry DSN env var. Companies can point to their own Sentry/GlitchTip instance if desired. --- @@ -1477,7 +1592,7 @@ This enables: - Multi-language support (i18n) - only when user base justifies - **Cryptocurrency payments (Bitcoin, Ethereum, USDC) via Coinbase Commerce** - Custom integrations (Slack, Teams notifications) -- White-label solutions for enterprise +- ~~White-label solutions for enterprise~~ β†’ **Partially addressed in v2.0** (custom branding via `APP_NAME`, `APP_LOGO_URL` env vars) ### Completed Ideas βœ… - ~~Annual subscription discount~~ β†’ 10% discount (~1.2 months free) From 748b7e42ea9b86e900692eee2a40ca2647229925 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:50:50 +0100 Subject: [PATCH 02/10] refactor: make Sentry optional, remove hardcoded GlitchTip DSN - Sentry only initializes when SENTRY_DSN / NEXT_PUBLIC_SENTRY_DSN is set - Remove hardcoded GlitchTip fallback DSN from client provider - Remove glitchtip.lynxprompt.com from CSP connect-src - Keep @sentry/nextjs as optional dependency for self-hosted Sentry --- sentry.edge.config.ts | 25 ++---- sentry.server.config.ts | 91 +++++++++----------- src/components/providers/sentry-provider.tsx | 20 +---- src/proxy.ts | 2 +- 4 files changed, 50 insertions(+), 88 deletions(-) diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 765b145..2359b33 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -1,19 +1,10 @@ -// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; -Sentry.init({ - // GlitchTip DSN - configured via environment variable - dsn: process.env.SENTRY_DSN, - - // Only enable in production - enabled: process.env.NODE_ENV === "production", - - // Performance Monitoring - adjust based on traffic - tracesSampleRate: 0.1, // 10% of transactions - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); - +if (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + enabled: true, + tracesSampleRate: 0.1, + debug: false, + }); +} diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 9ae6bb5..9f2e330 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -1,54 +1,41 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever the server handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; -Sentry.init({ - // GlitchTip DSN - configured via environment variable - dsn: process.env.SENTRY_DSN, - - // Only enable in production - enabled: process.env.NODE_ENV === "production", - - // Performance Monitoring - adjust based on traffic - tracesSampleRate: 0.1, // 10% of transactions - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Filter out sensitive data from error reports - beforeSend(event) { - // Remove potentially sensitive headers - if (event.request?.headers) { - const sensitiveHeaders = [ - "authorization", - "cookie", - "x-api-key", - "x-auth-token", - ]; - sensitiveHeaders.forEach((header) => { - if (event.request?.headers?.[header]) { - event.request.headers[header] = "[Filtered]"; - } - }); - } - - // Remove potentially sensitive data from breadcrumbs - if (event.breadcrumbs) { - event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => { - if ( - breadcrumb.data && - typeof breadcrumb.data === "object" && - "password" in breadcrumb.data - ) { - breadcrumb.data.password = "[Filtered]"; - } - return breadcrumb; - }); - } - - return event; - }, -}); - +if (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + enabled: true, + tracesSampleRate: 0.1, + debug: false, + + beforeSend(event) { + if (event.request?.headers) { + const sensitiveHeaders = [ + "authorization", + "cookie", + "x-api-key", + "x-auth-token", + ]; + sensitiveHeaders.forEach((header) => { + if (event.request?.headers?.[header]) { + event.request.headers[header] = "[Filtered]"; + } + }); + } + + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => { + if ( + breadcrumb.data && + typeof breadcrumb.data === "object" && + "password" in breadcrumb.data + ) { + breadcrumb.data.password = "[Filtered]"; + } + return breadcrumb; + }); + } + + return event; + }, + }); +} diff --git a/src/components/providers/sentry-provider.tsx b/src/components/providers/sentry-provider.tsx index fac3fde..1b7e200 100644 --- a/src/components/providers/sentry-provider.tsx +++ b/src/components/providers/sentry-provider.tsx @@ -3,30 +3,21 @@ import { useEffect } from "react"; import * as Sentry from "@sentry/nextjs"; -// GlitchTip DSN - hardcoded for client-side (NEXT_PUBLIC_* vars are build-time only) -const SENTRY_DSN = - process.env.NEXT_PUBLIC_SENTRY_DSN || - "https://84ae8dcc6c4b4028ade54aea25172088@glitchtip.lynxprompt.com/1"; +const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; let initialized = false; export function SentryProvider({ children }: { children: React.ReactNode }) { useEffect(() => { - if (initialized) return; + if (initialized || !SENTRY_DSN) return; initialized = true; Sentry.init({ dsn: SENTRY_DSN, - - // Performance Monitoring - 10% of transactions tracesSampleRate: 0.1, - - // Debug mode - set to true to troubleshoot debug: false, - // Filter out sensitive data beforeSend(event) { - // Remove potentially sensitive query parameters if (event.request?.query_string) { const sensitiveParams = [ "token", @@ -40,7 +31,6 @@ export function SentryProvider({ children }: { children: React.ReactNode }) { event.request.query_string = params.toString(); } - // Remove cookies from error reports if (event.request?.cookies) { delete event.request.cookies; } @@ -48,13 +38,7 @@ export function SentryProvider({ children }: { children: React.ReactNode }) { return event; }, }); - - // Expose Sentry globally for console testing - if (typeof window !== "undefined") { - (window as typeof window & { Sentry: typeof Sentry }).Sentry = Sentry; - } }, []); return <>{children}; } - diff --git a/src/proxy.ts b/src/proxy.ts index 84895e8..90bf043 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -89,7 +89,7 @@ function addSecurityHeaders(response: NextResponse): NextResponse { "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://*.gravatar.com https://gravatar.com", "font-src 'self' data:", - "connect-src 'self' https://umami.lynxprompt.com https://challenges.cloudflare.com https://glitchtip.lynxprompt.com https://cloudflareinsights.com", // Umami + Turnstile + GlitchTip + CF Insights + "connect-src 'self' https://umami.lynxprompt.com https://challenges.cloudflare.com https://cloudflareinsights.com", "frame-src 'self' https://challenges.cloudflare.com", // Turnstile iframe "frame-ancestors 'none'", "base-uri 'self'", From 7677fa85ef2f4971ca2b1a656f188f015d8b99c1 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:03:30 +0100 Subject: [PATCH 03/10] feat: remove pricing page, Teams billing, and subscription tier gating - Delete /pricing page and remove from navigation - Remove TeamBillingRecord model and Stripe subscription fields from schema - Simplify subscription.ts: all features available to everyone - Strip Stripe subscription code, keep marketplace blueprint purchases - Remove paid blueprint creation restriction (was Teams-only) - Remove AI access subscription check (will be env-var gated) - Remove Teams billing API (keep team org management) - Remove pricing/upgrade CTAs from blog, support, blueprints pages - Remove JSON-LD offers and Team references from homepage --- prisma/schema-users.prisma | 64 +- src/app/api/ai/edit-blueprint/route.ts | 17 +- src/app/api/billing/change-plan/route.ts | 87 --- src/app/api/billing/checkout/route.ts | 158 +---- src/app/api/billing/portal/route.ts | 55 +- src/app/api/billing/status/route.ts | 85 +-- src/app/api/billing/webhook/route.ts | 416 +------------ src/app/api/blueprints/route.ts | 10 - src/app/api/teams/[teamId]/billing/route.ts | 438 +------------- src/app/api/teams/route.ts | 92 +-- src/app/blog/[slug]/page.tsx | 3 - src/app/blog/admin/[slug]/edit/page.tsx | 3 - src/app/blog/admin/new/page.tsx | 3 - src/app/blog/admin/page.tsx | 3 - src/app/blueprints/[id]/edit/page.tsx | 2 +- src/app/blueprints/create/page.tsx | 2 +- src/app/docs/ai-features/page.tsx | 2 +- src/app/docs/faq/billing/page.tsx | 6 +- src/app/docs/marketplace/page.tsx | 4 +- src/app/docs/marketplace/pricing/page.tsx | 4 +- src/app/page.tsx | 9 - src/app/pricing/layout.tsx | 112 ---- src/app/pricing/page.tsx | 640 -------------------- src/app/settings/page.tsx | 10 +- src/app/sitemap.ts | 6 - src/app/support/[id]/page.tsx | 3 - src/app/teams/page.tsx | 3 - src/components/page-header.tsx | 1 - src/lib/stripe.ts | 76 +-- src/lib/subscription.ts | 133 +--- 30 files changed, 100 insertions(+), 2347 deletions(-) delete mode 100644 src/app/api/billing/change-plan/route.ts delete mode 100644 src/app/pricing/layout.tsx delete mode 100644 src/app/pricing/page.tsx diff --git a/prisma/schema-users.prisma b/prisma/schema-users.prisma index 814eed6..9c16b4f 100644 --- a/prisma/schema-users.prisma +++ b/prisma/schema-users.prisma @@ -92,16 +92,10 @@ model User { socialMastodon String? // Mastodon handle (e.g., @user@instance.social) socialDiscord String? // Discord username - // Subscription & Billing (Stripe) - stripeCustomerId String? @unique // Stripe Customer ID - stripeSubscriptionId String? // Stripe Subscription ID + // Subscription (billing removed - all users get full access) subscriptionPlan SubscriptionPlan @default(FREE) - subscriptionStatus String? // active, canceled, past_due, unpaid, trialing - subscriptionInterval String? // monthly or annual - currentPeriodEnd DateTime? // When current billing period ends - cancelAtPeriodEnd Boolean @default(false) - // AI Usage Tracking (for MAX users) + // AI Usage Tracking aiTokensUsedThisPeriod Int @default(0) // Total tokens used this billing period aiUsageResetAt DateTime? // When to reset token count (aligned with billing period) aiLastRequestAt DateTime? // For rate limiting @@ -116,7 +110,7 @@ model User { // Seller Payout Settings paypalEmail String? // PayPal email for receiving payouts - // Activity tracking (for Teams billing - track last login) + // Activity tracking lastLoginAt DateTime? // Last time user logged in (updated on session creation) // Relations @@ -139,8 +133,6 @@ model User { @@index([email]) @@index([role]) - @@index([stripeCustomerId]) - @@index([subscriptionPlan]) @@index([lastLoginAt]) } @@ -151,10 +143,8 @@ enum UserRole { } enum SubscriptionPlan { - FREE // Standard user - all features except AI - TEAMS // Teams user - all features including AI, SSO, team sharing - // DEPRECATED: PRO and MAX no longer used - all users get full wizard access - // Keeping enum values would require database migration, so we just use FREE for all non-Teams users + FREE // All users - everyone gets full access + TEAMS // Kept for DB backwards compatibility (no longer used for gating) } // ============================================================================ @@ -191,15 +181,8 @@ model Team { slug String @unique // URL-friendly identifier (e.g., "acme-corp") logo String? // Team logo URL (uploaded image) - // Billing - handled by team admin(s) - stripeCustomerId String? @unique // Stripe Customer ID for the team - stripeSubscriptionId String? // Stripe Subscription ID (per-seat) - subscriptionInterval String? // monthly or annual - billingCycleStart DateTime? // When current billing cycle started - maxSeats Int @default(3) // Prepaid seats (minimum 3) - - // AI Usage Settings (Teams get higher limits) - aiUsageLimitPerUser Int @default(500) // €5 = 500 cents max AI spend per user/month + // Organization limits + maxSeats Int @default(50) // Max members in the team createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -208,12 +191,10 @@ model Team { members TeamMember[] invitations TeamInvitation[] ssoConfig TeamSSOConfig? - billingRecords TeamBillingRecord[] blueprints UserTemplate[] // Team-shared blueprints purchases BlueprintPurchase[] // Templates purchased by team members (shared with all) @@index([slug]) - @@index([stripeCustomerId]) } model TeamMember { @@ -289,37 +270,6 @@ model TeamSSOConfig { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) } -model TeamBillingRecord { - id String @id @default(cuid()) - teamId String - - // Billing period - periodStart DateTime - periodEnd DateTime - - // Seat counts for this period - totalSeats Int // Max seats configured - activeSeats Int // Users who logged in during period - billedSeats Int // MAX(activeSeats, 3) - what was actually charged - - // Financial - amountBilled Int // Total amount in cents (billedSeats Γ— €10) - currency String @default("EUR") - stripeInvoiceId String? // Reference to Stripe invoice - - // Credits (for unused seats from previous cycle) - creditApplied Int @default(0) // Credit in cents applied to this invoice - creditGenerated Int @default(0) // Credit generated for next cycle (if active < billed) - - createdAt DateTime @default(now()) - - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - - @@index([teamId]) - @@index([periodStart]) - @@index([periodEnd]) -} - // ============================================================================ // PASSKEYS (WebAuthn Authenticators) // ============================================================================ diff --git a/src/app/api/ai/edit-blueprint/route.ts b/src/app/api/ai/edit-blueprint/route.ts index 7a450a9..f5aa400 100644 --- a/src/app/api/ai/edit-blueprint/route.ts +++ b/src/app/api/ai/edit-blueprint/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { authenticateRequest, isTeams } from "@/lib/api-auth"; +import { authenticateRequest } from "@/lib/api-auth"; import { prismaUsers } from "@/lib/db-users"; import Anthropic from "@anthropic-ai/sdk"; @@ -64,17 +64,13 @@ export async function POST(request: Request) { ); } - // Check if user is Teams subscriber (AI features are Teams-only) const user = await prismaUsers.user.findUnique({ where: { id: auth.user.id }, select: { - subscriptionPlan: true, - role: true, aiTokensUsedThisPeriod: true, aiUsageResetAt: true, aiLastRequestAt: true, aiRequestsThisMinute: true, - currentPeriodEnd: true, }, }); @@ -82,13 +78,6 @@ export async function POST(request: Request) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - if (!isTeams(auth.user)) { - return NextResponse.json( - { error: "AI editing is only available for Teams subscribers" }, - { status: 403 } - ); - } - // Check rate limiting const now = new Date(); const lastRequest = user.aiLastRequestAt; @@ -116,7 +105,7 @@ export async function POST(request: Request) { // Check usage reset (aligned with billing period) let tokensUsed = user.aiTokensUsedThisPeriod; - const resetAt = user.aiUsageResetAt || user.currentPeriodEnd; + const resetAt = user.aiUsageResetAt; if (resetAt && now > resetAt) { // Reset usage at billing period end @@ -254,7 +243,7 @@ export async function POST(request: Request) { aiTokensUsedThisPeriod: tokensUsed + costUnits, aiLastRequestAt: now, aiRequestsThisMinute: requestsThisMinute, - aiUsageResetAt: user.currentPeriodEnd || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // Default: 30 days + aiUsageResetAt: user.aiUsageResetAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), }, }); diff --git a/src/app/api/billing/change-plan/route.ts b/src/app/api/billing/change-plan/route.ts deleted file mode 100644 index 3e3ab04..0000000 --- a/src/app/api/billing/change-plan/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe"; -import { prismaUsers } from "@/lib/db-users"; - -/** - * Change subscription plan (DEPRECATED) - * - * This endpoint was used for upgrading/downgrading between Pro and Max plans. - * Since January 2026, we only have Users (free) and Teams plans, so plan - * changes are no longer supported via API. Users should contact support. - */ -export async function POST() { - // Plan changes are no longer supported as we only have Users (free) and Teams - // This endpoint is kept for backwards compatibility but will always return an error - return NextResponse.json( - { error: "Plan changes are no longer supported. Please contact support to modify your subscription." }, - { status: 400 } - ); -} - -/** - * GET: Check current plan and any pending changes - */ -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } - - const user = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { - stripeSubscriptionId: true, - subscriptionPlan: true, - subscriptionStatus: true, - currentPeriodEnd: true, - cancelAtPeriodEnd: true, - }, - }); - - if (!user?.stripeSubscriptionId) { - return NextResponse.json({ - currentPlan: user?.subscriptionPlan?.toLowerCase() || "free", - status: user?.subscriptionStatus || null, - pendingChange: null, - }); - } - - const stripe = ensureStripe(); - const subscriptionResponse = await stripe.subscriptions.retrieve(user.stripeSubscriptionId); - const subscription = subscriptionResponse as unknown as { - status: string; - current_period_end: number; - items: { data: Array<{ price: { id: string } }> }; - metadata?: Record; - cancel_at_period_end: boolean; - }; - - // Check for scheduled changes - const scheduledDowngrade = subscription.metadata?.scheduledDowngrade; - const currentPriceId = subscription.items.data[0]?.price?.id; - const currentPlan = currentPriceId ? getPlanFromPriceId(currentPriceId) : "free"; - const interval = currentPriceId ? getIntervalFromPriceId(currentPriceId) : "monthly"; - - return NextResponse.json({ - currentPlan, - interval, - status: subscription.status, - currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(), - cancelAtPeriodEnd: subscription.cancel_at_period_end, - pendingChange: scheduledDowngrade || null, - }); - } catch (error) { - console.error("Error getting subscription info:", error); - return NextResponse.json( - { error: "Failed to get subscription info" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts index 45dd206..5bc5f9c 100644 --- a/src/app/api/billing/checkout/route.ts +++ b/src/app/api/billing/checkout/route.ts @@ -1,148 +1,12 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { ensureStripe, getPriceIdForPlan, type SubscriptionPlan, type BillingInterval } from "@/lib/stripe"; -import { prismaUsers } from "@/lib/db-users"; - -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id || !session.user.email) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } - - const { plan, interval, euDigitalContentConsent } = (await request.json()) as { - plan: SubscriptionPlan; - interval?: BillingInterval; - euDigitalContentConsent?: boolean; - }; - - // Default to monthly if not specified - const billingInterval: BillingInterval = interval === "annual" ? "annual" : "monthly"; - - if (!plan || plan !== "teams") { - return NextResponse.json( - { error: "Invalid plan selected. Only Teams plan is available for subscription." }, - { status: 400 } - ); - } - - // EU Digital Content Directive compliance - // User must consent to immediate access and waive 14-day withdrawal right - if (!euDigitalContentConsent) { - return NextResponse.json( - { error: "You must consent to immediate access and waive your withdrawal right to proceed." }, - { status: 400 } - ); - } - - const priceId = getPriceIdForPlan(plan, billingInterval); - if (!priceId) { - return NextResponse.json( - { error: `Stripe price not configured for ${plan} ${billingInterval} plan` }, - { status: 500 } - ); - } - - const stripe = ensureStripe(); - - // Get or create Stripe customer - const user = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { - stripeCustomerId: true, - stripeSubscriptionId: true, - subscriptionStatus: true, - email: true, - name: true - }, - }); - - // If user already has an active subscription, they should use plan change API - if (user?.stripeSubscriptionId && - (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing")) { - return NextResponse.json( - { - error: "You already have an active subscription. Use the change plan option instead.", - hasActiveSubscription: true, - redirectTo: "/api/billing/change-plan" - }, - { status: 400 } - ); - } - - let customerId = user?.stripeCustomerId; - - if (!customerId) { - // Create new Stripe customer - const customer = await stripe.customers.create({ - email: session.user.email, - name: user?.name || undefined, - metadata: { - userId: session.user.id, - }, - }); - customerId = customer.id; - - // Save customer ID to database - await prismaUsers.user.update({ - where: { id: session.user.id }, - data: { stripeCustomerId: customerId }, - }); - } - - // Create Stripe Checkout session - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - success_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing&success=true`, - cancel_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing&canceled=true`, - metadata: { - userId: session.user.id, - plan: plan, - interval: billingInterval, - // EU Digital Content Directive consent tracking - euDigitalContentConsent: "true", - consentTimestamp: new Date().toISOString(), - }, - subscription_data: { - metadata: { - userId: session.user.id, - plan: plan, - interval: billingInterval, - euDigitalContentConsent: "true", - consentTimestamp: new Date().toISOString(), - }, - }, - // Allow promotion codes - allow_promotion_codes: true, - // Collect billing address for VAT - billing_address_collection: "required", - // Automatic tax calculation (if configured) - automatic_tax: { enabled: false }, // Enable when Stripe Tax is configured - }); - - return NextResponse.json({ url: checkoutSession.url }); - } catch (error) { - console.error("Error creating checkout session:", error); - return NextResponse.json( - { error: "Failed to create checkout session" }, - { status: 500 } - ); - } +import { NextResponse } from "next/server"; + +/** + * Subscription checkout has been removed. + * This endpoint is kept for future marketplace checkout if needed. + */ +export async function POST() { + return NextResponse.json( + { error: "Subscription billing is no longer available. All features are free for all users." }, + { status: 410 } + ); } - - - - diff --git a/src/app/api/billing/portal/route.ts b/src/app/api/billing/portal/route.ts index d9b023b..764752f 100644 --- a/src/app/api/billing/portal/route.ts +++ b/src/app/api/billing/portal/route.ts @@ -1,50 +1,13 @@ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { ensureStripe } from "@/lib/stripe"; -import { prismaUsers } from "@/lib/db-users"; +/** + * Stripe Customer Portal for marketplace customers. + * Subscription billing has been removed, but marketplace customers + * may still need to manage their payment methods. + */ export async function POST() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } - - const user = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { stripeCustomerId: true }, - }); - - if (!user?.stripeCustomerId) { - return NextResponse.json( - { error: "No billing account found" }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Create Stripe Customer Portal session - const portalSession = await stripe.billingPortal.sessions.create({ - customer: user.stripeCustomerId, - return_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing`, - }); - - return NextResponse.json({ url: portalSession.url }); - } catch (error) { - console.error("Error creating portal session:", error); - return NextResponse.json( - { error: "Failed to create portal session" }, - { status: 500 } - ); - } + return NextResponse.json( + { error: "Billing portal is no longer available." }, + { status: 410 } + ); } - - - - diff --git a/src/app/api/billing/status/route.ts b/src/app/api/billing/status/route.ts index 5d1e1d9..9c4ef7d 100644 --- a/src/app/api/billing/status/route.ts +++ b/src/app/api/billing/status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe"; export async function GET() { try { @@ -19,13 +18,6 @@ export async function GET() { where: { id: session.user.id }, select: { role: true, - subscriptionPlan: true, - subscriptionStatus: true, - subscriptionInterval: true, - currentPeriodEnd: true, - cancelAtPeriodEnd: true, - stripeCustomerId: true, - stripeSubscriptionId: true, teamMemberships: { include: { team: { @@ -34,8 +26,6 @@ export async function GET() { name: true, slug: true, logo: true, - stripeSubscriptionId: true, - billingCycleStart: true, }, }, }, @@ -50,69 +40,22 @@ export async function GET() { ); } - // Check if user is part of a team const teamMembership = user.teamMemberships[0]; - const isTeamsUser = user.subscriptionPlan === "TEAMS" || !!teamMembership; - - // Admins and Superadmins get Teams tier features for free const isAdmin = user.role === "ADMIN" || user.role === "SUPERADMIN"; - - // Determine effective plan - let effectivePlan: string; - if (isAdmin) { - effectivePlan = "teams"; - } else if (isTeamsUser) { - effectivePlan = "teams"; - } else { - effectivePlan = user.subscriptionPlan.toLowerCase(); - } - - // Check for pending changes if user has active subscription - let pendingChange: string | null = null; - let actualCurrentPlan = effectivePlan; - let billingInterval: "monthly" | "annual" = (user.subscriptionInterval as "monthly" | "annual") || "monthly"; - - // For non-Teams, non-Admin users with Stripe subscription - if (!isAdmin && !isTeamsUser && user.stripeSubscriptionId) { - try { - const stripe = ensureStripe(); - const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId); - - // Get the currently active plan and interval from Stripe - const currentPriceId = subscription.items.data[0]?.price?.id; - if (currentPriceId) { - actualCurrentPlan = getPlanFromPriceId(currentPriceId); - billingInterval = getIntervalFromPriceId(currentPriceId); - } - - // Check for scheduled downgrade in metadata - if (subscription.metadata?.scheduledDowngrade) { - pendingChange = subscription.metadata.scheduledDowngrade; - } - } catch (stripeError) { - // If we can't reach Stripe, just use the DB values - console.error("Error fetching subscription from Stripe:", stripeError); - } - } - - // Teams users are considered active if they're part of a team (billing is handled at team level) - const hasActiveSubscription = isAdmin || isTeamsUser || (!!user.stripeSubscriptionId && - (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing")); return NextResponse.json({ - plan: actualCurrentPlan, - interval: billingInterval, - status: isAdmin || isTeamsUser ? "active" : user.subscriptionStatus, - currentPeriodEnd: isAdmin ? null : (isTeamsUser ? teamMembership?.team?.billingCycleStart : user.currentPeriodEnd), - cancelAtPeriodEnd: isAdmin || isTeamsUser ? false : user.cancelAtPeriodEnd, - hasStripeAccount: !!user.stripeCustomerId, - hasActiveSubscription, - isAdmin, // Flag for UI to show "Admin" badge instead of plan - isTeamsUser, // Flag for UI to show "Teams" badge - pendingChange, // For showing scheduled downgrades - isAnnual: billingInterval === "annual", // Convenience flag for UI - // Teams-specific data - team: isTeamsUser && teamMembership ? { + plan: "free", + interval: "monthly", + status: "active", + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + hasStripeAccount: false, + hasActiveSubscription: true, + isAdmin, + isTeamsUser: !!teamMembership, + pendingChange: null, + isAnnual: false, + team: teamMembership ? { id: teamMembership.team.id, name: teamMembership.team.name, slug: teamMembership.team.slug, @@ -128,7 +71,3 @@ export async function GET() { ); } } - - - - diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts index 350524a..e237563 100644 --- a/src/app/api/billing/webhook/route.ts +++ b/src/app/api/billing/webhook/route.ts @@ -1,7 +1,7 @@ import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe"; +import { ensureStripe } from "@/lib/stripe"; import { prismaUsers } from "@/lib/db-users"; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -43,51 +43,12 @@ export async function POST(request: NextRequest) { switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; - // Handle blueprint purchases (one-time payments) if (session.metadata?.type === "blueprint_purchase") { await handleBlueprintPurchase(session); - } else { - // Handle subscription checkout - await handleCheckoutCompleted(session); } break; } - case "customer.subscription.created": - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - // Check if this is a Teams subscription (has teamId in metadata) - if (subscription.metadata?.teamId) { - await handleTeamsSubscriptionChange(subscription); - } else { - await handleSubscriptionChange(subscription); - } - break; - } - - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - // Check if this is a Teams subscription - if (subscription.metadata?.teamId) { - await handleTeamsSubscriptionDeleted(subscription); - } else { - await handleSubscriptionDeleted(subscription); - } - break; - } - - case "invoice.payment_failed": { - const invoice = event.data.object as Stripe.Invoice; - await handlePaymentFailed(invoice); - break; - } - - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - await handlePaymentSucceeded(invoice); - break; - } - default: console.log(`Unhandled event type: ${event.type}`); } @@ -102,272 +63,11 @@ export async function POST(request: NextRequest) { } } -async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { - // Check if this is a Teams checkout - if (session.metadata?.type === "teams") { - await handleTeamsCheckoutCompleted(session); - return; - } - - const userId = session.metadata?.userId; - if (!userId) { - console.error("No userId in checkout session metadata"); - return; - } - - // Subscription will be updated by subscription.created/updated webhook - console.log(`Checkout completed for user ${userId}`); -} - -async function handleTeamsCheckoutCompleted(session: Stripe.Checkout.Session) { - const { teamName, teamSlug, creatorUserId } = session.metadata || {}; - const subscriptionId = session.subscription as string; - const customerId = session.customer as string; - - if (!teamName || !teamSlug || !creatorUserId) { - console.error("Missing team metadata in checkout session", session.metadata); - return; - } - - // Check if team already exists (in case of duplicate webhook) - const existingTeam = await prismaUsers.team.findUnique({ - where: { slug: teamSlug }, - }); - - if (existingTeam) { - console.log(`Team ${teamSlug} already exists, skipping creation`); - return; - } - - // Get subscription details for billing info - const stripe = ensureStripe(); - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - // Get the interval from the subscription - const interval = subscription.items.data[0]?.plan?.interval === "year" ? "annual" : "monthly"; - - // Create the team - const team = await prismaUsers.team.create({ - data: { - name: teamName, - slug: teamSlug, - stripeCustomerId: customerId, - stripeSubscriptionId: subscriptionId, - subscriptionInterval: interval, - maxSeats: subscription.items.data[0]?.quantity || 3, - billingCycleStart: new Date(), - members: { - create: { - userId: creatorUserId, - role: "ADMIN", - isActiveThisCycle: true, - lastActiveAt: new Date(), - }, - }, - }, - }); - - // Update the subscription metadata with the team ID - await stripe.subscriptions.update(subscriptionId, { - metadata: { - teamId: team.id, - teamSlug: team.slug, - }, - }); - - // Update the creator's subscription plan to TEAMS - await prismaUsers.user.update({ - where: { id: creatorUserId }, - data: { - subscriptionPlan: "TEAMS", - }, - }); - - console.log(`Team "${teamName}" created successfully with ID ${team.id}`); -} - -async function handleSubscriptionChange(subscription: Stripe.Subscription) { - const userId = subscription.metadata?.userId; - const customerId = subscription.customer as string; - - // Find user by customer ID or metadata - const user = await prismaUsers.user.findFirst({ - where: userId ? { id: userId } : { stripeCustomerId: customerId }, - }); - - if (!user) { - console.error(`User not found for subscription ${subscription.id}`); - return; - } - - // Get the plan and interval from the first subscription item - const priceId = subscription.items.data[0]?.price?.id; - const plan = priceId ? getPlanFromPriceId(priceId) : "free"; - const interval = priceId ? getIntervalFromPriceId(priceId) : "monthly"; - - // Map subscription status - // Note: PRO and MAX are legacy - new subscriptions only have FREE or TEAMS - // Legacy PRO/MAX subscriptions are treated as FREE (full wizard access now) - type SubscriptionPlan = "FREE" | "TEAMS"; - const planMap: Record = { - free: "FREE", - pro: "FREE", // Legacy - map to FREE (they get full wizard access anyway) - max: "FREE", // Legacy - map to FREE (they get full wizard access anyway) - teams: "TEAMS", - }; - - // Get current period end - cast to access the property - const sub = subscription as unknown as { current_period_end?: number }; - const currentPeriodEnd = sub.current_period_end; - - // Determine effective plan based on subscription status - // If subscription is not active/trialing, user should be on FREE - const isActiveSubscription = - subscription.status === "active" || - subscription.status === "trialing"; - - const effectivePlan: SubscriptionPlan = isActiveSubscription - ? (planMap[plan] || "FREE") - : "FREE"; - - await prismaUsers.user.update({ - where: { id: user.id }, - data: { - stripeSubscriptionId: subscription.id, - stripeCustomerId: customerId, - subscriptionPlan: effectivePlan, - subscriptionStatus: subscription.status, - subscriptionInterval: interval, - currentPeriodEnd: currentPeriodEnd - ? new Date(currentPeriodEnd * 1000) - : null, - cancelAtPeriodEnd: subscription.cancel_at_period_end, - }, - }); - - console.log(`Updated subscription for user ${user.id}: ${effectivePlan} ${interval} (status: ${subscription.status})`); -} - -async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { - const customerId = subscription.customer as string; - - const user = await prismaUsers.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!user) { - console.error(`User not found for customer ${customerId}`); - return; - } - - await prismaUsers.user.update({ - where: { id: user.id }, - data: { - subscriptionPlan: "FREE", - subscriptionStatus: "canceled", - stripeSubscriptionId: null, - cancelAtPeriodEnd: false, - }, - }); - - console.log(`Subscription deleted for user ${user.id}`); -} - -async function handlePaymentFailed(invoice: Stripe.Invoice) { - const customerId = invoice.customer as string; - - const user = await prismaUsers.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (user) { - await prismaUsers.user.update({ - where: { id: user.id }, - data: { subscriptionStatus: "past_due" }, - }); - console.log(`Payment failed for user ${user.id}`); - } -} - -async function handlePaymentSucceeded(invoice: Stripe.Invoice) { - const customerId = invoice.customer as string; - const inv = invoice as unknown as { subscription?: string; billing_reason?: string }; - const subscriptionId = inv.subscription; - - const user = await prismaUsers.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!user) return; - - // Update status if was past_due - if (user.subscriptionStatus === "past_due") { - await prismaUsers.user.update({ - where: { id: user.id }, - data: { subscriptionStatus: "active" }, - }); - console.log(`Payment succeeded for user ${user.id}`); - } - - // Check for scheduled downgrade on renewal invoices - if (subscriptionId && inv.billing_reason === "subscription_cycle") { - try { - const stripe = ensureStripe(); - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - const scheduledDowngrade = subscription.metadata?.scheduledDowngrade; - const scheduledDowngradePrice = subscription.metadata?.scheduledDowngradePrice; - - if (scheduledDowngrade && scheduledDowngradePrice) { - // Apply the downgrade now - await stripe.subscriptions.update(subscriptionId, { - items: [ - { - id: subscription.items.data[0].id, - price: scheduledDowngradePrice, - }, - ], - proration_behavior: "none", // Already at new billing cycle - metadata: { - ...subscription.metadata, - scheduledDowngrade: null, - scheduledDowngradePrice: null, - plan: scheduledDowngrade, - }, - }); - - // Update database - // Note: Legacy PRO/MAX mapped to FREE - only TEAMS is a paid tier now - type SubscriptionPlan = "FREE" | "TEAMS"; - const planMap: Record = { - pro: "FREE", // Legacy - max: "FREE", // Legacy - free: "FREE", - teams: "TEAMS", - }; - - await prismaUsers.user.update({ - where: { id: user.id }, - data: { - subscriptionPlan: planMap[scheduledDowngrade] || "FREE", - }, - }); - - console.log(`Applied scheduled downgrade to ${scheduledDowngrade} for user ${user.id}`); - } - } catch (error) { - console.error("Error applying scheduled downgrade:", error); - } - } -} - -// Platform owner email - payments go directly to the platform's Stripe account const PLATFORM_OWNER_EMAIL = "dev@lynxprompt.com"; async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const { templateId, userId, originalPrice, paidPrice, isMaxDiscount, currency, teamId } = session.metadata || {}; - // Backwards compatibility with old purchases that only have 'price' const price = originalPrice || session.metadata?.price; const actualPaid = paidPrice || price; @@ -379,8 +79,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const originalPriceInCents = parseInt(price, 10); const paidPriceInCents = parseInt(actualPaid, 10); - // Check if the template belongs to the platform owner - // For platform owner blueprints, all revenue stays with platform (no payout needed) const template = await prismaUsers.userTemplate.findUnique({ where: { id: templateId }, select: { @@ -392,16 +90,11 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const isPlatformOwnerTemplate = template?.user?.email === PLATFORM_OWNER_EMAIL; - // Author gets 70% of ORIGINAL price, UNLESS it's the platform owner's template - // Platform owner's revenue goes directly to Stripe (no payout needed) const authorShare = isPlatformOwnerTemplate ? 0 : Math.floor(originalPriceInCents * 0.7); - // Platform fee is what's left from what was paid const platformFee = paidPriceInCents - authorShare; - // Get current version info for the purchase record const purchaseVersion = template?.publishedVersion || template?.currentVersion || 1; - // Find the version record ID for linking const versionRecord = await prismaUsers.userTemplateVersion.findUnique({ where: { templateId_version: { @@ -413,7 +106,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { }); try { - // Create purchase record - if teamId is present, this is a team purchase const purchaseData: { userId: string; templateId: string; @@ -428,16 +120,15 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { } = { userId, templateId, - amount: paidPriceInCents, // What was actually paid + amount: paidPriceInCents, currency: currency || "EUR", stripePaymentId: session.payment_intent as string, - authorShare, // 70% of original price - platformFee, // Remaining (20% if discounted, 30% if not) + authorShare, + platformFee, versionId: versionRecord?.id || undefined, versionNumber: purchaseVersion, }; - // If purchased by a team member, add teamId (makes it available to entire team) if (teamId) { purchaseData.teamId = teamId; } @@ -446,7 +137,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { data: purchaseData, }); - // Update template revenue with original price (for author earnings tracking) await prismaUsers.userTemplate.update({ where: { id: templateId }, data: { @@ -458,7 +148,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const teamInfo = teamId ? ` (team: ${teamId})` : ""; console.log(`Blueprint purchase: ${templateId} by ${userId}${teamInfo} - paid: ${paidPriceInCents} cents, original: ${originalPriceInCents} cents (author: ${authorShare}, platform: ${platformFee}, max discount: ${isMaxDiscount === "true"})`); } catch (error) { - // Handle duplicate purchase (race condition) if ((error as { code?: string }).code === "P2002") { console.log(`Duplicate purchase attempt for ${templateId} by ${userId}`); } else { @@ -466,100 +155,3 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { } } } - -/** - * Handle Teams subscription changes - */ -async function handleTeamsSubscriptionChange(subscription: Stripe.Subscription) { - const teamId = subscription.metadata?.teamId; - const customerId = subscription.customer as string; - - // Find team by teamId or customer ID - const team = await prismaUsers.team.findFirst({ - where: teamId ? { id: teamId } : { stripeCustomerId: customerId }, - }); - - if (!team) { - console.error(`Team not found for subscription ${subscription.id}`); - return; - } - - // Get seat count from subscription - const seatCount = subscription.items.data[0]?.quantity || 3; - - // Get current period info - const sub = subscription as unknown as { current_period_start?: number; current_period_end?: number }; - const billingCycleStart = sub.current_period_start - ? new Date(sub.current_period_start * 1000) - : null; - - await prismaUsers.team.update({ - where: { id: team.id }, - data: { - stripeSubscriptionId: subscription.id, - stripeCustomerId: customerId, - maxSeats: seatCount, - billingCycleStart, - }, - }); - - // If subscription became active, mark team members as TEAMS plan - if (subscription.status === "active") { - const members = await prismaUsers.teamMember.findMany({ - where: { teamId: team.id }, - select: { userId: true }, - }); - - // Update all team members to TEAMS plan - await prismaUsers.user.updateMany({ - where: { id: { in: members.map(m => m.userId) } }, - data: { subscriptionPlan: "TEAMS" }, - }); - - console.log(`Team ${team.id} activated: ${members.length} members upgraded to TEAMS plan`); - } - - console.log(`Updated Teams subscription for team ${team.id}: ${seatCount} seats (status: ${subscription.status})`); -} - -/** - * Handle Teams subscription deletion - */ -async function handleTeamsSubscriptionDeleted(subscription: Stripe.Subscription) { - const customerId = subscription.customer as string; - - const team = await prismaUsers.team.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!team) { - console.error(`Team not found for customer ${customerId}`); - return; - } - - // Get all team members - const members = await prismaUsers.teamMember.findMany({ - where: { teamId: team.id }, - select: { userId: true }, - }); - - // Downgrade all team members to FREE plan - await prismaUsers.user.updateMany({ - where: { id: { in: members.map(m => m.userId) } }, - data: { subscriptionPlan: "FREE" }, - }); - - await prismaUsers.team.update({ - where: { id: team.id }, - data: { - stripeSubscriptionId: null, - billingCycleStart: null, - }, - }); - - console.log(`Teams subscription canceled for team ${team.id}: ${members.length} members downgraded to FREE`); -} - - - - diff --git a/src/app/api/blueprints/route.ts b/src/app/api/blueprints/route.ts index 4019388..dbe5b5f 100644 --- a/src/app/api/blueprints/route.ts +++ b/src/app/api/blueprints/route.ts @@ -390,16 +390,6 @@ export async function POST(request: NextRequest) { // } // } - // Check if user can create paid blueprints (Teams only) - if (price !== null && price !== undefined && price > 0) { - if (!isTeamsUser) { - return NextResponse.json( - { error: "Only Teams subscribers can create paid blueprints. Upgrade to Teams to unlock this feature." }, - { status: 403 } - ); - } - } - // Validation if (!name || typeof name !== "string" || name.trim().length < 3) { return NextResponse.json( diff --git a/src/app/api/teams/[teamId]/billing/route.ts b/src/app/api/teams/[teamId]/billing/route.ts index 624d382..ee859c4 100644 --- a/src/app/api/teams/[teamId]/billing/route.ts +++ b/src/app/api/teams/[teamId]/billing/route.ts @@ -2,23 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -import { ensureStripe, STRIPE_PRICE_IDS } from "@/lib/stripe"; -import { TEAMS_MIN_SEATS, TEAMS_PRICE_PER_SEAT, calculateProratedAmount } from "@/lib/subscription"; -import { z } from "zod"; -// Validation schemas -const createSubscriptionSchema = z.object({ - seats: z.number().min(TEAMS_MIN_SEATS, `Minimum ${TEAMS_MIN_SEATS} seats required`), - euDigitalContentConsent: z.boolean(), -}); - -const updateSeatsSchema = z.object({ - seats: z.number().min(TEAMS_MIN_SEATS, `Minimum ${TEAMS_MIN_SEATS} seats required`), -}); - -/** - * Helper: Check if user is a team admin - */ async function isTeamAdmin(userId: string, teamId: string): Promise { const membership = await prismaUsers.teamMember.findUnique({ where: { @@ -29,7 +13,8 @@ async function isTeamAdmin(userId: string, teamId: string): Promise { } /** - * GET /api/teams/[teamId]/billing - Get billing info (admin only) + * GET /api/teams/[teamId]/billing - Get team info (admin only) + * Subscription billing has been removed. Returns basic team membership info. */ export async function GET( request: NextRequest, @@ -43,10 +28,9 @@ export async function GET( const { teamId } = await params; - // Check if user is a team admin if (!(await isTeamAdmin(session.user.id, teamId))) { return NextResponse.json( - { error: "Only team admins can view billing" }, + { error: "Only team admins can view team info" }, { status: 403 } ); } @@ -56,14 +40,9 @@ export async function GET( include: { members: { select: { - isActiveThisCycle: true, lastActiveAt: true, }, }, - billingRecords: { - orderBy: { periodStart: "desc" }, - take: 6, // Last 6 billing periods - }, }, }); @@ -71,425 +50,20 @@ export async function GET( return NextResponse.json({ error: "Team not found" }, { status: 404 }); } - // Calculate active users - const activeMembers = team.members.filter((m) => m.isActiveThisCycle).length; const totalMembers = team.members.length; - // Get Stripe subscription details if exists - let stripeSubscription = null; - if (team.stripeSubscriptionId) { - try { - const stripe = ensureStripe(); - const sub = await stripe.subscriptions.retrieve(team.stripeSubscriptionId); - stripeSubscription = { - status: sub.status, - currentPeriodEnd: new Date((sub as unknown as { current_period_end: number }).current_period_end * 1000), - cancelAtPeriodEnd: sub.cancel_at_period_end, - quantity: sub.items.data[0]?.quantity || 0, - }; - } catch (e) { - console.error("Error fetching Stripe subscription:", e); - } - } - - // Calculate next bill estimate - const billableSeats = Math.max(activeMembers, TEAMS_MIN_SEATS); - const nextBillEstimate = billableSeats * TEAMS_PRICE_PER_SEAT; - return NextResponse.json({ billing: { - stripeCustomerId: team.stripeCustomerId, - stripeSubscriptionId: team.stripeSubscriptionId, - subscription: stripeSubscription, maxSeats: team.maxSeats, totalMembers, - activeMembers, - billableSeats, - nextBillEstimate, - nextBillEstimateFormatted: `€${(nextBillEstimate / 100).toFixed(2)}`, - billingCycleStart: team.billingCycleStart, - aiUsageLimitPerUser: team.aiUsageLimitPerUser, - }, - history: team.billingRecords.map((r) => ({ - id: r.id, - periodStart: r.periodStart, - periodEnd: r.periodEnd, - totalSeats: r.totalSeats, - activeSeats: r.activeSeats, - billedSeats: r.billedSeats, - amountBilled: r.amountBilled, - amountFormatted: `€${(r.amountBilled / 100).toFixed(2)}`, - creditApplied: r.creditApplied, - creditGenerated: r.creditGenerated, - })), - }); - } catch (error) { - console.error("Error fetching billing:", error); - return NextResponse.json( - { error: "Failed to fetch billing information" }, - { status: 500 } - ); - } -} - -/** - * POST /api/teams/[teamId]/billing - Create Teams subscription (admin only) - */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id || !session.user.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { teamId } = await params; - - // Check if user is a team admin - if (!(await isTeamAdmin(session.user.id, teamId))) { - return NextResponse.json( - { error: "Only team admins can manage billing" }, - { status: 403 } - ); - } - - const body = await request.json(); - const validation = createSubscriptionSchema.safeParse(body); - - if (!validation.success) { - return NextResponse.json( - { error: "Invalid input", details: validation.error.flatten() }, - { status: 400 } - ); - } - - const { seats, euDigitalContentConsent } = validation.data; - - if (!euDigitalContentConsent) { - return NextResponse.json( - { error: "You must consent to immediate access and waive your withdrawal right to proceed." }, - { status: 400 } - ); - } - - const team = await prismaUsers.team.findUnique({ - where: { id: teamId }, - }); - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - if (team.stripeSubscriptionId) { - return NextResponse.json( - { error: "Team already has an active subscription. Use PATCH to update seats." }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Get or create Stripe customer for the team - let customerId = team.stripeCustomerId; - - if (!customerId) { - const customer = await stripe.customers.create({ - email: session.user.email, - name: team.name, - metadata: { - teamId: team.id, - teamSlug: team.slug, - adminUserId: session.user.id, - }, - }); - customerId = customer.id; - } - - // Create checkout session for Teams subscription - const priceId = STRIPE_PRICE_IDS.teams_seat_monthly; - if (!priceId) { - return NextResponse.json( - { error: "Teams pricing is not configured" }, - { status: 500 } - ); - } - - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: seats, // Per-seat billing with quantity - }, - ], - success_url: `${process.env.NEXTAUTH_URL}/teams/${team.slug}/manage?billing=success`, - cancel_url: `${process.env.NEXTAUTH_URL}/teams/${team.slug}/manage?billing=canceled`, - metadata: { - teamId: team.id, - seats: seats.toString(), - euDigitalContentConsent: "true", - consentTimestamp: new Date().toISOString(), - }, - subscription_data: { - metadata: { - teamId: team.id, - seats: seats.toString(), - }, - }, - allow_promotion_codes: true, - billing_address_collection: "required", - }); - - // Update team with customer ID and max seats - await prismaUsers.team.update({ - where: { id: teamId }, - data: { - stripeCustomerId: customerId, - maxSeats: seats, }, + history: [], }); - - return NextResponse.json({ url: checkoutSession.url }); } catch (error) { - console.error("Error creating subscription:", error); + console.error("Error fetching team info:", error); return NextResponse.json( - { error: "Failed to create subscription" }, + { error: "Failed to fetch team information" }, { status: 500 } ); } } - -/** - * PATCH /api/teams/[teamId]/billing - Update seat count (admin only) - */ -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { teamId } = await params; - - // Check if user is a team admin - if (!(await isTeamAdmin(session.user.id, teamId))) { - return NextResponse.json( - { error: "Only team admins can update billing" }, - { status: 403 } - ); - } - - const body = await request.json(); - const validation = updateSeatsSchema.safeParse(body); - - if (!validation.success) { - return NextResponse.json( - { error: "Invalid input", details: validation.error.flatten() }, - { status: 400 } - ); - } - - const { seats } = validation.data; - - const team = await prismaUsers.team.findUnique({ - where: { id: teamId }, - include: { - _count: { select: { members: true } }, - }, - }); - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - if (!team.stripeSubscriptionId) { - return NextResponse.json( - { error: "No active subscription. Use POST to create one." }, - { status: 400 } - ); - } - - // Can't reduce seats below current member count - if (seats < team._count.members) { - return NextResponse.json( - { error: `Cannot reduce seats below current member count (${team._count.members}). Remove members first.` }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Get current subscription - const subscription = await stripe.subscriptions.retrieve(team.stripeSubscriptionId); - const subscriptionItemId = subscription.items.data[0]?.id; - - if (!subscriptionItemId) { - return NextResponse.json( - { error: "Subscription item not found" }, - { status: 500 } - ); - } - - const currentSeats = subscription.items.data[0]?.quantity || TEAMS_MIN_SEATS; - const isIncreasing = seats > currentSeats; - const seatDifference = seats - currentSeats; - - // Calculate if this is the same day as billing cycle start - const now = new Date(); - const cycleStart = team.billingCycleStart ? new Date(team.billingCycleStart) : new Date(); - - // Check if same day (comparing year, month, day) - const isSameDayAsCycleStart = - now.getFullYear() === cycleStart.getFullYear() && - now.getMonth() === cycleStart.getMonth() && - now.getDate() === cycleStart.getDate(); - - // Determine proration behavior: - // - Increasing seats on same day as cycle start: no proration (full price) - // - Increasing seats mid-cycle: prorate - // - Decreasing seats: no proration, takes effect next cycle - let prorationBehavior: "create_prorations" | "none" | "always_invoice" = "none"; - - if (isIncreasing) { - if (isSameDayAsCycleStart) { - // Same day = full price, use always_invoice to charge immediately - prorationBehavior = "always_invoice"; - } else { - // Mid-cycle = prorate - prorationBehavior = "create_prorations"; - } - } - // Decreasing: prorationBehavior stays "none" (no refund, change takes effect at renewal) - - // Update subscription quantity - await stripe.subscriptions.update(team.stripeSubscriptionId, { - items: [ - { - id: subscriptionItemId, - quantity: seats, - }, - ], - proration_behavior: prorationBehavior, - metadata: { - ...subscription.metadata, - seats: seats.toString(), - }, - }); - - // Update team max seats - await prismaUsers.team.update({ - where: { id: teamId }, - data: { maxSeats: seats }, - }); - - // Calculate amount for response - let chargeAmount = 0; - let chargeNote = ""; - - if (isIncreasing) { - if (isSameDayAsCycleStart) { - // Full price for new seats - chargeAmount = seatDifference * TEAMS_PRICE_PER_SEAT; - chargeNote = "Full amount charged for new seats (same day as billing cycle start)"; - } else { - // Calculate prorated amount - const cycleEnd = new Date(cycleStart); - cycleEnd.setMonth(cycleEnd.getMonth() + 1); - - const totalDays = Math.ceil((cycleEnd.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)); - const daysRemaining = Math.ceil((cycleEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - - chargeAmount = calculateProratedAmount( - Math.max(0, daysRemaining), - totalDays, - seatDifference - ); - chargeNote = `Prorated amount charged for ${daysRemaining} remaining days`; - } - } else { - chargeNote = "Seat reduction will take effect at the next billing cycle. No refund for current period."; - } - - return NextResponse.json({ - message: `Seat count updated from ${currentSeats} to ${seats}`, - previousSeats: currentSeats, - newSeats: seats, - chargeAmount: isIncreasing ? chargeAmount : 0, - chargeAmountFormatted: isIncreasing ? `€${(chargeAmount / 100).toFixed(2)}` : "€0.00", - note: chargeNote, - isSameDayAsCycleStart, - }); - } catch (error) { - console.error("Error updating seats:", error); - return NextResponse.json( - { error: "Failed to update seat count" }, - { status: 500 } - ); - } -} - -/** - * DELETE /api/teams/[teamId]/billing - Cancel subscription (admin only) - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { teamId } = await params; - - // Check if user is a team admin - if (!(await isTeamAdmin(session.user.id, teamId))) { - return NextResponse.json( - { error: "Only team admins can cancel subscription" }, - { status: 403 } - ); - } - - const team = await prismaUsers.team.findUnique({ - where: { id: teamId }, - }); - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - if (!team.stripeSubscriptionId) { - return NextResponse.json( - { error: "No active subscription to cancel" }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Cancel at period end (don't immediately revoke access) - await stripe.subscriptions.update(team.stripeSubscriptionId, { - cancel_at_period_end: true, - }); - - return NextResponse.json({ - message: "Subscription will be canceled at the end of the current billing period", - note: "Team members will retain access until then", - }); - } catch (error) { - console.error("Error canceling subscription:", error); - return NextResponse.json( - { error: "Failed to cancel subscription" }, - { status: 500 } - ); - } -} - diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 8a5f4ae..cb268c7 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -2,19 +2,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -import { ensureStripe, STRIPE_PRICE_IDS } from "@/lib/stripe"; import { z } from "zod"; -const MIN_SEATS = 3; - -// Validation schema for team creation const createTeamSchema = z.object({ name: z.string().min(2).max(100), slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, { message: "Slug must be lowercase alphanumeric with hyphens only", }), - interval: z.enum(["monthly", "annual"]).optional().default("monthly"), - seats: z.number().min(MIN_SEATS, `Minimum ${MIN_SEATS} seats required`).optional().default(MIN_SEATS), }); /** @@ -47,7 +41,6 @@ export async function GET() { role: m.role, memberCount: m.team._count.members, joinedAt: m.joinedAt, - billingCycleStart: m.team.billingCycleStart, maxSeats: m.team.maxSeats, })); @@ -62,7 +55,7 @@ export async function GET() { } /** - * POST /api/teams - Create a new team (redirects to Stripe checkout) + * POST /api/teams - Create a new team (no billing required) */ export async function POST(request: NextRequest) { try { @@ -81,7 +74,7 @@ export async function POST(request: NextRequest) { ); } - const { name, slug, interval, seats } = validation.data; + const { name, slug } = validation.data; // Check if user is already in a team const existingMembership = await prismaUsers.teamMember.findFirst({ @@ -111,76 +104,26 @@ export async function POST(request: NextRequest) { ); } - // Get or create Stripe customer - const stripe = ensureStripe(); - let stripeCustomerId = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { stripeCustomerId: true }, - }).then(u => u?.stripeCustomerId); - - if (!stripeCustomerId) { - const customer = await stripe.customers.create({ - email: session.user.email, - name: session.user.name || undefined, - metadata: { - userId: session.user.id, - }, - }); - stripeCustomerId = customer.id; - - await prismaUsers.user.update({ - where: { id: session.user.id }, - data: { stripeCustomerId }, - }); - } - - // Determine price ID based on interval - const priceId = interval === "annual" - ? STRIPE_PRICE_IDS.teams_seat_annual - : STRIPE_PRICE_IDS.teams_seat_monthly; - - if (!priceId) { - return NextResponse.json( - { error: "Teams pricing not configured. Please contact support." }, - { status: 500 } - ); - } - - // Create Stripe checkout session for Teams subscription - const checkoutSession = await stripe.checkout.sessions.create({ - customer: stripeCustomerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: seats, - }, - ], - subscription_data: { - metadata: { - teamName: name, - teamSlug: slug, - creatorUserId: session.user.id, - type: "teams", - seats: seats.toString(), + // Create team directly (no billing) + const team = await prismaUsers.team.create({ + data: { + name, + slug, + members: { + create: { + userId: session.user.id, + role: "ADMIN", + }, }, }, - metadata: { - teamName: name, - teamSlug: slug, - creatorUserId: session.user.id, - type: "teams", - seats: seats.toString(), - }, - success_url: `${process.env.NEXTAUTH_URL}/teams/${slug}?success=true`, - cancel_url: `${process.env.NEXTAUTH_URL}/teams?cancelled=true`, - allow_promotion_codes: true, }); return NextResponse.json({ - checkoutUrl: checkoutSession.url, - sessionId: checkoutSession.id, + team: { + id: team.id, + name: team.name, + slug: team.slug, + }, }); } catch (error) { console.error("Error creating team:", error); @@ -190,4 +133,3 @@ export async function POST(request: NextRequest) { ); } } - diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 80e2f2d..0a6c010 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -264,9 +264,6 @@ export default async function BlogPostPage({ params }: PageProps) {