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
This commit is contained in:
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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<AppUser, String> {
|
||||
List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
|
||||
|
||||
Optional<AppUser> findByEmailIgnoreCase(String email);
|
||||
|
||||
Optional<AppUser> findByCustomerNumber(String customerNumber);
|
||||
|
||||
@Query(value = "{ 'customerNumber': { $exists: true, $ne: null } }", sort = "{ 'customerNumber': -1 }")
|
||||
List<AppUser> findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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<AppUser> 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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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<CustomerDto> 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<TemplateElementDto> 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<TemplateElementDto> getTemplateElements(String userId) {
|
||||
// Try to get user's template first
|
||||
Optional<Template> userTemplate = templateRepository.findByUserIdAndType(userId, TemplateType.INVOICE);
|
||||
if (userTemplate.isPresent()) {
|
||||
return mapTemplateElements(userTemplate.get().elements());
|
||||
}
|
||||
|
||||
// Fall back to legacy template
|
||||
Optional<InvoiceTemplate> legacyTemplate = invoiceTemplateRepository.findById(userId != null ? userId : "");
|
||||
if (legacyTemplate.isPresent()) {
|
||||
return mapTemplateElements(legacyTemplate.get().elements());
|
||||
}
|
||||
|
||||
// Return empty list - frontend will use default layout
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<TemplateElementDto> mapTemplateElements(List<?> elements) {
|
||||
if (elements == null) {
|
||||
return List.of();
|
||||
}
|
||||
return elements.stream()
|
||||
.filter(InvoiceTemplateElement.class::isInstance)
|
||||
.map(InvoiceTemplateElement.class::cast)
|
||||
.map(element -> new TemplateElementDto(
|
||||
element.id(),
|
||||
element.paletteId(),
|
||||
element.kind(),
|
||||
element.label(),
|
||||
element.content(),
|
||||
element.x(),
|
||||
element.y(),
|
||||
element.width(),
|
||||
element.height(),
|
||||
element.fontSize(),
|
||||
element.fontWeight(),
|
||||
element.textAlign(),
|
||||
element.lineOrientation(),
|
||||
element.imageSrc(),
|
||||
element.imageNaturalWidth(),
|
||||
element.imageNaturalHeight()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private CustomerDto toCustomerDto(AppUser user) {
|
||||
return new CustomerDto(
|
||||
user.id(),
|
||||
user.displayName(),
|
||||
user.companyName(),
|
||||
user.street(),
|
||||
user.houseNumber(),
|
||||
user.postalCode(),
|
||||
user.city(),
|
||||
user.email(),
|
||||
user.phoneNumber(),
|
||||
user.accountHolder(),
|
||||
user.bankName(),
|
||||
user.iban(),
|
||||
user.bic(),
|
||||
user.customerNumber()
|
||||
);
|
||||
}
|
||||
|
||||
public record CustomerDto(
|
||||
String id,
|
||||
String displayName,
|
||||
String companyName,
|
||||
String street,
|
||||
String houseNumber,
|
||||
String postalCode,
|
||||
String city,
|
||||
String email,
|
||||
String phoneNumber,
|
||||
String accountHolder,
|
||||
String bankName,
|
||||
String iban,
|
||||
String bic,
|
||||
String customerNumber
|
||||
) {
|
||||
}
|
||||
|
||||
public record InvoiceData(
|
||||
String invoiceNumber,
|
||||
String invoiceDate,
|
||||
String dueDate,
|
||||
CustomerDto customer,
|
||||
List<TemplateElementDto> templateElements,
|
||||
double netAmount,
|
||||
double vatAmount,
|
||||
double grossAmount,
|
||||
double monthlyPrice
|
||||
) {
|
||||
}
|
||||
|
||||
public record TemplateElementDto(
|
||||
String id,
|
||||
String paletteId,
|
||||
String kind,
|
||||
String label,
|
||||
String content,
|
||||
Integer x,
|
||||
Integer y,
|
||||
Integer width,
|
||||
Integer height,
|
||||
Integer fontSize,
|
||||
Integer fontWeight,
|
||||
String textAlign,
|
||||
String lineOrientation,
|
||||
String imageSrc,
|
||||
Integer imageNaturalWidth,
|
||||
Integer imageNaturalHeight
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -622,6 +622,7 @@ public class SampleService {
|
||||
actor.active(),
|
||||
actor.role(),
|
||||
sampleNumber + 1,
|
||||
actor.customerNumber(),
|
||||
actor.createdAt(),
|
||||
LocalDateTime.now()
|
||||
));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.svencarstensen.muh.web;
|
||||
|
||||
import de.svencarstensen.muh.service.InvoiceService;
|
||||
import de.svencarstensen.muh.security.SecuritySupport;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class InvoiceController {
|
||||
|
||||
private final InvoiceService invoiceService;
|
||||
private final SecuritySupport securitySupport;
|
||||
|
||||
public InvoiceController(InvoiceService invoiceService, SecuritySupport securitySupport) {
|
||||
this.invoiceService = invoiceService;
|
||||
this.securitySupport = securitySupport;
|
||||
}
|
||||
|
||||
@GetMapping("/customers/primary")
|
||||
public List<InvoiceService.CustomerDto> listPrimaryCustomers() {
|
||||
return invoiceService.listPrimaryCustomers();
|
||||
}
|
||||
|
||||
@GetMapping("/customers/{customerId}/invoice-data")
|
||||
public ResponseEntity<?> getInvoiceData(@PathVariable String customerId) {
|
||||
try {
|
||||
InvoiceService.InvoiceData data = invoiceService.getInvoiceData(
|
||||
securitySupport.currentUser().id(),
|
||||
customerId
|
||||
);
|
||||
return ResponseEntity.ok(data);
|
||||
} catch (ResponseStatusException e) {
|
||||
return ResponseEntity.status(e.getStatusCode()).body(Map.of("message", e.getReason()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Fehler beim Erstellen der Rechnung: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/invoices")
|
||||
public InvoiceOverview getInvoices() {
|
||||
// Mock implementation - returns empty list for now
|
||||
return new InvoiceOverview(List.of());
|
||||
}
|
||||
|
||||
public record InvoiceOverview(List<InvoiceSummary> invoices) {
|
||||
}
|
||||
|
||||
public record InvoiceSummary(
|
||||
String id,
|
||||
String invoiceNumber,
|
||||
String customerName,
|
||||
String invoiceDate,
|
||||
String dueDate,
|
||||
double totalAmount,
|
||||
String status
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user