feat(billing-feature): introduce billing module with Money class, calculation logic, and DI setup

- Added `Money` value class for precise monetary operations.
- Implemented `BillingCalculator` to handle fee calculations, including ÖTO-compliant contributions and prize distribution rules.
- Created `BillingModule` for dependency injection using Koin.
- Integrated `billing-feature` into the desktop shell and project dependencies.
- Introduced `TurnierWizardV2` and `VeranstalterAuswahlV2` screens with improved UI and billing synchronization support.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-30 16:38:25 +02:00
parent 0503cf8bcc
commit b2e6158328
9 changed files with 462 additions and 9 deletions

View File

@ -0,0 +1,47 @@
/**
* Dieses Modul kapselt die Gebühren-Logik und Abrechnungs-Features (Billing-Sync).
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
commonMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.domain)
implementation(projects.core.coreDomain)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
jvmMain.dependencies {
implementation(compose.uiTooling)
}
}
}

View File

@ -0,0 +1,8 @@
package at.mocode.frontend.features.billing.di
import at.mocode.frontend.features.billing.domain.BillingCalculator
import org.koin.dsl.module
val billingModule = module {
single { BillingCalculator() }
}

View File

@ -0,0 +1,58 @@
package at.mocode.frontend.features.billing.domain
import kotlinx.serialization.Serializable
/**
* Repräsentiert einen Geldbetrag in Cent zur Vermeidung von Floating-Point-Fehlern.
*/
@Serializable
@JvmInline
value class Money(val cents: Long) {
operator fun plus(other: Money) = Money(this.cents + other.cents)
operator fun times(factor: Int) = Money(this.cents * factor)
override fun toString(): String {
val euros = cents / 100
val rest = cents % 100
return "%d,%02d €".format(euros, if (rest < 0) -rest else rest)
}
}
enum class GebuehrTyp {
NENN_GEBUEHR,
START_GEBUEHR,
SPORTFOERDERBEITRAG,
BOXEN_GEBUEHR,
SYSTEM_GEBUEHR,
SONSTIGES
}
/**
* Logik für die Gebührenberechnung gemäß ÖTO.
*/
class BillingCalculator {
/**
* Berechnet die Gesamtsumme für eine Nennung.
* @param basisNenngeld Das Nenngeld des Bewerbs.
* @param sportfoerderbeitrag Der Sportförderbeitrag (standardmäßig 1,00 gemäß ÖTO).
*/
fun berechneNennsumme(
basisNenngeld: Money,
sportfoerderbeitrag: Money = Money(100)
): Money {
return basisNenngeld + sportfoerderbeitrag
}
/**
* Berechnet das Preisgeld gemäß § 30 ÖTO (Verteilungsschlüssel).
* Einfache Implementierung für den Anfang.
*/
fun berechnePreisgeld(gesamtSumme: Money, platzierung: Int): Money {
return when (platzierung) {
1 -> Money((gesamtSumme.cents * 0.25).toLong())
2 -> Money((gesamtSumme.cents * 0.20).toLong())
3 -> Money((gesamtSumme.cents * 0.15).toLong())
else -> Money(0)
}
}
}

View File

