a6c0f3de-eeb0-4d76-a57f-f600dd133c06
This commit is contained in:
1 parent
2aa3496a90
commit
762f3d7373
141 files changed
+4485
-69
No files matched your search
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
reason: z.string().min(1).max(500).optional(),
|
||||
immediate: z.boolean().optional(), // force immediate (admin only)
|
||||
})
|
||||
|
||||
// PATCH /api/aos/licenses/[id]/cancel
|
||||
// Client cancels (kündigt) a subscription.
|
||||
//
|
||||
// German B2B law: 3-month (90-day) notice period. The cancellation takes
|
||||
// effect at the end of the notice period (cancellationEffectiveAt).
|
||||
//
|
||||
// Special case: within the 14-day trial, the client may cancel with immediate
|
||||
// effect — no charge, subscription never starts.
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { reason, immediate } = parsed.data
|
||||
|
||||
const license = await db.license.findUnique({ where: { id } })
|
||||
if (!license) {
|
||||
return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
}
|
||||
if (license.status === 'cancelled' || license.cancelledAt) {
|
||||
return NextResponse.json({ error: 'already cancelled' }, { status: 409 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const inTrial = license.trialEndsAt && now < license.trialEndsAt
|
||||
|
||||
// Effective cancellation date:
|
||||
// - In trial (first 14 days): immediate (next day at most)
|
||||
// - After trial: notice period (90 days) from now
|
||||
// - immediate=true (admin override): now
|
||||
let effectiveAt: Date
|
||||
if (immediate || inTrial) {
|
||||
effectiveAt = now
|
||||
} else {
|
||||
effectiveAt = new Date(now.getTime() + (license.noticePeriodDays || 90) * 86400000)
|
||||
}
|
||||
|
||||
// If cancelling during trial, the subscription never starts — set
|
||||
// validUntil to the trial end (so the license stays active until trial ends
|
||||
// then expires). If immediate, set validUntil to now.
|
||||
const newValidUntil = immediate
|
||||
? now
|
||||
: inTrial
|
||||
? license.trialEndsAt
|
||||
: effectiveAt
|
||||
|
||||
await db.license.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: immediate ? 'cancelled' : 'active', // stays active until effective date unless immediate
|
||||
cancelledAt: now,
|
||||
cancellationEffectiveAt: effectiveAt,
|
||||
cancellationReason: reason,
|
||||
validUntil: newValidUntil,
|
||||
},
|
||||
})
|
||||
|
||||
// Transitive cancellation of module licenses (if core)
|
||||
let cancelledModules = 0
|
||||
if (!license.parentId) {
|
||||
const r = await db.license.updateMany({
|
||||
where: { parentId: id, status: { not: 'cancelled' } },
|
||||
data: {
|
||||
status: immediate ? 'cancelled' : 'active',
|
||||
cancelledAt: now,
|
||||
cancellationEffectiveAt: effectiveAt,
|
||||
cancellationReason: reason,
|
||||
validUntil: newValidUntil,
|
||||
},
|
||||
})
|
||||
cancelledModules = r.count
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'client:cancel',
|
||||
action: inTrial ? 'license.cancel:trial' : 'license.cancel:notice',
|
||||
entity: 'License',
|
||||
entityId: id,
|
||||
after: JSON.stringify({
|
||||
key: license.key.slice(0, 16) + '…',
|
||||
inTrial,
|
||||
immediate: immediate || inTrial,
|
||||
effectiveAt,
|
||||
cancelledModules,
|
||||
reason,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cancelledAt: now,
|
||||
effectiveAt,
|
||||
inTrial: !!inTrial,
|
||||
immediate: immediate || inTrial,
|
||||
cancelledModules,
|
||||
message: inTrial
|
||||
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung.'
|
||||
: `Kündigung eingegangen. Wirksam zum ${effectiveAt.toLocaleDateString('de-DE')} (3-Monate-Frist).`,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// PATCH /api/aos/licenses/[id]/promote
|
||||
// Body: { environment: "demo" | "production" }
|
||||
// Reactivates a suspended/cancelled license AND/OR promotes demo -> production.
|
||||
// PRD §6.4: "reactivate on payment".
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const environment = body.environment === 'demo' ? 'demo' : 'production'
|
||||
|
||||
const license = await db.license.findUnique({ where: { id } })
|
||||
if (!license) {
|
||||
return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let validUntil = license.validUntil
|
||||
if (environment === 'production' && license.environment === 'demo') {
|
||||
const fourteenDays = 14 * 86400000
|
||||
if (license.validUntil && license.validUntil.getTime() - license.createdAt.getTime() <= fourteenDays + 86400000) {
|
||||
validUntil = null
|
||||
}
|
||||
} else if (environment === 'demo') {
|
||||
validUntil = new Date(Date.now() + 14 * 86400000)
|
||||
}
|
||||
|
||||
await db.license.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'active',
|
||||
environment,
|
||||
suspendReason: null,
|
||||
suspendedAt: null,
|
||||
// Clear cancellation if reactivating
|
||||
cancelledAt: null,
|
||||
cancellationEffectiveAt: null,
|
||||
cancellationReason: null,
|
||||
validUntil,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'license.reactivate',
|
||||
entity: 'License',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ key: license.key.slice(0, 16) + '…', environment, status: 'active' }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, status: 'active', environment })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
reason: z.string().min(1).max(300),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/licenses/[id]/suspend
|
||||
// Suspend (bloke) a license. Transitive suspension of module licenses.
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'reason required', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { reason } = parsed.data
|
||||
|
||||
const license = await db.license.findUnique({ where: { id } })
|
||||
if (!license) {
|
||||
return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await db.license.update({
|
||||
where: { id },
|
||||
data: { status: 'suspended', suspendReason: reason, suspendedAt: now },
|
||||
})
|
||||
|
||||
let suspendedModules = 0
|
||||
if (!license.parentId) {
|
||||
const r = await db.license.updateMany({
|
||||
where: { parentId: id, status: { not: 'suspended' } },
|
||||
data: { status: 'suspended', suspendReason: `parent suspended: ${reason}`, suspendedAt: now },
|
||||
})
|
||||
suspendedModules = r.count
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'license.suspend',
|
||||
entity: 'License',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ key: license.key.slice(0, 16) + '…', reason, suspendedModules }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, status: 'suspended', reason, suspendedModules })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
key: z.string().min(1),
|
||||
module: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
fingerprint: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/licenses/check
|
||||
// License-server check endpoint for client apps (restov1, aos-cms, ...).
|
||||
// Returns the full license bundle (core + modules) with feature flags the
|
||||
// client app should gate, plus billing/trial/cancellation state.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rateLimited = checkRateLimit(req, 'license')
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { key, module: moduleKey, domain, version, fingerprint } = parsed.data
|
||||
|
||||
const license = await db.license.findUnique({
|
||||
where: { key },
|
||||
include: { activations: true, company: true },
|
||||
})
|
||||
|
||||
if (!license) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'license_not_found',
|
||||
message: 'License key not found',
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
// If a module is requested, this key must be either the module license itself
|
||||
// or a core license with a child module license of that module key.
|
||||
// We type loosely to accommodate core vs. module shape differences (module
|
||||
// has its own `module` field, core has nested `modules`).
|
||||
let targetLicense: {
|
||||
status: string; validUntil: Date | null; trialEndsAt: Date | null;
|
||||
environment: string; product: string; plan: string; module: string | null;
|
||||
maxActivations: number; suspendReason: string | null; id: string;
|
||||
billingCycle: string; priceCents: number; discountPct: number;
|
||||
netPriceCents: number; cancelledAt: Date | null;
|
||||
cancellationEffectiveAt: Date | null; cancellationReason: string | null;
|
||||
noticePeriodDays: number; subscriptionStartsAt: Date | null;
|
||||
} = license
|
||||
if (moduleKey) {
|
||||
if (license.module === moduleKey) {
|
||||
// direct module license key
|
||||
} else if (!license.parentId) {
|
||||
// core license — look up child module (include company for consistency)
|
||||
const child = await db.license.findFirst({
|
||||
where: { parentId: license.id, module: moduleKey },
|
||||
include: { company: true, activations: true },
|
||||
})
|
||||
if (!child) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'module_not_licensed',
|
||||
message: `Module "${moduleKey}" is not licensed for this core license`,
|
||||
core: { key: license.key.slice(0, 16) + '…', product: license.product, status: license.status },
|
||||
}, { status: 403 })
|
||||
}
|
||||
targetLicense = child
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'module_mismatch',
|
||||
message: `This key is a module license for "${license.module}", not "${moduleKey}"`,
|
||||
}, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// Status computation (with cancellation handling)
|
||||
let status: 'active' | 'suspended' | 'expired' | 'revoked' | 'cancelled' | 'demo' | 'trial'
|
||||
const isRevoked = targetLicense.status === 'revoked'
|
||||
const isExpired =
|
||||
targetLicense.status === 'expired' ||
|
||||
(targetLicense.validUntil && targetLicense.validUntil < now)
|
||||
const isSuspended = targetLicense.status === 'suspended'
|
||||
const isCancelled = targetLicense.status === 'cancelled' ||
|
||||
(targetLicense.cancellationEffectiveAt && targetLicense.cancellationEffectiveAt <= now)
|
||||
const inTrial = targetLicense.trialEndsAt && now < targetLicense.trialEndsAt
|
||||
const coreBlocked =
|
||||
!license.parentId &&
|
||||
(license.status === 'suspended' || license.status === 'expired' ||
|
||||
license.status === 'revoked' || license.status === 'cancelled')
|
||||
|
||||
if (isRevoked) status = 'revoked'
|
||||
else if (isExpired) status = 'expired'
|
||||
else if (isCancelled) status = 'cancelled'
|
||||
else if (isSuspended || (targetLicense !== license && coreBlocked)) status = 'suspended'
|
||||
else if (inTrial) status = 'trial'
|
||||
else if (targetLicense.environment === 'demo') status = 'demo'
|
||||
else status = 'active'
|
||||
|
||||
// Granted modules (only for core licenses)
|
||||
const childModules = !license.parentId
|
||||
? await db.license.findMany({ where: { parentId: license.id } })
|
||||
: []
|
||||
const grantedModules = !license.parentId
|
||||
? childModules.map((m) => ({
|
||||
module: m.module,
|
||||
key: m.key.slice(0, 16) + '…',
|
||||
status:
|
||||
m.status === 'revoked' ? 'revoked'
|
||||
: m.status === 'cancelled' || (m.cancellationEffectiveAt && m.cancellationEffectiveAt <= now) ? 'cancelled'
|
||||
: m.status === 'expired' || (m.validUntil && m.validUntil < now) ? 'expired'
|
||||
: m.status === 'suspended' || coreBlocked ? 'suspended'
|
||||
: m.trialEndsAt && now < m.trialEndsAt ? 'trial'
|
||||
: m.environment === 'demo' ? 'demo'
|
||||
: 'active',
|
||||
environment: m.environment,
|
||||
validUntil: m.validUntil,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
// Touch activation if domain matches
|
||||
if (domain) {
|
||||
const activation = license.activations.find((a) => a.domain === domain)
|
||||
if (activation) {
|
||||
await db.licenseActivation.update({
|
||||
where: { id: activation.id },
|
||||
data: {
|
||||
lastCheck: now,
|
||||
version: version ?? activation.version,
|
||||
fingerprint: fingerprint ?? activation.fingerprint,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: `client:${domain ?? 'unknown'}`,
|
||||
action: ['active', 'trial', 'demo'].includes(status) ? 'license.check:ok' : 'license.check:blocked',
|
||||
entity: 'License',
|
||||
entityId: targetLicense.id,
|
||||
after: JSON.stringify({
|
||||
key: key.slice(0, 16) + '…',
|
||||
module: moduleKey ?? null,
|
||||
domain,
|
||||
status,
|
||||
environment: targetLicense.environment,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
// Feature flags the client app must honor
|
||||
const validStatuses = ['active', 'trial', 'demo']
|
||||
const features = {
|
||||
enabled: status === 'active',
|
||||
demoMode: status === 'demo',
|
||||
trialMode: status === 'trial',
|
||||
watermark: status === 'demo' || status === 'trial',
|
||||
blocked: ['suspended', 'expired', 'revoked', 'cancelled'].includes(status),
|
||||
banner:
|
||||
status === 'suspended' ? 'license_suspended'
|
||||
: status === 'expired' ? 'license_expired'
|
||||
: status === 'revoked' ? 'license_revoked'
|
||||
: status === 'cancelled' ? 'license_cancelled'
|
||||
: status === 'demo' ? 'license_demo'
|
||||
: status === 'trial' ? 'license_trial'
|
||||
: null,
|
||||
}
|
||||
|
||||
// Billing info (so the client portal can show subscription status)
|
||||
const billing = !license.parentId ? {
|
||||
billingCycle: license.billingCycle,
|
||||
priceCents: license.priceCents,
|
||||
discountPct: license.discountPct,
|
||||
netPriceCents: license.netPriceCents,
|
||||
trialEndsAt: license.trialEndsAt,
|
||||
subscriptionStartsAt: license.subscriptionStartsAt,
|
||||
cancelledAt: license.cancelledAt,
|
||||
cancellationEffectiveAt: license.cancellationEffectiveAt,
|
||||
cancellationReason: license.cancellationReason,
|
||||
noticePeriodDays: license.noticePeriodDays,
|
||||
validUntil: license.validUntil,
|
||||
} : undefined
|
||||
|
||||
return NextResponse.json({
|
||||
valid: validStatuses.includes(status),
|
||||
status,
|
||||
environment: targetLicense.environment,
|
||||
product: targetLicense.product,
|
||||
plan: targetLicense.plan,
|
||||
module: targetLicense.module,
|
||||
validUntil: targetLicense.validUntil,
|
||||
suspendReason: targetLicense.suspendReason ?? (coreBlocked && targetLicense !== license ? license.suspendReason : null),
|
||||
maxActivations: targetLicense.maxActivations,
|
||||
features,
|
||||
modules: grantedModules,
|
||||
billing,
|
||||
checkedAt: now.toISOString(),
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,80 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const PLANS = ['starter', 'pro', 'enterprise'] as const
|
||||
const ENVIRONMENTS = ['demo', 'production'] as const
|
||||
const BILLING_CYCLES = ['monthly', 'yearly'] as const
|
||||
const PLAN_PREFIX: Record<string, string> = { starter: 'ST', pro: 'PRO', enterprise: 'ENT' }
|
||||
|
||||
// Default monthly prices per plan (cents). Used when no explicit priceCents given.
|
||||
const PLAN_PRICES: Record<string, number> = { starter: 2900, pro: 5000, enterprise: 15000 }
|
||||
// Yearly discount applied on top of any agency discount (10% off yearly total).
|
||||
const YEARLY_DISCOUNT_PCT = 10
|
||||
// Default notice period (Kündigung frist): 90 days (3 months, German B2B law).
|
||||
const DEFAULT_NOTICE_PERIOD_DAYS = 90
|
||||
// Trial duration: 14 days free.
|
||||
const TRIAL_DAYS = 14
|
||||
|
||||
// Compute effective net price given base monthly price, billing cycle, and
|
||||
// agency discount pct. Returns the per-cycle price + discount breakdown.
|
||||
function computePricing(input: {
|
||||
monthlyPriceCents: number
|
||||
billingCycle: 'monthly' | 'yearly'
|
||||
agencyDiscountPct: number
|
||||
}) {
|
||||
const { monthlyPriceCents, billingCycle, agencyDiscountPct } = input
|
||||
const baseCents = billingCycle === 'yearly' ? monthlyPriceCents * 12 : monthlyPriceCents
|
||||
const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0
|
||||
const totalDiscountPct = agencyDiscountPct + yearlyPct
|
||||
const discountAmount = Math.round((baseCents * totalDiscountPct) / 100)
|
||||
const netPriceCents = baseCents - discountAmount
|
||||
return {
|
||||
priceCents: baseCents,
|
||||
discountPct: totalDiscountPct,
|
||||
netPriceCents,
|
||||
breakdown: {
|
||||
baseCents,
|
||||
agencyDiscountPct,
|
||||
yearlyDiscountPct: yearlyPct,
|
||||
totalDiscountPct,
|
||||
discountAmount,
|
||||
netPriceCents,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateKey(plan: string, module?: string): string {
|
||||
const prefix = PLAN_PREFIX[plan] ?? 'ST'
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const block = (n: number) =>
|
||||
Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
const modTag = module ? '-M' : ''
|
||||
return `AOS-${prefix}${modTag}-${block(4)}-${block(4)}-${block(4)}`
|
||||
}
|
||||
|
||||
async function generateToken(): Promise<string> {
|
||||
const crypto = await import('crypto')
|
||||
return 'sig_' + crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
product: z.string().min(1).max(120),
|
||||
plan: z.enum(PLANS),
|
||||
environment: z.enum(ENVIRONMENTS).default('production'),
|
||||
billingCycle: z.enum(BILLING_CYCLES).default('monthly'),
|
||||
module: z.string().max(60).optional(),
|
||||
parentId: z.string().optional(),
|
||||
maxActivations: z.number().int().min(1).max(100).default(1),
|
||||
validUntil: z.string().optional(),
|
||||
monthlyPriceCents: z.number().int().min(0).optional(),
|
||||
startTrial: z.boolean().optional(),
|
||||
noticePeriodDays: z.number().int().min(0).max(365).optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/licenses — issued licenses + asset/renewal register
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -14,10 +86,44 @@ export async function GET() {
|
||||
db.asset.findMany({ orderBy: { renewalDate: 'asc' } }),
|
||||
])
|
||||
|
||||
// Fetch module licenses separately (avoids relation include issues
|
||||
// when Prisma client is regenerated mid-process).
|
||||
const coreIds = licenses.filter((l) => !l.parentId).map((l) => l.id)
|
||||
const allModules = coreIds.length
|
||||
? await db.license.findMany({
|
||||
where: { parentId: { in: coreIds } },
|
||||
include: { company: true, activations: true },
|
||||
})
|
||||
: []
|
||||
const modulesByParent = new Map<string, typeof allModules>()
|
||||
for (const m of allModules) {
|
||||
if (!m.parentId) continue
|
||||
const arr = modulesByParent.get(m.parentId) ?? []
|
||||
arr.push(m)
|
||||
modulesByParent.set(m.parentId, arr)
|
||||
}
|
||||
const licensesWithModules = licenses.map((l) => ({
|
||||
...l,
|
||||
modules: !l.parentId ? (modulesByParent.get(l.id) ?? []) : [],
|
||||
}))
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'active').length
|
||||
const suspended = licenses.filter((l) => l.status === 'suspended').length
|
||||
const expired = licenses.filter((l) => l.status === 'expired').length
|
||||
const cancelled = licenses.filter((l) => l.status === 'cancelled').length
|
||||
const inTrial = licenses.filter(
|
||||
(l) => l.trialEndsAt && new Date(l.trialEndsAt) > new Date()
|
||||
).length
|
||||
const totalActivations = licenses.reduce((s, l) => s + l.activations.length, 0)
|
||||
const demoLicenses = licenses.filter((l) => l.environment === 'demo').length
|
||||
|
||||
// MRR estimate (sum of net monthly-equivalent of active non-cancelled licenses)
|
||||
const mrrCents = licenses
|
||||
.filter((l) => l.status === 'active' && !l.cancelledAt)
|
||||
.reduce((s, l) => {
|
||||
const monthly = l.billingCycle === 'yearly' ? Math.round(l.netPriceCents / 12) : l.netPriceCents
|
||||
return s + monthly
|
||||
}, 0)
|
||||
|
||||
const expiringSoon = assets.filter((a) => {
|
||||
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
|
||||
@@ -29,16 +135,20 @@ export async function GET() {
|
||||
.reduce((s, a) => s + (a.costCentsPerMonth ?? 0), 0)
|
||||
|
||||
return NextResponse.json({
|
||||
licenses,
|
||||
licenses: licensesWithModules,
|
||||
assets,
|
||||
stats: {
|
||||
activeLicenses,
|
||||
suspended,
|
||||
expired,
|
||||
cancelled,
|
||||
inTrial,
|
||||
totalActivations,
|
||||
expiringSoon: expiringSoon.length,
|
||||
expiredAssets: expiredAssets.length,
|
||||
monthlyRebill,
|
||||
demoLicenses,
|
||||
mrrCents,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -46,3 +156,157 @@ export async function GET() {
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/aos/licenses — issue a new license (core or module add-on)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'validation failed', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const {
|
||||
companyId, product, plan, environment, billingCycle, module: moduleKey,
|
||||
parentId, maxActivations, validUntil, monthlyPriceCents, startTrial, noticePeriodDays,
|
||||
} = parsed.data
|
||||
|
||||
const company = await db.company.findUnique({ where: { id: companyId } })
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If parentId given, validate the parent is a core license of same product+company.
|
||||
if (parentId) {
|
||||
const parent = await db.license.findUnique({ where: { id: parentId } })
|
||||
if (!parent) {
|
||||
return NextResponse.json({ error: 'parent license not found' }, { status: 404 })
|
||||
}
|
||||
if (parent.parentId) {
|
||||
return NextResponse.json({ error: 'parent must be a core license, not a module' }, { status: 400 })
|
||||
}
|
||||
if (parent.product !== product) {
|
||||
return NextResponse.json({ error: 'module product must match parent product' }, { status: 400 })
|
||||
}
|
||||
if (parent.companyId !== companyId) {
|
||||
return NextResponse.json({ error: 'module must belong to the same company as the parent' }, { status: 400 })
|
||||
}
|
||||
if (!moduleKey) {
|
||||
return NextResponse.json({ error: 'module key required for module add-on licenses' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique key (retry on collision)
|
||||
let key = generateKey(plan, moduleKey)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const existing = await db.license.findUnique({ where: { key } })
|
||||
if (!existing) break
|
||||
key = generateKey(plan, moduleKey)
|
||||
}
|
||||
|
||||
const token = await generateToken()
|
||||
const now = new Date()
|
||||
|
||||
// --- Pricing computation ---
|
||||
// Module licenses inherit the parent's pricing (no separate billing).
|
||||
const isModule = !!parentId
|
||||
const monthlyCents = monthlyPriceCents ?? PLAN_PRICES[plan] ?? 0
|
||||
// Agency discount (only for non-module, agency-owned clients).
|
||||
// If the company's parentAgency is an approved agency, use its discount.
|
||||
let agencyDiscountPct = 0
|
||||
if (!isModule && company.parentAgencyId) {
|
||||
const agency = await db.company.findUnique({ where: { id: company.parentAgencyId } })
|
||||
if (agency?.isAgency && agency.agencyStatus === 'approved') {
|
||||
agencyDiscountPct = agency.agencyDiscountPct ?? 0
|
||||
}
|
||||
}
|
||||
const pricing = isModule
|
||||
? { priceCents: 0, discountPct: 0, netPriceCents: 0, breakdown: null }
|
||||
: computePricing({
|
||||
monthlyPriceCents: monthlyCents,
|
||||
billingCycle,
|
||||
agencyDiscountPct,
|
||||
})
|
||||
|
||||
// --- Trial + subscription start ---
|
||||
// Trial applies to: production core licenses issued to end clients (not modules,
|
||||
// not demos, not agency-internal). Can be overridden via startTrial param.
|
||||
const wantsTrial = startTrial ?? (!isModule && environment === 'production')
|
||||
const trialEndsAt = wantsTrial ? new Date(now.getTime() + TRIAL_DAYS * 86400000) : null
|
||||
const subscriptionStartsAt = wantsTrial
|
||||
? new Date(trialEndsAt!.getTime() + 86400000) // day 15
|
||||
: now
|
||||
|
||||
// --- validUntil ---
|
||||
// - explicit validUntil wins
|
||||
// - demo: 14 days from now
|
||||
// - trial + monthly: 1 month from subscription start (first billing cycle)
|
||||
// - trial + yearly: 1 year from subscription start
|
||||
// - no trial + monthly: 1 month from now
|
||||
// - no trial + yearly: 1 year from now
|
||||
const validUntilDate = validUntil
|
||||
? new Date(validUntil)
|
||||
: environment === 'demo'
|
||||
? new Date(now.getTime() + TRIAL_DAYS * 86400000)
|
||||
: billingCycle === 'yearly'
|
||||
? new Date(subscriptionStartsAt.getTime() + 365 * 86400000)
|
||||
: new Date(subscriptionStartsAt.getTime() + 30 * 86400000)
|
||||
|
||||
const license = await db.license.create({
|
||||
data: {
|
||||
key,
|
||||
companyId,
|
||||
product,
|
||||
plan,
|
||||
status: 'active',
|
||||
environment,
|
||||
module: moduleKey ?? null,
|
||||
parentId: parentId ?? null,
|
||||
maxActivations,
|
||||
validUntil: validUntilDate,
|
||||
token,
|
||||
noticePeriodDays: noticePeriodDays ?? DEFAULT_NOTICE_PERIOD_DAYS,
|
||||
trialEndsAt,
|
||||
subscriptionStartsAt,
|
||||
billingCycle,
|
||||
priceCents: pricing.priceCents,
|
||||
discountPct: pricing.discountPct,
|
||||
netPriceCents: pricing.netPriceCents,
|
||||
},
|
||||
include: { company: true, activations: true },
|
||||
})
|
||||
|
||||
// Audit log (GoBD) — include full pricing breakdown
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'license.issue',
|
||||
entity: 'License',
|
||||
entityId: license.id,
|
||||
after: JSON.stringify({
|
||||
key,
|
||||
product,
|
||||
plan,
|
||||
environment,
|
||||
billingCycle,
|
||||
module: moduleKey ?? null,
|
||||
parentId: parentId ?? null,
|
||||
company: company.name,
|
||||
agencyDiscountPct,
|
||||
maxActivations,
|
||||
trialEndsAt,
|
||||
subscriptionStartsAt,
|
||||
validUntil: validUntilDate,
|
||||
pricing: pricing.breakdown,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Reference in new issue
Block a user