First commit

This commit is contained in:
2026-01-12 13:12:46 +01:00
parent b2d9501f6d
commit a1fbd8acf5
4413 changed files with 1245183 additions and 0 deletions

22
src/app.ts Normal file
View File

@@ -0,0 +1,22 @@
import Fastify from 'fastify';
import { config } from './config/index.js';
import { healthRoutes } from './routes/health.routes.js';
import { invoiceRoutes } from './routes/invoice.routes.js';
import { errorHandler } from './errors/error-handler.js';
export function buildApp() {
const app = Fastify({
logger: {
level: config.logLevel,
},
});
// Error Handler
app.setErrorHandler(errorHandler);
// Routes registrieren
app.register(healthRoutes);
app.register(invoiceRoutes);
return app;
}

5
src/config/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
logLevel: process.env.LOG_LEVEL || 'info',
} as const;

View File

@@ -0,0 +1,61 @@
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import { ZodError } from 'zod';
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown[]
) {
super(message);
this.name = 'ApiError';
}
static badRequest(message: string, details?: unknown[]): ApiError {
return new ApiError(400, 'BAD_REQUEST', message, details);
}
static validationError(details: unknown[]): ApiError {
return new ApiError(400, 'VALIDATION_ERROR', 'Validierungsfehler', details);
}
static internalError(message: string = 'Interner Serverfehler'): ApiError {
return new ApiError(500, 'INTERNAL_ERROR', message);
}
}
export function errorHandler(
error: FastifyError | ApiError | ZodError | Error,
request: FastifyRequest,
reply: FastifyReply
): void {
request.log.error(error);
if (error instanceof ZodError) {
reply.status(400).send({
error: 'VALIDATION_ERROR',
message: 'Ungültige Eingabedaten',
details: error.errors.map(e => ({
path: e.path.join('.'),
message: e.message,
})),
});
return;
}
if (error instanceof ApiError) {
reply.status(error.statusCode).send({
error: error.code,
message: error.message,
details: error.details,
});
return;
}
// Unbekannter Fehler
reply.status(500).send({
error: 'INTERNAL_ERROR',
message: 'Ein unerwarteter Fehler ist aufgetreten',
});
}

18
src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { buildApp } from './app.js';
import { config } from './config/index.js';
const app = buildApp();
const start = async () => {
try {
await app.listen({ port: config.port, host: config.host });
console.log(`Server läuft auf http://${config.host}:${config.port}`);
console.log(`Health Check: http://${config.host}:${config.port}/health`);
console.log(`QR-Bill API: POST http://${config.host}:${config.port}/api/v1/invoice/qr-bill`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();

View File

@@ -0,0 +1,11 @@
import type { FastifyInstance } from 'fastify';
export async function healthRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
fastify.get('/health/ready', async () => {
return { status: 'ready', timestamp: new Date().toISOString() };
});
}

View File

@@ -0,0 +1,34 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { qrInvoiceRequestSchema, type QRInvoiceRequestInput } from '../schemas/qr-invoice.schema.js';
import { qrBillService } from '../services/qr-bill.service.js';
import { ApiError } from '../errors/error-handler.js';
export async function invoiceRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/api/v1/invoice/qr-bill',
async (request: FastifyRequest<{ Body: QRInvoiceRequestInput }>, reply: FastifyReply) => {
// Validierung mit Zod
const parseResult = qrInvoiceRequestSchema.safeParse(request.body);
if (!parseResult.success) {
throw ApiError.validationError(
parseResult.error.errors.map(e => ({
path: e.path.join('.'),
message: e.message,
}))
);
}
// PDF generieren
const pdfBuffer = await qrBillService.generateQRBill(parseResult.data);
// Response Headers setzen
reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', `attachment; filename="qr-bill-${Date.now()}.pdf"`)
.header('Content-Length', pdfBuffer.length);
return reply.send(pdfBuffer);
}
);
}

View File