@ -0,0 +1,152 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
private val PrimaryBlue = Color(0xFF1E3A8A)
/**
* TurnierWizardV2 - Der neue Wizard für die Turnieranlage (Vision_03).
*/
@Composable
fun TurnierWizardV2(
veranstaltungId: Long,
onBack: () -> Unit,
onSave: () -> Unit,
) {
var currentStep by remember { mutableIntStateOf(0) }
val steps = listOf("Stammdaten", "Organisation", "Bewerbe ⭐", "Preisliste")
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
// Wizard Header
Surface(shadowElevation = 4.dp, color = Color.White) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, null) }
Spacer(Modifier.width(8.dp))
Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
Button(
onClick = onSave,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
enabled = currentStep == steps.size - 1
) {
Text("Turnier finalisieren")
}
}
Spacer(Modifier.height(16.dp))
// Stepper UI
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
steps.forEachIndexed { index, title ->
StepItem(
title = title,
isActive = index == currentStep,
isCompleted = index < currentStep,
modifier = Modifier.weight(1f)
)
if (index < steps.size - 1) {
Box(
modifier = Modifier.height(2.dp).weight(0.5f)
.background(if (index < currentStep) PrimaryBlue else Color.LightGray)
)
}
}
}
}
}
// Content Area
Box(modifier = Modifier.weight(1f).padding(32.dp)) {
when (currentStep) {
0 -> PlaceholderContent("Stammdaten", "Hier werden OEPS-Nummer, Kategorie und Sparte konfiguriert.")
1 -> PlaceholderContent("Organisation", "Zuweisung von Richtern, Parcourschefs und Tierärzten.")
2 -> PlaceholderContent("Bewerbe", "Konfiguration der Bewerbe und Abteilungen gemäß § 39 ÖTO.")
3 -> PlaceholderContent("Preisliste", "Einstellung der Nenngebühren und Sportförderbeiträge (Billing-Sync).")
}
}
// Wizard Navigation
Surface(shadowElevation = 8.dp, color = Color.White) {
Row(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = { if (currentStep > 0) currentStep-- else onBack() }
) {
Text(if (currentStep == 0) "Abbrechen" else "Zurück")
}
if (currentStep < steps.size - 1) {
Button(
onClick = { currentStep++ },
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
) {
Text("Weiter")
Spacer(Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp))
}
}
}
}
}
}
@Composable
private fun StepItem(title: String, isActive: Boolean, isCompleted: Boolean, modifier: Modifier = Modifier) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
Box(
modifier = Modifier
.size(32.dp)
.background(
color = when {
isCompleted -> PrimaryBlue
isActive -> PrimaryBlue
else -> Color.LightGray
},
shape = androidx.compose.foundation.shape.CircleShape
),
contentAlignment = Alignment.Center
) {
if (isCompleted) {
Icon(Icons.Default.Check, null, tint = Color.White, modifier = Modifier.size(16.dp))
} else {
Text(
text = "", // Or step number
color = Color.White,
style = MaterialTheme.typography.bodySmall
)
}
}
Text(
text = title,
style = MaterialTheme.typography.bodySmall,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
color = if (isActive || isCompleted) PrimaryBlue else Color.Gray,
modifier = Modifier.padding(top = 4.dp)
)
}
}

View File

