Frontend, Backend und der ganze Zauber dazwischen – am Beispiel individueller Lohngegenstände
...Frontend, Backend und alles, was dazugehört.
Der Teil, der mit dem User interagiert - die Oberfläche des Programms.
Läuft im Browser / App
Der Teil, den niemand sieht (aber jeder braucht).
Läuft auf dem Server / Cloud
Die Lieferstrecke zwischen Frontend und Backend.
Legt fest:
Der Bauplan der Daten.
Definiert, wie Daten aussehen müssen
Fertige Bausteine für Infrastruktur statt alles selbst bauen.
Bietet fertige Services:
Ist der Server / Cloud
// 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)
Wenn sich das Schema ändert, bewegt sich alles
Vorher: feste Lohngegenstände
Nachher: frei definierbar + Feldtypen
Schema-Veränderung heißt Anpassungen an vielen Stellen:
Betrifft Steuerzentrale und Mandantenzentrale gleichermaßen
Neu bauen ist langfristig günstiger
Wenn eingehalten → kompatibel
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
Alte Daten ≠ neue Daten
Transformation der alten Daten in die neue Form
Risiko, weil am Live-System
Schätzungen sind Risikobewertungen, keine Stoppuhr
Fragen? Geschenke?