From 9754f3e36be42f91c895648dce02a9569b0cb1a5 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sun, 12 Apr 2026 18:35:49 +0200 Subject: [PATCH] Enhance billing logic: add REST support for manual and automated transactions, refine billing routes, adapt frontend API integration, and implement transaction type validation. --- .../domain/repository/BillingRepositories.kt | 1 + .../billing/service/TeilnehmerKontoService.kt | 25 ++++++- .../billing/service/api/BillingController.kt | 65 +++++++++++++++++++ .../persistence/ExposedBillingRepositories.kt | 7 ++ ...-04-12_Billing_Logic_Update_Curator_Log.md | 27 ++++++++ .../mocode/frontend/core/network/ApiRoutes.kt | 7 +- .../billing/data/DefaultBillingRepository.kt | 12 ++-- .../features/billing/domain/BillingModels.kt | 2 +- .../billing/presentation/BillingScreen.kt | 39 +++++++---- .../billing/presentation/BillingViewModel.kt | 4 +- 10 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/api/BillingController.kt create mode 100644 docs/04_Agents/Logs/2026-04-12_Billing_Logic_Update_Curator_Log.md diff --git a/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt index 6b9b2edf..21c425c5 100644 --- a/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt +++ b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt @@ -13,6 +13,7 @@ import kotlin.uuid.Uuid interface TeilnehmerKontoRepository { fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? fun findById(kontoId: Uuid): TeilnehmerKonto? + fun findByVeranstaltung(veranstaltungId: Uuid): List fun save(konto: TeilnehmerKonto): TeilnehmerKonto fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long } 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 d9eea0c8..64657269 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 @@ -53,18 +53,39 @@ class TeilnehmerKontoService( return transaction { val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId") + // Validierung: Bestimmte Typen sind immer "Soll" (negativ), andere "Haben" (positiv/Zahlung) + val validierterBetrag = when (typ) { + BuchungsTyp.NENNGELD, + BuchungsTyp.NENNGEBUEHR, + BuchungsTyp.NACHNENNGEBUEHR, + BuchungsTyp.STARTGEBUEHR, + BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent + + BuchungsTyp.ZAHLUNG_BAR, + BuchungsTyp.ZAHLUNG_KARTE, + BuchungsTyp.GUTSCHRIFT -> if (betragCent < 0) -betragCent else betragCent + + BuchungsTyp.STORNIERUNG -> betragCent // Storno kann beides sein (Gegenbuchung) + } + val buchung = Buchung( kontoId = kontoId, - betragCent = betragCent, + betragCent = validierterBetrag, typ = typ, verwendungszweck = zweck ) buchungRepository.save(buchung) - val neuerSaldo = konto.saldoCent + betragCent + val neuerSaldo = konto.saldoCent + validierterBetrag kontoRepository.updateSaldo(kontoId, neuerSaldo) kontoRepository.findById(kontoId)!! } } + + fun getKontenFuerVeranstaltung(veranstaltungId: Uuid): List { + return transaction { + kontoRepository.findByVeranstaltung(veranstaltungId) + } + } } diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/api/BillingController.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/api/BillingController.kt new file mode 100644 index 00000000..443e9b4d --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/api/BillingController.kt @@ -0,0 +1,65 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.service.api + +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 org.springframework.web.bind.annotation.* +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@RestController +@RequestMapping("/api/v1/billing") +class BillingController( + private val kontoService: TeilnehmerKontoService +) { + + @GetMapping("/konten/{kontoId}") + fun getKonto(@PathVariable kontoId: String): TeilnehmerKonto? { + return kontoService.getKontoById(Uuid.parse(kontoId)) + } + + @GetMapping("/veranstaltungen/{veranstaltungId}/personen/{personId}") + fun getOrCreateKonto( + @PathVariable veranstaltungId: String, + @PathVariable personId: String, + @RequestParam(required = false) personName: String? + ): TeilnehmerKonto { + return kontoService.getOrCreateKonto( + Uuid.parse(veranstaltungId), + Uuid.parse(personId), + personName ?: "Unbekannter Teilnehmer" + ) + } + + @GetMapping("/konten/{kontoId}/historie") + fun getHistorie(@PathVariable kontoId: String): List { + return kontoService.getBuchungsHistorie(Uuid.parse(kontoId)) + } + + @GetMapping("/veranstaltungen/{veranstaltungId}/konten") + fun getKonten(@PathVariable veranstaltungId: String): List { + return kontoService.getKontenFuerVeranstaltung(Uuid.parse(veranstaltungId)) + } + + @PostMapping("/konten/{kontoId}/buche") + fun buche( + @PathVariable kontoId: String, + @RequestBody request: BuchungRequest + ): TeilnehmerKonto { + return kontoService.buche( + Uuid.parse(kontoId), + request.betragCent, + request.typ, + request.verwendungszweck + ) + } +} + +data class BuchungRequest( + val betragCent: Long, + val typ: BuchungsTyp, + val verwendungszweck: String +) diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt index a9378d68..046c8080 100644 --- a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt @@ -37,6 +37,13 @@ class ExposedTeilnehmerKontoRepository : TeilnehmerKontoRepository { ?.toModel() } + override fun findByVeranstaltung(veranstaltungId: Uuid): List { + return TeilnehmerKontoTable + .selectAll() + .where { TeilnehmerKontoTable.veranstaltungId eq veranstaltungId } + .map { it.toModel() } + } + override fun save(konto: TeilnehmerKonto): TeilnehmerKonto { val existing = findById(konto.kontoId) if (existing == null) { diff --git a/docs/04_Agents/Logs/2026-04-12_Billing_Logic_Update_Curator_Log.md b/docs/04_Agents/Logs/2026-04-12_Billing_Logic_Update_Curator_Log.md new file mode 100644 index 00000000..94bdfa0f --- /dev/null +++ b/docs/04_Agents/Logs/2026-04-12_Billing_Logic_Update_Curator_Log.md @@ -0,0 +1,27 @@ +# 🧹 Curator Log - 12.04.2026 + +## 🎯 Fokus: Teilnehmer-Abrechnung & Buchungs-Logik (Phase 12) + +Heute wurden wesentliche Fortschritte in der Billing-Infrastruktur erzielt, um die Abrechnung von Teilnehmern (Reiter/Besitzer) während der Veranstaltung zu ermöglichen. + +### ✅ Erledigte Aufgaben +- **Backend (Billing-Service):** + - `BillingController` für REST-Zugriff auf Teilnehmerkonten und Buchungen erstellt. + - `TeilnehmerKontoService` um automatische Betragsvalidierung (Soll/Haben) basierend auf dem `BuchungsTyp` erweitert (z.B. Gebühren werden automatisch negativ, Zahlungen positiv gebucht). + - Repository um `findByVeranstaltung` für die Offene-Posten-Liste (OPL) ergänzt. +- **Frontend (Billing-Feature):** + - `ApiRoutes` & `DefaultBillingRepository` an die neue REST-Struktur angepasst. + - `BillingViewModel` um Typ-Unterstützung bei Buchungen erweitert. + - `ManuelleBuchungDialog` überarbeitet: Nutzer können nun explizit den Typ wählen (Barzahlung, Kartenzahlung, Nenngebühr etc.), wobei das Backend die Vorzeichenlogik übernimmt. + +### 🚧 Offene Punkte (Next Steps) +- **Rechnungserstellung (PDF):** Implementierung eines PDF-Generators für Teilnehmerrechnungen. +- **Druck-Anbindung:** Direkte Anbindung an Bondrucker für Kassa-Belege. +- **Statistik-Dashboard:** Visualisierung von Gesamteinnahmen pro Veranstaltung (Bar vs. Karte). + +### 📝 Notizen +- Die OPL (Offene Posten Liste) wird im Frontend durch die Filterung der Teilnehmerliste auf Konten mit Saldo != 0 realisiert. +- Das Backend stellt sicher, dass Buchungen konsistent bleiben, unabhängig davon, ob im Frontend ein positives oder negatives Vorzeichen eingegeben wird. + +--- +*Log erstellt von Junie (Curator Mode)* diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt index 854d6da3..80563098 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt @@ -44,7 +44,10 @@ object ApiRoutes { object Billing { const val ROOT = "/api/v1/billing" - const val KONTEN = "$ROOT/konten" - fun buchungen(kontoId: String) = "$KONTEN/$kontoId/buchungen" + fun konto(kontoId: String) = "$ROOT/konten/$kontoId" + fun historie(kontoId: String) = "$ROOT/konten/$kontoId/historie" + fun buche(kontoId: String) = "$ROOT/konten/$kontoId/buche" + fun veranstaltungKonten(veranstaltungId: String) = "$ROOT/veranstaltungen/$veranstaltungId/konten" + fun personKonto(veranstaltungId: String, personId: String) = "$ROOT/veranstaltungen/$veranstaltungId/personen/$personId" } } diff --git a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/data/DefaultBillingRepository.kt b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/data/DefaultBillingRepository.kt index 0c5859a6..f56e2471 100644 --- a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/data/DefaultBillingRepository.kt +++ b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/data/DefaultBillingRepository.kt @@ -16,28 +16,24 @@ class DefaultBillingRepository( personId: String, personName: String ): Result = runCatching { - client.get(ApiRoutes.Billing.KONTEN) { - parameter("veranstaltungId", veranstaltungId) - parameter("personId", personId) + client.get(ApiRoutes.Billing.personKonto(veranstaltungId, personId)) { parameter("personName", personName) }.body() } override suspend fun getKonten(veranstaltungId: String): Result> = runCatching { - client.get(ApiRoutes.Billing.KONTEN) { - parameter("veranstaltungId", veranstaltungId) - }.body() + client.get(ApiRoutes.Billing.veranstaltungKonten(veranstaltungId)).body() } override suspend fun getBuchungen(kontoId: String): Result> = runCatching { - client.get(ApiRoutes.Billing.buchungen(kontoId)).body() + client.get(ApiRoutes.Billing.historie(kontoId)).body() } override suspend fun addBuchung( kontoId: String, request: BuchungRequest ): Result = runCatching { - client.post(ApiRoutes.Billing.buchungen(kontoId)) { + client.post(ApiRoutes.Billing.buche(kontoId)) { contentType(ContentType.Application.Json) setBody(request) }.body() diff --git a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt index 28770581..a5394427 100644 --- a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt +++ b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt @@ -59,7 +59,7 @@ data class BuchungDto( data class BuchungRequest( val betragCent: Long, val verwendungszweck: String, - val typ: String = "MANUELL" + val typ: String // Pflichtfeld, muss mit Backend-Enum übereinstimmen ) enum class GebuehrTyp { diff --git a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingScreen.kt b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingScreen.kt index 01390b7b..303464a3 100644 --- a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingScreen.kt +++ b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingScreen.kt @@ -120,8 +120,8 @@ fun BillingScreen( if (showBuchungsDialog) { ManuelleBuchungDialog( onDismiss = { showBuchungsDialog = false }, - onConfirm = { betrag, zweck -> - viewModel.buche(betrag, zweck) + onConfirm = { betrag, zweck, typ -> + viewModel.buche(betrag, zweck, typ) showBuchungsDialog = false } ) @@ -179,28 +179,43 @@ private fun BuchungItem(buchung: BuchungDto) { @Composable private fun ManuelleBuchungDialog( onDismiss: () -> Unit, - onConfirm: (Long, String) -> Unit + onConfirm: (Long, String, String) -> Unit ) { var betragStr by remember { mutableStateOf("") } var zweck by remember { mutableStateOf("") } - var isGutschrift by remember { mutableStateOf(false) } + var selectedTyp by remember { mutableStateOf("ZAHLUNG_BAR") } + + val typen = listOf( + "ZAHLUNG_BAR" to "Zahlung Bar", + "ZAHLUNG_KARTE" to "Zahlung Karte", + "GUTSCHRIFT" to "Gutschrift", + "NENNGEBUEHR" to "Nenngebühr", + "STARTGEBUEHR" to "Startgebühr", + "BOXENGEBUEHR" to "Boxengebühr", + "STORNIERUNG" to "Stornierung" + ) AlertDialog( onDismissRequest = onDismiss, title = { Text("Manuelle Buchung") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = !isGutschrift, onClick = { isGutschrift = false }) - Text("Belastung (-)", modifier = Modifier.clickable { isGutschrift = false }) - Spacer(Modifier.width(16.dp)) - RadioButton(selected = isGutschrift, onClick = { isGutschrift = true }) - Text("Gutschrift (+)", modifier = Modifier.clickable { isGutschrift = true }) + Text("Buchungstyp:", style = MaterialTheme.typography.labelMedium) + + // Einfache Auswahl via FlowRow oder Column (hier Column für Platz) + Column { + typen.forEach { (id, label) -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { selectedTyp = id }) { + RadioButton(selected = selectedTyp == id, onClick = { selectedTyp = id }) + Text(label, fontSize = 12.sp) + } + } } + OutlinedTextField( value = betragStr, onValueChange = { if (it.isEmpty() || it.toDoubleOrNull() != null) betragStr = it }, - label = { Text("Betrag in €") }, + label = { Text("Betrag in € (immer positiv eingeben)") }, modifier = Modifier.fillMaxWidth(), singleLine = true ) @@ -218,7 +233,7 @@ private fun ManuelleBuchungDialog( onClick = { val euro = betragStr.toDoubleOrNull() ?: 0.0 val cent = (euro * 100).toLong() - onConfirm(if (isGutschrift) cent else -cent, zweck) + onConfirm(cent, zweck, selectedTyp) }, enabled = betragStr.isNotEmpty() && zweck.isNotEmpty() ) { diff --git a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingViewModel.kt b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingViewModel.kt index 5fba5cf7..c968ca58 100644 --- a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingViewModel.kt +++ b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/presentation/BillingViewModel.kt @@ -66,11 +66,11 @@ class BillingViewModel( } } - fun buche(betragCent: Long, zweck: String) { + fun buche(betragCent: Long, zweck: String, typ: String) { val konto = _uiState.value.selectedKonto ?: return viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true) - val request = BuchungRequest(betragCent = betragCent, verwendungszweck = zweck) + val request = BuchungRequest(betragCent = betragCent, verwendungszweck = zweck, typ = typ) repository.addBuchung(konto.id, request) .onSuccess { aktualisiertesKonto -> _uiState.value = _uiState.value.copy(selectedKonto = aktualisiertesKonto)