add service file headers
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Handles the pet adoption workflow from application
|
||||
* through to approval and completion.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.adoption.AdoptionRequest;
|
||||
@@ -165,6 +172,10 @@ public class AdoptionService {
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-facing adoption request. Validates the pet is at the claimed store
|
||||
* and available, then creates a Pending adoption and marks the pet as Pending.
|
||||
*/
|
||||
@Transactional
|
||||
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId, LocalDate adoptionDate) {
|
||||
Pet pet = petRepository.findByIdForUpdate(petId)
|
||||
@@ -235,6 +246,10 @@ public class AdoptionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a Pending adoption is deleted, resets the pet back to Available
|
||||
* unless another completed adoption already exists for it.
|
||||
*/
|
||||
private void resetPetIfPending(Pet pet, String deletedAdoptionStatus) {
|
||||
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(deletedAdoptionStatus)) {
|
||||
return;
|
||||
@@ -309,6 +324,11 @@ public class AdoptionService {
|
||||
throw new BusinessException("Adoption status must be Pending, Completed, Cancelled, or Missed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a pet hasn't already been adopted and is in Available status.
|
||||
* When updating an existing adoption, skips the status check if the pet
|
||||
* is the same one already attached to this adoption record.
|
||||
*/
|
||||
private void validatePetAvailability(Pet pet, Long adoptionId, Long currentPetId) {
|
||||
boolean samePetAsCurrentAdoption = currentPetId != null && currentPetId.equals(pet.getPetId());
|
||||
boolean adoptedElsewhere = adoptionId == null
|
||||
@@ -323,6 +343,10 @@ public class AdoptionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sale record when an adoption is marked Completed, using
|
||||
* the pet's price as the sale amount. Skipped if no employee or store is set.
|
||||
*/
|
||||
private void createSaleForAdoption(Adoption adoption, String paymentMethod) {
|
||||
if (adoption.getEmployee() == null || adoption.getSourceStore() == null) {
|
||||
return;
|
||||
@@ -345,6 +369,11 @@ public class AdoptionService {
|
||||
eventPublisher.publishEvent(new SaleReceiptEvent(savedSale.getSaleId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the pet's status in sync with the adoption lifecycle.
|
||||
* Completed -> Adopted (removed from store), Pending -> Pending (reserved),
|
||||
* Cancelled/Missed -> Available (back on the floor).
|
||||
*/
|
||||
private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) {
|
||||
boolean completedElsewhere = adoptionId != null
|
||||
&& adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(pet.getPetId(), ADOPTION_STATUS_COMPLETED, adoptionId);
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Manages appointment booking, availability checking, and
|
||||
* scheduled status updates for grooming and vet visits.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.appointment.AppointmentRequest;
|
||||
@@ -222,6 +229,15 @@ public class AppointmentService {
|
||||
appointmentRepository.deleteAllById(request.getIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all bookable time slots for a given store, service, and date.
|
||||
* Iterates through the business hours in fixed intervals, checking each
|
||||
* slot against every employee at the store to see if at least one is free.
|
||||
* @param storeId store to check
|
||||
* @param serviceId service being booked (determines slot duration)
|
||||
* @param date the date to check availability for
|
||||
* @return list of available start times as strings
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> checkAvailability(Long storeId, Long serviceId, LocalDate date) {
|
||||
storeRepository.findById(storeId)
|
||||
@@ -236,6 +252,7 @@ public class AppointmentService {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// Batch-load all appointments for the day to avoid N+1 queries per employee
|
||||
List<Long> employeeIds = assignableUsers.stream().map(User::getId).collect(Collectors.toList());
|
||||
List<Appointment> allAppointments = appointmentRepository.findByEmployeeIdInAndAppointmentDate(employeeIds, date);
|
||||
|
||||
@@ -245,6 +262,7 @@ public class AppointmentService {
|
||||
List<String> availableSlots = new ArrayList<>();
|
||||
LocalTime startTime = businessProperties.openTime();
|
||||
LocalTime endTime = businessProperties.closeTime();
|
||||
// Last slot must leave enough time for the service to finish before closing
|
||||
LocalTime latestStart = endTime.minusMinutes(service.getServiceDuration());
|
||||
|
||||
LocalTime currentTime = startTime;
|
||||
@@ -264,7 +282,11 @@ public class AppointmentService {
|
||||
return availableSlots;
|
||||
}
|
||||
|
||||
//Update booked status to completed at every midnight, and send 24h reminders
|
||||
/**
|
||||
* Midnight cron job that marks past booked appointments as Completed,
|
||||
* sends 24-hour reminder emails for tomorrow's appointments,
|
||||
* and sends reminders for pending adoptions scheduled for tomorrow.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
@Transactional
|
||||
public void updatePastAppointmentsStatus() {
|
||||
@@ -379,6 +401,10 @@ public class AppointmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a time slot is open by looking for overlaps with existing appointments.
|
||||
* Uses interval overlap detection: two ranges overlap when each starts before the other ends.
|
||||
*/
|
||||
private boolean isSlotAvailable(List<Appointment> existingAppointments, com.petshop.backend.entity.Service requestedService, LocalTime requestedStart, Long appointmentIdToIgnore) {
|
||||
LocalTime requestedEnd = requestedStart.plusMinutes(requestedService.getServiceDuration());
|
||||
for (Appointment existingAppointment : existingAppointments) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Handles shopping cart operations, checkout with Stripe payments,
|
||||
* and loyalty points redemption.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.cart.*;
|
||||
@@ -230,6 +237,14 @@ public class CartService {
|
||||
return toResponse(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the checkout flow for a user's active cart at a given store.
|
||||
* Handles free checkouts (points cover everything or total is $0) directly,
|
||||
* otherwise creates a Stripe PaymentIntent for card payment.
|
||||
* @param userId the customer checking out
|
||||
* @param storeId the store the cart belongs to
|
||||
* @return checkout response with client secret for Stripe or success status for free orders
|
||||
*/
|
||||
@Transactional
|
||||
public CheckoutResponse checkout(Long userId, Long storeId) {
|
||||
Cart cart = cartRepository
|
||||
@@ -316,6 +331,13 @@ public class CartService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes checkout after Stripe confirms payment succeeded.
|
||||
* Validates the payment intent metadata, re-checks the cart total hasn't
|
||||
* drifted since checkout started, and creates the sale record.
|
||||
* @param userId the customer completing checkout
|
||||
* @param paymentIntentId Stripe payment intent to verify
|
||||
*/
|
||||
@Transactional
|
||||
public void completeCheckout(Long userId, String paymentIntentId) {
|
||||
try {
|
||||
@@ -376,11 +398,13 @@ public class CartService {
|
||||
throw new BusinessException("Cart items were removed during checkout");
|
||||
}
|
||||
|
||||
// Recalculate to make sure prices/coupons haven't changed mid-checkout
|
||||
BigDecimal recalculatedTotal = recalculateTotalAmount(cart);
|
||||
if (recalculatedTotal.compareTo(cart.getCheckoutAmount()) != 0) {
|
||||
throw new BusinessException("Cart total changed during checkout");
|
||||
}
|
||||
|
||||
// Cross-check that what Stripe actually charged matches our expected amount
|
||||
long storedAmountInCents = cart.getCheckoutAmount()
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.setScale(0, RoundingMode.HALF_UP)
|
||||
@@ -390,6 +414,7 @@ public class CartService {
|
||||
throw new BusinessException("Stripe charged amount does not match expected amount");
|
||||
}
|
||||
|
||||
// Guard against duplicate sale creation if completeCheckout is called twice
|
||||
if (saleRepository.findByCartCartId(cart.getCartId()).isPresent()) {
|
||||
cart.setCartStatus("CHECKED_OUT");
|
||||
cart.setCheckoutPending(false);
|
||||
@@ -438,6 +463,10 @@ public class CartService {
|
||||
|
||||
private record CartTotals(BigDecimal subtotal, BigDecimal discount, BigDecimal pointsDiscount, BigDecimal total) {}
|
||||
|
||||
/**
|
||||
* Computes subtotal, coupon discount, points discount, and final total for a cart.
|
||||
* Discounts are applied in order: coupon first, then loyalty points on the remainder.
|
||||
*/
|
||||
private CartTotals computeTotals(Cart cart) {
|
||||
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
|
||||
|
||||
@@ -468,6 +497,11 @@ public class CartService {
|
||||
return computeTotals(cart).total();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the user's loyalty points into a dollar discount.
|
||||
* Points are converted at the configured rate (e.g. 100 points = $1).
|
||||
* The discount is capped at the remaining amount so the total never goes negative.
|
||||
*/
|
||||
private BigDecimal calculatePointsDiscount(User user, BigDecimal remainingAmount, boolean pointsApplied) {
|
||||
if (!pointsApplied || user == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Sends real-time chat updates to connected clients
|
||||
* through WebSocket messages.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.chat.ConversationResponse;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Manages chat conversations and messages between
|
||||
* customers and store staff.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.chat.ConversationRequest;
|
||||
@@ -76,6 +83,11 @@ public class ChatService {
|
||||
return ConversationResponse.fromEntity(conversation, greeting, botUser.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists conversations visible to the user based on their role.
|
||||
* Customers only see their own. Staff see conversations assigned to them
|
||||
* plus unassigned ones. Admins see everything.
|
||||
*/
|
||||
public List<ConversationResponse> getConversations(Long userId, User.Role role, boolean mine) {
|
||||
List<Conversation> conversations;
|
||||
|
||||
@@ -165,6 +177,7 @@ public class ChatService {
|
||||
message.setIsRead(false);
|
||||
message = messageRepository.save(message);
|
||||
|
||||
// When staff replies, claim the conversation and switch from AI to human mode
|
||||
if (role == User.Role.STAFF && conversation.getStaffId() == null) {
|
||||
conversation.setStaffId(userId);
|
||||
}
|
||||
@@ -376,6 +389,10 @@ public class ChatService {
|
||||
return hasConversationAccess(conversation, userId, role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access rules: admins can see all conversations, customers only their own,
|
||||
* and staff can see unassigned conversations or ones assigned to them.
|
||||
*/
|
||||
private boolean hasConversationAccess(Conversation conversation, Long userId, User.Role role) {
|
||||
if (role == User.Role.ADMIN) {
|
||||
return true;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Sends transactional emails like receipts and confirmations
|
||||
* using the Resend API.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.entity.Adoption;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Manages the AI chatbot conversation flow, building context
|
||||
* and coordinating responses from OpenRouter.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -67,6 +74,12 @@ public class OpenRouterAiService {
|
||||
CompletableFuture.runAsync(() -> generateReply(conversationId, triggerMessageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an AI reply for a conversation by calling the OpenRouter API.
|
||||
* Checks conversation state at multiple points to avoid replying if the
|
||||
* conversation was closed, handed off to a human, or a newer message arrived
|
||||
* while the API call was in flight.
|
||||
*/
|
||||
private void generateReply(Long conversationId, Long triggerMessageId) {
|
||||
try {
|
||||
if (triggerMessageId == null) {
|
||||
@@ -105,6 +118,7 @@ public class OpenRouterAiService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only reply if the trigger is still the latest message (no newer messages came in)
|
||||
Message lastMessage = history.get(history.size() - 1);
|
||||
if (!triggerMessageId.equals(lastMessage.getId())) {
|
||||
return;
|
||||
@@ -167,6 +181,7 @@ public class OpenRouterAiService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check conversation state after the API call in case anything changed while waiting
|
||||
Conversation latestConversation = chatService.getConversationEntity(conversationId);
|
||||
if (latestConversation.getStatus() == Conversation.ConversationStatus.CLOSED
|
||||
|| latestConversation.getHumanRequestedAt() != null
|
||||
@@ -174,6 +189,7 @@ public class OpenRouterAiService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also re-check that no new messages were sent while the API call was in progress
|
||||
List<Message> latestHistory = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
|
||||
if (latestHistory.isEmpty() || !triggerMessageId.equals(latestHistory.get(latestHistory.size() - 1).getId())) {
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Sends chat messages to the OpenRouter API and parses
|
||||
* the AI model response.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Handles creating, updating, and listing pets with
|
||||
* visibility rules based on user role.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
@@ -53,6 +60,11 @@ public class PetService {
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists pets with role-based visibility filtering.
|
||||
* Unauthenticated users only see Available pets. Customers see Available
|
||||
* pets plus their own Adopted/Owned ones. Staff and admins see all.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<PetResponse> getAllPets(String query, String species, String breed, String status, Long storeId, Long customerId, Pageable pageable) {
|
||||
String normalizedQuery = StringUtils.trimToNull(query);
|
||||
@@ -229,6 +241,10 @@ public class PetService {
|
||||
return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility rules: Available pets are public. Staff/admins see everything.
|
||||
* Customers can see pets they own or have adopted.
|
||||
*/
|
||||
private boolean canViewPet(Pet pet, CurrentViewer viewer) {
|
||||
if (isPubliclyVisible(pet)) {
|
||||
return true;
|
||||
@@ -245,6 +261,9 @@ public class PetService {
|
||||
return isAdoptedByUser(pet, viewer.userId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the pet was adopted by this user by looking for a Completed adoption record.
|
||||
*/
|
||||
private boolean isAdoptedByUser(Pet pet, Long userId) {
|
||||
if (userId == null) {
|
||||
return false;
|
||||
@@ -354,6 +373,11 @@ public class PetService {
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the owner and store based on pet status. Owned pets get an owner
|
||||
* but no store. Available pets get a store but no owner. Other statuses
|
||||
* can have both.
|
||||
*/
|
||||
private void applyOwnerAndStore(Pet pet, PetRequest request) {
|
||||
if ("owned".equalsIgnoreCase(request.getPetStatus())) {
|
||||
if (request.getCustomerId() != null) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Creates and manages sales records, including refunds and
|
||||
* discount calculations.
|
||||
*
|
||||
* Author: Harkamal
|
||||
* Date: April 2026
|
||||
*/
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.sale.SaleRequest;
|
||||
@@ -62,6 +69,14 @@ public class SaleService {
|
||||
return mapToResponse(sale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sale or refund record. For regular sales, deducts inventory and
|
||||
* applies coupon/employee/loyalty discounts in that order. For refunds,
|
||||
* restores inventory and proportionally reverses discounts and loyalty points
|
||||
* based on the refund-to-original subtotal ratio.
|
||||
* @param request sale details including items, store, and discount info
|
||||
* @return the created sale
|
||||
*/
|
||||
@Transactional
|
||||
public SaleResponse createSale(SaleRequest request) {
|
||||
User actor = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
@@ -136,6 +151,7 @@ public class SaleService {
|
||||
" for product: " + product.getProdName());
|
||||
}
|
||||
|
||||
// Sum up quantities already refunded across all prior refunds for this product
|
||||
int alreadyRefundedQuantity = saleRepository.findByOriginalSaleSaleId(sale.getOriginalSale().getSaleId()).stream()
|
||||
.flatMap(existingRefund -> existingRefund.getItems().stream())
|
||||
.filter(existingRefundItem -> existingRefundItem.getProduct().getProdId().equals(itemRequest.getProdId()))
|
||||
@@ -176,6 +192,7 @@ public class SaleService {
|
||||
BigDecimal couponDiscountRefunded = BigDecimal.ZERO;
|
||||
BigDecimal refundTotal;
|
||||
|
||||
// Proportionally split discounts based on what fraction of the original order is being refunded
|
||||
if (originalSubtotal != null && originalSubtotal.compareTo(BigDecimal.ZERO) > 0) {
|
||||
BigDecimal ratio = subtotalAmount.abs().divide(originalSubtotal, 10, RoundingMode.HALF_UP);
|
||||
refundTotal = originalSale.getTotalAmount().abs().multiply(ratio).negate().setScale(2, RoundingMode.HALF_UP);
|
||||
@@ -188,6 +205,7 @@ public class SaleService {
|
||||
User refundCustomer = originalSale.getCustomer();
|
||||
if (refundCustomer != null) {
|
||||
sale.setCustomer(refundCustomer);
|
||||
// Give back the points that were spent, subtract the points that were earned
|
||||
int pointsToRestore = toPointsUsed(loyaltyDiscountRefunded);
|
||||
int pointsEarnedToReverse = originalSale.getPointsEarned() != null
|
||||
? ratio.multiply(BigDecimal.valueOf(originalSale.getPointsEarned())).setScale(0, RoundingMode.FLOOR).intValue()
|
||||
@@ -245,6 +263,7 @@ public class SaleService {
|
||||
BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount));
|
||||
sale.setEmployeeDiscountAmount(employeeDiscount);
|
||||
|
||||
// Loyalty discount is applied last, after coupon and employee discounts
|
||||
BigDecimal remainingAfterDiscounts = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount);
|
||||
BigDecimal loyaltyDiscount;
|
||||
int pointsDeducted;
|
||||
@@ -265,6 +284,7 @@ public class SaleService {
|
||||
BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount).subtract(loyaltyDiscount);
|
||||
sale.setTotalAmount(finalTotal.max(BigDecimal.ZERO));
|
||||
|
||||
// Earn 1 point per dollar spent (floor), then update the customer's balance
|
||||
sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue());
|
||||
if (customer != null) {
|
||||
int currentPoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0;
|
||||
@@ -314,6 +334,9 @@ public class SaleService {
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a dollar discount back into the number of loyalty points it represents.
|
||||
*/
|
||||
private int toPointsUsed(BigDecimal loyaltyDiscount) {
|
||||
if (loyaltyDiscount == null || loyaltyDiscount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return 0;
|
||||
@@ -321,6 +344,10 @@ public class SaleService {
|
||||
return loyaltyDiscount.setScale(0, RoundingMode.DOWN).intValue() * businessProperties.loyaltyPointsPerDollar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Website sales need an employee on record. Picks a staff member at the
|
||||
* store, or falls back to any admin if no staff is available.
|
||||
*/
|
||||
private User resolveWebsiteSaleEmployee(Long storeId) {
|
||||
return userRepository.findFirstByPrimaryStoreStoreIdAndRoleAndActiveTrueOrderByIdAsc(storeId, User.Role.STAFF)
|
||||
.or(() -> userRepository.findFirstByRoleAndActiveTrueOrderByIdAsc(User.Role.ADMIN))
|
||||
|
||||
Reference in New Issue
Block a user