Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RestroHub/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Binary file not shown.
Binary file added RestroHub/logs/restroly-qrmenu-2026-05-18.0.log.gz
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -68,6 +72,16 @@ public ResponseEntity<BranchResponseDTO> 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 +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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())) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Map<String, String>> handleFeatureAccess(FeatureAccessException ex) {
Map<String, String> 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<ErrorResponse> handleResourceNotFoundException(
Expand Down Expand Up @@ -320,4 +330,9 @@ private ErrorResponse.ValidationError mapConstraintViolation(ConstraintViolation
private String generateTraceId() {
return UUID.randomUUID().toString().substring(0, 8);
}





}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ public ResponseEntity<RestaurantResponseDTO> 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<RestaurantResponseDTO> getRestaurantById(
@Parameter(description = "Long Id of the restaurant", required = true)
@PathVariable Long restaurantId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -23,15 +33,16 @@ 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";

/**
* @param requestDTO
* @return
*/
@Override
@Transactional
public RestaurantResponseDTO createRestaurant(RestaurantRequestDTO requestDTO) {
log.info("Creating new restaurant: {}", requestDTO.getName());
if (restaurantRepository.existsByNameIgnoreCase(requestDTO.getName())) {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<SubscriptionPlan> 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());
};
}
}
Loading