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

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