From c1fadac944eac07073d454c584cf7426db65da46 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Fri, 10 Apr 2026 12:48:57 +0200 Subject: [PATCH] feat(entries+billing): integrate automatic fee booking for entries with billing service - **Entries-Service Updates:** - Implemented automatic booking of fees (entry fees and late fees) during entry submission using `TeilnehmerKontoService`. - Enhanced `Bewerb` entity with financial fields (`nenngeldCent`, `nachnenngebuehrCent`). - Added Flyway migration to update `bewerbe` table with new financial fields. - Updated `EntriesServiceApplication` to include billing package scanning for integration. - **Billing-Service Enhancements:** - Adjusted `TeilnehmerKontoService` to support fetching accounts by event and person. - Improved database configuration to handle missing JDBC URLs during tests. - **Tests:** - Added integration tests to validate fee booking logic for entries, including late fee scenarios. - Introduced H2 database setup for test isolation. - **Misc:** - Updated tenant-aware transactions to support H2 and PostgreSQL dialects. - Adjusted log and error handling for robust integration between services. --- .../billing/domain/model/TeilnehmerKonto.kt | 2 + .../billing/api/rest/BillingController.kt | 14 +- .../billing/service/TeilnehmerKontoService.kt | 6 + .../config/BillingDatabaseConfiguration.kt | 11 +- .../entries/entries-service/build.gradle.kts | 2 + .../service/EntriesServiceApplication.kt | 2 +- .../service/bewerbe/BewerbRepository.kt | 2 + .../service/bewerbe/BewerbRepositoryImpl.kt | 6 + .../service/persistence/BewerbTable.kt | 2 + .../tenant/ExposedTenantTransactions.kt | 7 +- .../service/usecase/NennungUseCases.kt | 43 +++++- .../control/V1__init_control_and_tenants.sql | 2 +- .../V8__add_bewerb_financial_fields.sql | 3 + .../config/TestExposedConfiguration.kt | 14 +- .../usecase/NennungBillingIntegrationTest.kt | 142 ++++++++++++++++++ .../2026-04-10_Billing-Setup_ZNS-Hardening.md | 20 ++- 16 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 backend/services/entries/entries-service/src/main/resources/db/tenant/V8__add_bewerb_financial_fields.sql create mode 100644 backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/usecase/NennungBillingIntegrationTest.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 c17b017e..02376b35 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 @@ -42,6 +42,8 @@ data class Buchung constructor( @Serializable enum class BuchungsTyp { NENNGEBUEHR, + NENNGELD, + NACHNENNGEBUEHR, STARTGEBUEHR, BOXENGEBUEHR, ZAHLUNG_BAR, diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt index 14b67891..8996cde4 100644 --- a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/api/rest/BillingController.kt @@ -58,7 +58,7 @@ class BillingController( @GetMapping("/konten/{kontoId}") fun getKonto(@PathVariable kontoId: String): ResponseEntity { - val uuid = try { Uuid.parse(kontoId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } val konto = kontoService.getKontoById(uuid) ?: return ResponseEntity.notFound().build() return ResponseEntity.ok(konto.toDto()) } @@ -68,8 +68,8 @@ class BillingController( @RequestParam veranstaltungId: String, @RequestParam personId: String ): ResponseEntity { - val vUuid = try { Uuid.parse(veranstaltungId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } - val pUuid = try { Uuid.parse(personId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val vUuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } + val pUuid = try { Uuid.parse(personId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } val konto = kontoService.getOrCreateKonto(vUuid, pUuid, "Unbekannt") // Name wird bei getOrCreate ggf. ignoriert wenn existiert return ResponseEntity.ok(konto.toDto()) @@ -77,8 +77,8 @@ class BillingController( @PostMapping("/konten") fun createKonto(@Valid @RequestBody request: CreateKontoRequest): ResponseEntity { - val vUuid = try { Uuid.parse(request.veranstaltungId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } - val pUuid = try { Uuid.parse(request.personId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val vUuid = try { Uuid.parse(request.veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } + val pUuid = try { Uuid.parse(request.personId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } val konto = kontoService.getOrCreateKonto(vUuid, pUuid, request.personName) return ResponseEntity.ok(konto.toDto()) @@ -86,7 +86,7 @@ class BillingController( @GetMapping("/konten/{kontoId}/buchungen") fun getBuchungen(@PathVariable kontoId: String): ResponseEntity> { - val uuid = try { Uuid.parse(kontoId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } val buchungen = kontoService.getBuchungsHistorie(uuid) return ResponseEntity.ok(buchungen.map { it.toDto() }) } @@ -96,7 +96,7 @@ class BillingController( @PathVariable kontoId: String, @Valid @RequestBody request: BuchungRequest ): ResponseEntity { - val uuid = try { Uuid.parse(kontoId) } catch (e: Exception) { return ResponseEntity.badRequest().build() } + val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() } val konto = kontoService.buche( kontoId = uuid, betragCent = request.betragCent, 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 678967f4..d9eea0c8 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 @@ -37,6 +37,12 @@ class TeilnehmerKontoService( } } + fun getKonto(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? { + return transaction { + kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId) + } + } + fun getBuchungsHistorie(kontoId: Uuid): List { return transaction { buchungRepository.findByKonto(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 81f34df0..6be9e56c 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 @@ -9,18 +9,23 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile @Configuration class BillingDatabaseConfiguration( - @Value("\${spring.datasource.url}") private val jdbcUrl: String, - @Value("\${spring.datasource.username}") private val username: String, - @Value("\${spring.datasource.password}") private val password: String + @Value("\${spring.datasource.url:}") private val jdbcUrl: String, + @Value("\${spring.datasource.username:}") private val username: String, + @Value("\${spring.datasource.password:}") private val password: String ) { private val log = LoggerFactory.getLogger(BillingDatabaseConfiguration::class.java) @PostConstruct fun initializeDatabase() { + if (jdbcUrl.isBlank()) { + log.warn("No spring.datasource.url provided. Skipping Billing database initialization.") + return + } log.info("Initializing database schema for Billing Service...") try { Database.connect(jdbcUrl, user = username, password = password) diff --git a/backend/services/entries/entries-service/build.gradle.kts b/backend/services/entries/entries-service/build.gradle.kts index 57cf788d..2357da14 100644 --- a/backend/services/entries/entries-service/build.gradle.kts +++ b/backend/services/entries/entries-service/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(projects.platform.platformDependencies) implementation(projects.backend.services.entries.entriesApi) implementation(projects.backend.services.entries.entriesDomain) + implementation(projects.backend.services.billing.billingDomain) + implementation(projects.backend.services.billing.billingService) implementation(projects.core.coreUtils) implementation(projects.core.coreDomain) implementation(projects.backend.infrastructure.monitoring.monitoringClient) diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt index e7011658..208f8d0f 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt @@ -11,7 +11,7 @@ fun main(args: Array) { runApplication(*args) } -@SpringBootApplication +@SpringBootApplication(scanBasePackages = ["at.mocode.entries", "at.mocode.billing"]) @EnableAspectJAutoProxy class EntriesServiceApplication { diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt index d3e5ff76..5607e02d 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt @@ -35,6 +35,8 @@ data class Bewerb( val stechenGeplant: Boolean = false, // Finanzen val startgeldCent: Long? = null, + val nenngeldCent: Long? = null, + val nachnenngebuehrCent: Long? = null, val geldpreisAusbezahlt: Boolean = false, // ZNS-Integration val znsNummer: Int? = null, diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt index eaecbd25..d0d8b0ff 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt @@ -72,6 +72,8 @@ class BewerbRepositoryImpl : BewerbRepository { stechenGeplant = row[BewerbTable.stechenGeplant], // Finanzen startgeldCent = row[BewerbTable.startgeldCent], + nenngeldCent = row[BewerbTable.nenngeldCent], + nachnenngebuehrCent = row[BewerbTable.nachnenngebuehrCent], geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt], // ZNS-Integration znsNummer = row[BewerbTable.znsNummer], @@ -106,6 +108,8 @@ class BewerbRepositoryImpl : BewerbRepository { s[BewerbTable.stechenGeplant] = b.stechenGeplant // Finanzen s[BewerbTable.startgeldCent] = b.startgeldCent + s[BewerbTable.nenngeldCent] = b.nenngeldCent + s[BewerbTable.nachnenngebuehrCent] = b.nachnenngebuehrCent s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt // ZNS-Integration s[BewerbTable.znsNummer] = b.znsNummer @@ -153,6 +157,8 @@ class BewerbRepositoryImpl : BewerbRepository { s[BewerbTable.stechenGeplant] = b.stechenGeplant // Finanzen s[BewerbTable.startgeldCent] = b.startgeldCent + s[BewerbTable.nenngeldCent] = b.nenngeldCent + s[BewerbTable.nachnenngebuehrCent] = b.nachnenngebuehrCent s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt // ZNS-Integration s[BewerbTable.znsNummer] = b.znsNummer diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt index d1a01998..aa141b74 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt @@ -36,6 +36,8 @@ object BewerbTable : Table("bewerbe") { // Finanzen val startgeldCent = long("startgeld_cent").nullable() + val nenngeldCent = long("nenngeld_cent").nullable() + val nachnenngebuehrCent = long("nachnenngebuehr_cent").nullable() val geldpreisAusbezahlt = bool("geldpreis_ausbezahlt").default(false) // ZNS-Integration diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt index d693b921..a0f645c7 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/tenant/ExposedTenantTransactions.kt @@ -10,6 +10,11 @@ suspend inline fun tenantTransaction(crossinline block: () -> T): T = transa val schema = TenantContextHolder.current()?.schemaName ?: error("No tenant in context. Ensure TenantWebFilter is installed and request has X-Event-Id") // Set search_path for this transaction/connection - TransactionManager.current().exec("SET search_path TO \"$schema\"") + val dialect = TransactionManager.current().db.vendor + if (dialect == "postgresql") { + TransactionManager.current().exec("SET search_path TO \"$schema\"") + } else if (dialect == "h2") { + TransactionManager.current().exec("SET SCHEMA \"$schema\"") + } block() } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt index c7ec4bf1..f78bab4c 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt @@ -2,12 +2,15 @@ package at.mocode.entries.service.usecase +import at.mocode.billing.domain.model.BuchungsTyp +import at.mocode.billing.service.TeilnehmerKontoService import at.mocode.core.domain.model.NennStatusE import at.mocode.entries.api.* import at.mocode.entries.domain.model.Nennung import at.mocode.entries.domain.model.NennungsTransfer import at.mocode.entries.domain.repository.NennungRepository import at.mocode.entries.domain.repository.NennungsTransferRepository +import at.mocode.entries.service.bewerbe.BewerbRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import kotlin.uuid.Uuid @@ -23,7 +26,9 @@ import kotlin.uuid.Uuid @Service class NennungUseCases( private val nennungRepository: NennungRepository, - private val transferRepository: NennungsTransferRepository + private val transferRepository: NennungsTransferRepository, + private val bewerbRepository: BewerbRepository, + private val kontoService: TeilnehmerKontoService ) { private val log = LoggerFactory.getLogger(NennungUseCases::class.java) @@ -74,6 +79,42 @@ class NennungUseCases( ) val saved = nennungRepository.save(nennung) log.info("Nennung eingereicht: nennungId={} turnierId={}", saved.nennungId, saved.turnierId) + + // Automatische Buchung der Gebühren (Nenngeld / Nachnenngebühr) + // Wir nutzen den zahlerId, oder falls nicht gesetzt den reiterId + val zahlerId = saved.zahlerId ?: saved.reiterId + val bewerb = bewerbRepository.findById(saved.bewerbId) + if (bewerb != null && (bewerb.nenngeldCent != null || bewerb.nachnenngebuehrCent != null)) { + try { + val konto = kontoService.getOrCreateKonto(saved.turnierId, zahlerId, "Zahler für Nennung ${saved.nennungId}") + + // Nenngeld buchen + if (bewerb.nenngeldCent != null && bewerb.nenngeldCent > 0) { + kontoService.buche( + kontoId = konto.kontoId, + betragCent = -bewerb.nenngeldCent, // Gebühr ist negativ + typ = BuchungsTyp.NENNGELD, + zweck = "Nenngeld Bewerb ${bewerb.bezeichnung} (${bewerb.klasse})" + ) + } + + // Nachnenngebühr buchen (falls fällig und nicht erlassen) + if (saved.isNachnenngebuehrFaellig() && bewerb.nachnenngebuehrCent != null && bewerb.nachnenngebuehrCent > 0) { + kontoService.buche( + kontoId = konto.kontoId, + betragCent = -bewerb.nachnenngebuehrCent, // Gebühr ist negativ + typ = BuchungsTyp.NACHNENNGEBUEHR, + zweck = "Nachnenngebühr Bewerb ${bewerb.bezeichnung}" + ) + } + } catch (e: Exception) { + log.error("Fehler bei der automatischen Buchung für Nennung {}: {}", saved.nennungId, e.message, e) + // Wir lassen die Nennung bestehen, loggen aber den Fehler. + // In einem echten System würde man hier evtl. ein Domain Event publizieren + // oder die Transaktion rollbacken (wenn gewünscht). + } + } + return saved.toDetailDto() } diff --git a/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql b/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql index 6aaa951d..8cf4f4e9 100644 --- a/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql +++ b/backend/services/entries/entries-service/src/main/resources/db/control/V1__init_control_and_tenants.sql @@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS control.tenants ( db_url TEXT NULL, status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | READ_ONLY | LOCKED version INT NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); -- Index to speed up lookups by status when we add list operations later diff --git a/backend/services/entries/entries-service/src/main/resources/db/tenant/V8__add_bewerb_financial_fields.sql b/backend/services/entries/entries-service/src/main/resources/db/tenant/V8__add_bewerb_financial_fields.sql new file mode 100644 index 00000000..b3763af4 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/db/tenant/V8__add_bewerb_financial_fields.sql @@ -0,0 +1,3 @@ +-- Migration: Hinzufügen von Finanz-Feldern zu Bewerben für automatische Buchung +ALTER TABLE bewerbe ADD COLUMN IF NOT EXISTS nenngeld_cent BIGINT; +ALTER TABLE bewerbe ADD COLUMN IF NOT EXISTS nachnenngebuehr_cent BIGINT; diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/config/TestExposedConfiguration.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/config/TestExposedConfiguration.kt index c1cfa295..11311064 100644 --- a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/config/TestExposedConfiguration.kt +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/config/TestExposedConfiguration.kt @@ -1,6 +1,10 @@ package at.mocode.entries.service.config +import at.mocode.billing.service.persistence.BuchungTable +import at.mocode.billing.service.persistence.TeilnehmerKontoTable import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile @@ -9,6 +13,7 @@ import javax.sql.DataSource /** * Verbindet Exposed mit der Spring-DataSource im Test-Profil. * Ersetzt die EntriesDatabaseConfiguration (die mit @Profile("!test") ausgeschlossen ist). + * Initialisiert auch das Billing-Schema, da BillingDatabaseConfiguration im Test ebenfalls ausgeschlossen ist. */ @Configuration @Profile("test") @@ -16,6 +21,13 @@ class TestExposedConfiguration { @Bean fun exposedDatabase(dataSource: DataSource): Database { - return Database.connect(dataSource) + val db = Database.connect(dataSource) + transaction(db) { + SchemaUtils.create( + TeilnehmerKontoTable, + BuchungTable + ) + } + return db } } diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/usecase/NennungBillingIntegrationTest.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/usecase/NennungBillingIntegrationTest.kt new file mode 100644 index 00000000..427eaa0f --- /dev/null +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/usecase/NennungBillingIntegrationTest.kt @@ -0,0 +1,142 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.entries.service.usecase + +import at.mocode.billing.domain.model.BuchungsTyp +import at.mocode.billing.service.TeilnehmerKontoService +import at.mocode.entries.api.NennungEinreichenRequest +import at.mocode.entries.service.bewerbe.Bewerb +import at.mocode.entries.service.bewerbe.BewerbRepository +import at.mocode.entries.service.persistence.AbteilungTable +import at.mocode.entries.service.persistence.BewerbRichterEinsatzTable +import at.mocode.entries.service.persistence.BewerbTable +import at.mocode.entries.service.persistence.NennungTable +import at.mocode.entries.service.tenant.Tenant +import at.mocode.entries.service.tenant.TenantContextHolder +import at.mocode.entries.service.tenant.tenantTransaction +import org.jetbrains.exposed.v1.jdbc.deleteAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +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.uuid.Uuid + +@SpringBootTest +@ActiveProfiles("test") +class NennungBillingIntegrationTest { + + @Autowired + private lateinit var nennungUseCases: NennungUseCases + + @Autowired + private lateinit var bewerbRepository: BewerbRepository + + @Autowired + private lateinit var kontoService: TeilnehmerKontoService + + private val turnierId = Uuid.random() + private val reiterId = Uuid.random() + private val pferdId = Uuid.random() + private val abteilungId = Uuid.random() + + @BeforeEach + fun setup() { + // We use PUBLIC schema in H2 for simplicity in this integration test + TenantContextHolder.set(Tenant(turnierId.toString(), "PUBLIC", "jdbc:h2:mem:entries-test")) + + // Ensure tables exist in H2 (Flyway might have run on public already, but let's be sure) + kotlinx.coroutines.runBlocking { + tenantTransaction { + org.jetbrains.exposed.v1.jdbc.SchemaUtils.create( + NennungTable, + BewerbTable, + AbteilungTable, + BewerbRichterEinsatzTable + ) + NennungTable.deleteAll() + BewerbRichterEinsatzTable.deleteAll() + BewerbTable.deleteAll() + AbteilungTable.deleteAll() + } + } + } + + @AfterEach + fun teardown() { + TenantContextHolder.clear() + } + + @Test + fun `nennung einreichen bucht automatisch Nenngeld`() = kotlinx.coroutines.runBlocking { + // GIVEN: Ein Bewerb mit Nenngeld + val bewerb = bewerbRepository.create(Bewerb( + id = Uuid.random(), + turnierId = turnierId, + klasse = "L", + bezeichnung = "Standardspringprüfung", + nenngeldCent = 2500, // 25,00 EUR + hoeheCm = 120 + )) + + val request = NennungEinreichenRequest( + turnierId = turnierId, + bewerbId = bewerb.id, + abteilungId = abteilungId, + reiterId = reiterId, + pferdId = pferdId, + istNachnennung = false + ) + + // WHEN: Nennung einreichen + val result = nennungUseCases.nennungEinreichen(request) + + // THEN: Konto muss existieren und Saldo muss -25,00 EUR sein (Gebühr) + val konto = kontoService.getKonto(turnierId, reiterId) + assertNotNull(konto, "Konto sollte automatisch erstellt worden sein") + assertEquals(-2500L, konto?.saldoCent) + + val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId) + assertEquals(1, buchungen.size) + assertEquals(BuchungsTyp.NENNGELD, buchungen[0].typ) + assertEquals(-2500L, buchungen[0].betragCent) + } + + @Test + fun `nachnennung bucht zusätzlich Nachnenngebühr`() = kotlinx.coroutines.runBlocking { + // GIVEN: Ein Bewerb mit Nenngeld und Nachnenngebühr + val bewerb = bewerbRepository.create(Bewerb( + id = Uuid.random(), + turnierId = turnierId, + klasse = "M", + bezeichnung = "Zeitspringprüfung", + nenngeldCent = 3000, + nachnenngebuehrCent = 1500, + hoeheCm = 130 + )) + + val request = NennungEinreichenRequest( + turnierId = turnierId, + bewerbId = bewerb.id, + abteilungId = abteilungId, + reiterId = reiterId, + pferdId = pferdId, + istNachnennung = true + ) + + // WHEN: Nennung einreichen + nennungUseCases.nennungEinreichen(request) + + // THEN: Saldo muss -45,00 EUR sein (-30 - 15) + val konto = kontoService.getKonto(turnierId, reiterId) + assertEquals(-4500L, konto?.saldoCent) + + val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId) + assertEquals(2, buchungen.size) + // Einer muss NACHNENNGEBUEHR sein + assertNotNull(buchungen.find { it.typ == BuchungsTyp.NACHNENNGEBUEHR }) + } +} diff --git a/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md b/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md index 9688c2e3..0b266c8d 100644 --- a/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md +++ b/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md @@ -16,13 +16,19 @@ In dieser Session wurde das Fundament für den Kassa-Service (`billing-context`) * `billing-service` Modul erstellt, konfiguriert und mit `core-domain` (Serialisierung) verknüpft. * Exposed-Tabellendefinitionen (v1) für `TeilnehmerKonto` und `Buchung` implementiert. * `BillingController` mit REST-Endpunkten für Konten, Buchungen und Historie erstellt. - * `TeilnehmerKontoService` um API-Methoden (`getKontoById`, `getBuchungsHistorie`, `buche`) erweitert. + * `TeilnehmerKontoService` um API-Methoden (`getKontoById`, `getKonto`, `getBuchungsHistorie`, `buche`) erweitert. * Integrationstests (`TeilnehmerKontoServiceTest`) erfolgreich mit H2-In-Memory-DB durchgeführt. * **OpenAPI-Dokumentation:** `documentation.yaml` für `billing-service` erstellt und CRUD-Endpunkte für Konten und Buchungen dokumentiert. -2. **ZNS-Importer Hardening:** +2. **Entries-Integration (Neu):** + * Automatische Buchung von Nenngeld und Nachnenngebühren bei Einreichung einer Nennung implementiert. + * Erweiterung der `Bewerb`-Entität um Finanzfelder (`nenngeld_cent`, `nachnenngebuehr_cent`). + * Neue Flyway-Migration `V8__add_bewerb_financial_fields.sql` im `entries-service` hinzugefügt. + * `NennungUseCases` nutzt nun den `TeilnehmerKontoService` zur automatischen Belastung der Teilnehmerkonten (negativer Saldo). + * `EntriesServiceApplication` scannt nun auch `at.mocode.billing` Pakete für die Cross-Context Integration. +3. **ZNS-Importer Hardening:** * Erweiterung von `ZnsImportServiceTest` um Tests für mehrfache Qualifikationen und die Update-Strategie (Delete+Insert) bei Funktionären (`RICHT01.dat`). * Alle 11 Integrationstests sind erfolgreich durchgelaufen. -3. **Kompilations-Fixes (Billing):** +4. **Kompilations-Fixes (Billing):** * `billing-service` auf korrekte Exposed DSL Syntax (`selectAll().where { ... }`) umgestellt. * Explizite `transaction { ... }` Blöcke in `TeilnehmerKontoService` eingeführt. * Typ-Konsistenz für `Instant` (kotlin.time) in `billing-domain` zur Übereinstimmung mit `core-domain` hergestellt. @@ -32,9 +38,15 @@ In dieser Session wurde das Fundament für den Kassa-Service (`billing-context`) - `backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt` ### Nächste Schritte -- Integration des Billing-Services in den `entries-context` (z.B. automatische Buchung von Nenngebühren). +- [x] Integration des Billing-Services in den `entries-context` (automatische Buchung bei Nennung). +- [x] Fix von Kompilationsfehlern und Test-Regressionen (H2/Exposed Kompatibilität). - UI-Anbindung im Frontend für Kontenübersicht und manuelle Buchungen. - Erweiterung der Abrechnungs-Logik (z.B. Rechnungserstellung als PDF). +### Technische Details & Fixes +- **Exposed / H2:** `TIMESTAMPTZ` in Flyway-Migrationen auf `TIMESTAMP WITH TIME ZONE` umgestellt, um H2-Kompatibilität in Integrationstests zu gewährleisten. +- **Multi-Tenancy:** `ExposedTenantTransactions` unterstützt nun sowohl PostgreSQL (`SET search_path`) als auch H2 (`SET SCHEMA`). +- **Billing Config:** `BillingDatabaseConfiguration` ist nun robust gegen fehlende JDBC-URLs (wichtig für modularisierte Tests). + --- *Co-authored-by: Junie *