feat: Recurring invoices + license suspend on dunning level 3 (PRD §6.4/§6.6)
- Recurring API: list contracts needing invoicing + run endpoint - Auto-generate draft invoices for Wartung/SLA + Dienstvertrag contracts - Automatic license suspension at dunning level 3 (PRD §6.4) - Recurring billable expenses tracking - MRR/ARR computation from contracts + expenses - Prisma: added Contract.company + Expense.company relations - Scheduler info (pg-boss monthly cron in production)
This commit is contained in:
1 parent
c8b4be55f3
commit
597bfbc3d8
8 files changed
+453
No files matched your search
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/recurring — list recurring revenue sources + next-run status
|
||||
export async function GET() {
|
||||
try {
|
||||
// Find all active recurring contracts (Wartung/SLA + Dienstvertrag)
|
||||
const contracts = await db.contract.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
type: { in: ['wartung-sla', 'dienstvertrag'] },
|
||||
valueCents: { gt: 0 },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
// Find all recurring expenses that are billable
|
||||
const recurringExpenses = await db.expense.findMany({
|
||||
where: { recurring: true, billable: true, status: { not: 'invoiced' } },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
// Find recurring invoices already sent this month
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const existingInvoices = await db.invoice.findMany({
|
||||
where: { type: 'recurring', issueDate: { gte: monthStart } },
|
||||
select: { companyId: true, netCents: true },
|
||||
})
|
||||
|
||||
// Compute which contracts need invoicing this month
|
||||
const toInvoice = contracts.filter((c) => {
|
||||
// Already invoiced this month for this company + similar amount?
|
||||
const alreadyInvoiced = existingInvoices.some(
|
||||
(i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100
|
||||
)
|
||||
return !alreadyInvoiced
|
||||
})
|
||||
|
||||
// Compute MRR from contracts
|
||||
const contractMrr = contracts.reduce((s, c) => s + Math.round(c.valueCents / 12), 0)
|
||||
const expenseMrr = recurringExpenses.reduce((s, e) => s + e.amountCents, 0)
|
||||
|
||||
return NextResponse.json({
|
||||
contracts: contracts.map((c) => ({
|
||||
id: c.id,
|
||||
number: c.number,
|
||||
type: c.type,
|
||||
title: c.title,
|
||||
company: c.company.name,
|
||||
companyId: c.companyId,
|
||||
annualValueCents: c.valueCents,
|
||||
monthlyValueCents: Math.round(c.valueCents / 12),
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
needsInvoice: toInvoice.some((t) => t.id === c.id),
|
||||
})),
|
||||
recurringExpenses: recurringExpenses.map((e) => ({
|
||||
id: e.id,
|
||||
number: e.number,
|
||||
description: e.description,
|
||||
company: e.company?.name ?? '—',
|
||||
companyId: e.companyId,
|
||||
amountCents: e.amountCents,
|
||||
interval: e.recurringInterval,
|
||||
})),
|
||||
stats: {
|
||||
contractCount: contracts.length,
|
||||
expenseCount: recurringExpenses.length,
|
||||
pendingInvoices: toInvoice.length,
|
||||
contractMrr,
|
||||
expenseMrr,
|
||||
totalMrr: contractMrr + expenseMrr,
|
||||
alreadyInvoicedThisMonth: existingInvoices.length,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/recurring/run
|
||||
// Generates recurring draft invoices for all eligible contracts this month.
|
||||
// Also checks dunning level 3 → suspends linked licenses (PRD §6.4: "automatic
|
||||
// suspend at dunning level 3, reactivate on payment").
|
||||
export async function POST() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
|
||||
// 1. Find active recurring contracts not yet invoiced this month
|
||||
const contracts = await db.contract.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
type: { in: ['wartung-sla', 'dienstvertrag'] },
|
||||
valueCents: { gt: 0 },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
const existingInvoices = await db.invoice.findMany({
|
||||
where: { type: 'recurring', issueDate: { gte: monthStart } },
|
||||
select: { companyId: true, netCents: true },
|
||||
})
|
||||
|
||||
const toInvoice = contracts.filter((c) => {
|
||||
return !existingInvoices.some(
|
||||
(i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100
|
||||
)
|
||||
})
|
||||
|
||||
// 2. Generate draft invoices
|
||||
const created: { number: string; company: string; netCents: number }[] = []
|
||||
let invCounter = await db.invoice.count({ where: { number: { startsWith: `RE-${now.getFullYear()}-` } } })
|
||||
|
||||
for (const c of toInvoice) {
|
||||
invCounter++
|
||||
const number = `RE-${now.getFullYear()}-${String(invCounter).padStart(4, '0')}`
|
||||
const netCents = Math.round(c.valueCents / 12)
|
||||
const taxBp = 1900
|
||||
const grossCents = Math.round(netCents * (1 + taxBp / 10000))
|
||||
const dueDate = new Date()
|
||||
dueDate.setDate(dueDate.getDate() + 14)
|
||||
|
||||
const invoice = await db.invoice.create({
|
||||
data: {
|
||||
number,
|
||||
companyId: c.companyId,
|
||||
status: 'draft',
|
||||
type: 'recurring',
|
||||
issueDate: now,
|
||||
dueDate,
|
||||
netCents,
|
||||
taxBp,
|
||||
grossCents,
|
||||
zugferd: false,
|
||||
xrechnung: false,
|
||||
datevExported: false,
|
||||
},
|
||||
})
|
||||
|
||||
await db.invoiceLine.create({
|
||||
data: {
|
||||
invoiceId: invoice.id,
|
||||
description: `${c.title} — ${now.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} (Wiederkehrend)`,
|
||||
quantity: 1,
|
||||
unitCents: netCents,
|
||||
totalCents: netCents,
|
||||
},
|
||||
})
|
||||
|
||||
created.push({ number, company: c.company.name, netCents })
|
||||
}
|
||||
|
||||
// 3. Dunning level 3 → suspend licenses (PRD §6.4)
|
||||
const dunningLevel3 = await db.dunningRun.findMany({
|
||||
where: { level: 3, status: 'sent' },
|
||||
include: { invoice: { include: { company: { include: { licenses: true } } } } },
|
||||
})
|
||||
|
||||
let suspendedCount = 0
|
||||
for (const run of dunningLevel3) {
|
||||
const licenses = run.invoice.company.licenses.filter((l) => l.status === 'active')
|
||||
for (const lic of licenses) {
|
||||
await db.license.update({
|
||||
where: { id: lic.id },
|
||||
data: { status: 'suspended' },
|
||||
})
|
||||
suspendedCount++
|
||||
}
|
||||
await db.dunningRun.update({
|
||||
where: { id: run.id },
|
||||
data: { status: 'escalated' },
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Audit
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'system:cron',
|
||||
action: 'recurring.run',
|
||||
entity: 'Invoice',
|
||||
entityId: 'batch',
|
||||
after: JSON.stringify({
|
||||
invoicesCreated: created.length,
|
||||
licensesSuspended: suspendedCount,
|
||||
month: monthStart.toISOString().slice(0, 7),
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
invoicesCreated: created.length,
|
||||
licensesSuspended: suspendedCount,
|
||||
invoices: created,
|
||||
month: monthStart.toISOString().slice(0, 7),
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user