add service file headers

This commit is contained in:
2026-04-20 13:03:21 -06:00
parent e26239ae85
commit 3c6382318b
10 changed files with 195 additions and 1 deletions

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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))