feat(billing): introduce billing domain and service infrastructure

- **Billing Domain:**
  - Added Kotlin Multiplatform project with domain models (`TeilnehmerKonto`, `Buchung`, `BuchungsTyp`) to represent billing entities.
  - Defined serialization strategies using `InstantSerializer`.

- **Service Implementation:**
  - Introduced `BillingServiceApplication` as the main entry point for the billing service.
  - Developed `TeilnehmerKontoService` for account management and transactions.

- **Persistence Layer:**
  - Implemented Exposed repositories (`ExposedTeilnehmerKontoRepository`, `ExposedBillingRepositories`) for database interaction.
  - Added table definitions (`TeilnehmerKontoTable`, `BuchungTable`) with indexes for efficient querying.

- **Build Configuration:**
  - Setup Gradle build files for billing domain and service modules with dependencies for Kotlin, Serialization, Spring Boot, and Exposed.

- **Test Additions:**
  - Extended ZNS importer tests with new scenarios for qualification parsing
This commit is contained in:
Stefan Mogeritsch 2026-04-10 12:18:00 +02:00
parent bab95d14f4
commit 21f3a57e6e
12 changed files with 1237 additions and 5 deletions

View File

@ -306,4 +306,52 @@ class ZnsImportServiceTest {
assertThat(result.gesamtAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
}
@Test
fun `importiereZip - Funktionaer mit mehrfachen Qualifikationen`() = runTest {
// Zeile mit vielen Qualifikationen (Satznummer X014346)
val qualifikationen = "DM,DPF,GAR-SP,SPF,SS*,RD,RS"
val zeile = "X014346Schubert Renate $qualifikationen"
val zip = buildZip("RICHT01.DAT" to zeile)
coEvery { funktionaerRepository.findBySatz("X", 14346) } returns null
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
val result = service.importiereZip(zip)
assertThat(result.richterImportiert).isEqualTo(1)
coVerify {
funktionaerRepository.save(match { f ->
f.qualifikationen.size == 7 &&
f.qualifikationen.containsAll(listOf("DM", "DPF", "GAR-SP", "SPF", "SS*", "RD", "RS"))
})
}
}
@Test
fun `importiereZip - Funktionaer Update Strategie (Delete+Insert)`() = runTest {
val zeile = funktionaerZeile(typ = "X", satznummer = "123456", name = "Geaendert Name")
val zip = buildZip("RICHT01.DAT" to zeile)
val existing = Funktionaer(
funktionaerId = kotlin.uuid.Uuid.random(),
satzId = "X",
satzNummer = 123456,
name = "Alt Name"
)
coEvery { funktionaerRepository.findBySatz("X", 123456) } returns existing
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
val result = service.importiereZip(zip)
assertThat(result.richterAktualisiert).isEqualTo(1)
coVerify {
funktionaerRepository.save(match { f ->
f.funktionaerId == existing.funktionaerId && f.name == "Geaendert Name"
})
}
}
}

View File

@ -0,0 +1,26 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
}
}

View File

@ -0,0 +1,51 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.domain.model
import at.mocode.core.domain.serialization.InstantSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Repräsentiert das Kassa-Konto eines Teilnehmers (Reiter oder Besitzer).
* Ein Konto wird pro Veranstaltung/Turnier geführt, kann aber veranstaltungsübergreifend aggregiert werden.
*/
@Serializable
data class TeilnehmerKonto constructor(
val kontoId: Uuid = Uuid.random(),
val veranstaltungId: Uuid,
val personId: Uuid, // Referenz auf Reiter oder Besitzer
val personName: String,
val saldoCent: Long = 0L, // Aktueller Kontostand in Cent
val bemerkungen: String? = null,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant = Clock.System.now()
)
/**
* Ein einzelner Buchungsvorgang (Zahlung, Gutschrift, Gebühr).
*/
@Serializable
data class Buchung constructor(
val buchungId: Uuid = Uuid.random(),
val kontoId: Uuid,
val betragCent: Long, // Positiv für Gutschrift/Zahlung, Negativ für Gebühr/Soll
val typ: BuchungsTyp,
val verwendungszweck: String,
@Serializable(with = InstantSerializer::class)
val gebuchtAm: Instant = Clock.System.now()
)
@Serializable
enum class BuchungsTyp {
NENNGEBUEHR,
STARTGEBUEHR,
BOXENGEBUEHR,
ZAHLUNG_BAR,
ZAHLUNG_KARTE,
GUTSCHRIFT,
STORNIERUNG
}

