2026-09-27 09:05:13 +02:00
import type { FastifyInstance } from 'fastify' ;
import { z } from 'zod' ;
import { randomUUID } from 'node:crypto' ;
import type { PoolConnection } from 'mysql2/promise' ;
2026-09-27 16:51:19 +02:00
import { calculatePrice } from '@kc/platform/pricing' ;
2026-09-27 09:05:13 +02:00
import { one , query , run , tx } from '../../core/db.js' ;
import { audit } from '../../core/audit.js' ;
import { enqueue } from '../../core/jobs.js' ;
import { clientIp , requireAuth , requirePermission } from '../../core/auth.js' ;
import { badRequest , conflict , forbidden , notFound } from '../../core/errors.js' ;
import { can , canInOrg } from '../../core/policy.js' ;
import type { KcModule } from '../../core/module.js' ;
2026-09-28 23:00:31 +02:00
import { config } from '../../core/config.js' ;
2026-09-27 09:05:13 +02:00
import { renderInvoicePdf , type CompanySettings , type InvoiceForPdf } from './pdf.js' ;
/ * * K a u f m ä n n i s c h e s R u n d e n ( h a l b a u f ) , w i e i n @ k c / p l a t f o r m / p r i c i n g – h i e r l o k a l , w e i l R e c h n u n g s p o s i t i o n e n
* ( Freitext , Dezimalmenge ) sich nicht in das Produkt - Preisschema von calculatePrice pressen lassen . * /
const divRound = ( n : number , d : number ) : number = > Math . floor ( ( n * 2 + d ) / ( d * 2 ) ) ;
2026-09-28 11:01:19 +02:00
function lineAmounts ( unitNetCents : number , quantity : number , taxBp : number , discountBp = 0 ) {
const net = divRound ( Math . round ( unitNetCents * quantity ) * ( 10000 - discountBp ) , 10000 ) ;
2026-09-27 09:05:13 +02:00
const tax = divRound ( net * taxBp , 10000 ) ;
return { net , tax , gross : net + tax } ;
}
async function nextInvoiceNumber ( c : PoolConnection , prefix : string ) : Promise < string > {
await run ( "UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'invoice'" , [ ] , c ) ;
return ` ${ prefix } - ${ ( await one ( 'SELECT LAST_INSERT_ID() AS n' , [ ] , c ) ) ! . n } ` ;
}
async function settings ( ) : Promise < CompanySettings > {
const s = await one ( 'SELECT * FROM company_settings WHERE id = 1' ) ;
return {
name : s?.name ? ? null , street : s?.street ? ? null , zip : s?.zip ? ? null , city : s?.city ? ? null , country : s?.country ? ? 'DE' ,
taxNumber : s?.tax_number ? ? null , vatId : s?.vat_id ? ? null , bankName : s?.bank_name ? ? null , iban : s?.iban ? ? null , bic : s?.bic ? ? null ,
invoicePrefix : s?.invoice_prefix ? ? 'RE' , defaultDueDays : Number ( s ? . default_due_days ? ? 14 ) ,
paymentMethods : ( typeof s ? . payment_methods === 'string' ? JSON . parse ( s . payment_methods ) : s ? . payment_methods ) ? ? [ 'Überweisung' ] ,
footerText : s?.footer_text ? ? null ,
} ;
}
const complete = ( s : CompanySettings ) = > ! ! ( s . name && s . street && s . zip && s . city && ( s . taxNumber || s . vatId ) ) ;
2026-09-28 11:01:19 +02:00
const itemView = ( i : any ) = > ( { id : i.id , contractId : i.contract_id , description : i.description , quantity : Number ( i . quantity ) , unitPriceNetCents : i.unit_price_net_cents , taxBp : i.tax_bp , discountBp : i.discount_bp , netCents : i.net_cents , taxCents : i.tax_cents , grossCents : i.gross_cents } ) ;
2026-09-27 09:05:13 +02:00
const invoiceView = ( v : any ) = > ( {
id : v.id , number : v . number , orgId : v.org_id , orgName : v.org_name , customerNumber : v.customer_number , status : v.status ,
issueDate : v.issue_date , dueDate : v.due_date , overdue : v.status === 'open' && v . due_date && new Date ( v . due_date ) < new Date ( ) ,
currency : v.currency , totalNetCents : v.total_net_cents , totalTaxCents : v.total_tax_cents , totalGrossCents : v.total_gross_cents ,
paymentMethod : v.payment_method , note : v.note , paidAt : v.paid_at , cancelsInvoiceId : v.cancels_invoice_id , cancelledByInvoiceId : v.cancelled_by_invoice_id ,
createdAt : v.created_at , issuedAt : v.issued_at , cancelledAt : v.cancelled_at ,
} ) ;
const INVOICE_SQL = 'SELECT v.*, g.name AS org_name, g.customer_number FROM invoices v JOIN organizations g ON g.id = v.org_id' ;
async function loadInvoice ( id : string ) {
const v = await one ( ` ${ INVOICE_SQL } WHERE v.id = ? ` , [ id ] ) ;
if ( ! v ) return null ;
const items = await query ( 'SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order' , [ id ] ) ;
return { v , items } ;
}
const notify = ( event : string , extra : Record < string , unknown > , key : string , correlationId : string ) = > enqueue ( 'discord.notify' , { event , . . . extra } , { idempotencyKey : key , correlationId } ) ;
feat: SMTP-Stack mit konfigurierbaren Mailvorlagen
Bisher gab es nur zwei fest im Code verdrahtete E-Mails (Einladung,
Passwort-Reset) und die SMTP-Verbindung kam ausschließlich aus
Umgebungsvariablen. Jetzt:
- SMTP-Verbindung unter Einstellungen > E-Mail konfigurierbar (Host,
Port, Verschlüsselung, Zugangsdaten verschlüsselt gespeichert,
Absender), mit Testmail-Versand. Die Mail-Logik wandert dafür nach
@kc/platform/mail, damit API und Worker sie gemeinsam nutzen.
- Mailvorlagen-Verwaltung: jedes Ereignis hat eine feste "Definition"
(Name, Auslöser, verfügbare Platzhalter/Daten), Betreff/Text sind
editierbar, je Vorlage einzeln aktivierbar, mit Testversand anhand
von Beispieldaten. Unbekannte Platzhalter werden beim Speichern
abgelehnt.
- Neue Ereignisse verdrahtet: Ticket erstellt (Bestätigung an Kunde),
Personal antwortet auf Ticket (Benachrichtigung an Kunde), Rechnung
ausgestellt, sowie die zuvor zurückgestellte Funktion "Panel-
Zugangsdaten per E-Mail senden" nach einem Passwort-Reset. Diese
laufen über die Jobqueue (mail.template), analog zu discord.notify.
- Versandprotokoll (letzte 100 Versuche, ohne Inhalte) einsehbar unter
Einstellungen > E-Mail.
- Migration 022, Rechte email.read (Admin+) / email.write (Superadmin).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:53:29 +02:00
const mailTo = ( to : string , template : string , vars : Record < string , string > , key : string , correlationId : string ) = > enqueue ( 'mail.template' , { to , key : template , vars } , { idempotencyKey : key , correlationId } ) ;
const deDate = ( iso : string ) = > new Date ( iso ) . toLocaleDateString ( 'de-DE' ) ;
const deMoney = ( cents : number ) = > ( cents / 100 ) . toLocaleString ( 'de-DE' , { style : 'currency' , currency : 'EUR' } ) ;
2026-09-27 09:05:13 +02:00
export const invoicesModule : KcModule = {
name : 'invoices' ,
permissions : {
staff : { support : [ 'invoices.read' ] , accounting : [ 'invoices.read' , 'invoices.write' ] , admin : [ 'invoices.read' , 'invoices.write' ] , superadmin : [ 'invoices.read' , 'invoices.write' , 'settings.write' ] } ,
org : { owner : [ 'invoices.read' ] , admin : [ 'invoices.read' ] , member : [ 'invoices.read' ] } ,
} ,
register ( app : FastifyInstance ) {
// ---- Firmenstammdaten (für den Rechnungskopf) ---------------------------
app . get ( '/admin/company-settings' , async ( req ) = > { requirePermission ( req , 'invoices.read' ) ; const s = await settings ( ) ; return { . . . s , complete : complete ( s ) } ; } ) ;
app . put ( '/admin/company-settings' , async ( req ) = > {
const a = requirePermission ( req , 'settings.write' ) ;
const b = z . object ( {
name : z.string ( ) . trim ( ) . max ( 200 ) . optional ( ) , street : z.string ( ) . trim ( ) . max ( 200 ) . optional ( ) , zip : z.string ( ) . trim ( ) . max ( 20 ) . optional ( ) , city : z.string ( ) . trim ( ) . max ( 100 ) . optional ( ) , country : z.string ( ) . length ( 2 ) . optional ( ) ,
taxNumber : z.string ( ) . trim ( ) . max ( 50 ) . optional ( ) , vatId : z.string ( ) . trim ( ) . max ( 30 ) . optional ( ) , bankName : z.string ( ) . trim ( ) . max ( 150 ) . optional ( ) , iban : z.string ( ) . trim ( ) . max ( 34 ) . optional ( ) , bic : z.string ( ) . trim ( ) . max ( 11 ) . optional ( ) ,
invoicePrefix : z.string ( ) . trim ( ) . regex ( /^[A-Za-z0-9]{1,10}$/ ) . optional ( ) , defaultDueDays : z.number ( ) . int ( ) . min ( 0 ) . max ( 180 ) . optional ( ) ,
paymentMethods : z.array ( z . string ( ) . trim ( ) . min ( 1 ) . max ( 50 ) ) . min ( 1 ) . max ( 10 ) . optional ( ) , footerText : z.string ( ) . trim ( ) . max ( 500 ) . nullable ( ) . optional ( ) ,
} ) . parse ( req . body ) ;
const cols : Record < string , string > = { name : 'name' , street : 'street' , zip : 'zip' , city : 'city' , country : 'country' , taxNumber : 'tax_number' , vatId : 'vat_id' , bankName : 'bank_name' , iban : 'iban' , bic : 'bic' , invoicePrefix : 'invoice_prefix' , defaultDueDays : 'default_due_days' , footerText : 'footer_text' } ;
const sets : string [ ] = [ ] ; const params : unknown [ ] = [ ] ;
for ( const [ k , col ] of Object . entries ( cols ) ) if ( ( b as Record < string , unknown > ) [ k ] !== undefined ) { sets . push ( ` ${ col } = ? ` ) ; params . push ( ( b as Record < string , unknown > ) [ k ] ) ; }
if ( b . paymentMethods ) { sets . push ( 'payment_methods = ?' ) ; params . push ( JSON . stringify ( b . paymentMethods ) ) ; }
if ( sets . length ) await run ( ` UPDATE company_settings SET ${ sets . join ( ', ' ) } WHERE id = 1 ` , params ) ;
await audit ( { actorType : 'user' , actorId : a.user.id , action : 'company_settings.update' , resourceType : 'company_settings' , resourceId : '1' , correlationId : req.correlationId , ip : clientIp ( req ) , after : { . . . b , iban : b.iban ? '***' : undefined } } ) ;
return { ok : true } ;
} ) ;
// ---- Rechnungen: Entwurf, Positionen, Ausstellen, Bezahlt, Storno -------
app . get ( '/invoices' , async ( req ) = > {
const a = requireAuth ( req ) ;
const q = z . object ( { org : z.string ( ) . uuid ( ) . optional ( ) , status : z.enum ( [ 'draft' , 'open' , 'paid' , 'cancelled' ] ) . optional ( ) } ) . parse ( req . query ) ;
const staff = can ( a . principal , 'invoices.read' ) ;
const myOrgs = a . principal . memberships . map ( ( m ) = > m . orgId ) ;
if ( ! staff && myOrgs . length === 0 ) return [ ] ;
if ( staff && q . org && ! ( await one ( 'SELECT 1 AS x FROM organizations WHERE id = ?' , [ q . org ] ) ) ) throw notFound ( ) ;
const orgs = staff ? ( q . org ? [ q . org ] : null ) : myOrgs ;
const orgPlaceholders = orgs ? orgs . map ( ( ) = > '?' ) . join ( ',' ) : '' ;
const rows = await query (
` ${ INVOICE_SQL } WHERE ( ${ orgs ? ` v.org_id IN ( ${ orgPlaceholders } ) ` : '1=1' } ) AND (? IS NULL OR v.status = ?) ${ staff ? '' : " AND v.status != 'draft'" } ORDER BY v.created_at DESC LIMIT 200 ` ,
[ . . . ( orgs ? ? [ ] ) , q . status ? ? null , q . status ? ? null ] ) ;
return rows . map ( invoiceView ) ;
} ) ;
2026-09-27 21:19:48 +02:00
/ * * M a h n w e s e n - Ü b e r b l i c k : ü b e r f ä l l i g e R e c h n u n g e n u n d a k t i v e V e r t r ä g e , d i e v e r m u t l i c h n e u i n R e c h n u n g g e s t e l l t w e r d e n m ü s s e n
* ( letzte Rechnung liegt länger zurück als die Verlängerungslaufzeit des Vertrags ) . Nur Personal . * /
app . get ( '/invoices/reminders' , async ( req ) = > {
requirePermission ( req , 'invoices.read' ) ;
const overdue = ( await query ( ` ${ INVOICE_SQL } WHERE v.status = 'open' AND v.due_date < CURDATE() ORDER BY v.due_date ` ) ) . map ( invoiceView ) ;
const contracts = await query ( `
SELECT c . id , c . number , c . org_id , c . started_at , c . renewal_term_months , g . name AS org_name , g . customer_number ,
JSON_VALUE ( c . price_snapshot_json , '$.name' ) AS product_name , JSON_VALUE ( c . price_snapshot_json , '$.recurring.gross' ) AS last_gross_cents ,
( SELECT MAX ( i . issue_date ) FROM invoices i JOIN invoice_items ii ON ii . invoice_id = i . id WHERE ii . contract_id = c . id AND i . status IN ( 'open' , 'paid' ) ) AS last_invoiced
FROM contracts c JOIN organizations g ON g . id = c . org_id
WHERE c . status = 'active' AND c . renewal = 'auto' AND c . renewal_term_months > 0 ` );
const dueContracts = contracts
. map ( ( c ) = > { const since = c . last_invoiced ? ? c . started_at ; const due = new Date ( since ) ; due . setMonth ( due . getMonth ( ) + c . renewal_term_months ) ;
return { id : c.id , number : c . number , orgId : c.org_id , orgName : c.org_name , customerNumber : c.customer_number , productName : c.product_name , lastInvoicedAt : c.last_invoiced , dueSince : due.toISOString ( ) . slice ( 0 , 10 ) , lastGrossCents : c.last_gross_cents === null ? null : Number ( c . last_gross_cents ) } ; } )
. filter ( ( c ) = > new Date ( c . dueSince ) <= new Date ( ) )
. sort ( ( a , b ) = > a . dueSince . localeCompare ( b . dueSince ) ) ;
return { overdueInvoices : overdue , dueContracts } ;
} ) ;
2026-09-27 09:05:13 +02:00
app . post ( '/invoices' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ;
const b = z . object ( { orgId : z.string ( ) . uuid ( ) , note : z.string ( ) . trim ( ) . max ( 500 ) . optional ( ) , paymentMethod : z.string ( ) . max ( 50 ) . optional ( ) } ) . parse ( req . body ) ;
if ( ! ( await one ( 'SELECT 1 AS x FROM organizations WHERE id = ?' , [ b . orgId ] ) ) ) throw notFound ( ) ;
const id = randomUUID ( ) ;
await run ( 'INSERT INTO invoices (id, org_id, note, payment_method, created_by) VALUES (?,?,?,?,?)' , [ id , b . orgId , b . note ? ? null , b . paymentMethod ? ? null , a . user . id ] ) ;
await audit ( { actorType : 'user' , actorId : a.user.id , orgId : b.orgId , action : 'invoice.create' , resourceType : 'invoice' , resourceId : id , correlationId : req.correlationId , ip : clientIp ( req ) } ) ;
return { id } ;
} ) ;
app . get ( '/invoices/:id' , async ( req ) = > {
const a = requireAuth ( req ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const staff = can ( a . principal , 'invoices.read' ) ;
const res = await loadInvoice ( id ) ;
if ( ! res || ! canInOrg ( a . principal , res . v . org_id , 'invoices.read' , 'invoices.read' ) || ( ! staff && res . v . status === 'draft' ) ) throw notFound ( ) ;
return { . . . invoiceView ( res . v ) , items : res.items.map ( itemView ) , canWrite : staff } ;
} ) ;
/** Ersetzt die Positionen eines Entwurfs vollständig (einfacher als Einzel-CRUD, ausreichend für eine Entwurfsphase). */
app . put ( '/invoices/:id/items' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const b = z . object ( { items : z.array ( z . object ( {
2026-09-28 11:01:19 +02:00
description : z.string ( ) . trim ( ) . min ( 1 ) . max ( 300 ) , quantity : z.number ( ) . positive ( ) . max ( 100000 ) , unitPriceNetCents : z.number ( ) . int ( ) . min ( 0 ) . max ( 100 _000_00 ) , taxBp : z.number ( ) . int ( ) . min ( 0 ) . max ( 3000 ) , discountBp : z.number ( ) . int ( ) . min ( 0 ) . max ( 10000 ) . default ( 0 ) , contractId : z.string ( ) . uuid ( ) . optional ( ) ,
2026-09-27 09:05:13 +02:00
} ) ) . min ( 1 ) . max ( 100 ) } ) . parse ( req . body ) ;
const v = await one ( 'SELECT * FROM invoices WHERE id = ?' , [ id ] ) ; if ( ! v ) throw notFound ( ) ;
if ( v . status !== 'draft' ) throw conflict ( 'Nur Entwürfe können bearbeitet werden. Ausgestellte Rechnungen sind unveränderlich (nur Storno möglich).' , 'INVOICE_NOT_DRAFT' ) ;
let net = 0 , tax = 0 , gross = 0 ;
await tx ( async ( c ) = > {
await run ( 'DELETE FROM invoice_items WHERE invoice_id = ?' , [ id ] , c ) ;
for ( const [ idx , it ] of b . items . entries ( ) ) {
2026-09-28 11:01:19 +02:00
const a2 = lineAmounts ( it . unitPriceNetCents , it . quantity , it . taxBp , it . discountBp ) ; net += a2 . net ; tax += a2 . tax ; gross += a2 . gross ;
await run ( 'INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, discount_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)' ,
[ randomUUID ( ) , id , it . contractId ? ? null , it . description , it . quantity , it . unitPriceNetCents , it . taxBp , it . discountBp , a2 . net , a2 . tax , a2 . gross , idx ] , c ) ;
2026-09-27 09:05:13 +02:00
}
await run ( 'UPDATE invoices SET total_net_cents = ?, total_tax_cents = ?, total_gross_cents = ? WHERE id = ?' , [ net , tax , gross , id ] , c ) ;
} ) ;
await audit ( { actorType : 'user' , actorId : a.user.id , orgId : v.org_id , action : 'invoice.items.update' , resourceType : 'invoice' , resourceId : id , correlationId : req.correlationId , ip : clientIp ( req ) , after : { items : b.items.length , totalGrossCents : gross } } ) ;
return { ok : true } ;
} ) ;
/** Aus einem Vertrag die letzte Preisangabe als Positionsvorschlag übernehmen (nichts wird automatisch gespeichert). */
2026-09-27 16:51:19 +02:00
/** Vorschlag für eine Rechnungsposition aus dem eingefrorenen Preis-Snapshot eines Vertrags (die laufende Periode). */
2026-09-27 09:05:13 +02:00
app . get ( '/invoices/suggest-from-contract/:contractId' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { contractId } = z . object ( { contractId : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const c = await one ( 'SELECT * FROM contracts WHERE id = ?' , [ contractId ] ) ; if ( ! c ) throw notFound ( ) ;
2026-09-27 16:51:19 +02:00
const snap = typeof c . price_snapshot_json === 'string' ? JSON . parse ( c . price_snapshot_json ) : c . price_snapshot_json ;
return { orgId : c.org_id , items : [ { description : snap.name , quantity : 1 , unitPriceNetCents : snap.recurring?.net ? ? 0 , taxBp : snap.taxBp ? ? 1900 , contractId } ] } ;
} ) ;
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
app . get ( '/invoices/suggest-from-product/:productId' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { productId } = z . object ( { productId : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
2026-09-27 18:35:34 +02:00
const q = z . object ( { termMonths : z.coerce.number ( ) . int ( ) . min ( 1 ) . max ( 120 ) . optional ( ) } ) . parse ( req . query ) ;
const p = await one ( 'SELECT p.*, v.id AS vid, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?' , [ productId ] ) ;
2026-09-27 16:51:19 +02:00
if ( ! p ) throw notFound ( ) ;
2026-09-27 18:35:34 +02:00
let recurringCents = p . recurring_cents ; let label = p . name ;
if ( q . termMonths !== undefined ) {
const tier = await one ( 'SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?' , [ p . vid , q . termMonths ] ) ;
if ( ! tier ) throw badRequest ( 'Diese Laufzeit wird für dieses Produkt nicht angeboten' , 'TERM_NOT_AVAILABLE' ) ;
recurringCents = tier . recurring_cents ; label = ` ${ p . name } ( ${ q . termMonths } Monate) ` ;
}
const price = calculatePrice ( { basis : p.price_basis , setupCents : p.setup_cents , recurringCents , taxBp : p.tax_bp , interval : p.billing_interval , quantity : 1 , discountBp : 0 } ) ;
2026-09-27 16:51:19 +02:00
const items = [ ] ;
if ( price . setup . net > 0 ) items . push ( { description : ` Einrichtung: ${ p . name } ` , quantity : 1 , unitPriceNetCents : price.setup.net , taxBp : p.tax_bp } ) ;
2026-09-27 18:35:34 +02:00
if ( price . recurring . net > 0 ) items . push ( { description : label , quantity : 1 , unitPriceNetCents : price.recurring.net , taxBp : p.tax_bp } ) ;
if ( items . length === 0 ) items . push ( { description : label , quantity : 1 , unitPriceNetCents : 0 , taxBp : p.tax_bp } ) ;
2026-09-27 16:51:19 +02:00
return { items } ;
2026-09-27 09:05:13 +02:00
} ) ;
2026-09-27 18:23:31 +02:00
/** Vorschlag aus der Domain-Aufstellung: der dort errechnete Verkaufspreis (nie der Einkauf), plus Einrichtung falls vorhanden. */
app . get ( '/invoices/suggest-from-domain/:domainRecordId' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { domainRecordId } = z . object ( { domainRecordId : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const d = await one ( 'SELECT * FROM domain_records WHERE id = ?' , [ domainRecordId ] ) ; if ( ! d ) throw notFound ( ) ;
if ( d . sell_net_cents === null ) throw badRequest ( 'Für diese Domain ist noch kein Verkaufspreis hinterlegt (Aufschlag fehlt unter Domains → Preisliste). Bitte dort ergänzen oder die Position von Hand erfassen.' , 'NO_SELL_PRICE' ) ;
const items = [ ] ;
if ( d . setup_cost_cents > 0 ) { const setupNet = Math . round ( ( d . setup_cost_cents * 10000 ) / ( 10000 + d . tax_bp ) ) ; items . push ( { description : ` Einrichtung ${ d . domain } ` , quantity : 1 , unitPriceNetCents : setupNet , taxBp : d.tax_bp } ) ; }
items . push ( { description : d.term_months ? ` ${ d . domain } ( ${ d . term_months } Monate) ` : d . domain , quantity : 1 , unitPriceNetCents : d.sell_net_cents , taxBp : d.tax_bp } ) ;
return { orgId : d.org_id , items } ;
} ) ;
2026-09-27 09:05:13 +02:00
app . post ( '/invoices/:id/issue' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const b = z . object ( { dueDate : z.string ( ) . date ( ) . optional ( ) } ) . parse ( req . body ? ? { } ) ;
const v = await one ( 'SELECT * FROM invoices WHERE id = ?' , [ id ] ) ; if ( ! v ) throw notFound ( ) ;
if ( v . status !== 'draft' ) throw conflict ( 'Die Rechnung wurde bereits ausgestellt.' , 'INVOICE_NOT_DRAFT' ) ;
const items = await query ( 'SELECT 1 AS x FROM invoice_items WHERE invoice_id = ?' , [ id ] ) ;
if ( items . length === 0 ) throw badRequest ( 'Eine Rechnung ohne Positionen kann nicht ausgestellt werden.' , 'NO_ITEMS' ) ;
const s = await settings ( ) ; if ( ! complete ( s ) ) throw badRequest ( 'Die Firmenstammdaten sind unvollständig (Name, Anschrift, Steuernummer/USt-IdNr.). Bitte unter Einstellungen ergänzen, bevor Rechnungen ausgestellt werden.' , 'COMPANY_SETTINGS_INCOMPLETE' ) ;
feat: SMTP-Stack mit konfigurierbaren Mailvorlagen
Bisher gab es nur zwei fest im Code verdrahtete E-Mails (Einladung,
Passwort-Reset) und die SMTP-Verbindung kam ausschließlich aus
Umgebungsvariablen. Jetzt:
- SMTP-Verbindung unter Einstellungen > E-Mail konfigurierbar (Host,
Port, Verschlüsselung, Zugangsdaten verschlüsselt gespeichert,
Absender), mit Testmail-Versand. Die Mail-Logik wandert dafür nach
@kc/platform/mail, damit API und Worker sie gemeinsam nutzen.
- Mailvorlagen-Verwaltung: jedes Ereignis hat eine feste "Definition"
(Name, Auslöser, verfügbare Platzhalter/Daten), Betreff/Text sind
editierbar, je Vorlage einzeln aktivierbar, mit Testversand anhand
von Beispieldaten. Unbekannte Platzhalter werden beim Speichern
abgelehnt.
- Neue Ereignisse verdrahtet: Ticket erstellt (Bestätigung an Kunde),
Personal antwortet auf Ticket (Benachrichtigung an Kunde), Rechnung
ausgestellt, sowie die zuvor zurückgestellte Funktion "Panel-
Zugangsdaten per E-Mail senden" nach einem Passwort-Reset. Diese
laufen über die Jobqueue (mail.template), analog zu discord.notify.
- Versandprotokoll (letzte 100 Versuche, ohne Inhalte) einsehbar unter
Einstellungen > E-Mail.
- Migration 022, Rechte email.read (Admin+) / email.write (Superadmin).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:53:29 +02:00
const org = await one ( 'SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id, b.billing_email, b.contact_name FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?' , [ v . org_id ] ) ;
2026-09-27 09:05:13 +02:00
const due = b . dueDate ? ? new Date ( Date . now ( ) + s . defaultDueDays * 86400000 ) . toISOString ( ) . slice ( 0 , 10 ) ;
2026-09-28 10:29:00 +02:00
// Absender-/Empfängerdaten werden JETZT eingefroren (nicht mehr live beim PDF-Abruf gelesen) – ändert sich später
// IBAN, Steuernummer oder die Kundenanschrift, bleibt diese schon ausgestellte Rechnung unverändert, wie es sein muss.
const sellerSnap = { name : s.name , street : s.street , zip : s.zip , city : s.city , country : s.country , taxNumber : s.taxNumber , vatId : s.vatId , bankName : s.bankName , iban : s.iban , bic : s.bic , footerText : s.footerText } ;
const buyerSnap = { name : org?.company || org ? . name || null , street : org?.street ? ? null , zip : org?.zip ? ? null , city : org?.city ? ? null , country : org?.country ? ? 'DE' , vatId : org?.vat_id ? ? null } ;
2026-09-27 09:05:13 +02:00
const number = await tx ( async ( c ) = > {
2026-09-28 10:29:00 +02:00
// Zeile sperren statt nur zu lesen: verhindert, dass zwei gleichzeitige "Ausstellen"-Aufrufe beide eine Nummer
// ziehen (Nummernlücke) oder beide durchlaufen. Der Verlierer sieht nach dem Warten status≠'draft' und bricht ab,
// ohne je eine Nummer verbraucht zu haben.
const locked = await one ( 'SELECT status FROM invoices WHERE id = ? FOR UPDATE' , [ id ] , c ) ;
if ( ! locked || locked . status !== 'draft' ) throw conflict ( 'Die Rechnung wurde inzwischen von einem anderen Vorgang ausgestellt.' , 'STALE_STATE' ) ;
2026-09-27 09:05:13 +02:00
const n = await nextInvoiceNumber ( c , s . invoicePrefix ) ;
2026-09-28 10:29:00 +02:00
await run ( "UPDATE invoices SET number = ?, status = 'open', issue_date = CURDATE(), due_date = ?, issued_at = UTC_TIMESTAMP(3), seller_snapshot_json = ?, buyer_snapshot_json = ? WHERE id = ?" ,
[ n , due , JSON . stringify ( sellerSnap ) , JSON . stringify ( buyerSnap ) , id ] , c ) ;
2026-09-27 09:05:13 +02:00
return n ;
} ) ;
await audit ( { actorType : 'user' , actorId : a.user.id , orgId : v.org_id , action : 'invoice.issue' , resourceType : 'invoice' , resourceId : id , correlationId : req.correlationId , ip : clientIp ( req ) , after : { number } } ) ;
await notify ( 'invoice.issued' , { number , gross : v.total_gross_cents } , ` invoice.issued: ${ id } ` , req . correlationId ) ;
2026-09-28 23:00:31 +02:00
if ( org ? . billing_email ) await mailTo ( org . billing_email , 'invoice_issued' , { name : org.contact_name || org . company || org . name , number , amount : deMoney ( v . total_gross_cents ) , dueDate : deDate ( due ) , invoiceLink : ` ${ config . baseUrl } /rechnungen/ ${ id } ` } , ` mail:invoice.issued: ${ id } ` , req . correlationId ) ;
2026-09-27 09:05:13 +02:00
return { ok : true , number } ;
} ) ;
app . post ( '/invoices/:id/mark-paid' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
2026-09-28 10:29:00 +02:00
const v = await one ( 'SELECT org_id FROM invoices WHERE id = ?' , [ id ] ) ; if ( ! v ) throw notFound ( ) ;
await tx ( async ( c ) = > {
const locked = await one ( 'SELECT status FROM invoices WHERE id = ? FOR UPDATE' , [ id ] , c ) ;
if ( ! locked || locked . status !== 'open' ) throw conflict ( 'Nur ausgestellte, noch offene Rechnungen können als bezahlt markiert werden.' , 'INVOICE_NOT_OPEN' ) ;
await run ( "UPDATE invoices SET status = 'paid', paid_at = UTC_TIMESTAMP(3) WHERE id = ?" , [ id ] , c ) ;
} ) ;
2026-09-27 09:05:13 +02:00
await audit ( { actorType : 'user' , actorId : a.user.id , orgId : v.org_id , action : 'invoice.paid' , resourceType : 'invoice' , resourceId : id , correlationId : req.correlationId , ip : clientIp ( req ) } ) ;
return { ok : true } ;
} ) ;
/ * * S t o r n o : e r z e u g t e i n e n e u e , a u s g e s t e l l t e R e c h n u n g m i t u m g e k e h r t e n V o r z e i c h e n u n d v e r w e i s t a u f d a s O r i g i n a l .
2026-09-28 10:29:00 +02:00
* Die ursprüngliche Rechnung wird NIE gelöscht oder verändert ( gesetzliche Vorgabe ) . Ü bernimmt den eingefrorenen
* Absender - /Empfänger-Schnappschuss des Originals (nicht die evtl. inzwischen geänderten aktuellen Stammdaten). */
2026-09-27 09:05:13 +02:00
app . post ( '/invoices/:id/cancel' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const b = z . object ( { reason : z.string ( ) . trim ( ) . max ( 300 ) . optional ( ) } ) . parse ( req . body ? ? { } ) ;
2026-09-28 10:29:00 +02:00
const v = await one ( 'SELECT * FROM invoices WHERE id = ?' , [ id ] ) ; if ( ! v ) throw notFound ( ) ;
const items = await query ( 'SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order' , [ id ] ) ;
2026-09-27 09:05:13 +02:00
const s = await settings ( ) ;
const creditId = randomUUID ( ) ;
const number = await tx ( async ( c ) = > {
2026-09-28 10:29:00 +02:00
const locked = await one ( 'SELECT status, seller_snapshot_json, buyer_snapshot_json FROM invoices WHERE id = ? FOR UPDATE' , [ id ] , c ) ;
if ( ! locked || locked . status !== 'open' ) throw conflict ( 'Nur offene Rechnungen können storniert werden. Eine bezahlte Rechnung erst als Storno mit Rückzahlungsvermerk erfassen.' , 'INVOICE_NOT_OPEN' ) ;
const jstr = ( x : unknown ) = > ( x == null ? null : typeof x === 'string' ? x : JSON.stringify ( x ) ) ;
2026-09-27 09:05:13 +02:00
const n = await nextInvoiceNumber ( c , s . invoicePrefix ) ;
2026-09-28 10:29:00 +02:00
await run ( 'INSERT INTO invoices (id, number, org_id, status, issue_date, due_date, total_net_cents, total_tax_cents, total_gross_cents, note, seller_snapshot_json, buyer_snapshot_json, cancels_invoice_id, created_by, issued_at) VALUES (?,?,?,\'open\',CURDATE(),CURDATE(),?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))' ,
[ creditId , n , v . org_id , - v . total_net_cents , - v . total_tax_cents , - v . total_gross_cents , b . reason ? ` Storno zu ${ v . number } : ${ b . reason } ` : ` Storno zu ${ v . number } ` , jstr ( locked . seller_snapshot_json ) , jstr ( locked . buyer_snapshot_json ) , id , a . user . id ] , c ) ;
2026-09-28 11:01:19 +02:00
for ( const it of items ) await run ( 'INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, discount_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)' ,
[ randomUUID ( ) , creditId , it . contract_id , it . description , - Number ( it . quantity ) , it . unit_price_net_cents , it . tax_bp , it . discount_bp , - it . net_cents , - it . tax_cents , - it . gross_cents , it . sort_order ] , c ) ;
2026-09-27 09:05:13 +02:00
await run ( "UPDATE invoices SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3), cancelled_by_invoice_id = ? WHERE id = ?" , [ creditId , id ] , c ) ;
return n ;
} ) ;
await audit ( { actorType : 'user' , actorId : a.user.id , orgId : v.org_id , action : 'invoice.cancel' , resourceType : 'invoice' , resourceId : id , correlationId : req.correlationId , ip : clientIp ( req ) , after : { creditNumber : number } } ) ;
return { ok : true , creditInvoiceId : creditId , creditNumber : number } ;
} ) ;
app . delete ( '/invoices/:id' , async ( req ) = > {
const a = requirePermission ( req , 'invoices.write' ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
2026-09-28 10:29:00 +02:00
const v = await one ( 'SELECT org_id FROM invoices WHERE id = ?' , [ id ] ) ; if ( ! v ) throw notFound ( ) ;
await tx ( async ( c ) = > {
const locked = await one ( 'SELECT status FROM invoices WHERE id = ? FOR UPDATE' , [ id ] , c ) ;
if ( ! locked ) throw notFound ( ) ;
if ( locked . status !== 'draft' ) throw forbidden ( 'Ausgestellte Rechnungen können nicht gelöscht werden, nur storniert.' , 'INVOICE_NOT_DRAFT' ) ;
await run ( 'DELETE FROM invoice_items WHERE invoice_id = ?' , [ id ] , c ) ; await run ( 'DELETE FROM invoices WHERE id = ?' , [ id ] , c ) ;
} ) ;
2026-09-27 09:05:13 +02:00
await audit ( { actorType : 'user' , actorId : a.user.id , orgId : v.org_id , action : 'invoice.delete_draft' , resourceType : 'invoice' , resourceId : id , correlationId : req.correlationId , ip : clientIp ( req ) } ) ;
return { ok : true } ;
} ) ;
app . get ( '/invoices/:id/pdf' , async ( req , reply ) = > {
const a = requireAuth ( req ) ; const { id } = z . object ( { id : z.string ( ) . uuid ( ) } ) . parse ( req . params ) ;
const staff = can ( a . principal , 'invoices.read' ) ;
const res = await loadInvoice ( id ) ;
if ( ! res || ! canInOrg ( a . principal , res . v . org_id , 'invoices.read' , 'invoices.read' ) || ( ! staff && res . v . status === 'draft' ) ) throw notFound ( ) ;
if ( res . v . status === 'draft' ) throw badRequest ( 'Für Entwürfe gibt es noch kein PDF. Bitte zuerst ausstellen.' , 'INVOICE_DRAFT' ) ;
2026-09-28 10:29:00 +02:00
const jparse = ( x : unknown ) = > ( x == null ? null : typeof x === 'string' ? JSON . parse ( x ) : x ) ;
let seller = jparse ( res . v . seller_snapshot_json ) as CompanySettings | null ;
let customer = jparse ( res . v . buyer_snapshot_json ) as InvoiceForPdf [ 'customer' ] | null ;
if ( ! seller || ! customer ) { // vor Migration 020 ausgestellt: kein eingefrorener Stand vorhanden, Rückfall auf die damals übliche Live-Anzeige
const org = await one ( 'SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?' , [ res . v . org_id ] ) ;
seller ? ? = await settings ( ) ;
customer ? ? = { name : org ! . company || org ! . name , street : org ! . street , zip : org ! . zip , city : org ! . city , country : org ! . country ? ? 'DE' , vatId : org ! . vat_id ? ? null } ;
}
2026-09-27 09:05:13 +02:00
const inv : InvoiceForPdf = {
number : res . v . number , issueDate : res.v.issue_date , dueDate : res.v.due_date , status : res.v.status , paymentMethod : res.v.payment_method , note : res.v.note ,
totalNetCents : res.v.total_net_cents , totalTaxCents : res.v.total_tax_cents , totalGrossCents : res.v.total_gross_cents ,
2026-09-28 11:01:19 +02:00
customer , items : res.items.map ( ( i ) = > ( { description : i.description , quantity : Number ( i . quantity ) , unitPriceNetCents : i.unit_price_net_cents , taxBp : i.tax_bp , discountBp : i.discount_bp , netCents : i.net_cents , taxCents : i.tax_cents , grossCents : i.gross_cents } ) ) ,
2026-09-27 09:05:13 +02:00
} ;
reply . header ( 'content-type' , 'application/pdf' ) . header ( 'content-disposition' , ` inline; filename=" ${ res . v . number } .pdf" ` ) ;
2026-09-28 10:29:00 +02:00
return reply . send ( renderInvoicePdf ( inv , seller ) ) ;
2026-09-27 09:05:13 +02:00
} ) ;
} ,
} ;