diff --git a/src/db/postgres.ts b/src/db/postgres.ts index 7082050..ebb91f3 100644 --- a/src/db/postgres.ts +++ b/src/db/postgres.ts @@ -46,7 +46,30 @@ async function runMigrations(client: ReturnType extends Promise `; console.log("✅ Migration: logo_url column checked"); } catch (e) { - // Column might already exist, that's fine + console.log("ℹ️ Migration note:", e instanceof Error ? e.message : String(e)); + } + + // Add subscription fields to organizations + try { + await client.queryObject` + ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS subscription_status TEXT DEFAULT 'trial', + ADD COLUMN IF NOT EXISTS subscription_plan TEXT DEFAULT 'starter', + ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS subscription_ends_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS subscription_paused_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS subscription_pause_reason TEXT + `; + console.log("✅ Migration: subscription columns checked"); + + // Set trial_ends_at for existing orgs without it (14 days from now) + await client.queryObject` + UPDATE organizations + SET trial_ends_at = NOW() + INTERVAL '14 days' + WHERE trial_ends_at IS NULL AND subscription_status = 'trial' + `; + console.log("✅ Migration: trial dates initialized"); + } catch (e) { console.log("ℹ️ Migration note:", e instanceof Error ? e.message : String(e)); } } diff --git a/src/middleware/subscription.ts b/src/middleware/subscription.ts new file mode 100644 index 0000000..92d1b35 --- /dev/null +++ b/src/middleware/subscription.ts @@ -0,0 +1,127 @@ +import { Context, Next } from "@oak/oak"; +import { queryOne } from "../db/postgres.ts"; +import { AppError } from "./error.ts"; + +interface SubscriptionInfo { + subscription_status: string; + subscription_plan: string; + trial_ends_at: Date | null; + subscription_ends_at: Date | null; + subscription_paused_at: Date | null; + subscription_pause_reason: string | null; +} + +/** + * Middleware to check if organization has active subscription + * Must be used AFTER authMiddleware + */ +export async function subscriptionMiddleware(ctx: Context, next: Next): Promise { + const user = ctx.state.auth?.user; + + if (!user?.org_id) { + throw new AppError("Nicht authentifiziert", 401); + } + + const org = await queryOne( + `SELECT subscription_status, subscription_plan, trial_ends_at, + subscription_ends_at, subscription_paused_at, subscription_pause_reason + FROM organizations WHERE id = $1`, + [user.org_id] + ); + + if (!org) { + throw new AppError("Organisation nicht gefunden", 404); + } + + const now = new Date(); + let isActive = false; + let message = ""; + + switch (org.subscription_status) { + case "active": + // Check if subscription has ended + if (org.subscription_ends_at && new Date(org.subscription_ends_at) < now) { + message = "Ihr Abonnement ist abgelaufen. Bitte erneuern Sie Ihr Abo."; + } else { + isActive = true; + } + break; + + case "trial": + // Check if trial has ended + if (org.trial_ends_at && new Date(org.trial_ends_at) < now) { + message = "Ihre Testphase ist abgelaufen. Bitte wählen Sie ein Abo."; + } else { + isActive = true; + } + break; + + case "paused": + message = org.subscription_pause_reason || "Ihr Account wurde pausiert. Bitte kontaktieren Sie den Support."; + break; + + case "expired": + message = "Ihr Abonnement ist abgelaufen. Bitte erneuern Sie Ihr Abo."; + break; + + case "cancelled": + message = "Ihr Abonnement wurde gekündigt."; + break; + + default: + message = "Unbekannter Abonnement-Status."; + } + + if (!isActive) { + throw new AppError(message, 402); // 402 Payment Required + } + + // Add subscription info to context for use in routes + ctx.state.subscription = { + status: org.subscription_status, + plan: org.subscription_plan, + trialEndsAt: org.trial_ends_at, + subscriptionEndsAt: org.subscription_ends_at, + }; + + await next(); +} + +/** + * Lightweight check that just adds subscription info without blocking + * Useful for endpoints that need subscription info but shouldn't be blocked + */ +export async function subscriptionInfoMiddleware(ctx: Context, next: Next): Promise { + const user = ctx.state.auth?.user; + + if (user?.org_id) { + const org = await queryOne( + `SELECT subscription_status, subscription_plan, trial_ends_at, + subscription_ends_at, subscription_paused_at + FROM organizations WHERE id = $1`, + [user.org_id] + ); + + if (org) { + const now = new Date(); + let daysRemaining: number | null = null; + + if (org.subscription_status === "trial" && org.trial_ends_at) { + daysRemaining = Math.ceil((new Date(org.trial_ends_at).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + } else if (org.subscription_status === "active" && org.subscription_ends_at) { + daysRemaining = Math.ceil((new Date(org.subscription_ends_at).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + } + + ctx.state.subscription = { + status: org.subscription_status, + plan: org.subscription_plan, + trialEndsAt: org.trial_ends_at, + subscriptionEndsAt: org.subscription_ends_at, + daysRemaining, + isPaused: org.subscription_status === "paused", + }; + } + } + + await next(); +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 50ed83f..06b8117 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -56,10 +56,12 @@ adminRouter.get("/dashboard", requireSuperAdmin, async (ctx) => { ctx.response.body = { stats, recentOrgs }; }); -// List all organizations +// List all organizations (with subscription info) adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => { const orgs = await query( - `SELECT o.id, o.name, o.slug, o.status, o.plan, o.max_users, o.created_at, + `SELECT o.id, o.name, o.slug, o.subscription_status, o.subscription_plan, + o.trial_ends_at, o.subscription_ends_at, o.subscription_paused_at, + o.logo_url, o.created_at, (SELECT COUNT(*) FROM users WHERE org_id = o.id AND active = true) as user_count, (SELECT COUNT(*) FROM orders WHERE org_id = o.id) as order_count FROM organizations o @@ -226,3 +228,179 @@ adminRouter.put("/organizations/:id/modules/:moduleId", requireSuperAdmin, async ctx.response.body = { message: "Modul aktualisiert" }; }); + +// ============ SUBSCRIPTION MANAGEMENT ============ + +// Get organization subscription details +adminRouter.get("/organizations/:id/subscription", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + + const org = await queryOne<{ + id: string; + name: string; + subscription_status: string; + subscription_plan: string; + trial_ends_at: Date | null; + subscription_ends_at: Date | null; + subscription_paused_at: Date | null; + subscription_pause_reason: string | null; + }>( + `SELECT id, name, subscription_status, subscription_plan, trial_ends_at, + subscription_ends_at, subscription_paused_at, subscription_pause_reason + FROM organizations WHERE id = $1`, + [orgId] + ); + + if (!org) { + throw new AppError("Organisation nicht gefunden", 404); + } + + ctx.response.body = { subscription: org }; +}); + +// Update subscription status +adminRouter.put("/organizations/:id/subscription", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { status, plan, trial_days, subscription_days, pause_reason } = body; + + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (status) { + updates.push(`subscription_status = $${paramIndex}`); + values.push(status); + paramIndex++; + + // Clear pause info if activating + if (status === "active" || status === "trial") { + updates.push(`subscription_paused_at = NULL`); + updates.push(`subscription_pause_reason = NULL`); + } + } + + if (plan) { + updates.push(`subscription_plan = $${paramIndex}`); + values.push(plan); + paramIndex++; + } + + if (trial_days !== undefined) { + updates.push(`trial_ends_at = NOW() + INTERVAL '${parseInt(trial_days)} days'`); + } + + if (subscription_days !== undefined) { + updates.push(`subscription_ends_at = NOW() + INTERVAL '${parseInt(subscription_days)} days'`); + } + + if (updates.length === 0) { + throw new AppError("Keine Änderungen", 400); + } + + values.push(orgId); + + await execute( + `UPDATE organizations SET ${updates.join(", ")} WHERE id = $${paramIndex}`, + values + ); + + ctx.response.body = { message: "Subscription aktualisiert" }; +}); + +// Pause organization (non-payment, etc.) +adminRouter.post("/organizations/:id/pause", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { reason } = body; + + await execute( + `UPDATE organizations SET + subscription_status = 'paused', + subscription_paused_at = NOW(), + subscription_pause_reason = $1 + WHERE id = $2`, + [reason || "Zahlung ausstehend", orgId] + ); + + ctx.response.body = { message: "Organisation pausiert" }; +}); + +// Unpause/Activate organization +adminRouter.post("/organizations/:id/activate", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { plan, days } = body; + + const subscriptionDays = days || 30; + + await execute( + `UPDATE organizations SET + subscription_status = 'active', + subscription_plan = COALESCE($1, subscription_plan), + subscription_ends_at = NOW() + INTERVAL '${subscriptionDays} days', + subscription_paused_at = NULL, + subscription_pause_reason = NULL + WHERE id = $2`, + [plan, orgId] + ); + + ctx.response.body = { message: "Organisation aktiviert" }; +}); + +// Extend trial +adminRouter.post("/organizations/:id/extend-trial", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { days } = body; + + const extendDays = days || 14; + + await execute( + `UPDATE organizations SET + subscription_status = 'trial', + trial_ends_at = GREATEST(trial_ends_at, NOW()) + INTERVAL '${extendDays} days', + subscription_paused_at = NULL, + subscription_pause_reason = NULL + WHERE id = $1`, + [orgId] + ); + + ctx.response.body = { message: `Trial um ${extendDays} Tage verlängert` }; +}); + +// List all organizations with subscription info +adminRouter.get("/subscriptions", requireSuperAdmin, async (ctx) => { + const filter = ctx.request.url.searchParams.get("filter"); // all, trial, active, paused, expired + + let whereClause = ""; + if (filter && filter !== "all") { + if (filter === "expired") { + whereClause = `WHERE (subscription_status = 'trial' AND trial_ends_at < NOW()) + OR (subscription_status = 'active' AND subscription_ends_at < NOW()) + OR subscription_status = 'expired'`; + } else { + whereClause = `WHERE subscription_status = '${filter}'`; + } + } + + const orgs = await query( + `SELECT o.id, o.name, o.slug, o.subscription_status, o.subscription_plan, + o.trial_ends_at, o.subscription_ends_at, o.subscription_paused_at, + o.subscription_pause_reason, o.created_at, + (SELECT COUNT(*) FROM users WHERE org_id = o.id AND active = true) as user_count + FROM organizations o + ${whereClause} + ORDER BY + CASE subscription_status + WHEN 'paused' THEN 1 + WHEN 'expired' THEN 2 + WHEN 'trial' THEN 3 + WHEN 'active' THEN 4 + ELSE 5 + END, + created_at DESC` + ); + + ctx.response.body = { organizations: orgs }; +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index e2b7b06..1f3fd56 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -174,9 +174,10 @@ authRouter.post("/logout", authMiddleware, async (ctx) => { ctx.response.body = { message: "Logged out successfully" }; }); -// Get current user +// Get current user (with subscription info) authRouter.get("/me", authMiddleware, async (ctx) => { const userId = ctx.state.auth.user.id; + const orgId = ctx.state.auth.user.org_id; const user = await queryOne( `SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url, @@ -188,8 +189,45 @@ authRouter.get("/me", authMiddleware, async (ctx) => { if (!user) { throw new AppError("User not found", 404); } + + // Get subscription info + const org = await queryOne<{ + subscription_status: string; + subscription_plan: string; + trial_ends_at: Date | null; + subscription_ends_at: Date | null; + }>( + `SELECT subscription_status, subscription_plan, trial_ends_at, subscription_ends_at + FROM organizations WHERE id = $1`, + [orgId] + ); + + let subscription = null; + if (org) { + const now = new Date(); + let daysRemaining: number | null = null; + let isExpired = false; + + if (org.subscription_status === "trial" && org.trial_ends_at) { + const trialEnd = new Date(org.trial_ends_at); + daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + isExpired = daysRemaining < 0; + } else if (org.subscription_status === "active" && org.subscription_ends_at) { + const subEnd = new Date(org.subscription_ends_at); + daysRemaining = Math.ceil((subEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + isExpired = daysRemaining < 0; + } + + subscription = { + status: isExpired ? "expired" : org.subscription_status, + plan: org.subscription_plan, + trialEndsAt: org.trial_ends_at, + subscriptionEndsAt: org.subscription_ends_at, + daysRemaining: daysRemaining !== null ? Math.max(0, daysRemaining) : null, + }; + } - ctx.response.body = { user }; + ctx.response.body = { user, subscription }; }); // Change password diff --git a/src/routes/organizations.ts b/src/routes/organizations.ts index a2f9ee2..9ee77db 100644 --- a/src/routes/organizations.ts +++ b/src/routes/organizations.ts @@ -58,16 +58,17 @@ organizationsRouter.post("/register", async (ctx) => { // Import hash function const { hashPassword } = await import("../utils/auth.ts"); - // Create organization + // Create organization with 14-day trial + const trialEndsAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); // 14 days + const org = await queryOne<{ id: string }>( - `INSERT INTO organizations (name, slug, settings) - VALUES ($1, $2, $3) + `INSERT INTO organizations (name, slug, settings, subscription_status, subscription_plan, trial_ends_at) + VALUES ($1, $2, $3, 'trial', 'starter', $4) RETURNING id`, [name, cleanSlug, JSON.stringify({ created_at: new Date().toISOString(), - plan: "free", features: ["core", "orders", "availability", "timesheets"] - })] + }), trialEndsAt] ); if (!org) {