View File

@ -0,0 +1,26 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.domain.repository
import at.mocode.billing.domain.model.Buchung
import at.mocode.billing.domain.model.TeilnehmerKonto
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Repository für den Zugriff auf Teilnehmer-Konten.
*/
interface TeilnehmerKontoRepository {
fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto?
fun findById(kontoId: Uuid): TeilnehmerKonto?
fun save(konto: TeilnehmerKonto): TeilnehmerKonto
fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long
}
/**
* Repository für den Zugriff auf Buchungen.
*/
interface BuchungRepository {
fun findByKonto(kontoId: Uuid): List<Buchung>
fun save(buchung: Buchung): Buchung
}

View File

@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
alias(libs.plugins.kotlinSpring)
}
springBoot {
mainClass.set("at.mocode.billing.service.BillingServiceApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreUtils)
implementation(projects.backend.services.billing.billingDomain)
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.jackson.module.kotlin)
// Datenbank-Abhängigkeiten
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
}
tasks.test {
useJUnitPlatform()
}

View File

@ -0,0 +1,14 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import kotlin.uuid.ExperimentalUuidApi
@SpringBootApplication
class BillingServiceApplication
fun main(args: Array<String>) {
runApplication<BillingServiceApplication>(*args)
}

View File

@ -0,0 +1,59 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.service
import at.mocode.billing.domain.model.Buchung
import at.mocode.billing.domain.model.BuchungsTyp
import at.mocode.billing.domain.model.TeilnehmerKonto
import at.mocode.billing.domain.repository.BuchungRepository
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.stereotype.Service
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Service
class TeilnehmerKontoService(
private val kontoRepository: TeilnehmerKontoRepository,
private val buchungRepository: BuchungRepository
) {
fun getOrCreateKonto(veranstaltungId: Uuid, personId: Uuid, personName: String): TeilnehmerKonto {
return transaction {
kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId)
?: kontoRepository.save(
TeilnehmerKonto(
veranstaltungId = veranstaltungId,
personId = personId,
personName = personName
)
)
}
}
fun bucheBetrag(kontoId: Uuid, betragCent: Long, typ: BuchungsTyp, verwendungszweck: String): Buchung {
return transaction {
val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId")
val buchung = Buchung(
kontoId = kontoId,
betragCent = betragCent,
typ = typ,
verwendungszweck = verwendungszweck
)
val neueBuchung = buchungRepository.save(buchung)
kontoRepository.updateSaldo(kontoId, konto.saldoCent + betragCent)
neueBuchung
}
}
fun getBuchungen(kontoId: Uuid): List<Buchung> {
return buchungRepository.findByKonto(kontoId)
}
fun getKonto(kontoId: Uuid): TeilnehmerKonto? {
return kontoRepository.findById(kontoId)
}
}

View File

@ -0,0 +1,47 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
import org.jetbrains.exposed.v1.datetime.timestamp
import kotlin.uuid.ExperimentalUuidApi
/**
* Exposed-Tabellendefinition für das Teilnehmer-Konto.
*/
object TeilnehmerKontoTable : Table("teilnehmer_konten") {
val id = uuid("konto_id")
val veranstaltungId = uuid("veranstaltung_id")
val personId = uuid("person_id")
val personName = varchar("person_name", 200)
val saldoCent = long("saldo_cent").default(0L)
val bemerkungen = text("bemerkungen").nullable()
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
override val primaryKey = PrimaryKey(id)
init {
index("idx_konto_veranstaltung_person", isUnique = true, veranstaltungId, personId)
}
}
/**
* Exposed-Tabellendefinition für Buchungen.
*/
object BuchungTable : Table("buchungen") {
val id = uuid("buchung_id")
val kontoId = uuid("konto_id")
val betragCent = long("betrag_cent")
val typ = varchar("typ", 50)
val verwendungszweck = varchar("verwendungszweck", 500)
val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp)
override val primaryKey = PrimaryKey(id)
init {
index("idx_buchung_konto", isUnique = false, kontoId)
}
}

