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:
2026-04-10 12:18:00 +02:00
parent bab95d14f4
commit 21f3a57e6e
12 changed files with 1237 additions and 5 deletions
@@ -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"))
}
}
}
}
@@ -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
}
@@ -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
}
@@ -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()
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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]
)
}