diff --git a/backend/src/main/java/com/petshop/backend/service/CustomerService.java b/backend/src/main/java/com/petshop/backend/service/CustomerService.java index 040be22a..33c731d1 100644 --- a/backend/src/main/java/com/petshop/backend/service/CustomerService.java +++ b/backend/src/main/java/com/petshop/backend/service/CustomerService.java @@ -4,23 +4,34 @@ import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.customer.CustomerRequest; import com.petshop.backend.dto.customer.CustomerResponse; import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.CustomerRepository; import com.petshop.backend.repository.UserRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import static org.springframework.http.HttpStatus.CONFLICT; @Service public class CustomerService { + private static final String TEMP_PASSWORD = "TempPass123!"; + private final CustomerRepository customerRepository; private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final UserBusinessLinkageService userBusinessLinkageService; - public CustomerService(CustomerRepository customerRepository, UserRepository userRepository) { + public CustomerService(CustomerRepository customerRepository, UserRepository userRepository, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService) { this.customerRepository = customerRepository; this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.userBusinessLinkageService = userBusinessLinkageService; } public Page getAllCustomers(String query, Pageable pageable) { @@ -41,14 +52,19 @@ public class CustomerService { @Transactional public CustomerResponse createCustomer(CustomerRequest request) { + ensureEmailAvailable(request.getEmail(), null); + Customer customer = new Customer(); customer.setFirstName(request.getFirstName()); customer.setLastName(request.getLastName()); customer.setEmail(request.getEmail()); customer = customerRepository.save(customer); - syncLinkedUser(customer); - return mapToResponse(customer); + User user = createLinkedUser(customer); + + Customer linkedCustomer = userBusinessLinkageService.ensureLinkedCustomer(user); + syncLinkedUser(linkedCustomer); + return mapToResponse(linkedCustomer); } @Transactional @@ -56,6 +72,8 @@ public class CustomerService { Customer customer = customerRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + id)); + ensureEmailAvailable(request.getEmail(), customer.getUserId()); + customer.setFirstName(request.getFirstName()); customer.setLastName(request.getLastName()); customer.setEmail(request.getEmail()); @@ -67,9 +85,14 @@ public class CustomerService { @Transactional public void deleteCustomer(Long id) { - if (!customerRepository.existsById(id)) { - throw new ResourceNotFoundException("Customer not found with id: " + id); + Customer customer = customerRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + id)); + + if (customer.getUserId() != null && userRepository.existsById(customer.getUserId())) { + userRepository.deleteById(customer.getUserId()); + return; } + customerRepository.deleteById(id); } @@ -99,4 +122,37 @@ public class CustomerService { userRepository.save(user); }); } + + private User createLinkedUser(Customer customer) { + User user = new User(); + user.setUsername(generateUsername(customer)); + user.setPassword(passwordEncoder.encode(TEMP_PASSWORD)); + user.setEmail(customer.getEmail()); + user.setFullName((customer.getFirstName() + " " + customer.getLastName()).trim()); + user.setPhone(generatePhone(customer)); + user.setRole(User.Role.CUSTOMER); + user.setActive(false); + user.setTokenVersion(0); + return userRepository.save(user); + } + + private String generateUsername(Customer customer) { + return "customer_" + customer.getCustomerId(); + } + + private String generatePhone(Customer customer) { + return String.format("200-000-%04d", customer.getCustomerId()); + } + + private void ensureEmailAvailable(String email, Long currentUserId) { + if (email == null || email.isBlank()) { + return; + } + + userRepository.findByEmail(email).ifPresent(existing -> { + if (currentUserId == null || !existing.getId().equals(currentUserId)) { + throw new ResponseStatusException(CONFLICT, "Email already exists"); + } + }); + } } diff --git a/backend/src/main/java/com/petshop/backend/service/SaleService.java b/backend/src/main/java/com/petshop/backend/service/SaleService.java index b426dc38..b8d5861e 100644 --- a/backend/src/main/java/com/petshop/backend/service/SaleService.java +++ b/backend/src/main/java/com/petshop/backend/service/SaleService.java @@ -78,7 +78,7 @@ public class SaleService { sale.setSaleDate(LocalDateTime.now()); sale.setEmployee(employee); sale.setStore(store); - sale.setPaymentMethod(request.getPaymentMethod()); + sale.setPaymentMethod(normalizePaymentMethod(request.getPaymentMethod())); sale.setIsRefund(request.getIsRefund() != null ? request.getIsRefund() : false); if (request.getCustomerId() != null) { @@ -215,4 +215,22 @@ public class SaleService { return response; } + + String normalizePaymentMethod(String paymentMethod) { + if (paymentMethod == null) { + return null; + } + + String normalized = paymentMethod.trim(); + if (normalized.equalsIgnoreCase("Debit")) { + return "Card"; + } + if (normalized.equalsIgnoreCase("Cash")) { + return "Cash"; + } + if (normalized.equalsIgnoreCase("Card")) { + return "Card"; + } + return normalized; + } } diff --git a/backend/src/main/resources/db/migration/V10__remove_debit_payment_method.sql b/backend/src/main/resources/db/migration/V10__remove_debit_payment_method.sql new file mode 100644 index 00000000..874b0205 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__remove_debit_payment_method.sql @@ -0,0 +1,3 @@ +UPDATE sale +SET paymentMethod = 'Card' +WHERE LOWER(paymentMethod) = 'debit'; diff --git a/backend/src/main/resources/db/migration/V2__seed_data.sql b/backend/src/main/resources/db/migration/V2__seed_data.sql index 5e8d3fb6..d7308ad4 100644 --- a/backend/src/main/resources/db/migration/V2__seed_data.sql +++ b/backend/src/main/resources/db/migration/V2__seed_data.sql @@ -128,7 +128,7 @@ VALUES ('2026-01-05 09:15:00', 125.00, 'Card', 1, 1, 1), ('2026-01-08 11:30:00', 200.00, 'Card', 2, 1, 2), ('2026-01-12 14:20:00', 60.00, 'Cash', 3, 2, 3), -('2026-01-15 10:45:00', 150.00, 'Debit', 1, 1, 1), +('2026-01-15 10:45:00', 150.00, 'Card', 1, 1, 1), ('2026-01-18 16:30:00', 80.00, 'Card', 4, 3, 2), ('2026-01-22 13:15:00', 95.00, 'Cash', 2, 2, NULL), ('2026-01-25 15:40:00', 240.00, 'Card', 5, 4, 4), @@ -136,12 +136,12 @@ VALUES ('2026-02-01 09:00:00', 175.00, 'Card', 3, 3, 1), ('2026-02-03 11:20:00', 120.00, 'Card', 2, 1, 3), ('2026-02-05 14:50:00', 45.00, 'Cash', 4, 2, NULL), -('2026-02-08 16:15:00', 160.00, 'Debit', 1, 1, 2), +('2026-02-08 16:15:00', 160.00, 'Card', 1, 1, 2), ('2026-02-10 10:25:00', 100.00, 'Card', 5, 4, NULL), ('2026-02-12 13:45:00', 50.00, 'Cash', 2, 2, 1), ('2026-02-15 15:30:00', 85.00, 'Card', 3, 3, NULL), ('2026-02-18 11:10:00', 200.00, 'Card', 1, 1, 4), -('2026-02-20 14:35:00', 155.00, 'Debit', 4, 3, NULL), +('2026-02-20 14:35:00', 155.00, 'Card', 4, 3, NULL), ('2026-02-22 16:50:00', 75.00, 'Cash', 2, 1, 2), ('2026-02-24 10:15:00', 140.00, 'Card', 5, 4, NULL), (NOW(), 95.00, 'Card', 1, 1, 1); diff --git a/backend/src/main/resources/db/migration/V9__backfill_user_accounts.sql b/backend/src/main/resources/db/migration/V9__backfill_user_accounts.sql new file mode 100644 index 00000000..4af69669 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__backfill_user_accounts.sql @@ -0,0 +1,91 @@ +INSERT INTO users (username, password, email, fullName, phone, role, active, tokenVersion) +SELECT + CONCAT('customer_', c.customerId) AS username, + '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq' AS password, + CASE + WHEN c.email IS NOT NULL + AND c.email <> '' + AND (SELECT COUNT(*) FROM customer c2 WHERE c2.email = c.email) = 1 + AND NOT EXISTS (SELECT 1 FROM employee e2 WHERE e2.email = c.email) + AND NOT EXISTS (SELECT 1 FROM users u WHERE u.email = c.email) + THEN c.email + ELSE CONCAT('customer_', c.customerId, '@petshop.local') + END AS email, + CONCAT(c.firstName, ' ', c.lastName) AS fullName, + CONCAT('200-000-', LPAD(c.customerId, 4, '0')) AS phone, + 'CUSTOMER' AS role, + FALSE AS active, + 0 AS tokenVersion +FROM customer c +WHERE c.user_id IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM users u + WHERE u.username = CONCAT('customer_', c.customerId) + ); + +INSERT INTO users (username, password, email, fullName, phone, role, active, tokenVersion) +SELECT + CONCAT('employee_', e.employeeId) AS username, + '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq' AS password, + CASE + WHEN e.email IS NOT NULL + AND e.email <> '' + AND (SELECT COUNT(*) FROM employee e2 WHERE e2.email = e.email) = 1 + AND NOT EXISTS (SELECT 1 FROM customer c2 WHERE c2.email = e.email) + AND NOT EXISTS (SELECT 1 FROM users u WHERE u.email = e.email) + THEN e.email + ELSE CONCAT('employee_', e.employeeId, '@petshop.local') + END AS email, + CONCAT(e.firstName, ' ', e.lastName) AS fullName, + CONCAT('300-000-', LPAD(e.employeeId, 4, '0')) AS phone, + CASE + WHEN UPPER(e.role) = 'MANAGER' THEN 'ADMIN' + ELSE 'STAFF' + END AS role, + FALSE AS active, + 0 AS tokenVersion +FROM employee e +WHERE e.user_id IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM users u + WHERE u.username = CONCAT('employee_', e.employeeId) + ); + +UPDATE customer c +JOIN users u ON u.username = CONCAT('customer_', c.customerId) + AND u.role = 'CUSTOMER' +SET c.user_id = u.id +WHERE c.user_id IS NULL; + +UPDATE employee e +JOIN users u ON u.username = CONCAT('employee_', e.employeeId) + AND u.role IN ('STAFF', 'ADMIN') +SET e.user_id = u.id +WHERE e.user_id IS NULL; + +UPDATE users +SET + fullName = CASE + WHEN fullName IS NULL OR fullName = '' THEN username + ELSE fullName + END, + email = CASE + WHEN email IS NULL OR email = '' THEN CONCAT(username, '@petshop.local') + ELSE email + END, + phone = CASE + WHEN phone IS NULL OR phone = '' THEN CONCAT('000-000-', LPAD(id, 4, '0')) + ELSE phone + END, + active = COALESCE(active, TRUE), + tokenVersion = COALESCE(tokenVersion, 0) +WHERE fullName IS NULL + OR fullName = '' + OR email IS NULL + OR email = '' + OR phone IS NULL + OR phone = '' + OR active IS NULL + OR tokenVersion IS NULL; diff --git a/backend/src/test/java/com/petshop/backend/service/CustomerServiceTest.java b/backend/src/test/java/com/petshop/backend/service/CustomerServiceTest.java new file mode 100644 index 00000000..dde07768 --- /dev/null +++ b/backend/src/test/java/com/petshop/backend/service/CustomerServiceTest.java @@ -0,0 +1,180 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.customer.CustomerRequest; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.UserRepository; +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.security.crypto.password.PasswordEncoder; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerServiceTest { + + @Mock + private CustomerRepository customerRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private UserBusinessLinkageService userBusinessLinkageService; + + @InjectMocks + private CustomerService customerService; + + @Test + void createCustomerCreatesLinkedUser() { + CustomerRequest request = new CustomerRequest(); + request.setFirstName("Pat"); + request.setLastName("Owner"); + request.setEmail("pat@example.com"); + + Customer savedCustomer = new Customer(); + savedCustomer.setCustomerId(7L); + savedCustomer.setFirstName("Pat"); + savedCustomer.setLastName("Owner"); + savedCustomer.setEmail("pat@example.com"); + + when(customerRepository.save(any(Customer.class))).thenReturn(savedCustomer); + when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.empty()); + when(passwordEncoder.encode(any())).thenReturn("hashed-temp-password"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(11L); + return user; + }); + when(userBusinessLinkageService.ensureLinkedCustomer(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + savedCustomer.setUserId(user.getId()); + return savedCustomer; + }); + + var response = customerService.createCustomer(request); + + assertNotNull(response); + assertEquals("Pat", response.getFirstName()); + assertEquals("Owner", response.getLastName()); + assertEquals("pat@example.com", response.getEmail()); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + User createdUser = userCaptor.getValue(); + assertEquals("customer_7", createdUser.getUsername()); + assertEquals("hashed-temp-password", createdUser.getPassword()); + assertEquals("pat@example.com", createdUser.getEmail()); + assertEquals("Pat Owner", createdUser.getFullName()); + assertEquals("200-000-0007", createdUser.getPhone()); + assertEquals(false, createdUser.getActive()); + } + + @Test + void createCustomerRejectsExistingNonCustomerEmail() { + CustomerRequest request = new CustomerRequest(); + request.setFirstName("Pat"); + request.setLastName("Owner"); + request.setEmail("pat@example.com"); + + User existing = new User(); + existing.setId(22L); + existing.setUsername("staff1"); + existing.setEmail("pat@example.com"); + existing.setRole(User.Role.STAFF); + + when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.of(existing)); + + assertThrows(ResponseStatusException.class, () -> customerService.createCustomer(request)); + } + + @Test + void createCustomerRejectsExistingCustomerEmail() { + CustomerRequest request = new CustomerRequest(); + request.setFirstName("Pat"); + request.setLastName("Owner"); + request.setEmail("pat@example.com"); + + User existing = new User(); + existing.setId(22L); + existing.setUsername("customer1"); + existing.setEmail("pat@example.com"); + existing.setRole(User.Role.CUSTOMER); + + when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.of(existing)); + + assertThrows(ResponseStatusException.class, () -> customerService.createCustomer(request)); + } + + @Test + void updateCustomerRejectsExistingEmailFromOtherUser() { + Customer customer = new Customer(); + customer.setCustomerId(7L); + customer.setUserId(11L); + customer.setFirstName("Pat"); + customer.setLastName("Owner"); + customer.setEmail("old@example.com"); + + CustomerRequest request = new CustomerRequest(); + request.setFirstName("Pat"); + request.setLastName("Owner"); + request.setEmail("pat@example.com"); + + User existing = new User(); + existing.setId(22L); + existing.setUsername("customer2"); + existing.setEmail("pat@example.com"); + existing.setRole(User.Role.CUSTOMER); + + when(customerRepository.findById(7L)).thenReturn(Optional.of(customer)); + when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.of(existing)); + + assertThrows(ResponseStatusException.class, () -> customerService.updateCustomer(7L, request)); + } + + @Test + void deleteCustomerDeletesLinkedUser() { + Customer customer = new Customer(); + customer.setCustomerId(7L); + customer.setUserId(11L); + + when(customerRepository.findById(7L)).thenReturn(Optional.of(customer)); + when(userRepository.existsById(11L)).thenReturn(true); + + customerService.deleteCustomer(7L); + + verify(userRepository).deleteById(11L); + verify(customerRepository, never()).deleteById(7L); + } + + @Test + void deleteCustomerDeletesCustomerWhenNoLinkedUserExists() { + Customer customer = new Customer(); + customer.setCustomerId(7L); + customer.setUserId(11L); + + when(customerRepository.findById(7L)).thenReturn(Optional.of(customer)); + when(userRepository.existsById(11L)).thenReturn(false); + + customerService.deleteCustomer(7L); + + verify(customerRepository).deleteById(7L); + } +} diff --git a/backend/src/test/java/com/petshop/backend/service/SaleServiceTest.java b/backend/src/test/java/com/petshop/backend/service/SaleServiceTest.java new file mode 100644 index 00000000..b0ffeded --- /dev/null +++ b/backend/src/test/java/com/petshop/backend/service/SaleServiceTest.java @@ -0,0 +1,17 @@ +package com.petshop.backend.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SaleServiceTest { + + @Test + void normalizePaymentMethodMapsDebitToCard() { + SaleService saleService = new SaleService(null, null, null, null, null, null, null, null); + + assertEquals("Card", saleService.normalizePaymentMethod("Debit")); + assertEquals("Card", saleService.normalizePaymentMethod("debit")); + assertEquals("Cash", saleService.normalizePaymentMethod("Cash")); + } +}