View File

@ -0,0 +1,111 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.service.persistence
import at.mocode.billing.domain.model.Buchung
import at.mocode.billing.domain.model.BuchungsTyp
import at.mocode.billing.domain.model.TeilnehmerKonto
import at.mocode.billing.domain.repository.BuchungRepository
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.stereotype.Repository
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Repository
class ExposedTeilnehmerKontoRepository : TeilnehmerKontoRepository {
override fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? {
return TeilnehmerKontoTable
.selectAll()
.where { (TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and (TeilnehmerKontoTable.personId eq personId) }
.singleOrNull()
?.toModel()
}
override fun findById(kontoId: Uuid): TeilnehmerKonto? {
return TeilnehmerKontoTable
.selectAll()
.where { TeilnehmerKontoTable.id eq kontoId }
.singleOrNull()
?.toModel()
}
override fun save(konto: TeilnehmerKonto): TeilnehmerKonto {
val existing = findById(konto.kontoId)
if (existing == null) {
TeilnehmerKontoTable.insert {
it[id] = konto.kontoId
it[veranstaltungId] = konto.veranstaltungId
it[personId] = konto.personId
it[personName] = konto.personName
it[saldoCent] = konto.saldoCent
it[bemerkungen] = konto.bemerkungen
}
} else {
TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq konto.kontoId }) {
it[personName] = konto.personName
it[saldoCent] = konto.saldoCent
it[bemerkungen] = konto.bemerkungen
it[updatedAt] = CurrentTimestamp
}
}
return findById(konto.kontoId)!!
}
override fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long {
TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq kontoId }) {
it[this.saldoCent] = saldoCent
it[updatedAt] = CurrentTimestamp
}
return saldoCent
}
private fun ResultRow.toModel() = TeilnehmerKonto(
kontoId = this[TeilnehmerKontoTable.id],
veranstaltungId = this[TeilnehmerKontoTable.veranstaltungId],
personId = this[TeilnehmerKontoTable.personId],
personName = this[TeilnehmerKontoTable.personName],
saldoCent = this[TeilnehmerKontoTable.saldoCent],
bemerkungen = this[TeilnehmerKontoTable.bemerkungen],
updatedAt = this[TeilnehmerKontoTable.updatedAt]
)
}
@Repository
class ExposedBuchungRepository : BuchungRepository {
override fun findByKonto(kontoId: Uuid): List<Buchung> {
return BuchungTable
.selectAll()
.where { BuchungTable.kontoId eq kontoId }
.map { it.toModel() }
}
override fun save(buchung: Buchung): Buchung {
BuchungTable.insert {
it[id] = buchung.buchungId
it[kontoId] = buchung.kontoId
it[betragCent] = buchung.betragCent
it[typ] = buchung.typ.name
it[verwendungszweck] = buchung.verwendungszweck
it[gebuchtAm] = buchung.gebuchtAm
}
return buchung
}
private fun ResultRow.toModel() = Buchung(
buchungId = this[BuchungTable.id],
kontoId = this[BuchungTable.kontoId],
betragCent = this[BuchungTable.betragCent],
typ = BuchungsTyp.valueOf(this[BuchungTable.typ]),
verwendungszweck = this[BuchungTable.verwendungszweck],
gebuchtAm = this[BuchungTable.gebuchtAm]
)
}

View File

