feat(billing): implement support for Tagesabschluss and Buchung cancellations

- Added `Tagesabschluss` entity and repository to handle daily cash closing logic.
- Introduced cancellation logic for `Buchung`, enabling creation of offsetting entries.
- Extended schema definitions with `TagesabschlussTable` and nullable `storniertBuchungId` in `BuchungTable`.
- Updated services to support `Tagesabschluss` creation and `Buchung` cancellation.
- Implemented tests for `TagesabschlussService` and cancellation functionality.
- Updated documentation to reflect completed roadmap items related to cash management.
This commit is contained in:
2026-04-14 13:10:52 +02:00
parent 2a1508c6a5
commit cfe12e4dd0
10 changed files with 319 additions and 9 deletions
@@ -36,7 +36,24 @@ data class Buchung constructor(
val typ: BuchungsTyp, val typ: BuchungsTyp,
val verwendungszweck: String, val verwendungszweck: String,
@Serializable(with = InstantSerializer::class) @Serializable(with = InstantSerializer::class)
val gebuchtAm: Instant = Clock.System.now() val gebuchtAm: Instant = Clock.System.now(),
val storniertBuchungId: Uuid? = null // Referenz auf die ursprüngliche Buchung, falls dies ein Storno ist
)
/**
* Repräsentiert einen Kassa-Tagesabschluss.
*/
@Serializable
data class Tagesabschluss(
val tagesabschlussId: Uuid = Uuid.random(),
val veranstaltungId: Uuid,
val abgeschlossenAm: Instant = Clock.System.now(),
val abgeschlossenVon: String,
val summeBarCent: Long,
val summeKarteCent: Long,
val summeGutschriftCent: Long,
val anzahlBuchungen: Int,
val bemerkungen: String? = null
) )
@Serializable @Serializable
@@ -3,7 +3,9 @@
package at.mocode.billing.domain.repository package at.mocode.billing.domain.repository
import at.mocode.billing.domain.model.Buchung import at.mocode.billing.domain.model.Buchung
import at.mocode.billing.domain.model.Tagesabschluss
import at.mocode.billing.domain.model.TeilnehmerKonto import at.mocode.billing.domain.model.TeilnehmerKonto
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -24,5 +26,19 @@ interface TeilnehmerKontoRepository {
*/ */
interface BuchungRepository { interface BuchungRepository {
fun findByKonto(kontoId: Uuid): List<Buchung> fun findByKonto(kontoId: Uuid): List<Buchung>
fun findById(buchungId: Uuid): Buchung?
fun findByVeranstaltungAndZeitraum(
veranstaltungId: Uuid,
von: Instant,
bis: Instant
): List<Buchung>
fun save(buchung: Buchung): Buchung fun save(buchung: Buchung): Buchung
} }
/**
* Repository für den Zugriff auf Tagesabschlüsse.
*/
interface TagesabschlussRepository {
fun findByVeranstaltung(veranstaltungId: Uuid): List<Tagesabschluss>
fun save(abschluss: Tagesabschluss): Tagesabschluss
}
@@ -0,0 +1,68 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.service
import at.mocode.billing.domain.model.BuchungsTyp
import at.mocode.billing.domain.model.Tagesabschluss
import at.mocode.billing.domain.repository.BuchungRepository
import at.mocode.billing.domain.repository.TagesabschlussRepository
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.stereotype.Service
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Service
class TagesabschlussService(
private val buchungRepository: BuchungRepository,
private val tagesabschlussRepository: TagesabschlussRepository
) {
/**
* Erstellt einen Tagesabschluss für die angegebene Veranstaltung und den Zeitraum.
* Standardmäßig wird der Zeitraum von "heute 00:00" bis "jetzt" genommen,
* wenn keine Zeiten angegeben sind.
*/
fun erstelleAbschluss(
veranstaltungId: Uuid,
von: Instant,
bis: Instant,
abgeschlossenVon: String,
bemerkungen: String? = null
): Tagesabschluss {
return transaction {
val buchungen = buchungRepository.findByVeranstaltungAndZeitraum(veranstaltungId, von, bis)
val summeBar = buchungen
.filter { it.typ == BuchungsTyp.ZAHLUNG_BAR }
.sumOf { it.betragCent }
val summeKarte = buchungen
.filter { it.typ == BuchungsTyp.ZAHLUNG_KARTE }
.sumOf { it.betragCent }
val summeGutschrift = buchungen
.filter { it.typ == BuchungsTyp.GUTSCHRIFT }
.sumOf { it.betragCent }
val abschluss = Tagesabschluss(
veranstaltungId = veranstaltungId,
abgeschlossenVon = abgeschlossenVon,
summeBarCent = summeBar,
summeKarteCent = summeKarte,
summeGutschriftCent = summeGutschrift,
anzahlBuchungen = buchungen.size,
bemerkungen = bemerkungen
)
tagesabschlussRepository.save(abschluss)
}
}
fun getAbschluesse(veranstaltungId: Uuid): List<Tagesabschluss> {
return transaction {
tagesabschlussRepository.findByVeranstaltung(veranstaltungId)
}
}
}
@@ -94,4 +94,36 @@ class TeilnehmerKontoService(
kontoRepository.findOffenePosten(veranstaltungId) kontoRepository.findOffenePosten(veranstaltungId)
} }
} }
/**
* Storniert eine existierende Buchung durch eine Gegenbuchung.
*/
fun storniereBuchung(buchungId: Uuid, grund: String): TeilnehmerKonto {
return transaction {
val ursprung = buchungRepository.findById(buchungId)
?: throw IllegalArgumentException("Buchung nicht gefunden: $buchungId")
if (ursprung.typ == BuchungsTyp.STORNIERUNG) {
throw IllegalArgumentException("Ein Storno kann nicht erneut storniert werden.")
}
val konto = kontoRepository.findById(ursprung.kontoId)!!
// Gegenbuchung erstellen (Betrag umkehren)
val stornoBuchung = Buchung(
kontoId = ursprung.kontoId,
betragCent = -ursprung.betragCent,
typ = BuchungsTyp.STORNIERUNG,
verwendungszweck = "Storno von ${ursprung.buchungId}: $grund",
storniertBuchungId = ursprung.buchungId
)
buchungRepository.save(stornoBuchung)
val neuerSaldo = konto.saldoCent - ursprung.betragCent
kontoRepository.updateSaldo(konto.kontoId, neuerSaldo)
kontoRepository.findById(konto.kontoId)!!
}
}
} }
@@ -1,6 +1,7 @@
package at.mocode.billing.service.config package at.mocode.billing.service.config
import at.mocode.billing.service.persistence.BuchungTable import at.mocode.billing.service.persistence.BuchungTable
import at.mocode.billing.service.persistence.TagesabschlussTable
import at.mocode.billing.service.persistence.TeilnehmerKontoTable import at.mocode.billing.service.persistence.TeilnehmerKontoTable
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.Database
@@ -31,7 +32,8 @@ class BillingDatabaseConfiguration(
transaction { transaction {
SchemaUtils.create( SchemaUtils.create(
TeilnehmerKontoTable, TeilnehmerKontoTable,
BuchungTable BuchungTable,
TagesabschlussTable
) )
} }
log.info("Billing database schema initialized successfully") log.info("Billing database schema initialized successfully")
@@ -38,6 +38,7 @@ object BuchungTable : Table("buchungen") {
val typ = varchar("typ", 50) val typ = varchar("typ", 50)
val verwendungszweck = varchar("verwendungszweck", 500) val verwendungszweck = varchar("verwendungszweck", 500)
val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp) val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp)
val storniertBuchungId = uuid("storniert_buchung_id").nullable()
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
@@ -45,3 +46,24 @@ object BuchungTable : Table("buchungen") {
index("idx_buchung_konto", isUnique = false, kontoId) index("idx_buchung_konto", isUnique = false, kontoId)
} }
} }
/**
* Exposed-Tabellendefinition für Tagesabschlüsse.
*/
object TagesabschlussTable : Table("tagesabschluesse") {
val id = uuid("tagesabschluss_id")
val veranstaltungId = uuid("veranstaltung_id")
val abgeschlossenAm = timestamp("abgeschlossen_am").defaultExpression(CurrentTimestamp)
val abgeschlossenVon = varchar("abgeschlossen_von", 200)
val summeBarCent = long("summe_bar_cent")
val summeKarteCent = long("summe_karte_cent")
val summeGutschriftCent = long("summe_gutschrift_cent")
val anzahlBuchungen = integer("anzahl_buchungen")
val bemerkungen = text("bemerkungen").nullable()
override val primaryKey = PrimaryKey(id)
init {
index("idx_tagesabschluss_veranstaltung", isUnique = false, veranstaltungId)
}
}
@@ -4,18 +4,18 @@ package at.mocode.billing.service.persistence
import at.mocode.billing.domain.model.Buchung import at.mocode.billing.domain.model.Buchung
import at.mocode.billing.domain.model.BuchungsTyp import at.mocode.billing.domain.model.BuchungsTyp
import at.mocode.billing.domain.model.Tagesabschluss
import at.mocode.billing.domain.model.TeilnehmerKonto import at.mocode.billing.domain.model.TeilnehmerKonto
import at.mocode.billing.domain.repository.BuchungRepository import at.mocode.billing.domain.repository.BuchungRepository
import at.mocode.billing.domain.repository.TagesabschlussRepository
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -103,6 +103,29 @@ class ExposedBuchungRepository : BuchungRepository {
.map { it.toModel() } .map { it.toModel() }
} }
override fun findById(buchungId: Uuid): Buchung? {
return BuchungTable
.selectAll()
.where { BuchungTable.id eq buchungId }
.singleOrNull()
?.toModel()
}
override fun findByVeranstaltungAndZeitraum(
veranstaltungId: Uuid,
von: Instant,
bis: Instant
): List<Buchung> {
// Da Buchungen über Konten verknüpft sind, müssen wir einen Join machen oder über die Konten der Veranstaltung filtern
return Join(BuchungTable, TeilnehmerKontoTable, JoinType.INNER, BuchungTable.kontoId, TeilnehmerKontoTable.id)
.selectAll()
.where {
(TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and
(BuchungTable.gebuchtAm.between(von, bis))
}
.map { it.toModel() }
}
override fun save(buchung: Buchung): Buchung { override fun save(buchung: Buchung): Buchung {
BuchungTable.insert { BuchungTable.insert {
it[id] = buchung.buchungId it[id] = buchung.buchungId
@@ -111,6 +134,7 @@ class ExposedBuchungRepository : BuchungRepository {
it[typ] = buchung.typ.name it[typ] = buchung.typ.name
it[verwendungszweck] = buchung.verwendungszweck it[verwendungszweck] = buchung.verwendungszweck
it[gebuchtAm] = buchung.gebuchtAm it[gebuchtAm] = buchung.gebuchtAm
it[storniertBuchungId] = buchung.storniertBuchungId
} }
return buchung return buchung
} }
@@ -121,6 +145,45 @@ class ExposedBuchungRepository : BuchungRepository {
betragCent = this[BuchungTable.betragCent], betragCent = this[BuchungTable.betragCent],
typ = BuchungsTyp.valueOf(this[BuchungTable.typ]), typ = BuchungsTyp.valueOf(this[BuchungTable.typ]),
verwendungszweck = this[BuchungTable.verwendungszweck], verwendungszweck = this[BuchungTable.verwendungszweck],
gebuchtAm = this[BuchungTable.gebuchtAm] gebuchtAm = this[BuchungTable.gebuchtAm],
storniertBuchungId = this[BuchungTable.storniertBuchungId]
)
}
@Repository
class ExposedTagesabschlussRepository : TagesabschlussRepository {
override fun findByVeranstaltung(veranstaltungId: Uuid): List<Tagesabschluss> {
return TagesabschlussTable
.selectAll()
.where { TagesabschlussTable.veranstaltungId eq veranstaltungId }
.map { it.toModel() }
}
override fun save(abschluss: Tagesabschluss): Tagesabschluss {
TagesabschlussTable.insert {
it[id] = abschluss.tagesabschlussId
it[veranstaltungId] = abschluss.veranstaltungId
it[abgeschlossenAm] = abschluss.abgeschlossenAm
it[abgeschlossenVon] = abschluss.abgeschlossenVon
it[summeBarCent] = abschluss.summeBarCent
it[summeKarteCent] = abschluss.summeKarteCent
it[summeGutschriftCent] = abschluss.summeGutschriftCent
it[anzahlBuchungen] = abschluss.anzahlBuchungen
it[bemerkungen] = abschluss.bemerkungen
}
return abschluss
}
private fun ResultRow.toModel() = Tagesabschluss(
tagesabschlussId = this[TagesabschlussTable.id],
veranstaltungId = this[TagesabschlussTable.veranstaltungId],
abgeschlossenAm = this[TagesabschlussTable.abgeschlossenAm],
abgeschlossenVon = this[TagesabschlussTable.abgeschlossenVon],
summeBarCent = this[TagesabschlussTable.summeBarCent],
summeKarteCent = this[TagesabschlussTable.summeKarteCent],
summeGutschriftCent = this[TagesabschlussTable.summeGutschriftCent],
anzahlBuchungen = this[TagesabschlussTable.anzahlBuchungen],
bemerkungen = this[TagesabschlussTable.bemerkungen]
) )
} }
@@ -0,0 +1,60 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.billing.service
import at.mocode.billing.domain.model.BuchungsTyp
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
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.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@SpringBootTest
@ActiveProfiles("test")
class TagesabschlussServiceTest {
@Autowired
lateinit var kontoService: TeilnehmerKontoService
@Autowired
lateinit var tagesabschlussService: TagesabschlussService
@Test
fun `Tagesabschluss aggregiert Buchungen korrekt`() {
val vId = Uuid.random()
val k1 = kontoService.getOrCreateKonto(vId, Uuid.random(), "Reiter A")
val k2 = kontoService.getOrCreateKonto(vId, Uuid.random(), "Reiter B")
val jetzt = Clock.System.now()
val von = jetzt - 1.hours
val bis = jetzt + 1.hours
// Buchungen erstellen
kontoService.buche(k1.kontoId, 5000L, BuchungsTyp.ZAHLUNG_BAR, "Barzahlung 1")
kontoService.buche(k2.kontoId, 3000L, BuchungsTyp.ZAHLUNG_BAR, "Barzahlung 2")
kontoService.buche(k1.kontoId, 2500L, BuchungsTyp.ZAHLUNG_KARTE, "Kartenzahlung")
kontoService.buche(k2.kontoId, 1000L, BuchungsTyp.GUTSCHRIFT, "Gutschrift")
// Gebühren (sollten nicht in den Zahlungs-Summen auftauchen)
kontoService.buche(k1.kontoId, 1500L, BuchungsTyp.NENNGEBUEHR, "Gebühr")
// Abschluss erstellen
val abschluss = tagesabschlussService.erstelleAbschluss(
veranstaltungId = vId,
von = von,
bis = bis,
abgeschlossenVon = "Admin"
)
assertNotNull(abschluss)
assertEquals(8000L, abschluss.summeBarCent)
assertEquals(2500L, abschluss.summeKarteCent)
assertEquals(1000L, abschluss.summeGutschriftCent)
assertEquals(5, abschluss.anzahlBuchungen) // 2x Bar + 1x Karte + 1x Gutschrift + 1x Gebühr
}
}
@@ -61,4 +61,34 @@ class TeilnehmerKontoServiceTest {
val historian = service.getBuchungsHistorie(konto.kontoId) val historian = service.getBuchungsHistorie(konto.kontoId)
assertEquals(2, historian.size) assertEquals(2, historian.size)
} }
@Test
fun `Buchung stornieren`() {
val veranstaltungId = Uuid.random()
val personId = Uuid.random()
val konto = service.getOrCreateKonto(veranstaltungId, personId, "Storno Test")
// 1. Ursprüngliche Buchung
val gebuchtKonto = service.buche(
kontoId = konto.kontoId,
betragCent = 2500L,
typ = BuchungsTyp.BOXENGEBUEHR,
zweck = "Boxenmiete"
)
assertEquals(-2500L, gebuchtKonto.saldoCent)
val buchung = service.getBuchungsHistorie(konto.kontoId).first()
// 2. Stornieren
val storniertKonto = service.storniereBuchung(buchung.buchungId, "Falsche Box")
assertEquals(0L, storniertKonto.saldoCent)
// 3. Historie prüfen
val buchungen = service.getBuchungsHistorie(konto.kontoId)
assertEquals(2, buchungen.size)
assertTrue(buchungen.any { it.typ == BuchungsTyp.STORNIERUNG })
val storno = buchungen.find { it.typ == BuchungsTyp.STORNIERUNG }!!
assertEquals(2500L, storno.betragCent)
assertEquals(buchung.buchungId, storno.storniertBuchungId)
}
} }
+2 -2
View File
@@ -267,10 +267,10 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Backend-Infrastruktur:** `billing-service` initialisiert, Docker-Integration und Gateway-Routing (Port 8087) konfiguriert. ✓ * [x] **Backend-Infrastruktur:** `billing-service` initialisiert, Docker-Integration und Gateway-Routing (Port 8087) konfiguriert. ✓
* [x] **Frontend-Anbindung:** `BillingRepository` (Ktor) und `BillingViewModel` auf reale API-Kommunikation umgestellt. ✓ * [x] **Frontend-Anbindung:** `BillingRepository` (Ktor) und `BillingViewModel` auf reale API-Kommunikation umgestellt. ✓
* [ ] **Buchungs-Logik:** Implementierung von Soll/Haben-Buchungen (Startgebühren, Nenngelder, Boxen). * [x] **Buchungs-Logik:** Implementierung von Soll/Haben-Buchungen (Startgebühren, Nenngelder, Boxen).
* [x] **Offene Posten:** Liste aller unbezahlten Beträge pro Teilnehmer/Pferd. ✓ * [x] **Offene Posten:** Liste aller unbezahlten Beträge pro Teilnehmer/Pferd. ✓
* [x] **Rechnungserstellung:** Generierung von PDF-Rechnungen und Zahlungsbestätigungen. ✓ * [x] **Rechnungserstellung:** Generierung von PDF-Rechnungen und Zahlungsbestätigungen. ✓
* [ ] **Kassa-Management:** Tagesabschluss, Storno-Logik und verschiedene Zahlungsarten. * [x] **Kassa-Management:** Tagesabschluss, Storno-Logik und verschiedene Zahlungsarten.
--- ---