First commit
This commit is contained in:
22
src/app.ts
Normal file
22
src/app.ts
Normal 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
5
src/config/index.ts
Normal 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;
|
||||
61
src/errors/error-handler.ts
Normal file
61
src/errors/error-handler.ts
Normal 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
18
src/index.ts
Normal 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();
|
||||
11
src/routes/health.routes.ts
Normal file
11
src/routes/health.routes.ts
Normal 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() };
|
||||
});
|
||||
}
|
||||
34
src/routes/invoice.routes.ts
Normal file
34
src/routes/invoice.routes.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
134
src/schemas/qr-invoice.schema.ts
Normal file
134
src/schemas/qr-invoice.schema.ts
Normal 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>;
|
||||
111
src/services/qr-bill.service.ts
Normal file
111
src/services/qr-bill.service.ts
Normal 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();
|
||||
80
src/types/qr-invoice.types.ts
Normal file
80
src/types/qr-invoice.types.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user