@ -8,15 +8,20 @@ servers:
- url: http://localhost:8091
description: Lokaler Entwicklungs-Server
paths:
/reiter/search:
/reiter:
get:
summary: Sucht Reiter
summary: Alle Reiter abrufen (paginiert)
parameters:
- name: q
- name: limit
in: query
required: true
schema:
type: string
type: integer
default: 100
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: Liste von Reitern
@ -26,6 +31,450 @@ paths:
type: array
items:
$ref: '#/components/schemas/Reiter'
post:
summary: Neuen Reiter erstellen
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReiterCreateRequest'
responses:
'201':
description: Reiter erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/Reiter'
/reiter/{id}:
get:
summary: Reiter nach ID abrufen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Reiter Details
content:
application/json:
schema:
$ref: '#/components/schemas/Reiter'
'404':
description: Nicht gefunden
put:
summary: Reiter aktualisieren
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReiterUpdateRequest'
responses:
'200':
description: Reiter aktualisiert
content:
application/json:
schema:
$ref: '#/components/schemas/Reiter'
'404':
description: Nicht gefunden
delete:
summary: Reiter löschen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: Erfolgreich gelöscht
'404':
description: Nicht gefunden
/reiter/search:
get:
summary: Sucht Reiter nach Satznummer
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
'200':
description: Liste von Reitern (Satznummer Match)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Reiter'
/reiter/satznummer/{nr}:
get:
summary: Reiter nach Satznummer suchen
parameters:
- name: nr
in: path
required: true
schema:
type: string
responses:
'200':
description: Reiter gefunden
content:
application/json:
schema:
$ref: '#/components/schemas/Reiter'
'404':
description: Nicht gefunden
/horse:
get:
summary: Alle Pferde abrufen (paginiert)
parameters:
- name: jahrgang
in: query
schema:
type: integer
- name: limit
in: query
schema:
type: integer
default: 100
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: Liste von Pferden
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Horse'
post:
summary: Neues Pferd erstellen
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/HorseCreateRequest'
responses:
'201':
description: Pferd erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/Horse'
/horse/{id}:
get:
summary: Pferd nach ID abrufen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Pferd Details
content:
application/json:
schema:
$ref: '#/components/schemas/Horse'
'404':
description: Nicht gefunden
put:
summary: Pferd aktualisieren
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/HorseUpdateRequest'
responses:
'200':
description: Pferd aktualisiert
content:
application/json:
schema:
$ref: '#/components/schemas/Horse'
delete:
summary: Pferd löschen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: Gelöscht
/horse/search:
get:
summary: Sucht Pferde nach Lebensnummer
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
'200':
description: Liste von Pferden
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Horse'
/verein:
get:
summary: Alle Vereine abrufen (paginiert)
parameters:
- name: limit
in: query
schema:
type: integer
default: 100
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: Liste von Vereinen
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Verein'
post:
summary: Neuen Verein erstellen
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VereinCreateRequest'
responses:
'201':
description: Verein erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/Verein'
/verein/{id}:
get:
summary: Verein nach ID abrufen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Verein Details
content:
application/json:
schema:
$ref: '#/components/schemas/Verein'
'404':
description: Nicht gefunden
put:
summary: Verein aktualisieren
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VereinUpdateRequest'
responses:
'200':
description: Verein aktualisiert
content:
application/json:
schema:
$ref: '#/components/schemas/Verein'
delete:
summary: Verein löschen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: Gelöscht
/funktionaer:
get:
summary: Alle Funktionäre abrufen (paginiert)
parameters:
- name: limit
in: query
schema:
type: integer
default: 100
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: Liste von Funktionären
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Funktionaer'
post:
summary: Neuen Funktionär erstellen
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FunktionaerCreateRequest'
responses:
'201':
description: Funktionär erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/Funktionaer'
/funktionaer/{id}:
get:
summary: Funktionär nach ID abrufen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Funktionär Details
content:
application/json:
schema:
$ref: '#/components/schemas/Funktionaer'
'404':
description: Nicht gefunden
put:
summary: Funktionär aktualisieren
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FunktionaerUpdateRequest'
responses:
'200':
description: Funktionär aktualisiert
content:
application/json:
schema:
$ref: '#/components/schemas/Funktionaer'
delete:
summary: Funktionär löschen
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: Gelöscht
/funktionaer/search:
get:
summary: Sucht Funktionäre nach SatzNummer
parameters:
- name: q
in: query
required: true
schema:
type: integer
responses:
'200':
description: Liste von Funktionären
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Funktionaer'
/funktionaer/satz/{satzId}/{satzNummer}:
get:
summary: Funktionär nach Satz-ID und Nummer suchen
parameters:
- name: satzId
in: path
required: true
schema:
type: string
- name: satzNummer
in: path
required: true
schema:
type: integer
responses:
'200':
description: Funktionär gefunden
content:
application/json:
schema:
$ref: '#/components/schemas/Funktionaer'
'404':
description: Nicht gefunden
/rules/turnierklassen:
get:
summary: Alle Turnierklassen abrufen
@ -58,9 +507,327 @@ components:
reiterId:
type: string
format: uuid
satznummer:
type: string
nachname:
type: string
vorname:
type: string
geburtsdatum:
type: string
format: date
bundeslandNummer:
type: integer
vereinsName:
type: string
nation:
type: string
reiterLizenz:
type: string
startkarte:
type: string
fahrLizenz:
type: string
mitgliedsNummer:
type: integer
telefonNummer:
type: string
lastPayYear:
type: integer
feiId:
type: string
lizenzKlasse:
type: string
istAktiv:
type: boolean
updatedAt:
type: string
format: date-time
ReiterCreateRequest:
type: object
required:
- satznummer
- nachname
- vorname
properties:
satznummer:
type: string
nachname:
type: string
vorname:
type: string
geburtsdatum:
type: string
format: date
bundeslandNummer:
type: integer
vereinsName:
type: string
nation:
type: string
reiterLizenz:
type: string
startkarte:
type: string
fahrLizenz:
type: string
mitgliedsNummer:
type: integer
telefonNummer:
type: string
lastPayYear:
type: integer
feiId:
type: string
lizenzKlasse:
type: string
istAktiv:
type: boolean
ReiterUpdateRequest:
type: object
properties:
nachname:
type: string
vorname:
type: string
geburtsdatum:
type: string
format: date
bundeslandNummer:
type: integer
vereinsName:
type: string
nation:
type: string
reiterLizenz:
type: string
startkarte:
type: string
fahrLizenz:
type: string
mitgliedsNummer:
type: integer
telefonNummer:
type: string
lastPayYear:
type: integer
feiId:
type: string
lizenzKlasse:
type: string
istAktiv:
type: boolean
Horse:
type: object
properties:
pferdId:
type: string
format: uuid
kopfnummer:
type: string
pferdeName:
type: string
lebensnummer:
type: string
geschlecht:
type: string
geburtsjahr:
type: integer
farbe:
type: string
satznummer:
type: string
istAktiv:
type: boolean
updatedAt:
type: string
format: date-time
HorseCreateRequest:
type: object
required:
- pferdeName
- geschlecht
properties:
kopfnummer:
type: string
pferdeName:
type: string
lebensnummer:
type: string
geschlecht:
type: string
geburtsjahr:
type: integer
farbe:
type: string
satznummer:
type: string
istAktiv:
type: boolean
HorseUpdateRequest:
type: object
properties:
kopfnummer:
type: string
pferdeName:
type: string
lebensnummer:
type: string
geschlecht:
type: string
geburtsjahr:
type: integer
farbe:
type: string
istAktiv:
type: boolean
Verein:
type: object
properties:
vereinId:
type: string
format: uuid
vereinsNummer:
type: string
name:
type: string
bundesland:
type: string
ort:
type: string
plz:
type: string
strasse:
type: string
email:
type: string
telefon:
type: string
website:
type: string
istVeranstalter:
type: boolean
istAktiv:
type: boolean
imageUrl:
type: string
bemerkungen:
type: string
updatedAt:
type: string
format: date-time
VereinCreateRequest:
type: object
required:
- vereinsNummer
- name
properties:
vereinsNummer:
type: string
name:
type: string
bundesland:
type: string
ort:
type: string
plz:
type: string
strasse:
type: string
email:
type: string
telefon:
type: string
website:
type: string
istVeranstalter:
type: boolean
istAktiv:
type: boolean
imageUrl:
type: string
bemerkungen:
type: string
VereinUpdateRequest:
type: object
properties:
name:
type: string
bundesland:
type: string
ort:
type: string
plz:
type: string
strasse:
type: string
email:
type: string
telefon:
type: string
website:
type: string
istVeranstalter:
type: boolean
istAktiv:
type: boolean
imageUrl:
type: string
bemerkungen:
type: string
Funktionaer:
type: object
properties:
funktionaerId:
type: string
format: uuid
satzId:
type: string
satzNummer:
type: integer
name:
type: string
qualifikationen:
type: array
items:
type: string
istAktiv:
type: boolean
bemerkungen:
type: string
updatedAt:
type: string
format: date-time
FunktionaerCreateRequest:
type: object
required:
- satzId
- satzNummer
properties:
satzId:
type: string
satzNummer:
type: integer
name:
type: string
qualifikationen:
type: array
items:
type: string
istAktiv:
type: boolean
bemerkungen:
type: string
FunktionaerUpdateRequest:
type: object
properties:
name:
type: string
qualifikationen:
type: array
items:
type: string
istAktiv:
type: boolean
bemerkungen:
type: string

