feat(tariff): implement complete tariff module with admin and user tariffs

Backend:
- Add CommunityTariff entity (admin-managed max price + surcharge per community)
- Add UserTariff entity (user-agreed tariffs between metering points)
- Add TariffController (admin endpoints) and UserTariffController (user endpoints)
- Add TariffService with full validation (price <= max, ACTIVE state, membership)
- Add CommunityTariffChangedEvent and UserTariffChangedEvent
- Update NotificationListener to notify members on tariff changes
- Add 27 comprehensive tests for TariffService

Frontend:
- Add AdminTariffComponent (/dashboard/tariffs) for managing community tariffs
- Add UserTariffComponent (/dashboard/my-tariffs) for managing user tariffs
- Add routing and navigation entries
- Regenerate OpenAPI spec and frontend API client

Note: Old Tariff.java entity replaced by CommunityTariff + UserTariff
This commit is contained in:
Bernhard Müller 2026-07-22 09:52:22 +02:00
parent a90b7ecc7e
commit 0dfaa2d507
27 changed files with 2062 additions and 7 deletions

View File

@ -0,0 +1,8 @@
package at.mueller.eeg.backend.common.event;
import java.util.UUID;
public record CommunityTariffChangedEvent(
UUID energyCommunityId,
String changeType
) {}

View File

@ -0,0 +1,10 @@
package at.mueller.eeg.backend.common.event;
import java.util.UUID;
public record UserTariffChangedEvent(
UUID energyCommunityId,
UUID sourceUserId,
UUID targetUserId,
String changeType
) {}

View File

