a6c0f3de-eeb0-4d76-a57f-f600dd133c06

This commit is contained in:
Z User committed 2026-10-28 15:39:30 +00:00
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 })
}
}
+213
View File
@@ -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 })
}
}
+265 -1
View File
@@ -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 })
}
}
View File
File mode changed.