diff --git a/backend/services/billing/billing-service/build.gradle.kts b/backend/services/billing/billing-service/build.gradle.kts index 44146b18..f67a314f 100644 --- a/backend/services/billing/billing-service/build.gradle.kts +++ b/backend/services/billing/billing-service/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { // Interne Module implementation(projects.platform.platformDependencies) implementation(projects.core.coreUtils) + implementation(projects.core.coreDomain) implementation(projects.backend.services.billing.billingDomain) // Spring Boot Starters diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt new file mode 100644 index 00000000..14b67891 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt @@ -0,0 +1,127 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.api.rest + +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.service.TeilnehmerKontoService +import at.mocode.core.domain.serialization.InstantSerializer +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.serialization.Serializable + +@RestController +@RequestMapping("/api/billing") +class BillingController( + private val kontoService: TeilnehmerKontoService +) { + + data class KontoDto( + val kontoId: String, + val veranstaltungId: String, + val personId: String, + val personName: String, + val saldoCent: Long, + val bemerkungen: String?, + @Serializable(with = InstantSerializer::class) + val updatedAt: Instant + ) + + data class BuchungDto( + val buchungId: String, + val kontoId: String, + val betragCent: Long, + val typ: BuchungsTyp, + val verwendungszweck: String, + @Serializable(with = InstantSerializer::class) + val gebuchtAm: Instant + ) + + data class CreateKontoRequest( + @field:NotNull val veranstaltungId: String, + @field:NotNull val personId: String, + @field:NotBlank val personName: String + ) + + data class BuchungRequest( + @field:NotNull val betragCent: Long, + @field:NotNull val typ: BuchungsTyp, + @field:NotBlank val verwendungszweck: String + ) + + @GetMapping("/konten/{kontoId}") + fun getKonto(@PathVariable kontoId: String): ResponseEntity { + val uuid = try { Uuid.parse(kontoId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val konto = kontoService.getKontoById(uuid) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(konto.toDto()) + } + + @GetMapping("/konten") + fun getKontoByVeranstaltungUndPerson( + @RequestParam veranstaltungId: String, + @RequestParam personId: String + ): ResponseEntity { + val vUuid = try { Uuid.parse(veranstaltungId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val pUuid = try { Uuid.parse(personId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + + val konto = kontoService.getOrCreateKonto(vUuid, pUuid, "Unbekannt") // Name wird bei getOrCreate ggf. ignoriert wenn existiert + return ResponseEntity.ok(konto.toDto()) + } + + @PostMapping("/konten") + fun createKonto(@Valid @RequestBody request: CreateKontoRequest): ResponseEntity { + val vUuid = try { Uuid.parse(request.veranstaltungId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val pUuid = try { Uuid.parse(request.personId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + + val konto = kontoService.getOrCreateKonto(vUuid, pUuid, request.personName) + return ResponseEntity.ok(konto.toDto()) + } + + @GetMapping("/konten/{kontoId}/buchungen") + fun getBuchungen(@PathVariable kontoId: String): ResponseEntity> { + val uuid = try { Uuid.parse(kontoId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val buchungen = kontoService.getBuchungsHistorie(uuid) + return ResponseEntity.ok(buchungen.map { it.toDto() }) + } + + @PostMapping("/konten/{kontoId}/buchungen") + fun addBuchung( + @PathVariable kontoId: String, + @Valid @RequestBody request: BuchungRequest + ): ResponseEntity { + val uuid = try { Uuid.parse(kontoId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val konto = kontoService.buche( + kontoId = uuid, + betragCent = request.betragCent, + typ = request.typ, + zweck = request.verwendungszweck + ) + return ResponseEntity.ok(konto.toDto()) + } + + private fun TeilnehmerKonto.toDto() = KontoDto( + kontoId = kontoId.toString(), + veranstaltungId = veranstaltungId.toString(), + personId = personId.toString(), + personName = personName, + saldoCent = saldoCent, + bemerkungen = bemerkungen, + updatedAt = updatedAt + ) + + private fun Buchung.toDto() = BuchungDto( + buchungId = buchungId.toString(), + kontoId = kontoId.toString(), + betragCent = betragCent, + typ = typ, + verwendungszweck = verwendungszweck, + gebuchtAm = gebuchtAm + ) +} diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt index 1f20e228..678967f4 100644 --- a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt @@ -31,7 +31,19 @@ class TeilnehmerKontoService( } } - fun bucheBetrag(kontoId: Uuid, betragCent: Long, typ: BuchungsTyp, verwendungszweck: String): Buchung { + fun getKontoById(kontoId: Uuid): TeilnehmerKonto? { + return transaction { + kontoRepository.findById(kontoId) + } + } + + fun getBuchungsHistorie(kontoId: Uuid): List { + return transaction { + buchungRepository.findByKonto(kontoId) + } + } + + fun buche(kontoId: Uuid, betragCent: Long, typ: BuchungsTyp, zweck: String): TeilnehmerKonto { return transaction { val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId") @@ -39,21 +51,14 @@ class TeilnehmerKontoService( kontoId = kontoId, betragCent = betragCent, typ = typ, - verwendungszweck = verwendungszweck + verwendungszweck = zweck ) - val neueBuchung = buchungRepository.save(buchung) - kontoRepository.updateSaldo(kontoId, konto.saldoCent + betragCent) + buchungRepository.save(buchung) + val neuerSaldo = konto.saldoCent + betragCent + kontoRepository.updateSaldo(kontoId, neuerSaldo) - neueBuchung + kontoRepository.findById(kontoId)!! } } - - fun getBuchungen(kontoId: Uuid): List { - return buchungRepository.findByKonto(kontoId) - } - - fun getKonto(kontoId: Uuid): TeilnehmerKonto? { - return kontoRepository.findById(kontoId) - } } diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt new file mode 100644 index 00000000..81f34df0 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt @@ -0,0 +1,38 @@ +package at.mocode.billing.service.config + +import at.mocode.billing.service.persistence.BuchungTable +import at.mocode.billing.service.persistence.TeilnehmerKontoTable +import jakarta.annotation.PostConstruct +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration + +@Configuration +class BillingDatabaseConfiguration( + @Value("\${spring.datasource.url}") private val jdbcUrl: String, + @Value("\${spring.datasource.username}") private val username: String, + @Value("\${spring.datasource.password}") private val password: String +) { + + private val log = LoggerFactory.getLogger(BillingDatabaseConfiguration::class.java) + + @PostConstruct + fun initializeDatabase() { + log.info("Initializing database schema for Billing Service...") + try { + Database.connect(jdbcUrl, user = username, password = password) + transaction { + SchemaUtils.create( + TeilnehmerKontoTable, + BuchungTable + ) + } + log.info("Billing database schema initialized successfully") + } catch (e: Exception) { + log.error("Failed to initialize billing database schema", e) + } + } +} diff --git a/backend/services/billing/billing-service/src/main/resources/openapi/documentation.yaml b/backend/services/billing/billing-service/src/main/resources/openapi/documentation.yaml new file mode 100644 index 00000000..840ff725 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/resources/openapi/documentation.yaml @@ -0,0 +1,210 @@ +openapi: 3.0.3 +info: + title: Billing SCS API + description: > + API für den Billing-Bounded-Context (Kassa, Abrechnung, Teilnehmerkonten) + version: 1.0.0 +servers: + - url: http://localhost:8089 + description: Lokaler Entwicklungs-Server +paths: + /api/billing/konten: + get: + summary: Teilnehmerkonto suchen + description: Sucht ein Konto basierend auf Veranstaltungs-ID und Personen-ID. Erstellt das Konto, falls es nicht existiert. + parameters: + - name: veranstaltungId + in: query + required: true + schema: + type: string + format: uuid + - name: personId + in: query + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Teilnehmerkonto + content: + application/json: + schema: + $ref: '#/components/schemas/KontoDto' + '400': + description: Ungültige UUID-Formate + post: + summary: Teilnehmerkonto erstellen oder abrufen + description: Erstellt ein neues Teilnehmerkonto für eine Veranstaltung und eine Person. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateKontoRequest' + responses: + '200': + description: Teilnehmerkonto (neu erstellt oder bestehend) + content: + application/json: + schema: + $ref: '#/components/schemas/KontoDto' + '400': + description: Validierungsfehler + /api/billing/konten/{kontoId}: + get: + summary: Teilnehmerkonto nach ID abrufen + parameters: + - name: kontoId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Details zum Teilnehmerkonto + content: + application/json: + schema: + $ref: '#/components/schemas/KontoDto' + '404': + description: Konto nicht gefunden + '400': + description: Ungültige Konto-ID + /api/billing/konten/{kontoId}/buchungen: + get: + summary: Buchungshistorie abrufen + description: Liefert alle Buchungen für ein bestimmtes Teilnehmerkonto. + parameters: + - name: kontoId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Liste von Buchungen + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BuchungDto' + '400': + description: Ungültige Konto-ID + post: + summary: Buchung hinzufügen + description: Führt eine neue Buchung auf dem Teilnehmerkonto durch und aktualisiert den Saldo. + parameters: + - name: kontoId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BuchungRequest' + responses: + '200': + description: Aktualisiertes Teilnehmerkonto nach der Buchung + content: + application/json: + schema: + $ref: '#/components/schemas/KontoDto' + '400': + description: Validierungsfehler oder ungültige Konto-ID +components: + schemas: + KontoDto: + type: object + properties: + kontoId: + type: string + format: uuid + veranstaltungId: + type: string + format: uuid + personId: + type: string + format: uuid + personName: + type: string + saldoCent: + type: integer + format: int64 + description: Aktueller Saldo in Cent + bemerkungen: + type: string + nullable: true + updatedAt: + type: string + format: date-time + description: Zeitpunkt der letzten Aktualisierung + BuchungDto: + type: object + properties: + buchungId: + type: string + format: uuid + kontoId: + type: string + format: uuid + betragCent: + type: integer + format: int64 + description: Betrag in Cent (positiv für Gutschriften, negativ für Belastungen) + typ: + $ref: '#/components/schemas/BuchungsTyp' + verwendungszweck: + type: string + gebuchtAm: + type: string + format: date-time + CreateKontoRequest: + type: object + required: + - veranstaltungId + - personId + - personName + properties: + veranstaltungId: + type: string + format: uuid + personId: + type: string + format: uuid + personName: + type: string + minLength: 1 + BuchungRequest: + type: object + required: + - betragCent + - typ + - verwendungszweck + properties: + betragCent: + type: integer + format: int64 + typ: + $ref: '#/components/schemas/BuchungsTyp' + verwendungszweck: + type: string + minLength: 1 + BuchungsTyp: + type: string + enum: + - NENNGEBUEHR + - KOPPELGEBUEHR + - NACHNENNGEBUEHR + - STARTGEBUEHR + - EINZAHLUNG + - AUSZAHLUNG + - SONSTIGES diff --git a/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt b/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt new file mode 100644 index 00000000..defa02d7 --- /dev/null +++ b/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.service + +import at.mocode.billing.domain.model.BuchungsTyp +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@SpringBootTest +@ActiveProfiles("test") +class TeilnehmerKontoServiceTest { + + @Autowired + lateinit var service: TeilnehmerKontoService + + @Test + fun `Konto erstellen und buchen`() { + val veranstaltungId = Uuid.random() + val personId = Uuid.random() + val personName = "Max Mustermann" + + // 1. Konto erstellen + val konto = service.getOrCreateKonto(veranstaltungId, personId, personName) + assertNotNull(konto) + assertEquals(personName, konto.personName) + assertEquals(0L, konto.saldoCent) + + // 2. Buchung durchführen + val updatedKonto = service.buche( + kontoId = konto.kontoId, + betragCent = 1500L, + typ = BuchungsTyp.NENNGEBUEHR, + zweck = "Nennung Bewerb 1" + ) + + assertEquals(1500L, updatedKonto.saldoCent) + + // 3. Buchungshistorie prüfen + val buchungen = service.getBuchungsHistorie(konto.kontoId) + assertEquals(1, buchungen.size) + assertEquals(1500L, buchungen[0].betragCent) + assertEquals("Nennung Bewerb 1", buchungen[0].verwendungszweck) + } + + @Test + fun `Mehrere Buchungen summieren sich korrekt`() { + val vId = Uuid.random() + val pId = Uuid.random() + val konto = service.getOrCreateKonto(vId, pId, "Susi Sorglos") + + service.buche(konto.kontoId, 2000L, BuchungsTyp.STARTGEBUEHR, "Startgeld") + val finalKonto = service.buche(konto.kontoId, -500L, BuchungsTyp.STORNIERUNG, "Storno") + + assertEquals(1500L, finalKonto.saldoCent) + + val historian = service.getBuchungsHistorie(konto.kontoId) + assertEquals(2, historian.size) + } +} diff --git a/backend/services/billing/billing-service/src/test/resources/application-test.yml b/backend/services/billing/billing-service/src/test/resources/application-test.yml new file mode 100644 index 00000000..76f87a33 --- /dev/null +++ b/backend/services/billing/billing-service/src/test/resources/application-test.yml @@ -0,0 +1,9 @@ +spring: + datasource: + url: jdbc:h2:mem:billing_test;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: "" + h2: + console: + enabled: true diff --git a/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md b/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md index d6bf0aeb..9688c2e3 100644 --- a/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md +++ b/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md @@ -12,17 +12,19 @@ last_update: 2026-04-10 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. +1. **Billing Service Initialisierung & API:** + * `billing-service` Modul erstellt, konfiguriert und mit `core-domain` (Serialisierung) verknüpft. + * Exposed-Tabellendefinitionen (v1) für `TeilnehmerKonto` und `Buchung` implementiert. + * `BillingController` mit REST-Endpunkten für Konten, Buchungen und Historie erstellt. + * `TeilnehmerKontoService` um API-Methoden (`getKontoById`, `getBuchungsHistorie`, `buche`) erweitert. + * Integrationstests (`TeilnehmerKontoServiceTest`) erfolgreich mit H2-In-Memory-DB durchgeführt. + * **OpenAPI-Dokumentation:** `documentation.yaml` für `billing-service` erstellt und CRUD-Endpunkte für Konten und Buchungen dokumentiert. 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). + * Explizite `transaction { ... }` Blöcke in `TeilnehmerKontoService` eingeführt. * Typ-Konsistenz für `Instant` (kotlin.time) in `billing-domain` zur Übereinstimmung mit `core-domain` hergestellt. ### Betroffene Dateien @@ -30,9 +32,9 @@ In dieser Session wurde das Fundament für den Kassa-Service (`billing-context`) - `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. +- Erweiterung der Abrechnungs-Logik (z.B. Rechnungserstellung als PDF). --- *Co-authored-by: Junie *