View File

@ -0,0 +1,38 @@
---
type: Journal
status: ACTIVE
owner: Curator
last_update: 2026-04-10
---
# Journal Entry: 2026-04-10 - Billing Service Setup & ZNS Importer Hardening
## 👷 [Backend Developer] / 🏗️ [Lead Architect] / 🧹 [Curator]
### Zusammenfassung der Session
In dieser Session wurde das Fundament für den Kassa-Service (`billing-context`) gelegt und die Robustheit des ZNS-Importers durch zusätzliche Integrationstests für Funktionäre gesteigert.
### Wichtigste Ergebnisse
1. **Billing Service Initialisierung:**
* `billing-service` Modul erstellt und konfiguriert.
* Exposed-Tabellendefinitionen für `TeilnehmerKonto` und `Buchung` implementiert.
* Repository-Schnittstellen (Domain) und Exposed-Implementierungen (Service) erstellt.
* `TeilnehmerKontoService` mit Basis-Logik (Kontoerstellung & Buchungen) implementiert.
2. **ZNS-Importer Hardening:**
* Erweiterung von `ZnsImportServiceTest` um Tests für mehrfache Qualifikationen und die Update-Strategie (Delete+Insert) bei Funktionären (`RICHT01.dat`).
* Alle 11 Integrationstests sind erfolgreich durchgelaufen.
3. **Kompilations-Fixes (Billing):**
* `billing-service` auf korrekte Exposed DSL Syntax (`selectAll().where { ... }`) umgestellt.
* Explizite `transaction { ... }` Blöcke in `TeilnehmerKontoService` eingeführt (da `@Transactional` ohne JPA-Starter nicht verfügbar war).
* Typ-Konsistenz für `Instant` (kotlin.time) in `billing-domain` zur Übereinstimmung mit `core-domain` hergestellt.
### Betroffene Dateien
- `backend/services/billing/` (Neuer SCS-Kontext)
- `backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt`
### Nächste Schritte
- Implementierung der REST-API für den Billing-Service (OpenAPI-First).
- Integration des Billing-Services in den `entries-context` (z.B. automatische Buchung von Nenngebühren).
- UI-Anbindung im Frontend für Kontenübersicht und manuelle Buchungen.
---
*Co-authored-by: Junie <junie@jetbrains.com>*

View File

@ -94,6 +94,11 @@ include(":backend:services:masterdata:masterdata-domain")
include(":backend:services:masterdata:masterdata-infrastructure")
include(":backend:services:masterdata:masterdata-service")
// --- BILLING (Kassa, Zahlungen & Rechnungen) ---
include(":backend:services:billing:billing-api")
include(":backend:services:billing:billing-domain")
include(":backend:services:billing:billing-service")
// --- PING (Ping Service) ---
include(":backend:services:ping:ping-service")