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:
@@ -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
|
||||
|
||||
+127
@@ -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
|
||||
)
|
||||
}
|
||||
+18
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -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
|
||||
+64
@@ -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
|
||||
Reference in New Issue
Block a user