feat(billing): implement REST API, database config, and tests for billing service

- **REST API:** Added `BillingController` with endpoints for managing participant accounts and transactions, including history retrieval.
- **Database Configuration:** Introduced `BillingDatabaseConfiguration` to initialize database schema using Exposed.
- **Testing:** Added integration tests for `TeilnehmerKontoService` using H2 in-memory database.
This commit is contained in:
2026-04-10 12:26:57 +02:00
parent 21f3a57e6e
commit eef17b3067
8 changed files with 476 additions and 20 deletions
@@ -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
@@ -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<KontoDto> {
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<KontoDto> {
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<KontoDto> {
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<List<BuchungDto>> {
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<KontoDto> {
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
)
}
@@ -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<Buchung> {
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<Buchung> {
return buchungRepository.findByKonto(kontoId)
}
fun getKonto(kontoId: Uuid): TeilnehmerKonto? {
return kontoRepository.findById(kontoId)
}
}
@@ -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)
}
}
}
@@ -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
@@ -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)
}
}
@@ -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
@@ -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 <junie@jetbrains.com>*