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:
2026-03-13 05:58:00 +00:00
parent 6e421efef1
commit 40adeb15ee
5 changed files with 377 additions and 10 deletions

View File

@@ -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));
}
}

View 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();
}

View File

@@ -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 };
});

View File

@@ -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,
@@ -189,7 +190,44 @@ authRouter.get("/me", authMiddleware, async (ctx) => {
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

View File

@@ -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) {