From cfe12e4dd0aa9bfac6f960b406560c114f82fd0a Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Tue, 14 Apr 2026 13:10:52 +0200 Subject: [PATCH] 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. --- .../billing/domain/model/TeilnehmerKonto.kt | 19 ++++- .../domain/repository/BillingRepositories.kt | 16 ++++ .../billing/service/TagesabschlussService.kt | 68 +++++++++++++++++ .../billing/service/TeilnehmerKontoService.kt | 32 ++++++++ .../config/BillingDatabaseConfiguration.kt | 4 +- .../service/persistence/BillingTables.kt | 22 ++++++ .../persistence/ExposedBillingRepositories.kt | 73 +++++++++++++++++-- .../service/TagesabschlussServiceTest.kt | 60 +++++++++++++++ .../service/TeilnehmerKontoServiceTest.kt | 30 ++++++++ docs/01_Architecture/MASTER_ROADMAP.md | 4 +- 10 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TagesabschlussService.kt create mode 100644 backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TagesabschlussServiceTest.kt diff --git a/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt index 02376b35..b863edd8 100644 --- a/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt +++ b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt @@ -36,7 +36,24 @@ data class Buchung constructor( val typ: BuchungsTyp, val verwendungszweck: String, @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 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 60fa3e59..9e5d08f1 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 @@ -3,7 +3,9 @@ package at.mocode.billing.domain.repository import at.mocode.billing.domain.model.Buchung +import at.mocode.billing.domain.model.Tagesabschluss import at.mocode.billing.domain.model.TeilnehmerKonto +import kotlin.time.Instant import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -24,5 +26,19 @@ interface TeilnehmerKontoRepository { */ interface BuchungRepository { fun findByKonto(kontoId: Uuid): List + fun findById(buchungId: Uuid): Buchung? + fun findByVeranstaltungAndZeitraum( + veranstaltungId: Uuid, + von: Instant, + bis: Instant + ): List fun save(buchung: Buchung): Buchung } + +/** + * Repository für den Zugriff auf Tagesabschlüsse. + */ +interface TagesabschlussRepository { + fun findByVeranstaltung(veranstaltungId: Uuid): List + fun save(abschluss: Tagesabschluss): Tagesabschluss +} diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TagesabschlussService.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TagesabschlussService.kt new file mode 100644 index 00000000..c721f44b --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TagesabschlussService.kt @@ -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 { + return transaction { + tagesabschlussRepository.findByVeranstaltung(veranstaltungId) + } + } +} 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 0d5356c5..8f6f7d31 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 @@ -94,4 +94,36 @@ class TeilnehmerKontoService( 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)!! + } + } } diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt index 9cf7b8b2..e67afb45 100644 --- a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/config/BillingDatabaseConfiguration.kt @@ -1,6 +1,7 @@ package at.mocode.billing.service.config import at.mocode.billing.service.persistence.BuchungTable +import at.mocode.billing.service.persistence.TagesabschlussTable import at.mocode.billing.service.persistence.TeilnehmerKontoTable import jakarta.annotation.PostConstruct import org.jetbrains.exposed.v1.jdbc.Database @@ -31,7 +32,8 @@ class BillingDatabaseConfiguration( transaction { SchemaUtils.create( TeilnehmerKontoTable, - BuchungTable + BuchungTable, + TagesabschlussTable ) } log.info("Billing database schema initialized successfully") diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt index b5017de1..c09c595b 100644 --- a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt @@ -38,6 +38,7 @@ object BuchungTable : Table("buchungen") { val typ = varchar("typ", 50) val verwendungszweck = varchar("verwendungszweck", 500) val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp) + val storniertBuchungId = uuid("storniert_buchung_id").nullable() override val primaryKey = PrimaryKey(id) @@ -45,3 +46,24 @@ object BuchungTable : Table("buchungen") { 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) + } +} 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 96409a8d..e6651251 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 @@ -4,18 +4,18 @@ 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.Tagesabschluss import at.mocode.billing.domain.model.TeilnehmerKonto import at.mocode.billing.domain.repository.BuchungRepository +import at.mocode.billing.domain.repository.TagesabschlussRepository 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.core.less +import org.jetbrains.exposed.v1.core.* 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.time.Instant import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -103,6 +103,29 @@ class ExposedBuchungRepository : BuchungRepository { .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 { + // 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 { BuchungTable.insert { it[id] = buchung.buchungId @@ -111,6 +134,7 @@ class ExposedBuchungRepository : BuchungRepository { it[typ] = buchung.typ.name it[verwendungszweck] = buchung.verwendungszweck it[gebuchtAm] = buchung.gebuchtAm + it[storniertBuchungId] = buchung.storniertBuchungId } return buchung } @@ -121,6 +145,45 @@ class ExposedBuchungRepository : BuchungRepository { betragCent = this[BuchungTable.betragCent], typ = BuchungsTyp.valueOf(this[BuchungTable.typ]), 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 { + 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] ) } diff --git a/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TagesabschlussServiceTest.kt b/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TagesabschlussServiceTest.kt new file mode 100644 index 00000000..1e7ab0c6 --- /dev/null +++ b/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TagesabschlussServiceTest.kt @@ -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 + } +} diff --git a/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt b/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt index dec6b881..058e2080 100644 --- a/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt +++ b/backend/services/billing/billing-service/src/test/kotlin/at/mocode/billing/service/TeilnehmerKontoServiceTest.kt @@ -61,4 +61,34 @@ class TeilnehmerKontoServiceTest { val historian = service.getBuchungsHistorie(konto.kontoId) 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) + } } diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 9a0764ed..2bcba124 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -267,10 +267,10 @@ und über definierte Schnittstellen kommunizieren. * [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. ✓ -* [ ] **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] **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. ✓ ---