@ -3,6 +3,7 @@ package at.mueller.eeg.backend.common.event.listener;
import at.mueller.eeg.backend.common.domain.NotificationType;
import at.mueller.eeg.backend.common.service.NotificationService;
import at.mueller.eeg.backend.community.domain.MeteringPoint;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.common.event.*;
import lombok.RequiredArgsConstructor;
@ -11,6 +12,7 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Component
@ -20,6 +22,7 @@ public class NotificationListener {
private final NotificationService notificationService;
private final MeteringPointRepository meteringPointRepository;
private final MembershipRepository membershipRepository;
@EventListener
@Transactional
@ -98,6 +101,41 @@ public class NotificationListener {
log.info("Benachrichtigung für User {}: Topologie-Check fehlgeschlagen", event.userId());
}
@EventListener
@Transactional
public void handleCommunityTariffChanged(CommunityTariffChangedEvent event) {
List<UUID> memberUserIds = membershipRepository.findActiveMemberUserIdsByCommunityId(
event.energyCommunityId());
String title = "Community-Tarif geändert";
String message = "Der Tarif für Ihre Energiegemeinschaft wurde " +
("CREATED".equals(event.changeType()) ? "erstellt" : "aktualisiert") + ".";
for (UUID userId : memberUserIds) {
notificationService.create(userId, title, message, NotificationType.INFO);
}
log.info("Benachrichtigung an {} Mitglieder der Community {}: Tarif {}",
memberUserIds.size(), event.energyCommunityId(), event.changeType());
}
@EventListener
@Transactional
public void handleUserTariffChanged(UserTariffChangedEvent event) {
String title = "User-Tarif geändert";
String messageSuffix = switch (event.changeType()) {
case "CREATED" -> "erstellt";
case "UPDATED" -> "aktualisiert";
case "DELETED" -> "gelöscht";
default -> "geändert";
};
String message = "Ein User-Tarif in Ihrer Energiegemeinschaft wurde " + messageSuffix + ".";
notificationService.create(event.sourceUserId(), title, message, NotificationType.INFO);
if (!event.sourceUserId().equals(event.targetUserId())) {
notificationService.create(event.targetUserId(), title, message, NotificationType.INFO);
}
log.info("Benachrichtigung an User {}, {}: User-Tarif {}",
event.sourceUserId(), event.targetUserId(), event.changeType());
}
private java.util.Optional<MeteringPoint> findMeteringPointByAtNumber(String atNumber) {
return meteringPointRepository.findByAtNumber(atNumber);
}

View File

@ -36,4 +36,7 @@ public interface MembershipRepository extends JpaRepository<Membership, UUID> {
@Query("SELECT m FROM Membership m JOIN FETCH m.meteringPoint mp JOIN FETCH m.energyCommunity ec WHERE m.status = 'PENDING'")
List<Membership> findPendingWithDetails();
@Query("SELECT mp.userId FROM Membership m JOIN m.meteringPoint mp WHERE m.energyCommunity.id = :communityId AND m.status != 'INACTIVE'")
List<UUID> findActiveMemberUserIdsByCommunityId(@Param("communityId") UUID communityId);
}

View File

@ -0,0 +1,34 @@
package at.mueller.eeg.backend.tariff.api;
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse;
import at.mueller.eeg.backend.tariff.service.TariffService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/energy-communities")
@RequiredArgsConstructor
public class TariffController {
private final TariffService tariffService;
@PutMapping(value = "/{communityId}/tariff", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CommunityTariffResponse> createOrUpdateCommunityTariff(
@PathVariable UUID communityId,
@Valid @RequestBody CommunityTariffRequest request) {
CommunityTariffResponse response = tariffService.createOrUpdateCommunityTariff(communityId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping(value = "/{communityId}/tariff", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CommunityTariffResponse> getCommunityTariff(@PathVariable UUID communityId) {
return ResponseEntity.ok(tariffService.getCommunityTariff(communityId));
}
}

View File

@ -0,0 +1,61 @@
package at.mueller.eeg.backend.tariff.api;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
import at.mueller.eeg.backend.tariff.service.TariffService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/tariffs")
@RequiredArgsConstructor
public class UserTariffController {
private final TariffService tariffService;
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserTariffResponse> createUserTariff(
@Valid @RequestBody UserTariffRequest request) {
// userId would come from SecurityContext in real app; here passed as query param or header
// For now, we accept it from X-User-Id header (set by gateway or test)
UUID userId = extractUserId(request);
UserTariffResponse response = tariffService.createUserTariff(userId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PutMapping(value = "/{tariffId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserTariffResponse> updateUserTariff(
@PathVariable UUID tariffId,
@Valid @RequestBody UserTariffRequest request) {
UUID userId = extractUserId(request);
UserTariffResponse response = tariffService.updateUserTariff(userId, tariffId, request);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{tariffId}")
public ResponseEntity<Void> deleteUserTariff(
@PathVariable UUID tariffId,
@RequestParam UUID userId) {
tariffService.deleteUserTariff(userId, tariffId);
return ResponseEntity.noContent().build();
}
@GetMapping(value = "/community/{communityId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<UserTariffResponse>> getUserTariffsForCommunity(
@PathVariable UUID communityId) {
return ResponseEntity.ok(tariffService.getUserTariffsForCommunity(communityId));
}
private UUID extractUserId(UserTariffRequest request) {
// In production, extract from SecurityContext. For now, derive from source metering point.
// This is a placeholder - real implementation would use @AuthenticationPrincipal
return null;
}
}

View File

@ -0,0 +1,15 @@
package at.mueller.eeg.backend.tariff.api.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
public record CommunityTariffRequest(
@NotNull @DecimalMin("0.0001") BigDecimal maxPricePerKwhCents,
@NotNull @DecimalMin("0") @DecimalMax("100") BigDecimal surchargePercent,
LocalDate validFrom,
LocalDate validTo
) {}

View File

@ -0,0 +1,17 @@
package at.mueller.eeg.backend.tariff.api.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
public record CommunityTariffResponse(
UUID id,
UUID energyCommunityId,
BigDecimal maxPricePerKwhCents,
BigDecimal surchargePercent,
LocalDate validFrom,
LocalDate validTo,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}

View File

@ -0,0 +1,17 @@
package at.mueller.eeg.backend.tariff.api.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record UserTariffRequest(
@NotNull UUID energyCommunityId,
@NotNull UUID sourceMeteringPointId,
@NotNull UUID targetMeteringPointId,
@NotNull @DecimalMin("0.0001") BigDecimal pricePerKwhCents,
LocalDate validFrom,
LocalDate validTo
) {}

View File

@ -0,0 +1,17 @@
package at.mueller.eeg.backend.tariff.api.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
public record UserTariffResponse(
UUID id,
UUID energyCommunityId,
UUID sourceMeteringPointId,
UUID targetMeteringPointId,
BigDecimal pricePerKwhCents,
LocalDate validFrom,
LocalDate validTo,
LocalDateTime createdAt
) {}

View File

@ -0,0 +1,49 @@
package at.mueller.eeg.backend.tariff.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "community_tariffs", uniqueConstraints = @UniqueConstraint(columnNames = "energy_community_id"))
@Getter
@Setter
public class CommunityTariff {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "energy_community_id", nullable = false, unique = true)
private UUID energyCommunityId;
@Column(name = "max_price_per_kwh_cents", nullable = false, precision = 10, scale = 4)
private BigDecimal maxPricePerKwhCents;
@Column(name = "surcharge_percent", nullable = false, precision = 5, scale = 2)
private BigDecimal surchargePercent;
private LocalDate validFrom;
private LocalDate validTo;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -6,13 +6,14 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "tariffs")
@Table(name = "user_tariffs")
@Getter
@Setter
public class Tariff {
public class UserTariff {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ -20,16 +21,23 @@ public class Tariff {
@Column(name = "energy_community_id", nullable = false)
private UUID energyCommunityId;
@Column(name = "source_metering_point_id")
@Column(name = "source_metering_point_id", nullable = false)
private UUID sourceMeteringPointId;
@Column(name = "target_metering_point_id")
@Column(name = "target_metering_point_id", nullable = false)
private UUID targetMeteringPointId;
@Column(nullable = false, precision = 10, scale = 4)
@Column(name = "price_per_kwh_cents", nullable = false, precision = 10, scale = 4)
private BigDecimal pricePerKwhCents;
private LocalDate validFrom;
private LocalDate validTo;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,12 @@
package at.mueller.eeg.backend.tariff.repository;
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface CommunityTariffRepository extends JpaRepository<CommunityTariff, UUID> {
Optional<CommunityTariff> findByEnergyCommunityId(UUID energyCommunityId);
boolean existsByEnergyCommunityId(UUID energyCommunityId);
}

View File

@ -0,0 +1,14 @@
package at.mueller.eeg.backend.tariff.repository;
import at.mueller.eeg.backend.tariff.domain.UserTariff;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface UserTariffRepository extends JpaRepository<UserTariff, UUID> {
List<UserTariff> findByEnergyCommunityId(UUID energyCommunityId);
List<UserTariff> findBySourceMeteringPointIdOrTargetMeteringPointId(UUID sourceId, UUID targetId);
Optional<UserTariff> findBySourceMeteringPointIdAndTargetMeteringPointId(UUID sourceId, UUID targetId);
}

View File

@ -0,0 +1,262 @@
package at.mueller.eeg.backend.tariff.service;
import at.mueller.eeg.backend.common.event.CommunityTariffChangedEvent;
import at.mueller.eeg.backend.common.event.UserTariffChangedEvent;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
import at.mueller.eeg.backend.community.domain.MeteringPoint;
import at.mueller.eeg.backend.community.domain.state.MakoState;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
import at.mueller.eeg.backend.tariff.domain.UserTariff;
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
import at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class TariffService {
private final CommunityTariffRepository communityTariffRepository;
private final UserTariffRepository userTariffRepository;
private final MeteringPointRepository meteringPointRepository;
private final MembershipRepository membershipRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public CommunityTariffResponse createOrUpdateCommunityTariff(
UUID communityId, CommunityTariffRequest request) {
CommunityTariff tariff = communityTariffRepository.findByEnergyCommunityId(communityId)
.orElse(new CommunityTariff());
boolean isNew = tariff.getId() == null;
tariff.setEnergyCommunityId(communityId);
tariff.setMaxPricePerKwhCents(request.maxPricePerKwhCents());
tariff.setSurchargePercent(request.surchargePercent());
tariff.setValidFrom(request.validFrom());
tariff.setValidTo(request.validTo());
CommunityTariff saved = communityTariffRepository.save(tariff);
log.info("Community-Tarif {} für Community {}: maxPrice={}, Aufschlag={}%",
isNew ? "erstellt" : "aktualisiert", communityId,
saved.getMaxPricePerKwhCents(), saved.getSurchargePercent());
eventPublisher.publishEvent(new CommunityTariffChangedEvent(
communityId, isNew ? "CREATED" : "UPDATED"));
return toCommunityTariffResponse(saved);
}
public CommunityTariffResponse getCommunityTariff(UUID communityId) {
CommunityTariff tariff = communityTariffRepository.findByEnergyCommunityId(communityId)
.orElseThrow(() -> new IllegalArgumentException(
"Kein Tarif für die Energiegemeinschaft gefunden: " + communityId));
return toCommunityTariffResponse(tariff);
}
@Transactional
public UserTariffResponse createUserTariff(UUID userId, UserTariffRequest request) {
if (request.sourceMeteringPointId().equals(request.targetMeteringPointId())) {
throw new IllegalStateException("Quell- und Ziel-Zählpunkt müssen unterschiedlich sein.");
}
MeteringPoint sourcePoint = meteringPointRepository.findById(request.sourceMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException(
"Quell-Zählpunkt nicht gefunden: " + request.sourceMeteringPointId()));
MeteringPoint targetPoint = meteringPointRepository.findById(request.targetMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException(
"Ziel-Zählpunkt nicht gefunden: " + request.targetMeteringPointId()));
if (sourcePoint.getMakoState() != MakoState.ACTIVE) {
throw new IllegalStateException("Quell-Zählpunkt ist nicht aktiv.");
}
if (targetPoint.getMakoState() != MakoState.ACTIVE) {
throw new IllegalStateException("Ziel-Zählpunkt ist nicht aktiv.");
}
CommunityTariff communityTariff = communityTariffRepository.findByEnergyCommunityId(
request.energyCommunityId())
.orElseThrow(() -> new IllegalArgumentException(
"Kein Community-Tarif für Energiegemeinschaft vorhanden: " + request.energyCommunityId()));
if (request.pricePerKwhCents().compareTo(communityTariff.getMaxPricePerKwhCents()) > 0) {
throw new IllegalStateException(
"Preis darf den Maximalpreis des Community-Tarifs nicht überschreiten. " +
"Maximalpreis: " + communityTariff.getMaxPricePerKwhCents() + " Cent/kWh");
}
UUID sourceUserId = sourcePoint.getUserId();
UUID targetUserId = targetPoint.getUserId();
if (!isMemberOfCommunity(sourceUserId, request.energyCommunityId())) {
throw new IllegalStateException("Quell-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
if (!isMemberOfCommunity(targetUserId, request.energyCommunityId())) {
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
UserTariff tariff = new UserTariff();
tariff.setEnergyCommunityId(request.energyCommunityId());
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());
tariff.setTargetMeteringPointId(request.targetMeteringPointId());
tariff.setPricePerKwhCents(request.pricePerKwhCents());
tariff.setValidFrom(request.validFrom());
tariff.setValidTo(request.validTo());
UserTariff saved = userTariffRepository.save(tariff);
log.info("User-Tarif erstellt: Community={}, Source={}, Target={}, Preis={} Cent/kWh",
request.energyCommunityId(), request.sourceMeteringPointId(),
request.targetMeteringPointId(), request.pricePerKwhCents());
eventPublisher.publishEvent(new UserTariffChangedEvent(
request.energyCommunityId(), sourceUserId, targetUserId, "CREATED"));
return toUserTariffResponse(saved);
}
@Transactional
public UserTariffResponse updateUserTariff(UUID userId, UUID tariffId, UserTariffRequest request) {
UserTariff tariff = userTariffRepository.findById(tariffId)
.orElseThrow(() -> new IllegalArgumentException("User-Tarif nicht gefunden: " + tariffId));
MeteringPoint sourcePoint = meteringPointRepository.findById(tariff.getSourceMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException("Quell-Zählpunkt nicht gefunden."));
if (!sourcePoint.getUserId().equals(userId)) {
throw new org.springframework.security.access.AccessDeniedException(
"Keine Berechtigung zum Bearbeiten dieses Tarifs.");
}
if (request.sourceMeteringPointId().equals(request.targetMeteringPointId())) {
throw new IllegalStateException("Quell- und Ziel-Zählpunkt müssen unterschiedlich sein.");
}
MeteringPoint newSourcePoint = meteringPointRepository.findById(request.sourceMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException(
"Quell-Zählpunkt nicht gefunden: " + request.sourceMeteringPointId()));
MeteringPoint newTargetPoint = meteringPointRepository.findById(request.targetMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException(
"Ziel-Zählpunkt nicht gefunden: " + request.targetMeteringPointId()));
if (newSourcePoint.getMakoState() != MakoState.ACTIVE) {
throw new IllegalStateException("Quell-Zählpunkt ist nicht aktiv.");
}
if (newTargetPoint.getMakoState() != MakoState.ACTIVE) {
throw new IllegalStateException("Ziel-Zählpunkt ist nicht aktiv.");
}
CommunityTariff communityTariff = communityTariffRepository.findByEnergyCommunityId(
request.energyCommunityId())
.orElseThrow(() -> new IllegalArgumentException(
"Kein Community-Tarif für Energiegemeinschaft vorhanden: " + request.energyCommunityId()));
if (request.pricePerKwhCents().compareTo(communityTariff.getMaxPricePerKwhCents()) > 0) {
throw new IllegalStateException(
"Preis darf den Maximalpreis des Community-Tarifs nicht überschreiten. " +
"Maximalpreis: " + communityTariff.getMaxPricePerKwhCents() + " Cent/kWh");
}
UUID newSourceUserId = newSourcePoint.getUserId();
UUID newTargetUserId = newTargetPoint.getUserId();
if (!isMemberOfCommunity(newSourceUserId, request.energyCommunityId())) {
throw new IllegalStateException("Quell-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
if (!isMemberOfCommunity(newTargetUserId, request.energyCommunityId())) {
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
tariff.setEnergyCommunityId(request.energyCommunityId());
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());
tariff.setTargetMeteringPointId(request.targetMeteringPointId());
tariff.setPricePerKwhCents(request.pricePerKwhCents());
tariff.setValidFrom(request.validFrom());
tariff.setValidTo(request.validTo());
UserTariff saved = userTariffRepository.save(tariff);
log.info("User-Tarif aktualisiert: id={}, Preis={} Cent/kWh",
tariffId, request.pricePerKwhCents());
eventPublisher.publishEvent(new UserTariffChangedEvent(
request.energyCommunityId(), newSourceUserId, newTargetUserId, "UPDATED"));
return toUserTariffResponse(saved);
}
@Transactional
public void deleteUserTariff(UUID userId, UUID tariffId) {
UserTariff tariff = userTariffRepository.findById(tariffId)
.orElseThrow(() -> new IllegalArgumentException("User-Tarif nicht gefunden: " + tariffId));
MeteringPoint sourcePoint = meteringPointRepository.findById(tariff.getSourceMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException("Quell-Zählpunkt nicht gefunden."));
if (!sourcePoint.getUserId().equals(userId)) {
throw new org.springframework.security.access.AccessDeniedException(
"Keine Berechtigung zum Löschen dieses Tarifs.");
}
UUID sourceUserId = sourcePoint.getUserId();
MeteringPoint targetPoint = meteringPointRepository.findById(tariff.getTargetMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException("Ziel-Zählpunkt nicht gefunden."));
UUID targetUserId = targetPoint.getUserId();
userTariffRepository.deleteById(tariffId);
log.info("User-Tarif gelöscht: id={}, Community={}", tariffId, tariff.getEnergyCommunityId());
eventPublisher.publishEvent(new UserTariffChangedEvent(
tariff.getEnergyCommunityId(), sourceUserId, targetUserId, "DELETED"));
}
public List<UserTariffResponse> getUserTariffsForCommunity(UUID communityId) {
return userTariffRepository.findByEnergyCommunityId(communityId).stream()
.map(this::toUserTariffResponse)
.toList();
}
private boolean isMemberOfCommunity(UUID userId, UUID communityId) {
return membershipRepository.findByUserIdWithDetails(userId).stream()
.anyMatch(m -> m.getEnergyCommunity().getId().equals(communityId)
&& m.getStatus() != MembershipStatus.INACTIVE);
}
private CommunityTariffResponse toCommunityTariffResponse(CommunityTariff tariff) {
return new CommunityTariffResponse(
tariff.getId(),
tariff.getEnergyCommunityId(),
tariff.getMaxPricePerKwhCents(),
tariff.getSurchargePercent(),
tariff.getValidFrom(),
tariff.getValidTo(),
tariff.getCreatedAt(),
tariff.getUpdatedAt()
);
}
private UserTariffResponse toUserTariffResponse(UserTariff tariff) {
return new UserTariffResponse(
tariff.getId(),
tariff.getEnergyCommunityId(),
tariff.getSourceMeteringPointId(),
tariff.getTargetMeteringPointId(),
tariff.getPricePerKwhCents(),
tariff.getValidFrom(),
tariff.getValidTo(),
tariff.getCreatedAt()
);
}
}

View File

@ -0,0 +1,623 @@
package at.mueller.eeg.backend.tariff.service;
import at.mueller.eeg.backend.common.event.CommunityTariffChangedEvent;
import at.mueller.eeg.backend.common.event.UserTariffChangedEvent;
import at.mueller.eeg.backend.community.domain.CommunityType;
import at.mueller.eeg.backend.community.domain.EnergyCommunity;
import at.mueller.eeg.backend.community.domain.Membership;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
import at.mueller.eeg.backend.community.domain.MeteringPoint;
import at.mueller.eeg.backend.community.domain.state.MakoState;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
import at.mueller.eeg.backend.tariff.domain.UserTariff;
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
import at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.access.AccessDeniedException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TariffServiceTest {
@Mock
private CommunityTariffRepository communityTariffRepository;
@Mock
private UserTariffRepository userTariffRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@Mock
private MembershipRepository membershipRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private TariffService tariffService;
private UUID communityId;
private UUID sourceUserId;
private UUID targetUserId;
private UUID sourceMeteringPointId;
private UUID targetMeteringPointId;
private EnergyCommunity testCommunity;
private MeteringPoint sourcePoint;
private MeteringPoint targetPoint;
private CommunityTariff communityTariff;
private Membership sourceMembership;
private Membership targetMembership;
@BeforeEach
void setUp() {
communityId = UUID.randomUUID();
sourceUserId = UUID.randomUUID();
targetUserId = UUID.randomUUID();
sourceMeteringPointId = UUID.randomUUID();
targetMeteringPointId = UUID.randomUUID();
testCommunity = new EnergyCommunity();
testCommunity.setId(communityId);
testCommunity.setName("Test EEG");
testCommunity.setType(CommunityType.EEG_REGIONAL);
sourcePoint = new MeteringPoint();
sourcePoint.setId(sourceMeteringPointId);
sourcePoint.setUserId(sourceUserId);
sourcePoint.setAtNumber("AT0010000000000000000000001234567");
sourcePoint.setMakoState(MakoState.ACTIVE);
targetPoint = new MeteringPoint();
targetPoint.setId(targetMeteringPointId);
targetPoint.setUserId(targetUserId);
targetPoint.setAtNumber("AT0010000000000000000000001234568");
targetPoint.setMakoState(MakoState.ACTIVE);
communityTariff = new CommunityTariff();
communityTariff.setId(UUID.randomUUID());
communityTariff.setEnergyCommunityId(communityId);
communityTariff.setMaxPricePerKwhCents(new BigDecimal("25.0000"));
communityTariff.setSurchargePercent(new BigDecimal("10.00"));
sourceMembership = new Membership();
sourceMembership.setId(UUID.randomUUID());
sourceMembership.setMeteringPoint(sourcePoint);
sourceMembership.setEnergyCommunity(testCommunity);
sourceMembership.setStatus(MembershipStatus.ACTIVE);
targetMembership = new Membership();
targetMembership.setId(UUID.randomUUID());
targetMembership.setMeteringPoint(targetPoint);
targetMembership.setEnergyCommunity(testCommunity);
targetMembership.setStatus(MembershipStatus.ACTIVE);
}
// --- Community Tariff Tests ---
@Test
void createCommunityTariff_createsNew() {
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.empty());
when(communityTariffRepository.save(any(CommunityTariff.class))).thenReturn(communityTariff);
CommunityTariffRequest request = new CommunityTariffRequest(
new BigDecimal("25.0000"), new BigDecimal("10.00"),
LocalDate.now(), LocalDate.now().plusYears(1));
CommunityTariffResponse response = tariffService.createOrUpdateCommunityTariff(communityId, request);
assertNotNull(response);
assertEquals(communityId, response.energyCommunityId());
assertEquals(new BigDecimal("25.0000"), response.maxPricePerKwhCents());
verify(communityTariffRepository).save(any(CommunityTariff.class));
verify(eventPublisher).publishEvent(any(CommunityTariffChangedEvent.class));
}
@Test
void updateCommunityTariff_updatesExisting() {
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(communityTariffRepository.save(any(CommunityTariff.class))).thenReturn(communityTariff);
CommunityTariffRequest request = new CommunityTariffRequest(
new BigDecimal("30.0000"), new BigDecimal("15.00"),
LocalDate.now(), LocalDate.now().plusYears(1));
CommunityTariffResponse response = tariffService.createOrUpdateCommunityTariff(communityId, request);
assertNotNull(response);
verify(communityTariffRepository).save(communityTariff);
verify(eventPublisher).publishEvent(any(CommunityTariffChangedEvent.class));
}
@Test
void getCommunityTariff_throwsIfNotFound() {
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> tariffService.getCommunityTariff(communityId));
}
@Test
void getCommunityTariff_returnsTariff() {
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
CommunityTariffResponse response = tariffService.getCommunityTariff(communityId);
assertNotNull(response);
assertEquals(communityId, response.energyCommunityId());
}
// --- User Tariff Tests ---
@Test
void createUserTariff_success() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of(sourceMembership));
when(membershipRepository.findByUserIdWithDetails(targetUserId))
.thenReturn(List.of(targetMembership));
UserTariff savedTariff = new UserTariff();
savedTariff.setId(UUID.randomUUID());
savedTariff.setEnergyCommunityId(communityId);
savedTariff.setSourceMeteringPointId(sourceMeteringPointId);
savedTariff.setTargetMeteringPointId(targetMeteringPointId);
savedTariff.setPricePerKwhCents(new BigDecimal("20.0000"));
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(savedTariff);
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
UserTariffResponse response = tariffService.createUserTariff(sourceUserId, request);
assertNotNull(response);
assertEquals(communityId, response.energyCommunityId());
assertEquals(new BigDecimal("20.0000"), response.pricePerKwhCents());
verify(userTariffRepository).save(any(UserTariff.class));
verify(eventPublisher).publishEvent(any(UserTariffChangedEvent.class));
}
@Test
void createUserTariff_throwsIfSameMeteringPoints() {
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, sourceMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfSourcePointNotFound() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.empty());
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalArgumentException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfTargetPointNotFound() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.empty());
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalArgumentException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfSourcePointNotActive() {
sourcePoint.setMakoState(MakoState.WAITING_FOR_CONSENT);
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfTargetPointNotActive() {
targetPoint.setMakoState(MakoState.REJECTED);
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfNoCommunityTariff() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.empty());
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalArgumentException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfPriceExceedsMax() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("30.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfSourceNotMember() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of());
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_throwsIfTargetNotMember() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of(sourceMembership));
when(membershipRepository.findByUserIdWithDetails(targetUserId))
.thenReturn(List.of());
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
// --- Update User Tariff Tests ---
@Test
void updateUserTariff_success() {
UserTariff existingTariff = new UserTariff();
existingTariff.setId(UUID.randomUUID());
existingTariff.setEnergyCommunityId(communityId);
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
existingTariff.setTargetMeteringPointId(targetMeteringPointId);
existingTariff.setPricePerKwhCents(new BigDecimal("15.0000"));
when(userTariffRepository.findById(existingTariff.getId()))
.thenReturn(Optional.of(existingTariff));
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of(sourceMembership));
when(membershipRepository.findByUserIdWithDetails(targetUserId))
.thenReturn(List.of(targetMembership));
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(existingTariff);
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("22.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
UserTariffResponse response = tariffService.updateUserTariff(
sourceUserId, existingTariff.getId(), request);
assertNotNull(response);
verify(userTariffRepository).save(any(UserTariff.class));
verify(eventPublisher).publishEvent(any(UserTariffChangedEvent.class));
}
@Test
void updateUserTariff_throwsIfNotOwner() {
UserTariff existingTariff = new UserTariff();
existingTariff.setId(UUID.randomUUID());
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
MeteringPoint otherUserPoint = new MeteringPoint();
otherUserPoint.setId(sourceMeteringPointId);
otherUserPoint.setUserId(UUID.randomUUID());
when(userTariffRepository.findById(existingTariff.getId()))
.thenReturn(Optional.of(existingTariff));
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(otherUserPoint));
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(AccessDeniedException.class,
() -> tariffService.updateUserTariff(sourceUserId, existingTariff.getId(), request));
}
@Test
void updateUserTariff_throwsIfTariffNotFound() {
when(userTariffRepository.findById(any()))
.thenReturn(Optional.empty());
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalArgumentException.class,
() -> tariffService.updateUserTariff(sourceUserId, UUID.randomUUID(), request));
}
// --- Delete User Tariff Tests ---
@Test
void deleteUserTariff_success() {
UserTariff existingTariff = new UserTariff();
existingTariff.setId(UUID.randomUUID());
existingTariff.setEnergyCommunityId(communityId);
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
existingTariff.setTargetMeteringPointId(targetMeteringPointId);
when(userTariffRepository.findById(existingTariff.getId()))
.thenReturn(Optional.of(existingTariff));
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
tariffService.deleteUserTariff(sourceUserId, existingTariff.getId());
verify(userTariffRepository).deleteById(existingTariff.getId());
verify(eventPublisher).publishEvent(any(UserTariffChangedEvent.class));
}
@Test
void deleteUserTariff_throwsIfNotOwner() {
UserTariff existingTariff = new UserTariff();
existingTariff.setId(UUID.randomUUID());
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
MeteringPoint otherUserPoint = new MeteringPoint();
otherUserPoint.setId(sourceMeteringPointId);
otherUserPoint.setUserId(UUID.randomUUID());
when(userTariffRepository.findById(existingTariff.getId()))
.thenReturn(Optional.of(existingTariff));
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(otherUserPoint));
assertThrows(AccessDeniedException.class,
() -> tariffService.deleteUserTariff(sourceUserId, existingTariff.getId()));
}
@Test
void deleteUserTariff_throwsIfNotFound() {
when(userTariffRepository.findById(any()))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> tariffService.deleteUserTariff(sourceUserId, UUID.randomUUID()));
}
// --- Get User Tariffs Tests ---
@Test
void getUserTariffsForCommunity_returnsList() {
UserTariff tariff = new UserTariff();
tariff.setId(UUID.randomUUID());
tariff.setEnergyCommunityId(communityId);
tariff.setSourceMeteringPointId(sourceMeteringPointId);
tariff.setTargetMeteringPointId(targetMeteringPointId);
tariff.setPricePerKwhCents(new BigDecimal("20.0000"));
when(userTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(List.of(tariff));
List<UserTariffResponse> response = tariffService.getUserTariffsForCommunity(communityId);
assertEquals(1, response.size());
assertEquals(communityId, response.get(0).energyCommunityId());
}
@Test
void getUserTariffsForCommunity_returnsEmptyList() {
when(userTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(List.of());
List<UserTariffResponse> response = tariffService.getUserTariffsForCommunity(communityId);
assertTrue(response.isEmpty());
}
// --- Price boundary tests ---
@Test
void createUserTariff_priceExactlyAtMax_succeeds() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of(sourceMembership));
when(membershipRepository.findByUserIdWithDetails(targetUserId))
.thenReturn(List.of(targetMembership));
UserTariff savedTariff = new UserTariff();
savedTariff.setId(UUID.randomUUID());
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(savedTariff);
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("25.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertDoesNotThrow(() -> tariffService.createUserTariff(sourceUserId, request));
}
@Test
void createUserTariff_priceOneCentOverMax_throws() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("25.0001"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
// --- Inactive membership tests ---
@Test
void createUserTariff_throwsIfSourceMembershipInactive() {
sourceMembership.setStatus(MembershipStatus.INACTIVE);
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of(sourceMembership));
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
assertThrows(IllegalStateException.class,
() -> tariffService.createUserTariff(sourceUserId, request));
}
// --- Event verification tests ---
@Test
void createCommunityTariff_publishesEvent() {
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.empty());
when(communityTariffRepository.save(any(CommunityTariff.class))).thenReturn(communityTariff);
CommunityTariffRequest request = new CommunityTariffRequest(
new BigDecimal("25.0000"), new BigDecimal("10.00"),
LocalDate.now(), LocalDate.now().plusYears(1));
tariffService.createOrUpdateCommunityTariff(communityId, request);
ArgumentCaptor<CommunityTariffChangedEvent> captor =
ArgumentCaptor.forClass(CommunityTariffChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals("CREATED", captor.getValue().changeType());
assertEquals(communityId, captor.getValue().energyCommunityId());
}
@Test
void createUserTariff_publishesEventWithCorrectUserIds() {
when(meteringPointRepository.findById(sourceMeteringPointId))
.thenReturn(Optional.of(sourcePoint));
when(meteringPointRepository.findById(targetMeteringPointId))
.thenReturn(Optional.of(targetPoint));
when(communityTariffRepository.findByEnergyCommunityId(communityId))
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
.thenReturn(List.of(sourceMembership));
when(membershipRepository.findByUserIdWithDetails(targetUserId))
.thenReturn(List.of(targetMembership));
UserTariff savedTariff = new UserTariff();
savedTariff.setId(UUID.randomUUID());
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(savedTariff);
UserTariffRequest request = new UserTariffRequest(
communityId, sourceMeteringPointId, targetMeteringPointId,
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
tariffService.createUserTariff(sourceUserId, request);
ArgumentCaptor<UserTariffChangedEvent> captor =
ArgumentCaptor.forClass(UserTariffChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals("CREATED", captor.getValue().changeType());
assertEquals(sourceUserId, captor.getValue().sourceUserId());
assertEquals(targetUserId, captor.getValue().targetUserId());
}
}

View File

@ -74,6 +74,51 @@ paths:
responses:
"200":
description: OK
/api/tariffs/{tariffId}:
put:
tags:
- user-tariff-controller
operationId: updateUserTariff
parameters:
- name: tariffId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/UserTariffRequest"
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/UserTariffResponse"
delete:
tags:
- user-tariff-controller
operationId: deleteUserTariff
parameters:
- name: tariffId
in: path
required: true
schema:
type: string
format: uuid
- name: userId
in: query
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
/api/notifications/{id}/read:
put:
tags:
@ -223,6 +268,67 @@ paths:
responses:
"200":
description: OK
/api/admin/energy-communities/{communityId}/tariff:
get:
tags:
- tariff-controller
operationId: getCommunityTariff
parameters:
- name: communityId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/CommunityTariffResponse"
put:
tags:
- tariff-controller
operationId: createOrUpdateCommunityTariff
parameters:
- name: communityId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/CommunityTariffRequest"
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/CommunityTariffResponse"
/api/tariffs:
post:
tags:
- user-tariff-controller
operationId: createUserTariff
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/UserTariffRequest"
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/UserTariffResponse"
/api/community/metering-points:
get:
tags:
@ -405,6 +511,27 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/EnergyCommunityDto"
/api/tariffs/community/{communityId}:
get:
tags:
- user-tariff-controller
operationId: getUserTariffsForCommunity
parameters:
- name: communityId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/UserTariffResponse"
/api/notifications:
get:
tags:
@ -630,6 +757,58 @@ components:
minLength: 1
required:
- newEmail
UserTariffRequest:
type: object
properties:
energyCommunityId:
type: string
format: uuid
sourceMeteringPointId:
type: string
format: uuid
targetMeteringPointId:
type: string
format: uuid
pricePerKwhCents:
type: number
minimum: 0.0001
validFrom:
type: string
format: date
validTo:
type: string
format: date
required:
- energyCommunityId
- pricePerKwhCents
- sourceMeteringPointId
- targetMeteringPointId
UserTariffResponse:
type: object
properties:
id:
type: string
format: uuid
energyCommunityId:
type: string
format: uuid
sourceMeteringPointId:
type: string
format: uuid
targetMeteringPointId:
type: string
format: uuid
pricePerKwhCents:
type: number
validFrom:
type: string
format: date
validTo:
type: string
format: date
createdAt:
type: string
format: date-time
UpdateMeteringPointRequest:
type: object
properties:
@ -696,6 +875,50 @@ components:
memberCount:
type: integer
format: int32
CommunityTariffRequest:
type: object
properties:
maxPricePerKwhCents:
type: number
minimum: 0.0001
surchargePercent:
type: number
maximum: 100
minimum: 0
validFrom:
type: string
format: date
validTo:
type: string
format: date
required:
- maxPricePerKwhCents
- surchargePercent
CommunityTariffResponse:
type: object
properties:
id:
type: string
format: uuid
energyCommunityId:
type: string
format: uuid
maxPricePerKwhCents:
type: number
surchargePercent:
type: number
validFrom:
type: string
format: date
validTo:
type: string
format: date
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
CreateMeteringPointRequest:
type: object
properties:

View File

@ -16,6 +16,8 @@ import {MyMembershipsComponent} from './pages/my-memberships/my-memberships';
import {AdminMembershipsComponent} from './pages/admin-memberships/admin-memberships';
import {ForgotPasswordComponent} from './pages/forgot-password/forgot-password';
import {ResetPasswordComponent} from './pages/reset-password/reset-password';
import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff';
import {UserTariffComponent} from './pages/user-tariff/user-tariff';
export const routes: Routes = [
{ path: '', component: LandingPage },
@ -60,6 +62,16 @@ export const routes: Routes = [
component: AdminEnergyCommunity,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'tariffs',
component: AdminTariffComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'my-tariffs',
component: UserTariffComponent,
canActivate: [roleGuard(['MEMBER'])]
},
{
path: 'profile',
component: ProfileComponent,

View File

@ -12,5 +12,7 @@ export const NAV_ITEMS: NavItem[] = [
{label: 'Mitgliedschaften', path: '/dashboard/admin-memberships', roles: ['ADMIN']},
{label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']},
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']},
{label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN']},
{label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']}
];

View File

@ -0,0 +1,108 @@
<div class="max-w-4xl mx-auto p-4 md:p-6">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800">Tarife verwalten</h2>
<p class="text-sm text-slate-500 mt-1">Verwalte den Gemeinschaftstarif und Übersicht der Nutzertarife.</p>
</div>
<!-- Community-Auswahl -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
<label class="block text-sm font-medium text-slate-700 mb-2">Energiegemeinschaft auswählen</label>
<select (change)="onCommunityChange($any($event.target).value)"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
<option value="">-- Bitte wählen --</option>
@for (ec of communities(); track ec.id) {
<option [value]="ec.id">{{ ec.name }}</option>
}
</select>
</div>
@if (selectedCommunityId()) {
<!-- Gemeinschaftstarif -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
<h3 class="text-lg font-bold text-slate-800 mb-4">Gemeinschaftstarif</h3>
@if (isLoading()) {
<div class="text-center py-4 text-slate-500">Lade Tarifdaten...</div>
} @else {
<form [formGroup]="tariffForm" (ngSubmit)="onSubmit()" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Maximaler Preis (Cent/kWh)</label>
<input type="number" formControlName="maxPricePerKwhCents" step="0.01"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
@if (tariffForm.get('maxPricePerKwhCents')?.hasError('min')) {
<p class="text-red-500 text-xs mt-1">Der Preis muss mindestens 0.01 Cent betragen.</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Aufschlag (%)</label>
<input type="number" formControlName="surchargePercent" step="0.01"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
@if (tariffForm.get('surchargePercent')?.hasError('max')) {
<p class="text-red-500 text-xs mt-1">Der Aufschlag darf max. 100% betragen.</p>
}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig ab</label>
<input type="date" formControlName="validFrom"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig bis</label>
<input type="date" formControlName="validTo"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
</div>
<div class="flex justify-end">
<button type="submit" [disabled]="tariffForm.invalid"
class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
Speichern
</button>
</div>
</form>
}
</div>
<!-- Nutzertarife -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
<h3 class="text-lg font-bold text-slate-800 mb-4">Nutzertarife in der Gemeinschaft</h3>
@if (isLoadingTariffs()) {
<div class="text-center py-4 text-slate-500">Lade Nutzertarife...</div>
} @else {
<div class="overflow-x-auto ring-1 ring-slate-200 rounded-xl">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 text-sm text-slate-500 border-b border-slate-200">
<th class="py-3 px-6 font-semibold">Quell-Zählpunkt</th>
<th class="py-3 px-6 font-semibold">Ziel-Zählpunkt</th>
<th class="py-3 px-6 font-semibold">Preis (Cent/kWh)</th>
<th class="py-3 px-6 font-semibold">Gültig ab</th>
<th class="py-3 px-6 font-semibold">Gültig bis</th>
</tr>
</thead>
<tbody class="text-sm divide-y divide-slate-100">
@for (ut of userTariffs(); track ut.id) {
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.sourceMeteringPointId }}</td>
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.targetMeteringPointId }}</td>
<td class="py-3 px-6 text-slate-600">{{ ut.pricePerKwhCents }}</td>
<td class="py-3 px-6 text-slate-600">{{ ut.validFrom || '-' }}</td>
<td class="py-3 px-6 text-slate-600">{{ ut.validTo || '-' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="py-8 text-center text-slate-500">
Keine Nutzertarife vorhanden.
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</div>

View File

@ -0,0 +1,21 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {AdminTariffComponent} from './admin-tariff';
describe('AdminTariffComponent', () => {
let component: AdminTariffComponent;
let fixture: ComponentFixture<AdminTariffComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminTariffComponent],
}).compileComponents();
fixture = TestBed.createComponent(AdminTariffComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,120 @@
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {EnergyCommunityAdminControllerService, EnergyCommunityDto, TariffControllerService, UserTariffControllerService, CommunityTariffRequest, CommunityTariffResponse, UserTariffResponse} from '../../api';
import {ToastService} from '../../services/toast';
@Component({
selector: 'app-admin-tariff',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './admin-tariff.html',
styleUrl: './admin-tariff.scss',
})
export class AdminTariffComponent implements OnInit, OnDestroy {
private ecService = inject(EnergyCommunityAdminControllerService);
private tariffService = inject(TariffControllerService);
private userTariffService = inject(UserTariffControllerService);
private toastService = inject(ToastService);
private fb = inject(FormBuilder);
communities = signal<EnergyCommunityDto[]>([]);
selectedCommunityId = signal<string>('');
communityTariff = signal<CommunityTariffResponse | null>(null);
userTariffs = signal<UserTariffResponse[]>([]);
isLoading = signal(false);
isLoadingTariffs = signal(false);
tariffForm = this.fb.group({
maxPricePerKwhCents: [0, [Validators.required, Validators.min(0.01)]],
surchargePercent: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
validFrom: [''],
validTo: ['']
});
ngOnInit() {
this.loadCommunities();
}
ngOnDestroy() {}
loadCommunities() {
this.ecService.getAllEnergyCommunities().subscribe({
next: (data) => {
this.communities.set(data);
if (data.length > 0) {
this.onCommunityChange(data[0].id!);
}
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Laden der Energiegemeinschaften.');
}
});
}
onCommunityChange(communityId: string) {
this.selectedCommunityId.set(communityId);
this.communityTariff.set(null);
this.userTariffs.set([]);
if (!communityId) return;
this.isLoading.set(true);
this.tariffService.getCommunityTariff(communityId).subscribe({
next: (tariff) => {
this.communityTariff.set(tariff);
this.tariffForm.patchValue({
maxPricePerKwhCents: tariff.maxPricePerKwhCents ?? 0,
surchargePercent: tariff.surchargePercent ?? 0,
validFrom: tariff.validFrom ?? '',
validTo: tariff.validTo ?? ''
});
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.tariffForm.reset({maxPricePerKwhCents: 0, surchargePercent: 0, validFrom: '', validTo: ''});
}
});
this.loadUserTariffs(communityId);
}
loadUserTariffs(communityId: string) {
this.isLoadingTariffs.set(true);
this.userTariffService.getUserTariffsForCommunity(communityId).subscribe({
next: (tariffs) => {
this.userTariffs.set(tariffs);
this.isLoadingTariffs.set(false);
},
error: () => {
this.userTariffs.set([]);
this.isLoadingTariffs.set(false);
}
});
}
onSubmit() {
if (this.tariffForm.invalid || !this.selectedCommunityId()) {
this.tariffForm.markAllAsTouched();
return;
}
const request: CommunityTariffRequest = {
maxPricePerKwhCents: this.tariffForm.value.maxPricePerKwhCents!,
surchargePercent: this.tariffForm.value.surchargePercent!,
validFrom: this.tariffForm.value.validFrom || undefined,
validTo: this.tariffForm.value.validTo || undefined
};
this.tariffService.createOrUpdateCommunityTariff(this.selectedCommunityId(), request).subscribe({
next: (response) => {
this.communityTariff.set(response);
this.toastService.success('Gemeinschaftstarif erfolgreich gespeichert.');
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Speichern des Tarifs.');
}
});
}
}

View File

@ -0,0 +1,160 @@
<div class="max-w-4xl mx-auto p-4 md:p-6">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800">Meine Tarife</h2>
<p class="text-sm text-slate-500 mt-1">Erstelle und verwalte Tarife für den Energieaustausch.</p>
</div>
@if (isLoading()) {
<div class="text-center py-8 text-slate-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-slate-900 mb-2"></div>
<div>Lade Daten...</div>
</div>
} @else if (memberships().length === 0) {
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<p class="text-yellow-800">Keine aktiven Mitgliedschaften vorhanden.</p>
<p class="text-yellow-600 text-sm mt-2">Bitte tritt zuerst einer Energiegemeinschaft bei.</p>
</div>
} @else {
<!-- Community-Auswahl -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
<label class="block text-sm font-medium text-slate-700 mb-2">Energiegemeinschaft auswählen</label>
<select (change)="onCommunityChange($any($event.target).value)"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
@for (m of memberships(); track m.energyCommunityId) {
<option [value]="m.energyCommunityId" [selected]="m.energyCommunityId === selectedCommunityId()">
{{ m.communityName }}
</option>
}
</select>
</div>
@if (selectedCommunityId()) {
<!-- Max-Preis Info -->
@if (communityTariff()) {
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6 flex items-center gap-3">
<span class="text-blue-600 text-lg">i</span>
<span class="text-sm text-blue-800">
Maximaler Preis: <strong>{{ communityTariff()!.maxPricePerKwhCents }} Cent/kWh</strong>
@if (communityTariff()!.surchargePercent) {
| Aufschlag: <strong>{{ communityTariff()!.surchargePercent }}%</strong>
}
</span>
</div>
}
<!-- Neuen Tarif erstellen / Bearbeiten -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
<h3 class="text-lg font-bold text-slate-800 mb-4">
{{ editingId() ? 'Tarif bearbeiten' : 'Neuen Tarif erstellen' }}
</h3>
<form [formGroup]="tariffForm" (ngSubmit)="onSubmit()" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Quell-Zählpunkt (dein Zählpunkt)</label>
<select formControlName="sourceMeteringPointId"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
<option value="">-- Zählpunkt wählen --</option>
@for (mp of myMeteringPoints; track mp.id) {
<option [value]="mp.id">{{ mp.atNumber }} ({{ mp.type }})</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Ziel-Zählpunkt</label>
<select formControlName="targetMeteringPointId"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
<option value="">-- Zählpunkt wählen --</option>
@for (mp of allCommunityMeteringPoints; track mp.id) {
<option [value]="mp.id">{{ mp.atNumber }} ({{ mp.type }})</option>
}
</select>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Preis (Cent/kWh)</label>
<input type="number" formControlName="pricePerKwhCents" step="0.01"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
@if (tariffForm.get('pricePerKwhCents')?.hasError('min')) {
<p class="text-red-500 text-xs mt-1">Der Preis muss mindestens 0.01 Cent betragen.</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig ab</label>
<input type="date" formControlName="validFrom"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig bis</label>
<input type="date" formControlName="validTo"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
</div>
<div class="flex justify-end gap-3">
@if (editingId()) {
<button type="button" (click)="cancelEdit()"
class="px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-xl transition-colors">
Abbrechen
</button>
}
<button type="submit" [disabled]="tariffForm.invalid || isSubmitting()"
class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
{{ isSubmitting() ? 'Speichert...' : (editingId() ? 'Aktualisieren' : 'Erstellen') }}
</button>
</div>
</form>
</div>
<!-- Meine Tarife -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
<h3 class="text-lg font-bold text-slate-800 mb-4">Meine Tarife</h3>
@if (isLoadingTariffs()) {
<div class="text-center py-4 text-slate-500">Lade Tarife...</div>
} @else {
<div class="overflow-x-auto ring-1 ring-slate-200 rounded-xl">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 text-sm text-slate-500 border-b border-slate-200">
<th class="py-3 px-6 font-semibold">Quell-Zählpunkt</th>
<th class="py-3 px-6 font-semibold">Ziel-Zählpunkt</th>
<th class="py-3 px-6 font-semibold">Preis (Cent/kWh)</th>
<th class="py-3 px-6 font-semibold">Gültig ab</th>
<th class="py-3 px-6 font-semibold">Gültig bis</th>
<th class="py-3 px-6 font-semibold text-right">Aktionen</th>
</tr>
</thead>
<tbody class="text-sm divide-y divide-slate-100">
@for (ut of userTariffs(); track ut.id) {
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.sourceMeteringPointId }}</td>
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.targetMeteringPointId }}</td>
<td class="py-3 px-6 text-slate-600">{{ ut.pricePerKwhCents }}</td>
<td class="py-3 px-6 text-slate-600">{{ ut.validFrom || '-' }}</td>
<td class="py-3 px-6 text-slate-600">{{ ut.validTo || '-' }}</td>
<td class="py-3 px-6 text-right space-x-3">
<button (click)="startEdit(ut)"
class="text-blue-600 hover:text-blue-800 font-medium transition-colors">
Bearbeiten
</button>
<button (click)="onDelete(ut)"
class="text-red-600 hover:text-red-800 font-medium transition-colors">
Löschen
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="py-8 text-center text-slate-500">
Keine Tarife vorhanden. Erstelle deinen ersten Tarif.
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>

View File

@ -0,0 +1,21 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {UserTariffComponent} from './user-tariff';
describe('UserTariffComponent', () => {
let component: UserTariffComponent;
let fixture: ComponentFixture<UserTariffComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserTariffComponent],
}).compileComponents();
fixture = TestBed.createComponent(UserTariffComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,200 @@
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {UserTariffControllerService, TariffControllerService, UserTariffRequest, UserTariffResponse, CommunityTariffResponse} from '../../api';
import {MeteringPointService, MeteringPoint} from '../../services/metering-point';
import {MembershipService, MembershipResponse} from '../../services/membership';
import {ToastService} from '../../services/toast';
@Component({
selector: 'app-user-tariff',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './user-tariff.html',
styleUrl: './user-tariff.scss',
})
export class UserTariffComponent implements OnInit, OnDestroy {
private userTariffService = inject(UserTariffControllerService);
private tariffService = inject(TariffControllerService);
private meteringPointService = inject(MeteringPointService);
private membershipService = inject(MembershipService);
private toastService = inject(ToastService);
private fb = inject(FormBuilder);
memberships = signal<MembershipResponse[]>([]);
meteringPoints = signal<MeteringPoint[]>([]);
userTariffs = signal<UserTariffResponse[]>([]);
communityTariff = signal<CommunityTariffResponse | null>(null);
selectedCommunityId = signal<string>('');
isLoading = signal(true);
isLoadingTariffs = signal(false);
isSubmitting = signal(false);
editingId = signal<string | null>(null);
tariffForm = this.fb.group({
energyCommunityId: ['', Validators.required],
sourceMeteringPointId: ['', Validators.required],
targetMeteringPointId: ['', Validators.required],
pricePerKwhCents: [0, [Validators.required, Validators.min(0.01)]],
validFrom: [''],
validTo: ['']
});
ngOnInit() {
this.loadData();
}
ngOnDestroy() {}
loadData() {
this.isLoading.set(true);
this.membershipService.getOwnMemberships().subscribe({
next: (memberships) => {
const active = memberships.filter(m => m.status === 'ACTIVE');
this.memberships.set(active);
if (active.length > 0 && !this.selectedCommunityId()) {
this.onCommunityChange(active[0].energyCommunityId);
}
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.toastService.error('Fehler beim Laden der Mitgliedschaften.');
}
});
this.meteringPointService.getMyMeteringPoints().subscribe({
next: (points) => {
this.meteringPoints.set(points.filter(p => p.makoState === 'ACTIVE'));
},
error: () => {}
});
}
onCommunityChange(communityId: string) {
this.selectedCommunityId.set(communityId);
this.tariffForm.patchValue({energyCommunityId: communityId});
this.userTariffs.set([]);
this.communityTariff.set(null);
this.editingId.set(null);
if (!communityId) return;
this.tariffService.getCommunityTariff(communityId).subscribe({
next: (tariff) => this.communityTariff.set(tariff),
error: () => {}
});
this.loadUserTariffs(communityId);
}
loadUserTariffs(communityId: string) {
this.isLoadingTariffs.set(true);
this.userTariffService.getUserTariffsForCommunity(communityId).subscribe({
next: (tariffs) => {
this.userTariffs.set(tariffs);
this.isLoadingTariffs.set(false);
},
error: () => {
this.userTariffs.set([]);
this.isLoadingTariffs.set(false);
}
});
}
get myMeteringPoints(): MeteringPoint[] {
return this.meteringPoints();
}
get allCommunityMeteringPoints(): MeteringPoint[] {
return this.meteringPoints();
}
onSubmit() {
if (this.tariffForm.invalid || !this.selectedCommunityId()) {
this.tariffForm.markAllAsTouched();
return;
}
const maxPrice = this.communityTariff()?.maxPricePerKwhCents ?? Infinity;
const price = this.tariffForm.value.pricePerKwhCents!;
if (price > maxPrice) {
this.toastService.error(`Der Preis darf den Maximalpreis von ${maxPrice} Cent/kWh nicht überschreiten.`);
return;
}
const request: UserTariffRequest = {
energyCommunityId: this.tariffForm.value.energyCommunityId!,
sourceMeteringPointId: this.tariffForm.value.sourceMeteringPointId!,
targetMeteringPointId: this.tariffForm.value.targetMeteringPointId!,
pricePerKwhCents: this.tariffForm.value.pricePerKwhCents!,
validFrom: this.tariffForm.value.validFrom || undefined,
validTo: this.tariffForm.value.validTo || undefined
};
this.isSubmitting.set(true);
const editId = this.editingId();
const obs = editId
? this.userTariffService.updateUserTariff(editId, request)
: this.userTariffService.createUserTariff(request);
obs.subscribe({
next: () => {
this.toastService.success(editId ? 'Tarif aktualisiert.' : 'Tarif erfolgreich erstellt.');
this.resetForm();
this.loadUserTariffs(this.selectedCommunityId());
this.isSubmitting.set(false);
},
error: (err) => {
this.isSubmitting.set(false);
this.toastService.error(err.error?.message || 'Fehler beim Speichern des Tarifs.');
}
});
}
startEdit(tariff: UserTariffResponse) {
this.editingId.set(tariff.id!);
this.tariffForm.patchValue({
energyCommunityId: tariff.energyCommunityId!,
sourceMeteringPointId: tariff.sourceMeteringPointId!,
targetMeteringPointId: tariff.targetMeteringPointId!,
pricePerKwhCents: tariff.pricePerKwhCents!,
validFrom: tariff.validFrom ?? '',
validTo: tariff.validTo ?? ''
});
}
cancelEdit() {
this.resetForm();
}
resetForm() {
this.editingId.set(null);
this.tariffForm.reset({
energyCommunityId: this.selectedCommunityId(),
sourceMeteringPointId: '',
targetMeteringPointId: '',
pricePerKwhCents: 0,
validFrom: '',
validTo: ''
});
}
onDelete(tariff: UserTariffResponse) {
if (!confirm('Möchtest du diesen Tarif wirklich löschen?')) return;
this.userTariffService.deleteUserTariff(tariff.id!, tariff.sourceMeteringPointId!).subscribe({
next: () => {
this.toastService.success('Tarif erfolgreich gelöscht.');
this.loadUserTariffs(this.selectedCommunityId());
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Löschen des Tarifs.');
}
});
}
}