@@ -0,0 +1,134 @@
import { z } from 'zod';
/**
* Modulo 10 rekursiv Prüfsummenvalidierung für QR-Referenz
*/
function validateMod10Recursive(reference: string): boolean {
const table = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5];
let carry = 0;
for (let i = 0; i < reference.length - 1; i++) {
carry = table[(carry + parseInt(reference[i], 10)) % 10];
}
return (10 - carry) % 10 === parseInt(reference[reference.length - 1], 10);
}
/**
* Modulo 97 Prüfsummenvalidierung für Creditor Reference (ISO 11649)
*/
function validateMod97(reference: string): boolean {
const rearranged = reference.substring(4) + reference.substring(0, 4);
const numeric = rearranged.split('').map(c => {
const code = c.charCodeAt(0);
return code >= 65 ? (code - 55).toString() : c;
}).join('');
return BigInt(numeric) % 97n === 1n;
}
/**
* Prüft ob IID einer QR-IBAN entspricht (30000-31999)
*/
function isQrIban(iban: string): boolean {
const cleanIban = iban.replace(/\s/g, '').toUpperCase();
if (!cleanIban.startsWith('CH') && !cleanIban.startsWith('LI')) {
return false;
}
const iid = parseInt(cleanIban.substring(4, 9), 10);
return iid >= 30000 && iid <= 31999;
}
// IBAN Schema (Schweiz/Liechtenstein)
const ibanSchema = z.string()
.transform(val => val.replace(/\s/g, '').toUpperCase())
.refine(
val => /^(CH|LI)\d{2}[A-Z0-9]{5}\d{12}$/.test(val),
'Ungültiges IBAN-Format. Muss CH oder LI IBAN sein.'
);
// Strukturierte Adresse
const structuredAddressSchema = z.object({
name: z.string().min(1, 'Name ist erforderlich').max(70, 'Name darf max. 70 Zeichen haben'),
street: z.string().max(70, 'Strasse darf max. 70 Zeichen haben').optional(),
buildingNumber: z.string().max(16, 'Hausnummer darf max. 16 Zeichen haben').optional(),
postalCode: z.string().min(1, 'PLZ ist erforderlich').max(16, 'PLZ darf max. 16 Zeichen haben'),
city: z.string().min(1, 'Ort ist erforderlich').max(35, 'Ort darf max. 35 Zeichen haben'),
country: z.string().length(2, 'Land muss ISO 3166-1 alpha-2 sein (z.B. CH)').regex(/^[A-Z]{2}$/, 'Land muss Grossbuchstaben sein'),
});
// Referenz Schema
const referenceSchema = z.object({
type: z.enum(['QRR', 'SCOR', 'NON']),
value: z.string().optional(),
}).refine(
(ref) => {
if (ref.type === 'QRR') {
if (!ref.value || !/^\d{27}$/.test(ref.value)) {
return false;
}
return validateMod10Recursive(ref.value);
}
if (ref.type === 'SCOR') {
if (!ref.value || !/^RF\d{2}[A-Z0-9]{1,21}$/.test(ref.value)) {
return false;
}
return validateMod97(ref.value);
}
if (ref.type === 'NON') {
return !ref.value || ref.value === '';
}
return false;
},
{
message: 'Referenz-Wert ungültig oder stimmt nicht mit Referenz-Typ überein',
}
);
// Hauptschema für QR-Invoice Request
export const qrInvoiceRequestSchema = z.object({
creditor: z.object({
iban: ibanSchema,
address: structuredAddressSchema,
}),
amount: z.number()
.min(0.01, 'Betrag muss mindestens 0.01 sein')
.max(999999999.99, 'Betrag darf max. 999999999.99 sein')
.refine(val => Number.isFinite(val) && Math.round(val * 100) === val * 100, 'Betrag darf max. 2 Dezimalstellen haben')
.optional(),
currency: z.enum(['CHF', 'EUR'], {
errorMap: () => ({ message: 'Währung muss CHF oder EUR sein' }),
}),
reference: referenceSchema.optional().default({ type: 'NON' }),
debtor: z.object({
address: structuredAddressSchema,
}).optional(),
additionalInformation: z.object({
message: z.string().max(140, 'Mitteilung darf max. 140 Zeichen haben').optional(),
billingInformation: z.string().max(140, 'Rechnungsinformationen dürfen max. 140 Zeichen haben').optional(),
}).optional(),
options: z.object({
language: z.enum(['de', 'fr', 'it', 'en']).default('de'),
separate: z.boolean().default(true),
}).optional().default({}),
}).superRefine((data, ctx) => {
const qrIban = isQrIban(data.creditor.iban);
// QRR erfordert QR-IBAN
if (data.reference?.type === 'QRR' && !qrIban) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'QR-Referenz (QRR) erfordert eine QR-IBAN (IID 30000-31999)',
path: ['creditor', 'iban'],
});
}
// SCOR/NON erfordert reguläre IBAN
if ((data.reference?.type === 'SCOR' || data.reference?.type === 'NON') && qrIban) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'SCOR/NON Referenz erfordert eine reguläre IBAN (nicht QR-IBAN)',
path: ['creditor', 'iban'],
});
}
});
export type QRInvoiceRequestInput = z.input<typeof qrInvoiceRequestSchema>;
export type QRInvoiceRequestOutput = z.output<typeof qrInvoiceRequestSchema>;

