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:
+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]
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user