Warum dieses Ticket nicht mal eben war

Frontend, Backend und der ganze Zauber dazwischen – am Beispiel individueller Lohngegenstände

Why, Hello There

  • James Tophoven 1st of his name
  • Softwareentwickler bei fastdocs seit November 2022
  • Diverse Jobs hier und da, aber größtenteils hier
My Photo

Gleiche Sprache sprechen

Same Language

Tech Stack

Frontend - Backend

...Frontend, Backend und alles, was dazugehört.

Frontend

Der Teil, der mit dem User interagiert - die Oberfläche des Programms.

  • Formulare
  • Buttons
  • ...

Läuft im Browser / App

Frontend HTML, CSS und JavaScript
Analogie: Lichtschalter

Backend

Der Teil, den niemand sieht (aber jeder braucht).

  • Daten prüfen, verarbeiten und speichern
  • E-Mails versenden
  • Rechte und Sicherheit
  • ...

Läuft auf dem Server / Cloud

Backend JavaScript
Analogie: Sicherungskasten

HTTP API

Die Lieferstrecke zwischen Frontend und Backend.

Legt fest:

  • wie man etwas anfragt
  • was man anfragen darf
  • in welchem Format die Antwort kommt
API
Analogie: Kellner

Schema

Der Bauplan der Daten.

Definiert, wie Daten aussehen müssen

  • Feldname
  • Typ (Text, Zahl, Datum)
  • Pflicht / optional
Schema
Analogie: Steuererklärung

Firebase / Cloud

Fertige Bausteine für Infrastruktur statt alles selbst bauen.

Bietet fertige Services:

  • Datenbank
  • Authentifizierung
  • Dateispeicher
  • Hosting

Ist der Server / Cloud

Firebase
Analogie: Baukasten

Akt 1
Am Anfang war alles einfach

Der ursprüngliche Plan - Bewegungsdaten

  • Mandanten haben einen eigenen Zugang
  • Definierte Lohngegenstände können übermittelt werden
  • Gemeinsame API als Vertrag
  • Wird extern entwickelt von Tobi
  • ...war teuer
Tobi
Viel Geld, bitte!
Fastdocs
ok

Zwei Welten, ein Vertrag

Steuerzentrale - Mandantenzentrale Steuerzentrale - Mandantenzentrale hervorgehoben
  • Unterschiedliche Technologien
  • Solange API und Schema definiert sind → Systeme kompatibel

