diff --git a/RestroHub/build.gradle b/RestroHub/build.gradle index 49e56a0..c70225d 100644 --- a/RestroHub/build.gradle +++ b/RestroHub/build.gradle @@ -66,7 +66,7 @@ dependencies { // Testing testCompileOnly "org.projectlombok:lombok:${lombokVersion}" testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" - // testImplementation 'org.springframework.boot:spring-boot-starter-test'; + testImplementation 'org.springframework.boot:spring-boot-starter-test'; testImplementation 'org.springframework.security:spring-security-test'; //OpenAPI Documentation @@ -83,11 +83,13 @@ dependencies { implementation 'ch.qos.logback.contrib:logback-jackson:0.1.5' } +// tasks.named('test') { +// enabled = false +// } tasks.named('test') { - enabled = false + useJUnitPlatform() } - //tasks.withType { // useJUnitPlatform() //} \ No newline at end of file diff --git a/RestroHub/gradle.properties b/RestroHub/gradle.properties new file mode 100644 index 0000000..b93d106 --- /dev/null +++ b/RestroHub/gradle.properties @@ -0,0 +1 @@ +org.gradle.java.installations.auto-download=true diff --git a/RestroHub/settings.gradle b/RestroHub/settings.gradle index d86e7bb..117c121 100644 --- a/RestroHub/settings.gradle +++ b/RestroHub/settings.gradle @@ -1 +1,4 @@ rootProject.name = 'restroly' +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/branch/entity/Branch.java b/RestroHub/src/main/java/com/restroly/qrmenu/branch/entity/Branch.java index eeee800..b5016af 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/branch/entity/Branch.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/branch/entity/Branch.java @@ -75,6 +75,9 @@ public class Branch { @Builder.Default private List tables = new ArrayList<>(); + @Column(name = "branch_upi_id") + private String branchUpiId; + @PrePersist protected void onCreate() { createdDate = LocalDateTime.now(); diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/order/controller/OrderController.java b/RestroHub/src/main/java/com/restroly/qrmenu/order/controller/OrderController.java index 5792a30..0db95a2 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/order/controller/OrderController.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/order/controller/OrderController.java @@ -19,6 +19,7 @@ import com.restroly.qrmenu.order.dto.OrderResponse; import com.restroly.qrmenu.order.dto.UpdateOrderStatusRequest; import com.restroly.qrmenu.order.service.OrderService; +import com.restroly.qrmenu.payment.service.PaymentService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -29,14 +30,22 @@ @RequestMapping(SECURE_API_VERSION+"/orders") @CrossOrigin(origins = "*") public class OrderController { - + + @Autowired + private final PaymentService paymentService = null; @Autowired private final OrderService orderService = null; + //private final OrderService orderService; @PostMapping public ResponseEntity createOrder(@Valid @RequestBody CreateOrderRequest request) { OrderResponse response = orderService.createOrder(request); + //Order tacking improvement needed + //Response is used to carry the UPI Id out of instead of calling it from database again hence reducing the number of calls to database and improving the performance of the application + String paymentUrl = paymentService.generatePaymentLink(response.getTotalAmount(), response.getOrderId(), response.getPaymentLink()); + //Genarated payment link is stored in response object to send it to client and use it for payment + response.setPaymentLink(paymentUrl); return ResponseEntity.status(HttpStatus.CREATED).body(response); } diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/order/dto/OrderResponse.java b/RestroHub/src/main/java/com/restroly/qrmenu/order/dto/OrderResponse.java index de82718..2719a33 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/order/dto/OrderResponse.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/order/dto/OrderResponse.java @@ -26,6 +26,7 @@ public class OrderResponse { private String customerPhone; private String specialInstructions; private BigDecimal totalAmount; + private String paymentLink; private OrderStatus status; private LocalDateTime createdAt; private List items; @@ -101,5 +102,11 @@ public List getItems() { public void setItems(List items) { this.items = items; } + public String getPaymentLink() { + return paymentLink; + } + public void setPaymentLink(String paymentLink) { + this.paymentLink = paymentLink; + } } \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/order/entity/Order.java b/RestroHub/src/main/java/com/restroly/qrmenu/order/entity/Order.java index 607b75e..d3b13e9 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/order/entity/Order.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/order/entity/Order.java @@ -57,6 +57,10 @@ public class Order { @Builder.Default private List orderItems = new ArrayList<>(); + //Not storing in database only for better payment utility + @Transient + private String paymentId; + @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/order/mapper/OrderMapper.java b/RestroHub/src/main/java/com/restroly/qrmenu/order/mapper/OrderMapper.java index d1c3b4e..cd7ca4b 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/order/mapper/OrderMapper.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/order/mapper/OrderMapper.java @@ -30,6 +30,7 @@ public OrderResponse toResponse(Order order) { .status(order.getStatus()) .createdAt(order.getCreatedAt()) .items(toItemResponseList(order.getOrderItems())) + .paymentLink(order.getPaymentId()) .build(); } diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/order/service/impl/OrderServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/order/service/impl/OrderServiceImpl.java index a8560cb..ea5f943 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/order/service/impl/OrderServiceImpl.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/order/service/impl/OrderServiceImpl.java @@ -78,6 +78,8 @@ public OrderResponse createOrder(CreateOrderRequest request) { // Send notification to admin notificationService.notifyNewOrder(savedOrder); + //Storing paymentId in order for better utility + savedOrder.setPaymentId(branch.getBranchUpiId()); return orderMapper.toResponse(savedOrder); } diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/payment/entity/PaymentStatus.java b/RestroHub/src/main/java/com/restroly/qrmenu/payment/entity/PaymentStatus.java new file mode 100644 index 0000000..1c7c923 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/payment/entity/PaymentStatus.java @@ -0,0 +1,7 @@ +package com.restroly.qrmenu.payment.entity; + +public enum PaymentStatus { + PENDING, + SUCCESS, + CANCELLED +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/payment/entity/PaymentVerification.java b/RestroHub/src/main/java/com/restroly/qrmenu/payment/entity/PaymentVerification.java new file mode 100644 index 0000000..e37a9f8 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/payment/entity/PaymentVerification.java @@ -0,0 +1,41 @@ +package com.restroly.qrmenu.payment.entity; + +import java.math.BigDecimal; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "payment_verification") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PaymentVerification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "payment_id", nullable = false, unique = true) + private String paymentId; + + @Column(name = "order_id",unique = true) + private Long orderId; + + @Column(name = "amount") + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(name = "verified", nullable = false) + private PaymentStatus status; + + @Column(name = "transaction_id", unique = true) + private String transactionId; + +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/payment/repository/PaymentVerificationRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/payment/repository/PaymentVerificationRepository.java new file mode 100644 index 0000000..88f00e0 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/payment/repository/PaymentVerificationRepository.java @@ -0,0 +1,12 @@ +package com.restroly.qrmenu.payment.repository; + +import com.restroly.qrmenu.payment.entity.PaymentVerification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PaymentVerificationRepository extends JpaRepository { + Optional findByPaymentId(String paymentId); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/payment/service/PaymentService.java b/RestroHub/src/main/java/com/restroly/qrmenu/payment/service/PaymentService.java new file mode 100644 index 0000000..c7a1754 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/payment/service/PaymentService.java @@ -0,0 +1,19 @@ +package com.restroly.qrmenu.payment.service; + +import java.math.BigDecimal; + +import org.springframework.core.io.Resource; + +import jakarta.transaction.Transactional; + +public interface PaymentService { + @Transactional + String newPayment(Long orderId, BigDecimal amount); + String generatePaymentLink(BigDecimal amount, Long orderId, String upiId); + Resource generateUPIQR(BigDecimal amount, String upiId, String description); + @Transactional + void markPaymentAsVerified(String paymentId, String transactionId); + @Transactional + void markPaymentAsCancelled(String paymentId); + boolean verifyPayment(String paymentId); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/payment/service/PaymentServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/payment/service/PaymentServiceImpl.java new file mode 100644 index 0000000..ff74797 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/payment/service/PaymentServiceImpl.java @@ -0,0 +1,131 @@ +package com.restroly.qrmenu.payment.service; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.restroly.qrmenu.payment.entity.PaymentStatus; +import com.restroly.qrmenu.payment.entity.PaymentVerification; +import com.restroly.qrmenu.payment.repository.PaymentVerificationRepository; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PaymentServiceImpl implements PaymentService { + + private static final int QR_CODE_SIZE = 250; + + @Value("${payment.payee.name:RestroHub}") + private String upiPayerName; + + private final PaymentVerificationRepository verificationRepository; + + public String newPayment(Long orderId, BigDecimal amount) { + log.info("Creating new payment record with manual verification flag set to false"); + PaymentVerification entity = PaymentVerification.builder() + .paymentId("PAY" + orderId) + .orderId(orderId) + .amount(amount) + .status(PaymentStatus.PENDING) + .build(); + verificationRepository.save(entity); + return entity.getPaymentId(); + } + + @Override + public String generatePaymentLink(BigDecimal amount, Long orderId, String upiId) { + log.info("Generating raw UPI payment link for orderId: {}, amount: {}, upiId: {}", orderId, amount, upiId); + + String description = (orderId != null && orderId > 0) + ? "Payment for Order " + orderId + : "RestroHub payment"; + + // MUST use raw upi://pay to comply with library specs and avoid 404s + return buildUri("upi://pay", amount, orderId, upiId, description); + } + + @Override + public Resource generateUPIQR(BigDecimal amount, String upiId, String description) { + log.info("Generating raw UPI QR for amount: {}, upiId: {}, description: {}", amount, upiId, description); + + String desc = (description != null && !description.isBlank()) ? description : "RestroHub payment"; + + // MUST use raw upi:// for QR codes so mobile scanners open payment apps + // directly + String rawUpiLink = buildUri("upi://pay", amount, null, upiId, desc); + + try { + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + BitMatrix bitMatrix = qrCodeWriter.encode(rawUpiLink, BarcodeFormat.QR_CODE, QR_CODE_SIZE, QR_CODE_SIZE); + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream); + return new ByteArrayResource(outputStream.toByteArray()); + } + } catch (WriterException | IOException ex) { + log.error("Failed to generate UPI QR code", ex); + throw new IllegalStateException("Unable to generate UPI QR code", ex); + } + } + + private String buildUri( + String baseUrl, + BigDecimal amount, + Long orderId, + String upiId, + String description) { + + String formattedAmount = String.format(Locale.US, "%.2f", amount); + + String transactionRef = (orderId != null && orderId > 0) + ? ("ORD" + orderId) + : ("PAY" + System.currentTimeMillis()); + + return baseUrl + + "?pa=" + URLEncoder.encode(upiId.trim(), StandardCharsets.UTF_8) + + "&pn=" + URLEncoder.encode(upiPayerName.trim(), StandardCharsets.UTF_8) + + "&am=" + formattedAmount.trim() + + "&cu=INR" + + "&tr=" + URLEncoder.encode(transactionRef.trim(), StandardCharsets.UTF_8) + + "&tn=" + URLEncoder.encode(description.trim(), StandardCharsets.UTF_8); + } + + public void markPaymentAsVerified(String paymentId, String transactionId) { + log.info("Marking paymentId: {} as verified with transactionId: {}", paymentId, transactionId); + verificationRepository.findByPaymentId(paymentId).ifPresent(entity -> { + entity.setStatus(PaymentStatus.SUCCESS); + entity.setTransactionId(transactionId); + verificationRepository.save(entity); + }); + } + + public void markPaymentAsCancelled(String paymentId) { + log.info("Marking paymentId: {} as cancelled", paymentId); + verificationRepository.findByPaymentId(paymentId).ifPresent(entity -> { + entity.setStatus(PaymentStatus.CANCELLED); + verificationRepository.save(entity); + }); + } + + @Override + public boolean verifyPayment(String paymentId) { + log.info("Verifying payment status for paymentId: {} using manual admin verification flag", paymentId); + return verificationRepository.findByPaymentId(paymentId) + .map(entity -> entity.getStatus() == PaymentStatus.SUCCESS) + .orElse(false); + } +} \ No newline at end of file diff --git a/RestroHub/src/main/resources/application.properties b/RestroHub/src/main/resources/application.properties index 15e96ff..9695f8a 100644 --- a/RestroHub/src/main/resources/application.properties +++ b/RestroHub/src/main/resources/application.properties @@ -89,6 +89,14 @@ security.jwt.secret=${JWT_SECRET:your-256-bit-secret-key-here-change-in-producti security.jwt.expiration=${JWT_EXPIRATION:86400000} security.jwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000} +# =============================== +# Payment Gateway +# =============================== +# razorpay.key-id= +# razorpay.key-secret= +#--- UPI Link generation used as instructed --- +payment.payee.name=Restroly + # =============================== # Security / CORS # =============================== diff --git a/RestroHub/src/test/java/com/restroly/qrmenu/payment/service/PaymentServiceImplTest.java b/RestroHub/src/test/java/com/restroly/qrmenu/payment/service/PaymentServiceImplTest.java new file mode 100644 index 0000000..2067590 --- /dev/null +++ b/RestroHub/src/test/java/com/restroly/qrmenu/payment/service/PaymentServiceImplTest.java @@ -0,0 +1,163 @@ +package com.restroly.qrmenu.payment.service; + +import com.restroly.qrmenu.payment.entity.PaymentStatus; +import com.restroly.qrmenu.payment.entity.PaymentVerification; +import com.restroly.qrmenu.payment.repository.PaymentVerificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PaymentServiceImplTest { + + @Mock + private PaymentVerificationRepository verificationRepository; + + @InjectMocks + private PaymentServiceImpl paymentService; + + @BeforeEach + void setUp() { + // Because @Value fields aren't injected in pure unit tests, we set it manually + ReflectionTestUtils.setField(paymentService, "upiPayerName", "RestroHub"); + } + + @Test + void newPayment_ShouldSaveAndReturnPaymentId() { + // Given + Long orderId = 123L; + BigDecimal amount = new BigDecimal("500.00"); + + // When + String resultId = paymentService.newPayment(orderId, amount); + + // Then + assertEquals("PAY123", resultId); + // Verify that the repository's save method was called exactly once + verify(verificationRepository, times(1)).save(any(PaymentVerification.class)); + } + + @Test + void generatePaymentLink_ShouldReturnCorrectlyFormattedUpiString() { + // Given + BigDecimal amount = new BigDecimal("150.50"); + Long orderId = 456L; + String upiId = "admin@bank"; + + // When + String link = paymentService.generatePaymentLink(amount, orderId, upiId); + + // Then + assertTrue(link.startsWith("upi://pay")); + assertTrue(link.contains("pa=admin%40bank")); // Checks URL encoding + assertTrue(link.contains("pn=RestroHub")); + assertTrue(link.contains("am=150.50")); + assertTrue(link.contains("tr=ORD456")); + } + + @Test + void verifyPayment_WhenStatusIsSuccess_ShouldReturnTrue() { + // Given + String paymentId = "PAY789"; + PaymentVerification mockPayment = PaymentVerification.builder() + .paymentId(paymentId) + .status(PaymentStatus.SUCCESS) + .build(); + + // Tell Mockito: "When the repository is asked for this ID, return our mock payment" + when(verificationRepository.findByPaymentId(paymentId)) + .thenReturn(Optional.of(mockPayment)); + + // When + boolean isVerified = paymentService.verifyPayment(paymentId); + + // Then + assertTrue(isVerified); + } + + @Test + void verifyPayment_WhenStatusIsPending_ShouldReturnFalse() { + // Given + String paymentId = "PAY999"; + PaymentVerification mockPayment = PaymentVerification.builder() + .paymentId(paymentId) + .status(PaymentStatus.PENDING) // Not successful yet! + .build(); + + when(verificationRepository.findByPaymentId(paymentId)) + .thenReturn(Optional.of(mockPayment)); + + // When + boolean isVerified = paymentService.verifyPayment(paymentId); + + // Then + assertFalse(isVerified); + } + @Test + void markPaymentAsVerified_ShouldUpdateStatusAndTransactionId() { + // Given + String paymentId = "PAY123"; + String transactionId = "UTR987654321"; + PaymentVerification mockPayment = PaymentVerification.builder() + .paymentId(paymentId) + .status(PaymentStatus.PENDING) + .build(); + + when(verificationRepository.findByPaymentId(paymentId)).thenReturn(Optional.of(mockPayment)); + + // When + paymentService.markPaymentAsVerified(paymentId, transactionId); + + // Then + assertEquals(PaymentStatus.SUCCESS, mockPayment.getStatus()); + assertEquals(transactionId, mockPayment.getTransactionId()); + verify(verificationRepository, times(1)).save(mockPayment); + } + + @Test + void markPaymentAsCancelled_ShouldUpdateStatusToCancelled() { + // Given + String paymentId = "PAY456"; + PaymentVerification mockPayment = PaymentVerification.builder() + .paymentId(paymentId) + .status(PaymentStatus.PENDING) + .build(); + + when(verificationRepository.findByPaymentId(paymentId)).thenReturn(Optional.of(mockPayment)); + + // When + paymentService.markPaymentAsCancelled(paymentId); + + // Then + assertEquals(PaymentStatus.CANCELLED, mockPayment.getStatus()); + verify(verificationRepository, times(1)).save(mockPayment); + } + + @Test + void generateUPIQR_ShouldReturnResource() throws IOException { + // Given + BigDecimal amount = new BigDecimal("100.00"); + String upiId = "test@bank"; + String description = "Test QR"; + + // When + Resource qrResource = paymentService.generateUPIQR(amount, upiId, description); + + // Then + assertNotNull(qrResource); + assertTrue(qrResource.contentLength() > 0); + } +}