@ -0,0 +1,183 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.LoginStatus
import at.mocode.frontend.core.designsystem.models.LoginStatusBadge
private val PrimaryBlue = Color(0xFF1E3A8A)
/**
* Screen: "Admin - Verwaltung / Veranstalter auswählen V2"
* Optimiert für Vision_03 mit verbesserter UI und echtem DDD-Mapping Vorbereitung.
*/
@Composable
fun VeranstalterAuswahlV2(
onZurueck: () -> Unit,
onWeiter: (Long) -> Unit,
onNeuerVeranstalter: () -> Unit = {},
) {
var selectedId by remember { mutableStateOf<Long?>(null) }
var suchtext by remember { mutableStateOf("") }
// Placeholder-Daten gemäß Figma
val veranstalter = remember {
listOf(
VeranstalterUiModel(
1L,
"Reit- und Fahrverein Wels",
"V-OOE-1234",
"4600 Wels",
"Maria Huber",
"office@rfv-wels.at",
LoginStatus.AKTIV
),
VeranstalterUiModel(
2L,
"Pferdesportverein Linz",
"V-OOE-5678",
"4020 Linz",
"Thomas Maier",
"kontakt@psv-linz.at",
LoginStatus.AKTIV
),
VeranstalterUiModel(
3L,
"Reitclub Eferding",
"V-OOE-9012",
"4070 Eferding",
"Anna Schmid",
"info@rc-eferding.at",
LoginStatus.AUSSTEHEND
),
)
}
val gefiltert = veranstalter.filter {
suchtext.isBlank() ||
it.name.contains(suchtext, ignoreCase = true) ||
it.oepsNummer.contains(suchtext, ignoreCase = true) ||
it.ort.contains(suchtext, ignoreCase = true)
}
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
// Top Bar
Surface(shadowElevation = 4.dp, color = Color.White) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("Veranstalter auswählen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
"Wählen Sie den Verein für die Veranstaltung aus.",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
IconButton(onClick = onZurueck) {
Icon(Icons.Default.Close, contentDescription = "Schließen")
}
}
}
// Suche & Aktionen
Row(
modifier = Modifier.fillMaxWidth().padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Suche nach Name, OEPS-Nummer oder Ort...") },
leadingIcon = { Icon(Icons.Default.Search, null) },
singleLine = true
)
Button(
onClick = onNeuerVeranstalter,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(8.dp))
Text("Neuer Veranstalter")
}
}
// Liste
LazyColumn(
modifier = Modifier.weight(1f).padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(gefiltert) { item ->
val isSelected = selectedId == item.id
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedId = item.id }
.border(
width = 2.dp,
color = if (isSelected) PrimaryBlue else Color.Transparent,
shape = MaterialTheme.shapes.medium
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) Color(0xFFEFF6FF) else Color(0xFFF9FAFB)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = isSelected, onClick = { selectedId = item.id })
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text("${item.oepsNummer} | ${item.ort}", style = MaterialTheme.typography.bodySmall)
}
LoginStatusBadge(item.loginStatus)
}
}
}
}
// Footer
Surface(shadowElevation = 8.dp, color = Color.White) {
Row(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onZurueck) { Text("Abbrechen") }
Spacer(Modifier.width(16.dp))
Button(
onClick = { selectedId?.let { onWeiter(it) } },
enabled = selectedId != null,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
) {
Text("Weiter zur Turnier-Konfiguration")
Spacer(Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp))
}
}
}
}
}

View File

@ -35,6 +35,7 @@ kotlin {
implementation(projects.frontend.features.veranstaltungFeature)
implementation(projects.frontend.features.turnierFeature)
implementation(project(":frontend:features:profile-feature"))
implementation(project(":frontend:features:billing-feature"))
// Compose Desktop
implementation(compose.desktop.currentOs)

View File

@ -11,6 +11,7 @@ import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.profile.di.profileModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import at.mocode.ping.feature.di.pingFeatureModule
@ -33,6 +34,7 @@ fun main() = application {
nennungFeatureModule,
znsImportModule,
profileModule,
billingModule,
desktopModule,
)
}

View File

@ -23,8 +23,10 @@ import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.turnier.feature.presentation.TurnierWizardV2
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlV2
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@ -300,18 +302,18 @@ private fun DesktopContentArea(
// Root-Screen: Leitet in V2-Fluss ab
is AppScreen.Veranstaltungen -> {
// Direkt zur Veranstalter-Auswahl V2
at.mocode.desktop.v2.VeranstalterAuswahlV2(
onBack = { /* bleibt root */ },
VeranstalterAuswahlV2(
onZurueck = { /* bleibt root */ },
onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
)
}
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2(
onBack = { onNavigate(AppScreen.Veranstaltungen) },
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlV2(
onZurueck = { onNavigate(AppScreen.Veranstaltungen) },
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
)
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
@ -414,11 +416,10 @@ private fun DesktopContentArea(
onBack = { onNavigate(AppScreen.Veranstaltungen) }
)
} else {
at.mocode.desktop.v2.TurnierWizardV2(
veranstalterId = parent.id,
TurnierWizardV2(
veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onSave = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
)
}
}

View File

@ -127,6 +127,7 @@ include(":frontend:features:veranstalter-feature")
include(":frontend:features:veranstaltung-feature")
include(":frontend:features:profile-feature")
include(":frontend:features:turnier-feature")
include(":frontend:features:billing-feature")
// --- SHELLS ---
include(":frontend:shells:meldestelle-desktop")