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:
+18
-1
@@ -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
|
||||
|
||||
+16
@@ -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<Buchung>
|
||||
fun findById(buchungId: Uuid): Buchung?
|
||||
fun findByVeranstaltungAndZeitraum(
|
||||
veranstaltungId: Uuid,
|
||||
von: Instant,
|
||||
bis: Instant
|
||||
): List<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
|
||||
}
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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)!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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")
|
||||
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-5
@@ -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<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 {
|
||||
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<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]
|
||||
)
|
||||
}
|
||||
|
||||
+60
@@ -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
|
||||
}
|
||||
}
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. ✓
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user