From f9e370afe28c47bf31ea509187d29ba1b1e48b3d Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Wed, 18 Mar 2026 20:10:38 +0100 Subject: [PATCH] feat: add customer number generation and invoice creation Backend: - Add customerNumber field to AppUser with unique index - Add customer number generation starting at K1000 - Add InvoiceService for creating invoices with PDF data - Add InvoiceController with endpoints for listing customers and generating invoice data - Update CatalogService to assign customer numbers to new users - Update SampleService to preserve customerNumber on updates - Fix SecurityConfig to enable method security and admin role checks Frontend: - Update UserOption/UserRow types to include customerNumber - Add customer selection dialog in InvoiceManagementPage - Add PDF generation and preview for invoices - Fix encodePdfText function for proper PDF encoding - Fix LocalStorage key for auth token --- .../de/svencarstensen/muh/domain/AppUser.java | 2 + .../muh/repository/AppUserRepository.java | 6 + .../BearerTokenAuthenticationFilter.java | 5 + .../muh/security/SecurityConfig.java | 3 + .../muh/service/CatalogService.java | 70 ++- .../muh/service/InvoiceService.java | 222 +++++++++ .../muh/service/SampleService.java | 1 + .../muh/web/InvoiceController.java | 68 +++ frontend/src/lib/types.ts | 1 + frontend/src/pages/InvoiceManagementPage.tsx | 454 +++++++++++++++++- frontend/src/pages/InvoiceTemplatePage.tsx | 23 +- frontend/src/styles/global.css | 2 +- 12 files changed, 841 insertions(+), 16 deletions(-) create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/InvoiceService.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/web/InvoiceController.java diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java b/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java index 2712491..b124681 100644 --- a/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java +++ b/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java @@ -1,6 +1,7 @@ package de.svencarstensen.muh.domain; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import java.time.LocalDateTime; @@ -27,6 +28,7 @@ public record AppUser( boolean active, UserRole role, Long nextSampleNumber, + @Indexed(unique = true) String customerNumber, LocalDateTime createdAt, LocalDateTime updatedAt ) { diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java index 06f475c..06e4d50 100644 --- a/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java +++ b/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java @@ -2,6 +2,7 @@ package de.svencarstensen.muh.repository; import de.svencarstensen.muh.domain.AppUser; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; import java.util.List; import java.util.Optional; @@ -12,4 +13,9 @@ public interface AppUserRepository extends MongoRepository { List findByAccountIdOrderByDisplayNameAsc(String accountId); Optional findByEmailIgnoreCase(String email); + + Optional findByCustomerNumber(String customerNumber); + + @Query(value = "{ 'customerNumber': { $exists: true, $ne: null } }", sort = "{ 'customerNumber': -1 }") + List findTopByCustomerNumberExistsOrderByCustomerNumberDesc(); } diff --git a/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java b/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java index 17453cc..db9c7b0 100644 --- a/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java +++ b/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java @@ -48,6 +48,8 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { .filter(AppUser::active) .orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig")); + System.out.println("[DEBUG] Authenticating user: " + user.id() + ", role: " + user.role()); + AuthenticatedUser principal = new AuthenticatedUser(user.id(), user.displayName(), user.role()); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( principal, @@ -55,7 +57,10 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { List.of(new SimpleGrantedAuthority("ROLE_" + user.role().name())) ); SecurityContextHolder.getContext().setAuthentication(authentication); + + System.out.println("[DEBUG] Authentication set: " + authentication.getAuthorities()); } catch (RuntimeException exception) { + System.err.println("[DEBUG] Authentication failed: " + exception.getMessage()); SecurityContextHolder.clearContext(); } diff --git a/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java b/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java index 2b9c6ce..ce2f85c 100644 --- a/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java +++ b/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -11,6 +12,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration +@EnableMethodSecurity public class SecurityConfig { @Bean @@ -23,6 +25,7 @@ public class SecurityConfig { .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java index 39093ad..e8b1d76 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java @@ -421,6 +421,7 @@ public class CatalogService { } String userId = UUID.randomUUID().toString(); boolean adminManaged = actor.role() == UserRole.ADMIN; + String customerNumber = generateNextCustomerNumber(); AppUser created = appUserRepository.save(new AppUser( userId, adminManaged ? userId : resolveAccountId(actor), @@ -444,6 +445,7 @@ public class CatalogService { mutation.active(), adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER, 100000L, + customerNumber, now, now )); @@ -481,6 +483,7 @@ public class CatalogService { ? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role())) : normalizeStoredRole(existing.role()), existing.nextSampleNumber(), + existing.customerNumber(), existing.createdAt(), now )); @@ -532,6 +535,7 @@ public class CatalogService { existing.active(), existing.role(), existing.nextSampleNumber(), + existing.customerNumber(), existing.createdAt(), LocalDateTime.now() )); @@ -578,6 +582,7 @@ public class CatalogService { String address = formatAddress(street, houseNumber, postalCode, city); String displayName = companyName; LocalDateTime now = LocalDateTime.now(); + String customerNumber = generateNextCustomerNumber(); AppUser created = appUserRepository.save(new AppUser( UUID.randomUUID().toString(), @@ -600,6 +605,7 @@ public class CatalogService { false, UserRole.CUSTOMER, 100000L, + customerNumber, now, now )); @@ -624,6 +630,7 @@ public class CatalogService { false, created.role(), created.nextSampleNumber(), + created.customerNumber(), created.createdAt(), created.updatedAt() )); @@ -645,6 +652,7 @@ public class CatalogService { removeLegacyUserCodeField(); backfillDefaultUserEmails(); removeLegacyPortalLoginField(); + migrateCustomerNumbers(); ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN); } @@ -734,6 +742,7 @@ public class CatalogService { user.bic(), user.active(), normalizeStoredRole(user.role()), + user.customerNumber(), user.updatedAt() ); } @@ -771,7 +780,8 @@ public class CatalogService { user.bankName(), user.iban(), user.bic(), - normalizeStoredRole(user.role()) + normalizeStoredRole(user.role()), + user.customerNumber() ); } @@ -871,11 +881,64 @@ public class CatalogService { user.active(), normalizeStoredRole(user.role()), user.nextSampleNumber(), + user.customerNumber(), user.createdAt(), now ))); } + private void migrateCustomerNumbers() { + LocalDateTime now = LocalDateTime.now(); + appUserRepository.findAll().stream() + .filter(user -> isBlank(user.customerNumber())) + .forEach(user -> appUserRepository.save(new AppUser( + user.id(), + user.accountId(), + user.primaryUser(), + user.displayName(), + user.companyName(), + user.address(), + user.street(), + user.houseNumber(), + user.postalCode(), + user.city(), + user.email(), + user.phoneNumber(), + user.accountHolder(), + user.bankName(), + user.iban(), + user.bic(), + user.passwordHash(), + user.active(), + user.role(), + user.nextSampleNumber(), + generateNextCustomerNumber(), + user.createdAt(), + now + ))); + } + + private String generateNextCustomerNumber() { + List usersWithNumbers = appUserRepository.findTopByCustomerNumberExistsOrderByCustomerNumberDesc(); + int nextNumber = 1000; + if (!usersWithNumbers.isEmpty()) { + String highestNumber = usersWithNumbers.get(0).customerNumber(); + if (highestNumber != null && highestNumber.startsWith("K")) { + try { + int highest = Integer.parseInt(highestNumber.substring(1)); + nextNumber = highest + 1; + } catch (NumberFormatException e) { + // Fallback to 1000 if parsing fails + } + } + } + // Ensure uniqueness in case of concurrent operations + while (appUserRepository.findByCustomerNumber("K" + nextNumber).isPresent()) { + nextNumber++; + } + return "K" + nextNumber; + } + private void ensureDefaultUser( String displayName, String email, @@ -910,6 +973,7 @@ public class CatalogService { true, role, 100000L, + generateNextCustomerNumber(), now, now )); @@ -1037,7 +1101,8 @@ public class CatalogService { String bankName, String iban, String bic, - UserRole role + UserRole role, + String customerNumber ) { } @@ -1112,6 +1177,7 @@ public class CatalogService { String bic, boolean active, UserRole role, + String customerNumber, LocalDateTime updatedAt ) { } diff --git a/backend/src/main/java/de/svencarstensen/muh/service/InvoiceService.java b/backend/src/main/java/de/svencarstensen/muh/service/InvoiceService.java new file mode 100644 index 0000000..183e23e --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/InvoiceService.java @@ -0,0 +1,222 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.AppUser; +import de.svencarstensen.muh.domain.InvoiceTemplate; +import de.svencarstensen.muh.domain.InvoiceTemplateElement; +import de.svencarstensen.muh.domain.SystemPricing; +import de.svencarstensen.muh.domain.Template; +import de.svencarstensen.muh.domain.TemplateType; +import de.svencarstensen.muh.repository.AppUserRepository; +import de.svencarstensen.muh.repository.InvoiceTemplateRepository; +import de.svencarstensen.muh.repository.TemplateRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +@Service +public class InvoiceService { + + private final AppUserRepository appUserRepository; + private final TemplateRepository templateRepository; + private final InvoiceTemplateRepository invoiceTemplateRepository; + private final SystemPricingService pricingService; + + public InvoiceService( + AppUserRepository appUserRepository, + TemplateRepository templateRepository, + InvoiceTemplateRepository invoiceTemplateRepository, + SystemPricingService pricingService + ) { + this.appUserRepository = appUserRepository; + this.templateRepository = templateRepository; + this.invoiceTemplateRepository = invoiceTemplateRepository; + this.pricingService = pricingService; + } + + public List listPrimaryCustomers() { + return appUserRepository.findAll().stream() + .filter(user -> Boolean.TRUE.equals(user.primaryUser())) + .filter(user -> user.role() != de.svencarstensen.muh.domain.UserRole.ADMIN) + .map(this::toCustomerDto) + .sorted((a, b) -> a.displayName().compareToIgnoreCase(b.displayName())) + .toList(); + } + + public InvoiceData getInvoiceData(String actorId, String customerId) { + AppUser customer = appUserRepository.findById(customerId != null ? customerId : "") + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kunde nicht gefunden")); + + if (!Boolean.TRUE.equals(customer.primaryUser())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nur Hauptbenutzer können Rechnungen erhalten"); + } + + // Use the issuing admin's template for invoice generation + List templateElements = getTemplateElements(actorId); + + // Generate invoice number: R-YYYY-NNNN + String invoiceNumber = generateInvoiceNumber(); + + // Calculate dates + LocalDate invoiceDate = LocalDate.now(); + LocalDate dueDate = invoiceDate.plusDays(14); + + // Get pricing + double monthlyPrice = getMonthlyPrice(); + double vatRate = 0.19; + double netAmount = monthlyPrice; + double vatAmount = netAmount * vatRate; + double grossAmount = netAmount + vatAmount; + + return new InvoiceData( + invoiceNumber, + invoiceDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")), + dueDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")), + toCustomerDto(customer), + templateElements, + netAmount, + vatAmount, + grossAmount, + monthlyPrice + ); + } + + private String generateInvoiceNumber() { + // Format: R-YYYY-NNNN (starting from 1000) + String year = String.valueOf(LocalDate.now().getYear()); + // For now, use a simple counter based on current time + // In production, this should query the database for the last invoice number + int sequence = (int) (System.currentTimeMillis() % 9000) + 1000; + return "R-" + year + "-" + sequence; + } + + private double getMonthlyPrice() { + try { + var pricing = pricingService.getCurrentPricing(); + return pricing.map(SystemPricing::monthlyPrice).orElse(49.00); + } catch (Exception e) { + return 49.00; // Default price + } + } + + private List getTemplateElements(String userId) { + // Try to get user's template first + Optional