Enhance billing logic: add REST support for manual and automated transactions, refine billing routes, adapt frontend API integration, and implement transaction type validation.
This commit is contained in:
parent
03950f8b0c
commit
9754f3e36b
|
|
@ -13,6 +13,7 @@ import kotlin.uuid.Uuid
|
||||||
interface TeilnehmerKontoRepository {
|
interface TeilnehmerKontoRepository {
|
||||||
fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto?
|
fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto?
|
||||||
fun findById(kontoId: Uuid): TeilnehmerKonto?
|
fun findById(kontoId: Uuid): TeilnehmerKonto?
|
||||||
|
fun findByVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto>
|
||||||
fun save(konto: TeilnehmerKonto): TeilnehmerKonto
|
fun save(konto: TeilnehmerKonto): TeilnehmerKonto
|
||||||
fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long
|
fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,18 +53,39 @@ class TeilnehmerKontoService(
|
||||||
return transaction {
|
return transaction {
|
||||||
val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId")
|
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(
|
val buchung = Buchung(
|
||||||
kontoId = kontoId,
|
kontoId = kontoId,
|
||||||
betragCent = betragCent,
|
betragCent = validierterBetrag,
|
||||||
typ = typ,
|
typ = typ,
|
||||||
verwendungszweck = zweck
|
verwendungszweck = zweck
|
||||||
)
|
)
|
||||||
|
|
||||||
buchungRepository.save(buchung)
|
buchungRepository.save(buchung)
|
||||||
val neuerSaldo = konto.saldoCent + betragCent
|
val neuerSaldo = konto.saldoCent + validierterBetrag
|
||||||
kontoRepository.updateSaldo(kontoId, neuerSaldo)
|
kontoRepository.updateSaldo(kontoId, neuerSaldo)
|
||||||
|
|
||||||
kontoRepository.findById(kontoId)!!
|
kontoRepository.findById(kontoId)!!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getKontenFuerVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||||
|
return transaction {
|
||||||
|
kontoRepository.findByVeranstaltung(veranstaltungId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Buchung> {
|
||||||
|
return kontoService.getBuchungsHistorie(Uuid.parse(kontoId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/veranstaltungen/{veranstaltungId}/konten")
|
||||||
|
fun getKonten(@PathVariable veranstaltungId: String): List<TeilnehmerKonto> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
@ -37,6 +37,13 @@ class ExposedTeilnehmerKontoRepository : TeilnehmerKontoRepository {
|
||||||
?.toModel()
|
?.toModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||||
|
return TeilnehmerKontoTable
|
||||||
|
.selectAll()
|
||||||
|
.where { TeilnehmerKontoTable.veranstaltungId eq veranstaltungId }
|
||||||
|
.map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
override fun save(konto: TeilnehmerKonto): TeilnehmerKonto {
|
override fun save(konto: TeilnehmerKonto): TeilnehmerKonto {
|
||||||
val existing = findById(konto.kontoId)
|
val existing = findById(konto.kontoId)
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
|
|
|
||||||
|
|
@ -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)*
|
||||||
|
|
@ -44,7 +44,10 @@ object ApiRoutes {
|
||||||
|
|
||||||
object Billing {
|
object Billing {
|
||||||
const val ROOT = "/api/v1/billing"
|
const val ROOT = "/api/v1/billing"
|
||||||
const val KONTEN = "$ROOT/konten"
|
fun konto(kontoId: String) = "$ROOT/konten/$kontoId"
|
||||||
fun buchungen(kontoId: String) = "$KONTEN/$kontoId/buchungen"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,28 +16,24 @@ class DefaultBillingRepository(
|
||||||
personId: String,
|
personId: String,
|
||||||
personName: String
|
personName: String
|
||||||
): Result<TeilnehmerKontoDto> = runCatching {
|
): Result<TeilnehmerKontoDto> = runCatching {
|
||||||
client.get(ApiRoutes.Billing.KONTEN) {
|
client.get(ApiRoutes.Billing.personKonto(veranstaltungId, personId)) {
|
||||||
parameter("veranstaltungId", veranstaltungId)
|
|
||||||
parameter("personId", personId)
|
|
||||||
parameter("personName", personName)
|
parameter("personName", personName)
|
||||||
}.body()
|
}.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getKonten(veranstaltungId: String): Result<List<TeilnehmerKontoDto>> = runCatching {
|
override suspend fun getKonten(veranstaltungId: String): Result<List<TeilnehmerKontoDto>> = runCatching {
|
||||||
client.get(ApiRoutes.Billing.KONTEN) {
|
client.get(ApiRoutes.Billing.veranstaltungKonten(veranstaltungId)).body()
|
||||||
parameter("veranstaltungId", veranstaltungId)
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getBuchungen(kontoId: String): Result<List<BuchungDto>> = runCatching {
|
override suspend fun getBuchungen(kontoId: String): Result<List<BuchungDto>> = runCatching {
|
||||||
client.get(ApiRoutes.Billing.buchungen(kontoId)).body()
|
client.get(ApiRoutes.Billing.historie(kontoId)).body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addBuchung(
|
override suspend fun addBuchung(
|
||||||
kontoId: String,
|
kontoId: String,
|
||||||
request: BuchungRequest
|
request: BuchungRequest
|
||||||
): Result<TeilnehmerKontoDto> = runCatching {
|
): Result<TeilnehmerKontoDto> = runCatching {
|
||||||
client.post(ApiRoutes.Billing.buchungen(kontoId)) {
|
client.post(ApiRoutes.Billing.buche(kontoId)) {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}.body()
|
}.body()
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ data class BuchungDto(
|
||||||
data class BuchungRequest(
|
data class BuchungRequest(
|
||||||
val betragCent: Long,
|
val betragCent: Long,
|
||||||
val verwendungszweck: String,
|
val verwendungszweck: String,
|
||||||
val typ: String = "MANUELL"
|
val typ: String // Pflichtfeld, muss mit Backend-Enum übereinstimmen
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class GebuehrTyp {
|
enum class GebuehrTyp {
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ fun BillingScreen(
|
||||||
if (showBuchungsDialog) {
|
if (showBuchungsDialog) {
|
||||||
ManuelleBuchungDialog(
|
ManuelleBuchungDialog(
|
||||||
onDismiss = { showBuchungsDialog = false },
|
onDismiss = { showBuchungsDialog = false },
|
||||||
onConfirm = { betrag, zweck ->
|
onConfirm = { betrag, zweck, typ ->
|
||||||
viewModel.buche(betrag, zweck)
|
viewModel.buche(betrag, zweck, typ)
|
||||||
showBuchungsDialog = false
|
showBuchungsDialog = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -179,28 +179,43 @@ private fun BuchungItem(buchung: BuchungDto) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManuelleBuchungDialog(
|
private fun ManuelleBuchungDialog(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConfirm: (Long, String) -> Unit
|
onConfirm: (Long, String, String) -> Unit
|
||||||
) {
|
) {
|
||||||
var betragStr by remember { mutableStateOf("") }
|
var betragStr by remember { mutableStateOf("") }
|
||||||
var zweck 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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Manuelle Buchung") },
|
title = { Text("Manuelle Buchung") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Text("Buchungstyp:", style = MaterialTheme.typography.labelMedium)
|
||||||
RadioButton(selected = !isGutschrift, onClick = { isGutschrift = false })
|
|
||||||
Text("Belastung (-)", modifier = Modifier.clickable { isGutschrift = false })
|
// Einfache Auswahl via FlowRow oder Column (hier Column für Platz)
|
||||||
Spacer(Modifier.width(16.dp))
|
Column {
|
||||||
RadioButton(selected = isGutschrift, onClick = { isGutschrift = true })
|
typen.forEach { (id, label) ->
|
||||||
Text("Gutschrift (+)", modifier = Modifier.clickable { isGutschrift = true })
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { selectedTyp = id }) {
|
||||||
|
RadioButton(selected = selectedTyp == id, onClick = { selectedTyp = id })
|
||||||
|
Text(label, fontSize = 12.sp)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = betragStr,
|
value = betragStr,
|
||||||
onValueChange = { if (it.isEmpty() || it.toDoubleOrNull() != null) betragStr = it },
|
onValueChange = { if (it.isEmpty() || it.toDoubleOrNull() != null) betragStr = it },
|
||||||
label = { Text("Betrag in €") },
|
label = { Text("Betrag in € (immer positiv eingeben)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
@ -218,7 +233,7 @@ private fun ManuelleBuchungDialog(
|
||||||
onClick = {
|
onClick = {
|
||||||
val euro = betragStr.toDoubleOrNull() ?: 0.0
|
val euro = betragStr.toDoubleOrNull() ?: 0.0
|
||||||
val cent = (euro * 100).toLong()
|
val cent = (euro * 100).toLong()
|
||||||
onConfirm(if (isGutschrift) cent else -cent, zweck)
|
onConfirm(cent, zweck, selectedTyp)
|
||||||
},
|
},
|
||||||
enabled = betragStr.isNotEmpty() && zweck.isNotEmpty()
|
enabled = betragStr.isNotEmpty() && zweck.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
val konto = _uiState.value.selectedKonto ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_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)
|
repository.addBuchung(konto.id, request)
|
||||||
.onSuccess { aktualisiertesKonto ->
|
.onSuccess { aktualisiertesKonto ->
|
||||||
_uiState.value = _uiState.value.copy(selectedKonto = aktualisiertesKonto)
|
_uiState.value = _uiState.value.copy(selectedKonto = aktualisiertesKonto)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user