diff --git a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java index b99be2e2..a5b122e8 100644 --- a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java +++ b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java @@ -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); diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index 048bdc37..5ad5d46e 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -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 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 employeeIds = assignableUsers.stream().map(User::getId).collect(Collectors.toList()); List allAppointments = appointmentRepository.findByEmployeeIdInAndAppointmentDate(employeeIds, date); @@ -245,6 +262,7 @@ public class AppointmentService { List 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 existingAppointments, com.petshop.backend.entity.Service requestedService, LocalTime requestedStart, Long appointmentIdToIgnore) { LocalTime requestedEnd = requestedStart.plusMinutes(requestedService.getServiceDuration()); for (Appointment existingAppointment : existingAppointments) { diff --git a/backend/src/main/java/com/petshop/backend/service/CartService.java b/backend/src/main/java/com/petshop/backend/service/CartService.java index 305d5ef4..77e76f1a 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -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 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; diff --git a/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java b/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java index d6b9d380..4e9f1c63 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java @@ -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; diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index bf519f23..77b866d7 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -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 getConversations(Long userId, User.Role role, boolean mine) { List 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; diff --git a/backend/src/main/java/com/petshop/backend/service/EmailService.java b/backend/src/main/java/com/petshop/backend/service/EmailService.java index 6e096285..043c3cb9 100644 --- a/backend/src/main/java/com/petshop/backend/service/EmailService.java +++ b/backend/src/main/java/com/petshop/backend/service/EmailService.java @@ -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; diff --git a/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java index d2e007dc..84f699e6 100644 --- a/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java @@ -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 latestHistory = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); if (latestHistory.isEmpty() || !triggerMessageId.equals(latestHistory.get(latestHistory.size() - 1).getId())) { return; diff --git a/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java index c3314994..60e416ac 100644 --- a/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java @@ -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; diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index 1c4cf875..552fdb40 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -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 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) { 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 d33d11b3..c21d2143 100644 --- a/backend/src/main/java/com/petshop/backend/service/SaleService.java +++ b/backend/src/main/java/com/petshop/backend/service/SaleService.java @@ -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))