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");
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
// 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 };
|
||||
});
|
||||
|
||||
@@ -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<User>(
|
||||
`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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user