View File

@@ -0,0 +1,111 @@
import PDFDocument from 'pdfkit';
import { SwissQRBill } from 'swissqrbill/pdf';
import type { Data, PDFOptions } from 'swissqrbill/types';
import type { QRInvoiceRequestOutput } from '../schemas/qr-invoice.schema.js';
export class QRBillService {
/**
* Generiert ein PDF mit Swiss QR-Bill
*/
async generateQRBill(request: QRInvoiceRequestOutput): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
// PDF Document erstellen (A4)
const pdf = new PDFDocument({
size: 'A4',
margin: 0,
autoFirstPage: true,
bufferPages: true,
});
// Buffer sammeln
pdf.on('data', (chunk: Buffer) => chunks.push(chunk));
pdf.on('end', () => resolve(Buffer.concat(chunks)));
pdf.on('error', reject);
// Daten für swissqrbill aufbereiten
const qrBillData = this.mapToSwissQRBillData(request);
const options = this.mapToSwissQRBillOptions(request);
// SwissQRBill erstellen und an PDF anhängen
const qrBill = new SwissQRBill(qrBillData, options);
qrBill.attachTo(pdf);
// PDF finalisieren
pdf.end();
});
}
/**
* Mappt Request-Daten auf swissqrbill Data Format
*/
private mapToSwissQRBillData(request: QRInvoiceRequestOutput): Data {
const data: Data = {
creditor: {
account: request.creditor.iban,
name: request.creditor.address.name,
address: request.creditor.address.street || '',
buildingNumber: request.creditor.address.buildingNumber || undefined,
zip: request.creditor.address.postalCode,
city: request.creditor.address.city,
country: request.creditor.address.country,
},
currency: request.currency,
};
// Betrag (optional)
if (request.amount !== undefined) {
data.amount = request.amount;
}
// Referenz
if (request.reference && request.reference.type !== 'NON' && request.reference.value) {
data.reference = request.reference.value.replace(/\s/g, '');
}
// Debitor (optional)
if (request.debtor) {
data.debtor = {
name: request.debtor.address.name,
address: request.debtor.address.street || '',
buildingNumber: request.debtor.address.buildingNumber || undefined,
zip: request.debtor.address.postalCode,
city: request.debtor.address.city,
country: request.debtor.address.country,
};
}
// Nachricht
if (request.additionalInformation?.message) {
data.message = request.additionalInformation.message;
}
// Strukturierte Rechnungsinformationen
if (request.additionalInformation?.billingInformation) {
data.additionalInformation = request.additionalInformation.billingInformation;
}
return data;
}
/**
* Mappt Request-Optionen auf swissqrbill PDFOptions
*/
private mapToSwissQRBillOptions(request: QRInvoiceRequestOutput): PDFOptions {
const languageMap: Record<string, 'DE' | 'EN' | 'FR' | 'IT'> = {
de: 'DE',
en: 'EN',
fr: 'FR',
it: 'IT',
};
return {
language: languageMap[request.options?.language || 'de'] || 'DE',
outlines: request.options?.separate !== false,
scissors: request.options?.separate !== false,
};
}
}
export const qrBillService = new QRBillService();

View File

@@ -0,0 +1,80 @@
/**
* Strukturierte Adresse (Typ "S") - seit November 2025 obligatorisch
*/
export interface StructuredAddress {
name: string;
street?: string;
buildingNumber?: string;
postalCode: string;
city: string;
country: string;
}
/**
* Referenz-Typen nach SIX Spezifikation
*/
export type ReferenceType = 'QRR' | 'SCOR' | 'NON';
/**
* Währungen
*/
export type Currency = 'CHF' | 'EUR';
/**
* Sprachen für den Zahlteil
*/
export type Language = 'de' | 'fr' | 'it' | 'en';
/**
* Referenz
*/
export interface Reference {
type: ReferenceType;
value?: string;
}
/**
* Zusätzliche Informationen
*/
export interface AdditionalInformation {
message?: string;
billingInformation?: string;
}
/**
* Output-Optionen
*/
export interface OutputOptions {
language?: Language;
separate?: boolean;
}
/**
* Hauptstruktur für QR-Invoice Request
*/
export interface QRInvoiceRequest {
creditor: {
iban: string;
address: StructuredAddress;
};
amount?: number;
currency: Currency;
reference?: Reference;
debtor?: {
address: StructuredAddress;
};
additionalInformation?: AdditionalInformation;
options?: OutputOptions;
}
/**
* API Error Response
*/
export interface ApiErrorResponse {
error: string;
message: string;
details?: Array<{
path: string;
message: string;
}>;
}