feat: Add subscription management system
- Add subscription fields to organizations (status, plan, trial_ends_at, etc.) - Add subscription middleware for access control - Add /auth/me returns subscription info - Add admin endpoints for subscription management: - GET/PUT /admin/organizations/:id/subscription - POST /admin/organizations/:id/pause - POST /admin/organizations/:id/activate - POST /admin/organizations/:id/extend-trial - GET /admin/subscriptions (with filters) - New orgs get 14-day trial automatically
This commit is contained in:
@@ -46,7 +46,30 @@ async function runMigrations(client: ReturnType<Pool["connect"]> extends Promise
|
|||||||
`;
|
`;
|
||||||
console.log("✅ Migration: logo_url column checked");
|
console.log("✅ Migration: logo_url column checked");
|
||||||
} catch (e) {
|
} 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));
|
console.log("ℹ️ Migration note:", e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/middleware/subscription.ts
Normal file
127
src/middleware/subscription.ts
Normal file
@@ -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<void> {
|
||||||
|
const user = ctx.state.auth?.user;
|
||||||
|
|
||||||
|
if (!user?.org_id) {
|
||||||
|
throw new AppError("Nicht authentifiziert", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await queryOne<SubscriptionInfo>(
|
||||||
|
`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<void> {
|
||||||
|
const user = ctx.state.auth?.user;
|
||||||
|
|
||||||
|
if (user?.org_id) {
|
||||||
|
const org = await queryOne<SubscriptionInfo>(
|
||||||
|
`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();
|
||||||
|
}
|
||||||
@@ -56,10 +56,12 @@ adminRouter.get("/dashboard", requireSuperAdmin, async (ctx) => {
|
|||||||
ctx.response.body = { stats, recentOrgs };
|
ctx.response.body = { stats, recentOrgs };
|
||||||
});
|
});
|
||||||
|
|
||||||
// List all organizations
|
// List all organizations (with subscription info)
|
||||||
adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => {
|
adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => {
|
||||||
const orgs = await query(
|
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 users WHERE org_id = o.id AND active = true) as user_count,
|
||||||
(SELECT COUNT(*) FROM orders WHERE org_id = o.id) as order_count
|
(SELECT COUNT(*) FROM orders WHERE org_id = o.id) as order_count
|
||||||
FROM organizations o
|
FROM organizations o
|
||||||
@@ -226,3 +228,179 @@ adminRouter.put("/organizations/:id/modules/:moduleId", requireSuperAdmin, async
|
|||||||
|
|
||||||
ctx.response.body = { message: "Modul aktualisiert" };
|
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 };
|
||||||
|
});
|
||||||
|
|||||||
@@ -174,9 +174,10 @@ authRouter.post("/logout", authMiddleware, async (ctx) => {
|
|||||||
ctx.response.body = { message: "Logged out successfully" };
|
ctx.response.body = { message: "Logged out successfully" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current user
|
// Get current user (with subscription info)
|
||||||
authRouter.get("/me", authMiddleware, async (ctx) => {
|
authRouter.get("/me", authMiddleware, async (ctx) => {
|
||||||
const userId = ctx.state.auth.user.id;
|
const userId = ctx.state.auth.user.id;
|
||||||
|
const orgId = ctx.state.auth.user.org_id;
|
||||||
|
|
||||||
const user = await queryOne<User>(
|
const user = await queryOne<User>(
|
||||||
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
||||||
@@ -189,7 +190,44 @@ authRouter.get("/me", authMiddleware, async (ctx) => {
|
|||||||
throw new AppError("User not found", 404);
|
throw new AppError("User not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = { user };
|
// 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, subscription };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Change password
|
// Change password
|
||||||
|
|||||||
@@ -58,16 +58,17 @@ organizationsRouter.post("/register", async (ctx) => {
|
|||||||
// Import hash function
|
// Import hash function
|
||||||
const { hashPassword } = await import("../utils/auth.ts");
|
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 }>(
|
const org = await queryOne<{ id: string }>(
|
||||||
`INSERT INTO organizations (name, slug, settings)
|
`INSERT INTO organizations (name, slug, settings, subscription_status, subscription_plan, trial_ends_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, 'trial', 'starter', $4)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[name, cleanSlug, JSON.stringify({
|
[name, cleanSlug, JSON.stringify({
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
plan: "free",
|
|
||||||
features: ["core", "orders", "availability", "timesheets"]
|
features: ["core", "orders", "availability", "timesheets"]
|
||||||
})]
|
}), trialEndsAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
|
|||||||
Reference in New Issue
Block a user