Das alte Schema – sehr spezifisch



						// MonthYear schema for YYYY-MM format
						const MonthYearSchema = z
							.string()
							.regex(/^\d{4}-\d{2}$/, 'Invalid date format, must be YYYY-MM')
							.refine((val) => !isNaN(Date.parse(val + '-01')), {
								message: 'Invalid month-year',
								path: ['date'],
							})
						
						// Function to create a MonthYearSchema with date range validation
						function createMonthYearWithRangeSchema(
							offsetMonths: number = DEFAULT_OFFSET_MONTHS,
							messageOverride?: string
						) {
							return MonthYearSchema.refine(
								(date) => {
									const { minDate, maxDate } = getValidMonthYearRange(offsetMonths)
									return date >= minDate && date <= maxDate
								},
								{
									message:
										messageOverride ||
										`date must be within current month ±${offsetMonths} month${offsetMonths !== 1 ? 's' : ''
										}`,
									path: ['date'],
								}
							)
						}
						
						// DateRange schema for YYYY-MM-DD format
						const DateRangeSchema = z
							.object({
								from: z
									.string()
									.regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format, must be YYYY-MM-DD')
									.refine((val) => !isNaN(Date.parse(val)), {
										message: 'Invalid date format for "from"',
									}),
								to: z
									.string()
									.regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format, must be YYYY-MM-DD')
									.refine((val) => !isNaN(Date.parse(val)), {
										message: 'Invalid date format for "to"',
									}),
							})
							.refine((data) => new Date(data.from) <= new Date(data.to), {
								message: '"from" date must be before or equal to "to" date',
							})
						
						// Validators for increments
						const hundredthHourIncrement = (val: number) => {
							// Check if value is in 0.01h increments by comparing to nearest 0.01
							const nearestHundredth = Math.round(val * 100) / 100
							return Math.abs(val - nearestHundredth) < Number.EPSILON
						}
						
						const HundredthHourSchema = (path: string) =>
							z.number().refine(hundredthHourIncrement, {
								message: 'Value must be in 0.01h increments',
								path: [path],
							})
						
						const halfDayIncrement = (val: number) => {
							// Check if value is in 0.5d increments by comparing to nearest 0.5
							const nearestHalf = Math.round(val * 2) / 2
							return Math.abs(val - nearestHalf) < Number.EPSILON
						}
						
						const HalfDaySchema = (path: string) =>
							z.number().refine(halfDayIncrement, {
								message: 'Value must be in 0.5d increments',
								path: [path],
							})
						
						const CommentSchema = z.string().optional()
						
						// Base Bewegungsdaten schema with type discriminator
						const BewegungsdatenBaseSchema = z.object({
							id: z.string().uuid(),
							type: z.enum(BEWEGUNGSDATEN_TYPE_VALUES),
							comment: CommentSchema,
							isCorrection: z.boolean().optional(),
							previousValue: z.number().optional(),
						})
						
						// Specific Bewegungsdaten Schemas
						
						// Arbeitsstunden
						const ArbeitsstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Arbeitsstunden'),
							arbeitsstunden: HundredthHourSchema('arbeitsstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Urlaubstage
						const UrlaubstageSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Urlaubstage'),
							urlaubstage: HalfDaySchema('urlaubstage'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// KrankMitLohnfortzahlung
						const KrankMitLohnfortzahlungSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('KrankMitLohnfortzahlung'),
							tage: HalfDaySchema('tage'),
							dateRange: DateRangeSchema.refine(
								(data) => {
									const fromDate = new Date(data.from)
									const minDate = new Date(
										getValidMonthYearRange(DEFAULT_OFFSET_MONTHS).minDate + '-01'
									)
									return fromDate >= minDate
								},
								{
									message: `"from" date must be from current month -${DEFAULT_OFFSET_MONTHS} months or later`,
									path: ['dateRange', 'from'],
								}
							),
							mitAU: z.boolean(),
						})
						
						// Ueberstunden
						const UeberstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Ueberstunden'),
							ueberstunden: HundredthHourSchema('ueberstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Einmalzahlung
						const EinmalzahlungSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Einmalzahlung'),
							amountInCents: z.number().int(),
							date: MonthYearSchema,
						})
						
						// Spesen/Verpflegungsmehraufwand
						const SpesenVerpflegungsmehraufwandSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('SpesenVerpflegungsmehraufwand'),
							amountInCents: z.number().int(),
							date: MonthYearSchema,
						})
						
						// Feiertagsstunden
						const FeiertagsstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Feiertagsstunden'),
							feiertagsstunden: HundredthHourSchema('feiertagsstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Urlaubsstunden
						const UrlaubsstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Urlaubsstunden'),
							urlaubsstunden: HundredthHourSchema('urlaubsstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Krankstunden
						const KrankstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Krankstunden'),
							krankstunden: HundredthHourSchema('krankstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Nachtstunden
						const NachtstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Nachtstunden'),
							nachtstunden: HundredthHourSchema('nachtstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Sonntagsstunden
						const SonntagsstundenSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Sonntagsstunden'),
							sonntagsstunden: HundredthHourSchema('sonntagsstunden'),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Urlaubsgeld
						const UrlaubsgeldSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Urlaubsgeld'),
							amountInCents: z.number().int(),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// ErholungsbeihilfeEhegatte
						const ErholungsbeihilfeEhegatteSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('ErholungsbeihilfeEhegatte'),
							amountInCents: z.number().int(),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// ErholungsbeihilfeKind
						const ErholungsbeihilfeKindSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('ErholungsbeihilfeKind'),
							amountInCents: z.number().int(),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Erholungsbeihilfe
						const ErholungsbeihilfeSchema = BewegungsdatenBaseSchema.extend({
							type: z.literal('Erholungsbeihilfe'),
							amountInCents: z.number().int(),
							date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
						})
						
						// Fahrtkostenzuschuss15PctPauschalsteuer
						const Fahrtkostenzuschuss15PctPauschalsteuerSchema =
							BewegungsdatenBaseSchema.extend({
								type: z.literal('Fahrtkostenzuschuss15PctPauschalsteuer'),
								amountInCents: z.number().int(),
								date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
							})
					

(endlich Code)

Das Schema als Herzstück

Schema als Herzstück

Wenn sich das Schema ändert, bewegt sich alles

Akt 2
Der Wunsch nach Freiheit

Wir wollen flexibler werden

Vorher: feste Lohngegenstände

  • Arbeitsstunden
  • Urlaubsgeld
  • ...

Nachher: frei definierbar + Feldtypen

  • frei definierbare Namen
  • Feldtypen
    • Stunden
    • Tage
    • Beträge

Warum das nicht nur eine Dropdown-Änderung ist

Schema-Veränderung heißt Anpassungen an vielen Stellen:

  • Datenbank, API, Frontend, Backend, Migrationen, Tests, ...

Betrifft Steuerzentrale und Mandantenzentrale gleichermaßen

Akt 3
Warum wir die Mandantenzentrale neu gebaut haben

Warum nicht
erweiternkopieren
?

  • Anderer Tech-Stack
  • Andere Architektur
  • Andere Annahmen
  • Wartung

Neu bauen ist langfristig günstiger

Wie haben wir es gemacht?

  • Bekannter und bewährter Tech-Stack
  • Wir konnten neu bauen, ohne alles neu zu erfinden

API und Schema als Sicherheitsnetz

  • API = Vertrag
  • Schema = Regeln

Wenn eingehalten → kompatibel

Akt 4
The new kid on the block: Custom

Schema-Erweiterung für Custom


            const BEWEGUNGSDATEN_TYPE_VALUES = [
              ...BUILT_IN_BEWEGUNGSDATEN_TYPE_VALUES,
              'Custom',
            ] as const
            
            const CUSTOM_FIELD_TYPE_VALUES = [
              'hours',
              'days',
              'kilometers',
              'amount',
            ] as const

            const CustomFieldTypeSchema = z.enum(CUSTOM_FIELD_TYPE_VALUES)
            type CustomFieldType = z.infer

            const CustomBewegungsdatenSchema = BewegungsdatenBaseSchema.extend({
              type: z.literal('Custom'),
              gegenstand: z
                .string()
                .min(1, { message: 'Gegenstand is required' })
                .max(100, { message: 'Gegenstand cannot exceed 100 characters' }),
              fieldType: CustomFieldTypeSchema,
              bearbeitungsschl: z
                .string()
                .max(5, { message: 'Bearbeitungsschlüssel cannot exceed 5 characters' })
                .optional()
                .or(z.literal(''))
                .transform((val) => val ?? ''),
                lohnartNr: z
                .string()
                .regex(/^\d{1,4}$/, { message: 'Nur 1-4 Ziffern erlaubt' })
                .or(z.literal(''))
                .transform(val => (val === undefined ? '' : val))
                .optional()
                .default(''),
              columnId: z
                .string()
                .min(1)
                .max(150)
                .optional(),
              value: z.number(),
              date: createMonthYearWithRangeSchema(DEFAULT_OFFSET_MONTHS),
            }).superRefine((data, ctx) => {
              switch (data.fieldType) {
                case 'hours':
                case 'kilometers':
                  if (!hundredthHourIncrement(data.value)) {
                    ctx.addIssue({
                      code: z.ZodIssueCode.custom,
                      message: 'Value must be in 0.01 increments',
                      path: ['value'],
                    })
                  }
                  break
                case 'days':
                  if (!halfDayIncrement(data.value)) {
                    ctx.addIssue({
                      code: z.ZodIssueCode.custom,
                      message: 'Value must be in 0.5 day increments',
                      path: ['value'],
                    })
                  }
                  break
                case 'amount':
                  if (!Number.isInteger(data.value)) {
                    ctx.addIssue({
                      code: z.ZodIssueCode.custom,
                      message: 'Value must be an integer amount in cents',
                      path: ['value'],
                    })
                  }
                  break
                default:
                  exhaustiveMatchingGuard(data.fieldType)
              }
            })
          

Heißt leider auch:

Betroffen Veränderungen
Steuerzentrale
Frontend
Neue Komponenten
Mandantenzentrale
Frontend
Neue Komponenten
Steuerzentrale
Backend
Neue Routinen für Verarbeitung
Mandantenzentrale
Backend
Neue Routinen für Verarbeitung
API Spezifikation Neue Endpunkte

Systemweite Refaktorierungen

Das unerwartete Problem: Historische Daten

Alte Daten ≠ neue Daten

  • Vorher: arbeitsstunden
  • Nachher: custom + hours

Lösung: Datenmigration

Transformation der alten Daten in die neue Form

  • Analyse
  • Mapping
  • Transformation
  • Validierung
  • Review

Risiko, weil am Live-System

Akt 5
Reality Check: Softwareentwicklung

Warum schätzen so schwierig ist?

  • Sichtbares ≠ tatsächlicher Aufwand
  • Große Codebase
  • Viele Abhängigkeiten
  • Mehrere Systeme
  • Edge Cases
  • Jede neue Erkenntnis erzeugt neue Tasks

Schätzungen sind Risikobewertungen, keine Stoppuhr

Was wurde ausgelassen?

  • Konzeptionelle Arbeit
  • Design
  • Architektur
  • Code-"Qualität"
  • Delegation
  • Code Review
  • Code Debugging

THE END

Fragen? Geschenke?