diff --git a/RestroHub/build.gradle b/RestroHub/build.gradle index 49e56a0f..8833a6fd 100644 --- a/RestroHub/build.gradle +++ b/RestroHub/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-tomcat' developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-aop' // Database runtimeOnly 'org.postgresql:postgresql:42.7.2' diff --git a/RestroHub/logs/restroly-qrmenu-2026-05-17.0.log.gz b/RestroHub/logs/restroly-qrmenu-2026-05-17.0.log.gz new file mode 100644 index 00000000..ddf3caf6 Binary files /dev/null and b/RestroHub/logs/restroly-qrmenu-2026-05-17.0.log.gz differ diff --git a/RestroHub/logs/restroly-qrmenu-2026-05-18.0.log.gz b/RestroHub/logs/restroly-qrmenu-2026-05-18.0.log.gz new file mode 100644 index 00000000..7cedb950 Binary files /dev/null and b/RestroHub/logs/restroly-qrmenu-2026-05-18.0.log.gz differ diff --git a/RestroHub/logs/restroly-qrmenu-2026-05-23.0.log.gz b/RestroHub/logs/restroly-qrmenu-2026-05-23.0.log.gz new file mode 100644 index 00000000..78ff5c49 Binary files /dev/null and b/RestroHub/logs/restroly-qrmenu-2026-05-23.0.log.gz differ diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/branch/controller/BranchController.java b/RestroHub/src/main/java/com/restroly/qrmenu/branch/controller/BranchController.java index 6bfe74e3..83c7746a 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/branch/controller/BranchController.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/branch/controller/BranchController.java @@ -7,6 +7,8 @@ import com.restroly.qrmenu.common.generic.PageResponseDTO; import com.restroly.qrmenu.common.util.ApiConstants; +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.service.SubscriptionValidationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -45,6 +47,8 @@ public class BranchController { private final BranchService branchService; + private final SubscriptionValidationService subscriptionValidationService; + // ========== CREATE ========== @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) @@ -68,6 +72,16 @@ public ResponseEntity createBranch( @Valid @RequestBody BranchRequestDTO requestDTO) { log.info("REST request to create branch: {}", requestDTO.getName()); + Long restaurantId = requestDTO.getRestaurantId(); + + // 1. Check if their subscription plan allows the ADD_BRANCH feature at all + subscriptionValidationService.validateFeatureAccess(restaurantId, FeatureType.ADD_BRANCH); + + // 2. Count how many branches they currently have + long currentBranchCount = branchService.countBranchesByRestaurant(restaurantId); + + // 3. Check if adding one more branch exceeds their plan's maximum branch limit + subscriptionValidationService.validateBranchLimit(restaurantId, (int) currentBranchCount); BranchResponseDTO response = branchService.createBranch(requestDTO); URI location = URI.create("/" + ApiConstants.APP_NAME + SECURE_API_VERSION + diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/branch/service/BranchServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/branch/service/BranchServiceImpl.java index 062596fc..69361029 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/branch/service/BranchServiceImpl.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/branch/service/BranchServiceImpl.java @@ -13,6 +13,8 @@ import com.restroly.qrmenu.restaurant.entity.Restaurant; import com.restroly.qrmenu.restaurant.repository.RestaurantRepository; +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.service.FeatureAccessService; import com.restroly.qrmenu.user.exception.DuplicateResourceException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,6 +36,7 @@ public class BranchServiceImpl implements BranchService { private final RestaurantRepository restaurantRepository; private final MenuRepository menuRepository; private final BranchMapper branchMapper; + private final FeatureAccessService featureAccessService; // ========== CREATE ========== @Override @@ -45,6 +48,10 @@ public BranchResponseDTO createBranch(BranchRequestDTO requestDTO) { .orElseThrow(() -> new ResourceNotFoundException( "Restaurant not found with ID: " + requestDTO.getRestaurantId())); + featureAccessService.assertFeatureAccess(requestDTO.getRestaurantId(), FeatureType.ADD_BRANCH); + long currentCount = countBranchesByRestaurant(requestDTO.getRestaurantId()); + featureAccessService.validateBranchLimit(requestDTO.getRestaurantId(), (int) currentCount); + // Check for duplicate branch name if (branchRepository.existsByNameAndRestaurant_RestId( requestDTO.getName(), requestDTO.getRestaurantId())) { diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/common/exception/GlobalExceptionHandler.java b/RestroHub/src/main/java/com/restroly/qrmenu/common/exception/GlobalExceptionHandler.java index 683ea172..3efdb04b 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/common/exception/GlobalExceptionHandler.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ // src/main/java/com/Restroly/qrmenu/common/exception/GlobalExceptionHandler.java package com.restroly.qrmenu.common.exception; +import com.restroly.qrmenu.subscription.exception.FeatureAccessException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -22,13 +23,22 @@ import org.springframework.web.servlet.NoHandlerFoundException; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { + @ExceptionHandler(FeatureAccessException.class) + public ResponseEntity> handleFeatureAccess(FeatureAccessException ex) { + Map response = new HashMap<>(); + response.put("message", "Your current subscription does not support this feature"); + response.put("feature", ex.getFeature().name()); + return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + } @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleResourceNotFoundException( @@ -320,4 +330,9 @@ private ErrorResponse.ValidationError mapConstraintViolation(ConstraintViolation private String generateTraceId() { return UUID.randomUUID().toString().substring(0, 8); } + + + + + } \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/controller/RestaurantController.java b/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/controller/RestaurantController.java index 60255420..e5f13315 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/controller/RestaurantController.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/controller/RestaurantController.java @@ -76,6 +76,8 @@ public ResponseEntity createRestaurant( @ApiResponse(responseCode = "404", description = "Restaurant not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) + @GetMapping(value = "/{restaurantId}") + @Operation(summary = "Get restaurant by ID", description = "Retrieves a restaurant by ID") public ResponseEntity getRestaurantById( @Parameter(description = "Long Id of the restaurant", required = true) @PathVariable Long restaurantId) { diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/service/RestaurantServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/service/RestaurantServiceImpl.java index a89b0811..d1c2e67f 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/service/RestaurantServiceImpl.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/restaurant/service/RestaurantServiceImpl.java @@ -1,5 +1,6 @@ package com.restroly.qrmenu.restaurant.service; +import com.restroly.qrmenu.common.exception.BusinessException; import com.restroly.qrmenu.common.exception.ResourceAlreadyExistsException; import com.restroly.qrmenu.common.exception.ResourceNotFoundException; import com.restroly.qrmenu.restaurant.dto.RestaurantRequestDTO; @@ -10,10 +11,19 @@ import com.restroly.qrmenu.restaurant.repository.RestaurantRepository; +import com.restroly.qrmenu.subscription.entity.RestaurantSubscription; +import com.restroly.qrmenu.subscription.entity.SubscriptionPlan; +import com.restroly.qrmenu.subscription.enums.SubscriptionType; +import com.restroly.qrmenu.subscription.repository.RestaurantSubscriptionRepository; +import com.restroly.qrmenu.subscription.repository.SubscriptionPlanRepository; import lombok.*; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; @Service @@ -23,8 +33,8 @@ public class RestaurantServiceImpl implements RestaurantService{ private final RestaurantRepository restaurantRepository; private final RestaurantMapper restaurantMapper; - - private static final String RESTAURANT_NOT_FOUND_MSG = "RESTAURANT not found with id: %s"; + private final SubscriptionPlanRepository planRepository; + private final RestaurantSubscriptionRepository restaurantSubscriptionRepository; private static final String RESTAURANT_NOT_FOUND_MSG = "RESTAURANT not found with id: %s"; private static final String RESTAURANT_EXISTS_MSG = "RESTAURANT already exists with name: %s"; /** @@ -32,6 +42,7 @@ public class RestaurantServiceImpl implements RestaurantService{ * @return */ @Override + @Transactional public RestaurantResponseDTO createRestaurant(RestaurantRequestDTO requestDTO) { log.info("Creating new restaurant: {}", requestDTO.getName()); if (restaurantRepository.existsByNameIgnoreCase(requestDTO.getName())) { @@ -42,6 +53,18 @@ public RestaurantResponseDTO createRestaurant(RestaurantRequestDTO requestDTO) { Restaurant restaurant = restaurantMapper.toEntity(requestDTO); Restaurant savedRestaurant = restaurantRepository.save(restaurant); + SubscriptionPlan freePlan = planRepository + .findByTypeAndActiveTrue(SubscriptionType.FREE) + .orElseThrow(() -> new BusinessException("FREE plan not found in database.", HttpStatus.INTERNAL_SERVER_ERROR)); + RestaurantSubscription newSubscription = RestaurantSubscription.builder() + .restId(savedRestaurant.getRestId()) + .plan(freePlan) + .startDate(LocalDateTime.now()) + .endDate(LocalDateTime.now().plusYears(10)) // Free plan lasts 10 years + .active(true) + .build(); + + restaurantSubscriptionRepository.save(newSubscription); log.info("Successfully created food item with id: {}", savedRestaurant.getRestId()); return restaurantMapper.toResponseDTO(savedRestaurant); diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/annotation/RequiresFeature.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/annotation/RequiresFeature.java new file mode 100644 index 00000000..ad796730 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/annotation/RequiresFeature.java @@ -0,0 +1,13 @@ +package com.restroly.qrmenu.subscription.annotation; + +import com.restroly.qrmenu.subscription.enums.FeatureType; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequiresFeature { + FeatureType value(); + String restaurantIdParam() default "restId"; +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/aspect/SubscriptionFeatureAspect.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/aspect/SubscriptionFeatureAspect.java new file mode 100644 index 00000000..b485b57e --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/aspect/SubscriptionFeatureAspect.java @@ -0,0 +1,79 @@ +package com.restroly.qrmenu.subscription.aspect; + +import com.restroly.qrmenu.subscription.annotation.RequiresFeature; +import com.restroly.qrmenu.subscription.service.SubscriptionValidationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class SubscriptionFeatureAspect { + + private final SubscriptionValidationService validationService; + + @Before("@annotation(requiresFeature)") + public void checkFeatureAccess(JoinPoint joinPoint, RequiresFeature requiresFeature) { + Long restaurantId = resolveRestaurantId(joinPoint, requiresFeature.restaurantIdParam()); + + if (restaurantId == null) { + log.warn("Could not resolve restaurantId from param '{}', skipping feature check.", + requiresFeature.restaurantIdParam()); + return; + } + + log.debug("Checking feature {} for restaurantId={}", requiresFeature.value(), restaurantId); + validationService.validateFeatureAccess(restaurantId, requiresFeature.value()); + } + + private Long resolveRestaurantId(JoinPoint joinPoint, String paramName) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Parameter[] parameters = method.getParameters(); + Object[] args = joinPoint.getArgs(); + + for (int i = 0; i < parameters.length; i++) { + Parameter param = parameters[i]; + + // Match by Java parameter name + if (param.getName().equals(paramName)) { + return toLong(args[i]); + } + + // Match by @PathVariable name + PathVariable pv = param.getAnnotation(PathVariable.class); + if (pv != null && (pv.value().equals(paramName) || pv.name().equals(paramName))) { + return toLong(args[i]); + } + + // Match by @RequestParam name + RequestParam rp = param.getAnnotation(RequestParam.class); + if (rp != null && (rp.value().equals(paramName) || rp.name().equals(paramName))) { + return toLong(args[i]); + } + } + + return null; + } + + private Long toLong(Object value) { + if (value instanceof Long l) return l; + if (value instanceof Integer i) return i.longValue(); + if (value instanceof String s) { + try { return Long.parseLong(s); } catch (NumberFormatException ignored) {} + } + return null; + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/config/SubscriptionConfig.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/config/SubscriptionConfig.java new file mode 100644 index 00000000..68bdbd7e --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/config/SubscriptionConfig.java @@ -0,0 +1,89 @@ +package com.restroly.qrmenu.subscription.config; + +import com.restroly.qrmenu.subscription.entity.SubscriptionPlan; +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.enums.SubscriptionType; +import com.restroly.qrmenu.subscription.repository.SubscriptionPlanRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import java.math.BigDecimal; +import java.util.EnumSet; +import java.util.List; + +@Slf4j +@Configuration +@EnableAspectJAutoProxy // activates AOP proxy so @RequiresFeature aspect fires +@RequiredArgsConstructor +public class SubscriptionConfig { + + private final SubscriptionPlanRepository planRepository; + + /** + * Seeds default subscription plans on first startup if they don't exist yet. + * Each plan builds on the previous tier's features — FREE has none, + * BASIC adds WhatsApp, PRO adds AI + domains, ENTERPRISE unlocks everything. + */ + @Bean + public ApplicationRunner seedSubscriptionPlans() { + return args -> { + if (planRepository.count() > 0) { + log.info("Subscription plans already seeded, skipping."); + return; + } + + List plans = List.of( + SubscriptionPlan.builder() + .type(SubscriptionType.FREE) + .name("Free Plan") + .monthlyPrice(BigDecimal.ZERO) + .allowedFeatures(EnumSet.noneOf(FeatureType.class)) + .maxBranches(1) + .active(true) + .build(), + + SubscriptionPlan.builder() + .type(SubscriptionType.BASIC) + .name("Basic Plan") + .monthlyPrice(new BigDecimal("499.00")) + .allowedFeatures(EnumSet.of( + FeatureType.WHATSAPP_NOTIFICATION, + FeatureType.WEBSITE_TEMPLATE + )) + .maxBranches(2) + .active(true) + .build(), + + SubscriptionPlan.builder() + .type(SubscriptionType.PRO) + .name("Pro Plan") + .monthlyPrice(new BigDecimal("1499.00")) + .allowedFeatures(EnumSet.of( + FeatureType.WHATSAPP_NOTIFICATION, + FeatureType.AI_TRANSLATION, + FeatureType.WEBSITE_TEMPLATE, + FeatureType.ADD_BRANCH, + FeatureType.CUSTOM_DOMAIN + )) + .maxBranches(5) + .active(true) + .build(), + + SubscriptionPlan.builder() + .type(SubscriptionType.ENTERPRISE) + .name("Enterprise Plan") + .monthlyPrice(new BigDecimal("4999.00")) + .allowedFeatures(EnumSet.allOf(FeatureType.class)) + .maxBranches(Integer.MAX_VALUE) + .active(true) + .build() + ); + + planRepository.saveAll(plans); + log.info("Seeded {} subscription plans.", plans.size()); + }; + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/controller/SubscriptionManagementController.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/controller/SubscriptionManagementController.java new file mode 100644 index 00000000..ab0706c5 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/controller/SubscriptionManagementController.java @@ -0,0 +1,102 @@ +package com.restroly.qrmenu.subscription.controller; + +import com.restroly.qrmenu.subscription.dto.SubscriptionStatusResponse; +import com.restroly.qrmenu.subscription.entity.RestaurantSubscription; +import com.restroly.qrmenu.subscription.entity.SubscriptionPlan; +import com.restroly.qrmenu.subscription.enums.SubscriptionType; +import com.restroly.qrmenu.subscription.repository.RestaurantSubscriptionRepository; +import com.restroly.qrmenu.subscription.repository.SubscriptionPlanRepository; +import com.restroly.qrmenu.common.exception.ResourceNotFoundException; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static com.restroly.qrmenu.common.util.ApiConstants.SECURE_API_VERSION; + +@RestController +@RequestMapping(SECURE_API_VERSION + "/restaurants/{restId}/subscription") +@RequiredArgsConstructor +public class SubscriptionManagementController { + + private final RestaurantSubscriptionRepository subscriptionRepository; + private final SubscriptionPlanRepository planRepository; + + // --- GET CURRENT PLAN --- + @GetMapping + public ResponseEntity getActiveSubscriptionState(@PathVariable Long restId) { + return subscriptionRepository.findByRestIdAndActiveTrueAndEndDateAfter(restId, LocalDateTime.now()) + .map(sub -> SubscriptionStatusResponse.builder() + .restId(restId) + .planType(sub.getPlan().getType()) + .planName(sub.getPlan().getName()) + .expiresAt(sub.getEndDate()) + .active(true) + .maxBranches(sub.getPlan().getMaxBranches()) + .allowedFeatures(sub.getPlan().getAllowedFeatures()) + .build()) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + // --- CHANGE PLAN (UPGRADE/DOWNGRADE) --- + @PutMapping("/change") + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public ResponseEntity transitionAccountTier( + @PathVariable Long restId, + @RequestParam SubscriptionType targetTier) { + + // 1. Find the target plan + SubscriptionPlan targetPlan = planRepository.findByTypeAndActiveTrue(targetTier) + .orElseThrow(() -> new ResourceNotFoundException("Target plan not found: " + targetTier)); + + // Variable to hold our dynamic success message + String actionMessage = "subscribed"; + + // 2. Find Current Plan & Check for Upgrade vs Downgrade + Optional currentSubOpt = subscriptionRepository + .findByRestIdAndActiveTrueAndEndDateAfter(restId, LocalDateTime.now()); + + if (currentSubOpt.isPresent()) { + RestaurantSubscription currentLease = currentSubOpt.get(); + SubscriptionType currentTier = currentLease.getPlan().getType(); + + // Duplicate Check + if (currentTier == targetTier) { + return ResponseEntity.badRequest().body(Map.of("message", "Already on " + targetTier.name() + " plan!")); + } + + // Determine Upgrade or Downgrade based on Enum position + if (targetTier.ordinal() > currentTier.ordinal()) { + actionMessage = "upgraded"; + } else { + actionMessage = "downgraded"; + } + + // Deactivate old plan + currentLease.setActive(false); + subscriptionRepository.save(currentLease); + } + + // 3. Save new plan + RestaurantSubscription newSubscription = RestaurantSubscription.builder() + .restId(restId) + .plan(targetPlan) + .startDate(LocalDateTime.now()) + .endDate(LocalDateTime.now().plusYears(1)) + .active(true) + .build(); + + subscriptionRepository.save(newSubscription); + + // 4. Return dynamic message + return ResponseEntity.ok(Map.of("message", "Successfully " + actionMessage + " to " + targetTier.name())); + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/dto/FeatureCheckResponse.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/dto/FeatureCheckResponse.java new file mode 100644 index 00000000..64342ef8 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/dto/FeatureCheckResponse.java @@ -0,0 +1,13 @@ +package com.restroly.qrmenu.subscription.dto; + +import com.restroly.qrmenu.subscription.enums.FeatureType; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class FeatureCheckResponse { + private Long restId; + private FeatureType feature; + private boolean allowed; +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/dto/SubscriptionStatusResponse.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/dto/SubscriptionStatusResponse.java new file mode 100644 index 00000000..24feaece --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/dto/SubscriptionStatusResponse.java @@ -0,0 +1,21 @@ +package com.restroly.qrmenu.subscription.dto; + +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.enums.SubscriptionType; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Set; + +@Data +@Builder +public class SubscriptionStatusResponse { + private Long restId; + private SubscriptionType planType; + private String planName; + private LocalDateTime expiresAt; + private boolean active; + private int maxBranches; + private Set allowedFeatures; +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/entity/RestaurantSubscription.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/entity/RestaurantSubscription.java new file mode 100644 index 00000000..86fe38cb --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/entity/RestaurantSubscription.java @@ -0,0 +1,40 @@ +package com.restroly.qrmenu.subscription.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "restaurant_subscriptions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RestaurantSubscription { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rest_id", nullable = false) + private Long restId; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "plan_id", nullable = false) + private SubscriptionPlan plan; + + @Column(nullable = false) + private LocalDateTime startDate; + + @Column(nullable = false) + private LocalDateTime endDate; + + @Column(nullable = false) + private boolean active; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(endDate); + } +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/entity/SubscriptionPlan.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/entity/SubscriptionPlan.java new file mode 100644 index 00000000..85324f56 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/entity/SubscriptionPlan.java @@ -0,0 +1,47 @@ +package com.restroly.qrmenu.subscription.entity; +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.enums.SubscriptionType; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.util.Set; + +@Entity +@Table(name = "subscription_plans") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SubscriptionPlan { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, unique = true) + private SubscriptionType type; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private BigDecimal monthlyPrice; + + @ElementCollection(targetClass = FeatureType.class, fetch = FetchType.EAGER) + @CollectionTable( + name = "plan_features", + joinColumns = @JoinColumn(name = "plan_id") + ) + @Enumerated(EnumType.STRING) + @Column(name = "feature") + private Set allowedFeatures; + + @Column(nullable = false) + private int maxBranches; + + @Column(nullable = false) + private boolean active; +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/enums/FeatureType.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/enums/FeatureType.java new file mode 100644 index 00000000..ed99b69d --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/enums/FeatureType.java @@ -0,0 +1,10 @@ +package com.restroly.qrmenu.subscription.enums; +public enum FeatureType { + WHATSAPP_NOTIFICATION, + AI_TRANSLATION, + ADD_BRANCH, + CUSTOM_DOMAIN, + WEBSITE_TEMPLATE, + ADVANCED_ANALYTICS, + PRIORITY_SUPPORT +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/enums/SubscriptionType.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/enums/SubscriptionType.java new file mode 100644 index 00000000..5077c715 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/enums/SubscriptionType.java @@ -0,0 +1,8 @@ +package com.restroly.qrmenu.subscription.enums; + +public enum SubscriptionType { + FREE, + BASIC, + PRO, + ENTERPRISE +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/exception/FeatureAccessException.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/exception/FeatureAccessException.java new file mode 100644 index 00000000..d941ab19 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/exception/FeatureAccessException.java @@ -0,0 +1,23 @@ +package com.restroly.qrmenu.subscription.exception; + +import com.restroly.qrmenu.subscription.enums.FeatureType; +import lombok.Getter; + +@Getter +public class FeatureAccessException extends RuntimeException { + + private final FeatureType feature; + private final String reason; + + public FeatureAccessException(FeatureType feature) { + super("Your current subscription does not support this feature: " + feature.name()); + this.feature = feature; + this.reason = "Your current subscription does not support this feature."; + } + + public FeatureAccessException(FeatureType feature, String reason) { + super(reason); + this.feature = feature; + this.reason = reason; + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/repository/RestaurantSubscriptionRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/repository/RestaurantSubscriptionRepository.java new file mode 100644 index 00000000..a8d0fbe2 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/repository/RestaurantSubscriptionRepository.java @@ -0,0 +1,17 @@ +package com.restroly.qrmenu.subscription.repository; + +import com.restroly.qrmenu.subscription.entity.RestaurantSubscription; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface RestaurantSubscriptionRepository extends JpaRepository { + + Optional findByRestIdAndActiveTrueAndEndDateAfter( + Long restaurantId, + LocalDateTime now + ); +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/repository/SubscriptionPlanRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/repository/SubscriptionPlanRepository.java new file mode 100644 index 00000000..940835f2 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/repository/SubscriptionPlanRepository.java @@ -0,0 +1,14 @@ +package com.restroly.qrmenu.subscription.repository; + +import com.restroly.qrmenu.subscription.entity.SubscriptionPlan; +import com.restroly.qrmenu.subscription.enums.SubscriptionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SubscriptionPlanRepository extends JpaRepository { + + Optional findByTypeAndActiveTrue(SubscriptionType type); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/service/FeatureAccessService.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/service/FeatureAccessService.java new file mode 100644 index 00000000..0b315718 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/service/FeatureAccessService.java @@ -0,0 +1,75 @@ +package com.restroly.qrmenu.subscription.service; + +import com.restroly.qrmenu.subscription.entity.RestaurantSubscription; +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.exception.FeatureAccessException; +import com.restroly.qrmenu.subscription.repository.RestaurantSubscriptionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class FeatureAccessService { + + private final RestaurantSubscriptionRepository subscriptionRepository; + + public boolean hasFeatureAccess(Long restId, FeatureType feature) { + Optional subscription = + subscriptionRepository.findByRestIdAndActiveTrueAndEndDateAfter( + restId, LocalDateTime.now() + ); + + return subscription + .map(sub -> { + + if (feature == FeatureType.ADD_BRANCH && sub.getPlan().getMaxBranches() > 0) { + return true; + } + + + return sub.getPlan().getAllowedFeatures().contains(feature); + }) + .orElse(false); + } + + public void assertFeatureAccess(Long restId, FeatureType feature) { + if (!hasFeatureAccess(restId, feature)) { + throw new FeatureAccessException(feature); + } + } + + public boolean canUseWhatsApp(Long restId) { + return hasFeatureAccess(restId, FeatureType.WHATSAPP_NOTIFICATION); + } + + public boolean canUseAiTranslation(Long restId) { + return hasFeatureAccess(restId, FeatureType.AI_TRANSLATION); + } + + public boolean canAddBranch(Long restId) { + return hasFeatureAccess(restId, FeatureType.ADD_BRANCH); + } + + public boolean canUseCustomDomain(Long restId) { + return hasFeatureAccess(restId, FeatureType.CUSTOM_DOMAIN); + } + + public boolean canUseWebsiteTemplate(Long restId) { + return hasFeatureAccess(restId, FeatureType.WEBSITE_TEMPLATE); + } + public void validateBranchLimit(Long restId, int currentBranchCount) { + Optional sub = subscriptionRepository.findByRestIdAndActiveTrueAndEndDateAfter(restId, LocalDateTime.now()); + + if (sub.isPresent()) { + int limit = sub.get().getPlan().getMaxBranches(); + if (currentBranchCount >= limit) { + throw new FeatureAccessException(FeatureType.ADD_BRANCH); + } + } else { + throw new FeatureAccessException(FeatureType.ADD_BRANCH); + } + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/subscription/service/SubscriptionValidationService.java b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/service/SubscriptionValidationService.java new file mode 100644 index 00000000..db32e9b2 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/subscription/service/SubscriptionValidationService.java @@ -0,0 +1,65 @@ +package com.restroly.qrmenu.subscription.service; + +import com.restroly.qrmenu.subscription.entity.RestaurantSubscription; +import com.restroly.qrmenu.subscription.enums.FeatureType; +import com.restroly.qrmenu.subscription.exception.FeatureAccessException; +import com.restroly.qrmenu.subscription.repository.RestaurantSubscriptionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubscriptionValidationService { + + private final RestaurantSubscriptionRepository subscriptionRepository; + + public void validateFeatureAccess(Long restId, FeatureType feature) { + Optional subscriptionOpt = + subscriptionRepository.findByRestIdAndActiveTrueAndEndDateAfter( + restId, LocalDateTime.now() + ); + + if (subscriptionOpt.isEmpty()) { + log.warn("No active subscription found for restaurantId={}", restId); + throw new FeatureAccessException(feature, "No active subscription found."); + } + + RestaurantSubscription subscription = subscriptionOpt.get(); + + + if (feature == FeatureType.ADD_BRANCH && subscription.getPlan().getMaxBranches() > 0) { + return; + } + + + if (!subscription.getPlan().getAllowedFeatures().contains(feature)) { + log.warn("Feature {} not allowed for restaurantId={}, plan={}", + feature, restId, subscription.getPlan().getType()); + throw new FeatureAccessException(feature); + } + } + public void validateBranchLimit(Long restaurantId, int currentBranchCount) { + Optional subscriptionOpt = + subscriptionRepository.findByRestIdAndActiveTrueAndEndDateAfter( + restaurantId, LocalDateTime.now() + ); + + if (subscriptionOpt.isEmpty()) { + throw new FeatureAccessException(FeatureType.ADD_BRANCH, "No active subscription found."); + } + + int maxBranches = subscriptionOpt.get().getPlan().getMaxBranches(); + + if (currentBranchCount >= maxBranches) { + throw new FeatureAccessException( + FeatureType.ADD_BRANCH, + String.format("Branch limit of %d reached for your current plan.", maxBranches) + ); + } + } +}